Skip to content

高级数据类型2

函数

在Go语言中,函数是一等(first-class)类型。这意味着,我们可以把函数作为值来传递和使用。函数代表着这样一个过程:它接受若干输入(参数),并经过一些步骤(语句)的执行之后再返回输出(结果)。特别的是,Go语言中的函数可以返回多个结果。

函数类型的字面量由关键字func、由圆括号包裹参数声明列表、空格以及可以由圆括号包裹的结果声明列表组成。其中,参数声明列表中的单个参数声明之间是由英文逗号分隔的。每个参数声明由参数名称、空格和参数类型组成。参数声明列表中的参数名称是可以被统一省略的。结果声明列表的编写方式与此相同。结果声明列表中的结果名称也是可以被统一省略的。并且,在只有一个无名称的结果声明时还可以省略括号。示例如下:

func(input1 string ,input2 string) string

这一类型字面量表示了一个接受两个字符串类型的参数且会返回一个字符串类型的结果的函数。如果我们在它的左边加入type关键字和一个标识符作为名称的话,那就变成了一个函数类型声明,就像这样:

type MyFunc func(input1 string ,input2 string) string

函数值(或简称函数)的写法与此不完全相同。编写函数的时候需要先写关键字func和函数名称,后跟参数声明列表和结果声明列表,最后是由花括号包裹的语句列表。例如:

1
2
3
4
func myFunc(part1 string, part2 string) (result string) {
    result = part1 + part2
    return
}

我们在这里用到了一个小技巧:如果结果声明是带名称的,那么它就相当于一个已被声明但未被显式赋值的变量。我们可以为它赋值且在return语句中省略掉需要返回的结果值。至于什么是return语句,我就不用多说了吧。显然,该函数还有一种更常规的写法:

1
2
3
func myFunc(part1 string, part2 string) string {
    return part1 + part2
}

注意,函数myFunc是函数类型MyFunc的一个实现。实际上,只要一个函数的参数声明列表和结果声明列表中的数据类型的顺序和名称与某一个函数类型完全一致,前者就是后者的一个实现。请大家回顾上面的示例并深刻理解这句话。

我们可以声明一个函数类型的变量,如:

var splice func(string, string) string // 等价于 var splice MyFunc

然后把函数myFunc赋给它:

splice = myFunc

如此一来,我们就可以在这个变量之上实施调用动作了:

splice("1", "2")

实际上,这是一个调用表达式。它由代表函数的标识符(这里是splice)以及代表调用动作的、由圆括号包裹的参数值列表组成。

如果你觉得上面对splice变量声明和赋值有些啰嗦,那么可以这样来简化它:

1
2
3
var splice = func(part1 string, part2 string) string {
    return part1 + part2
}

在这个示例中,我们直接使用了一个匿名函数来初始化splice变量。顾名思义,匿名函数就是不带名称的函数值。匿名函数直接由函数类型字面量和由花括号包裹的语句列表组成。注意,这里的函数类型字面量中的参数名称是不能被忽略的。

其实,我们还可以进一步简化——索性省去splice变量。既然我们可以在代表函数的变量上实施调用表达式,那么在匿名函数上肯定也是可行的。因为它们的本质是相同的。后者的示例如下:

1
2
3
var result = func(part1 string, part2 string) string {
    return part1 + part2
}("1", "2")
可以看到,在这个匿名函数之后的即是代表调用动作的参数值列表。注意,这里的result变量的类型不是函数类型,而与后面的匿名函数的结果类型是相同的。

最后,函数类型的零值是nil。这意味着,一个未被显式赋值的、函数类型的变量的值必为nil

范例:使程序打印到标准输出上的内容为Gophers-RD-1 true

package main

import (
    "fmt"
    "strconv"
    "sync/atomic"
)

// 员工ID生成器
type EmployeeIdGenerator func(company string, department string, sn uint32) string

// 默认公司名称
var company = "Gophers"

// 序列号
var sn uint32

