当前位置: 首页 > 工具软件 > Cosmos SDK > 使用案例 >

【区块链】Go面向对象编程以及在Tendermint/Cosmos-SDK中的应用

鄂育
2023-12-01

        大家都知道,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中对应/模拟
classstruct
实例字段instance fieldfiled
类字段static fieldglobal var
实例方法instance methodmethod
类方法static methodfunc
构造函数constructorfunc
信息隐藏modifier由名字首字母大小写决定
子类继承extendsembedding
LSP完全满足只对接口有效
方法重写overriding可以重写method,但不支持多态
方法重载overloading不支持
多态(方法动态分派)完全支持不支持,但可以通过一些tricky方式来模拟
接口interfaceinterface
接口扩展extendsembedding
接口实现显式实现(编译器检查)隐式实现(鸭子类型)

本文由CoinEx Chain团队Chase写作,转载无需授权。

 类似资料: