Golang 中的物件導向

JaguarJack發表於2019-04-05

Golang 的物件導向機制與 Smalltalk 或者 Java 等傳統物件導向的程式語言不同。傳統物件導向程式語言的一個重要特性是繼承機制。因為繼承機制支援在關聯物件間進行程式碼複用和資料共享。繼承機制曾在程式碼複用和資料共享的設計模式佔據主導地位,但是目前組合這一古老的技術重新煥發了活力。

本篇文章轉自 Tim Henderson的 "Object Oriented Inheritance in Go", 原文地址是 http://hackthology.com/object-oriented-inheritance-in-go.html 。非常感謝李浩和駿奇對於這篇文章的翻譯。

在我們探討如何在 Go 中實現繼承機制之前(Golang 中的繼承機制和其他語言 (Java) 的繼承機制有區別),我們先看一下 Java 中如何實現繼承機制。

繼承與組合

讓我們先看一下我最喜歡的話題之一:編譯器!編譯器由管道轉換構成,該管道讀取 text 文字並將其轉化為機器程式碼、組合語言、位元組碼或者其他的程式語言。管道首先會使用語法分析器對目標變成語言進行語法分析。一般情況下文字會被分解為不同的組成部分,例如:關鍵詞、識別符號、標點和數字等等。每個組成部分都會被相應的資料型別標記。例如下面這個 Java 資料型別:

public class Main {}

這些組成部分(可以稱作標記)如下所示:

public keyword, "public"\
class keyword, "class"\
idenitifier, "Main"\
left-bracket, "{"\
right-bracket, "}"

這些標記可以劃分為兩個部分:

標記型別\
語義部分

這會導致我們進行如下的 Java 設計方式:

public enum TokenType {
    KEYWORD, IDENTIFIER, LBRACKET, RBRACKET, ...
}
public class Token {
    public TokenToken type;
    **Here, I think TokenToken should be "TokenType"**
    public String lexeme;
}

對於一些標記型別來說,例如數值常量,標記型別最好能夠將包含這些屬性資訊。就數值常量來說,在他的標記型別裡應該包括常量值這一屬性。實現這一設計的傳統方式是使用繼承機制產生 Token 子類。

public class IntegerConstant extends Token {
        public long value;
}

另外一種完成該設計的方式是利用組合方式產生 IntegerConstantIntegerConstant 包含 token 的引用:

public class IntegerConstant {
    public Token type;
    public long value;
}

在這個例子中,繼承機制是一個比較恰當的選擇。理由是語法分析器需要返回一個通用型別。考慮一下語法分析器的介面設計:

public class Lexer {
    public Lexer(InputStream in)
    public boolean EOF()
    public Token peek() throws Error
    public Token next() throws Error
}

在繼承機制中,IntegerConstant屬於Token型別,所以它可以在Lexer中呼叫。這不是唯一可用或者最好的設計,但是這種設計方式是有效的。讓我們看一下 Go 是如何完成這一目的的。

Inheritance and Composition in Go

Go 中實現組合是一件十分容易的事情。簡單組合兩個結構體就能夠構造一個新的資料型別。

type TokenType uint16

const (
    KEYWORD TokenType = iota
    IDENTIFIER
    LBRACKET
    RBRACKET
    INT
)

type Token struct {
  Type   TokenType
  Lexeme string
}

type IntegerConstant struct {
  Token *Token
  Value uint64
}

這就是 Go 中實現程式碼和資料共享的常用方式。然而如果你想實現繼承機制,我們該如何去做?

Why would you want to use inheritance in go

一個可選的方案是將 Token 設計成介面型別。這種方案在 Java 和 Go都適用:

type Token interface {
  Type()   TokenType
  Lexeme() string
}

type Match struct {
  toktype TokenType
  lexeme  string
}

type IntegerConstant struct {
  token Token
  value uint64
}

func (m *Match) Type() TokenType {
  return m.toktype
}

func (m *Match) Lexeme() string {
  return m.lexeme
}

func (i *IntegerConstant) Type() TokenType {
  return i.token.Type()
}

func (i *IntegerConstant) Lexeme() string {
  return i.token.Lexeme()
}

func (i *IntegerConstant) Value() uint64 {
  return i.value
}

這樣分析器就可以返回滿足 Match和 IntegerConstant 型別的 Token 介面。

繼承機制的簡化版

上面的實現方案的一個問題是 *IntegerConstant 的方法呼叫中,出現了重複造輪子的問題。但是我們可以使用Go 內建的嵌入機制來避免此類情況的出現。嵌入機制(匿名嵌入)允許型別之前共享程式碼和資料。

type IntegerConstant struct {
  Token
  value uint64
}

func (i *IntegerConstant) Value() uint64 {
  return i.value
}

IntegerConstant中 匿名嵌入了 Token 型別,使得 IntegerConstant "繼承"了 Token的欄位和方法。很酷的方法!我們可以這樣寫程式碼:

t := IntegerConstant{&Match{KEYWORD, "wizard"}, 2}
fmt.Println(t.Type(), t.Lexeme(), t.Value())
x := Token(t)
fmt.Println(x.Type(), x.Lexeme())

(可以在這裡試一下 :https://play.golang.org/p/PJW7VShpE0)

我們沒有編寫 Type() 和 Value()方法的程式碼,但是 *IntegerConstant 也實現了 Token 介面,非常棒。

結構體的"繼承"機制

Go 中有三種方式完成”繼承“機制,您已經看到了第一種實現方式:在結構體的第一個欄位匿名嵌入介面型別。你還可以利用結構體實現其他兩種”繼承“機制:

  1. 匿名嵌入結構體例項

    type IntegerConstant struct {
    Match
    value uint64
    }
    
  2. 匿名嵌入結構體例項指標

    type IntegerConstant struct {
    *Match
    value uint64
    }
    

    在所有的例子中,與正常嵌入型別不同的是我們使用匿名嵌入。然而,這個欄位還是有欄位名稱的,名稱是嵌入型別名稱。在IntegerConstant 的 Match 欄位中,欄位名稱是 Match,無論嵌入型別是例項還是指標。

在以上的方案中,你不能嵌入與嵌入型別相同的方法名。例如結構體 Bar 匿名嵌入結構體 Foo 後,就不能擁有名稱為 Foo 的方法,同樣也不能實現 type Fooer interface { Foo() } 介面型別。

共享程式碼、共享資料或者兩者兼得

相比於 JavaGo 在繼承和聚合之間的界限是很模糊的。Go 中沒有 extends 關鍵詞。在語法的層次上,繼承看上去與聚合沒有什麼區別。Go 中聚合繼承唯一的不同在於,繼承自其他結構體的 struct 型別可以直接訪問父類結構體的欄位和方法。

type Pet struct {
  name string
}

type Dog struct {
  Pet
  Breed string
}

func (p *Pet) Speak() string {
  return fmt.Sprintf("my name is %v", p.name)
}

func (p *Pet) Name() string {
  return p.name
}

func (d *Dog) Speak() string {
  return fmt.Sprintf("%v and I am a %v", d.Pet.Speak(), d.Breed)
}

func main() {
  d := Dog{Pet: Pet{name: "spot"}, Breed: "pointer"}
  fmt.Println(d.Name())
  fmt.Println(d.Speak())
}

(可以試一下 https://play.golang.org/p/Pmkd27Nqqy)

輸出:

spot\
my name is spot and I am a pointer

嵌入式繼承機制的的侷限

相比於 Java Go 的繼承機制的作用是非常有限的。有很多的設計方案可以在 Java 輕鬆實現,但是 Go卻不可能完成同樣的工作。讓我們看一下:

Overriding Methods

上面的 Pet 例子中,Dog 型別過載了 Speak() 方法。然而如果 Pet 有另外一個方法 Play() 被呼叫,但是 Dog 沒有實現 Play() 的時候,Dog 型別的 Speak() 方法則不會被呼叫。

package main

import (
    "fmt"
)

type Pet struct {
    name string
}

type Dog struct {
    Pet
    Breed string
}

func (p *Pet) Play() {
    fmt.Println(p.Speak())
}

func (p *Pet) Speak() string {
    return fmt.Sprintf("my name is %v", p.name)
}

func (p *Pet) Name() string {
    return p.name
}

func (d *Dog) Speak() string {
    return fmt.Sprintf("%v and I am a %v", d.Pet.Speak(), d.Breed)
}

func main() {
    d := Dog{Pet: Pet{name: "spot"}, Breed: "pointer"}
    fmt.Println(d.Name())
    fmt.Println(d.Speak())
    d.Play()
}

(試一下 https://play.golang.org/p/id-aDKW8L6)

輸出:

spot\
my name is spot and I am a pointer\
my name is spot

但是 Java 中就會像我們預想的那樣工作:

public class Main {
  public static void main(String[] args) {
    Dog d = new Dog("spot", "pointer");
    System.out.println(d.Name());
    System.out.println(d.Speak());
    d.Play();
  }
}

class Pet {
  public String name;

  public Pet(String name) {
    this.name = name;
  }

  public void Play() {
    System.out.println(Speak());
  }

  public String Speak() {
    return String.format("my name is %s", name);
  }

  public String Name() {
    return name;
  }
}

class Dog extends Pet {
  public String breed;

  public Dog(String name, String breed) {
    super(name);
    this.breed = breed;
  }

  public String Speak() {
    return String.format("my name is %s and I am a %s", name, breed);
  }
}

輸出:

$ javac Main.java && java Main\
spot\
my name is spot and I am a pointer\
my name is spot and I am a pointer

這個明顯的區別是因為 Go 從根本上阻止了抽象方法的使用。讓我們看看下面這個例子:

package main

import (
    "fmt"
)

type Pet struct {
    speaker func() string
    name    string
}

type Dog struct {
    Pet
    Breed string
}

func NewPet(name string) *Pet {
    p := &Pet{
        name: name,
    }
    p.speaker = p.speak
    return p
}

func (p *Pet) Play() {
    fmt.Println(p.Speak())
}

func (p *Pet) Speak() string {
    return p.speaker()
}

func (p *Pet) speak() string {
    return fmt.Sprintf("my name is %v", p.name)
}

func (p *Pet) Name() string {
    return p.name
}

func NewDog(name, breed string) *Dog {
    d := &Dog{
        Pet:   Pet{name: name},
        Breed: breed,
    }
    d.speaker = d.speak
    return d
}

func (d *Dog) speak() string {
    return fmt.Sprintf("%v and I am a %v", d.Pet.speak(), d.Breed)
}

func main() {
    d := NewDog("spot", "pointer")
    fmt.Println(d.Name())
    fmt.Println(d.Speak())
    d.Play()
}

(試一下 https://play.golang.org/p/9iIb2px7jH)

輸出:

spot\
my name is spot and I am a pointer\
my name is spot and I am a pointer

現在跟我們預想的一樣了,但是跟Java相比略顯冗長和晦澀。你必須手工過載方法簽名。而且,程式碼在結構體未正確初始化的情況下會崩潰,例如當呼叫 Speak() 時,speaker() 卻沒有完成初始化工作的時候。

Subtyping 在 Java 中,Dog 繼承自 Pet ,那麼 Dog 型別就是 Pet 子類。這意味著在任何需要呼叫 Pet型別的場景都可以使用 Dog 型別替換。這種關係稱作多型性,但 Go的結構體型別不存在這種機制。\
讓我們看下面的例子:

package main

import (
    "fmt"
)

type Pet struct {
    speaker func() string
    name    string
}

type Dog struct {
    Pet
    Breed string
}

func NewPet(name string) *Pet {
    p := &Pet{
        name: name,
    }
    p.speaker = p.speak
    return p
}

func (p *Pet) Play() {
    fmt.Println(p.Speak())
}

func (p *Pet) Speak() string {
    return p.speaker()
}

func (p *Pet) speak() string {
    return fmt.Sprintf("my name is %v", p.name)
}

func (p *Pet) Name() string {
    return p.name
}

func NewDog(name, breed string) *Dog {
    d := &Dog{
        Pet:   Pet{name: name},
        Breed: breed,
    }
    d.speaker = d.speak
    return d
}

func (d *Dog) speak() string {
    return fmt.Sprintf("%v and I am a %v", d.Pet.speak(), d.Breed)
}

func Play(p *Pet) {
    p.Play()
}

func main() {
    d := NewDog("spot", "pointer")
    fmt.Println(d.Name())
    fmt.Println(d.Speak())
    Play(d)
}

(試一下 https://play.golang.org/p/e1Ujx0VhwK)

輸出:

prog.go:62: cannot use d (type Dog) as type Pet in argument to Play

然而,介面型別中存在子類化的多型機制!

package main

import (
    "fmt"
)

type Pet interface {
    Name() string
    Speak() string
    Play()
}

type pet struct {
    speaker func() string
    name    string
}

type Dog interface {
    Pet
    Breed() string
}

type dog struct {
    pet
    breed string
}

func NewPet(name string) *pet {
    p := &pet{
        name: name,
    }
    p.speaker = p.speak
    return p
}

func (p *pet) Play() {
    fmt.Println(p.Speak())
}

func (p *pet) Speak() string {
    return p.speaker()
}

func (p *pet) speak() string {
    return fmt.Sprintf("my name is %v", p.name)
}

func (p *pet) Name() string {
    return p.name
}

func NewDog(name, breed string) *dog {
    d := &dog{
        pet:   pet{name: name},
        breed: breed,
    }
    d.speaker = d.speak
    return d
}

func (d *dog) speak() string {
    return fmt.Sprintf("%v and I am a %v", d.pet.speak(), d.breed)
}

func Play(p Pet) {
    p.Play()
}

func main() {
    d := NewDog("spot", "pointer")
    fmt.Println(d.Name())
    fmt.Println(d.Speak())
    Play(d)
}

(試一下 https://play.golang.org/p/WMH-cr4AJf)

輸出:

spot\
my name is spot and I am a pointer\
my name is spot and I am a pointer

所以介面型別可以用來實現子類化的機制。但是如果你想正確的實現方法過載,需要了解以上的技巧。

Conclusion

事實上,雖然這不是Go的主打特性,但是Go語言在結構體嵌入結構體或者介面方面的能力確實為實際工作增加了很大的靈活性。Go的這些特性為我們解決實際問題提供了新的解決方案。但是相較於Java等語言,由於Go缺少子類化和方法過載支援還有存在一些侷限性。Go含有一項Java沒有的特性--介面嵌入。關於介面嵌入的細節請參考Golang的官方文件的Embedding部分。

相關文章