Skip to content

Go 语言实战 第 5 章 Go 语言的类型系统

🏷️ Go 《Go 语言实战》

Go 语言是一种静态类型的编程语言。这就意味着,编译器需要在编译时知晓程序里每个值的类型。

值的类型给编译器提供两个信息:

  1. 需要分配多少内存给这个值(即值的规模)
  2. 这段内存表示什么

5.1 用户定义类型

Go 语言允许用户定义类型。当用户声明一个类型时,这个声明就给编译器提供了一个框架,告知必要的内存大小和表示信息。

使用 struct 关键字声明结构:

go
type user struct {
    name        string
    email       string
    ext         int
    privileged  bool
}

声明 user 类型的变量:

go
var bill user

当声明变量时,这个变量对应的值总是会被初始化。

任何时候,创建一个变量并初始化为零值,习惯是使用关键字 var

使用结构字面量声明一个结构类型的变量:

go
jiajia := user{
    name:       "JiaJia",
    email:      "liu_jiajia@outlook.com",
    ext:        123,
    privileged: true,
}

结构字面量还有第二种形式:没有字段名,只声明对应的值。

go
jiajia := user{"JiaJia", "liu_jiajia@outlook.com", 123, true}

这种形式下,值的顺序很重要,必须和结构声明中字段的顺序一致。

使用其它结构类型声明字段:

go
type admin struct {
    person  user
    level   string
}
go
root := admin{
    person: user{
        name:       "JiaJia",
        email:      "liu_jiajia@outlook.com",
        ext:        123,
        privileged: true,
    },
    level: "super",
}

基于 int64 声明一个新类型:

go
type Duration int64

在这个类型声明中,int64 类型叫做 Duration 的基础类型。
但这两个类型是完全不同的有区别的类型。
两种不同类型的值即便互相兼容,也不能互相赋值。
编译器不会对不同类型的值做隐式转换。

5.2 方法

方法能给用户定义的类型添加新的行为。

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

go
// user defines a user in the program.
type user struct {
    name  string
    email string
}

// notify implements a method with a value receiver.
func (u user) notify() {
    fmt.Printf("Sending User Email To %s<%s>\n",
        u.name,
        u.email)
}

// changeEmail implements a method with a pointer receiver.
func (u *user) changeEmail(email string) {
    u.email = email
}

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

go
// Values of type user can be used to call methods
// declared with a value receiver.
bill := user{"Bill", "bill@email.com"}
bill.notify()

也可以使用指针来调用使用值接收者声明的方法。

go
// Pointers of type user can also be used to call methods
// declared with a value receiver.
lisa := &user{"Lisa", "lisa@email.com"}
lisa.notify()

为了支持这种方法调用,Go 语言调整了指针的值,来符合方法接收者的定义。
可以认为 Go 语言执行了如下操作:

go
(*lisa).notify()

指针被解引用为值,这样就符合了值接收者的要求。
notify() 操作的是一个副本,只不过这次操作的是从 lisa 指针指向的值的副本(不是 lisa 指针的副本)。

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

总结一下,值接收者使用值的副本来调用方法,而指针接收者使用实际值来调用方法。

也可以使用一个值来调用使用指针接收者声明的方法。

go
// Values of type user can be used to call methods
// declared with a pointer receiver.
bill.changeEmail("bill@newdomain.com")
bill.notify()

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

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

Go 语言既允许使用值,也允许使用指针来调用方法,不必严格符合接收者的类型。这个支持非常方便开发者编写程序。

5.3 类型的本质

在声明一个新类型之后,声明一个该类型的方法之前,需要先回答一个问题:这个类型的本质是什么
如果给这个类型增加或者删除某个值,是要创建一个新值,还是要更改当前的值?

  • 如果是要创建一个新值,该类型的方法就使用值接收者。
  • 如果是要修改当前值,就使用指针接收者。
    这个答案也会影响程序内部传递这个类型的值的方式:是按值做传递,还是按指针做传递。
    保持传递的一致性很重要。
    这个背后的原则是,不要只关注某个方法是如何处理这个值的,而是要关注这个值的本质是什么。

