Skip to content

类型系统

用户定义类型

Go 语言允许用户定义类型。当用户声明一个新类型时,这个声明就给编译器提供了一个框 Go 语言的类型系告知必要的内存大小和表示信息。声明后的类型与内置类型的运作方式类似。Go 语言里声明用户定义的类型有两种方法。最常用的方法是使用关键字 struct,它可以让用户创建一个结 构类型。

go
// user 在程序里定义一个用户类型
type user struct {
name string
email string
ext int
privileged bool
}

使用结构类型声明变量,并初始化为其零值

go
 // 声明 user 类型的变量
var bill user

关键字 var 创建了类型为 user 且名为 bill 的变量。当声明 变量时,这个变量对应的值总是会被初始化。这个值要么用指定的值初始化,要么用零值(即变 量类型的默认值)做初始化。对数值类型来说,零值是 0;对字符串来说,零值是空字符串;对 布尔类型,零值是 false。对这个例子里的结构,结构里每个字段都会用零值初始化。 任何时候,创建一个变量并初始化为其零值,习惯是使用关键字 var。这种用法是为了更明 确地表示一个变量被设置为零值。如果变量被初始化为某个非零值,就配合结构字面量和短变量 声明操作符来创建变量.

go
 // 声明 user 类型的变量,并初始化所有字段
lisa := user{
name: "Lisa",
email: "lisa@email.com",
ext: 123,
privileged: true,
}

不使用字段名,创建结构类型的值

go
// 声明 user 类型的变量
lisa := user{"Lisa", "lisa@email.com", 123, true}

另一种声明用户定义的类型的方法是,基于一个已有的类型,将其作为新类型的类型说明。当需要一个可以用已有类型表示的新类型的时候,这种方法会非常好用,标准库使用这种声明类型的方法,从内置类型创建出很多更加明确的类型,并赋予更高级的功能。

go
type Duration int64

给不同类型的变量赋值会产生编译错误

go
 package main

type Duration int64

func main() {
var dur Duration
dur = int64(1000)
}

prog.go:7: cannot use int64(1000) (type int64) as type Duration
in assignment

编译器很清楚这个程序的问题:类型 int64 的值不能作为类型 Duration 的值来用。换句 话说,虽然 int64 类型是基础类型,Duration 类型依然是一个独立的类型。两种不同类型的 值即便互相兼容,也不能互相赋值。编译器不会对不同类型的值做隐式转换。

方法

方法能给用户定义的类型添加新的行为。方法实际上也是函数,只是在声明时,在关键字 func 和方法名之间增加了一个参数

