「每週譯 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 的結構,包含字符串類型的 NameGreeting 字段。這個 Creature 結構體有一個定義的方法,即 Greet

在接收器聲明中,我們將 Creature 的實例分配給變量 c,以便我們在 fmt.Printf 中打印問候信息時可以引用 Creature 字段。

在其他編程語言中,方法調用的接收器通常用一個關鍵字來表示(例如:thisself)。Go 認爲接收器和其他變量一樣,是一個變量,所以你可以自由地命名。社區對這個參數的首選風格是接收器類型小寫版本的第一個字符。在這個例子中,我們使用了 c,因爲接收器的類型是 Creature

main 方法中,我們創建了一個 Creature 實例,併爲其 NameGreeting 字段進行賦值。我們在這裏調用了 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 變量上的 GreetSayGoodbye 方法,然後使用函數式調用方式。

兩種風格輸出的結果相同,但使用點號的例子更易讀。

點號調用鏈路還會告訴我們方法被調用的順序,而函數式則顛倒了這個順序。在 SayGoodbye 的調用中增加了一個參數,進一步模糊了方法調用的順序。

點號調用的清晰性是 Go 中調用方法的首選風格,無論是在標準庫中還是在整個 Go 生態的第三方包中都是如此。

相對於定義對某些值進行操作的方法,爲類型定義方法對 Go 編程語言還有其他特殊意義,方法是接口背後的核心概念。

接口

當你在 Go 中爲任何類型定義方法時,該方法會被添加到該類型的方法集中。

方法集是與該類型相關聯的方法的集合,並被 Go 編譯器用來確定某種類型是否可以分配給具有接口類型的變量。

接口類型是一種方法的規範,被編譯器用來保證一個類型會實現這些方法。

任何具有與接口定義中相同名稱、相同參數與相同返回值的方法類型都被稱爲實現了該接口,並允許被分配給具有該接口類型的變量。

下面是標準庫中 fmt.Stringer 接口的定義:

type Stringer interface {
  String() string
}

一個類型要實現 fmt.Stringer 接口,需要提供一個返回 stringString() 方法。

實現了這個接口,當你把你的類型實例傳遞給 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 作爲其參數之一時,會調用 OceanString 方法。

如果 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

這個例子定義了一個包含 NameoccupantsBoat 類型。

我們想規定其他包中的代碼只用 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 方法更容易重用,因爲你不必爲 SubmarineWhale 或任何其他我們還沒有想到的未來水生居民編寫新的 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