Go物件導向程式設計以及在Tendermint/Cosmos-SDK中的應用

CET_Talk發表於2019-07-16

Go物件導向程式設計以及在Tendermint/Cosmos-SDK中的應用

Go物件導向程式設計以及在Tendermint/Cosmos-SDK中的應用

大家都知道,Go不是物件導向(Object Oriented,後面簡稱為OO)語言。本文以Java語言為例,介紹傳統OO程式設計擁有的特性,以及在Go語言中如何模擬這些特性。文中出現的示例程式碼都取自Cosmos-SDK或Tendermint原始碼。以下是本文將要介紹的OO程式設計的主要概念:

  • 類(Class)
    • 欄位(Field)
      • 例項欄位
      • 類欄位
    • 方法(Method)
      • 例項方法
      • 類方法
      • 建構函式(Constructor)
    • 資訊隱藏
    • 繼承
      • 利斯科夫替換原則(Liskov Substitution Principle,LSP)
      • 方法重寫(Overriding)
      • 方法過載(Overloading)
      • 多型
  • 介面(Interface)
    • 擴充套件
    • 實現

傳統OO語言很重要的一個概念就是,類相當於一個模版,可以用來建立例項(或者物件)。在Java裡,使用class關鍵子來自定義一個類:

class StdTx {
  // 欄位省略
}
複製程式碼

Go並不是傳統意義上的OO語言,甚至根本沒有"類"的概念,所以也沒有class關鍵字,直接用struct定義結構體即可:

type StdTx struct {
  // 欄位省略
}
複製程式碼

欄位

類的狀態可以分為兩種:每個例項各自的狀態(簡稱例項狀態),以及類本身的狀態(簡稱類狀態)。類或例項的狀態由欄位構成,例項狀態由例項欄位構成,類狀態則由類欄位構成。

例項欄位

在Java的類裡定義例項欄位,或者在Go的結構體裡定義欄位,寫法差不多,當然語法略有不同。仍以Cosmos-SDK提供的標準交易為例,先給出Java的寫法:

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

再給出Go的寫法:

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

類欄位

在Java裡,可以用static關鍵字定義類欄位(因此也叫做靜態欄位):

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

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

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

方法

為了寫出更容易維護的程式碼,外界通常需要通過方法來讀寫例項或類狀態,讀寫例項狀態的方法叫做例項方法,讀寫類狀態的方法則叫做類方法。大部分OO語言還有一種特殊的方法,叫做建構函式,專門用於建立類的例項。

例項方法

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

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寫作,轉載無需授權。

相關文章