Go面向对象编程以及在Tendermint/Cosmos-SDK中的应用

312 阅读13分钟

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写作,转载无需授权。