# 第六章 方法
從90年代早期開始,面向對象編程(OOP)就成為了稱霸工程界和教育界的編程范式,所以之后幾乎所有大規模被應用的語言都包含了對OOP的支持,go語言也不例外。
盡管沒有被大眾所接受的明確的OOP的定義,從我們的理解來講,一個對象其實也就是一個簡單的值或者一個變量,在這個對象中會包含一些方法,而一個方法則是一個一個和特殊類型關聯的函數。一個面向對象的程序會用方法來表達其屬性和對應的操作,這樣使用這個對象的用戶就不需要直接去操作對象,而是借助方法來做這些事情。
在早些的章節中,我們已經使用了標準庫提供的一些方法,比如time.Duration這個類型的Seconds方法:
~~~
const day = 24 * time.Hour
fmt.Println(day.Seconds()) // "86400"
~~~
并且在2.5節中,我們定義了一個自己的方法,Celsius類型的String方法:
~~~
func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
~~~
在本章中,OOP編程的第一方面,我們會向你展示如何有效地定義和使用方法。我們會覆蓋到OOP編程的兩個關鍵點,封裝和組合。
### 6.1. 方法聲明
在函數聲明時,在其名字之前放上一個變量,即是一個方法。這個附加的參數會將該函數附加到這種類型上,即相當于為這種類型定義了一個獨占的方法。
下面來寫我們第一個方法的例子,這個例子在package geometry下:
*gopl.io/ch6/geometry*
~~~
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方法來執行。選擇器也會被用來選擇一個struct類型的字段,比如p.X。由于方法和字段都是在同一命名空間,所以如果我們在這里聲明一個X方法的話,編譯器會報錯,因為在調用p.X時會有歧義(譯注:這里確實挺奇怪的)。
因為每種類型都有其方法的命名空間,我們在用Distance這個名字的時候,不同的Distance調用指向了不同類型里的Distance方法。讓我們來定義一個Path類型,這個Path代表一個線段的集合,并且也給這個Path定義一個叫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類型,然而我們依然可以為它定義方法。在能夠給任意類型定義方法這一點上,Go和很多其它的面向對象的語言不太一樣。因此在Go語言里,我們為一些簡單的數值、字符串、slice、map來定義一些附加行為很方便。方法可以被聲明到任意類型,只要不是一個指針或者一個interface。
兩個Distance方法有不同的類型。他們兩個方法之間沒有任何關系,盡管Path的Distance方法會在內部調用Point.Distance方法來計算每個連接鄰接點的線段的長度。
讓我們來調用一個新方法,計算三角形的周長:
~~~
perim := Path{
{1, 1},
{5, 1},
{5, 4},
{1, 1},
}
fmt.Println(perim.Distance()) // "12"
~~~
在上面兩個對Distance名字的方法的調用中,編譯器會根據方法的名字以及接收器來決定具體調用的是哪一個函數。第一個例子中path[i-1]數組中的類型是Point,因此Point.Distance這個方法被調用;在第二個例子中perim的類型是Path,因此Distance調用的是Path.Distance。
對于一個給定的類型,其內部的方法都必須有唯一的方法名,但是不同的類型卻可以有同樣的方法名,比如我們這里Point和Path就都有Distance這個名字的方法;所以我們沒有必要非在方法名之前加類型名來消除歧義,比如PathDistance。這里我們已經看到了方法比之函數的一些好處:方法名可以簡短。當我們在包外調用的時候這種好處就會被放大,因為我們可以使用這個短名字,而可以省略掉包的名字,下面是例子:
~~~
import "gopl.io/ch6/geometry"
perim := geometry.Path{{1, 1}, {5, 1}, {5, 4}, {1, 1}}
fmt.Println(geometry.PathDistance(perim)) // "12", standalone function
fmt.Println(perim.Distance()) // "12", method of geometry.Path
~~~
**譯注:** 如果我們要用方法去計算perim的distance,還需要去寫全geometry的包名,和其函數名,但是因為Path這個變量定義了一個可以直接用的Distance方法,所以我們可以直接寫perim.Distance()。相當于可以少打很多字,作者應該是這個意思。因為在Go里包外調用函數需要帶上包名,還是挺麻煩的。
### 6.2. 基于指針對象的方法
當調用一個函數時,會對其每一個參數值進行拷貝,如果一個函數需要更新一個變量,或者函數的其中一個參數實在太大我們希望能夠避免進行這種默認的拷貝,這種情況下我們就需要用到指針了。對應到我們這里用來更新接收器的對象的方法,當這個接受者變量本身比較大時,我們就可以用其指針而不是對象來聲明方法,如下:
~~~
func (p *Point) ScaleBy(factor float64) {
p.X *= factor
p.Y *= factor
}
~~~
這個方法的名字是`(*Point).ScaleBy`。這里的括號是必須的;沒有括號的話這個表達式可能會被理解為`*(Point.ScaleBy)`。
在現實的程序里,一般會約定如果Point這個類有一個指針作為接收器的方法,那么所有Point的方法都必須有一個指針接收器,即使是那些并不需要這個指針接收器的函數。我們在這里打破了這個約定只是為了展示一下兩種方法的異同而已。
只有類型(Point)和指向他們的指針(*Point),才是可能會出現在接收器聲明里的兩種接收器。此外,為了避免歧義,在聲明方法時,如果一個類型名本身是一個指針的話,是不允許其出現在接收器中的,比如下面這個例子:
~~~
type P *int
func (P) f() { /* ... */ } // compile error: invalid receiver type
~~~
想要調用指針類型方法`(*Point).ScaleBy`,只要提供一個Point類型的指針即可,像下面這樣。
~~~
r := &Point{1, 2}
r.ScaleBy(2)
fmt.Println(*r) // "{2, 4}"
~~~
或者這樣:
~~~
p := Point{1, 2}
pptr := &p
pptr.ScaleBy(2)
fmt.Println(p) // "{2, 4}"
~~~
或者這樣:
~~~
p := Point{1, 2}
(&p).ScaleBy(2)
fmt.Println(p) // "{2, 4}"
~~~
不過后面兩種方法有些笨拙。幸運的是,go語言本身在這種地方會幫到我們。如果接收器p是一個Point類型的變量,并且其方法需要一個Point指針作為接收器,我們可以用下面這種簡短的寫法:
~~~
p.ScaleBy(2)
~~~
編譯器會隱式地幫我們用&p去調用ScaleBy這個方法。這種簡寫方法只適用于“變量”,包括struct里的字段比如p.X,以及array和slice內的元素比如perim[0]。我們不能通過一個無法取到地址的接收器來調用指針方法,比如臨時變量的內存地址就無法獲取得到:
~~~
Point{1, 2}.ScaleBy(2) // compile error: can't take address of Point literal
~~~
但是我們可以用一個`*Point`這樣的接收器來調用Point的方法,因為我們可以通過地址來找到這個變量,只要用解引用符號`*`來取到該變量即可。編譯器在這里也會給我們隱式地插入`*`這個操作符,所以下面這兩種寫法等價的:
~~~
pptr.Distance(q)
(*pptr).Distance(q)
~~~
這里的幾個例子可能讓你有些困惑,所以我們總結一下:在每一個合法的方法調用表達式中,也就是下面三種情況里的任意一種情況都是可以的:
不論是接收器的實際參數和其接收器的形式參數相同,比如兩者都是類型T或者都是類型`*T`:
~~~
Point{1, 2}.Distance(q) // Point
pptr.ScaleBy(2) // *Point
~~~
或者接收器形參是類型T,但接收器實參是類型`*T`,這種情況下編譯器會隱式地為我們取變量的地址:
~~~
p.ScaleBy(2) // implicit (&p)
~~~
或者接收器形參是類型`*T`,實參是類型T。編譯器會隱式地為我們解引用,取到指針指向的實際變量:
~~~
pptr.Distance(q) // implicit (*pptr)
~~~
如果類型T的所有方法都是用T類型自己來做接收器(而不是`*T`),那么拷貝這種類型的實例就是安全的;調用他的任何一個方法也就會產生一個值的拷貝。比如time.Duration的這個類型,在調用其方法時就會被全部拷貝一份,包括在作為參數傳入函數的時候。但是如果一個方法使用指針作為接收器,你需要避免對其進行拷貝,因為這樣可能會破壞掉該類型內部的不變性。比如你對bytes.Buffer對象進行了拷貝,那么可能會引起原始對象和拷貝對象只是別名而已,但實際上其指向的對象是一致的。緊接著對拷貝后的變量進行修改可能會有讓你意外的結果。
**譯注:** 作者這里說的比較繞,其實有兩點:
1. 不管你的method的receiver是指針類型還是非指針類型,都是可以通過指針/非指針類型進行調用的,編譯器會幫你做類型轉換。
1. 在聲明一個method的receiver該是指針還是非指針類型時,你需要考慮兩方面的內部,第一方面是這個對象本身是不是特別大,如果聲明為非指針變量時,調用會產生一次拷貝;第二方面是如果你用指針類型作為receiver,那么你一定要注意,這種指針類型指向的始終是一塊內存地址,就算你對其進行了拷貝。熟悉C或者C艸的人這里應該很快能明白。
### 6.2.1. 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變量代表的意義是很有必要的,就像我們上面例子里做的這樣。
下面是net/url包里Values類型定義的一部分。
*net/url*
~~~
package url
// Values maps a string key to a list of values.
type Values map[string][]string
// Get returns the first value associated with the given key,
// or "" if there are none.
func (v Values) Get(key string) string {
if vs := v[key]; len(vs) > 0 {
return vs[0]
}
return ""
}
// Add adds the value to key.
// It appends to any existing values associated with key.
func (v Values) Add(key, value string) {
v[key] = append(v[key], value)
}
~~~
這個定義向外部暴露了一個map的類型的變量,并且提供了一些能夠簡單操作這個map的方法。這個map的value字段是一個string的slice,所以這個Values是一個多維map。客戶端使用這個變量的時候可以使用map固有的一些操作(make,切片,m[key]等等),也可以使用這里提供的操作方法,或者兩者并用,都是可以的:
*gopl.io/ch6/urlvalues*
~~~
m := url.Values{"lang": {"en"}} // direct construction
m.Add("item", "1")
m.Add("item", "2")
fmt.Println(m.Get("lang")) // "en"
fmt.Println(m.Get("q")) // ""
fmt.Println(m.Get("item")) // "1" (first value)
fmt.Println(m["item"]) // "[1 2]" (direct map access)
m = nil
fmt.Println(m.Get("item")) // ""
m.Add("item", "3") // panic: assignment to entry in nil map
~~~
對Get的最后一次調用中,nil接收器的行為即是一個空map的行為。我們可以等價地將這個操作寫成Value(nil).Get("item"),但是如果你直接寫nil.Get("item")的話是無法通過編譯的,因為nil的字面量編譯器無法判斷其準備類型。所以相比之下,最后的那行m.Add的調用就會產生一個panic,因為他嘗試更新一個空map。
由于url.Values是一個map類型,并且間接引用了其key/value對,因此url.Values.Add對這個map里的元素做任何的更新、刪除操作對調用方都是可見的。實際上,就像在普通函數中一樣,雖然可以通過引用來操作內部值,但在方法想要修改引用本身是不會影響原始值的,比如把他置為nil,或者讓這個引用指向了其它的對象,調用方都不會受影響。(譯注:因為傳入的是存儲了內存地址的變量,你改變這個變量是影響不了原始的變量的,想想C語言,是差不多的)
### 6.3. 通過嵌入結構體來擴展類型
來看看ColoredPoint這個類型:
*gopl.io/ch6/coloredpoint*
~~~
import "image/color"
type Point struct{ X, Y float64 }
type ColoredPoint struct {
Point
Color color.RGBA
}
~~~
我們完全可以將ColoredPoint定義為一個有三個字段的struct,但是我們卻將Point這個類型嵌入到ColoredPoint來提供X和Y這兩個字段。像我們在4.4節中看到的那樣,內嵌可以使我們在定義ColoredPoint時得到一種句法上的簡寫形式,并使其包含Point類型所具有的一切字段,然后再定義一些自己的。如果我們想要的話,我們可以直接認為通過嵌入的字段就是ColoredPoint自身的字段,而完全不需要在調用時指出Point,比如下面這樣。
~~~
var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X) // "1"
cp.Point.Y = 2
fmt.Println(cp.Y) // "2"
~~~
對于Point中的方法我們也有類似的用法,我們可以把ColoredPoint類型當作接收器來調用Point里的方法,即使ColoredPoint里沒有聲明這些方法:
~~~
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"
~~~
Point類的方法也被引入了ColoredPoint。用這種方式,內嵌可以使我們定義字段特別多的復雜類型,我們可以將字段先按小類型分組,然后定義小類型的方法,之后再把它們組合起來。
讀者如果對基于類來實現面向對象的語言比較熟悉的話,可能會傾向于將Point看作一個基類,而ColoredPoint看作其子類或者繼承類,或者將ColoredPoint看作"is a" Point類型。但這是錯誤的理解。請注意上面例子中對Distance方法的調用。Distance有一個參數是Point類型,但q并不是一個Point類,所以盡管q有著Point這個內嵌類型,我們也必須要顯式地選擇它。嘗試直接傳q的話你會看到下面這樣的錯誤:
~~~
p.Distance(q) // compile error: cannot use q (ColoredPoint) as Point
~~~
一個ColoredPoint并不是一個Point,但他"has a"Point,并且它有從Point類里引入的Distance和ScaleBy方法。如果你喜歡從實現的角度來考慮問題,內嵌字段會指導編譯器去生成額外的包裝方法來委托已經聲明好的方法,和下面的形式是等價的:
~~~
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的任何字段的。
在類型中內嵌的匿名字段也可能是一個命名類型的指針,這種情況下字段和方法會被間接地引入到當前的類型中(譯注:訪問需要通過該指針指向的對象去取)。添加這一層間接關系讓我們可以共享通用的結構并動態地改變對象之間的關系。下面這個ColoredPoint的聲明內嵌了一個*Point的指針。
~~~
type ColoredPoint struct {
*Point
Color color.RGBA
}
p := ColoredPoint{&Point{1, 1}, red}
q := ColoredPoint{&Point{5, 4}, blue}
fmt.Println(p.Distance(*q.Point)) // "5"
q.Point = p.Point // p and q now share the same Point
p.ScaleBy(2)
fmt.Println(*p.Point, *q.Point) // "{2 2} {2 2}"
~~~
一個struct類型也可能會有多個匿名字段。我們將ColoredPoint定義為下面這樣:
~~~
type ColoredPoint struct {
Point
color.RGBA
}
~~~
然后這種類型的值便會擁有Point和RGBA類型的所有方法,以及直接定義在ColoredPoint中的方法。當編譯器解析一個選擇器到方法時,比如p.ScaleBy,它會首先去找直接定義在這個類型里的ScaleBy方法,然后找被ColoredPoint的內嵌字段們引入的方法,然后去找Point和RGBA的內嵌字段引入的方法,然后一直遞歸向下找。如果選擇器有二義性的話編譯器會報錯,比如你在同一級里有兩個同名的方法。
方法只能在命名類型(像Point)或者指向類型的指針上定義,但是多虧了內嵌,有些時候我們給匿名struct類型來定義方法也有了手段。
下面是一個小trick。這個例子展示了簡單的cache,其使用兩個包級別的變量來實現,一個mutex互斥量(§9.2)和它所操作的cache:
~~~
var (
mu sync.Mutex // guards mapping
mapping = make(map[string]string)
)
func Lookup(key string) string {
mu.Lock()
v := mapping[key]
mu.Unlock()
return v
}
~~~
下面這個版本在功能上是一致的,但將兩個包級吧的變量放在了cache這個struct一組內:
~~~
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
}
~~~
我們給新的變量起了一個更具表達性的名字:cache。因為sync.Mutex字段也被嵌入到了這個struct里,其Lock和Unlock方法也就都被引入到了這個匿名結構中了,這讓我們能夠以一個簡單明了的語法來對其進行加鎖解鎖操作。
### 6.4. 方法值和方法表達式
我們經常選擇一個方法,并且在同一個表達式里執行,比如常見的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)
~~~
在一個包的API需要一個函數值、且調用方希望操作的是某一個綁定了對象的方法的話,方法"值"會非常實用(=_=真是繞)。舉例來說,下面例子中的time.AfterFunc這個函數的功能是在指定的延遲時間之后來執行一個(譯注:另外的)函數。且這個函數操作的是一個Rocket對象r
~~~
type Rocket struct { /* ... */ }
func (r *Rocket) Launch() { /* ... */ }
r := new(Rocket)
time.AfterFunc(10 * time.Second, func() { r.Launch() })
~~~
直接用方法"值"傳入AfterFunc的話可以更為簡短:
~~~
time.AfterFunc(10 * time.Second, r.Launch)
~~~
譯注:省掉了上面那個例子里的匿名函數。
和方法"值"相關的還有方法表達式。當調用一個方法時,與調用一個普通的函數相比,我們必須要用選擇器(p.Distance)語法來指定方法的接收器。
當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)
}
}
~~~
### 6.5. 示例: Bit數組
Go語言里的集合一般會用map[T]bool這種形式來表示,T代表元素類型。集合用map類型來表示雖然非常靈活,但我們可以以一種更好的形式來表示它。例如在數據流分析領域,集合元素通常是一個非負整數,集合會包含很多元素,并且集合會經常進行并集、交集操作,這種情況下,bit數組會比map表現更加理想。(譯注:這里再補充一個例子,比如我們執行一個http下載任務,把文件按照16kb一塊劃分為很多塊,需要有一個全局變量來標識哪些塊下載完成了,這種時候也需要用到bit數組)
一個bit數組通常會用一個無符號數或者稱之為“字”的slice或者來表示,每一個元素的每一位都表示集合里的一個值。當集合的第i位被設置時,我們才說這個集合包含元素i。下面的這個程序展示了一個簡單的bit數組類型,并且實現了三個函數來對這個bit數組來進行操作:
*gopl.io/ch6/intset*
~~~
// An IntSet is a set of small non-negative integers.
// Its zero value represents the empty set.
type IntSet struct {
words []uint64
}
// Has reports whether the set contains the non-negative value x.
func (s *IntSet) Has(x int) bool {
word, bit := x/64, uint(x%64)
return word < len(s.words) && s.words[word]&(1<<bit) != 0
}
// Add adds the non-negative value x to the set.
func (s *IntSet) Add(x int) {
word, bit := x/64, uint(x%64)
for word >= len(s.words) {
s.words = append(s.words, 0)
}
s.words[word] |= 1 << bit
}
// UnionWith sets s to the union of s and t.
func (s *IntSet) UnionWith(t *IntSet) {
for i, tword := range t.words {
if i < len(s.words) {
s.words[i] |= tword
} else {
s.words = append(s.words, tword)
}
}
}
~~~
因為每一個字都有64個二進制位,所以為了定位x的bit位,我們用了x/64的商作為字的下標,并且用x%64得到的值作為這個字內的bit的所在位置。UnionWith這個方法里用到了bit位的“或”邏輯操作符號|來一次完成64個元素的或計算。(在練習6.5中我們還會程序用到這個64位字的例子。)
當前這個實現還缺少了很多必要的特性,我們把其中一些作為練習題列在本小節之后。但是有一個方法如果缺失的話我們的bit數組可能會比較難混:將IntSet作為一個字符串來打印。這里我們來實現它,讓我們來給上面的例子添加一個String方法,類似2.5節中做的那樣:
~~~
// String returns the set as a string of the form "{1 2 3}".
func (s *IntSet) String() string {
var buf bytes.Buffer
buf.WriteByte('{')
for i, word := range s.words {
if word == 0 {
continue
}
for j := 0; j < 64; j++ {
if word&(1<<uint(j)) != 0 {
if buf.Len() > len("{") {
buf.WriteByte('}')
}
fmt.Fprintf(&buf, "%d", 64*i+j)
}
}
}
buf.WriteByte('}')
return buf.String()
}
~~~
這里留意一下String方法,是不是和3.5.4節中的intsToString方法很相似;bytes.Buffer在String方法里經常這么用。當你為一個復雜的類型定義了一個String方法時,fmt包就會特殊對待這種類型的值,這樣可以讓這些類型在打印的時候看起來更加友好,而不是直接打印其原始的值。fmt會直接調用用戶定義的String方法。這種機制依賴于接口和類型斷言,在第7章中我們會詳細介紹。
現在我們就可以在實戰中直接用上面定義好的IntSet了:
~~~
var x, y IntSet
x.Add(1)
x.Add(144)
x.Add(9)
fmt.Println(x.String()) // "{1 9 144}"
y.Add(9)
y.Add(42)
fmt.Println(y.String()) // "{9 42}"
x.UnionWith(&y)
fmt.Println(x.String()) // "{1 9 42 144}"
fmt.Println(x.Has(9), x.Has(123)) // "true false"
~~~
這里要注意:我們聲明的String和Has兩個方法都是以指針類型*IntSet來作為接收器的,但實際上對于這兩個類型來說,把接收器聲明為指針類型也沒什么必要。不過另外兩個函數就不是這樣了,因為另外兩個函數操作的是s.words對象,如果你不把接收器聲明為指針對象,那么實際操作的是拷貝對象,而不是原來的那個對象。因此,因為我們的String方法定義在IntSet指針上,所以當我們的變量是IntSet類型而不是IntSet指針時,可能會有下面這樣讓人意外的情況:
~~~
fmt.Println(&x) // "{1 9 42 144}"
fmt.Println(x.String()) // "{1 9 42 144}"
fmt.Println(x) // "{[4398046511618 0 65536]}"
~~~
在第一個Println中,我們打印一個*IntSet的指針,這個類型的指針確實有自定義的String方法。第二Println,我們直接調用了x變量的String()方法;這種情況下編譯器會隱式地在x前插入&操作符,這樣相當遠我們還是調用的IntSet指針的String方法。在第三個Println中,因為IntSet類型沒有String方法,所以Println方法會直接以原始的方式理解并打印。所以在這種情況下&符號是不能忘的。在我們這種場景下,你把String方法綁定到IntSet對象上,而不是IntSet指針上可能會更合適一些,不過這也需要具體問題具體分析。
練習6.1: 為bit數組實現下面這些方法
~~~
func (*IntSet) Len() int // return the number of elements
func (*IntSet) Remove(x int) // remove x from the set
func (*IntSet) Clear() // remove all elements from the set
func (*IntSet) Copy() *IntSet // return a copy of the set
~~~
**練習 6.2:** 定義一個變參方法(*IntSet).AddAll(...int),這個方法可以為一組IntSet值求和,比如s.AddAll(1,2,3)。
**練習 6.3:** (*IntSet).UnionWith會用|操作符計算兩個集合的交集,我們再為IntSet實現另外的幾個函數IntersectWith(交集:元素在A集合B集合均出現),DifferenceWith(差集:元素出現在A集合,未出現在B集合),SymmetricDifference(并差集:元素出現在A但沒有出現在B,或者出現在B沒有出現在A)。 練習6.4: 實現一個Elems方法,返回集合中的所有元素,用于做一些range之類的遍歷操作。
**練習 6.5:** 我們這章定義的IntSet里的每個字都是用的uint64類型,但是64位的數值可能在32位的平臺上不高效。修改程序,使其使用uint類型,這種類型對于32位平臺來說更合適。當然了,這里我們可以不用簡單粗暴地除64,可以定義一個常量來決定是用32還是64,這里你可能會用到平臺的自動判斷的一個智能表達式:32 << (^uint(0) >> 63)
### 6.6. 封裝
一個對象的變量或者方法如果對調用方是不可見的話,一般就被定義為“封裝”。封裝有時候也被叫做信息隱藏,同時也是面向對象編程最關鍵的一個方面。
Go語言只有一種控制可見性的手段:大寫首字母的標識符會從定義它們的包中被導出,小寫字母的則不會。這種限制包內成員的方式同樣適用于struct或者一個類型的方法。因而如果我們想要封裝一個對象,我們必須將其定義為一個struct。
這也就是前面的小節中IntSet被定義為struct類型的原因,盡管它只有一個字段:
~~~
type IntSet struct {
words []uint64
}
~~~
當然,我們也可以把IntSet定義為一個slice類型,盡管這樣我們就需要把代碼中所有方法里用到的s.words用*s替換掉了:
~~~
type IntSet []uint64
~~~
盡管這個版本的IntSet在本質上是一樣的,他也可以允許其它包中可以直接讀取并編輯這個slice。換句話說,相對*s這個表達式會出現在所有的包中,s.words只需要在定義IntSet的包中出現(譯注:所以還是推薦后者吧的意思)。
這種基于名字的手段使得在語言中最小的封裝單元是package,而不是像其它語言一樣的類型。一個struct類型的字段對同一個包的所有代碼都有可見性,無論你的代碼是寫在一個函數還是一個方法里。
封裝提供了三方面的優點。首先,因為調用方不能直接修改對象的變量值,其只需要關注少量的語句并且只要弄懂少量變量的可能的值即可。
第二,隱藏實現的細節,可以防止調用方依賴那些可能變化的具體實現,這樣使設計包的程序員在不破壞對外的api情況下能得到更大的自由。
把bytes.Buffer這個類型作為例子來考慮。這個類型在做短字符串疊加的時候很常用,所以在設計的時候可以做一些預先的優化,比如提前預留一部分空間,來避免反復的內存分配。又因為Buffer是一個struct類型,這些額外的空間可以用附加的字節數組來保存,且放在一個小寫字母開頭的字段中。這樣在外部的調用方只能看到性能的提升,但并不會得到這個附加變量。Buffer和其增長算法我們列在這里,為了簡潔性稍微做了一些精簡:
~~~
type Buffer struct {
buf []byte
initial [64]byte
/* ... */
}
// Grow expands the buffer's capacity, if necessary,
// to guarantee space for another n bytes. [...]
func (b *Buffer) Grow(n int) {
if b.buf == nil {
b.buf = b.initial[:0] // use preallocated space initially
}
if len(b.buf)+n > cap(b.buf) {
buf := make([]byte, b.Len(), 2*cap(b.buf) + n)
copy(buf, b.buf)
b.buf = buf
}
}
~~~
封裝的第三個優點也是最重要的優點,是阻止了外部調用方對對象內部的值任意地進行修改。因為對象內部變量只可以被同一個包內的函數修改,所以包的作者可以讓這些函數確保對象內部的一些值的不變性。比如下面的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兼容的情況下去除對其的導出,所以在一開始的選擇一定要經過深思熟慮并且要考慮到包內部的一些不變量的保證,未來可能的變化,以及調用方的代碼質量是否會因為包的一點修改而變差。
封裝并不總是理想的。 雖然封裝在有些情況是必要的,但有時候我們也需要暴露一些內部內容,比如:time.Duration將其表現暴露為一個int64數字的納秒,使得我們可以用一般的數值操作來對時間進行對比,甚至可以定義這種類型的常量:
~~~
const day = 24 * time.Hour
fmt.Println(day.Seconds()) // "86400"
~~~
另一個例子,將IntSet和本章開頭的geometry.Path進行對比。Path被定義為一個slice類型,這允許其調用slice的字面方法來對其內部的points用range進行迭代遍歷;在這一點上,IntSet是沒有辦法讓你這么做的。
這兩種類型決定性的不同:geometry.Path的本質是一個坐標點的序列,不多也不少,我們可以預見到之后也并不會給他增加額外的字段,所以在geometry包中將Path暴露為一個slice。相比之下,IntSet僅僅是在這里用了一個[]uint64的slice。這個類型還可以用[]uint類型來表示,或者我們甚至可以用其它完全不同的占用更小內存空間的東西來表示這個集合,所以我們可能還會需要額外的字段來在這個類型中記錄元素的個數。也正是因為這些原因,我們讓IntSet對調用方透明。
在這章中,我們學到了如何將方法與命名類型進行組合,并且知道了如何調用這些方法。盡管方法對于OOP編程來說至關重要,但他們只是OOP編程里的半邊天。為了完成OOP,我們還需要接口。Go里的接口會在下一章中介紹。
- Go語言程序設計
- Go語言圣經(中文版)
- 譯者序
- 前言
- 第一章 入門
- 第二章 程序結構
- 第三章 基礎數據類型
- 第四章 復合數據類型
- 第五章 函數
- 第六章 方法
- 第七章 接口
- 7.4. flag.Value接口
- 7.6. sort.Interface接口
- 7.7. http.Handler接口
- 第八章 Goroutines和Channels
- 第九章 基于共享變量的并發
- 9.2. sync.Mutex互斥鎖
- 第十章 包和工具
- 第十一章 測試
- 第十二章 反射
- 12.2. reflect.Type和reflect.Value
- 12.5. 通過reflect.Value修改值
- 第13章 底層編程
- 13.1. unsafe.Sizeof, Alignof 和 Offsetof
- 附錄