Go面向對象編程以及在Tendermint/Cosmos-SDK中的應用

Go面向對象編程以及在Tendermint/Cosmos-SDK中的應用

你們都知道,Go不是面向對象(Object Oriented,後面簡稱爲OO)語言。本文以Java語言爲例,介紹傳統OO編程擁有的特性,以及在Go語言中如何模擬這些特性。文中出現的示例代碼都取自Cosmos-SDK或Tendermint源代碼。如下是本文將要介紹的OO編程的主要概念:java

  • 類(Class)
    • 字段(Field)
      • 實例字段
      • 類字段
    • 方法(Method)
      • 實例方法
      • 類方法
      • 構造函數(Constructor)
    • 信息隱藏
    • 繼承
      • 利斯科夫替換原則(Liskov Substitution Principle,LSP)
      • 方法重寫(Overriding)
      • 方法重載(Overloading)
      • 多態
  • 接口(Interface)
    • 擴展
    • 實現

傳統OO語言很重要的一個概念就是,類至關於一個模版,能夠用來建立實例(或者對象)。在Java裏,使用class關鍵子來自定義一個類:node

class StdTx {
  // 字段省略
}
複製代碼

Go並非傳統意義上的OO語言,甚至根本沒有"類"的概念,因此也沒有class關鍵字,直接用struct定義結構體便可:編程

type StdTx struct {
  // 字段省略
}
複製代碼

字段

類的狀態能夠分爲兩種:每一個實例各自的狀態(簡稱實例狀態),以及類自己的狀態(簡稱類狀態)。類或實例的狀態由字段構成,實例狀態由實例字段構成,類狀態則由類字段構成。json

實例字段

在Java的類裏定義實例字段,或者在Go的結構體裏定義字段,寫法差很少,固然語法略有不一樣。仍以Cosmos-SDK提供的標準交易爲例,先給出Java的寫法:安全

class StdTx {
  Msg[]          msgs;
  StdFee         fee;
  StdSignature[] StdSignatures
  String         memo;
}
複製代碼

再給出Go的寫法:bash

type StdTx struct {
	Msgs       []sdk.Msg      `json:"msg"`
	Fee        StdFee         `json:"fee"`
	Signatures []StdSignature `json:"signatures"`
	Memo       string         `json:"memo"`
}
複製代碼

類字段

在Java裏,能夠用static關鍵字定義類字段(所以也叫作靜態字段):ide

class StdTx {
  static long maxGasWanted = (1 << 63) - 1;
  
  Msg[]          msgs;
  StdFee         fee;
  StdSignature[] StdSignatures
  String         memo;
}
複製代碼

Go語言沒有對應的概念,只能用全局變量來模擬:函數

var maxGasWanted = uint64((1 << 63) - 1)
複製代碼

方法

爲了寫出更容易維護的代碼,外界一般須要經過方法來讀寫實例或類狀態,讀寫實例狀態的方法叫作實例方法,讀寫類狀態的方法則叫作類方法。大部分OO語言還有一種特殊的方法,叫作構造函數,專門用於建立類的實例。ui

實例方法

在Java中,有明確的返回值,且沒有用static關鍵字修飾的方法便是實例方法。在實例方法中,能夠隱式或顯式(經過this關鍵字)訪問當前實例。下面以Java中最簡單的Getter/Setter方法爲例演示實例方法的定義:this

class StdTx {
  
  private String memo;
  // 其餘字段省略
  
  public voie setMemo(String memo) {this.memo = memo; } // 使用this關鍵字
  public String getMemo() { return memo; }              // 不用this關鍵字
  
}
複製代碼

實例方法固然只能在類的實例(也即對象)上調用:

StdTx stdTx = new StdTx();     // 建立類實例
stdTx.setMemo("hello");        // 調用實例方法
String memo = stdTx.getMemo(); // 調用實例方法
複製代碼

Go語言則經過顯式指定receiver來給結構體定義方法(Go只有這麼一種方法,因此也就不用區分是什麼方法了):

// 在func關鍵字後面的圓括號裏指定receiver
func (tx StdTx) GetMemo() string { return tx.Memo }
複製代碼

方法調用看起來則和Java同樣:

stdTx := StdTx{ ... }   // 建立結構體實例
memo := stdTx.GetMemo() // 調用方法
複製代碼

類方法

在Java裏,能夠用static關鍵字定義類方法(所以也叫作靜態方法):

class StdTx {
  private static long maxGasWanted = (1 << 63) - 1;
  
  public static long getMaxGasWanted() {
    return maxGasWanted;
  }
}
複製代碼

類方法直接在類上調用:StdTx.getMaxGasWanted()。Go語言沒有對應的概念,只能用普通函數(不指定receiver)來模擬(下面這個函數在Cosmos-SDK中並不存在,僅僅是爲了演示而已):

func MaxGasWanted() long {
  return maxGasWanted
}
複製代碼

構造函數

在Java裏,和類同名且不指定返回值的實例方法便是構造函數

class StdTx {
  StdTx(String memo) {
    this.memo = memo;
  }
}
複製代碼

使用關鍵字new調用構造函數就能夠建立類實例(參加前面出現的例子)。Go語言沒有提供專門的構造函數概念,可是很容易使用普通的函數來模擬:

func NewStdTx(msgs []sdk.Msg, fee StdFee, sigs []StdSignature, memo string) StdTx {
	return StdTx{
		Msgs:       msgs,
		Fee:        fee,
		Signatures: sigs,
		Memo:       memo,
	}
}
複製代碼

信息隱藏

若是不想讓代碼變得不可維護,那麼必定要把類或者實例狀態隱藏起來,沒必要要對外暴露的方法也要隱藏起來。Java語言提供了4種可見性:

Java類/字段/方法可見性 類內可見 包內可見 子類可見 徹底公開
用public關鍵字修飾
用protected關鍵字修飾
不用任何可見性修飾符修飾
用private關鍵字修飾

相比之下,Go語言只有兩種可見性:徹底公開,或者包內可見。若是全局變量、函數、方法、結構體、結構體字段等等以大寫字母開頭,則徹底公開,不然僅在同一個包內可見。

繼承

在Java裏,類經過extends關鍵字繼承其餘類。繼承其餘類的類叫作子類(Subclass),被繼承的類叫作超類(Superclass),子類會繼承超類的全部非私有字段和方法。以Cosmos-SDK提供的帳戶體系爲例:

class BaseAccount { /* 字段和方法省略 */ }
class BaseVestingAccount extends BaseAccount { /* 字段和方法省略 */ }
class ContinuousVestingAccount extends BaseVestingAccount { /* 字段和方法省略 */ }
class DelayedVestingAccount extends BaseVestingAccount { /* 字段和方法省略 */ }
複製代碼

Go沒有"繼承"這個概念,只能經過"組合"來模擬。在Go裏,若是結構體的某個字段(暫時假設這個字段也是結構體類型,而且能夠是指針類型)沒有名字,那麼外圍結構體就能夠從內嵌結構體那裏"繼承"方法。下面是Account類繼承體系在Go裏面的表現:

type BaseAccount struct { /* 字段省略 */ }

type BaseVestingAccount struct {
	*BaseAccount
	// 其餘字段省略
}

type ContinuousVestingAccount struct {
	*BaseVestingAccount
	// 其餘字段省略
}

type DelayedVestingAccount struct {
	*BaseVestingAccount
}
複製代碼

好比BaseAccount結構體定義了GetCoins()方法:

func (acc *BaseAccount) GetCoins() sdk.Coins {
	return acc.Coins
}
複製代碼

那麼BaseVestingAccountDelayedVestingAccount等結構體都"繼承"了這個方法:

dvacc := auth.DelayedVestingAccount{ ... }
coins := dvacc.GetCoins() // 調用BaseAccount#GetCoins()
複製代碼

利斯科夫替換原則

OO編程的一個重要原則是利斯科夫替換原則(Liskov Substitution Principle,後面簡稱LSP)。簡單來講,任何超類可以出現的地方(例如局部變量、方法參數等),都應該能夠替換成子類。以Java爲例:

