結構與方法


在〈結構入門〉中看過,有些資料會有相關性,相關聯的資料組織在一起,對於資料本身的可用性或者是程式碼的可讀性,都會有所幫助,實際上,有些資料與可處理它的函式也會有相關性,將相關聯的資料與函式組織在一起,對資料與函式本身的可用性或者是程式碼的可讀性,也有著極大的幫助。

建立方法

假設可能原本有如下的程式內容,負責銀行帳戶的建立、存款與提款:

package main

import (
    "errors"
    "fmt"
)

type Account struct {
    id      string
    name    string
    balance float64
}

func Deposit(account *Account, amount float64) {
    if amount <= 0 {
        panic("必須存入正數")
    }
    account.balance += amount
}

func Withdraw(account *Account, amount float64) error {
    if amount > account.balance {
        return errors.New("餘額不足")
    }
    account.balance -= amount
    return nil
}

func String(account *Account) string {
    return fmt.Sprintf("Account{%s %s %.2f}",
        account.id, account.name, account.balance)
}

func main() {
    account := &Account{"1234-5678", "Justin Lin", 1000}
    Deposit(account, 500)
    Withdraw(account, 200)
    fmt.Println(String(account)) // Account{1234-5678 Justin Lin 1300.00}
}

實際上,DespositWithdrawString 的函式操作,都是與傳入的 Account 實例有關,何不將它們組織在一起呢?這樣比較容易使用些,在 Go 語言中,你可以重新修改函式如下:

package main

import (
    "errors"
    "fmt"
)

type Account struct {
    id      string
    name    string
    balance float64
}

func (ac *Account) Deposit(amount float64) {
    if amount <= 0 {
        panic("必須存入正數")
    }
    ac.balance += amount
}

func (ac *Account) Withdraw(amount float64) error {
    if amount > ac.balance {
        return errors.New("餘額不足")
    }
    ac.balance -= amount
    return nil
}

func (ac *Account) String() string {
    return fmt.Sprintf("Account{%s %s %.2f}",
        ac.id, ac.name, ac.balance)
}

func main() {
    account := &Account{"1234-5678", "Justin Lin", 1000}
    account.Deposit(500)
    account.Withdraw(200)
    fmt.Println(account.String()) // Account{1234-5678 Justin Lin 1300.00}
}

簡單來說,只是將函式的第一個參數,移至方法名稱之前成為函式呼叫的接收者(Receiver),這麼一來,就可以使用 account.Deposit(500)account.Withdraw(200)account.String() 這樣的方式來呼叫函式,就像是物件導向程式語言中的方法(Method)。

注意到,在這邊使用的是 (ac *Account),也就是指標,如果你是如下使用 (ac Account)

func (ac Account) Deposit(amount float64) {
    if amount <= 0 {
        panic("必須存入正數")
    }
    ac.balance += amount
}

那麼執行像是 account.Deposit(500),就像是以 Deposit(*account, 500) 呼叫以下函式:

func Deposit(account Account, amount float64) {
    if amount <= 0 {
        panic("必須存入正數")
    }
    account.balance += amount
}

也就是,相當於將 Account 實例以傳值方式複製給 Deposit 函式的參數。

某些程度上,可以將接收者想成是其他語言中的 thisselfGo 建議為接收者適當命名,而不是用 thisself 之類的名稱。接收者並沒有文件上記載的作用,命名時不用其他參數具有一定的描述性,只要能表達程式意圖就可以了,Go 建議是個一或兩個字母的名稱(某些程度上,也可以用來與其他參數區別)。

名稱相同的方法

之前談過,Go 語言中不允許方法重載(Overload),因此,對於以下的程式,是會發生 String 重複宣告的編譯錯誤:

package main

import "fmt"

type Account struct {
    id      string
    name    string
    balance float64
}

func String(account *Account) string {
    return fmt.Sprintf("Account{%s %s %.2f}",
        account.id, account.name, account.balance)
}

