Go 语言也支持面向对象的思想; | |
所谓面向对象编程: | |
1 | 对象就是简单的一个值或者变量,并且拥有其方法 |
2 | 方法是某种特定类型的函数 |
3 | 面向对象编程就是使用方法来描述每个数据结构的属性和操作; 使用者不需要了解对象本身的实现 |
1.1 方法声明
方法声明基础语法 :
方法的声明和普通函数的声明类似,只是在函数名字前面多了一个(类型)参数
这个参数把这个方法绑定到对应的类型上
示例 :现在尝试在一个与平面几何相关的包中写第一个方法
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)
}
方法声明中的一些要点:
a). 附加的参数 p 称为方法的接收者,用来描述主调方法向对象发送消息
b). Go 语言中,接收者不使用特殊名(比如 this 或 self);我们自己选择接收者名字,就像其他的参数变量名一样
c). 接收者会频繁地使用,因此最好能够选择简短且在整个方法中名称始终保持一致的名字;最常用的办法是取类型名称的首字母(小写),就像 Point 中的 p
方法调用要点 :
a). 调用方法的时候,接收者在方法名的前面;这样,调用就和声明保持顺序一致了
p := Point{1,2}
q := Point{3,4}
fmt.Println(Distance(p,q)) // 函数调用
fmt.Println(p.Distance(q)) // 方法调用
方法调用 | ||
上面两个 Distance 函数声明没有冲突 | ||
第一个声明了一个包级别的函数(geometry.Distance) | ||
第二个声明了一个类型 Point 的方法,其名字为 Point.Distance | ||
表达式 p.Distance 称为选择子(selector),因为 p.Distance 为接收者 p 选择合适的 Distance 方法 | ||
选择子语法也用于选择结构类型中的某些字段值,就像 p.X 中的字段值 | ||
由于方法和字段来自于同一个命名空间(在同一个结构类型中),因此在 Point 结构类型中声明一个叫 X 的方法会与字段 X 冲突,编译器会报错 小结 :同类型中,不能存在同名的标识符 | ||
因为每一个类型有其自己的命名空间,所以我们能够在其他不同的类型中使用名字 Distance 作为方法名 | ||
定义一个 Path 类型表示一条线段,同样也使用 Distance 作为方法名 | ||
// 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 和许多其他面向对象的语言不同; 在 Go 语言中,可以将方法绑定到任何类型上 | ||
我们可以很方便地为简单的类型(如数字 、字符串 、slice 、map ,甚至函数等)定义附加的行为(方法) | ||
同一个包下的任何类型都可以声明方法,只要它的类型既不是指针类型也不是接口类型 | ||
这两个 Distance 方法(Point.Distance 和 Path.Distance)拥有不同的类型; 它们彼此无关,尽管 Path.Distance 在内部使用 Point.Distance 来计算线段相邻点之间的距离 | ||
总结: | 在同一个结构类型中, 字段与字段不能同名 方法与方法不能同名 字段与方法不能同名 但在两个不同的结构类型中,可以存在相同名称的字段或方法,前提是相同的字段或方法,所属的类型不同 | |
1.2 指针接收者的方法
背景 :
主调函数会复制每一个实参变量
如果函数需要更新一个变量,或者如果一个实参太大而我们希望避免复制整个实参,则我们必须使用指针来传递变量的地址;这也同样适用于更新接收者 :我们将方法绑定到指针类型,比如 *Point
func (p *Point) ScaleBy(factor float64) {
p.X *= factor
p.Y *= factor
}
注意 :
这个方法的名字是 (*Point).ScaleBy ;圆括号是必需的,没有圆括号,表达式会被解析为 *(Point.ScaleBy)
Tips :
在真实的程序中,习惯上遵循如果 Point 的任何一个方法使用指针接收者,那么所有的 Point 方法都应该使用指针接收者,即使有些方法不一定需要
变量类型(Point)与指针类型(*Point)是唯一可以出现在接收者声明处的类型;不允许本身是指针的类型进行方法声明
type P *int
func (P) f() { /* ... */ } // 编译错误:非法的接收者类型
通过提供 *Point 可以调用 (*Point).ScaleBy 方法
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}"
后面两个用法虽然看上去比较别扭,却也是合法的
如果接收者 p 是 Point 类型的变量,但方法要求一个 *Point 接收者,我们可以使用简写 :
变量类型实参可以以句点形式直接调用指针类型方法
p := Point{1,2}
p.ScaleBy(2)
说明:
实际上编译器会对变量进行 &p 的隐式转换;
只有变量才允许这么做,包括结构体字段,像 p.X 、数组 、slice 的元素,比如 perim[0]
不能对一个不能取地址的 Point 接收者参数调用 *Point 方法,因为无法获取临时变量的地址
Point{1,2}.ScaleBy(2) // 编译错误 :不能获得 Point 类型字面量的地址
但是,如果实参接收者是 *Point 类型,以 Point.Distance 的方式调用 Point 类型的方法是合法的,因为我们有办法从地址中获取 Point 的值,只要解引用指向接收者的指针值即可;编译器自动插入一个隐式的 * 操作符;
换句话说,变量可以直接以句点的方式调用接收者是指针类型的方法
示例 :下面两个函数的调用效果是一样的
pptr.Distance(q)
(*pptr).Distance(q)
总结一下这些例子 :在合法的方法调用表达式中,只有符合下面三种形式的语句才能够成立
(A). 实参接收者和形参接收者是同一个类型,比如都是 T 类型或都是 *T 类型
方法声明中接收者的类型与实参的类型,两者一致
Point{1,2}.Distance(q) // Point
pptr.ScaleBy(2) // *Point
(B). 方法声明中接收者是指针类型(*T),实参是变量类型(T),变量类型实参直接以句点形式调用指针类型方法;编译器会隐式获取变量类型实参的地址
p.ScaleBy(2) // 隐式转换为 (&p)
(C). 方法声明中接收者是变量类型(T),实参是指针类型(*T),指针类型实参亦直接以句点形式调用变量类型方法;编译器会隐式解引用指针类型实参,获得其实际的值
pptr.Distance(q) // 隐式转换为 (*pptr)
注意 :
如果所有类型 T 方法的(实参)接收者是 T 自己(而非 *T),也就是说接收者和实参都是变量类型,那么复制该类型的实例是安全的;调用方法的时候都必须进行一次复制
(示例 :time.Duration 的值在作为实参传递到函数的时候就会复制)
但是,任何方法的接收者是指针类型时,应该避免复制 T 的实例;因为这么做可能会破坏内部原本的数据;随后的方法调用会产生不可预期的结果
(示例 :复制 bytes.Buffer 实例只会得到相当于原来 bytes 数组的一个别名;)
nil 是一个合法的接收者
就像一些函数允许 nil 指针作为实参,方法的接收者也一样,尤其当 nil 是类型中有意义的零值(如 map 和 slice 类型)时,更是如此
示例 :在下面这个简单的整数链表中,nil 代表空链表 :
// 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 类型的部分定义 :
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)
}
它的实现是 map 类型,但也提供了一系列方法来简化 map 的操作,它的值是字符串 slice ,即一个多重 map ;使用者可以使用它固有的操作方式(make 、slice 字面量 、m[key] 等方式),或者使用它的方法,或同时使用
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 方法会发生宕机,因为 Add 方法尝试更新一个空的 map
因为 url.Values 是 map 类型而且 map 间接地指向它的键值对,所以 url.Values.Add 对 map 中元素的任何更新和删除操作,对调用者都是可见的
然而,和普通函数一样,方法对引用本身做的任何改变(比如设置 url.Values 为 nil 或者使它指向一个不同 map 数据结构)都不会在调用者身上产生作用
1.3 通过结构体内嵌来组成(新)类型
考虑 ColoredPoint 类型 :
import "image/color"
type Point struct{ X,Y float64 }
type ColoredPoint struct {
Point
Color color.RGBA
}
我们只是想定义一个有三个字段的结构体 ColoredPoint ,但实际上我们内嵌了一个 Point 类型从而提供字段 X 和 Y ;内嵌可以更简单地定义 ColoredPoint 类型,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 则作为子类或派生类,或将这两个之间的关系翻译为 ColoredPoint 就是 Point 的其中一种表现;但这是个误解
注意上面调用 Distance 的地方,Distance 有一个形参 Point ,q 不是 Point ,因此虽然 q 有一个内嵌的 Point 字段,但是必须显式地使用该 Point 字段;尝试直接传递 q 作为参数会报错
p.Distance(q) // 编译错误: 不能将 q (ColoredPoint) 转换为 Point 类型
ColoredPoint 并不是 Point ,但是 ColoredPoint 包含一个 Point ,并且 ColoredPoint 还有两个另外的方法 Distance 和 ScaleBy 也来自 Point ;
如果考虑具体实现,实际上,内嵌的字段会告诉编译器,生成额外的包装方法来调用 Point 声明的方法,这相当于以下代码 :
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 和 q 共享同一个 Point
p.ScaleBy(2)
fmt.Println(*p.Point, *q.Point) // "{2 2} {2 2}"
结构体类型可以拥有多个匿名字段;
示例 :声明 ColoredPoint
type ColoredPoint struct {
Point
color.RGBA
}
那么,这个 ColoredPoint 类型的实例可以拥有 Point 所有的方法和 RGBA 所有的方法,以及任何其他直接在 ColoredPoint 类型中声明的方法;当编译器处理选择子(比如 p.ScaleBy)的时候,首先,编译器先查找到直接声明的方法 ScaleBy ,之后再从来自 ColoredPoint 的内嵌字段的方法中进行查找,再之后从 Point 和 RGBA 中内嵌字段的方法中进行查找,以此类推;
当同一个查找层级中存在同名方法时,编译器会报告选择子不明确的错误
方法声明只能绑定到变量类型(比如 Point)或指针类型(比如 *Point)上,但内嵌允许在未命名的结构体类型中声明方法
示例 :下面的例子展示了简单的缓存实现,其中使用了两个包级别的变量 -- 互斥锁和 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 变量本身进行加锁
1.4 方法变量与表达式
通常,我们都在相同的表达式中使用和调用方法,就像在 p.Distance() 中,但也可以把两个操作分开;选择子 p.Distance 可以赋予一个方法变量,它是一个函数,把方法(Point.Distance)绑定到一个接收者 p 上;函数只需要提供实参而不需要提供接收者就能够调用
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.23606"
scaleP := p.ScaleBy // 方法变量
scaleP(2) // p 变成 (2, 4)
scaleP(3) // 然后是 (6, 12)
scaleP(10) // 然后是 (60, 120)
如果包内的 API 调用一个函数值,并且使用者期望这个函数的行为是调用一个特定接收者的方法,方法变量就非常有用;
示例 :比如,函数 time.AfterFunc 会在指定的延迟后调用一个函数值,下面这个程序使用 time.AfterFunc 在 10s 后启动火箭 r :
type Rocket struct { /* ... */ }
func (r *Rocket) Launch() { /* ... */ }
r := new(Rocket)
time.AfterFunc(10 * time.Second, func() { r.Launch() })
如果使用方法变量则可以更加简洁 :
time.AfterFunc(10 * tiem.Sceind, r.Launch)
与方法变量相关的是 "方法表达式";和调用一个普通的函数不同,在调用方法的时候必须提供接收者,并且按照选择子的语法进行调用;而方法表达式写成 T.f 或者 (*T).f ,其中 T 是类型,是一种函数变量,把原来方法的接收者替换成函数的第一个形参,因此可以像平常的函数一样调用
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 调用了它计算路径上的每一个点
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 {
// 调用 path[i].Add(offset) 或者是 path[i].Sub(offset)
path[i] = op(path[i], offset)
}
}
1.5 示例:位向量
Go 语言的集合,通常使用 map[T]bool 来实现,其中 T 是元素类型
使用 map 的集合,扩展性良好,但是对于一些特定问题,一个专门设计过的集合性能会更优;比如,在数据流分析领域,集合元素都是小的非负整型,集合拥有许多元素,而且集合的操作多数是求并集和交集,"位向量" 是个理想的数据结构
位向量使用一个无符号整型值的 slice ,每一位代表集合中的一个元素;如果设置第 i 位的元素,则认为集合包含 i ;
示例 :下面的程序演示了一个含有三个方法的简单位向量类型
// IntSet 是一个包含非负整数的集合
// 零值代表空的集合
type IntSet struct {
words []uint64
}
// Has 方法的返回值表示是否存在非负数 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 添加非负数 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 的方法;添加一个 String 方法
// String 方法以字符串 "{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 方法和之前的 intsToString 函数相似;在 String 方法中 bytes.Buffer 经常以这样的方式用到;fmt 包把具有 String 方法的类型进行特殊处理,于是,即使是复杂类型也可以按照友好的方式显示出来;fmt 默认调用 String 方法而不是原生的值;这个机制需要依靠接口和类型断言
现在,可以演示 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"
提醒一句:我们为指针类型 *IntSet 声明了 String 和 Has 方法并非出于需要,而是为了和其他两个方法保持一致,另外两个方法需要指针接收者,因为它们需要对 s.words 进行赋值;所以,IntSet 的值并不含有 String 方法,使用它可能会产生意料外的结果
fmt.Println(&x) // "{1 9 42 144}"
fmt.Println(x.String()) // "{1 9 42 144}"
fmt.Println(x) // "{[4298046511618 0 65536]}"
第一个示例中,输出了 *IntSet 指针,它有一个 String 方法;第二个示例中,基于 IntSet 值调用 String() 方法;编译器会帮助我们隐式地插入 & 操作符,我们得到指针后就可以获取到 String 方法了;但在第三个示例中,因为 IntSet 值本身并没有 String 方法,所以 fmt.Println 直接输出结构体;因此,记得加上 & 操作符很重要;那么给 IntSet(而不是 *IntSet)加上 String 方法应该是个不错的主意,但这还需要根据实际情况而定
1.6 封装
如果(成员)变量或者方法不能通过对象访问到,这称作 "封装" 的变量或者方法;封装(有时候也称作信息隐藏)是面向对象编程中重要的一方面
Go 语言只有一种方式控制命名的可见性 :定义的时候,首字母大写的标识符是可以从包中导出的,而首字母没有大写的标识符则不是导出的;相同的机制也同样作用于结构体内的字段和类型中的方法
结论就是,要封装一个对象,必须将其定义为一个结构体
这就是为什么上一节中 IntSet 类型被声明为结构体,但它只有一个字段 :
type IntSet struct {
words []uint64
}
可以重新定义 IntSet 为一个 slice 类型,如下所示,当然,必须把方法中出现的 s.words 替换为 *s
type IntSet []uint64
尽管这个版本的 IntSet 和之前的基本等同,但是这个版本的 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 的 slice 类型暴露出来是合理的做法;与它不同的是,IntSet 只是看上去像 []uint64 的 slice ,但它实际上完全可以是 []uint 或其他复杂的集合类型,而且另外用来记录集合中元素数量的字段充当了重要的作用;基于上述原因,IntSet 不对外透明也合情合理
总结 :
在这一章中,我们学习了如何在命名类型中定义方法,以及如何调用这些方法;尽管方法是面向对象编程的关键,但这一章只讲述了其中的一部分内容;下一章会继续介绍 "接口" 相关的内容来完成这方面的学习