内置类型

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

JiaJia:

一直没理解这里所说的原始的类型是什么意思。

引用类型

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

引用类型的值在其它方面像原始的数据类型的值一样对待。

JiaJia:

这里引用类型的值指的是标头值?这里又出现了一个原始的数据类型的值,所以说原始的数据类型到底是什么意思?

结构类型

结构类型可以用来描述一组数据值,这组值的本质既可以是原始的,也可以是非原始的。

原始的结构类型可以参考标准库里的 time 类型。

go
type Time struct {
    wall uint64
    ext  int64
    loc *Location
}

func Now() Time {
    sec, nsec, mono := now()
    mono -= startNano
    sec += unixToInternal - minWall
    if uint64(sec)>>33 != 0 {
        return Time{uint64(nsec), sec + minWall, Local}
    }
    return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local}
}

// Add returns the time t+d.
func (t Time) Add(d Duration) Time {
    dsec := int64(d / 1e9)
    nsec := t.nsec() + int32(d%1e9)
    if nsec >= 1e9 {
        dsec++
        nsec -= 1e9
    } else if nsec < 0 {
        dsec--
        nsec += 1e9
    }
    t.wall = t.wall&^nsecMask | uint64(nsec) // update nsec
    t.addSec(dsec)
    if t.wall&hasMonotonic != 0 {
        te := t.ext + int64(d)
        if d < 0 && te > t.ext || d > 0 && te < t.ext {
            // Monotonic clock reading now out of range; degrade to wall-only.
            t.stripMono()
        } else {
            t.ext = te
        }
    }
    return t
}

大多数情况下,结构类型的本质并不是原始的,而是非原始的。

go
// File represents an open file descriptor.
type File struct {
    *file // os specific
}

// file is the real representation of *File.
// The extra level of indirection ensures that no clients of os
// can overwrite this data, which could cause the finalizer
// to close the wrong file descriptor.
type file struct {
    pfd        poll.FD
    name       string
    dirinfo    *dirInfo // nil unless directory being read
    appendMode bool     // whether file is opened for appending
}

File 类型包含一个内嵌类型(file)的指针,这种结构阻止了复制。

go
// Open opens the named file for reading. If successful, methods on
// the returned file can be used for reading; the associated file
// descriptor has mode O_RDONLY.
// If there is an error, it will be of type *PathError.
func Open(name string) (*File, error) {
    return OpenFile(name, O_RDONLY, 0)
}

如果一个创建用的工厂函数返回一个指针,就表示这个被返回的值的本质是非原始的。

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

go
// Chdir changes the current working directory to the file,
// which must be a directory.
// If there is an error, it will be of type *PathError.
func (f *File) Chdir() error {
    if err := f.checkValid("chdir"); err != nil {
        return err
    }
    if e := syscall.Fchdir(f.fd); e != nil {
        return &PathError{"chdir", f.name, e}
    }
    return nil
}

因为 File 类型的值具备非原始的本质,所以总是应该被共享,而不是被复制。

是使用值接收者还是指针接收者,不应该由该方法是否修改了接收到的值来决定。这个决策应该基于该类型的本质。

这条规则的一个例外是,需要让类型值符合某个接口的时候,即便类型的本质是非原始的,也可以选择使用值接收者声明方法。
这样做完全符合接口值调用方法的机制。

5.4 接口

多态是指代码可以根据类型的具体实现采取不同行为的能力。
如果一个类型实现了某个接口,所有使用这个接口的地方,都可以支持这种类型的值。

