从20世纪90年代初开始,面向对象编程(OOP)的编程思想就已经在工业领域和教学领域占据了主导位置,而且几乎所有广泛应用的编程语言都支持了这种思想。Go语言也不例外。
尽管没有统一的面向对象编程的定义,对我们来说,对象就是简单的一个值或者变量,并且拥有其方法,而方法是某种特定类型的函数。面向对象编程就是使用方法来描述每个数据结构的属性和操作,于是,使用者不需要了解对象本身的实现。
在之前的章节,我们了解了标准库中方法的常规使用方法,比如time.Duration类型的Seconds方法。
1
2
const day = 24 * time.Hour
fmt.Println(day.Seconds()) // "86400"
在这一章中,首先我们要学习如何基于面向对象编程思想,从而更有效地定义和使用方法。我们也会讲到两个关键的原则:封装和组合。
方法声明
方法的声明和普通函数的声明类似,只是在函数名字前面多了一个参数。这个参数把这个方法绑定到这个参数对应的类型上。
让我们现在尝试在一个与平面几何相关的包中写第一个方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package geometry
import "math"
type Point struct{ X, Y float64 }
// 普通的函数
func Distance(p, q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
// Point类型的方法
func (p Point) Distance(q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
附加的参数p称为方法的接收者,它源自早先的面向对象语言,用来描述主调方法向对象发送消息。
Go语言中,接收者不使用特殊名(比如this或者self);而是我们自己选择接收者名字,就像其他的参数变量一样。由于接收者会频繁地使用,因此最好能够选择简短且在整个方法中名称始终保持一致的名字。最常用的方法就是取类型名称的首字母,就像Point中的p。
调用方法的时候,接收者在方法名的前面。这样就和声明保持一致。
1
2
3
4
p := Point{1, 2}
q := Point{4, 6}
fmt.Println(Distance(p, q)) // "5",函数调用
fmt.Printin(p.Distance(q)) // "5",方法调用
上面两个Distance函数声明没有冲突:
- 第一个声明一个包级别的函数(称为
geometry.Distance)。 - 第二个声明一个类型
Point的方法,因此它的名字是Point.Distance。
表达式p.Distance称作选择子(selector),因为它为接收者p选择合适的Distance方法。选择子也用于选择结构类型中的某些字段值,就像p.X中的字段值。由于方法和字段来自于同一个命名空间,因此在Point结构类型中声明一个叫作X的方法会与字段X冲突,编译器会报错。
因为每一个类型有它自己的命名空间,所以我们能够在其他不同的类型中使用名字Distance作为方法名。定义一个Path类型表示一条线段,同样也使用Distance作为方法名。
1
2
3
4
5
6
7
8
9
10
11
12
// Path是连接多个点的直线段
type Path []Point
// Distance方法返回路径的长度
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这样的结构体类型,但我们依旧可以给它定义方法。
Go和许多其他面向对象的语言不同,它可以将方法绑定到任何类型上。可以很方便地为简单的类型(如数字、字符串、slice、map,甚至函数等)定义附加的行为。同一个包下的任何类型都可以声明方法,只要它的类型既不是指针类型也不是接口类型。
这两个Distance方法拥有不同的类型。它们彼此无关,尽管Path.Distance在内部使用Point.Distance来计算线段相邻点之间的距离。
调用这个新的方法计算右边三角形的周长。
1
2
3
4
5
6
7
perim := Path{ // 这路径表示方法真抽象
{1, 1},
{5, 1},
{5, 4},
{1, 1},
}
fmt.Println(perim.Distance()) // "12"
上面两个Distance方法调用中,编译器会通过方法名和接收者的类型决定调用哪一个函数。
- 在第一个示例中,
path[i-1]是Point类型,因此调用Point.Distance; - 在第二个示例,
perim是Path类型,因此调用Path.Distance。
类型拥有的所有方法名都必须是唯一的,但不同的类型可以使用相同的方法名,比如Point和Path类型的Distance方法;也没有必要使用附加的字段来修饰方法名(比如,PathDistance)以示区别。这里我们可以看到使用方法的第一个好处:命名可以比函数更简短。在包的外部进行调用时,方法能够使用更加简短的名字且省略包的名字。
指针接收者的方法
由于主调函数会复制每一个实参变量,如果函数需要更新一个变量,或者如果一个实参太大而我们希望避免复制整个实参,因此我们必须使用指针来传递变量的地址。这也同样适用于更新接收者:我们将它绑定到指针类型,比如*Point。
1
2
3
4
func (p *Point) ScaleBy(factor float64) {
p.X *= factor
p.Y *= factor
}
这个方法的名字是(*Point).ScaleBy。
- 圆括号是必需的;没有圆括号,表达式会被解析为
*(Point.ScaleBy)。
在真实的程序中,习惯上遵循如果Point的任何一个方法使用指针接收者,那么所有的Point方法都应该使用指针接收者,即使有些方法并不一定需要。我们在Point中打破了这个习惯只为了方便展示方法的不同使用方法。
命名类型(Point)与指向它们的指针(*Point)是唯一可以出现在接收者声明处的类型。因此,为防止混淆,不允许本身是指针的类型进行方法声明:
1
2
type P *int
func (P) f() { /*··*/ } // 编译错误:非法的接收者类型
通过提供*Point能够调用(*Point).ScaleBy方法,比如:
1
2
3
r =& Point{1, 2}
r.ScaleBy(2)
fmt.Println(*r) // "{2, 4}"
或者:
1
2
3
4
p := Point{1, 2}
pptr := &p
pptr.ScaleBy(2)
fmt.Println(p) // "{2, 4}"
或者:
1
2
3
p := Point{1, 2}
(&p).ScaleBy(2)
fmt.Println(p) // "{2, 4}"
最后两个用法虽然看上去比较别扭,但也是合法的。
然而,如果接收者p是Point类型的变量,但方法要求一个*Point接收者,我们可以使用简写:
1
p.ScaleBy(2)
实际上编译器会对变量进行&p即的隐式转换。但只有变量才允许这么做,包括结构体字段,像p.X和数组或者slice的元素,比如perim[0]。
- 不能够对一个不能取地址的
Point接收者参数调用*Point方法,因为无法获取临时变量的地址。1
Point{1, 2}.ScaleBy(2) // 编译错误:不能获得Point类型字面量的地址
如果实参接收者是*Point类型,以Point.Distance的方式调用Point类型的方法同样是合法的,因为我们有办法从地址中获取Point的值;只要解引用指向接收者的指针值即可。编译器自动插入一个隐式的*操作符。下面两个函数的调用效果是一样的:
1
2
pptr.Distance(q)
(*pptr).Distance(q)
让我们总结一下这些例子,因为经常容易弄错。在合法的方法调用表达式中,只有符合下面三种形式的语句才能够成立。
- 实参接收者和形参接收者是同一个类型,比如都是
T类型或都是*T类型:1 2
Point{1, 2}.Distance(q) // Point pptr.ScaleBy(2) // *Point
- 实参接收者是T类型的变量而形参接收者是*T类型。编译器会隐式地获取变量的地址。
1
p.ScaleBy(2) // 隐式转换为(&p)
- 实参接收者是
*T类型而形参接收者是T类型。编译器会隐式地解引用接收者,获得实际的取值。1
pptr.Distance(q) // 隐式转换为(*pptr)
如果所有类型T方法的接收者是T自己(而非*T),那么复制它的实例是安全的;调用方法的时候都必须进行一次复制。比如,time.Duration的值在作为实参传递到函数的时候就会复制。但是任何方法的接收者是指针的情况下,应该避免复制T的实例,因为这么做可能会破坏内部原本的数据。比如,复制bytes.Buffer实例只会得到相当于原来bytes数组的一个别名。随后的方法调用会产生不可预期的结果。
- 这段话写得有些晦涩了,其实核心就两个,在函数调用中,传值,复制是安全的,因为改动不影响原来的内容。
- 如果方法传的是指针,在函数调用中复制后改动就有隐患,因为其实只是得到了一个别名B,改动B之后,也间接影响了A。
nil是一个合法的接收者
就像一些函数允许nil指针作为实参,方法的接收者也一样,尤其是当nil是类型中有意义的零值(如map和slice类型)时,更是如此。在这个简单的整型数链表中,nil代表空链表:
1
2
3
4
5
6
7
8
9
10
11
12
13
// IntList是整型链表
// *IntList的类型nil代表空列表
type IntList struct {
Value int
Tail *IntList
}
// Sum返回列表元素的总和
func (list *IntList) Sum() int {
if list == nil {
return 0
}
return list.Value + list.Tail.Sum() // 经典递归
}
当定义一个类型允许nil作为接收者时,应当在文档注释中显式地标明,如上例所示。
看一下net/url包中values类型的部分定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package url
// Values映射字符串到字符串列表
type Values map[string][]string
// Get返回第一个具有给定key的值
// 如不存在,则返回空字符串
func (v Values) Get(key string) string {
if vs := v[key]; len(vs) > 0 {
return vs[0]
}
return ""
}
// Add添加一个键值到对应key列表中
func (v Values) Add(key, value string) {
v[key] = append(v[key], value) // 若v为nil,那么这一行代码就没有意义
}
它的实现是map类型但也提供了一系列方法来简化map的操作,它的值是字符串slice,即一个多重map。使用者可以使用它固有的操作方式(make、slice字面量、m[key]等方式),或者使用它的方法,或同时使用:
1
2
3
4
5
6
7
8
9
10
11
12
m := url.Values{"lang": {"en"}} // 直接构造
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" (第一个值)
fmt.Println(m["item"]) // "[1 2]"、(直接访问map)
m = nil
fmt.Println(m.Get("item")) // ""
m.Add("item", "3") // 宕机:赋值给空的map类型
在最后一个Get调用中,nil接收者充当一个空map。它可以等同地写成Values(nil).Get("item"),但是nil.Get("item")不能通过编译,因为nil的类型没有确定。相比之下,最后的Add方法会发生宕机因为它尝试更新一个空的map。
因为url.Values是map类型而且map间接地指向它的键/值对,所以url.Values.Add对map中元素的任何更新和删除操作对调用者都是可见的。然而,和普通函数一样,方法对引用本身做的任何改变,比如设置url.Values为nil或者使它指向一个不同的map数据结构,都不会在调用者身上产生作用。
- 关于这个例子的疑问,明明传递的是值,却表现为传引用的效果,以下是解答。
- 在Go语言中,切片(slice)是一种引用类型。虽然在函数或方法调用中,切片作为参数传递时是按值传递的,但由于切片本身包含指向底层数组的指针,因此实际上表现为引用传递。传递切片的副本时,副本中的指针仍然指向相同的底层数组,因此对副本的修改会影响原始切片的数据。
通过结构体内嵌组成类型
考虑ColoredPoint类型:
1
2
3
4
5
6
7
8
import "image/color"
type Point struct{ X, Y float64 }
type ColoredPoint struct {
Point
Color color.RGBA
}
我们只是想定义一个有三个字段的结构体ColoredPoint,但实际上我们内嵌了一个Point类型以提供字段X和Y。在讲述结构体部分时已经看到,内嵌使我们更简便地定义了coloredPoint类型,它包含Point类型的所有字段以及其他更多的自有字段。如果需要,可以直接使用ColoredPoint内所有的字段而不需要提到Point类型:
1
2
3
4
5
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类型没有声明过这个方法的情况下:
1
2
3
4
5
6
7
8
red := color.RGBA{255, 0, 0, 255}
b1ue := co1or.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",这里直接调用了Point的方法(考虑可能的冲突问题)
p.ScaleBy(2)
q.ScaleBy(2)
fmt.Println(p.Distance(q.Point)) // "10"
Point的方法都被纳入到ColoredPoint类型中。以这种方式,内嵌允许构成复杂的类型,该类型由许多字段构成,每个字段提供一些方法。
可能会认为Point类型就是ColoredPoint类型的基类,而ColoredPoint则作为子类或派生类,或将这两个之间的关系翻译为ColoredPoint就是Point的其中一种表现。但这是个误解。
- 上面调用
Distance的地方。Distance有一个形参Point,q不是Point - 因此虽然
q有一个内嵌的Point字段,但是必须显式地使用它。尝试直接传递q作为参数会报错:1
p.Distance(q) // 编译错误:不能将q(ColoredPoint)转换为Point类型
ColoredPoint并不是Point,但是它包含一个Point,并且它有两个另外的方法Distance和ScaleBy来自Point。如果考虑具体实现,实际上,内嵌的字段会告诉编译器生成额外的包装方法来调用Point声明的方法,这相当于以下代码:
1
2
3
4
5
6
7
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,而且这个方法是不能访问ColoredPoint(其中内嵌了Point)类型的。
匿名字段类型可以是个指向命名类型的指针,这个时候,字段和方法间接地来自于所指向的对象。这可以让我们共享通用的结构以及使对象之间的关系更加动态、多样化。下面的ColoredPoint声明内嵌了*Point:
1
2
3
4
5
6
7
8
9
10
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 和 q共享同一个Point
p.ScaleBy(2) // 编译器自动加上取值
fmt.Println(*p.Point, *q.Point) // "{2 2} {2 2}"
结构体类型可以拥有多个匿名字段。声明ColoredPoint:
1
2
3
4
type ColoredPoint struct {
Point
color.RGBA
}
那么这个类型的值可以拥有Point所有的方法和RGBA所有的方法,以及任何其他直接在ColoredPoint类型中声明的方法。当编译器处理选择子(p.ScaleBy)的时候,首先,它先查找到直接声明的方法ScaleBy,之后再从来自ColoredPoint的内嵌字段的方法中进行查找,再之后从Point和RGBA中内嵌字段的方法中进行查找,以此类推。
- 当同一个查找级别中有同名方法时,编译器会报告选择子不明确的错误。
方法只能在命名的类型(比如Point)和指向它们的指针(*Point)中声明,但内嵌帮助我们能够在未命名的结构体类型中声明方法。
下面是个很好的示例。这个例子展示了简单的缓存实现,其中使用了两个包级别的变量一互斥锁和map,互斥锁将会保护map的数据。
1
2
3
4
5
6
7
8
9
10
11
12
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中:
1
2
3
4
5
6
7
8
9
10
11
12
var cache = struct {
sync.Mutex
mapping map[string]string
} { // 这个大括号表示的是初始化操作,这里两步当一步写了
mapping: make(map[string]string), // 逗号很关键
}
func Lookup(key string) string {
cache.Lock() // 声明方法,虽然Mutex并未命名,但不影响
v := cache.mapping[key]
cache.Unlock()
return v
}
新的变量名更加贴切,而且sync.Mutex是内嵌的,它的Lock和Unlock方法也包含进了结构体中,允许我们直接使用cache变量本身进行加锁。
方法变量与表达式
通常我们都在相同的表达式里使用和调用方法,就像在p.Distance()中,但是把两个操作分开也是可以的。选择子p.Distance可以赋予一个方法变量,它是一个函数,把方法Point.Distance绑定到一个接收者p上。使得函数只需要提供实参而不需要提供接收者就能够调用。
- 方法变量本质上就是说,将对象中的方法封装成变量。
- 可以理解为是Go语言的一个特性。
1
2
3
4
5
6
7
8
9
10
11
p := Point{1, 2}
q := Point{4, 6}
distanceFromP := p.Distance // 方法变量
fmt.Println(distanceFromP(q)) // "5"
var origin Point // {0,0}
fmt.Println(distanceFromP(origin)) // "2.23606797749979",√5
scaleP := p.ScaleBy // 方法变量
scaleP(2) // p变成 (2, 4)
scaleP(3) // 然后是(6, 12)
scaleP(10) // 然后是(60, 120)
如果包内的API调用一个函数值,并且使用者期望这个函数的行为是调用一个特定接收者的方法,方法变量就非常有用。比如,函数time.AfterFunc会在指定的延迟后调用一个函数值。这个程序使用time.AfterFunc在10s后启动火箭r:
1
2
3
4
type Rocket struct { /* ... */ }
func (r *Rocket) Launch() { /* ... */ }
r := new(Rocket)
time.AfterFunc(10 * time.Second, func() { r.Launch() })
如果使用方法变量则可以更简洁:
1
time.AfterFunc(10 * time.Second, r.Launch) // 后面直接去调用即可,因为方法变成了一个变量
与方法变量相关的是方法表达式。和调用一个普通的函数不同,在调用方法的时候必须提供接收者,并且按照选择子的语法进行调用。而方法表达式写成T.f或者(*T).f,其中T是类型,是一种函数变量,把原来方法的接收者替换成函数的第一个形参,因此它可以像平常的函数一样调用。
- 理解为方法表达式对其中的方法进行了实际调用,也就是说,有实例将之进行了调用。
- 将方法看成一个值,只不过这个值的类型是函数类型。
- 可以传入实例,或者其他参数。
- 其实跟方法变量还是有点像的,方法表达式绑定的是具体的类型而非实例。
1
2
3
4
5
6
7
8
9
10
p := Point{1, 2}
q := Point{4, 6}
distance := Point.Distance // 方法表达式
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)"
如果你需要用一个值来代表多个方法中的一个,而方法都属于同一个类型,方法变量可以帮助你调用这个值所对应的方法来处理不同的接收者。在下面这个例子中,变量op代表加法或减法,二者都属于Point类型的方法。Path.TranslateBy调用了它计算路径上的每一个点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 // op的类型是一个返回值为Point类型的函数,但这个写法可能有问题.....
if add {
op = Point.Add
} else {
op = Point.Sub
}
for i := range path {
// 调用path[i].Add(offset)或者是path[i].Sub(offset)
path[i] = op(path[i], offset)
}
}
示例:位向量的使用
Go语言的集合通常使用map[T]bool来实现,其中T是元素类型。使用map的集合扩展性良好,但是对于一些特定问题,一个专门设计过的集合性能会更优。比如,在数据流分析领域,集合元素都是小的非负整型,集合拥有许多元素,而且集合的操作多数是求并集和交集,位向量是个理想的数据结构。
位向量使用一个无符号整型值的slice,每一位代表集合中的一个元素。如果设置第i位的元素,则认为集合包含i。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// IntSet是一个包含非负整数的集合
// 每个元素占64位,假设12345....存在于该数组中
// 那么显然12345....这个数通过除以64可以得到索引位置
// 通过访问该索引位置会得到一个数,但这个数是不是我们找的12345....呢
// 此时就能通过s.words[word]&(1<<bit)来进行判断
type Intset struct {
words []uint64
}
// Has方法的返回值表示是否存在非负数x
func (s *IntSet) Has(x int) bool {
word, bit := x/64, uint(x%64) // x/64可以计算索引,因为每个uint64类型有64位
// 将1左移bit位,得到一个只有bit位为1的数,如果x的bit同样为1
// 那么结果必然不为0,则表明x存在于s中
return word < len(s.words) && s.words[word]&(1<<bit) != 0
}
// Add添加非负数x到集合中,理解了上面,这个就容易理解了
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将会对s和t做并集并将结果存在s中
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位的位置,我们使用商数x/64作为字的索引,而x%64记作该字内位的索引。UnionWith操作使用按位或操作符|来计算一次64个元素求并集的结果。
这个实现缺少许多需要的特性,下面的代码改成以字符串输出Intset的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//String方法以字符串"{1 2 3}"的形式返回集中
func (s *Intset) String() string {
var buf bytes.Buffer // 字节缓冲区类型
buf.WriteByte('{')
for i, word range s.words { // 这里的word直接访问到了具体的数值,因此直接判断
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方法中bytes.Buffer经常以这样的方式用到。fmt包把具有String方法的类型进行特殊处理,于是,即使是复杂类型也可以按照友好的方式显示出来。fmt默认调用String方法而不是原生的值。这个机制需要依靠接口和类型断言,后续将介绍。
现在,可以演示Intset了:
1
2
3
4
5
6
7
8
9
10
11
12
13
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"
提醒一句:我们为指针类型*IntSet声明了String和Has方法并非出于需要,而是为了和其他两个方法保持一致,另外两个方法需要指针接收者,因为它们需要对s.words进行赋值。所以,Intset的值并不含有String方法,使用它可能会产生意料外的结果:
1
2
3
fmt.Println(&x) // "{1 9 42 144}",是的,该函数自动打印出结构体的内容
fmt.Println(x.String()) //"{1 9 42 144}",这个可以很容易理解
fmt.Println(x) // "{[4398046511618 0 65536]}"
第一个示例中,输出了*Intset指针,它有一个String方法。第二个示例中,基于Intset值调用String();编译器会帮我们隐式地插入&操作符,我们得到指针后就可以获取到String方法了。但在第三个示例中,因为Intset值本身并没有String方法,所以fmt.Println直接输出结构体。因此,记得加上&操作符很重要。某种程度上来说,给Intset(而不是*IntSet)加上String方法应该是个不错的主意,但这还是需要根据实际情况来看。
- 第一个示例真的合法吗?(2024/05/14标注)
- 还是说涉及到了未知知识点。
- 合法(2024/06/16标注)。
因为当直接传递一个指针(如px或pp)给fmt.Println时,Go会打印该指针指向的值。对于基本类型(如整数),这是因为指针会被自动解引用;对于结构体,Go会打印结构体的内容。
封装
如果变量或者方法是不能通过对象访问到的,这称作封装的变量或者方法。封装(有时候称作数据隐藏)是面向对象编程中重要的一方面。
Go语言只有一种方式控制命名的可见性:
- 定义的时候,首字母大写的标识符是可以从包中导出的,而首字母没有大写的则不导出。
同样的机制也同样作用于结构体内的字段和类型中的方法。结论就是,要封装一个对象,必须使用结构体。
这就是为什么上一节里Intset类型被声明为结构体但是它只有单个字段:
1
2
3
type Intset struct {
words []uint64
}
可以重新定义IntSet为一个slice类型,如下所示,当然,必须把方法中出现的s.words替换为*s(意思就是结构体的写法就不能用了,不要想太多)。
1
type Intset []uint64
尽管这个版本的Intset和之前的基本等同,但是它将能够允许其他包内的使用方读取和改变这个slice。换句话说,表达式*s可以在其他包内使用,s.words只能在定义Intset的包内使用。
另一个结论就是在Go语言中封装的单元是包而不是类型。无论是在函数内的代码还是方法内的代码,结构体类型内的字段对于同一个包中的所有代码都是可见的。
封装提供了三个优点:
- 因为使用方不能直接修改对象的变量,所以不需要更多的语句用来检查变量的值。
- 隐藏实现细节可以防止使用方依赖的属性发生改变,使得设计者可以更加灵活地改变API的实现而不破坏兼容性。
- 防止使用者肆意地改变对象内的变量,对象的变量只能被同一个包内的函数修改。
关于上述的第二个优点,有这么一个例子可以说明:
以bytes.Buffer类型为例。它用来堆积非常小的字符串,因此为了优化性能,实现上会预留一部分额外的空间避免频繁申请内存。由于Buffer是结构体类型,因此这块空间使用额外的一个字段[64]byte,且命名不是首字母大写。因为这个字段没有导出,bytes包之外的Buffer使用者除了能感觉到性能的提升之外,不会关心其中的实现。Buffer和它的Grow方法如下面的例子所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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) // 分配新空间,容量:2*cap(b.buf) + n
copy(buf, b.buf)
b.buf = buf
}
}
关于上述的第三个优点,同样有一个例子可以说明:
因为对象的变量只能被同一个包内的函数修改,所以包的作者能够保证所有的函数都可以维护对象内部的资源。比如,下面的Counter类型允许使用者递增计数或者重置计数器,但是不能够随意地设置当前计数器的值:
1
2
3
4
5
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。
1
2
3
4
5
6
7
8
9
10
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的整型数用于获得微秒,这使我们能够对其进行通常的数学运算和比较操作,甚至定义常数:
1
2
const day = 24 * time.Hour // 发现了吗,Hour首字母大写了
fmt.Println(day.Seconds()) // "86400"
另一个例子可以比较Intset和本章开头的geometry.Path类型。Path定义为一个slice类型,允许它的使用者使用slice字面量的语法来构成实例,比如使用range循环遍历Path所有的点等,而Intset则不允许这些操作。
有个明显的对比:geometry.Path从本质上讲就只是连续的点,以后也不会添加新的字段,因此geometry包将Path的slice类型暴露出来是合理的做法。与它不同的是,Intset只是看上去像[]uint64的slice。但它实际上完全可以是[]uint或其他复杂的集合类型,而且另外用来记录集合中元素数量的字段充当了重要的作用。基于上述原因,Intset不对外透明也合情合理。
- 解释一下,因为
words在结构体内是小写的。
在这一章中,学习了如何在命名类型中定义方法,以及如何调用它们。尽管方法是面向对象编程的关键,但这一章只讲述了其中的一部分内容。下一章会继续介绍接口相关的内容来完成这方面的学习。