你們都知道,Go不是面向對象(Object Oriented,後面簡稱爲OO)語言。本文以Java語言爲例,介紹傳統OO編程擁有的特性,以及在Go語言中如何模擬這些特性。文中出現的示例代碼都取自Cosmos-SDK或Tendermint源代碼。如下是本文將要介紹的OO編程的主要概念:java
傳統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
}
複製代碼
那麼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語言,這也是不得已而爲之。
爲了進一步加深理解,咱們來看一下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寫做,轉載無需受權。