Go语言的类型系统 - 翻译

老丐说码

概览

本文涉及到下面的几个方面:

  • 声明新的用户自定义类型
  • 为类型添加行为
  • 何时用值类型何时用指针类型
  • 使用接口实现多态
  • 通过组合扩展和改变类型
  • 标识符的暴露与不暴露

Go语言是一种静态类型的编程语言。编译器总是需要知道程序中的每个值的类型是什么。编译器提前知道值的类型信息,可以帮助程序安全的处理这些值。 这样可以减少潜在的bug或内存的破坏,同时还有机会让编译器产生更有效的代码。

变量的值类型为编译器提供两条信息:

  1. 值的尺寸: 需要为值分配多少内存。
  2. 内存代表什么。

很多内置类型中,类型名同时包含了值尺寸和所代表的东西。 比如int64类型表示需要8个字节内存(64位), 代表的是整数值。 float32类型表示需要4个字节内存(32位), 代表的是IEEE-754浮点数。bool类型需要一个字节内存(8位), 代表的是布尔值true或false。

而有些类型具体代表什么,是和机器的代码架构相关的。 例如, int类型的值,尺寸可能是64位,也有可能是32位,具体要看所在机器的架构情况了。 还有一些和架构相关的其他类型, 例如Go语言中的所有引用类型都是和架构相关的。比较幸运的是,这些类型的值,在创建和处理的时候,你无需知道这些信息。但是编译器不知道这些信息的话,它就不能防止你做一些可能引起伤害程序本身或者运行机器的事情了。

自定义类型

Go语言支持自定义类型的声明。在声明新类型的时候,构建的声明为编译器提供值的尺寸和内存所代表信息, 这点和内置类型的工作方式类似。Go语言中有两种声明用于自定义类型的方法。 最常用的就是用关键词struct来创建组合类型。

struct(结构体)是由固定的独立字段组合起来声明的。 结构体中的每个字段都由已知类型来声明的,这些已知类型可以是内置类型,也可以是用户自定义类型。

type user struct {
    name string
    email string
    exp int
    privileged bool
}

上面就声明了一个结构体类型。声明以type关键词开头,然后是新类型的名字,最后是关键词struct。 这个结构体包含四个字段,它们都是内置类型。 你可以看这些字段如何组合在一起形成新的类型。 类型一旦声明好,就可以使用它创建值。

var bill user

上面我们通过关键词var创建一个名为bill的user类型变量。当声明变量的时候,代表变量的值总是被初始化的。 可以使用特定值或对应类型的零值(变量类型的默认值)来初始化它们的值。

数字类型的零值是0。字符串的零值是空字符串。布尔值的零值是false。

上面的结构体中,零值需要应用到结构体中的每个字段。

每当变量被创建并初始化为它的零值时,我们习惯使用var关键词。保留对关键词var的使用,表示变量被设置为零值的一种方式。如果我们需要将变量初始化为零值意外的值,我们可以使用短变量声明符后面带一个结构体字面量。
lisa := user{
    name: "Lisa",
    email: "lisa@email.com",
    ext: 124,
    privileged: true,
}

注意短变量声明符(:=)前面的变量不能是已经声明的变量。 结构体字面量和类型后面跟上打括号构成,结构体中的每个字段名和字段值以冒号分割,值后面必须跟一个逗号。

短变量声明符(:=)提供两个目的,声明变量和初始化变量。 根据操作符右边的类型信息,短变量声明符可以确定变量的类型。

既然我们创建并初始化了一个结构体类型,那么我们就可以使用结构体字面量来执行初始化。

结构体类型的结构体字面量可以接受两种格式的内容。 上面展示的是第一种,括号里边列出每个字段名和字段值,中间用冒号分割,然后在值后面加上逗号。 字段顺序可以随意排放。

第二种形式可以省略字段名,只用值来声明。 如下所示:

lisa := user{"Lisa", "lisa@email.com", 123, true}

这种形式的值也可以分成多行列出, 但是上面这种形式的传统值都是放一行里边的,结尾没有逗号。这种情况下, 值的顺序就非常重要了,需要匹配结构体声明中的字段顺序。

当声明结构体类型是,不限制仅使用内置类型。你还可以使用一些使用其他自定义类型的字段。

type admin struct {
    person user
    level string
}

上面我们定义了一个新的admin结构体类型。这个结构体类型有一个名字为person的字段,类型为user, 另外还有一个string类型的level字段。创建这样的一个变量,初始化该类型时结构体字面量稍有变化。

// Declare a variable of type admin.
fred := admin{
    person: user{
        name: "Lisa",
        email: "lisa@email.com",
        ext: 123,
        privileged: true,
    },
    level: "super",
}

要初始化person字段,需要创建一个user类型的值。这就是上面我们的lisa变量的字面量。 使用结构体字面量形式,user类型的值被创建并赋值给person字段。

另外一种声明自定义类型的方式是使用现有类型,让现有类型作为类型的类型规范。在新类型可以用现有类型表示的情况中,这种申明方式非常有用。标准库中就有很多使用这种声明方式从内置类型创建高级类别功能的例子。

type Duration int64

上面就是标准库time中声明Duration类型的代码。Duration代表的是持续的纳秒时间。这个类型代表的是内置类型int64。 Duration和int64是两个有区别的、不同的类型。

为了更好的阐明这一点,我们可以看看下面这个不能编译的小程序。

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的基础类型, Duration仍然属于它自己的唯一类型。不同类型的值不能互相赋值, 即便它们能兼容。编译器不能隐式转换不同类型的值。

方法

方法提供了一种为用户自定义类型添加行为的方式。方法实际上就是函数,在关键词func和函数名之间包含了一个额外参数。

// Sample program to show how to declare methods and how the Go
// compiler supports them.

package main

import "fmt"

// 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) {
    u.email = email
}

// main is the entry point for the application.
func main() {
    // Values of type user can be used to call methods
    // declared with a value receiver.
    bill := user{"Bill", "bill@email.com"}
    bill.notify()

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

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

上面展示了两个不同的方法。在关键词func和函数名之间的参数叫做接受者(receiver), 函数被绑定给这个特定的类型。 当函数有接受者时, 函数就被叫做方法。 当你运行上面的代码,会有下面的输出:

Sending User Email To Bill<bill@email.com>
Sending User Email To Lisa<lisa@email.com>
Sending User Email To Bill<bill@newdomain.com>
Sending User Email To Lisa<lisa@comcast.com>

让我们检查下程序做了些什么。程序声明了结构体user, 然后声明了一个名为notify的方法。

type user struct {
    name string
    email string
}

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

在Go语言中有两种类型的接收者: 值接受者和指针接受者。notify方法以值接受者的方式声明的。

notify方法的接收者被声明为类型user的值。 当以值接受者声明方法时,这个方法是种能与用于调用该方法的值副本进行操作。

bill := user{"Bill", "bill@email.com"}
bill.notify()

上面使用user类型的值bill对方法notify进行调用。

这个语法看起来类似于调用包的函数。然而这个例子中,bill不是包名,而是一个变量名。 这种情况下我们调用notify方法, bill的值对于调用来说是接受者值, notify方法是对这个值的副本进行操作的。

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

lisa := &user{"Lisa", "lisa@email.com"}
lisa.notify()

上面我们使用user类型的指针lisa对方法notify()进行调用。为了支持方法调用,Go语言调整了指针以满足方法的接受者。你可以想象Go语言执行了下面的操作:

(*lisa).notify()

上面就展示了Go编译器所作的支持方法调用的等价。 指针值会被取消引用,以便方法调用和值接受者兼容。 再来一次,notify是操作副本的, 但是这次值的副本是lisa指针指向的。

同样可以使用指针接受者声明方法:

func (u *user) changeEmail(email string) {
    u.email = email
}

上面声明了changeEmail方法,使用的是指针接受者。这次,接受者不是user类型的值,而是指针。 当调用以指针接受者声明的方法时,用于调用方法的值是方法共享的。

lisa := &user{"Lisa", "lisa@email.com"}
lisa.changeEmail("lisa@newdomain.com")

上面你看到lisa指针的声明,后面跟着changeEmail的方法调用。一旦changeEmail方法调用返回,对lisa指向的值的改变在调用后会受影响。这多亏了指针接受者。 值接受者操作的是用于方法调用的值的副本。而指针接受者操作的是实际的数据。

同样可以使用值类型来调用使用指针接受者声明的方法。

bill := user{"Bill", "bill@email.com"}
bill.changeEmail("bill@newdomain.com")

上面你可以看到,bill变量的声明以及对changeEmail方法的调用, changeEmail方法以指针接受者的方式声明的。Go语言再次调整值来让它满足方法的接受者, 以支持方法调用。

(&bill).notify()

上面展示了Go编译器支持方法调用所作的事情本质。 该情况下,值是引用的,因此方法调用时和接受者类型兼容的。 这是Go语言提供的极大便利, 允许方法调用使用值和指针,而不用天生匹配方法的接受者类型。(Go编译器会帮你进行适当的转换。)

决定是否使用值或指针接受者有时候会感觉到困惑。 有一些来自标准库中的基本规则你可以直接遵循。

类型性质(Nature of types)

声明新类型之后,在为这个类型声明方法之前先回答一个问题。 你是否需要在这个类型上添加或删除一些东西来创建新的值或改变现有值? 如果是创建新值,那么方法就使用值接收者(value receiver)。如果答案是改变值,那么方法使用指针接收者。

这种原则同样适用于这些值如何传递给程序的其他部分。

保持一致非常重要。这样做的目的不是为了关注使用值做什么,而是关注值的本质是什么。

内建类型

内建类型是Go语言提供的类型集合。也就是我们知道的数字、字符串、布尔类型的集合。 这些类型具有原始性质(primitive nature)。

所谓原始性质,可以理解为机器指令和翻译的最小的或最基本的单元, 具有原子性。

正因为如此,向这样的值添加或删除一个值,就会创建新的值。鉴于这个原因,传递这些类型的值给函数和方法,应该使用这些值的副本,也就是值传递。 下面我们看看标准库中的函数是如何处理这些内置类型值的。
func Trim(s string, cutset string) string {
    if s == "" || cutset == "" {
        return s
    }
    return TrimFunc(s, makeCutsetFunc(cutset))
}

上面代码是Trim函数的实现,来自标准库strings包。Trim函数传入要操作的字符串值和要查找的字符值。然后返回新的字符串,也就是操作的结果。函数操作调用者使用原始字符串的副本, 然后返回的新字符串的值。字符串,就像整数、浮点数和布尔类型,都是原始数据类型,传入传出函数或方法的时候都应拷贝。

下面我们看另外一个例子,内置类型如何被视为原始属性。

func isShellSpecialVar(c uint8) bool {
    switch c {
    case '*', '$', '@', '!', '#', '?', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
        return true
    }
    return false
}

上面展示了env包中的isShellSpecialVar函数。这个函数传入一个uint8类型的值,返回一个bool类型的值。注意这里指针为什么不能用于共享参数和返回值。调用者将传入的uint8值进行拷贝,接收到的却是一个true或false的值。

引用类型

Go语言中的引用类型是分片、映射、通道以及函数类型。

当声明这样类型的变量时,被创建的值被称为头值(header value). 技术上, 字符串也是一种引用类型值。

所有来自不同引用类型的不同头值都包含一个指向底层数据结构的指针。每个引用类型也包含唯一字段集合,用于管理底层数据结构。不能共享引用类型值,因为头值设计是用来拷贝的。头值包含一个指针,因此你可以传递任意引用类型值副本,本质上共享底层数据结构。

让我们看看来自net包的一个类型:

type IP []byte

上面展示了一个叫IP的类型,它被声明为字节分片。 当你需要为内置或引用类型声明行为时,声明这样的类型就非常有用了,因为编译器只允许对你声明的命名类型声明方法。

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的值接受者声明的。 值接受者完全是你所期望看到的,既然不需要共享引用类型值。这同样适用于传递引用类型值作为函数和方法的参数。

// ipEmptyString is like ip.String except that it returns
// an empty string when ip is unset.
func ipEmptyString(ip IP) string {
    if len(ip) == 0 {
        return ""
    }
    return ip.String()
}

ipEmptyString函数传入一个IP类型的值。 再一次,你可以看到调用者的对这个参数的引用类型不在函数间共享。 函数被传入调用者的引用类型值副本。 这对于返回值同样适用。 结尾处,引用类型值被视为类似基本数据值。

结构体

结构体类型可以代表既包含基本类型或非基本类型值的数据。 当我们决定创建那样的一个结构体类型,我们对哪些应加入或哪些应去掉的值表现易变, 应该遵循内置类型和引用类型的指导。 下面我们开始看看通过标准库实现的结构体, 有一个基本特性。

type Time struct {
    // sec gives the number of seconds elapsed since
    // January 1, year 1 00:00:00 UTC.
    sec int64
    // nsec specifies a non-negative nanosecond
    // offset within the second named by Seconds.
    // It must be in the range [0, 999999999].
    nsec int32
    // loc specifies the Location that should be used to
    // determine the minute, hour, month, day, and year
    // that correspond to this Time.
    // Only the zero Time has a nil Location.
    // In that case it is interpreted to mean UTC.
    loc *Location
}

Time结构体来自time包。 当你考虑time的时候,你会意识到任何给定的时间点都是不能改变的。这正是标准库实现Time类型的方式。 下面我们看看创建时间类型值的Now函数。

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

上面展示了Now函数的实现。 这个函数创建了一个Time类型的值,并返回那个Time值的副本给调用者。 指针不用来共享这个函数创建的Time值。下一步,我们看一下Time类型声明的另一个方法。

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
}

