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
}
那么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写作,转载无需授权。
有疑问加站长微信联系(非本文作者)