// 生成员工ID
func generateId(generator EmployeeIdGenerator, department string) (string, bool) {
    // 这是一条 if 语句,我们会在下一章讲解它。
    // 若员工ID生成器不可用,则无法生成员工ID,应直接返回。
    if generator == nil {
        return "", false
    }
    // 使用代码包 sync/atomic 中提供的原子操作函数可以保证并发安全。
    newSn := atomic.AddUint32(&sn, 1)
    return generator(company, department, newSn), true
}

// 字符串类型和数值类型不可直接拼接,所以提供这样一个函数作为辅助。
func appendSn(firstPart string, sn uint32) string {
    return firstPart + strconv.FormatUint(uint64(sn), 10)
}

func main() {
    var generator EmployeeIdGenerator
    generator = func(company string, department string, sn uint32) string {
        return appendSn(company + "-" + department + "-", sn)
    }
    fmt.Println(generateId(generator, "RD"))
}

结构体和方法

Go语言的结构体类型(Struct)比函数类型更加灵活。它可以封装属性和操作。前者即是结构体类型中的字段,而后者则是结构体类型所拥有的方法。

结构体类型的字面量由关键字type、类型名称、关键字struct,以及由花括号包裹的若干字段声明组成。其中,每个字段声明独占一行并由字段名称(可选)和字段类型组成。示例如下:

1
2
3
4
5
type Person struct {
    Name   string
    Gender string
    Age    uint8
}

结构体类型Person中有三个字段,分别是NameGenderAge。我们可以用字面量创建出一个该类型的值,像这样:

Person{Name: "Robert", Gender: "Male", Age: 33}

可以看到,结构体值的字面量(或简称结构体字面量)由其类型的名称和由花括号包裹的若干键值对组成。注意,这里的键值对与字典字面量中的键值对的写法相似,但不相同。这里的键是其类型中的某个字段的名称(注意,它不是字符串字面量),而对应的值则是欲赋给该字段的那个值。另外,如果这里的键值对的顺序与其类型中的字段声明完全相同的话,我们还可以统一省略掉所有字段的名称,就像这样:

Person{"Robert", "Male", 33}

当然,我们在编写某个结构体类型的值字面量时可以只对它的部分字段赋值,甚至不对它的任何字段赋值。这时,未被显式赋值的字段的值则为其类型的零值。注意,在上述两种情况下,字段的名称是不能被省略的。

与代表函数值的字面量类似,我们在编写一个结构体值的字面量时不需要先拟好其类型。这样的结构体字面量被称为匿名结构体。与匿名函数类似,我们在编写匿名结构体的时候需要先写明其类型特征(包含若干字段声明),再写出它的值初始化部分。下面,我们依照结构体类型Person创建一个匿名结构体:

1
2
3
4
5
p := struct {
    Name   string
    Gender string
    Age    uint8
}{"Robert", "Male", 33}

匿名结构体最大的用处就是在内部临时创建一个结构以封装数据,而不必正式为其声明相关规则。而在涉及到对外的场景中,我强烈建议使用正式的结构体类型。

我在本节开始处提到过,结构体类型可以拥有若干方法(注意,匿名结构体是不可能拥有方法的)。所谓方法,其实就是一种特殊的函数。它可以依附于某个自定义类型。方法的特殊在于它的声明包含了一个接收者声明。这里的接收者指代它所依附的那个类型。我们仍以结构体类型Person为例。下面是依附于它的一个名为Grow的方法的声明:

1
2
3
func (person *Person) Grow() {
    person.Age++
}

如上所示,在关键字func和名称Grow之间的那个圆括号及其包含的内容就是接收者声明。其中的内容由两部分组成。第一部分是代表它依附的那个类型的值的标识符。第二部分是它依附的那个类型的名称。后者表明了依附关系,而前者则使得在该方法中的代码可以使用到该类型的值(也称为当前值)。代表当前值的那个标识符可被称为接收者标识符,或简称为接收者。请看下面的示例:

p := Person{"Robert", "Male", 33}
p.Grow()

我们可以直接在Person类型的变量p之上应用调用表达式来调用它的方法Grow。注意,此时方法Grow的接收者标识符person指代的正是变量p的值。这也是“当前值”这个词的由来。在Grow方法中,我们通过使用选择表达式选择了当前值的字段Age,并使其自增。因此,在语句p.Grow()被执行之后,p所代表的那个人就又年长了一岁(pAge字段的值已变为34)。

需要注意的是,在Grow方法的接收者声明中的那个类型是*Person,而不是Person。实际上,前者是后者的指针类型。这也使得person指代的是p的指针,而不是它本身。至于为什么这么做,我们在讲指针的时候在予以揭晓。

说到这里,熟悉面向对象编程的同学可能已经意识到,包含若干字段和方法的结构体类型就相当于一个把属性和操作封装在一起的对象。不过要注意,与对象不同的是,结构体类型(以及任何类型)之间都不可能存在继承关系。实际上,在Go语言中并没有继承的概念。不过,我们可以通过在结构体类型的声明中添加匿名字段(或称嵌入类型)来模仿继承。

最后,结构体类型属于值类型。它的零值并不是nil,而是其中字段的值均为相应类型的零值的值。举个例子,结构体类型Person的零值若用字面量来表示的话则为Person{}

范例:打印出Robert moved from Beijing to San Francisco.

package main

import "fmt"

type Person struct {
    Name    string
    Gender  string
    Age     uint8
    Address string
}

func (person *Person) Move(newAddress string) string {
    old := person.Address
    person.Address = newAddress
    return old
}

func main() {
    p := Person{"Robert", "Male", 33, "Beijing"}
    oldAddress := p.Move("San Francisco")
    fmt.Printf("%s moved from %s to %s.\n", p.Name, oldAddress, p.Address)
}

接口

在Go语言中,一个接口类型总是代表着某一种类型(即所有实现它的类型)的行为。一个接口类型的声明通常会包含关键字type、类型名称、关键字interface以及由花括号包裹的若干方法声明。示例如下:

1
2
3
4
type Animal interface {
    Grow()
    Move(string) string
}

注意,接口类型中的方法声明是普通的方法声明的简化形式。它们只包括方法名称、参数声明列表和结果声明列表。其中的参数的名称和结果的名称都可以被省略。不过,出于文档化的目的,我还是建议大家在这里写上它们。因此,Move方法的声明至少应该是这样的:

Move(new string) (old string)

如果一个数据类型所拥有的方法集合中包含了某一个接口类型中的所有方法声明的实现,那么就可以说这个数据类型实现了那个接口类型。所谓实现一个接口中的方法是指,具有与该方法相同的声明并且添加了实现部分(由花括号包裹的若干条语句)。相同的方法声明意味着完全一致的名称、参数类型列表和结果类型列表。其中,参数类型列表即为参数声明列表中除去参数名称的部分。一致的参数类型列表意味着其长度以及顺序的完全相同。对于结果类型列表也是如此。

例如,如果你正确地完成了上一小节的练习的话,*Person类型(注意,不是Person类型)就会拥有一个Move方法。该方法会是Animal接口的Move方法的一个实现。再加上我们在之前为它编写的那个Grow方法,*Person类型就可以被看做是Animal接口的一个实现类型了。

你可能已经意识到,我们无需在一个数据类型中声明它实现了哪个接口。只要满足了“方法集合为其超集”的条件,就建立了“实现”关系。这是典型的无侵入式的接口实现方法。

好了,现在我们已经认为*Person类型实现了Animal接口。但是Go语言编译器是否也这样认为呢?这显然需要一种显式的判定方法。在Go语言中,这种判定可以用类型断言来实现。不过,在这里,我们是不能在一个非接口类型的值上应用类型断言来判定它是否属于某一个接口类型的。我们必须先把前者转换成空接口类型的值。这又涉及到了Go语言的类型转换。

