位元詩人 [Golang] 程式設計教學:建立類別 (Class) 和物件 (Object)

Golang物件
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

傳統的程序式程式設計 (procedural programming) 或是指令式程式設計 (imperative programming) 學到函式大概就算學完基本概念。

不過,近年來,物件導向程式設計 (object-oriented programming) 是程式設計主流的模式 (paradigm),即使 C 這種非物件導向的語言,我們也會用結構和函式模擬物件的特性。本文將介紹如何在 Go 撰寫物件導向程式。

五分鐘的物件導向概論

由於物件導向是程式設計主流的模式 (paradigm),很多語言都直接在語法機制中支援物件導向,然而,每個語言支援的物件導向特性略有不同,像 C++ 的物件系統相當完整,而 Perl 的原生物件系統則相對原始。物件導向在理論上是和語言無關的,但在實務上卻受到不同語言特性 (features) 的影響。學習物件導向時,除了學習在某個特定語言下的實作方式外,更應該學習其抽象層次的思維,有時候,暫時放下實作細節,從更高的視角看物件及物件間訊息的流動,對於學習物件導向有相當的幫助。

物件導向是一種將程式碼以更高的層次組織起來的方法。大部分的物件導向以類別 (class) 為基礎,透過類別可產生實際的物件 (object) 或實體 (instance) ,類別和物件就像是餅乾模子和餅乾的關係,透過同一個模子可以產生很多片餅乾。物件擁有屬性 (field) 和方法 (method),屬性是其內在狀態,而方法是其外在行為。透過物件,狀態和方法是連動的,比起傳統的程序式程式設計,更容易組織程式碼。

許多物件導向語言支援封裝 (encapsulation),透過封裝,程式設計者可以決定物件的那些部分要對外公開,那些部分僅由內部使用,封裝不僅限於靜態的資料,決定物件應該對外公開的行為也是封裝。當多個物件間互動時,封裝可使得程式碼容易維護,反之,過度暴露物件的內在屬性和細部行為會使得程式碼相互糾結,難以除錯。

物件間可以透過組合 (composition) 再利用程式碼。物件的屬性不一定要是基本型別,也可以是其他物件。組合是透過有... (has-a) 關係建立物件間的關連。例如,汽車物件有引擎物件,而引擎物件本身又有許多的狀態和行為。繼承 (inheritance) 是另一個再利用程式碼的方式,透過繼承,子類別 (child class) 可以再利用父類別 (parent class) 的狀態和行為。繼承是透過是... (is-a) 關係建立物件間的關連。例如,研究生物件是學生物件的特例。然而,過度濫用繼承,容易使程式碼間高度相依,造成程式難以維護。可參考組合勝過繼承 (composition over inheritance) 這個指導原則來設計自己的專案。

透過多型 (polymorphism) 使用物件,不需要在意物件的實作,只需依照其公開介面使用即可。例如,我們想要開車,不論駕駛 Honda 汽車或是 Ford 汽車,由於汽車的儀表板都大同小異,都可以執行開車這項行為,而不需在意不同廠牌的汽車的內部差異。多型有許多種形式,如:

  • 特定多態 (ad hoc polymorphism):
    • 函數重載 (functional overloading):同名而不同參數型別的方法 (method)
    • 運算子重載 (operator overloading) : 對不同型別的物件使用相同運算子 (operator)
  • 泛型 (generics):對不同型別使用相同實作
  • 子類型 (Subtyping):不同子類別共享相同的公開介面,不同語言有不同的繼承機制

以物件導向實作程式,需要從宏觀的角度來思考,不僅要設計單一物件的公開行為,還有物件間如何互動,以達到良好且易於維護的程式碼結構。除了閱讀本教程或其他程式設計的書籍以學習如何實作物件外,可閱讀關於 物件導向分析及設計 (object-oriented analysis and design) 或是設計模式 (design pattern) 的書籍,以增進對物件導向的了解。

[Update on 2018/05/20] 嚴格來說,Go 只能撰寫基於物件的程式 (object-based programming),無法撰寫物件導向程式 (object-oriented programming),因為 Go 僅支援一部分的物件導向特性,像是 Go 不支援繼承。