接口是用来定义行为的类型。这些被定义的行为不由接口直接实现,而是通过方法由用户定义的类型实现。
因为任何用户定义的类型都可以实现任何接口,所有对接口值方法的调用自然就是一种多态。
用户定义的类型通常叫做实体类型,原因是如果离开内部存储的用户定义的类型的值的实现,接口值并没有具体的行为。

接口值是一个两个字长度的数据结构:

  1. 第一个字包含一个指向内部表的指针。
    这个内部表叫做 iTable ,包含了所存储的值的类型信息。
    iTable 包含了已存储的值的类型信息以及与这个值相关联的一组方法。
  2. 第二个字是一个指向所存储值的指针。

方法集

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

方法集定义了一组关联到给定类型的值或者指针的方法。
定义方法时使用的接收者的类型决定了这个方法是关联到值,还是关联到指针,还是两个都关联。

  1. 关联到值
  2. 关联到指针
  3. 两个都关联

JiaJia:

有上面的第二种情况(关联到指针)吗?

  • T 类型的值的方法集只包含值接收者声明的方法。
  • 指向 T 类型的指针的方法集既包含值接收者声明的方法,也包含指针接收者声明的方法。

接收者视角:

  • 如果使用指针接收者来实现一个接口,那么只有指向那个类型的指针才能够实现对应的接口。
  • 如果使用值接收者来实实现一个接口,那么那个类型的值和指针都能够实现对应的接口。

为什么会有这种限制?事实上,编译器并不是总能自动获得一个值的地址。

go
// Sample program to show how you can't always get the
// address of a value.
package main

import "fmt"

// duration is a type with a base type of int.
type duration int

// format pretty-prints the duration value.
func (d *duration) pretty() string {
    return fmt.Sprintf("Duration: %d", *d)
}

// main is the entry point for the application.
func main() {
    duration(42).pretty()

    // ./listing46.go:17: cannot call pointer method on duration(42)
    // ./listing46.go:17: cannot take the address of duration(42)
}

JiaJia:

尝试运行时确实报了 cannot take the address of duration(42) 这个错误,但是没有说这种情况为什么获取不到这个值的地址。

按照上一小结中讲到的,Go 语言会自动对值做调整。
此时会调整成如下形式,但是仍然报错。

go
(&duration(42)).pretty()

但是修改成如下形式就可以了:

go
d := duration(42)
(&d).pretty()

不理解这两种写法有什么区别。因为没有标头?

多态

go
// Sample program to show how polymorphic behavior with interfaces.
package main

import (
    "fmt"
)

// notifier is an interface that defines notification
// type behavior.
type notifier interface {
    notify()
}

// user defines a user in the program.
type user struct {
    name  string
    email string
}

// notify implements the notifier interface with a pointer receiver.
func (u *user) notify() {
    fmt.Printf("Sending user email to %s<%s>\n",
        u.name,
        u.email)
}

// admin defines a admin in the program.
type admin struct {
    name  string
    email string
}

// notify implements the notifier interface with a pointer receiver.
func (a *admin) notify() {
    fmt.Printf("Sending admin email to %s<%s>\n",
        a.name,
        a.email)
}

// main is the entry point for the application.
func main() {
    // Create a user value and pass it to sendNotification.
    bill := user{"Bill", "bill@email.com"}
    sendNotification(&bill)

    // Create an admin value and pass it to sendNotification.
    lisa := admin{"Lisa", "lisa@email.com"}
    sendNotification(&lisa)
}

// sendNotification accepts values that implement the notifier
// interface and sends notifications.
func sendNotification(n notifier) {
    n.notify()
}

5.5 嵌入类型

