前言
傳統的程序式程式設計 (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
的屬性 X
和 Y
,這在物件導向上不是好的習慣,因為我們無法控管屬性,物件可能會產生預期外的行為,比較好的方法,是將屬性隱藏在物件內部,由公開方法去存取。我們在後文中會討論。
類別宣告不限定於結構體
雖然大部分的 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 行是類別宣告的部分。在這個版本的宣告中,我們將 x
和 y
改為小寫,代表該屬性是私有屬性,其可視度僅限於同一 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 語言中,沒有 this
或 self
這種代表物件的關鍵字,而是由程式設計者自訂代表物件的變數,在本例中,我們用 p
表示物件本身。透過這種帶有物件的函式宣告後,函式會和物件連動;在物件導向中,將這種和物件連動的函式稱為方法 (method)。
雖然在這個例子中,暫時無法直接看出使用方法的好處,比起直接操作屬性,透過私有屬性搭配公開方法帶來許多的益處。例如,如果我們希望 Point 在建立之後是唯讀的,我們只要將 SetX
和 SetY
改為私有方法即可。或者,我們希望限定 Point 所在的範圍為 0.0 至 1000.0,我們可以在 SetX
和 SetY
中檢查參數是否符合我們的要求。
靜態方法 (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
特有的方法。實際上的效果等同於繼承。
然而,Point
和 Point3D
兩者在類別關係上卻是不相干的獨立物件。在以下例子中,我們想將 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) 才有解。