由於 Go 的設計思維,以 Go 實作基於物件的程式時,會和 Java 或 Python 等相對傳統的物件系統略有不同,本文會在相關處提及相同及相異處,供讀者參考。

建立物件 (Object)

以下範例程式碼建立簡單的 Point 類別和物件:

package main                                  /*  1 */

import (                                      /*  2 */
        "log"                                 /*  3 */
)                                             /*  4 */

// `X` and `Y` are public fields.             /*  5 */
type Point struct {                           /*  6 */
        X float64                             /*  7 */
        Y float64                             /*  8 */
}                                             /*  9 */

// Use an ordinary function as constructor    /* 10 */
func NewPoint(x float64, y float64) *Point {  /* 11 */
        p := new(Point)                       /* 12 */

        p.X = x                               /* 13 */
        p.Y = y                               /* 14 */

        return p                              /* 15 */
}                                             /* 16 */

func main() {                                 /* 17 */
        p := NewPoint(3, 4)                   /* 18 */

        if !(p.X == 3.0) {                    /* 19 */
                log.Fatal("Wrong value")      /* 20 */
        }                                     /* 21 */

        if !(p.Y == 4.0) {                    /* 22 */
                log.Fatal("Wrong value")      /* 23 */
        }                                     /* 24 */
}                                             /* 25 */

第 6 行至第 9 行的部分是形態宣告。Golang 沿用結構體為類別的型態,而沒有用新的保留字。

第 11 行至第 16 行的部分是建構函式。在一些程式語言中,會有為了建立物件使用特定的建構子 (constructor),而 Golang 沒有引入額外的新語法,直接以一般的函式充當建構函式來建立物件即可。

第 17 行至第 25 行為外部程式。在我們的 Point 物件 p 中,我們直接存取 p 的屬性 XY,這在物件導向上不是好的習慣,因為我們無法控管屬性,物件可能會產生預期外的行為,比較好的方法,是將屬性隱藏在物件內部,由公開方法去存取。我們在後文中會討論。

類別宣告不限定於結構體

雖然大部分的 Golang 類別都使用結構體,但其實 Golang 類別內部可用其他的型別,如下例:

type Vector []float64                     /*  1 */

func NewVector(args ...float64) Vector {  /*  2 */
        return args                       /*  3 */
}                                         /*  4 */

func WithSize(s int) Vector {             /*  5 */
        v := make([]float64, s)           /*  6 */

        return v                          /*  7 */
}                                         /*  8 */

在第 1 行中,我們宣告 Vector 型態,該型態內部不是使用結構體,而是使用陣列。

我們在第 2 行至第 4 行間及第 5 行至第 8 間宣告了兩個建構函式。由此例可知,Go 不限定建構函式的數量,我們可以視需求使用多個不同的建構函式。

撰寫方法 (Method)

在物件導向程式中,我們很少直接操作屬性 (field),通常會將屬性私有化,再加入相對應的公開方法 (method)。我們將先前的 Point 物件改寫如下:

package main                                  /*  1 */

import (                                      /*  2 */
        "log"                                 /*  3 */
)                                             /*  4 */

// `x` and `y` are private fields.            /*  5 */
type Point struct {                           /*  6 */
        x float64                             /*  7 */
        y float64                             /*  8 */
}                                             /*  9 */

func NewPoint(x float64, y float64) *Point {  /* 10 */
        p := new(Point)                       /* 11 */

        p.SetX(x)                             /* 12 */
        p.SetY(y)                             /* 13 */

        return p                              /* 14 */
}                                             /* 15 */

// The getter of x                            /* 16 */
func (p *Point) X() float64 {                 /* 17 */
        return p.x                            /* 18 */
}                                             /* 19 */

// The getter of y                            /* 20 */
func (p *Point) Y() float64 {                 /* 21 */
        return p.y                            /* 22 */
}                                             /* 23 */

// The setter of x                            /* 24 */
func (p *Point) SetX(x float64) {             /* 25 */
        p.x = x                               /* 26 */
}                                             /* 27 */

// The setter of y                            /* 28 */
func (p *Point) SetY(y float64) {             /* 29 */
        p.y = y                               /* 30 */
}                                             /* 31 */

