《Go 語言程式設計》讀書筆記 (三) 方法
- 2019 年 12 月 30 日
- 筆記
方法
方法聲明
在函數聲明時,在其名字之前放上一個變數,即是一個方法。這個附加的參數會將該函數附加到這種類型上,即相當於為這種類型定義了一個獨佔的方法。
package geometry import "math" type Point struct{ X, Y float64 } // traditional function func Distance(p, q Point) float64 { return math.Hypot(q.X-p.X, q.Y-p.Y) } // same thing, but as a method of the Point type func (p Point) Distance(q Point) float64 { return math.Hypot(q.X-p.X, q.Y-p.Y) }
上面的程式碼里那個附加的參數p,叫做方法的接收器(receiver)。在Go語言中,我們並不會像其它語言那樣用this或者self作為接收器;我們可以任意的選擇接收器的名字。建議是可以使用其類型的第一個字母,比如這裡使用了Point的首字母p。
在方法調用過程中,接收器參數一般會在方法名之前出現。這和方法聲明是一樣的,都是接收器參數在方法名字之前。下面是例子:
p := Point{1, 2} q := Point{4, 6} fmt.Println(Distance(p, q)) // "5", function call fmt.Println(p.Distance(q)) // "5", method call
可以看到,上面的兩個函數調用都是Distance,但是卻沒有發生衝突。第一個Distance的調用實際上用的是包級別的函數geometry.Distance,而第二個則是使用剛剛聲明的Point,調用的是Point類下聲明的Point.Distance方法。這種 p.Distance
的表達式叫做選擇器,因為他會選擇合適的對應p這個對象的 Distance
方法來執行。
因為每種類型都有其方法的命名空間,我們在用Distance這個名字的時候,不同的Distance調用指向了不同類型里的Distance方法。
// A Path is a journey connecting the points with straight lines. type Path []Point // Distance returns the distance traveled along the path. func (path Path) Distance() float64 { sum := 0.0 for i := range path { if i > 0 { sum += path[i-1].Distance(path[i]) } } return sum }
Path是一個命名的slice類型,而不是Point那樣的struct類型,然而我們依然可以為它定義方法。兩個Distance方法有不同的類型。他們兩個方法之間沒有任何關係,儘管Path的Distance方法會在內部調用 Point.Distance
方法來計算每個連接鄰接點的線段的長度。
Go和很多其它的面向對象的語言不太一樣。在Go語言里,我們可以為一些簡單的數值、字元串、slice、map來定義一些附加行為很方便。方法可以被聲明到任意類型,只要不是一個指針或者一個interface(接收者不能是一個指針類型,但是它可以是任何其他允許類型的指針)。
對於一個給定的類型,其內部的方法都必須有唯一的方法名,但是不同的類型卻可以有同樣的方法名,比如我們這裡Point和Path就都有Distance這個名字的方法;所以我們沒有必要非在方法名之前加類型名來消除歧義,比如PathDistance。在上面兩個對Distance名字的方法的調用中,編譯器會根據方法的名字以及接收器來決定具體調用的是哪一個函數。
指針對象的方法
當調用一個函數時,會對其每一個參數值進行拷貝,如果一個函數需要更新一個變數,或者函數的其中一個參數實在太大我們希望能夠避免進行這種默認的拷貝,這種情況下我們就需要用到指針了。對應到我們這裡用來更新接收器的對象的方法,當這個接受者變數本身比較大時,我們就可以用其指針而不是對象來聲明方法,如下:
func (p *Point) ScaleBy(factor float64) { p.X *= factor p.Y *= factor }
這個方法的名字是 (*Point).ScaleBy
。這裡的括弧是必須的;沒有括弧的話這個表達式可能會被理解為 *(Point.ScaleBy)
。
- 在現實的程式里,一般會約定如果Point這個類有一個指針作為接收器的方法,那麼所有Point的方法都必須有一個指針接收器,即使是那些並不需要這個指針接收器的函數。我們在這裡打破了這個約定只是為了展示一下兩種方法的異同而已。
- 不管你的method的receiver是指針類型還是非指針類型,都是可以通過指針/非指針類型進行調用的,編譯器會幫你做類型轉換。
p := Point{1, 2} pptr := &p p.ScaleBy(2) // implicit (&p) pptr.Distance(q) // implicit (*pptr)
- 在聲明一個method的receiver是指針還是非指針類型時,你需要考慮兩方面的內部,第一方面是這個對象本身是不是特別大,如果聲明為非指針變數時,調用會產生一次拷貝;第二方面是如果你用指針類型作為receiver,那麼你一定要注意,這種指針類型指向的始終是一塊記憶體地址,就算你對其進行了拷貝(指針調用時也是值拷貝,只不過指針的值是一個記憶體地址,所以在函數里的指針與調用方的指針變數是兩個不同的指針但是指向了相同的記憶體地址)。
Nil也是一個合法的接收器類型
- 就像一些函數允許nil指針作為參數一樣,方法理論上也可以用nil指針作為其接收器,尤其當nil對於對象來說是合法的零值時,比如map或者slice。在下面的簡單int鏈表的例子里,nil代表的是空鏈表:
// An IntList is a linked list of integers. // A nil *IntList represents the empty list. type IntList struct { Value int Tail *IntList } // Sum returns the sum of the list elements. func (list *IntList) Sum() int { if list == nil { return 0 } return list.Value + list.Tail.Sum() }
當你定義一個允許nil作為接收器的方法的類型時,在類型前面的注釋中指出nil變數代表的意義是很有必要的,就像我們上面例子里做的這樣。
通過嵌入結構體來擴展類型
- 下面的ColoredPoint類型
import "image/color" type Point struct{ X, Y float64 } type ColoredPoint struct { Point Color color.RGBA }
內嵌可以使我們在定義ColoredPoint時得到一種句法上的簡寫形式,並使其包含Point類型所具有的一切欄位和方法。
var cp ColoredPoint cp.X = 1 fmt.Println(cp.Point.X) // "1" cp.Point.Y = 2 fmt.Println(cp.Y) // "2" red := color.RGBA{255, 0, 0, 255} blue := color.RGBA{0, 0, 255, 255} var p = ColoredPoint{Point{1, 1}, red} var q = ColoredPoint{Point{5, 4}, blue} fmt.Println(p.Distance(q.Point)) // "5" p.ScaleBy(2) q.ScaleBy(2) fmt.Println(p.Distance(q.Point)) // "10"
通過內嵌結構體可以使我們定義欄位特別多的複雜類型,我們可以將欄位先按小類型分組,然後定義小類型的方法,之後再把它們組合起來。
- 內嵌欄位會指導編譯器去生成額外的包裝方法來委託已經聲明好的方法,和下面的形式是等價的:
func (p ColoredPoint) Distance(q Point) float64 { return p.Point.Distance(q) } func (p *ColoredPoint) ScaleBy(factor float64) { p.Point.ScaleBy(factor) }
當Point.Distance被第一個包裝方法調用時,它的接收器值是p.Point,而不是p,當然了,在Point類的方法里,你是訪問不到ColoredPoint的任何欄位的。
- 方法只能在命名類型(像Point)或者指向類型的指針上定義,但是多虧了內嵌,我們給匿名struct類型來定義方法也有了手段。這個例子中我們為變數起了一個更具表達性的名字:cache。因為sync.Mutex類型被嵌入到了這個struct里,其Lock和Unlock方法也就都被引入到了這個匿名結構中了,這讓我們能夠以一個簡單明了的語法來對其進行加鎖解鎖操作。
var cache = struct { sync.Mutex mapping map[string]string }{ mapping: make(map[string]string), } func Lookup(key string) string { cache.Lock() v := cache.mapping[key] cache.Unlock() return v }
方法值和方法表達式
- 我們經常選擇一個方法,並且在同一個表達式里執行,比如常見的p.Distance()形式,實際上將其分成兩步來執行也是可能的。p.Distance叫作「選擇器」,選擇器會返回一個方法"值"->一個將方法(Point.Distance)綁定到特定接收器變數的函數。因為已經在前文中指定過了,這個函數可以不通過指定其接收器即可被調用,只要傳入函數的參數即可:
p := Point{1, 2} q := Point{4, 6} distanceFromP := p.Distance // method value fmt.Println(distanceFromP(q)) // "5" var origin Point // {0, 0} fmt.Println(distanceFromP(origin)) // "2.23606797749979", sqrt(5) scaleP := p.ScaleBy // method value scaleP(2) // p becomes (2, 4) scaleP(3) // then (6, 12) scaleP(10) // then (60, 120)
- 當T是一個類型時,方法表達式可能會寫作T.f或者(*T).f,會返回一個函數"值",這種函數會將其第一個參數用作接收器,所以可以用通常(譯註:不寫選擇器)的方式來對其進行調用:
p := Point{1, 2} q := Point{4, 6} distance := Point.Distance // method expression fmt.Println(distance(p, q)) // "5" fmt.Printf("%Tn", distance) // "func(Point, Point) float64" scale := (*Point).ScaleBy scale(&p, 2) fmt.Println(p) // "{2 4}" fmt.Printf("%Tn", scale) // "func(*Point, float64)" // 譯註:這個Distance實際上是指定了Point對象為接收器的一個方法func (p Point) Distance(), // 但通過Point.Distance得到的函數需要比實際的Distance方法多一個參數, // 即其需要用第一個額外參數指定接收器,後面排列Distance方法的參數。
- 當你根據一個變數來決定調用同一個類型的哪個函數時,方法表達式就顯得很有用了。你可以根據選擇來調用接收器各不相同的方法。下面的例子,變數op代表Point類型的addition或者subtraction方法,Path.TranslateBy方法會為其Path數組中的每一個Point來調用對應的方法:
type Point struct{ X, Y float64 } func (p Point) Add(q Point) Point { return Point{p.X + q.X, p.Y + q.Y} } func (p Point) Sub(q Point) Point { return Point{p.X - q.X, p.Y - q.Y} } type Path []Point func (path Path) TranslateBy(offset Point, add bool) { var op func(p, q Point) Point if add { op = Point.Add } else { op = Point.Sub } for i := range path { // Call either path[i].Add(offset) or path[i].Sub(offset). path[i] = op(path[i], offset) } }
封裝
- 一個對象的變數或者方法如果對調用方是不可見的話,一般就被定義為「封裝」。封裝有時候也被叫做資訊隱藏,同時也是面向對象編程最關鍵的一個方面。
- Go語言只有一種控制可見性的手段:大寫首字母的標識符會從定義它們的包中被導出,小寫字母的則不會。
- 這種基於名字的手段使得在語言中最小的封裝單元是package,而不是像其它語言一樣的
Class
。一個struct類型的欄位對同一個包的所有程式碼都有可見性,無論你的程式碼是寫在一個函數還是一個方法里。 - 封裝提供了三方面的優點。首先,因為調用方不能直接修改對象的變數值,其只需要關注少量的語句並且只要弄懂少量變數的可能的值即可。 第二,隱藏實現的細節,可以防止調用方依賴那些可能變化的具體實現,這樣使設計包的程式設計師在不破壞對外的api情況下能得到更大的自由。 封裝的第三個優點也是最重要的優點,是阻止了外部調用方對對象內部的值任意地進行修改。因為對象內部變數只可以被同一個包內的函數修改,所以包的作者可以讓這些函數確保對象內部的一些值的不變性。比如下面的Counter類型允許調用方來增加counter變數的值,並且允許將這個值reset為0,但是不允許隨便設置這個值(譯註:因為壓根就訪問不到):
type Counter struct { n int } func (c *Counter) N() int { return c.n } func (c *Counter) Increment() { c.n++ } func (c *Counter) Reset() { c.n = 0 }
- 只用來訪問或修改內部變數的函數被稱為setter或者getter,例子如下,比如log包里的Logger類型對應的一些函數。在命名一個getter方法時,我們通常會省略掉前面的Get前綴。這種簡潔上的偏好也可以推廣到各種類型的前綴比如Fetch,Find或者Lookup。
package log type Logger struct { flags int prefix string // ... } func (l *Logger) Flags() int func (l *Logger) SetFlags(flag int) func (l *Logger) Prefix() string func (l *Logger) SetPrefix(prefix string)
- Go的編碼風格不禁止直接導出欄位。當然,一旦進行了導出,就沒有辦法在保證API兼容的情況下去除對其的導出,所以在一開始的選擇一定要經過深思熟慮並且要考慮到包內部的一些不變數的保證,還有未來可能的變化。