Go 语言允许用户扩展或者修改已有类型的行为。这个功能是通过 嵌入类型(type embedding 完成的。

通过嵌入类型,与内部类型相关的标识符会提升到外部类型上。
这些被提升的标识符就像直接声明在外部类型里的标识符一样,也是外部类型的一部分。

外部类型也可以通过声明与内部类型标识符同名的标识符来覆盖内部标识符的字段或者方法。

go
// user defines a user in the program.
type user struct {
    name  string
    email string
}

// notify implements a method that can be called via
// a value of type user.
func (u *user) notify() {
    fmt.Printf("Sending user email to %s<%s>\n",
        u.name,
        u.email)
}

要嵌入一个类型,只需要声明这个类型的名字就可以了。

JiaJia:

貌似可以嵌入多个类型。

go
// admin represents an admin user with privileges.
type admin struct {
    user  // Embedded Type
    level string
}

JiaJia:

如果此时添加一个 user string 的字段,会报 Duplicate field 'user' 的错误。

go
// main is the entry point for the application.
func main() {
    // Create an admin user.
    ad := admin{
        user: user{
            name:  "john smith",
            email: "john@yahoo.com",
        },
        level: "super",
    }

    // We can access the inner type's method directly.
    ad.user.notify()

    // The inner type's method is promoted.
    ad.notify()
}

对于外部类型来说,内部类型总是存在的。这就意味着,虽然没有指定内部类型对应的字段名,还是可以使用内部类型的类型名,来访问内部类型的值。

如果将 notify() 方法提取为接口,并创建一个接收 notifier 接口的 sendNotification 方法,通过该方法来调用 notify() 方法。

go
// notifier is an interface that defined notification
// type behavior.
type notifier interface {
    notify()
}

// sendNotification accepts values that implement the notifier
// interface and sends notifications.
func sendNotification(n notifier) {
    n.notify()
}
go
// main is the entry point for the application.
func main() {
    // Create an admin user.
    ad := admin{
        user: user{
            name:  "john smith",
            email: "john@yahoo.com",
        },
        level: "super",
    }

    // Send the admin user a notification.
    // The embedded inner type's implementation of the
    // interface is "promoted" to the outer type.
    sendNotification(&ad)
}

由于内部类型的提升,内部类型实现的接口会自动提升到外部类型。
这意味着由于内部类型的实现,外部类型也同样实现了这个接口。

由于 notify() 是指针接收者,所以参数为 &ad

JiaJia:

此时,如果将参数改为 ad 则会报如下错误:

cannot use ad (type admin) as type notifier in argument to sendNotification:
admin does not implement notifier (notify method has pointer receiver)

也就是说此时,admin 类型的值,并不能调用 notify() 这个指针接收者方法,即 admin 类型的值的方法集中不包含 notify() 方法。

不知道我这个理解对不对?

外部类型可以通过实现同样的接口来覆盖嵌入类型的接口实现。

go
// notify implements a method that can be called via
// a value of type Admin.
func (a *admin) notify() {
    fmt.Printf("Sending admin email to %s<%s>\n",
        a.name,
        a.email)
}

此时,内部类型的 notify() 方法没有被提升,但仍然可以通过访问内部类型的同名字段来调用内部类型的 notify() 方法。

go
// Send the admin user a notification.
// The embedded inner type's implementation of the
// interface is NOT "promoted" to the outer type.
sendNotification(&ad)

// We can access the inner type's method directly.
ad.user.notify()

// The inner type's method is NOT promoted.
ad.notify()

5.6 公开或未公开的标识符

Go 语言支持从包里公开或者隐藏标识符。

当要写的代码属于某个包时,好的实践是使用与代码所在文件夹一样的名字作为包名。所有 Go 工具都会利用这个习惯,所以最好遵守这个好的实践。

go
// Package counters provides alert counter support.
package counters

// alertCounter is an unexported type that
// contains an integer counter for alerts.
type alertCounter int

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

go
// Sample program to show how the program can't access an
// unexported identifier from another package.
package main

import (
    "fmt"
    "github.com/goinaction/code/chapter5/listing64/counters"
)

// main is the entry point for the application.
func main() {
    // Create a variable of the unexported type and initialize
    // the value to 10.
    counter := counters.alertCounter(10)

    // ./listing64.go:15: cannot refer to unexported name
    //                                         counters.alertCounter
    // ./listing64.go:15: undefined: counters.alertCounter

    fmt.Printf("Counter: %d\n", counter)
}

由于 alertCounter 类型是未公开的,在 main 包中无法访问。
编译会报如下错误:

.\listing64.go:15:13: cannot refer to unexported name counters.alertCounter
.\listing64.go:15:13: undefined: counters.alertCounter

添加一个公开的工厂函数 New (将工厂函数命名为 New 是 Go 语言的一个习惯),使包外的代码可以获得这个未公开类型的值。

go
// Package counters provides alert counter support.
package counters

// alertCounter is an unexported type that
// contains an integer counter for alerts.
type alertCounter int

// New creates and returns values of the unexported
// type alertCounter.
func New(value int) alertCounter {
    return alertCounter(value)
}
go
// Sample program to show how the program can access a value
// of an unexported identifier from another package.
package main

import (
    "fmt"

    "github.com/goinaction/code/chapter5/listing68/counters"
)

// main is the entry point for the application.
func main() {
    // Create a variable of the unexported type using the exported
    // New function from the package counters.
    counter := counters.New(10)

    fmt.Printf("Counter: %d\n", counter)
}
  1. 标识符才有公开或者未公开的属性,值没有;
  2. 短变量声明操作符,有能力捕获引用的类型,并创建一个未公开的类型的变量。
    永远不能显式创建一个未公开类型的变量,不过短变量声明操作符可以这么做。

未公开的字段在包外也不可被访问。

go
// Package entities contains support for types of
// people in the system.
package entities

// User defines a user in the program.
type User struct {
    Name  string
    email string
}

email 字段是非公开的,在包外试图访问时会报错。

go
// Sample program to show how unexported fields from an exported
// struct type can't be accessed directly.
package main

import (
    "fmt"
    "github.com/goinaction/code/chapter5/listing71/entities"
)

// main is the entry point for the application.
func main() {
    // Create a value of type User from the entities package.
    u := entities.User{
        Name:  "Bill",
        email: "bill@email.com",
    }

    // ./example71.go:16: unknown entities.User field 'email' in
    //                    struct literal

    fmt.Printf("User: %v\n", u)
}

当内嵌类型是未公开标志时,会导致默认的内嵌类型同名字段无法访问,但是嵌套类型的公开字段由于提升到了外部,仍然是可以访问的。

go
// Package entities contains support for types of
// people in the system.
package entities

// user defines a user in the program.
type user struct {
    Name  string
    Email string
}

// Admin defines an admin in the program.
type Admin struct {
    user   // The embedded type is unexported.
    Rights int
}

此时,user 字段不能被访问,导致在使用结构字面量的方式初始化时,不能直接对未公开的内嵌类型的字段赋值。
但仍然可以在定义后直接通过外部类型的变量直接访问。

go
// Sample program to show how unexported fields from an exported
// struct type can't be accessed directly.
package main

import (
    "fmt"

    "github.com/goinaction/code/chapter5/listing74/entities"
)

// main is the entry point for the application.
func main() {
    // Create a value of type Admin from the entities package.
    a := entities.Admin{
        Rights: 10,
    }

    // Set the exported fields from the unexported
    // inner type.
    a.Name = "Bill"
    a.Email = "bill@email.com"

    fmt.Printf("User: %v\n", a)
}

5.7 小结

  • 使用关键字 struct 或者通过指定已经存在的类型,可以声明用户定义的类型。
  • 方法提供了一种给用户定义的类型增加行为的方式。
  • 设计类型时需要确认类型的本质是原始的,还是非原始的。
  • 接口是声明了一组行为并支持多态的类型。
  • 嵌入类型提供了扩展类型的能力,而无需使用继承。
  • 标识符要么是从包里公开的,要么是在包里未公开的。