上面代码很好的展示了标准库如何对待具有原始属性的Time类型的。方法Add使用一个值接收器声明,返回一个新的Time值。方法操作调用者的Time值的副本, 返回它局部的Time值给调用者。 调用者不管是使用返回的Time替换它们的Time值, 或者声明一个新变量来保存这个值都行。

在大多数情况中,结构体类型没有呈现出原始性质,而是非原始属性。在这些例子中,从值中添加或删除都会让值变化。在这种情况下,你想使用指针和程序的其他需要它的部分共享这个值. 让我们看看标准库实现的具有非原始性质的结构体类型。

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

// 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.
// 额外的间接层,取保操作系统的客户端不能覆盖这些数据,如果覆盖可能会导致定稿人(finalizer)关闭错误的文件描述符。
type file struct {
    fd int
    name string
    dirinfo *dirinfo // nil unless directory being read
    nepipe int32 // number of consecutive EPIPE in Write
}

上面你看到标准库中File类型的声明。这个类型的性质是非原始的。这个类型的值实际上是拷贝不安全的。不暴露类型的注释说的很清楚了。 既然没有办法防止程序拷贝, File类型的实现使用了嵌入的指向不希望暴露类型的指针。 本章后面会讨论嵌入类型,但是这个额外的间接层提供了复制的保护。 并不是所有结构体类型都需要或应该实现这种额外的保护。程序员应该关心每种类型的性质,并对应的使用它们。

下面我们看看Open函数的实现。

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

Open函数的实现展示了如何使用指针共享调用函数带的File类型值。Open创建一个File类型值,并返回指向那个值的指针。 当工厂函数返回指针, 就是很好的指示,返回值的属性是非原始的。即便函数或方法没有打算直接改变非原始值的状态, 它也应该被共享起来。

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类型值具有非原始属性,它们应该总是共享的,并且不要复制的。

要使用值接受者还是指针接受者不应该基于方法是否需要改变接受者的值。 决定必须基于类型的属性。 这个指导原则的一个例外情况就是,当你需要值类型接受者操作接口时提供的灵活性。在这些情况中,你应该选择使用值接受者,即便类型的性质是非原始的。 完全基于接口值如何使用存储在它里边值来调用方法的机制。下一节,你将了解到接口值是什么,使用它们调用方法的背后机制。

原文地址

阅读 4.2k

Go语言
walkerqiao's golang
avatar
老丐说码
架构师、技术总监

10多年互联网研发经验,技术老丐一枚。

872 声望
285 粉丝
0 条评论
avatar
老丐说码
架构师、技术总监

10多年互联网研发经验,技术老丐一枚。

872 声望
285 粉丝
文章目录
宣传栏