type Point struct {
    x, y int
}

func String(point *Point) string { // String redeclared in this block 的編譯錯誤
    return fmt.Sprintf("Point{%d %d}", point.x, point.y)
}

func main() {
    account := &Account{"1234-5678", "Justin Lin", 1000}
    point := &Point{10, 20}
    fmt.Println(account.String())
    fmt.Println(point.String())
}

然而,若是將函式定義為方法,就不會有這個問題,Go 可以從方法的接收者辨別,該使用哪個 String 方法:

package main

import "fmt"

type Account struct {
    id      string
    name    string
    balance float64
}

func (ac *Account) String() string {
    return fmt.Sprintf("Account{%s %s %.2f}",
        ac.id, ac.name, ac.balance)
}

type Point struct {
    x, y int
}

func (p *Point) String() string {
    return fmt.Sprintf("Point{%d %d}", p.x, p.y)
}

func main() {
    account := &Account{"1234-5678", "Justin Lin", 1000}
    point := &Point{10, 20}
    fmt.Println(account.String()) // Account{1234-5678 Justin Lin 1000.00}
    fmt.Println(point.String())   // Point{10 20}
}

方法作為值

在 Go 語言中,函式也可以作為值傳遞,那麼就產生了一個問題,方法呢?既然方法本質上也是個函式,那麼是否也可以作為值傳遞,答案是可以的,不過,以上面的程式為例,你不能直接以 String := String 這樣的方式傳遞,而必須使用方法運算式(Method expression)。例如:

package main

import (
    "errors"
    "fmt"
)

type Account struct {
    id      string
    name    string
    balance float64
}

func (ac *Account) Deposit(amount float64) {
    if amount <= 0 {
        panic("必須存入正數")
    }
    ac.balance += amount
}

func (ac *Account) Withdraw(amount float64) error {
    if amount > ac.balance {
        return errors.New("餘額不足")
    }
    ac.balance -= amount
    return nil
}

func (ac *Account) String() string {
    return fmt.Sprintf("Account{%s %s %.2f}",
        ac.id, ac.name, ac.balance)
}

func main() {
    deposit := (*Account).Deposit
    withdraw := (*Account).Withdraw
    String := (*Account).String

    account1 := &Account{"1234-5678", "Justin Lin", 1000}
    deposit(account1, 500)
    withdraw(account1, 200)
    fmt.Println(String(account1)) // Account{1234-5678 Justin Lin 1300.00}

    account2 := &Account{"5678-1234", "Monica Huang", 500}
    deposit(account2, 250)
    withdraw(account2, 150)
    fmt.Println(String(account2)) // Account{5678-1234 Monica Huang 600.00}
}

可以看到,這樣取得的函式,就像是本文一開始的範例那樣,你可以傳入任何的 Account 實例。另一個取得方法的方式是方法值(Method value),這會保有取得方法當時的接收者:

package main

import (
    "errors"
    "fmt"
)

type Account struct {
    id      string
    name    string
    balance float64
}

func (ac *Account) Deposit(amount float64) {
    if amount <= 0 {
        panic("必須存入正數")
    }
    ac.balance += amount
}

func (ac *Account) Withdraw(amount float64) error {
    if amount > ac.balance {
        return errors.New("餘額不足")
    }
    ac.balance -= amount
    return nil
}

func (ac *Account) String() string {
    return fmt.Sprintf("Account{%s %s %.2f}",
        ac.id, ac.name, ac.balance)
}

func main() {
    account1 := &Account{"1234-5678", "Justin Lin", 1000}
    acct1Deposit := account1.Deposit
    acct1Withdraw := account1.Withdraw
    acct1String := account1.String

    acct1Deposit(500)
    acct1Withdraw(200)
    fmt.Println(acct1String()) // Account{1234-5678 Justin Lin 1300.00}

    account2 := &Account{"5678-1234", "Monica Huang", 500}
    acct2Deposit := account2.Deposit
    acct2Withdraw := account2.Withdraw
    acct2String := account2.String

    acct2Deposit(250)
    acct2Withdraw(150)
    fmt.Println(acct2String()) // Account{5678-1234 Monica Huang 600.00}
}

值都能有方法

實際上,不只是結構的實例可以定義方法,在 Go 語言中,只要是值,就可以定義方法,條件是必須是定義的型態(defined type),具體而言,就是使用 type 定義的新型態。

例如,以下的範例為 []int 定義了一個新的型態名稱,並定義了一個 ForEach 方法:

package main

import "fmt"

type IntList []int
type Funcint func(int)

func (lt IntList) ForEach(f Funcint) {
    for _, ele := range lt {
        f(ele)
    }
}

func main() {
    var lt IntList = []int{10, 20, 30, 40, 50}
    lt.ForEach(func(ele int) {
        fmt.Println(ele)
    })
}

這個範例會顯示 10 到 50 作為結果,必須留意的是,type 定義了新型態 Funcint,因為 ForEach 是針對 Funcint 定義,而不是針對 []int,因此底下是行不通的:

lt2 := []int {10, 20, 30, 40, 50}

// lt2.ForEach undefined (type []int has no field or method ForEach)
lt2.ForEach(func(ele int) {
    fmt.Println(ele)
})

編譯器認為 []int 並沒有定義 ForEach,因此發生錯誤,想要通過編譯的話,可以進行型態轉換:

lt2 := IntList([]int {10, 20, 30, 40, 50})
lt2.ForEach(func(ele int) {
    fmt.Println(ele)
})

你甚至可以基於 int 等基本型態定義方法,同樣地,必須定義一個新的型態名稱:

package main

import (
    "fmt"
)

type Int int
type FuncInt func(Int)

func (n Int) Times(f FuncInt) {
    if n < 0 {
        panic("必須是正數")
    }

    var i Int
    for i = 0; i < n; i++ {
        f(i)
    }
}

func main() {
    var x Int = 10
    x.Times(func(n Int) {
        fmt.Println(n)
    })
}

像這樣基於某個基本型態定義新型態,並為其定義更多高階特性,在 Go 的領域是常見的做法。這個範例會顯示 0 到 9,看起來就像是指定函式,要求執行 x 次吧!…XD

nil 接收者

在 Go 中,接收者可以是 nil,這讓你有機會在方法中處理接收者為 nil 的情況,例如:

package main

import "fmt"

type Account struct {
    id      string
    name    string
    balance float64
}

func (ac *Account) String() string {
    if ac == nil {
        return "<nil>"
    }
    return fmt.Sprintf("Account{%s %s %.2f}",
        ac.id, ac.name, ac.balance)
}

func findById(id string) *Account {
    accts := []*Account{&Account{"123", "Justin Lin", 10000}, &Account{"456", "Monica", 10000}}
    for i := 0; i < len(accts); i++ {
        if accts[i].id == id {
            return accts[i]
        }
    }
    return nil
}

func main() {
    fmt.Println(findById("123").String())
    fmt.Println(findById("789").String())
}

如果是其他語言,例如 Java 的話,在 findById("789").String() 的地方會 NullPointerException,不過在 Go 中,可以針對接收者是否為 nil,來決定如何處理,例如這邊就實作了 nil safety 的概念。

模擬建構式、初始式

Go 沒有物件導向語言中建構式或初始式之類的概念,然而可以自行模擬,例如在 container/list原始碼可以看到 New 作為一個工廠函式,用來建立新的 List,初始的流程寫在 Init 方法之中:

...
// Init initializes or clears list l.
func (l *List) Init() *List {
    l.root.next = &l.root
    l.root.prev = &l.root
    l.len = 0
    return l
}

// New returns an initialized list.
func New() *List { return new(List).Init() }