BaseAccount bacc = new BaseAccount();
bacc = new DelayedVestingAccount(); // LSP
複製代碼

很遺憾,Go的結構體嵌套不知足LSP:

bacc := auth.BaseAccount{}
bacc = auth.DelayedVestingAccount{} // compile error: cannot use auth.DelayedVestingAccount literal (type auth.DelayedVestingAccount) as type auth.BaseAccount in assignment
複製代碼

在Go裏,只有使用接口時才知足SLP。接口在後面會介紹。

方法重寫

在Java裏,子類能夠重寫(Override)超類的方法。這個特性很是重要,由於這樣就能夠把不少通常的方法放到超類裏,子類按需重寫少許方法便可,儘量避免重複代碼。仍以帳戶體系爲例,帳戶的SpendableCoins()方法計算某一時間點帳戶的全部可花費餘額。那麼BaseAccount提供默認實現,子類重寫便可:

class BaseAccount {
  // 其餘字段和方法省略
  Coins SpendableCoins(Time time) {
    return GetCoins(); // 默認實現
  }
}

class ContinuousVestingAccount {
  // 其餘字段和方法省略
  Coins SpendableCoins(Time time) {
    // 提供本身的實現
  }
}

class DelayedVestingAccount {
  // 其餘字段和方法省略
  Coins SpendableCoins(Time time) {
    // 提供本身的實現
  }
}
複製代碼

在Go語言裏能夠經過在結構體上從新定義方法達到相似的效果:

func (acc *BaseAccount) SpendableCoins(_ time.Time) sdk.Coins {
	return acc.GetCoins()
}

func (cva ContinuousVestingAccount) SpendableCoins(blockTime time.Time) sdk.Coins {
	return cva.spendableCoins(cva.GetVestingCoins(blockTime))
}

func (dva DelayedVestingAccount) SpendableCoins(blockTime time.Time) sdk.Coins {
	return dva.spendableCoins(dva.GetVestingCoins(blockTime))
}
複製代碼

在結構體實例上直接調用重寫的方法便可:

dvacc := auth.DelayedVestingAccount{ ... }
coins := dvacc.SpendableCoins(someTime) // DelayedVestingAccount#SpendableCoins()
複製代碼

方法重載

爲了討論的完整性,這裏簡單介紹一下方法重載。在Java裏,同一個類(或者超類和子類)能夠容許有同名方法,只要這些方法的簽名(由參數個數、順序、類型共同肯定)各不相同便可。以Cosmos-SDK提供的Dec類型爲例:

public class Dec {
  // 字段省略
  public Dec mul(int i) { /* 代碼省略 */ }
  public Dec mul(long i) { /* 代碼省略 */ }
  // 其餘方法省略
}
複製代碼

不管是方法仍是普通函數,在Go語言裏都沒法進行重載(不支持),所以只能起不一樣的名字:

type Dec struct { /* 字段省略 */ }
func (d Dec) MulInt(i Int) Dec { /* 代碼省略 */ }
func (d Dec) MulInt64(i int64) Dec { /* 代碼省略 */ }
// 其餘方法省略
複製代碼

多態

方法的重寫要配合多態(具體來講,這裏只關心動態分派)才能發揮所有威力。以Tendermint提供的Service爲例,Service能夠啓動、中止、重啓等等。下面是Service接口的定義(Go語言):

type Service interface {
	Start()   error
	OnStart() error
	Stop()    error
	OnStop()  error
	Reset()   error
	OnReset() error
	// 其餘方法省略
}
複製代碼

翻譯成Java代碼是下面這樣:

interface Servive {
  void start() throws Exception;
  void onStart() throws Exception;
  void stop() throws Exception;
  void onStop() throws Exception;
  void reset() throws Exception;
  void onRest() throws Exception;
  // 其餘方法省略
}
複製代碼

不論是何種服務,啓動、中止、重啓都涉及到判斷狀態,所以Start()Stop()Reset()方法很是適合在超類裏實現。具體的啓動、中止、重啓邏輯則因服務而異,所以能夠由子類在OnStart()OnStop()OnReset()方法中提供。以Start()OnStart()方法爲例,下面先給出用Java實現的BaseService基類(只是爲了說明多態,所以忽略了線程安全、異常處理等細節):

public class BaseService implements Service {
  private boolean started;
  private boolean stopped;
  
  public void onStart() throws Exception {
    // 默認實現;若是不想提供默認實現,這個方法能夠是abstract
  }
  
  public void start() throws Exception {
    if (started) { throw new AlreadyStartedException(); }
    if (stopped) { throw new AlreadyStoppedException(); }
    onStart(); // 這裏會進行dynamic dispatch
    started = true;
  }
  
  // 其餘字段和方法省略
}
複製代碼

很遺憾,在Go語言裏,結構體嵌套+方法重寫並不支持多態。所以在Go語言裏,不得不把代碼寫的更tricky一些。下面是Tendermint裏BaseService結構體的定義:

type BaseService struct {
	Logger  log.Logger
	name    string
	started uint32 // atomic
	stopped uint32 // atomic
	quit    chan struct{}

	// The "subclass" of BaseService
	impl Service
}
複製代碼

再來看OnStart()Start()方法:

func (bs *BaseService) OnStart() error { return nil }

func (bs *BaseService) Start() error {
	if atomic.CompareAndSwapUint32(&bs.started, 0, 1) {
		if atomic.LoadUint32(&bs.stopped) == 1 {
			bs.Logger.Error(fmt.Sprintf("Not starting %v -- already stopped", bs.name), "impl", bs.impl)
			// revert flag
			atomic.StoreUint32(&bs.started, 0)
			return ErrAlreadyStopped
		}
		bs.Logger.Info(fmt.Sprintf("Starting %v", bs.name), "impl", bs.impl)
		err := bs.impl.OnStart() // 重點看這裏
		if err != nil {
			// revert flag
			atomic.StoreUint32(&bs.started, 0)
			return err
		}
		return nil
	}
	bs.Logger.Debug(fmt.Sprintf("Not starting %v -- already started", bs.name), "impl", bs.impl)
	return ErrAlreadyStarted
}
複製代碼

能夠看出,爲了模擬多態效果,BaseService結構體裏多出一個難看的impl字段,而且在Start()方法裏要經過這個字段去調用OnStart()方法。畢竟Go不是真正意義上的OO語言,這也是不得已而爲之。

例子:Node

爲了進一步加深理解,咱們來看一下Tendermint提供的Node結構體是如何繼承BaseService的。Node結構體表示Tendermint全節點,下面是它的定義:

type Node struct {
	cmn.BaseService
	// 其餘字段省略
}
複製代碼

能夠看到,Node嵌入("繼承")了BaseServiceNewNode()函數建立Node實例,函數中會初始化BaseService

func NewNode(/* 參數省略 */) (*Node, error) {
	// 省略無關代碼
	node := &Node{ ... }
	node.BaseService = *cmn.NewBaseService(logger, "Node", node)
	return node, nil
}
複製代碼

能夠看到,在調用NewBaseService()函數建立BaseService實例時,傳入了node指針,這個指針會被賦值給BaseServiceimpl字段:

func NewBaseService(logger log.Logger, name string, impl Service) *BaseService {
	return &BaseService{
		Logger: logger,
		name:   name,
		quit:   make(chan struct{}),
		impl:   impl,
	}
}
複製代碼

通過這麼一番折騰以後,Node只需重寫OnStart()方法便可,這個方法會在"繼承"下來的Start()方法中被正確調用。下面的UML"類圖"展現了BaseServiceNode之間的關係:

+-------------+
| BaseService |<>---+
+-------------+     |
       △            |
       |            |
+-------------+     |
|    Node     |<----+
+-------------+
複製代碼

接口

Java和Go都支持接口,而且用起來也很是相似。前面介紹過的Cosmos-SDK裏的Account以及Temdermint裏的Service,其實都有相應的接口。Service接口的代碼前面已經給出過,下面給出Account接口的完整代碼以供參考:

