Go Object-oriented Programming and Its Application in Tendermint/Cosmos-SDK

0.192字数 1972阅读 24

It is known that Go is not an “object-oriented” (hereinafter referred to as “OO”) language. With “Java” language as an example, this article introduces the features in traditional OO programming, and explains how to simulate these features in Golang. The sample code in this article is extracted from the Cosmos-SDK or Tendermint source code. Here are the main concepts of OO programming in this article.

  • (Class)
    • (Field)
      • Instance Field
      • Class Field
    • (Method)
      • Instance Method
      • Class Method
      • (Constructor)
    • Information Hiding
    • Inheritance
    • (Liskov Substitution Principle, LSP)
    • (Method Overriding)
    • (Method Overloading)
    • Polymorphism
  • (Interface)
    • Extends
    • Implementation

Class

One of the important concepts in traditional OO languages is “class”. A class is used as a template to create “instances” (or “objects”). In Java, the keyword “class” is used to create a class.

class StdTx {
  // field omitted
}

Go is not a traditional OO language. There is even no “class” concept in Go, so no keyword “class”. A struct can be used to define a structure.

type StdTx struct {
  // field omitted
}

Field

There are two class states: state of each instance (referred to as “instance state”), and state of the class itself (referred to as “class state”). The class state or the instance state consists of the “field”. The instance state consists of the “instance field” while the class state the “class field”.

Instance Field

The writing is similar whether you define instance fields in Java classes, or define fields in Go's structure, but their syntax is slightly different. Take a standard Cosmos-SDK transaction as an example. If written in Java:

class StdTx {
  Msg[]          msgs;
  StdFee         fee;
  StdSignature[] StdSignatures
  String         memo;
}

If written in Golang:

type StdTx struct {
    Msgs       []sdk.Msg      `json:"msg"`
    Fee        StdFee         `json:"fee"`
    Signatures []StdSignature `json:"signatures"`
    Memo       string         `json:"memo"`
}

Class Field

class StdTx {
  static long maxGasWanted = (1 << 63) - 1;
  
  Msg[]          msgs;
  StdFee         fee;
  StdSignature[] StdSignatures
  String         memo;
}

There is no corresponding concept in Golang, thus it can only be simulated with global variables.

var maxGasWanted = uint64((1 << 63) - 1)

Method

To write code that is easier to maintain, the outside usually needs to read and write the instance or class state through the “method”. The method of reading and writing the instance state is called “instance method”, while the method of reading and writing the class state is called “class method”. Most OO languages also have a special method called “constructor” that is specifically used to create class instances.

Instance Method

In Java, a method with an explicit return value but is not modified with the keyword “static” is the “instance method”. In the instance method, the current instance can be accessed implicitly or explicitly (by using the keyword “this”). Let's take the simplest Getter/Setter method in Java as an example to explain the definition of an instance method.

class StdTx {
  
  private String memo;
  // other fields omitted
  
  public voie setMemo(String memo) {this.memo = memo; } // use this keyword
  public String getMemo() { return memo; }              // not use this keyword
  
}

Of course, an instance method can only be called in a class instance (i.e. an object).

StdTx stdTx = new StdTx();     // create class instances
stdTx.setMemo("hello");        // call instance methods
String memo = stdTx.getMemo(); // call instance methods

Golang defines a method for a structure by explicitly assigning a receiver (there is only one method in Go, so no need to distinguish among the methods).

// assign a receiver in the round brackets after the keywork “func”

func (tx StdTx) GetMemo() string { return tx.Memo }

Calling a method seems similar as it is in Java.

stdTx := StdTx{ ... }   // create structure instance 
memo := stdTx.GetMemo() // call a method 

Class Method

class StdTx {
  private static long maxGasWanted = (1 << 63) - 1;
  
  public static long getMaxGasWanted() {
    return maxGasWanted;
  }
}

A class method is called directly in a class: “StdTx.getMaxGasWanted()”. There is no corresponding concept in Golang, thus it can only be simulated with normal functions (without assigning a receiver) (The following function is only for illustration, and does not exist in Cosmos-SDK).

