gopl-zh.github.com/ch6/ch6-06.md
2016-12-15 15:27:24 +08:00

5.9 KiB
Raw Blame History

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前缀。这种简洁上的偏好也可以推广到各种类型的前缀比如FetchFind或者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里的接口会在下一章中介绍。