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)
- 多型
- 欄位(Field)
- 介面(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
}
複製程式碼
那麼BaseVestingAccount
、DelayedVestingAccount
等結構體都"繼承"了這個方法:
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
嵌入("繼承")了BaseService
。NewNode()
函式建立Node
例項,函式中會初始化BaseService
:
func NewNode(/* 引數省略 */) (*Node, error) {
// 省略無關程式碼
node := &Node{ ... }
node.BaseService = *cmn.NewBaseService(logger, "Node", node)
return node, nil
}
複製程式碼
可以看到,在呼叫NewBaseService()
函式建立BaseService
例項時,傳入了node
指標,這個指標會被賦值給BaseService
的impl
欄位:
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"類圖"展示了BaseService
和Node
之間的關係:
+-------------+
| 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寫作,轉載無需授權。