func MaxGasWanted() long {
  return maxGasWanted
}

Constructor

In Java, an instance method that has the same name as a class but doesn’t assign a return value is called “constructor”.

class StdTx {
  StdTx(String memo) {
    this.memo = memo;
  }
}

You can create a class instance by calling a constructor with the keyword “new” (refer to the previous examples). There is no specific constructor concept in Golang, but it is easy to be simulated by using normal functions.

func NewStdTx(msgs []sdk.Msg, fee StdFee, sigs []StdSignature, memo string) StdTx {
    return StdTx{
        Msgs:       msgs,
        Fee:        fee,
        Signatures: sigs,
        Memo:       memo,
    }
}

Information Hiding

If you don't want to write unmaintainable code, you must hide the class or instance state, as well as the method that you don't have to expose. There are 4 levels of visibility in Java.

Java Class/Field/Method Visibility Visible Inside Class Visible Inside Package Visible to Subclass All Classes
modifier public
modifier protected
no modifier
modifier private

In contrast, there are only 2 levels of visibility in Golang: all classes or visible inside package. If global variables, functions, methods, structures, structure fields, etc. begin with an uppercase letter, they are fully accessible, otherwise they are only visible in the same package.

Inheritance

In Java, a class can inherit another class by using the keyword “extends”. The class that inherits from another class is called “Subclass”, while the class that is inherited from is called “Superclass”.
A subclass inherits all non-private fields and methods of its superclass. Take the account system provided by Cosmos-SDK as an example:

class BaseAccount { /* field and method omitted */ }   
class BaseVestingAccount extends BaseAccount { /* field and method omitted */ } 
class ContinuousVestingAccount extends BaseVestingAccount { /* field and method omitted*/ } 
class DelayedVestingAccount extends BaseVestingAccount { /* field and method omitted */ }    

There is no such concept as “inheritance” in Golang and it can only be simulated by "combination". In Go, if a field of a struct (let’s temporarily assume that the field is also a struct type and can be a pointer type) has no name, then the outer struct can "inherit" the method from the embedded struct. The following shows how the Account class inheritance system performs in Go:

type BaseAccount struct { /* field omitted */ } 

type BaseVestingAccount struct {
    *BaseAccount
    // other fields omitted
}

type ContinuousVestingAccount struct {
    *BaseVestingAccount
    // other fields omitted
}

type DelayedVestingAccount struct {
    *BaseVestingAccount
}

For example, the “BaseAccount” structure defines the “GetCoins()” method.

func (acc *BaseAccount) GetCoins() sdk.Coins {
    return acc.Coins
}

Thus, “BaseVestingAccount”, “DelayedVestingAccount” and other structures inherit this method.

dvacc := auth.DelayedVestingAccount{ ... }
coins := dvacc.GetCoins() // 调用BaseAccount#GetCoins()   call BaseAccount#GetCoins()

Liskov Substitution Principle

An important principle of OO programming is [Liskov Substitution Principle] (https://en.wikipedia.org/wiki/Liskov_substitution_principle) (Liskov Substitution Principle, hereinafter referred to as “LSP”). In simple terms, anywhere that a superclass can appear (such as local variables, method arguments, etc.) could be replaced by a subclass. Take Java as an example:

BaseAccount bacc = new BaseAccount();
bacc = new DelayedVestingAccount(); // LSP

Unfortunately, the nested structure in Golang doesn’t “fulfill” LSP.

bacc := auth.BaseAccount{}
bacc = auth.DelayedVestingAccount{} // compile error: cannot use auth.DelayedVestingAccount literal (type auth.DelayedVestingAccount) as type auth.BaseAccount in assignment

In Go, LSP can only be fulfilled when an interface is used. The interface will be introduced later.

Method Override

In Java, a subclass can “override” any superclass method. This feature is very important as a large number of general methods can be placed into a superclass, and the subclass only needs to override a small number of methods, avoiding duplicate code as much as possible. Let’s take the account system as an example again.The “SpendableCoins()” method in the account calculates all the available balance at a certain point of time. The “BaseAccount” provides a default implementation, thus the subclass can be overridden.

class BaseAccount {
  // other fields and methods omitted
  Coins SpendableCoins(Time time) {
    return GetCoins(); // default implementation 
  }
}

class ContinuousVestingAccount {
  // other fields and methods omitted
  Coins SpendableCoins(Time time) {
    // provide its own implementation 
  }
}

class DelayedVestingAccount {
  // other fields and methods omitted
  Coins SpendableCoins(Time time) {
    // provide its own implementation
  }
}

In Golang, you can achieve “similar” effect by redefining a method in a structure.

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))
}

