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裡的接口會在下一章中介紹。