type Account interface {
	GetAddress() sdk.AccAddress
	SetAddress(sdk.AccAddress) error // errors if already set.

	GetPubKey() crypto.PubKey // can return nil.
	SetPubKey(crypto.PubKey) error

	GetAccountNumber() uint64
	SetAccountNumber(uint64) error

	GetSequence() uint64
	SetSequence(uint64) error

	GetCoins() sdk.Coins
	SetCoins(sdk.Coins) error

	// Calculates the amount of coins that can be sent to other accounts given
	// the current time.
	SpendableCoins(blockTime time.Time) sdk.Coins

	// Ensure that account implements stringer
	String() string
}
複製代碼

在Go語言裏,使用接口+各類不一樣實現能夠達到LSP的效果,具體用法也比較簡單,這裏略去代碼演示。

擴展

在Java裏,接口可使用extends關鍵字擴展其餘接口,仍以Account系統爲例:

interface VestingAccount extends Account {
	Coins getVestedCoins(Time blockTime);
	Coint getVestingCoins(Time blockTime);
	// 其餘方法省略
}
複製代碼

在Go裏,在接口裏直接嵌入其餘接口便可:

type VestingAccount interface {
	Account

	// Delegation and undelegation accounting that returns the resulting base
	// coins amount.
	TrackDelegation(blockTime time.Time, amount sdk.Coins)
	TrackUndelegation(amount sdk.Coins)

	GetVestedCoins(blockTime time.Time) sdk.Coins
	GetVestingCoins(blockTime time.Time) sdk.Coins

	GetStartTime() int64
	GetEndTime() int64

	GetOriginalVesting() sdk.Coins
	GetDelegatedFree() sdk.Coins
	GetDelegatedVesting() sdk.Coins
}
複製代碼

實現

對於接口的實現,Java和Go表現出了不一樣的態度。在Java中,若是一個類想實現某接口,那麼必須用implements關鍵字顯式聲明,而且必須一個不落的實現接口裏的全部方法(除非這個類被聲明爲抽象類,那麼檢查推遲進行),不然編譯器就會報錯:

class BaseAccount implements Account {
  // 必須實現全部方法
}
複製代碼

Go語言則否則,只要一個結構體定義了某個接口的所有方法,那麼這個結構體就隱式實現了這個接口:

type BaseAccount struct { /* 字段省略 */ } // 不須要,也沒辦法聲明要實現那個接口
func (acc BaseAccount) GetAddress() sdk.AccAddress { /* 代碼省略 */ }
// 其餘方法省略
複製代碼

Go的這種作法很像某些動態語言裏的鴨子類型。但是有時候想像Java那樣,讓編譯器來保證某個結構體實現了特定的接口,及早發現問題,這種狀況怎麼辦?其實作法也很簡單,Cosmos-SDK/Tendermint裏也不乏這樣的例子,你們一看便知:

var _ Account = (*BaseAccount)(nil)
var _ VestingAccount = (*ContinuousVestingAccount)(nil)
var _ VestingAccount = (*DelayedVestingAccount)(nil)
複製代碼

經過定義一個不使用的、具備某種接口類型的全局變量,而後把nil強制轉換爲結構體(指針)並賦值給這個變量,這樣就能夠觸發編譯器類型檢查,起到及早發現問題的效果。

總結

本文以Java爲例,討論了OO編程中最主要的一些概念,並結合Tendermint/Comsos-SDK源代碼介紹瞭如何在Golang中模擬這些概念。下表對本文中討論的OO概念進行了總結:

OO概念 Java 在Golang中對應/模擬
class struct
實例字段 instance field filed
類字段 static field global var
實例方法 instance method method
類方法 static method func
構造函數 constructor func
信息隱藏 modifier 由名字首字母大小寫決定
子類繼承 extends embedding
LSP 徹底知足 只對接口有效
方法重寫 overriding 能夠重寫method,但不支持多態
方法重載 overloading 不支持
多態(方法動態分派) 徹底支持 不支持,但能夠經過一些tricky方式來模擬
接口 interface interface
接口擴展 extends embedding
接口實現 顯式實現(編譯器檢查) 隱式實現(鴨子類型)

本文由CoinEx Chain團隊Chase寫做,轉載無需受權。

相關文章
相關標籤/搜索