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

【分享本文】
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) 才有解。

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
    【追蹤新文章】
    Facebook Twitter Plurk