Go语言的类型转换规则定义了是否能够以及怎样可以把一个类型的值转换另一个类型的值。另一方面,所谓空接口类型即是不包含任何方法声明的接口类型,用interface{}表示,常简称为空接口。正因为空接口的定义,Go语言中的包含预定义的任何数据类型都可以被看做是空接口的实现。我们可以直接使用类型转换表达式把一个*Person类型转换成空接口类型的值,就像这样:

p := Person{"Robert", "Male", 33, "Beijing"}
v := interface{}(&p)

请注意第二行。在类型字面量后跟由圆括号包裹的值(或能够代表它的变量、常量或表达式)就构成了一个类型转换表达式,意为将后者转换为前者类型的值。在这里,我们把表达式&p的求值结果转换成了一个空接口类型的值,并由变量v代表。注意,表达式&p(&是取址操作符)的求值结果是一个*Person类型的值,即p的指针。

在这之后,我们就可以在v上应用类型断言了,即:

h, ok := v.(Animal)   

类型断言表达式v.(Animal)的求值结果可以有两个。第一个结果是被转换后的那个目标类型(这里是Animal)的值,而第二个结果则是转换操作成功与否的标志。显然,ok代表了一个bool类型的值。它也是这里判定实现关系的重要依据。

至此,我们掌握了接口类型、实现类型以及实现关系判定的重要知识和技巧。而至于为什么只有*Person类型才实现了Animal接口,请参看后面两块。

范例:运行该文件会使标准输出上出现true, &{Little C 2 In the house}

package main

import "fmt"

type Animal interface {
    Grow()
    Move(string) string
}

type Cat struct {
    Name     string
    Age      uint8
    Location string
}

func (cat *Cat) Grow() {
    cat.Age++
}

func (cat *Cat) Move(new string) string {
    old := cat.Location
    cat.Location = new
    return old
}

func main() {
    myCat := Cat{"Little C", 2, "In the house"}
    animal, ok := interface{}(&myCat).(Animal)
    fmt.Printf("%v, %v\n", ok, animal)
}

指针

我们在前面多次提到过指针及指针类型。例如,*PersonPerson的指针类型。又例如,表达式&p的求值结果是p的指针。方法的接收者类型的不同会给方法的功能带来什么影响?该方法所属的类型又会因此发生哪些潜移默化的改变?现在,我们就来解答第一个问题。至于第二个问题,我会在下一小节予以解答。

指针操作涉及到两个操作符——&*。这两个操作符均有多个用途。但是当它们作为地址操作符出现时,前者的作用是取址,而后者的作用是取值。更通俗地讲,当地址操作符&被应用到一个值上时会取出指向该值的指针值,而当地址操作符*被应用到一个指针值上时会取出该指针指向的那个值。它们可以被视为相反的操作。

除此之外,当*出现在一个类型之前(如*Person*[3]string)时就不能被看做是操作符了,而应该被视为一个符号。如此组合而成的标识符所表达的含义是作为第二部分的那个类型的指针类型。我们也可以把其中的第二部分所代表的类型称为基底类型。例如,*[3]string是数组类型[3]string的指针类型,而[3]string*[3]string的基底类型。

好了,我们现在回过头去再看结构体类型Person。它及其两个方法的完整声明如下:

type Person struct {
    Name    string
    Gender  string
    Age     uint8
    Address string
}

func (person *Person) Grow() {
    person.Age++
}

func (person *Person) Move(newAddress string) string {
    old := person.Address
    person.Address = newAddress
    return old
}

注意,Person的两个方法GrowMove的接收者类型都是*Person,而不是Person。只要一个方法的接收者类型是其所属类型的指针类型而不是该类型本身,那么我就可以称该方法为一个指针方法。上面的Grow方法和Move方法都是Person类型的指针方法。

相对的,如果一个方法的接收者类型就是其所属的类型本身,那么我们就可以把它叫做值方法。我们只要微调一下Grow方法的接收者类型就可以把它从指针方法变为值方法:

1
2
3
func (person Person) Grow() {
    person.Age++
}

那指针方法和值方法到底有什么区别呢?我们在保留上述修改的前提下编写如下代码:

1
2
3
p := Person{"Robert", "Male", 33, "Beijing"}
p.Grow()
fmt.Printf("%v\n", p)   

这段代码被执行后,标准输出会打印出什么内容呢?直觉上,34会被打印出来,但是被打印出来的却是33。这是怎么回事呢?Grow方法的功能失效了?!

解答这个问题需要引出一条定论:方法的接收者标识符所代表的是该方法当前所属的那个值的一个副本,而不是该值本身。例如,在上述代码中,Person类型的Grow方法的接收者标识符person代表的是p的值的一个拷贝,而不是p的值。我们在调用Grow方法的时候,Go语言会将p的值复制一份并将其作为此次调用的当前值。正因为如此,Grow方法中的person.Age++语句的执行会使这个副本的Age字段的值变为34,而pAge字段的值却依然是33。这就是问题所在。

只要我们把Grow变回指针方法就可以解决这个问题。原因是,这时的person代表的是p的值的指针的副本。指针的副本仍会指向p的值。另外,之所以选择表达式person.Age成立,是因为如果Go语言发现person是指针并且指向的那个值有Age字段,那么就会把该表达式视为(*person).Age。其实,这时的person.Age正是(*person).Age的速记法。

范例:

package main

import "fmt"

type MyInt struct {
    n int
}

func (m *MyInt) Inc() {
    m.n++
}

func (m *MyInt) Dec() {
    m.n--
}

func main() {
    number := MyInt{}
    number.Inc()
    number.Inc()
    number.Inc()
    number.Dec()
    fmt.Println(number.n)
    // 2
}

指针(续)

我们在讲接口的时候说过,如果一个数据类型所拥有的方法集合中包含了某一个接口类型中的所有方法声明的实现,那么就可以说这个数据类型实现了那个接口类型。要获知一个数据类型都包含哪些方法并不难。但是要注意指针方法与值方法的区别。

拥有指针方法GrowMove的指针类型*Person是接口类型Animal的实现类型,但是它的基底类型Person却不是。这样的表象隐藏着另一条规则:一个指针类型拥有以它以及以它的基底类型为接收者类型的所有方法,而它的基底类型却只拥有以它本身为接收者类型的方法。

以上一小节练习题中的类型MyInt为例,如果Increase方法是它的指针方法且Decrease方法是它的值方法,那么*MyInt类型会拥有这两个方法,而MyInt类型仅拥有Decrease方法。再以Person类型为例。即使我们把GrowMove都改为值方法,*Person类型也仍会是Animal接口的实现类型。另一方面,GrowMove中只要有一个是指针方法,Person类型就不可能是Animal接口的实现类型。

另外,还有一点需要大家注意,我们在基底类型的值上仍然可以调用它的指针方法。例如,若我们有一个Person类型的变量bp,则调用表达式bp.Grow()是合法的。这是因为,如果Go语言发现我们调用的Grow方法是bp的指针方法,那么它会把该调用表达式视为(&bp).Grow()。实际上,这时的bp.Grow()(&bp).Grow()的速记法。

在Go语言中,与指针有关的操作实际上还有更多。我们也可以依据这些操作玩儿出很多花样。不过就一般的Go语言编程而言,目前讲述的这些知识已经足够了。

范例:运行会使标准输出上出现true, true

package main

import "fmt"

type Pet interface {
    Name() string
    Age() uint8
}

type Dog struct {
    name string
    age  uint8
}

func (dog Dog) Name() string {
    return dog.name
}

func (dog Dog) Age() uint8 {
    return dog.age
}

func main() {
    myDog := Dog{"Little D", 3}
    _, ok1 := interface{}(&myDog).(Pet)
    _, ok2 := interface{}(myDog).(Pet)
    fmt.Printf("%v, %v\n", ok1, ok2)
}