大家都知道,Go不是 面向对象 (Object Oriented,后面简称为 OO )语言。本文以 Java 语言为例,介绍传统OO编程拥有的特性,以及在Go语言中如何模拟这些特性。文中出现的示例代码都取自Cosmos-SDK或Tendermint源代码。以下是本文将要介绍的OO编程的主要概念:
传统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语言,这也是不得已而为之。
为了进一步加深理解,我们来看一下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写作,转载无需授权。