func main() {                                 /* 32 */
        p := NewPoint(0, 0)                   /* 33 */

        if !(p.X() == 0) {                    /* 34 */
                log.Fatal("Wrong value")      /* 35 */
        }                                     /* 36 */

        if !(p.Y() == 0) {                    /* 37 */
                log.Fatal("Wrong value")      /* 38 */
        }                                     /* 39 */

        p.SetX(3)                             /* 40 */
        p.SetY(4)                             /* 41 */

        if !(p.X() == 3.0) {                  /* 42 */
                log.Fatal("Wrong value")      /* 43 */
        }                                     /* 44 */

        if !(p.Y() == 4.0) {                  /* 45 */
                log.Fatal("Wrong value")      /* 46 */
        }                                     /* 47 */
}                                             /* 48 */

第 6 行至第 9 行是類別宣告的部分。在這個版本的宣告中,我們將 xy 改為小寫,代表該屬性是私有屬性,其可視度僅限於同一 package 中。

第 10 行至第 15 行是 Point 類別的建構函式。請注意我們刻意在第 12 行及第 13 行用該類別的 setters 來初始化屬性,這是刻意的動作。因為我們要確保在設置屬性時的行為保持一致。

第 16 行至第 31 行是 Point 類別的 getters 和 setters。所謂的 getters 和 setters 是用來存取內部屬性的 method。比起直接暴露屬性,使用 getters 和 setters 會有比較好的控制權。日後要修改 getters 或 setters 的實作時,也只要修改同一個地方即可。

在本例中,getters 和 setters 都是公開 method。但 getters 或 setters 不一定必為公開 method。例如,我們想做唯讀的 Point 物件時,就可以把 setters 的部分設為私有 method,留給類別內部使用。

在 Go 語言中,沒有 thisself 這種代表物件的關鍵字,而是由程式設計者自訂代表物件的變數,在本例中,我們用 p 表示物件本身。透過這種帶有物件的函式宣告後,函式會和物件連動;在物件導向中,將這種和物件連動的函式稱為方法 (method)。

雖然在這個例子中,暫時無法直接看出使用方法的好處,比起直接操作屬性,透過私有屬性搭配公開方法帶來許多的益處。例如,如果我們希望 Point 在建立之後是唯讀的,我們只要將 SetXSetY 改為私有方法即可。或者,我們希望限定 Point 所在的範圍為 0.0 至 1000.0,我們可以在 SetXSetY 中檢查參數是否符合我們的要求。

靜態方法 (Static Method)

有些讀者學過 Java 或 C#,可能有聽過過靜態方法 (static method)。這是因為 Java 和 C# 直接將物件導向的概念融入其語法中,然而,為了要讓某些方法在不建立物件時即可使用,所使用的一種補償性的語法機制。由於 Go 語言沒有將物件導向的概念直接加在語法中,不需要用這種語法,直接用頂層函式即可。

例如:我們撰寫一個計算兩點間長度的函式:

package main                                   /*  1 */

import (                                       /*  2 */
        "log"                                  /*  3 */
        "math"                                 /*  4 */
)                                              /*  5 */

type Point struct {                            /*  6 */
        x float64                              /*  7 */
        y float64                              /*  8 */
}                                              /*  9 */

func NewPoint(x float64, y float64) *Point {   /* 10 */
        p := new(Point)                        /* 11 */

        p.SetX(x)                              /* 12 */
        p.SetY(y)                              /* 13 */

        return p                               /* 14 */
}                                              /* 15 */

func (p *Point) X() float64 {                  /* 16 */
        return p.x                             /* 17 */
}                                              /* 18 */

func (p *Point) Y() float64 {                  /* 19 */
        return p.y                             /* 20 */
}                                              /* 21 */

func (p *Point) SetX(x float64) {              /* 22 */
        p.x = x                                /* 23 */
}                                              /* 24 */

func (p *Point) SetY(y float64) {              /* 25 */
        p.y = y                                /* 26 */
}                                              /* 27 */

// Use an ordinary function as static method.  /* 28 */
func Dist(p1 *Point, p2 *Point) float64 {      /* 29 */
        xSqr := math.Pow(p1.X()-p2.X(), 2)     /* 30 */
        ySqr := math.Pow(p1.Y()-p2.Y(), 2)     /* 31 */

        return math.Sqrt(xSqr + ySqr)          /* 32 */
}                                              /* 33 */

func main() {                                  /* 34 */
        p1 := NewPoint(0, 0)                   /* 35 */
        p2 := NewPoint(3.0, 4.0)               /* 36 */

        if !(Dist(p1, p2) == 5.0) {            /* 37 */
                log.Fatal("Wrong value")       /* 38 */
        }                                      /* 39 */
}                                              /* 40 */

本範例和前一節的範例大同小異。主要的差別在於第 29 行至第 33 間多了一個用來計算距離的函式。該函式不綁定特定的物件,相當於 Java 的靜態函式。

因為 Golang 不是 Java 這種純物件導向語言,而是混合命令式和物件式兩種語法,所以不需要使用特定的語法來實踐靜態函式,使用一般的函式即可。

或許有讀者會擔心,使用過多的頂層函式會造成全域空間的汙染和衝突;實際上不需擔心,雖然我們目前將物件和主程式寫在一起,實務上,物件會寫在獨立的package 中,藉由 package 即可大幅減低命名空間衝突的議題。

使用嵌入 (Embedding) 取代繼承 (Inheritance)

繼承 (inheritance) 是一種重用程式碼的方式,透過從父類別 (parent class) 繼承程式碼,子類別 (child class) 可以少寫一些程式碼。此外,對於靜態型別語言來說,繼承也是實現多型 (polymorphism) 的方式。然而,Go 語言卻刻意地拿掉繼承,這是出自於其他語言的經驗。

繼承雖然好用,但也引起許多的問題。像是 C++ 相對自由,可以直接使用多重繼承,但這項特性會引來菱型繼承 (diamond inheritance) 的議題,Java 和 C# 刻意把這個機制去掉,改以介面 (interface) 進行有限制的多重繼承。從過往經驗可知過度地使用繼承,會增加程式碼的複雜度,使得專案難以維護。出自於工程上的考量,Go 捨去繼承這個語法特性。

為了補償沒有繼承的缺失,Go 加入了嵌入 (embedding) 這個新的語法特性,透過嵌入,也可以達到程式碼共享的功能。

例如,我們擴展 Point 類別至三維空間:

package main                                                 /*  1 */

import (                                                     /*  2 */
        "log"                                                /*  3 */
)                                                            /*  4 */

type Point struct {                                          /*  5 */
        x float64                                            /*  6 */
        y float64                                            /*  7 */
}                                                            /*  8 */

func NewPoint(x float64, y float64) *Point {                 /*  9 */
        p := new(Point)                                      /* 10 */

        p.SetX(x)                                            /* 11 */
        p.SetY(y)                                            /* 12 */

        return p                                             /* 13 */
}                                                            /* 14 */

func (p *Point) X() float64 {                                /* 15 */
        return p.x                                           /* 16 */
}                                                            /* 17 */

func (p *Point) Y() float64 {                                /* 18 */
        return p.y                                           /* 19 */
}                                                            /* 20 */

func (p *Point) SetX(x float64) {                            /* 21 */
        p.x = x                                              /* 22 */
}                                                            /* 23 */

func (p *Point) SetY(y float64) {                            /* 24 */
        p.y = y                                              /* 25 */
}                                                            /* 26 */

type Point3D struct {                                        /* 27 */
        // Point is embedded                                 /* 28 */
        Point                                                /* 29 */
        z float64                                            /* 30 */
}                                                            /* 31 */

func NewPoint3D(x float64, y float64, z float64) *Point3D {  /* 32 */
        p := new(Point3D)                                    /* 33 */

        p.SetX(x)                                            /* 34 */
        p.SetY(y)                                            /* 35 */
        p.SetZ(z)                                            /* 36 */

        return p                                             /* 37 */
}                                                            /* 38 */

func (p *Point3D) Z() float64 {                              /* 39 */
        return p.z                                           /* 40 */
}                                                            /* 41 */

func (p *Point3D) SetZ(z float64) {                          /* 42 */
        p.z = z                                              /* 43 */
}                                                            /* 44 */

func main() {                                                /* 45 */
        p := NewPoint3D(1, 2, 3)                             /* 46 */

        // GetX method is from Point                         /* 47 */
        if !(p.X() == 1) {                                   /* 48 */
                log.Fatal("Wrong value")                     /* 49 */
        }                                                    /* 50 */

        // GetY method is from Point                         /* 51 */
        if !(p.Y() == 2) {                                   /* 52 */
                log.Fatal("Wrong value")                     /* 53 */
        }                                                    /* 54 */

        // GetZ method is from Point3D                       /* 55 */
        if !(p.Z() == 3) {                                   /* 56 */
                log.Fatal("Wrong value")                     /* 57 */
        }                                                    /* 58 */
}                                                            /* 59 */

第 5 行至第 26 行是原本的 Point 類別,這和先前的實作是雷同的,不多做說明。

第 27 行至第 44 行是 Point3D 類別,我們來看一下這個類別。

第 27 行至第 31 行是 Point3D 的類別宣告。請注意我們在第 29 行嵌入了 Point 類別。

第 32 行至第 38 行是 Point3d 的建構函式。雖然我們沒有為 Point3D 宣告 SetX()SetY() method,但我們有嵌入 Point 類別,所以我們在第 34 行及第 35 行可以直接使用這些 method。

第 45 行至第 59 行是外部程式的部分。由於我們的 Point3D 內嵌了 Point,雖然 Point3D 沒有自己實作 X()Y() method,我們在第 48 行及第 52 行可直接呼叫這些 method。

在本例中,我們重用了 Point 的方法,再加入 Point3D 特有的方法。實際上的效果等同於繼承。

然而,PointPoint3D 兩者在類別關係上卻是不相干的獨立物件。在以下例子中,我們想將 Point3D 加入 Point 物件組成的切片,而引發程式的錯誤:

// Declare Point and Point3D as above.

func main() {
    points := make([]*Point, 0)

    p1 := NewPoint(3, 4)
    p2 := NewPoint3D(1, 2, 3)

    // Error!
    points = append(points, p1, p2)
}

在 Go 語言中,需要使用介面 (interface) 來解決這個議題,這就是我們下一篇文章所要探討的主題。

嵌入指標

除了嵌入其他結構外,結構也可以嵌入指標。我們將上例改寫如下:

package main

import (
    "log"
)

type Point struct {
    x float64
    y float64
}

func NewPoint(x float64, y float64) *Point {
    p := new(Point)

    p.SetX(x)
    p.SetY(y)

    return p
}

func (p *Point) X() float64 {
    return p.x
}

func (p *Point) Y() float64 {
    return p.y
}

func (p *Point) SetX(x float64) {
    p.x = x
}

func (p *Point) SetY(y float64) {
    p.y = y
}

type Point3D struct {
    // Point is embedded as a pointer
    *Point
    z float64
}

func NewPoint3D(x float64, y float64, z float64) *Point3D {
    p := new(Point3D)

    // Forward promotion
    p.Point = NewPoint(x, y)

    // Forward promotion
    p.Point.SetX(x)
    p.Point.SetY(y)

    p.SetZ(z)

    return p
}

func (p *Point3D) Z() float64 {
    return p.z
}

func (p *Point3D) SetZ(z float64) {
    p.z = z
}

func main() {
    p := NewPoint3D(1, 2, 3)

    // GetX method is from Point
    if !(p.X() == 1) {
        log.Fatal("Wrong value")
    }

    // GetY method is from Point
    if !(p.Y() == 2) {
        log.Fatal("Wrong value")
    }

    // GetZ method is from Point3D
    if !(p.Z() == 3) {
        log.Fatal("Wrong value")
    }
}

同樣地,仍然不能透過嵌入指楆讓型別直接互通,而需要透過介面 (interface)。

結語

在本文中,我們介紹了 Golang 的物件系統。相較於 C++ 或 Java 或 C#,Golang 的物件系統相對比較輕量,儘量不使用新的保留字,而用現用的語法來實現物件的特性。

Golang 的物件系統刻意拿掉繼承,改用嵌入來重用程式碼,這是由先前的程式語言中學習到的經驗和教訓。但嵌入無法實踐子類別 (subtyping),這個問題要等到我們下一篇講到的介面 (interface) 才有解。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。