Then we call the method overriding directly in the struct instance:

dvacc := auth.DelayedVestingAccount{ ... }
coins := dvacc.SpendableCoins(someTime) // DelayedVestingAccount#SpendableCoins()

Method Overloading

To have a full discussion, we would like to briefly introduce the method “overloading”. In Java, the same class (or superclass and subclass) is allowed to have the “same name” method as long as the signature of these methods (jointly determined by the number, order and type of arguments) is different. Let’s take the Dec type provided by Cosmos-SDK as an example.

public class Dec {
  // field omitted
  public Dec mul(int i) { /* code omitted */ } 
  public Dec mul(long i) { /* code omitted */ } 
  // other methods omitted 
}

Neither a method nor a normal function can be overloaded (not supported) in Golang, so it can only be named differently.

type Dec struct { /* field omitted */ } 
func (d Dec) MulInt(i Int) Dec { /* code omitted */ } 
func (d Dec) MulInt64(i int64) Dec { /* code omitted */ } 
// other methods omitted 

Polymorphism

Method overriding needs to work together with [polymorphism] (https://en.wikipedia.org/wiki/Polymorphism_(computer_science)) (specifically,the [Dynamic Dispatch] is a focus here (https://en.wikipedia.org) /wiki/Dynamic_dispatch)) to play its full role. Take the Service provided by Tendermint as an example. The Service can be started, stopped, restarted, and so on. Here is the definition of the Service interface (Golang):

type Service interface {
    Start()   error
    OnStart() error
    Stop()    error
    OnStop()  error
    Reset()   error
    OnReset() error
    // 其他方法省略 other methods omitted
}

If it is translated into Java code:

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;
  // other methods omitted
}

Regardless of the service, starting, stopping, and restarting involve the judgment state, so the “Start()”, “Stop()”, and “Reset()” methods are very suitable for implementation in superclasses. The logis for specific start, stop, and restart varies from service to service, so they can be provided by subclasses in the “OnStart()”, “OnStop()” and “OnReset()” methods. Take the “Start()” and “OnStart()” methods as an example. Let's first loot at the “BaseService” base class implemented in Java (just to illustrate polymorphism, so we here overlook details such as thread safety and exception handling).

public class BaseService implements Service {
  private boolean started;
  private boolean stopped;
  
  public void onStart() throws Exception {
// Default implementation; if you don’t want to provide a default implementation, the method can be abstract
  }
  
  public void start() throws Exception {
    if (started) { throw new AlreadyStartedException(); }
    if (stopped) { throw new AlreadyStoppedException(); }
    onStart(); // dynamic dispatch will be implemented here
    started = true;
  }
  
  // other fields and methods omitted
}

Unfortunately, structure nesting + method overriding doesn’t support polymorphism in Golang. Therefore, code in Golang has to be tricky. Here is the definition of the “BaseService” structure in Tendermint:

type BaseService struct {
    Logger  log.Logger
    name    string
    started uint32 // atomic
    stopped uint32 // atomic
    quit    chan struct{}

    // The "subclass" of BaseService
    impl Service
}

Let’s have a look at the “OnStart()” and “Start()” methods.

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() // focus here
        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
}

You can tell that in order to simulate polymorphism, an ugly “impl” field is added to the “BaseService” structure, and the “OnStart()” method is called through this field. After all, Go is not a true OO language and there is no other way.

Example

To have a better understanding, let's take a look at how the “Node” structure provided by Tendermint inherits “BaseService”. The “Node” structure represents the Tendermint full node, and here is its definition:

type Node struct {
    cmn.BaseService
    // other fields omitted
}

