「每週譯 Go」在 Go 中定義方法
簡介
函數 _(點擊可跳轉查看)_允許你將邏輯組織成可重複的程序,每次運行時可以使用不同的參數。在定義函數的過程中,你常常會發現,可能會有多個函數每次對同一塊數據進行操作。
Go 可以識別這種模式,並允許您定義特殊的函數,稱爲方法,其目的是對某些特定類型(稱爲接收器)的實例進行操作。將方法添加到類型中,不僅可以傳達數據是什麼,還可以傳達如何使用這些數據。
定義一個方法
定義一個方法的語法與定義一個函數的語法很相似。
唯一的區別是在 func
關鍵字後面增加了一個額外的參數,用於指定方法的接收器。接收器是你希望定義的方法的類型聲明。
下面的例子爲一個結構體類型定義了一個方法:
package main
import "fmt"
type Creature struct {
Name string
Greeting string
}
func (c Creature) Greet() {
fmt.Printf("%s says %s", c.Name, c.Greeting)
}
func main() {
sammy := Creature{
Name: "Sammy",
Greeting: "Hello!",
}
Creature.Greet(sammy)
}
如果你運行這段代碼,輸出將是:
Output
Sammy says Hello!
我們創建了一個名爲 Creature
的結構,包含字符串類型的 Name
和 Greeting
字段。這個 Creature
結構體有一個定義的方法,即 Greet
。
在接收器聲明中,我們將 Creature
的實例分配給變量 c
,以便我們在 fmt.Printf
中打印問候信息時可以引用 Creature
字段。
在其他編程語言中,方法調用的接收器通常用一個關鍵字來表示(例如:this
或 self
)。Go 認爲接收器和其他變量一樣,是一個變量,所以你可以自由地命名。社區對這個參數的首選風格是接收器類型小寫版本的第一個字符。在這個例子中,我們使用了 c
,因爲接收器的類型是 Creature
。
在 main
方法中,我們創建了一個 Creature
實例,併爲其 Name
和 Greeting
字段進行賦值。我們在這裏調用了 Greet
方法,用 .
連接類型名和方法名,並提供 Creature
實例作爲第一個參數。
Go 提供了另一種更簡潔的方式來調用結構體實例的方法,如本例所示:
package main
import "fmt"
type Creature struct {
Name string
Greeting string
}
func (c Creature) Greet() {
fmt.Printf("%s says %s", c.Name, c.Greeting)
}
func main() {
sammy := Creature{
Name: "Sammy",
Greeting: "Hello!",
}
sammy.Greet()
}
如果你運行這個,輸出將與前面的例子相同:
Output
Sammy says Hello!
這個例子與前一個例子相同,但這次我們使用點號來調用 Greet
方法,使用存儲在 sammy
變量中的 Creature
作爲接收器,這是對第一個例子中的方法調用的簡化。
標準庫和 Go 社區更喜歡這種風格,以至於你很少看到前面所示的方法調用風格。
下一個例子展示了使用點號比較普遍的一個原因:
package main
import "fmt"
type Creature struct {
Name string
Greeting string
}
func (c Creature) Greet() Creature {
fmt.Printf("%s says %s!\n", c.Name, c.Greeting)
return c
}
func (c Creature) SayGoodbye(name string) {
fmt.Println("Farewell", name, "!")
}
func main() {
sammy := Creature{
Name: "Sammy",
Greeting: "Hello!",
}
sammy.Greet().SayGoodbye("gophers")
Creature.SayGoodbye(Creature.Greet(sammy), "gophers")
}
如果你運行這段代碼,輸出看起來像這樣:
Output
Sammy says Hello!!
Farewell gophers !
Sammy says Hello!!
Farewell gophers !
我們修改了前面的例子,引入了另一個名爲 SayGoodbye
的方法,並將 Greet
改爲返回一個 Creature
,這樣我們就可以對該實例調用更多的方法。
在 main
方法中,我們首先使用點號調用 sammy
變量上的 Greet
和 SayGoodbye
方法,然後使用函數式調用方式。
兩種風格輸出的結果相同,但使用點號的例子更易讀。
點號調用鏈路還會告訴我們方法被調用的順序,而函數式則顛倒了這個順序。在 SayGoodbye
的調用中增加了一個參數,進一步模糊了方法調用的順序。
點號調用的清晰性是 Go 中調用方法的首選風格,無論是在標準庫中還是在整個 Go 生態的第三方包中都是如此。
相對於定義對某些值進行操作的方法,爲類型定義方法對 Go 編程語言還有其他特殊意義,方法是接口背後的核心概念。
接口
當你在 Go 中爲任何類型定義方法時,該方法會被添加到該類型的方法集中。
方法集是與該類型相關聯的方法的集合,並被 Go 編譯器用來確定某種類型是否可以分配給具有接口類型的變量。
接口類型是一種方法的規範,被編譯器用來保證一個類型會實現這些方法。
任何具有與接口定義中相同名稱、相同參數與相同返回值的方法類型都被稱爲實現了該接口,並允許被分配給具有該接口類型的變量。
下面是標準庫中 fmt.Stringer
接口的定義:
type Stringer interface {
String() string
}
一個類型要實現 fmt.Stringer
接口,需要提供一個返回 string
的 String()
方法。
實現了這個接口,當你把你的類型實例傳遞給 fmt
包中定義的函數時,你的類型就可以完全按照你的意願被打印出來(有時稱爲 “pretty-printed”)。
下面的例子定義了一個實現了這個接口的類型:
package main
import (
"fmt"
"strings"
)
type Ocean struct {
Creatures []string
}
func (o Ocean) String() string {
return strings.Join(o.Creatures, ", ")
}
func log(header string, s fmt.Stringer) {
fmt.Println(header, ":", s)
}
func main() {
o := Ocean{
Creatures: []string{
"sea urchin",
"lobster",
"shark",
},
}
log("ocean contains", o)
}
當你運行該代碼時,你會看到這樣的輸出:
Output
ocean contains : sea urchin, lobster, shark
這個例子定義了一個名爲 Ocean
的新結構體類型。
Ocean
實現了 fmt.Stringer
接口,因爲 Ocean
定義了一個名爲 String
的方法,該方法不需要參數,返回一個 string
。在 main
方法中,我們定義了一個新的 Ocean
,並把它傳遞給一個 log
函數,該函數首先接收一個 string
來打印,然後是任何實現 fmt.Stringer
的參數。
Go 編譯器允許我們在這裏傳遞 o
,因爲 Ocean
實現了 fmt.Stringer
所要求的所有方法。在 log
中,我們使用 fmt.Println
,當它遇到 fmt.Stringer
作爲其參數之一時,會調用 Ocean
的 String
方法。
如果 Ocean
沒有實現 String()
方法,Go 會產生一個編譯錯誤,因爲 log
方法要求一個 fmt.Stringer
作爲其參數。
這個錯誤看起來像這樣:
Output
src/e4/main.go:24:6: cannot use o (type Ocean) as type fmt.Stringer in argument to log:
Ocean does not implement fmt.Stringer (missing String method)
Go 還將確保提供的 String()
方法與 fmt.Stringer
接口所要求的方法完全一致。如果不匹配,就會產生一個類似這樣的錯誤:
Output
src/e4/main.go:26:6: cannot use o (type Ocean) as type fmt.Stringer in argument to log:
Ocean does not implement fmt.Stringer (wrong type for String method)
have String()
want String() string
在到目前爲止的例子中,我們已經在值接收器上定義了方法。
也就是說,如果我們使用方法的功能調用,第一個參數(指的是方法所定義的類型)將是一個該類型的值,而不是一個 指針(點擊可跳轉查看)。
因此,我們對方法實例所做的任何修改都會在方法執行完畢後被丟棄,因爲收到的值是數據的副本。
此外,我們也可以在一個類型的指針接收器上定義方法。
指針接收器
在指針接收器上定義方法的語法與在值接收器上定義方法的語法幾乎相同。
不同的是在接收器聲明中用星號(*
)作爲類型名稱的前綴。
下面的例子在指針接收器上定義了一個類型的方法:
package main
import "fmt"
type Boat struct {
Name string
occupants []string
}
func (b *Boat) AddOccupant(name string) *Boat {
b.occupants = append(b.occupants, name)
return b
}
func (b Boat) Manifest() {
fmt.Println("The", b.Name, "has the following occupants:")
for _, n := range b.occupants {
fmt.Println("\t", n)
}
}
func main() {
b := &Boat{
Name: "S.S. DigitalOcean",
}
b.AddOccupant("Sammy the Shark")
b.AddOccupant("Larry the Lobster")
b.Manifest()
}
當你運行這個例子時,你會看到以下輸出:
Output
The S.S. DigitalOcean has the following occupants:
Sammy the Shark
Larry the Lobster
這個例子定義了一個包含 Name
和 occupants
的 Boat
類型。
我們想規定其他包中的代碼只用 AddOccupant
方法來添加乘員,所以我們通過小寫字段名的第一個字母使 occupants
字段不被導出。
我們還想確保調用 AddOccupant
會導致 Boat
實例被修改,這就是爲什麼我們通過指針接收器定義 AddOccupant
。
指針作爲一個類型的特定實例的引用,而不是該類型的副本。AddOccupant
將使用 Boat
類型的指針調用,可以保證任何修改都是持久的。
在 main
方法中,我們定義了一個新的變量 b
,它將持有一個指向 Boat
(*Boat
)的指針。我們在這個實例上調用了兩次 AddOccupant
方法來增加兩名乘客。
Manifest
方法是在Boat
值上定義的,因爲在其定義中,接收器被指定爲(b Boat
)。在 main
方法中,我們仍然能夠調用 Manifest
,因爲 Go 能夠自動解引用指針以獲得 Boat
值。b.Manifest()
在這裏等同於 (*b).Manifest()
。
當試圖爲接口類型的變量賦值時,一個方法是定義在一個指針接收器上還是定義在一個值接收器上有重要的影響。
指針接收器和接口
當你爲一個接口類型的變量賦值時,Go 編譯器會檢查被賦值類型的方法集,以確保它實現了所有接口方法。
指針接收器和值接收器的方法集是不同的,因爲接收指針的方法可以修改其接收器,而接收值的方法則不能。
下面的例子演示了定義兩個方法:
一個在一個類型的指針接收器上,一個在它的值接收器上。然而,只有指針接收器能夠滿足本例中也定義的接口。
package main
import "fmt"
type Submersible interface {
Dive()
}
type Shark struct {
Name string
isUnderwater bool
}
func (s Shark) String() string {
if s.isUnderwater {
return fmt.Sprintf("%s is underwater", s.Name)
}
return fmt.Sprintf("%s is on the surface", s.Name)
}
func (s *Shark) Dive() {
s.isUnderwater = true
}
func submerge(s Submersible) {
s.Dive()
}
func main() {
s := &Shark{
Name: "Sammy",
}
fmt.Println(s)
submerge(s)
fmt.Println(s)
}
當你運行該代碼時,你會看到這樣的輸出:
Output
Sammy is on the surface
Sammy is underwater
這個例子定義了一個叫做 Submersible
的接口,它要求類型實現一個 Dive()
方法。然後我們定義了一個包含 Name
字段 和 isUnderwater
方法的 Shark
類型來跟蹤 Shark
的狀態。
我們在 Shark
的指針接收器上定義了一個 Dive()
方法,將 isUnderwater
修改爲 true
。
我們還定義了值接收器的 String()
方法,這樣它就可以使用 fmt.Println
乾淨利落地打印出 Shark
的狀態,方法使用我們之前看過的 fmt.Println
所接收的 fmt.Stringer
接口。
我們還使用了一個函數 submerge
,它接受一個 Submersible
參數。
使用 Submersible
接口而不是 *Shark
允許 submerge
方法只依賴於一個類型所提供的行爲。這使得 submerge
方法更容易重用,因爲你不必爲 Submarine
、Whale
或任何其他我們還沒有想到的未來水生居民編寫新的 submerge
方法。只要它們定義了一個 Dive()
方法,就可以和 submerge
方法一起使用。
在 main
方法中,我們定義了一個變量 s
,它是一個指向 Shark
的指針,並立即用 fmt.Println
打印了 s
。這展示了輸出的第一部分,Sammy is on the surface
。我們把 s
傳給submerge
,然後再次調用 fmt.Println
,以 s
爲參數,看到輸出的第二部分,Sammy is underwater
。
如果我們把 s
改成 Shark
而不是 *Shark
,Go 編譯器會產生錯誤:
Output
cannot use s (type Shark) as type Submersible in argument to submerge:
Shark does not implement Submersible (Dive method has pointer receiver)
Go 編譯器很好心地告訴我們,Shark
確實有一個 Dive
方法,它只在指針接收器上定義。
當你在自己的代碼中看到這條信息時,解決方法是在分配值類型的變量前使用 &
操作符,傳遞一個指向接口類型的指針。
總結
在 Go 中聲明方法與定義接收不同類型變量的函數本質上沒有區別。同樣, 使用 指針 _(點擊可跳轉查看)_規則也適用。
Go 爲這種極其常見的函數定義提供了一些便利,並將這些方法收集到可以通過接口類型進行要求的方法集中。有效地使用方法可以讓你在代碼中使用接口來提高可測試性,併爲你的代碼的未來讀者留下更好的結構。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/2pw6Rix1tDqdsrwSLEQsoQ