《Go 語言程式設計》讀書筆記 (三) 方法

KevinYan發表於2019-12-24

方法

方法宣告

在函式宣告時,在其名字之前放上一個變數,即是一個方法。這個附加的引數會將該函式附加到這種型別上,即相當於為這種型別定義了一個獨佔的方法。

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)繫結到特定接收器變數的函式。因為已經在前文中指定過了,這個函式可以不透過指定其接收器即可被呼叫,只要傳入函式的引數即可:

  ```go
  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("%T\n", distance) // "func(Point, Point) float64"
    
    scale := (*Point).ScaleBy
    scale(&p, 2)
    fmt.Println(p)            // "{2 4}"
    fmt.Printf("%T\n", 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相容的情況下去除對其的匯出,所以在一開始的選擇一定要經過深思熟慮並且要考慮到包內部的一些不變數的保證,還有未來可能的變化。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章