6.1 方法声明
在函数声明前,在其名字前放上一个变量,即是一个方法。这个附加的参数会将该函数附加到这种类型上,即相当于为这种类型定义了一个独占的方法。
下面来写我们第一个方法的例子,这个例子放在我们的package geometry下:
// gopl.io/ch5/geometry
package geometry
import "math"
type Point strut { X, Y float64 }
// traditionl 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(q.Distance(q)) // "5", method call
可见,上面两个函数调用都是Distance,但却没有发生冲突。第一个Distance的调用实际上用的是包级别的函数geometry.Distance,而第二个调用的是Point类下声明的Point.Distance方法。
这种p.Distance的表达式叫做选择器,因为它会选择合适的对应p这个对象的Distance方法来执行。选择器也会被用来选择一个类中的字段,比如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。
Path和Point的两个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
如上例,使用方法可以不用写全geometry的包名。
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}"
以上两种方法有些笨拙。幸运的是,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)
这里的几个例子可能让你有些困惑,所以我们总结一下,以下三种情况任意一种都是可以的:
1.接收器的实际参数和形式参数是相同类型,比如两者都是T
或都是T*
:
Point{1, 2}.Distance(q) // Point
pptr.ScaleBy(2) // *Point
2.接收器实参是T,但接收器形参是*T
,此时编译器会隐式为我们取变量的地址:
p.ScaleBy(2) // implicit (&p)
3.接收器实参类型是*T
,形参类型是T,编译器会隐式为我们解引用,取到指针指向的实际变量:
pptr.Distance(q) // implicit (*pptr)
如果命名类型T的所有方法都是用T类型自己来做接收器,那么拷贝这种类型的实例就是安全的;调用它的任何一个方法也就会产生一个值的拷贝。比如time.Duration这个类型,在调用其方法时就会被全部拷贝一份(其作为参数被传入函数的时候也是一样)。但如果一个方法使用指针作为接收器,你需要避免对其进行拷贝(指拷贝指针),因为这样可能会破坏该类型内部的不变性。比如你对bytes.Buffer对象进行了拷贝,那么原始对象和拷贝对象只是别名而已,实际上它们指向的对象是一样的。紧接着对拷贝后的变量进行修改可能会有让你意外的结果。
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包里Value类型定义的一部分:
// net/url
package url
// Value 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里的元素作任何更新、删除操作对调用方都是可见的。实际上,就像在普通函数中一样,虽然可以通过引用(Go里的引用指的是slice、map、通道这种值的传递)来操作内部值,但在方法想要修改引用本身时是不会影响原始值的,比如把它置换为nil,或者让这个引用指向了其他对象,调用方都不会受影响。
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.X = 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 shared 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)或指向它们的指针上定义,但多亏了内嵌,我们能在未命名的结构体类型中声明方法。
下例展示了简单的缓存实现,其中使用了两个包级别的变量——互斥锁(9.2节)和map,互斥锁将会保护map的数据。
var (
mu sync.Mutex // 保护mapping
mapping = make(map[string]string)
)
func Lookup(key string) string {
mu.Lock()
v := mapping[key]
mu.Unlock()
return v
}
下面版本和上例功能上完全相同,但是将两个相关变量放到了一个包级别的变量cache中:
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
}
新的变量名更贴切,且sync.Mutex是内嵌的,它的Lock和Unlock方法也包含进了结构体中,允许我们直接使用cache变量本身进行加锁。
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) // them (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.f
或(*T).f
,T是一个类型,它返回一个函数值,这种函数会将其第一个参数用作接收器,所以可以用通常的方式来对其调用:
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.Println("%T\n", scale) // "func(*Point, float64)"
当你根据一个变量来决定调用某类型的哪个函数时,方法表达式就显得很有用了。下例中,变量op代表加法或减法,二者都属于Point类型的方法。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表现更加理想。
一个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这个方法里用到了but位的或逻辑操作符|
来完成一次64个元素的或计算。
当前实现还缺少了很多必要的特性。如将IntSet作为一个字符串来打印。这里我们实现它,给上例添加一个String方法:
// 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()
}
bytes.Buffe在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指针上,会有下面这样令人意外的情况:
// x是上例中的
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.6 封装
一个对象的变量或方法如果对调用方不可见,就是封装。封装有时候也叫信息隐藏,也是面向对象编程的最关键的一个方面。
Go只有一种控制可见性的手段:首字母大写的标识符会从定义它的包中被导出,小写首字母则不会。同样的机制也作用于结构体内的字段和类型中的方法。结论是,要封装一个对象,必须使用结构体。
这就是为什么上一节里IntSet类型被声明为结构体但它只有单个字段:
type IntSet struct {
words []uint64
}
可以重新定义IntSet为一个slice类型,如下所示,当然,必须把方法中出现的s.words替换为*s:
type IntSet []uint64
尽管这个版本的IntSet和之前的基本等同,但是它能够允许其他包内的使用方读取和改变这个slice。换句话说,表达式*s
可以在其他包内使用,s.words只能在定义IntSet的包内使用。
Go中封装的单元是包而非类型。结构体类型内的字段对于同一个包中的所有代码都是可见的。
封装提供了三个优点。第一,因为使用方不能直接修改对象的变量,所以不需要更多语句来检查变量的值(因为变量的可能值变少了)。
第二,隐藏实现细节可以防止使用方依赖的属性发生改变,使得设计者可以更灵活地改变API的实现而不破坏兼容性。
作为一个例子,考虑bytes.Buffer类型。它用来堆积非常小的字符串,因此为了优化性能,实现上会预留一部分额外空间避免频繁地申请内存。由于Buffer是结构体类型,因此这块空间使用额外的一个类型为[64]byte的字段,且命名首字母小写。因为这个字段没有导出,bytes包外的Buffer使用者除了能感觉到性能提升外,不会关心其中的实现。Buffer和它的Grow方法如下:
type Buffer struct {
buf []byte
initial [64]byte
/* ... */
}
// Grow方法按需扩展缓冲区的大小
// 保证n个字节的空间
func (b *Buffer) Grow(n int) {
if b.buf == nil {
b.buf = b.initial[:0] // 最初使用预分配的空间
}
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类型允许使用者递增计数或重置计数器,但不能随意设置当前计数器的值:
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 }
仅用来获取或修改内部变量的方法称为getter和setter,就像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字面量的语法来构成实例,比如使用range循环遍历Path所有的点等,而IntSet不允许此操作。
有个明显的对比:geometry.Path从本质上将就是连续的点,以后也不会添加新的字段,因此geometry包将Path暴露出来完全是合理的做法。而IntSet只是看上去像[]uint64的slice。但它实际上完全可以是[]uint或其他复杂的集合类型,而且另一个用来记录集合中元素数量的字段也很重要。基于上述原因,IntSet不对外透明也合情合理。