go
 // 这个示例程序展示如何声明
 // 并使用方法
 package main

 import (
 "fmt"
 )

 // user 在程序里定义一个用户类型
 type user struct {
 name string
 email string
 }

 // notify 使用值接收者实现了一个方法
 func (u user) notify() {
 fmt.Printf("Sending User Email To %s<%s>\n",
 u.name,
 u.email)
 }

 // changeEmail 使用指针接收者实现了一个方法
 func (u *user) changeEmail(email string) {
 u.email = email
 }

 // main 是应用程序的入口
 func main() {
 // user 类型的值可以用来调用
 // 使用值接收者声明的方法
 bill := user{"Bill", "bill@email.com"}
 bill.notify()

 // 指向 user 类型值的指针也可以用来调用
 // 使用值接收者声明的方法
 lisa := &user{"Lisa", "lisa@email.com"}
 lisa.notify()

 // user 类型的值可以用来调用
 // 使用指针接收者声明的方法
 bill.changeEmail("bill@newdomain.com")
 bill.notify()

 // 指向 user 类型值的指针可以用来调用
 // 使用指针接收者声明的方法
 lisa.changeEmail("lisa@newdomain.com")
 lisa.notify()

关键字 func 和函数名之间的参数被称作接收者,将函数与接收者的类型绑在一起。如果一个函数有接收者,这个函数就被称为方法。

Go 语言里有两种类型的接收者:值接收者和指针接收者。

go
func (u user) notify(){
    ...
}

notify 方法的接收者被声明为 user 类型的值。如果使用值接收者声明方法,调用时会使 用这个值的一个副本来执行

go
// user 类型的值可以用来调用
// 使用值接收者声明的方法
bill := user{"Bill", "bill@email.com"}
bill.notify()

这个语法与调用一个包里的函数看起来很类似。但在这个例子里,bill 不是包名,而是变 量名。这段程序在调用 notify 方法时,使用 bill 的值作为接收者进行调用,方法 notify 会接收到 bill 的值的一个副本。也可以使用指针来调用使用值接收者声明的方法

go
// 指向 user 类型值的指针也可以用来调用
 // 使用值接收者声明的方法
lisa := &user{"Lisa", "lisa@email.com"}
lisa.notify()

Go 在代码背后的执行动作

go
(*lisa).notify()

Go 编译器为了支持这种方法调用背后做的事情。指针被解引用为值,这样就符合了值接收者的要求。再强调一次,notify 操作的是一个副本,只不过这次操作的是从 lisa 指针指向的值的副本。

指针接收者声明方法

go
 // changeEmail 使用指针接收者实现了一个方法
func (u *user) changeEmail(email string) {
    u.email = email
}

这个方法使用指针接收者声明。这个接收者的类型是指向 user 类型值的指针,而不是 user 类型的值。当调用使用指针接收者声明的方法时,这个方法会共享调用方法时接收者所指向的值

go
lisa := &user{"Lisa", "lisa@email.com"}
// 指向 user 类型值的指针可以用来调用
// 使用指针接收者声明的方法
lisa.changeEmail("lisa@newdomain.com")

一旦 changeEmail 调用返回,这个调用对值做的修改也会反映在 lisa指针所指向的值上。这是因为 changeEmail 方法使用了指针接收者。总结一下,值接收者使用值的副本来调用方法,而指针接受者使用实际值来调用方法。

值来调用使用指针接收者声明的方法

go
bill := user{"Bill", "bill@email.com"}
// user 类型的值可以用来调用
// 使用指针接收者声明的方法
bill.changeEmail("bill@newdomain.com")

Go 语言再一次对值做了调整,使之符合函数的接收者,进行调用

go
(&bill).changeEmail ("bill@newdomain.com")

首先引用 bill 值得到一个指针,这样这个指针就能够匹配方法的接收者类型,再进行调用。Go语言既允许使用值,也允许使用指针来调用方法,不必严格符合接收者的类型。这个支持非常方便开发者编写程序。

类型的本质

在声明一个新类型之后,声明一个该类型的方法之前,需要先回答一个问题:这个类型的本质是什么。如果给这个类型增加或者删除某个值,是要创建一个新值,还是要更改当前的值?如果是要创建一个新值,该类型的方法就使用值接收者。如果是要修改当前值,就使用指针接收者。这个答案也会影响程序内部传递这个类型的值的方式:是按值做传递,还是按指针做传递。保持传递的一致性很重要。这个背后的原则是,不要只关注某个方法是如何处理这个值,而是要关注这个值的本质是什么。

内置类型

内置类型是由语言提供的一组类型。我们已经见过这些类型,分别是数值类型、字符串类型和布尔类型。这些类型本质上是原始的类型。因此,当对这些值进行增加或者删除的时候,会创建一个新值。基于这个结论,当把这些类型的值传递给方法或者函数时,应该传递一个对应值的副本。

go
 func Trim(s string, cutset string) string {
 if s == "" || cutset == "" {
 return s
 } 
 return TrimFunc(s, makeCutsetFunc(cutset))
 }

可以看到标准库里 strings 包的 Trim 函数。Trim 函数传入一个 string 类型的值作操作,再传入一个 string 类型的值用于查找。之后函数会返回一个新的 string 值作为操作结果。这个函数对调用者原始的 string 值的一个副本做操作,并返回一个 新的 string 值的副本。字符串(string)就像整数、浮点数和布尔值一样,本质上是一种很原 始的数据值,所以在函数或方法内外传递时,要传递字符串的一份副本。

引用类型

Go 语言里的引用类型有如下几个:切片、映射、通道、接口和函数类型。当声明上述类型 的变量时,创建的变量被称作标头(header)值。从技术细节上说,字符串也是一种引用类型。 每个引用类型创建的标头值是包含一个指向底层数据结构的指针。每个引用类型还包含一组独特 的字段,用于管理底层数据结构。因为标头值是为复制而设计的,所以永远不需要共享一个引用 类型的值。标头值里包含一个指针,因此通过复制来传递一个引用类型的值的副本,本质上就是 在共享底层数据结构。

go
 type IP []byte

一个名为 IP 的类型,这个类型被声明为字节切片。当要围绕相关的内 置类型或者引用类型来声明用户定义的行为时,直接基于已有类型来声明用户定义的类型会很好 用。编译器只允许为命名的用户定义的类型声明方法

go
 func (ip IP) MarshalText() ([]byte, error) {
if len(ip) == 0 {
return []byte(""), nil
} 
if len(ip) != IPv4len && len(ip) != IPv6len {
return nil, errors.New("invalid IP address")
} 
return []byte(ip.String()), nil
}

里定义的 MarshalText 方法是用 IP 类型的值接收者声明的。一个值接收者,正像预期的那样通过复制来传递引用,从而不需要通过指针来共享引用类型的值。这种传递方法也可以应用到函数或者方法的参数传递

go
 // ipEmptyString 像 ip.String 一样, 
// 只不过在没有设置 ip 时会返回一个空字符串
func ipEmptyString(ip IP) string {
if len(ip) == 0 {
return ""
} 
return ip.String()
}

有一个 ipEmptyString 函数。这个函数需要传入一个 IP 类型的值。再一次,你可以看到调用者传入的是这个引用类型的值,而不是通过引用共享给这个函数。调用者将引用类型的值的副本传入这个函数。这种方法也适用于函数的返回值。最后要说的是,引用类型的值在其他方面像原始的数据类型的值一样对待。

结构类型

结构类型可以用来描述一组数据值,这组值的本质即可以是原始的,也可以是非原始的。如 果决定在某些东西需要删除或者添加某个结构类型的值时该结构类型的值不应该被更改,那么需 要遵守之前提到的内置类型和引用类型的规范。让我们从标准库里的一个原始本质的类型的结构 实现开始

go
 type Time struct {
 // sec 给出自公元 1 年 1 月 1 日 00:00:00
 // 开始的秒数
 sec int64

 // nsec 指定了一秒内的纳秒偏移,
 // 这个值是非零值,
 // 必须在[0, 999999999]范围内
 nsec int32

 // loc 指定了一个 Location, 
 // 用于决定该时间对应的当地的分、小时、
 // 天和年的值
 // 只有 Time 的零值,其 loc 的值是 nil
 // 这种情况下,认为处于 UTC 时区
 loc *Location
 }

当思考时间的值时,你应该意识到给定的一个时间点的时间是不能修改的。所以标准库里也是这样实现 Time 类型的。让我们看一下 Now 函数是如何创建 Time 类型的值的

go
func Now() Time {
sec, nsec := now()
return Time{sec + unixToInternal, nsec, Local}
}

这个函数创建了一个 Time 类型的值,并给调用者返回了 Time 值的副本。这个函数没有使用指针来共享 Time 值。之后,让我们来看一个 Time 类型的方法。

go
 func (t Time) Add(d Duration) Time {
t.sec += int64(d / 1e9)
nsec := int32(t.nsec) + int32(d%1e9)
if nsec >= 1e9 {
t.sec++
nsec -= 1e9
} else if nsec < 0 {
t.sec--
nsec += 1e9
} 
t.nsec = nsec
return t
}

Add 方法是展示标准库如何将 Time 类型作为本质是原始的类型的绝佳例子。这个方法使用值接收者,并返回了一个新的 Time 值。该方法操作的是调用者传入的 Time 值的副本,并且给调用者返回了一个方法内的 Time 值的副本。至于是使用返回的值替换原来的 Time 值,还是创建一个新的 Time 变量来保存结果,是由调用者决定的事情。

大多数情况下,结构类型的本质并不是原始的,而是非原始的。这种情况下,对这个类型的值做增加或者删除的操作应该更改值本身。当需要修改值本身时,在程序中其他地方,需要使用指针来共享这个值。让我们看一个由标准库中实现的具有非原始本质的结构类型的例子

go
 // File 表示一个打开的文件描述符
type File struct {
*file
}

// file 是*File 的实际表示
// 额外的一层结构保证没有哪个 os 的客户端
// 能够覆盖这些数据。如果覆盖这些数据,
// 可能在变量终结时关闭错误的文件描述符
type file struct {
fd int
name string
dirinfo *dirInfo // 除了目录结构,此字段为 nil
nepipe int32 // Write 操作时遇到连续 EPIPE 的次数
}

这个类型的本质是非原始的。这个类型的值实际上不能安全复制。对内部未公开的类型的注释,解释了不安全的原因。因为没有方法阻止程序员进行复制,所以 File 类型的实现使用了一个嵌入的指针,指向一个未公开的类型。本章后面会继续探讨内嵌类型。正是这层额外的内嵌类型阻止了复制。不是所有的结构类型 都需要或者应该实现类似的额外保护。程序员需要能识别出每个类型的本质,并使用这个本质来 决定如何组织类型。

go
func Open(name string) (file *File, err error) {
 return OpenFile(name, O_RDONLY, 0)
}

Open 函数的实现,调用者得到的是一个指向 File 类型值的指针。Open 创建了 File 类型的值,并返回指向这个值的指针。如果一个创建用的工厂函数返回了一个指针,就表示这个被返回的值的本质是非原始的。

即便函数或者方法没有直接改变非原始的值的状态,依旧应该使用共享的方式传递

go
 func (f *File) Chdir() error {
 if f == nil {
 return ErrInvalid
 } 
 if e := syscall.Fchdir(f.fd); e != nil {
 return &PathError{"chdir", f.name, e}
 } 
 return nil
 }

Chdir 方法展示了,即使没有修改接收者的值,依然是用指针接收者来 声明的。因为 File 类型的值具备非原始的本质,所以总是应该被共享,而不是被复制。 是使用值接收者还是指针接收者,不应该由该方法是否修改了接收到的值来决定。这个决策 应该基于该类型的本质。这条规则的一个例外是,需要让类型值符合某个接口的时候,即便类型 的本质是非原始本质的,也可以选择使用值接收者声明方法。这样做完全符合接口值调用方法的 机制。

接口

多态是指代码可以根据类型的具体实现采取不同行为的能力。如果一个类型实现了某个接口,所有使用这个接口的地方,都可以支持这种类型的值。标准库里有很好的例子,如 io 包里实现的流式处理接口。io 包提供了一组构造得非常好的接口和函数,来让代码轻松支持流式数据处理。只要实现两个接口,就能利用整个 io 包背后的所有强大能力。不过,我们的程序在声明和实现接口时会涉及很多细节。即便实现的是已有接口,也需要了解这些接口是如何工作的。

实现

接口是用来定义行为的类型。这些被定义的行为不由接口直接实现,而是通过方法由用户 定义的类型实现。如果用户定义的类型实现了某个接口类型声明的一组方法,那么这个用户定 义的类型的值就可以赋给这个接口类型的值。这个赋值会把用户定义的类型的值存入接口类型 的值。

对接口值方法的调用会执行接口值里存储的用户定义的类型的值对应的方法。因为任何用户 定义的类型都可以实现任何接口,所以对接口值方法的调用自然就是一种多态。在这个关系里, 用户定义的类型通常叫作实体类型,原因是如果离开内部存储的用户定义的类型的值的实现,接 口值并没有具体的行为。

下图展示了在 user 类型值赋值后接口变量的值的内部布局。接口值是一个两个字长度 的数据结构,第一个字包含一个指向内部表的指针。这个内部表叫作 iTable,包含了所存储的 值的类型信息。iTable 包含了已存储的值的类型信息以及与这个值相关联的一组方法。第二个 字是一个指向所存储值的指针。将类型信息和指针组合在一起,就将这两个值组成了一种特殊 的关系。

下图展示了一个指针赋值给接口之后发生的变化。在这种情况里,类型信息会存储一个指 向保存的类型的指针,而接口值第二个字依旧保存指向实体值的指针。

方法集

方法集定义了接口的接受规则。

go
 // 这个示例程序展示 Go 语言里如何使用接口
 package main

 import (
 "fmt"
 )

 // notifier 是一个定义了
 // 通知类行为的接口
 type notifier interface {
 notify()
 }

 // user 在程序里定义一个用户类型
 type user struct {
 name string
 email string
 }

 // notify 是使用指针接收者实现的方法
 func (u *user) notify() {
 fmt.Printf("Sending user email to %s<%s>\n",
 u.name,
 u.email)
 }

 // main 是应用程序的入口
 func main() {
 // 创建一个 user 类型的值,并发送通知
 u := user{"Bill", "bill@email.com"}

 sendNotification(u)

 // ./listing36.go:32: 不能将 u(类型是 user)作为
 // sendNotification 的参数类型 notifier:
 // user 类型并没有实现 notifier
 // (notify 方法使用指针接收者声明)
 }

 // sendNotification 接受一个实现了 notifier 接口的值
 // 并发送通知
 func sendNotification(n notifier) {
 n.notify()
 }

代码中的程序虽然看起来没问题,但实际上却无法通过编译。声明了一个名为 notifier 的接口,包含一个名为 notify 的方法。声明了名为 user的实体类型,并通过方法声明实现了 notifier 接口。这个方法是使用 user 类型的指针接收者实现的

要了解用指针接收者来实现接口时为什么 user 类型的值无法实现该接口,需要先了解方法 集。方法集定义了一组关联到给定类型的值或者指针的方法。定义方法时使用的接收者的类型决 定了这个方法是关联到值,还是关联到指针,还是两个都关联。

规范里描述的方法集

Values Methods Receivers
-----------------------------------------------
 T (t T)
*T (t T) and (t *T)

描述中说到,T 类型的值的方法集只包含值接收者声明的方法。而指向 T 类型的指针的方法集既包含值接收者声明的方法,也包含指针接收者声明的方法。从值的角度看这些规则,会显得很复杂。让我们从接收者的角度来看一下这些规则

Methods Receivers Values 
-----------------------------------------------
(t T) T and *T
(t *T) *T

这个规则说,如果使用指针接收者来实现一个接口,那么只有指向那个类型的指针才能够实现对应的接口。如果使用值接收者来实现一个接口,那么那个类型的值和指针都能够实现对应的接口。因此,上述代码可以改成:

go
 func main() {
 // 使用 user 类型创建一个值,并发送通知
 u := user{"Bill", "bill@email.com"}

 sendNotification(&u)

 // 传入地址,不再有错误
 }

这个程序终于可以编译并且运行。因为使用指针接收者实现的接口,只有 user 类型的指针可以传给 sendNotification 函数。

多态

现在了解了接口和方法集背后的机制,最后来看一个展示接口的多态行为的例子

go
 // 这个示例程序使用接口展示多态行为
 package main

 import (
 "fmt"
 )

 // notifier 是一个定义了
 // 通知类行为的接口
 type notifier interface {
 notify()
 }

 // user 在程序里定义一个用户类型
 type user struct {
 name string
 email string
 }

 // notify 使用指针接收者实现了 notifier 接口
 func (u *user) notify() {
 fmt.Printf("Sending user email to %s<%s>\n",
 u.name,
 u.email)
 }

 // admin 定义了程序里的管理员
 type admin struct {
 name string
 email string
 }

 // notify 使用指针接收者实现了 notifier 接口
 func (a *admin) notify() {
 fmt.Printf("Sending admin email to %s<%s>\n",
 a.name,
 a.email)
 }

 // main 是应用程序的入口
 func main() {
 // 创建一个 user 值并传给 sendNotification
 bill := user{"Bill", "bill@email.com"}
 sendNotification(&bill)

 // 创建一个 admin 值并传给 sendNotification
 lisa := admin{"Lisa", "lisa@email.com"}
 sendNotification(&lisa)
 }

 // sendNotification 接受一个实现了 notifier 接口的值
 // 并发送通知
 func sendNotification(n notifier) {
 n.notify()
 }

嵌入类型

Go 语言允许用户扩展或者修改已有类型的行为。这个功能对代码复用很重要,在修改已有 类型以符合新类型的时候也很重要。这个功能是通过嵌入类型(type embedding)完成的。嵌入类 型是将已有的类型直接声明在新的结构类型里。被嵌入的类型被称为新的外部类型的内部类型。 通过嵌入类型,与内部类型相关的标识符会提升到外部类型上。这些被提升的标识符就像直 接声明在外部类型里的标识符一样,也是外部类型的一部分。这样外部类型就组合了内部类型包 含的所有属性,并且可以添加新的字段和方法。外部类型也可以通过声明与内部类型标识符同名 的标识符来覆盖内部标识符的字段或者方法。这就是扩展或者修改已有类型的方法。

go
 // 这个示例程序展示如何将一个类型嵌入另一个类型,以及
 // 内部类型和外部类型之间的关系
 package main

 import (
 "fmt"
 )

 // user 在程序里定义一个用户类型
 type user struct {
 name string
 email string
 }

 // notify 实现了一个可以通过 user 类型值的指针
 // 调用的方法
 func (u *user) notify() {
 fmt.Printf("Sending user email to %s<%s>\n",
 u.name,
 u.email)
 }

 // admin 代表一个拥有权限的管理员用户
 type admin struct {
 user // 嵌入类型
 level string
 }

 // main 是应用程序的入口
 func main() {
 // 创建一个 admin 用户
 ad := admin{
 user: user{
 name: "john smith",
 email: "john@yahoo.com",
 },
 level: "super",
 } 

 // 我们可以直接访问内部类型的方法
 ad.user.notify()

 // 内部类型的方法也被提升到外部类型
 ad.notify()
 }

一旦我们将 user 类型嵌入 admin,我们就可以说 user 是外部类型 admin 的内部类型。有了内部类型和外部类型这两个概念,就能更容易地理解这两种类型之间的关系。

对外部类型来说,内部类型总是存在的。这就意味着,虽然没有指定内部类型对应的字段名,还是可以使用内部类型的类型名,来访问到内部类型的值。由于内部类型的标识符提升到了外部类型,我们可以直接通过外部类型的值来访问内部类型的标识符。

我们修改一下这个例子,加入一个接口:

go
 // 这个示例程序展示如何将嵌入类型应用于接口
 package main

 import (
 "fmt"
 )

 // notifier 是一个定义了
 // 通知类行为的接口
 type notifier interface {
 notify()
 }

 // user 在程序里定义一个用户类型
 type user struct {
 name string
 email string
 }

 // 通过 user 类型值的指针
 // 调用的方法
 func (u *user) notify() {
 fmt.Printf("Sending user email to %s<%s>\n",
 u.name,
 u.email)
 }

 // admin 代表一个拥有权限的管理员用户
 type admin struct {
 user
 level string
 }

 // main 是应用程序的入口
 func main() {
 // 创建一个 admin 用户
 ad := admin{
 user: user{
 name: "john smith",
 email: "john@yahoo.com",
 },
 level: "super",
 } 

 // 给 admin 用户发送一个通知
 // 用于实现接口的内部类型的方法,被提升到
 // 外部类型
 sendNotification(&ad)
 }

 // sendNotification 接受一个实现了 notifier 接口的值
 // 并发送通知
 func sendNotification(n notifier) {
 n.notify()
 }

声明了一个 notifier 接口。有一个sendNotification 函数,接受 notifier 类型的接口的值。我们将这个外部类型变量的地址传给 sendNotification 函数。编译器认为这个指针实现了 notifier 接口,并接受了这个值的传递。不过如果看一下整个示例程序,就会发现 admin 类型并没有实现 这个接口。由于内部类型的提升,内部类型实现的接口会自动提升到外部类型。这意味着由于内部类型的 实现,外部类型也同样实现了这个接口。

如果外部类型并不需要使用内部类型的实现,而想使用自己的一套实现,该怎么办?让我们 看另一个示例程序是如何解决这个问题的

go
 // 通过 admin 类型值的指针
// 调用的方法
 func (a *admin) notify() {
 fmt.Printf("Sending admin email to %s<%s>\n",
 a.name,
 a.email)
 }

通过增加上述代码,实现方法的重写。这表明,如果外部类型实现了 notify 方法,内部类型的实现就不会被提升。不过内部类型的值一直存在,因此还可以通过直接访问内部类型的值,来调用没有被提升的内部类型实现的方法。

公开或未公开的标识符

要想设计出好的 API,需要使用某种规则来控制声明后的标识符的可见性。Go 语言支持从 包里公开或者隐藏标识符。通过这个功能,让用户能按照自己的规则控制标识符的可见性。

当一个标识符的名字以小写字母开头时,这个标识符就是未公开的,即包外的代码不可见。 如果一个标识符以大写字母开头,这个标识符就是公开的,即被包外的代码可见。

Released under the MIT License.