首页 Go方法
文章
取消

Go方法

从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
  • 在第二个示例,perimPath类型,因此调用Path.Distance

类型拥有的所有方法名都必须是唯一的,但不同的类型可以使用相同的方法名,比如PointPath类型的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}"

最后两个用法虽然看上去比较别扭,但也是合法的。

然而,如果接收者pPoint类型的变量,但方法要求一个*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是类型中有意义的零值(如mapslice类型)时,更是如此。在这个简单的整型数链表中,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。使用者可以使用它固有的操作方式(makeslice字面量、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.Valuesmap类型而且map间接地指向它的键/值对,所以url.Values.Addmap中元素的任何更新和删除操作对调用者都是可见的。然而,和普通函数一样,方法对引用本身做的任何改变,比如设置url.Valuesnil或者使它指向一个不同的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类型以提供字段XY。在讲述结构体部分时已经看到,内嵌使我们更简便地定义了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有一个形参Pointq不是Point
  • 因此虽然q有一个内嵌的Point字段,但是必须显式地使用它。尝试直接传递q作为参数会报错:
    1
    
      p.Distance(q)   // 编译错误:不能将q(ColoredPoint)转换为Point类型
    

ColoredPoint并不是Point,但是它包含一个Point,并且它有两个另外的方法DistanceScaleBy来自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的内嵌字段的方法中进行查找,再之后从PointRGBA中内嵌字段的方法中进行查找,以此类推。

  • 当同一个查找级别中有同名方法时,编译器会报告选择子不明确的错误。

方法只能在命名的类型(比如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是内嵌的,它的LockUnlock方法也包含进了结构体中,允许我们直接使用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声明了StringHas方法并非出于需要,而是为了和其他两个方法保持一致,另外两个方法需要指针接收者,因为它们需要对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标注)。

因为当直接传递一个指针(如pxpp)给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 }

仅仅用来获得或者修改内部变量的函数称为gettersetter,就像log包里的Logger类型。然而,命名getter方法的时候,通常将Get前缀省略。这个简洁的命名习惯也同样适用在其他冗余的前缀上,比如FetchFindLookup

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包将Pathslice类型暴露出来是合理的做法。与它不同的是,Intset只是看上去像[]uint64slice。但它实际上完全可以是[]uint或其他复杂的集合类型,而且另外用来记录集合中元素数量的字段充当了重要的作用。基于上述原因,Intset不对外透明也合情合理。

  • 解释一下,因为words在结构体内是小写的。

在这一章中,学习了如何在命名类型中定义方法,以及如何调用它们。尽管方法是面向对象编程的关键,但这一章只讲述了其中的一部分内容。下一章会继续介绍接口相关的内容来完成这方面的学习。

本文由作者按照 CC BY 4.0 进行授权

Go函数

Go接口