你们都知道,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写做,转载无需受权。