06 結構
Go 的設計者對物件導向程式設計(OOP; object-oriented programming)的看法跟一般認知的 OOP 不大相同。
Go 沒有類別和繼承機制,但是有結構(struct),而且:
- 我們可以將任何函式附加(attach)至同一個 package 中的任何具象型別。換言之,如果函式和型別隸屬不同 package,那就不行。比如說,我們無法將自己寫的函式附加至 Go 標準函式庫的
time.Duration
。 - 型別能夠隱含地實作介面(無需明白宣告欲實作哪個介面)。
結構入門
以下範例先定義了一個 Person
結構,然後在程式中使用它。
// 定義一個結構類型
type Person struct {
Name string
Age int
}
func main() {
p1 := Person{} // 每個欄位都初始化為 0 或空值。
p2 := Person{"Michael", 25} // 按欄位宣告順序設定初值。
p3 := Person{ // 依欄位名稱設定初值。
Name: "Michael",
Age: 25, // 最後一律要加逗號!
}
fmt.Println(p1) // { 0}
fmt.Println(p2) // {Michael 25}
fmt.Println(p2 == p3) // true
}
Try it: https://go.dev/play/p/x-2GAFCIcG8
此範例展示了以 struct literal 語法來建立和初始化一個結構,共有三種寫法:
p1
:每個欄位都初始化為 0 或空值。p2
:按欄位宣告的順序逐一設定初值。此寫法不用寫欄位名稱,但必須給所有的欄位都提供初始值。p3
:依欄位名稱來設定初值,格式為name: value
,可以只設定部分欄位。由於這裡是每一個欄位寫成單獨一行,故每一行最後一律要加逗號,否則編譯器會視為語法錯誤(這是 Go 設計者貼心的地方)。如果全寫在同一行,則最後一個欄位的結尾處不用加逗號。
其他值得留意的地方:
- 這裡的結構型別
Person
以及結構成員(欄位)都是以英文大寫開頭,表示它們都會公開給其他套件使用。如果要限定同一套件才能使用,則名稱必須以英文小寫開頭來命名,例如person
。結構名稱與其成員欄位的名稱不見得採用一致的大小寫命名方式,例如結構型別可能以英文小寫開頭來命名(例如person
),亦即 unexported(不給其他套件使用),而結構成員以大寫英文開頭來命名(例如Age
)。 - 範例程式的最後一行用
==
來比較p2
和p3
,用來展示如何比較兩個結構的內容是否相同。不過,這種方法只適用於簡單的場合,對於比較複雜的情況,則需要改用其他方法甚至第三方套件。詳見稍後的小節:比較兩個結構是否相同。
底層型別
宣告一個具名型別時,必須指定另一個型別作為其「底層型別」(underlying type)。例如前面範例在宣告 Person
結構時,其底層型別是匿名的 struct
:
type Person struct {
Name string
Age int
}
這個底層型別也可以是具名型別,例如 Go 標準函式庫的 Duration
型別是這麼宣告的:
type Duration int64
這表示 Duration
的底層型別是 int64
。(參閱官方文件:type Duration)
使用結構指標
上一節的範例是使用所謂的 struct literal 語法來建立一個 Person
結構的實體。還有兩種建立結構的寫法:
- 使用地址運算子
&
。 - 使用關鍵字
new
。
範例:
p1 := &Person{Name: "Michael", Age: 25}
p2 := new(Person)
p2.Name = "Michael"
p2.Age = 25
以上兩種寫法都會得到一個指標,指向新建立的結構。也就是說,p1
和 p2
這兩個變數的型別都是「指向 Person 結構的指標」。
注意: 使用 new
來建立結構時,不能在同一行程式碼完成欄位的初始設定,而必須分開寫。
以下示範更多種寫法,並藉由執行結果來觀察變數的型別:
p1 := Person{"Michael", 25}
// p2, p3, p4, p5 全都是指向 Person 結構的指標,只是寫法不同。
p2 := &Person{"Michael", 25}
p3 := &Person{Name: "Michael", Age: 25}
p4 := &Person{
Name: "Michael",
Age: 25,
}
p5 := new(Person)
p5.Name = "Michael"
p5.Age = 25
fmt.Printf("p1: %v, type: %T\n", p1, p1)
fmt.Printf("p2: %v, type: %T\n", p2, p2)
fmt.Printf("p3: %v, type: %T\n", p3, p3)
fmt.Printf("p4: %v, type: %T\n", p4, p4)
fmt.Println(p1 == *p2) // true
fmt.Println(p2 == p5) // false
執行結果:
p1: {Michael 25}, type: main.Person
p2: &{Michael 25}, type: *main.Person
p3: &{Michael 25}, type: *main.Person
p4: &{Michael 25}, type: *main.Person
true
false
Try it: https://go.dev/play/p/8srsvYtUflh
說明:
- 範例中的
p3
和p4
只是為了示範不同寫法,最後將它們的內容和型別印出來並沒有特別用意,單純是因為要通過編譯。(變數宣告了就必須使用,否則無法通過編譯。) - 倒數第二行的
p1 == *p2
是比較兩個結構實體的內容。注意這裡的p2
前面要加*
,因為它本身只是個指標,必須用*
來表示它指向的實體。 - 最後一行程式碼輸出結果為
false
,因為p2
和p5
都是指標,所以p2 == p5
所比較的內容會是兩個變數的內容,亦即記憶體位址。p2
和p5
分別指向不同的結構實體,故二者指向的記憶體位址當然不同,故二者不相等。
匿名結構
匿名結構的使用時機:暫時性的場合,通常是在某個函式裡面只用到一次(故無需用費心替它命名)。
以下範例展示了如何使用匿名型別的結構,並且直接初始化。
func main() {
p1 := struct {
name string
age int
}{
name: "Michael",
age: 25,
}
fmt.Println(p1.name, p1.age)
}
結構的欄位也可以是函式
範例:
func main() {
animal := struct {
name string
speak func() string
} {
name: "cat",
speak: func() string {
return "meow"
},
}
fmt.Println(fmt.Sprintf("動物名稱是 %s,牠說 %s", animal.name, animal.speak() ))
}
為結構附加方法
上一節的範例是把函式加入結構的成員,這裡要示範的寫法有點像是替既有結構額外附加(擴充)一個方法。
type Animal struct {
name string
}
func (a Animal) speak() string {
switch a.name {
case "cat":
return "meow"
case "dog":
return "woof"
default:
return "nondescript animal noise?"
}
}
func main() {
a := Animal{
name: "cat",
}
fmt.Println(a.speak())
a.name = "dog"
fmt.Println(a.speak())
a.name = "llama"
fmt.Println(a.speak())#
}
像 func (a Animal) speak() string {
這樣的寫法稱為 "a method with a receiver"。事實上,「方法」(method)這個名詞在 Go 語言中是有正式定義的:
A method is a function with a receiver.
參見 The Go Programming Language Specification: Method declarations。
剛才的範例中,每次呼叫 a.speak()
時傳入的參數 a
都是一個新副本。如果想要讓 speak()
方法中修改原始傳入的 a
結構的內容,就要宣告成指標,像這樣:
func (a *Animal) speak() string {
...
}
這裡只需要修改一行程式碼而已,其他地方不變。
重點整理:
- 方法(methods)是帶有一個 receiver 的函式,而 receiver 是寫在函式名稱前面的一個特殊參數,該參數的型別則表明了這是哪個型別的方法。
- Receiver 有兩種:pointer receiver 和 value receiver。前者可以修改傳入物件的內容,後者不行。
熟悉物件導向程式語言的人可以把 receiver 參數理解為
this
或self
,即「當前的物件本身」。
結構成員可以匿名
type Animal struct {
string
}
欲存取沒有名稱的欄位,必須使用欄位的型別:
func main() {
a := Animal{
"cat",
}
func (a Animal) speak() {
log.Println(a.string)
}
fmt.Println(a.speak())
a.string = "dog"
fmt.Println(a.speak())
}
由於匿名欄位只能以其型別來存取,故這種寫法有個限制:只能有一個匿名欄位。
如果有給欄位命名,那麼即使只有一個欄位,也必須以名稱來存取該欄位,而不能用型別。
結構中的 tags
結構的欄位可以附加額外的描述資訊(metadata),稱為「標籤」(tags)。
Tags 的寫法是用一對 backtick 字元 ( ` ) 包住一組或多組 key: "value" 字串。每一組 key-value pair 是以空白字元隔開。
範例:
type Animal struct {
name string `help: "動物的種類或名稱,只要是貓或狗就行。"`
}
這裡替 name
欄位加上了一個 tag。該 tag 的 key 是 help
,而 value 是 "動物的...."
。
以下示範如何讀取欄位的 tag 內容:
func (a Animal) speak() string {
switch a.name {
case "cat":
return "meow"
case "dog":
return "woof"
default:
if member, ok := reflect.TypeOf(a).FieldByName("name"); ok {
return fmt.Sprintf("無效的動物名稱:%s", member.Tag.Get("help"))
}
return "nondescript animal noise?"
}
}
這裡使用了 Go 的 reflection 套件來取得結構的執行時期型別資訊,並以 FieldByName
來取得結構成員。取得結構成員之後,便可以透過它的 Tag.Get("help")
方法來取得 tag key 為 "help" 的內容。
範例:將 tags 用於 JSON 序列化
package main
import (
"fmt"
"encoding/json"
)
type Animal struct {
Name string `json:"animal_name"`
ScientificName string `json:"scientific_name"`
Weight float32 `json:"animal_average_weight"`
}
func main() {
a := Animal{
Name: "cat",
ScientificName: "Felis catus",
Weight: 10.5,
}
output, err := json.Marshal(a)
if err != nil {
panic("couldn't encode json")
}
fmt.Println(string(output))
}
請注意這裡的 Animal
結構的所有欄位成員的名稱開頭第一個字元都是大寫英文字母,表示它們是公開給任何程式碼存取。如果欄位名稱以小寫英文字母開頭,將導致 encoding/json
套件的函式無法存取它們。
程式的執行結果如下:
{"animal_name":"cat","scientific_name":"Felis catus","animal_average_weight":10.5}
比較兩個結構是否相同
欲比較兩個結構的內容(所有欄位)是否相等,Go 標準函式庫有提供 reflect.DeepEqual() 函式。不過,使用上可能不夠彈性,例如:
- 不允許浮點數的誤差。
- 結構中的未公開欄位(unexported fields)也會一併比較。
若碰到類似限制,可試試開源套件:go-cmp。
References
先這樣,也許有空時會再更新。 我的其他站點: