5.3 类型的本质
在声明一个新类型之后,声明一个该类型的方法之前,需要先回答一个问题:这个类型的本质是什么。如果给这个类型增加或者删除某个值,是要创建一个新值,还是要更改当前的值?如果是要创建一个新值,该类型的方法就使用值接收者。如果是要修改当前值,就使用指针接收者。这个答案也会影响程序内部传递这个类型的值的方式:是按值做传递,还是按指针做传递。保持传递的一致性很重要。这个背后的原则是,不要只关注某个方法是如何处理这个值,而是要关注这个值的本质是什么。
5.3.1 内置类型
内置类型是由语言提供的一组类型。我们已经见过这些类型,分别是数值类型、字符串类型和布尔类型。这些类型本质上是原始的类型。因此,当对这些值进行增加或者删除的时候,会创建一个新值。基于这个结论,当把这些类型的值传递给方法或者函数时,应该传递一个对应值的副本。让我们看一下标准库里使用这些内置类型的值的函数,如代码清单 5-23 所示。
代码清单 5-23 golang.org/src/strings/strings.go:第 620 行到第 625 行
620 func Trim(s string, cutset string) string {
621 if s == "" || cutset == "" {
622 return s
623 }
624 return TrimFunc(s, makeCutsetFunc(cutset))
625 }
在代码清单 5-23 中,可以看到标准库里 strings
包的 Trim
函数。 Trim
函数传入一个 string
类型的值作操作,再传入一个 string
类型的值用于查找。之后函数会返回一个新的 string
值作为操作结果。这个函数对调用者原始的 string
值的一个副本做操作,并返回一个新的 string 值的副本。字符串( string
)就像整数、浮点数和布尔值一样,本质上是一种很原始的数据值,所以在函数或方法内外传递时,要传递字符串的一份副本。
让我们看一下体现内置类型具有的原始本质的第二个例子,如代码清单 5-24 所示。
代码清单 5-24 golang.org/src/os/env.go:第 38 行到第 44 行
38 func isShellSpecialVar(c uint8) bool {
39 switch c {
40 case '*', '#', '$', '@', '!', '?', '0', '1', '2', '3', '4', '5',
'6', '7', '8', '9':
41 return true
42 }
43 return false
44 }
代码清单 5-24 展示了 env
包里的 isShellSpecialVar
函数。这个函数传入了一个 int8
类型的值,并返回一个 bool
类型的值。注意,这里的参数没有使用指针来共享参数的值或者返回值。调用者传入了一个 uint8
值的副本,并接受一个返回值 true
或者 false
。
5.3.2 引用类型
Go 语言里的引用类型有如下几个:切片、映射、通道、接口和函数类型。当声明上述类型的变量时,创建的变量被称作 标头 (header)值。从技术细节上说,字符串也是一种引用类型。每个引用类型创建的标头值是包含一个指向底层数据结构的指针。每个引用类型还包含一组独特的字段,用于管理底层数据结构。因为标头值是为复制而设计的,所以永远不需要共享一个引用类型的值。标头值里包含一个指针,因此通过复制来传递一个引用类型的值的副本,本质上就是在共享底层数据结构。
让我们看一下 net
包里的类型,如代码清单 5-25 所示。
代码清单 5-25 golang.org/src/net/ip.go:第 32 行
32 type IP []byte
代码清单 5-25 展示了一个名为 IP
的类型,这个类型被声明为字节切片。当要围绕相关的内置类型或者引用类型来声明用户定义的行为时,直接基于已有类型来声明用户定义的类型会很好用。编译器只允许为命名的用户定义的类型声明方法,如代码清单 5-26 所示。
代码清单 5-26 golang.org/src/net/ip.go:第 329 行到第 337 行
329 func (ip IP) MarshalText() ([]byte, error) {
330 if len(ip) == 0 {
331 return []byte(""), nil
332 }
333 if len(ip) != IPv4len && len(ip) != IPv6len {
334 return nil, errors.New("invalid IP address")
335 }
336 return []byte(ip.String()), nil
337 }
代码清单 5-26 里定义的 MarshalText
方法是用 IP
类型的值接收者声明的。一个值接收者,正像预期的那样通过复制来传递引用,从而不需要通过指针来共享引用类型的值。这种传递方法也可以应用到函数或者方法的参数传递,如代码清单 5-27 所示。
代码清单 5-27 golang.org/src/net/ip.go:第 318 行到第 325 行
318 // ipEmptyString 像 ip.String 一样,
319 // 只不过在没有设置 ip 时会返回一个空字符串
320 func ipEmptyString(ip IP) string {
321 if len(ip) == 0 {
322 return ""
323 }
324 return ip.String()
325 }
在代码清单 5-27 里,有一个 ipEmptyString
函数。这个函数需要传入一个 IP
类型的值。再一次,你可以看到调用者传入的是这个引用类型的值,而不是通过引用共享给这个函数。调用者将引用类型的值的副本传入这个函数。这种方法也适用于函数的返回值。最后要说的是,引用类型的值在其他方面像原始的数据类型的值一样对待。
5.3.3 结构类型
结构类型可以用来描述一组数据值,这组值的本质即可以是原始的,也可以是非原始的。如果决定在某些东西需要删除或者添加某个结构类型的值时该结构类型的值不应该被更改,那么需要遵守之前提到的内置类型和引用类型的规范。让我们从标准库里的一个原始本质的类型的结构实现开始,如代码清单 5-28 所示。
代码清单 5-28 golang.org/src/time/time.go:第 39 行到第 55 行
39 type Time struct {
40 // sec 给出自公元 1 年 1 月 1 日 00:00:00
41 // 开始的秒数
42 sec int64
43
44 // nsec 指定了一秒内的纳秒偏移,
45 // 这个值是非零值,
46 // 必须在[0, 999999999]范围内
47 nsec int32
48
49 // loc 指定了一个 Location,
50 // 用于决定该时间对应的当地的分、小时、
51 // 天和年的值
52 // 只有 Time 的零值,其 loc 的值是 nil
53 // 这种情况下,认为处于 UTC 时区
54 loc *Location
55 }
代码清单 5-28 中的 Time
结构选自 time
包。当思考时间的值时,你应该意识到给定的一个时间点的时间是不能修改的。所以标准库里也是这样实现 Time
类型的。让我们看一下 Now
函数是如何创建 Time
类型的值的,如代码清单 5-29 所示。
代码清单 5-29 golang.org/src/time/time.go:第 781 行到第 784 行
781 func Now() Time {
782 sec, nsec := now()
783 return Time{sec + unixToInternal, nsec, Local}
784 }
代码清单 5-29 中的代码展示了 Now
函数的实现。这个函数创建了一个 Time
类型的值,并给调用者返回了 Time
值的副本。这个函数没有使用指针来共享 Time
值。之后,让我们来看一个 Time
类型的方法,如代码清单 5-30 所示。
代码清单 5-30 golang.org/src/time/time.go:第 610 行到第 622 行
610 func (t Time) Add(d Duration) Time {
611 t.sec += int64(d / 1e9)
612 nsec := int32(t.nsec) + int32(d%1e9)
613 if nsec >= 1e9 {
614 t.sec++
615 nsec -= 1e9
616 } else if nsec < 0 {
617 t.sec--
618 nsec += 1e9
619 }
620 t.nsec = nsec
621 return t
622 }
代码清单 5-30 中的 Add
方法是展示标准库如何将 Time
类型作为本质是原始的类型的绝佳例子。这个方法使用值接收者,并返回了一个新的 Time
值。该方法操作的是调用者传入的 Time
值的副本,并且给调用者返回了一个方法内的 Time
值的副本。至于是使用返回的值替换原来的 Time
值,还是创建一个新的 Time
变量来保存结果,是由调用者决定的事情。
大多数情况下,结构类型的本质并不是原始的,而是非原始的。这种情况下,对这个类型的值做增加或者删除的操作应该更改值本身。当需要修改值本身时,在程序中其他地方,需要使用指针来共享这个值。让我们看一个由标准库中实现的具有非原始本质的结构类型的例子,如代码清单 5-31 所示。
代码清单 5-31 golang.org/src/os/file_unix.go:第 15 行到第 29 行
15 // File 表示一个打开的文件描述符
16 type File struct {
17 *file
18 }
19
20 // file 是*File 的实际表示
21 // 额外的一层结构保证没有哪个 os 的客户端
22 // 能够覆盖这些数据。如果覆盖这些数据,
23 // 可能在变量终结时关闭错误的文件描述符
24 type file struct {
25 fd int
26 name string
27 dirinfo *dirInfo // 除了目录结构,此字段为 nil
28 nepipe int32 // Write 操作时遇到连续 EPIPE 的次数
29 }
可以在代码清单 5-31 里看到标准库中声明的 File
类型。这个类型的本质是非原始的。这个类型的值实际上不能安全复制。对内部未公开的类型的注释,解释了不安全的原因。因为没有方法阻止程序员进行复制,所以 File
类型的实现使用了一个嵌入的指针,指向一个未公开的类型。本章后面会继续探讨内嵌类型。正是这层额外的内嵌类型阻止了复制。不是所有的结构类型都需要或者应该实现类似的额外保护。程序员需要能识别出每个类型的本质,并使用这个本质来决定如何组织类型。
让我们看一下 Open
函数的实现,如代码清单 5-32 所示。
代码清单 5-32 golang.org/src/os/file.go:第 238 行到第 240 行
238 func Open(name string) (file *File, err error) {
239 return OpenFile(name, O_RDONLY, 0)
240 }
代码清单 5-32 展示了 Open
函数的实现,调用者得到的是一个指向 File
类型值的指针。 Open
创建了 File
类型的值,并返回指向这个值的指针。如果一个创建用的工厂函数返回了一个指针,就表示这个被返回的值的本质是非原始的。
即便函数或者方法没有直接改变非原始的值的状态,依旧应该使用共享的方式传递,如代码清单 5-33 所示。
代码清单 5-33 golang.org/src/os/file.go:第 224 行到第 232 行
224 func (f *File) Chdir() error {
225 if f == nil {
226 return ErrInvalid
227 }
228 if e := syscall.Fchdir(f.fd); e != nil {
229 return &PathError{"chdir", f.name, e}
230 }
231 return nil
232 }
代码清单 5-33 中的 Chdir
方法展示了,即使没有修改接收者的值,依然是用指针接收者来声明的。因为 File
类型的值具备非原始的本质,所以总是应该被共享,而不是被复制。
是使用值接收者还是指针接收者,不应该由该方法是否修改了接收到的值来决定。这个决策应该基于该类型的本质。这条规则的一个例外是,需要让类型值符合某个接口的时候,即便类型的本质是非原始本质的,也可以选择使用值接收者声明方法。这样做完全符合接口值调用方法的机制。5.4 节会讲解什么是接口值,以及使用接口值调用方法的机制。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论