As you can see, the “Node” embeds ("inherits") “BaseService”. The “NewNode()” function creates a “Node” instance and initializes BaseService:

func NewNode (*Node, error) {
func NewNode(/* argument omitted */) (*Node, error) {
    // omit irrelevant code
    node := &Node{ ... }
    node.BaseService = *cmn.NewBaseService(logger, "Node", node)
    return node, nil
}

As you can see, when it calls the “NewBaseService()” function to create a “BaseService” instance, the “node” pointer is passed to the function. This pointer is assigned to the “impl” field of “BaseService”.

func NewBaseService(logger log.Logger, name string, impl Service) *BaseService {
    return &BaseService{
        Logger: logger,
        name:   name,
        quit:   make(chan struct{}),
        impl:   impl,
    }
}

After all of this, the “Node” only needs to override the “OnStart()” method which will be called correctly in the "inherited" “Start()” method. The following UML "class diagram" shows the relationship between the “BaseService” and the “Node”.

+-------------+
| BaseService |<>---+
+-------------+     |
       △            |
       |            |
+-------------+     |
|    Node     |<----+
+-------------+

Interface

Both Java and Go support the “interface” and are very similar in use. Both the “Account” in Cosmos-SDK and the “Service” in Temerdermint mentioned above actually have corresponding interfaces. The “Service” interface code has been given earlier. Now the complete code for the “Account” interface is listed as below for reference:

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
}

In the Golang, the interface + various implementations can achieve the LSP effect. The usage is simple. We show the usage with the code omitted omitted.

Extends

interface VestingAccount extends Account {
    Coins getVestedCoins(Time blockTime);
    Coint getVestingCoins(Time blockTime);
    // other methods omitted 
}

In Go, other interfaces can be “embedded” directly in an interface.

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
}

Implementation

For the implementation of the interface, Java and Go show different attitudes. In Java, if a class wants to implement an interface, it has to specify it explicitly with the keyword “implements” and implements all the methods in the interface (the check is postponed if the class is marked as abstract), otherwise the compiler will report an error:

class BaseAccount implements Account {
  // must implement all the methods
}

While Golang is not the case. As long as a structure defines all the methods of an interface, the structure implements this interface “implicitly”:

type BaseAccount struct { /* filed omitted */ } // not needed, and no way to specify to implement that interface
func (acc BaseAccount) GetAddress() sdk.AccAddress { /*  code omitted */ }
// other methods omitted

Go's approach is similar to the [duck typing] concept in some dynamic languages (https://en.wikipedia.org/wiki/Duck_typing). But what to do if you sometimes want the compiler to secure that a structure implements a specific interface to identify problems early as in Java? In fact, it’s very simple. There are many such examples in Cosmos-SDK/Tendermint. Take a look and you will know.

var _ Account = (*BaseAccount)(nil)
var _ VestingAccount = (*ContinuousVestingAccount)(nil)
var _ VestingAccount = (*DelayedVestingAccount)(nil)

By defining a global variable that is an interface type but not used, converting nil to a structure (pointer) and assigning it to the variable, in this way can the compiler type check be triggered to detect problems early.

Summary

This article takes Java as an example to discuss the most important concepts in OO programming, and introduces how to simulate these concepts in Golang in combination with the Tendermint/Comsos-SDK source code. The following table summarizes the OO concepts discussed in this article:

OO Concept Java Corresponding/Stimulation in Golang
class class struct
instance field instance field filed
class field static field global var
instance method instance method method
class method static method func
constructor constructor func
info hiding modifier depend on the initial of the name (uppercase or lowercase)
subclass inheritance extends embedding
LSP fully fulfil only valid for interface
method overriding overriding can override method, but not support polymorphism
method overloading overloading doesn’t support
polymorphism (method dynamic dispatch) fully fulfil doesn’t support , but can stimulate via some tricky methods
interface interface interface
interface extends extends embedding
interface implementation explicit implementation(compiler check ) implicit implementation (duck type)

This article was written by Team Chase [CoinEx Chain] (https://www.coinex.org/). Authorization not needed for repost.

推荐阅读更多精彩内容