GO:2-8 关于指针的有限操作

我们那时大多指的是指针类型及其对应的指针值,今天我们讲的则是更为深入的内容。
复习一下。

1
2
3
4
5
6
7
type Dog struct {
name string
}

func (dog *Dog) SetName(name string) {
dog.name = name
}

对于基本类型Dog来说,*Dog就是它的指针类型。而对于一个Dog类型,值不为nil的变量dog,取址表达式&dog的结果就是该变量的值(也就是基本值)的指针值。

在这种情况下,这个方法的接收者,实际上就是当前的基本值的指针值。

我们可以通过指针值无缝地访问到基本值包含的任何字段,以及调用与之关联的任何方法。这应该就是我们在编写 Go 程序的过程中,用得最频繁的“指针”了。

从传统意义上说,指针是一个指向某个确切的内存地址的值。这个内存地址可以是任何数据或代码的起始地址,比如,某个变量、某个字段或某个函数。

我们刚刚只提到了其中的一种情况,在 Go 语言中还有其他几样东西可以代表“指针”。其中最贴近传统意义的当属uintptr类型了。该类型实际上是一个数值类型,也是 Go 语言内建的数据类型之一。

根据当前计算机的计算架构的不同,它可以存储 32 位或 64 位的无符号整数,可以代表任何指针的位(bit)模式,也就是原始的内存地址。

再来看 Go 语言标准库中的unsafe包。unsafe包中有一个类型叫做Pointer,也代表了“指针”。

unsafe.Pointer可以表示任何指向可寻址的值的指针,同时它也是前面提到的指针值和uintptr值之间的桥梁。也就是说,通过它,我们可以在这两种值之上进行双向的转换。这里有一个很关键的词——可寻址的(addressable)。在我们继续说unsafe.Pointer之前,需要先要搞清楚这个词的确切含义。

1、列举出 Go 语言中不可寻址的值

以下列表中的值都是不可寻址的。

  • 常量的值。
  • 基本类型值的字面量。
  • 算术操作的结果值。
  • 对各种字面量的索引表达式和切片表达式的结果值。不过有一个例外,对切片字面量的索引结果值却是可寻址的。
  • 对字符串变量的索引表达式和切片表达式的结果值。
  • 对字典变量的索引表达式的结果值。
  • 函数字面量和方法字面量,以及对它们的调用表达式的结果值。
  • 结构体字面量的字段值,也就是对结构体字面量的选择表达式的结果值。
  • 类型转换表达式的结果值。
  • 类型断言表达式的结果值。
  • 接收表达式的结果值。

    初看答案中的这些不可寻址的值好像并没有什么规律。不过别急,我们一起来梳理一下。你可以对照着 以下例子中的代码来看,这样应该会让你理解起来更容易一些。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    package main

    type Named interface {
    // Name 用于获取名字。
    Name() string
    }

    type Dog struct {
    name string
    }

    func (dog *Dog) SetName(name string) {
    dog.name = name
    }

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

    func main() {
    // 示例1。
    const num = 123
    //_ = &num // 常量不可寻址。
    //_ = &(123) // 基本类型值的字面量不可寻址。

    var str = "abc"
    _ = str
    //_ = &(str[0]) // 对字符串变量的索引结果值不可寻址。
    //_ = &(str[0:2]) // 对字符串变量的切片结果值不可寻址。
    str2 := str[0]
    _ = &str2 // 但这样的寻址就是合法的。

    //_ = &(123 + 456) // 算术操作的结果值不可寻址。
    num2 := 456
    _ = num2
    //_ = &(num + num2) // 算术操作的结果值不可寻址。

    //_ = &([3]int{1, 2, 3}[0]) // 对数组字面量的索引结果值不可寻址。
    //_ = &([3]int{1, 2, 3}[0:2]) // 对数组字面量的切片结果值不可寻址。
    _ = &([]int{1, 2, 3}[0]) // 对切片字面量的索引结果值却是可寻址的。
    //_ = &([]int{1, 2, 3}[0:2]) // 对切片字面量的切片结果值不可寻址。
    //_ = &(map[int]string{1: "a"}[0]) // 对字典字面量的索引结果值不可寻址。

    var map1 = map[int]string{1: "a", 2: "b", 3: "c"}
    _ = map1
    //_ = &(map1[2]) // 对字典变量的索引结果值不可寻址。

    //_ = &(func(x, y int) int {
    // return x + y
    //}) // 字面量代表的函数不可寻址。
    //_ = &(fmt.Sprintf) // 标识符代表的函数不可寻址。
    //_ = &(fmt.Sprintln("abc")) // 对函数的调用结果值不可寻址。

    dog := Dog{"little pig"}
    _ = dog
    //_ = &(dog.Name) // 标识符代表的函数不可寻址。
    //_ = &(dog.Name()) // 对方法的调用结果值不可寻址。

    //_ = &(Dog{"little pig"}.name) // 结构体字面量的字段不可寻址。

    //_ = &(interface{}(dog)) // 类型转换表达式的结果值不可寻址。
    dogI := interface{}(dog)
    _ = dogI
    //_ = &(dogI.(Named)) // 类型断言表达式的结果值不可寻址。
    named := dogI.(Named)
    _ = named
    //_ = &(named.(Dog)) // 类型断言表达式的结果值不可寻址。

    var chan1 = make(chan int, 1)
    chan1 <- 1
    //_ = &(<-chan1) // 接收表达式的结果值不可寻址。

    }

1、常量的值总是会被存储到一个确切的内存区域中,并且这种值肯定是不可变的。基本类型值的字面量也是一样,其实它们本就可以被视为常量,只不过没有任何标识符可以代表它们罢了。

  • 第一个关键词:不可变的
    由于 Go 语言中的字符串值也是不可变的,所以对于一个字符串类型的变量来说,基于它的索引或切片的结果值也都是不可寻址的,因为即使拿到了这种值的内存地址也改变不了什么。

2、算术操作的结果值属于一种临时结果。在我们把这种结果值赋给任何变量或常量之前,即使能拿到它的内存地址也是没有任何意义的。

  • 第二个关键词:临时结果
    这个关键词能被用来解释很多现象。我们可以把各种对值字面量施加的表达式的求值结果都看做是临时结果。

我们都知道,Go 语言中的表达式有很多种,其中常用的包括以下几种。

  • 用于获得某个元素的索引表达式。
  • 用于获得某个切片(片段)的切片表达式。
  • 用于访问某个字段的选择表达式。
  • 用于调用某个函数或方法的调用表达式。
  • 用于转换值的类型的类型转换表达式。
  • 用于判断值的类型的类型断言表达式。
  • 向通道发送元素值或从通道那里接收元素值的接收表达式。

我们把以上这些表达式施加在某个值字面量上一般都会得到一个临时结果。比如,对数组字面量和字典字面量的索引结果值,又比如,对数组字面量和切片字面量的切片结果值。它们都属于临时结果,都是不可寻址的。

一个需要特别注意的例外是,对切片字面量的索引结果值是可寻址的。因为不论怎样,每个切片值都会持有一个底层数组,而这个底层数组中的每个元素值都是有一个确切的内存地址的

你可能会问,那么对切片字面量的切片结果值为什么却是不可寻址的?这是因为切片表达式总会返回一个新的切片值,而这个新的切片值在被赋给变量之前属于临时结果。

针对数组值、切片值或字典值的字面量表达式会产生临时结果。如果针对的是数组类型或切片类型的变量,那么索引或切片的结果值就都不属于临时结果了,是可寻址的。

这主要因为变量的值本身就不是“临时的”。对比而言,值字面量在还没有与任何变量(或者说任何标识符)绑定之前是没有落脚点的我们无法以任何方式引用到它们。这样的值就是“临时的”

再说一个例外。我们通过对字典类型的变量施加索引表达式,得到的结果值不属于临时结果,可是,这样的值却是不可寻址的。原因是,字典中的每个键 - 元素对的存储位置都可能会变化,而且这种变化外界是无法感知的。

我们都知道,字典中总会有若干个哈希桶用于均匀地储存键 - 元素对。当满足一定条件时,字典可能会改变哈希桶的数量,并适时地把其中的键 - 元素对搬运到对应的新的哈希桶中。

在这种情况下,获取字典中任何元素值的指针都是无意义的,也是不安全的。我们不知道什么时候那个元素值会被搬运到何处,也不知道原先的那个内存地址上还会被存放什么别的东西。所以,这样的值就应该是不可寻址的。

  • 第三个关键词:不安全的
    “不安全的”操作很可能会破坏程序的一致性,引发不可预知的错误,从而严重影响程序的功能和稳定性。

3、再来看函数。函数在 Go 语言中是一等公民,所以我们可以把代表函数或方法的字面量或标识符赋给某个变量、传给某个函数或者从某个函数传出。但是,这样的函数和方法都是不可寻址的。一个原因是函数就是代码,是不可变的

另一个原因是,拿到指向一段代码的指针是不安全的。此外,对函数或方法的调用结果值也是不可寻址的,这是因为它们都属于临时结果

4、至于典型回答中最后列出的那几种值,由于都是针对值字面量的某种表达式的结果值,所以都属于临时结果,都不可寻址。

总结一下:

  • 1、不可变的值不可寻址。常量、基本类型的值字面量、字符串变量的值、函数以及方法的字面量都是如此。其实这样规定也有安全性方面的考虑。
  • 2、绝大多数被视为临时结果的值都是不可寻址的。算术操作的结果值属于临时结果,针对值字面量的表达式结果值也属于临时结果。但有一个例外,对切片字面量的索引结果值虽然也属于临时结果,但却是可寻址的。
  • 3、若拿到某值的指针可能会破坏程序的一致性,那么就是不安全的,该值就不可寻址。由于字典的内部机制,对字典的索引结果值的取址操作都是不安全的。另外,获取由字面量或标识符代表的函数或方法的地址显然也是不安全的。

最后说一句,如果我们把临时结果赋给一个变量,那么它就是可寻址的了。如此一来,取得的指针指向的就是这个变量持有的那个值了。

2、不可寻址的值在使用上有哪些限制?

首当其冲的当然是无法使用取址操作符&获取它们的指针了。不过,对不可寻址的值施加取址操作都会使编译器报错,所以倒是不用太担心,你只要记住我在前面讲述的那几条规律,并在编码的时候提前注意一下就好了。

我们来看下面这个小问题。我们依然以那个结构体类型Dog为例。

1
2
3
func New(name string) Dog {
return Dog{name}
}

再为它编写一个函数New。这个函数会接受一个名为name的string类型的参数,并会用这个参数初始化一个Dog类型的值,最后返回该值。我现在要问的是:如果我调用该函数,并直接以链式的手法调用其结果值的指针方法SetName,那么可以达到预期的效果吗?

1
New("little pig").SetName("monster")

调用New函数所得到的结果值属于临时结果,是不可寻址的。

可是,那又怎样呢?别忘了,我在讲结构体类型及其方法的时候还说过,我们可以在一个基本类型的值上调用它的指针方法,这是因为 Go 语言会自动地帮我们转译。

更具体地说,对于一个Dog类型的变量dog来说,调用表达式dog.SetName(“monster”)会被自动地转译为(&dog).SetName(“monster”),即:先取dog的指针值,再在该指针值上调用SetName方法。

发现问题了吗?由于New函数的调用结果值是不可寻址的,所以无法对它进行取址操作。因此,上边这行链式调用会让编译器报告两个错误,一个是果,即:不能在New(“little pig”)的结果值上调用指针方法。一个是因,即:不能取得New(“little pig”)的地址。

除此之外,我们都知道,Go 语言中的++和–并不属于操作符,而分别是自增语句和自减语句的重要组成部分。

虽然 Go 语言规范中的语法定义是,只要在++或–的左边添加一个表达式,就可以组成一个自增语句或自减语句,但是,它还明确了一个很重要的限制,那就是这个表达式的结果值必须是可寻址的。这就使得针对值字面量的表达式几乎都无法被用在这里。

不过这有一个例外,虽然对字典字面量和字典变量索引表达式的结果值都是不可寻址的,但是这样的表达式却可以被用在自增语句和自减语句中。

与之类似的规则还有两个。一个是,在赋值语句中,赋值操作符左边的表达式的结果值必须可寻址的,但是对字典的索引结果值也是可以的。

另一个是,在带有range子句的for语句中,在range关键字左边的表达式的结果值也都必须是可寻址的,不过对字典的索引结果值同样可以被用在这里。以上这三条规则我们合并起来记忆就可以了。

3、怎样通过unsafe.Pointer操纵可寻址的值?

unsafe.Pointer是像*Dog类型的值这样的指针值和uintptr值之间的桥梁,那么我们怎样利用unsafe.Pointer的中转和uintptr的底层操作来操纵像dog这样的值呢?

首先说明,这是一项黑科技。它可以绕过 Go 语言的编译器和其他工具的重重检查,并达到潜入内存修改数据的目的。这并不是一种正常的编程手段,使用它会很危险,很有可能造成安全隐患。

我们总是应该优先使用常规代码包中提供的 API 去编写程序,当然也可以把像reflect以及go/ast这样的代码包作为备选项。作为上层应用的开发者,请谨慎地使用unsafe包中的任何程序实体。

不过既然说到这里了,我们还是要来一探究竟的。请看下面的代码:

1
2
3
dog := Dog{"little pig"}
dogP := &dog
dogPtr := uintptr(unsafe.Pointer(dogP))

我先声明了一个Dog类型的变量dog,然后用取址操作符&,取出了它的指针值,并把它赋给了变量dogP。

最后,我使用了两个类型转换,先把dogP转换成了一个unsafe.Pointer类型的值,然后紧接着又把后者转换成了一个uintptr的值,并把它赋给了变量dogPtr。这背后隐藏着一些转换规则,如下:
1、一个指针值(比如*Dog类型的值)可以被转换为一个unsafe.Pointer类型的值,反之亦然。
2、一个uintptr类型的值也可以被转换为一个unsafe.Pointer类型的值,反之亦然。
3、一个指针值无法被直接转换成一个uintptr类型的值,反过来也是如此。

所以,对于指针值和uintptr类型值之间的转换,必须使用unsafe.Pointer类型的值作为中转。那么,我们把指针值转换成uintptr类型的值有什么意义吗?

1
2
namePtr := dogPtr + unsafe.Offsetof(dogP.name)
nameP := (*string)(unsafe.Pointer(namePtr))

这里需要与unsafe.Offsetof函数搭配使用才能看出端倪。unsafe.Offsetof函数用于获取两个值在内存中的起始存储地址之间的偏移量,以字节为单位。

这两个值一个是某个字段的值,另一个是该字段值所属的那个结构体值。我们在调用这个函数的时候,需要把针对字段的选择表达式传给它,比如dogP.name。

有了这个偏移量,又有了结构体值在内存中的起始存储地址(这里由dogPtr变量代表),把它们相加我们就可以得到dogP的name字段值的起始存储地址了。这个地址由变量namePtr代表。

此后,我们可以再通过两次类型转换把namePtr的值转换成一个*string类型的值,这样就得到了指向dogP的name字段值的指针值。

你可能会问,我直接用取址表达式&(dogP.name)不就能拿到这个指针值了吗?干嘛绕这么大一圈呢?你可以想象一下,如果我们根本就不知道这个结构体类型是什么,也拿不到dogP这个变量,那么还能去访问它的name字段吗?

答案是,只要有namePtr就可以。它就是一个无符号整数,但同时也是一个指向了程序内部数据的内存地址。它可能会给我们带来一些好处,比如可以直接修改埋藏得很深的内部数据。

但是,一旦我们有意或无意地把这个内存地址泄露出去,那么其他人就能够肆意地改动dogP.name的值,以及周围的内存地址上存储的任何数据了。

即使他们不知道这些数据的结构也无所谓啊,改不好还改不坏吗?不正确地改动一定会给程序带来不可预知的问题,甚至造成程序崩溃。这可能还是最好的灾难性后果;所以我才说,使用这种非正常的编程手段会很危险。

GO:2.7 接口类型的合理运用

1、正确使用接口的基础知识

1、在 Go 语言的语境中,当我们在谈论“接口”的时候,一定指的是接口类型。因为接口类型与其他数据类型不同,它是没法被实例化的

2、更具体地说,我们既不能通过调用new函数或make函数创建出一个接口类型的值,也无法用字面量来表示一个接口类型的值

3、对于某一个接口类型来说,如果没有任何数据类型可以作为它的实现,那么该接口的值就不可能存在。

4、通过关键字type和interface,我们可以声明出接口类型

5、接口类型的类型字面量与结构体类型的看起来有些相似,它们都用花括号包裹一些核心信息。只不过,结构体类型包裹的是它的字段声明,而接口类型包裹的是它的方法定义

6、注意:接口类型声明中的这些方法所代表的就是该接口的方法集合。一个接口的方法集合就是它的全部特征。

7、对于任何数据类型,只要它的方法集合中完全包含了一个接口的全部特征(即全部的方法),那么它就一定是这个接口的实现类型。比如下面这样:

1
2
3
4
5
6
type Pet interface {//声明了一个接口类型Pet
SetName(name string)
Name() string
Category() string
//包含了 3 个方法定义,方法名称分别为SetName、Name和Category,这 3 个方法共同组成了接口类型Pet的方法集合。
}

只要一个数据类型的方法集合中有这 3 个方法,那么它就一定是Pet接口的实现类型。这是一种无侵入式的接口实现方式。这种方式还有一个专有名词,叫“Duck typing”,中文常译作“鸭子类型”。

1、怎样判定一个数据类型的某一个方法实现的就是某个接口类型中的某个方法呢?

这有两个充分必要条件:
1、两个方法的签名需要完全一致
2、两个方法的名称要一模一样
例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import "fmt"

type Pet interface {
SetName(name string)
Name() string
Category() string
}

type Dog struct {
name string // 名字。
}

func (dog *Dog) SetName(name string) {
dog.name = name
}

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

func (dog Dog) Category() string {
return "dog"
}

func main() {
// 示例1。
dog := Dog{"little pig"}
_, ok := interface{}(dog).(Pet)
fmt.Printf("Dog implements interface Pet: %v\n", ok)//false

_, ok = interface{}(&dog).(Pet)
fmt.Printf("*Dog implements interface Pet: %v\n", ok)//true
fmt.Println()

// 示例2。
var pet Pet = &dog
fmt.Printf("This pet is a %s, the name is %q.\n",
pet.Category(), pet.Name())
}

运行结果:

1
2
3
4
Dog implements interface Pet: false
*Dog implements interface Pet: true

This pet is a dog, the name is "little pig".

1、声明的类型Dog附带了 3 个方法。其中有 2 个值方法,分别是Name和Category,另外还有一个指针方法SetName。

这就意味着,Dog类型本身的方法集合中只包含了 2 个方法,也就是所有的值方法。而它的指针类型*Dog方法集合却包含了 3 个方法,

也就是说,它拥有Dog类型附带的所有值方法和指针方法。又由于这 3 个方法恰恰分别是Pet接口中某个方法的实现,所以*Dog类型就成为了Pet接口的实现类型(Dog类型不是)。

2、正因为如此,我可以声明并初始化一个Dog类型的变量dog,然后把它的指针值赋给类型为Pet的变量pet(var pet Pet = &dog)。

3、对于一个接口类型的变量来说,例如上面的变量pet,我们赋给它的值可以被叫做它的实际值(也称动态值),而该值的类型可以被叫做这个变量的实际类型(也称动态类型)。

比如,我们把取址表达式&dog的结果值赋给了变量pet,这时这个结果值就是变量pet的动态值,而此结果值的类型*Dog就是该变量的动态类型。

4、动态类型这个叫法是相对于静态类型而言的。对于变量pet来讲,它的静态类型就是Pet,并且永远是Pet,但是它的动态类型却会随着我们赋给它的动态值而变化

比如,只有我把一个Dog类型的值赋给变量pet之后,该变量的动态类型才会是Dog。如果还有一个Pet接口的实现类型Fish,并且我又把一个此类型的值赋给了pet,那么它的动态类型就会变为Fish。

还有,在我们给一个接口类型的变量赋予实际的值之前,它的动态类型是不存在的

2、当我们为一个接口变量赋值时会发生什么?

例子:把Pet接口的声明简化一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package main

import (
"fmt"
)

type Pet interface {
Name() string
Category() string
}

type Dog struct {
name string // 名字。
}

func (dog *Dog) SetName(name string) {
dog.name = name
}

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

func (dog Dog) Category() string {
return "dog"
}

func main() {
// 示例1。
dog := Dog{"little pig"}
fmt.Printf("The dog's name is %q.\n", dog.Name())
var pet Pet = dog
dog.SetName("monster")
fmt.Printf("The dog's name is %q.\n", dog.Name())
fmt.Printf("This pet is a %s, the name is %q.\n",
pet.Category(), pet.Name())
fmt.Println()

// 示例2。
dog1 := Dog{"little pig"}
fmt.Printf("The name of first dog is %q.\n", dog1.Name())
dog2 := dog1
fmt.Printf("The name of second dog is %q.\n", dog2.Name())
dog1.name = "monster"
fmt.Printf("The name of first dog is %q.\n", dog1.Name())
fmt.Printf("The name of second dog is %q.\n", dog2.Name())
fmt.Println()

// 示例3。
dog = Dog{"little pig"}
fmt.Printf("The dog's name is %q.\n", dog.Name())
pet = &dog
dog.SetName("monster")
fmt.Printf("The dog's name is %q.\n", dog.Name())
fmt.Printf("This pet is a %s, the name is %q.\n",
pet.Category(), pet.Name())
}

运行:

1
2
3
4
5
6
7
8
9
10
11
12
The dog's name is "little pig".
The dog's name is "monster".
This pet is a dog, the name is "little pig".

The name of first dog is "little pig".
The name of second dog is "little pig".
The name of first dog is "monster".
The name of second dog is "little pig".

The dog's name is "little pig".
The dog's name is "monster".
This pet is a dog, the name is "monster".

从中去掉了Pet接口的那个名为SetName的方法(*Dog方法)。这样一来,Dog类型也就变成Pet接口的实现类型了

2、从接口类型值的存储方式和结构说起。前面说过,接口类型本身是无法被值化的。在我们赋予它实际的值之前,它的值一定会是nil,这也是它的零值。

反过来讲,一旦它被赋予了某个实现类型的值,它的值就不再是nil了。不过要注意,即使我们像前面那样把dog的值赋给了pet,pet的值与dog的值也是不同的。这不仅仅是副本与原值的那种不同

3、当我们给一个接口变量赋值的时候,该变量的动态类型会与它的动态值一起被存储在一个专用的数据结构中

严格来讲,这样一个变量的值其实是这个专用数据结构的一个实例,而不是我们赋给该变量的那个实际的值。所以我才说,pet的值与dog的值肯定是不同的,无论是从它们存储的内容,还是存储的结构上来看都是如此。不过,我们可以认为,这时pet的值中包含了dog值的副本。

把这个专用的数据结构叫做iface吧,在 Go 语言的runtime包中它其实就叫这个名字。

4、iface的实例会包含两个指针,一个是指向类型信息的指针,另一个是指向动态值的指针。这里的类型信息是由另一个专用数据结构的实例承载的,其中包含了动态值的类型,以及使它实现了接口的方法和调用它们的途径,等等。

总之,接口变量被赋予动态值的时候,存储的是包含了这个动态值的副本的一个结构更加复杂的值。

3、接口变量的值在什么情况下才真正为nil?

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
"fmt"
"reflect"
)

type Pet interface {
Name() string
Category() string
}

type Dog struct {
name string // 名字。
}

func (dog *Dog) SetName(name string) {
dog.name = name
}

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

func (dog Dog) Category() string {
return "dog"
}

func main() {
// 示例1。
var dog1 *Dog
fmt.Println("The first dog is nil.")
dog2 := dog1
fmt.Println("The second dog is nil.")
var pet Pet = dog2
if pet == nil {
fmt.Println("The pet is nil.")
} else {
fmt.Println("The pet is not nil.")
}
fmt.Printf("The type of pet is %T.\n", pet)
fmt.Printf("The type of pet is %s.\n", reflect.TypeOf(pet).String())
fmt.Printf("The type of second dog is %T.\n", dog2)
fmt.Println()

// 示例2。
wrap := func(dog *Dog) Pet {
if dog == nil {
return nil
}
return dog
}
pet = wrap(dog2)
if pet == nil {
fmt.Println("The pet is nil.")
} else {
fmt.Println("The pet is not nil.")
}
}

运行:

1
2
3
4
5
6
7
8
The first dog is nil.
The second dog is nil.
The pet is not nil.
The type of pet is *main.Dog.
The type of pet is *main.Dog.
The type of second dog is *main.Dog.

The pet is nil.

在示例1中,当我把dog2赋给Pet类型的变量pet之后,变量pet的值会是什么?

1、当我们把dog2的值赋给变量pet的时候,dog2的值会先被复制,不过由于在这里它的值是nil,所以就没必要复制了。
2、然后,Go 语言会用我上面提到的那个专用数据结构iface的实例包装这个dog2的值的副本,这里是nil。
3、虽然被包装的动态值是nil,但是pet的值却不会是nil,因为这个动态值只是pet值的一部分而已。
4、顺便说一句,这时的pet的动态类型就存在了,是*Dog。我们可以通过fmt.Printf函数和占位符%T来验证这一点,另外reflect包的TypeOf函数也可以起到类似的作用。
5、换个角度来看。我们把nil赋给了pet,但是pet的值却不是nil。

这很奇怪对吗?其实不然。在 Go 语言中,我们把由字面量nil表示的值叫做无类型的nil。这是真正的nil,因为它的类型也是nil的。虽然dog2的值是真正的nil,但是当我们把这个变量赋给pet的时候,Go 语言会把它的类型和值放在一起考虑。

也就是说,这时 Go 语言会识别出赋予pet的值是一个*Dog类型的nil。然后,Go 语言就会用一个iface的实例包装它,包装后的产物肯定就不是nil了。

只要我们把一个有类型的nil赋给接口变量,那么这个变量的值就一定不会是那个真正的nil。因此,当我们使用判等符号==判断pet是否与字面量nil相等的时候,答案一定会是false。

那么,怎样才能让一个接口变量的值真正为nil呢?
要么只声明它但不做初始化,要么直接把字面量nil赋给它

4、怎样实现接口之间的组合?

接口类型间的嵌入也被称为接口的组合。我在前面讲过结构体类型的嵌入字段,这其实就是在说结构体类型间的嵌入。

接口类型间的嵌入要更简单一些,因为它不会涉及方法间的“屏蔽”。只要组合的接口之间有同名的方法就会产生冲突,从而无法通过编译,即使同名方法的签名彼此不同也会是如此。因此,接口的组合根本不可能导致“屏蔽”现象的出现。

与结构体类型间的嵌入很相似,我们只要把一个接口类型的名称直接写到另一个接口类型的成员列表中就可以了。比如:

1
2
3
4
5
6
7
8
9
type Animal interface {
ScientificName() string
Category() string
}

type Pet interface {
Animal
Name() string
}

接口类型Pet包含了两个成员,一个是代表了另一个接口类型的Animal,一个是方法Name的定义。它们都被包含在Pet的类型声明的花括号中,并且都各自独占一行。此时,Animal接口包含的所有方法也就成为了Pet接口的方法。

Go 语言团队鼓励我们声明体量较小的接口,并建议我们通过这种接口间的组合来扩展程序、增加程序的灵活性。

这是因为相比于包含很多方法的大接口而言,小接口可以更加专注地表达某一种能力或某一类特征,同时也更容易被组合在一起。

Go 语言标准库代码包io中的ReadWriteCloser接口和ReadWriter接口就是这样的例子,它们都是由若干个小接口组合而成的。以io.ReadWriteCloser接口为例,它是由io.Reader、io.Writer和io.Closer这三个接口组成的。

这三个接口都只包含了一个方法,是典型的小接口。它们中的每一个都只代表了一种能力,分别是读出、写入和关闭。我们编写这几个小接口的实现类型通常都会很容易。并且,一旦我们同时实现了它们,就等于实现了它们的组合接口io.ReadWriteCloser。

即使我们只实现了io.Reader和io.Writer,那么也等同于实现了io.ReadWriter接口,因为后者就是前两个接口组成的。可以看到,这几个io包中的接口共同组成了一个接口矩阵。它们既相互关联又独立存在。

一个能够体现接口组合优势的小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package main

import (
"fmt"
)

type Animal interface {
// ScientificName 用于获取动物的学名。
ScientificName() string
// Category 用于获取动物的基本分类。
Category() string
}

type Named interface {
// Name 用于获取名字。
Name() string
}

type Pet interface {
Animal
Named
}

type PetTag struct {
name string
owner string
}

func (pt PetTag) Name() string {
return pt.name
}

func (pt PetTag) Owner() string {
return pt.owner
}

type Dog struct {
PetTag
scientificName string
}

func (dog Dog) ScientificName() string {
return dog.scientificName
}

func (dog Dog) Category() string {
return "dog"
}

func main() {
petTag := PetTag{name: "little pig"}
_, ok := interface{}(petTag).(Named)
fmt.Printf("PetTag implements interface Named: %v\n", ok)
dog := Dog{
PetTag: petTag,
scientificName: "Labrador Retriever",
}
_, ok = interface{}(dog).(Animal)
fmt.Printf("Dog implements interface Animal: %v\n", ok)
_, ok = interface{}(dog).(Named)
fmt.Printf("Dog implements interface Named: %v\n", ok)
_, ok = interface{}(dog).(Pet)
fmt.Printf("Dog implements interface Pet: %v\n", ok)
}

运行:

1
2
3
4
PetTag implements interface Named: true
Dog implements interface Animal: true
Dog implements interface Named: true
Dog implements interface Pet: true

总之,善用接口组合和小接口可以让你的程序框架更加稳定和灵活。

GO:2.6 结构体及其方法

结构体类型表示的是实实在在的数据结构。一个结构体类型可以包含若干个字段,每个字段通常都需要有确切的名字和类型。

1、结构体类型基础知识

结构体类型也可以不包含任何字段,这样并不是没有意义的,因为我们还可以为类型关联上一些方法,这里你可以把方法看做是函数的特殊版本。

函数是独立的程序实体。我们可以声明有名字的函数,也可以声明没名字的函数,还可以把它们当做普通的值传来传去。我们能把具有相同签名的函数抽象成独立的函数类型,以作为一组输入、输出(或者说一类逻辑组件)的代表。

方法却不同,它需要有名字,不能被当作值来看待,最重要的是,它必须隶属于某一个类型。方法所属的类型会通过其声明中的接收者(receiver)声明体现出来。

接收者声明就是在关键字func和方法名称之间的圆括号包裹起来的内容,其中必须包含确切的名称和类型字面量。

接收者的类型其实就是当前方法所属的类型,而接收者的名称,则用于在当前方法中引用它所属的类型的当前值。

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// AnimalCategory 代表动物分类学中的基本分类法。
type AnimalCategory struct {
kingdom string // 界。
phylum string // 门。
class string // 纲。
order string // 目。
family string // 科。
genus string // 属。
species string // 种。
}

func (ac AnimalCategory) String() string {
return fmt.Sprintf("%s%s%s%s%s%s%s",
ac.kingdom, ac.phylum, ac.class, ac.order,
ac.family, ac.genus, ac.species)
}

结构体类型AnimalCategory代表了动物的基本分类法,其中有 7 个string类型的字段,分别表示各个等级的分类。

下边有个名叫String的方法,从它的接收者声明可以看出它隶属于AnimalCategory类型。

通过该方法的接收者名称ac,我们可以在其中引用到当前值的任何一个字段,或者调用到当前值的任何一个方法(也包括String方法自己)。

这个String方法的功能是提供当前值的字符串表示形式,其中的各个等级分类会按照从大到小的顺序排列。使用时,我们可以这样表示:

1
2
category := AnimalCategory{species: "cat"}
fmt.Printf("The animal category: %s\n", category)

里,我用字面量初始化了一个AnimalCategory类型的值,并把它赋给了变量category。为了不喧宾夺主,我只为其中的species字段指定了字符串值”cat”,该字段代表最末级分类“种”。

在 Go 语言中,我们可以通过为一个类型编写名为String的方法,来自定义该类型的字符串表示形式。这个String方法不需要任何参数声明,但需要有一个string类型的结果声明。

正因为如此,我在调用fmt.Printf函数时,使用占位符%s和category值本身就可以打印出后者的字符串表示形式,而无需显式地调用它的String方法。

mt.Printf函数会自己去寻找它。此时的打印内容会是The animal category: cat。显而易见,category的String方法成功地引用了当前值的所有字段。


*1、方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型
2、一个数据类型关联的所有方法,共同组成了该类型的方法集合。同一个方法集合中的方法不能出现重名
3、并且,如果它们所属的是一个结构体类型,那么它们的名称与该类型中任何字段的名称也不能重复
4、我们可以把结构体类型中的一个字段看作是它的一个属性或者一项数据,再把隶属于它的一个方法看作是附加在其中数据之上的一个能力或者一项操作。
5、将属性及其能力(或者说数据及其操作)封装在一起,是面向对象编程(object-oriented programming)的一个主要原则。
6、Go 语言摄取了面向对象编程中的很多优秀特性,同时也推荐这种封装的做法。从这方面看,Go 语言其实是支持面向对象编程的,但它选择摒弃了一些在实际运用过程中容易引起程序开发者困惑的特性和规则。*


现在,让我们再把目光放到结构体类型的字段声明上。我们来看下面的代码:

1
2
3
4
5
6
type Animal struct {
//声明结构体类型Animal
scientificName string // 学名。
AnimalCategory // 动物基本分类。
//字段AnimalCategory只声明了一个类型名,代表了Animal类型的一个嵌入字段,也称为匿名字段
}

字段声明AnimalCategory代表了Animal类型的一个嵌入字段。Go 语言规范规定,如果一个字段的声明中只有字段的类型名而没有字段的名称,那么它就是一个嵌入字段,也可以被称为匿名字段。我们可以通过此类型变量的名称后跟“.”,再后跟嵌入字段类型的方式引用到该字段。也就是说,嵌入字段的类型既是类型也是名称

说到引用结构体的嵌入字段,Animal类型有个方法叫Category,它是这么写的:

1
2
3
func (a Animal) Category() string {
return a.AnimalCategory.String()
}

Category方法的接收者类型是Animal,接收者名称是a。通过表达式a.AnimalCategory选择到了a的这个嵌入字段,调用了String方法。

在某个代表变量的标识符的右边加“.”,再加上字段名或方法名的表达式被称为选择表达式,它用来表示选择了该变量的某个字段或者方法。

实际上,把一个结构体类型嵌入到另一个结构体类型中的意义不止如此。嵌入字段的方法集合会被无条件地合并进被嵌入类型的方法集合中。例如下面这种:

1
2
3
4
5
animal := Animal{
scientificName: "American Shorthair",
AnimalCategory: category,
}
fmt.Printf("The animal: %s\n", animal)

声明了一个Animal类型的变量animal并对它进行初始化。我把字符串值”American Shorthair”赋给它的字段scientificName,并把前面声明过的变量category赋给它的嵌入字段AnimalCategory。

我在后面使用fmt.Printf函数和%s占位符试图打印animal的字符串表示形式,相当于调用animal的String方法。虽然我们还没有为Animal类型编写String方法,但这样做是没问题的。因为在这里,嵌入字段AnimalCategory的String方法会被当做animal的方法调用

1、如果也为Animal类型编写一个String方法呢?这里会调用哪一个呢?

animal的String方法会被调用

这时,我们说,嵌入字段AnimalCategory的String方法被“屏蔽”了。注意,只要名称相同,无论这两个方法的签名是否一致,被嵌入类型的方法都会“屏蔽”掉嵌入字段的同名方法

类似的,由于我们同样可以像访问被嵌入类型的字段那样,直接访问嵌入字段的字段,所以如果这两个结构体类型里存在同名的字段,那么嵌入字段中的那个字段一定会被“屏蔽”。这与我们在前面讲过的,可重名变量之间可能存在的“屏蔽”现象很相似。

正因为嵌入字段的字段和方法都可以“嫁接”到被嵌入类型上,所以即使在两个同名的成员一个是字段,另一个是方法的情况下,这种“屏蔽”现象依然会存在。

不过,即使被屏蔽了,我们仍然可以通过链式的选择表达式,选择到嵌入字段的字段或方法,就像我在Category方法中所做的那样。这种“屏蔽”其实还带来了一些好处。我们看看下面这个Animal类型的String方法的实现:

1
2
3
4
func (a Animal) String() string {
return fmt.Sprintf("%s (category: %s)",
a.scientificName, a.AnimalCategory)
}

在这里,我们把对嵌入字段的String方法的调用结果融入到了Animal类型的同名方法的结果中。这种将同名方法的结果逐层“包装”的手法是很常见和有用的,也算是一种惯用法了。
struct1

2、多层嵌入

也就是说,嵌入字段本身也有嵌入字段的情况。请看我声明的Cat类型:

1
2
3
4
5
6
7
8
9
type Cat struct {
name string
Animal
}

func (cat Cat) String() string {
return fmt.Sprintf("%s (category: %s, name: %q)",
cat.scientificName, cat.Animal.AnimalCategory, cat.name)
}

结构体类型Cat中有一个嵌入字段Animal,而Animal类型还有一个嵌入字段AnimalCategory。

在这种情况下,“屏蔽”现象会以嵌入的层级为依据,嵌入层级越深的字段或方法越可能被“屏蔽”。

例如,当我们调用Cat类型值的String方法时,如果该类型确有String方法,那么嵌入字段Animal和AnimalCategory的String方法都会被“屏蔽”。

如果该类型没有String方法,那么嵌入字段Animal的String方法会被调用,而它的嵌入字段AnimalCategory的String方法仍然会被屏蔽。

最后的最后,如果处于同一个层级的多个嵌入字段拥有同名的字段或方法,那么从被嵌入类型的值那里,选择此名称的时候就会引发一个编译错误,因为编译器无法确定被选择的成员到底是哪一个。

2、Go 语言是用嵌入字段实现了继承吗?

这里强调一下,Go 语言中根本没有继承的概念,它所做的是通过嵌入字段的方式实现了类型之间的组合

简单来说,面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的。

类型之间的组合采用的是非声明的方式,我们不需要显式地声明某个类型实现了某个接口,或者一个类型继承了另一个类型。

同时,类型组合也是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合。

我们要做的只是把类型当做字段嵌入进来,然后坐享其成地使用嵌入字段所拥有的一切。如果嵌入字段有哪里不合心意,我们还可以用“包装”或“屏蔽”的方式去调整和优化

另外,类型间的组合也是灵活的,我们总是可以通过嵌入字段的方式把一个类型的属性和能力“嫁接”给另一个类型。

这时候,被嵌入类型也就自然而然地实现了嵌入字段所实现的接口。再者,组合要比继承更加简洁和清晰,Go 语言可以轻而易举地通过嵌入多个字段来实现功能强大的类型,却不会有多重继承那样复杂的层次结构和可观的管理成本。

接口类型之间也可以组合。在 Go 语言中,接口类型之间的组合甚至更加常见,我们常常以此来扩展接口定义的行为或者标记接口的特征。

3、值方法和指针方法都是什么意思,有什么区别?

方法的接收者类型必须是某个自定义的数据类型,而且不能是接口类型或接口的指针类型

1、所谓的值方法,就是接收者类型是非指针的自定义数据类型的方法。

比如,我们在前面为AnimalCategory、Animal以及Cat类型声明的那些方法都是值方法。就拿Cat来说,它的String方法的接收者类型就是Cat,一个非指针类型。

2、指针方法,就是接收者类型是指针类型的方法
请看这个方法:

1
2
3
func (cat *Cat) SetName(name string) {
cat.name = name
}

方法SetName的接收者类型是Cat。指这时,Cat可以被叫做Cat的基本类型,针类型的值表示的是指向某个基本类型值的指针。

3、值方法和指针方法之间有什么不同点呢?
它们的不同如下所示:

  • 1、值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。
  • 2、一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go 语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。比如,在Cat类型的变量cat之上,之所以我们可以通过cat.SetName(“monster”)修改猫的名字,是因为 Go 语言把它自动转译为了(&cat).SetName(“monster”),即:先取cat的指针值,然后在该指针值上调用SetName方法。
  • 3、在后边你会了解到,一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的。如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型
    例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    package main

    import "fmt"

    type Cat struct {
    name string // 名字。
    scientificName string // 学名。
    category string // 动物学基本分类。
    }

    func New(name, scientificName, category string) Cat {
    return Cat{
    name: name,
    scientificName: scientificName,
    category: category,
    }
    }

    func (cat *Cat) SetName(name string) {
    cat.name = name
    }

    func (cat Cat) SetNameOfCopy(name string) {
    cat.name = name
    }

    func (cat Cat) Name() string {
    return cat.name
    }

    func (cat Cat) ScientificName() string {
    return cat.scientificName
    }

    func (cat Cat) Category() string {
    return cat.category
    }

    func (cat Cat) String() string {
    return fmt.Sprintf("%s (category: %s, name: %q)",
    cat.scientificName, cat.category, cat.name)
    }

    func main() {
    cat := New("little pig", "American Shorthair", "cat")
    cat.SetName("monster") // (&cat).SetName("monster")
    fmt.Printf("The cat: %s\n", cat)

    cat.SetNameOfCopy("little pig")
    fmt.Printf("The cat: %s\n", cat)

    type Pet interface {
    SetName(name string)
    Name() string
    Category() string
    ScientificName() string
    }

    _, ok := interface{}(cat).(Pet)
    fmt.Printf("Cat implements interface Pet: %v\n", ok)
    _, ok = interface{}(&cat).(Pet)
    fmt.Printf("*Cat implements interface Pet: %v\n", ok)
    }

运行结果:

1
2
3
4
The cat: American Shorthair (category: cat, name: "monster")
The cat: American Shorthair (category: cat, name: "monster")
Cat implements interface Pet: false
*Cat implements interface Pet: true

GO:2.5 使用函数的正确姿势

1、函数是一等的数据类型

在 Go 语言中,函数类型是一等的数据类型。

简单来说,这意味着函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等,就像切片和字典的值那样。

深层次的含义就是:函数值可以由此成为能够被随意传播的独立逻辑组件(或者说功能模块)。

对于函数类型来说,它是一种对一组输入、输出进行模板化的重要工具,它比接口类型更加轻巧、灵活,它的值也借此变成了可被热替换的逻辑组件。
例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

type Printer func(contents string) (n int, err error)
//先声明一个函数类型Printer

func printToStd(contents string) (bytesNum int, err error) {
//定义一个函数printToStd
return fmt.Println(contents)
}

func main() {
var p Printer
p = printToStd
p("something")
}

只要两个函数的参数列表和结果列表中的元素顺序及其类型是一致的,我们就可以说它们是一样的函数,或者说是实现了同一个函数类型的函数。


*函数的签名其实就是函数的参数列表和结果列表的统称,它定义了可用来鉴别不同函数的那些特征,同时也定义了我们与函数交互的方式。*


注意,各个参数和结果的名称不能算作函数签名的一部分,甚至对于结果声明来说,没有名称都可以。

严格来说,函数的名称也不能算作函数签名的一部分,它只是我们在调用函数时,需要给定的标识符而已。

所以函数Printer的签名与printToStd的是一致的,printToStd可以看作是Printer的一个实现,即使它们的名称以及有的结果名称是不同的。

通过main函数中的代码,我们就可以证实这两者的关系了,顺利地把printToStd函数赋给了Printer类型的变量p,并且成功地调用了它。

2、高阶函数

1、什么是高阶函数?

简单地说,高阶函数可以满足下面的两个条件:

  • 1、接受其他的函数作为参数传入;
  • 2、把其他的函数作为结果返回。

只要满足了其中任意一个特点,我们就可以说这个函数是一个高阶函数。高阶函数也是函数式编程中的重要概念和特征。

例:编写calculate函数来实现两个整数间的加减乘除运算,但是希望两个整数和具体的操作都由该函数的调用方给出,那么,这样一个函数应该怎样编写呢?
1、首先声明一个名叫operate的函数类型,它有两个参数和一个结果,都是int类型的。

1
type operate func(x, y int) int

2、然后编写calculate函数的签名部分。这个函数除了需要两个int类型的参数之外,还应该有一个operate类型的参数。

该函数的结果应该有两个,一个是int类型的,代表真正的操作结果,另一个应该是error类型的,因为如果那个operate类型的参数值为nil,那么就应该直接返回一个错误。(函数类型是引用类型,它的零值是nil)

1
2
3
4
5
6
func calculate(x int, y int, op operate) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}

calculate函数实现:先用卫述语句检查一下参数,如果operate类型的参数op为nil,那么就直接返回0和一个代表了具体错误的error类型值。如果检查无误,那么就调用op并把那两个操作数传给它,最后返回op返回的结果和代表没有错误发生的nil。


卫述语句是指被用来检查关键的先决条件的合法性,并在检查未通过的情况下立即终止当前代码块执行的语句。在 Go 语言中,if 语句常被作为卫述语句。


3、在该例中,重点是:让函数在其他函数间传递。
calculate函数的第三个参数是operate类型,而且operate类型就是一个函数类型。在调用calculate函数的时候,我们需要传入一个operate类型的函数值。这个函数值应该怎么写?

只要它的签名与operate类型的签名一致,并且实现得当就可以了。我们可以像上一个例子那样先声明好一个函数,再把它赋给一个变量,也可以直接编写一个实现了operate类型的匿名函数。

1
2
3
op := func(x, y int) int {
return x + y
}

calculate函数就是一个高阶函数。但是我们说高阶函数的特点有两个,而该函数只展示了其中一个特点,即:接受其他的函数作为参数传入

那另一个特点,把其他的函数作为结果返回。如下;
声明函数类型calculateFunc和函数genCalculator。其中,genCalculator函数的唯一结果的类型就是calculateFunc。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package main

import (
"errors"
"fmt"
)

type operate func(x, y int) int

// 方案1。
func calculate(x int, y int, op operate) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}

// 方案2。
type calculateFunc func(x int, y int) (int, error)

func genCalculator(op operate) calculateFunc {
return func(x int, y int) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}
}

func main() {
// 方案1。
x, y := 12, 23
op := func(x, y int) int {
return x + y
}
result, err := calculate(x, y, op)
fmt.Printf("The result: %d (error: %v)\n",
result, err)
result, err = calculate(x, y, nil)
fmt.Printf("The result: %d (error: %v)\n",
result, err)

// 方案2。
x, y = 56, 78
add := genCalculator(op)
result, err = add(x, y)
fmt.Printf("The result: %d (error: %v)\n",
result, err)
}

3、如何实现闭包?

1、闭包是什么?
你可以想象一下,在一个函数中存在对外来标识符的引用。所谓的外来标识符,既不代表当前函数的任何参数或结果,也不是函数内部声明的,它是直接从外边拿过来的。

还有个专门的术语称呼它,叫自由变量,可见它代表的肯定是个变量。实际上,如果它是个常量,那也就形成不了闭包了,因为常量是不可变的程序实体,而闭包体现的却是由“不确定”变为“确定”的一个过程

这个函数(以下简称闭包函数)就是因为引用了自由变量,而呈现出了一种“不确定”的状态,也叫“开放”状态。

也就是说,它的内部逻辑并不是完整的,有一部分逻辑需要这个自由变量参与完成,而后者到底代表了什么在闭包函数被定义的时候却是未知的。

即使对于像 Go 语言这种静态类型的编程语言而言,我们在定义闭包函数的时候最多也只能知道自由变量的类型。

在genCalculator函数内部,实际上就实现了一个闭包,而genCalculator函数也是一个高阶函数。

1
2
3
4
5
6
7
8
func genCalculator(op operate) calculateFunc {
return func(x int, y int) (int, error) {
if op == nil {
return 0, errors.New("invalid operation")
}
return op(x, y), nil
}
}

enCalculator函数只做了一件事,那就是定义一个匿名的、calculateFunc类型的函数并把它作为结果值返回。

而这个匿名的函数就是一个闭包函数。它里面使用的变量op既不代表它的任何参数或结果,也不是它自己声明的,而是定义它的genCalculator函数的参数,所以是一个自由变量。

只有给定了该函数的参数op,我们才能知道它返回给我们的闭包函数可以用于什么运算。

看到if op == nil {那一行了吗?Go 语言编译器读到这里时会试图去寻找op所代表的东西,它会发现op代表的是genCalculator函数的参数,然后,它会把这两者联系起来。这时可以说,自由变量op被“捕获”了。

当程序运行到这里的时候,op就是那个参数值了。如此一来,这个闭包函数的状态就由“不确定”变为了“确定”,或者说转到了“闭合”状态,至此也就真正地形成了一个闭包。

我们在用高阶函数实现闭包。这也是高阶函数的一大功用。
fun1
(高阶函数与闭包)

那么,实现闭包的意义又在哪里呢?
表面上看,我们只是延迟实现了一部分程序逻辑或功能而已,但实际上,我们是在动态地生成那部分程序逻辑。

4、传入函数的那些参数值后来怎么样了?

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

func main() {
array1 := [3]string{"a", "b", "c"}
fmt.Printf("The array: %v\n", array1)
array2 := modifyArray(array1)
fmt.Printf("The modified array: %v\n", array2)
fmt.Printf("The original array: %v\n", array1)
}

func modifyArray(a [3]string) [3]string {
a[1] = "x"
return a
}

运行:

1
2
3
The array: [a b c]
The modified array: [a x c]
The original array: [a b c]

由输出结果知:
数组不会改变,因为所有传给函数的参数值都会被复制函数在其内部使用的并不是参数值的原值,而是它的副本

由于数组是值类型,所以每一次复制都会拷贝它,以及它的所有元素值。我在modify函数中修改的只是原数组的副本而已,并不会对原数组造成任何影响。

注意,对于引用类型,比如:切片、字典、通道,像上面那样复制它们的值,只会拷贝它们本身而已,并不会拷贝它们引用的底层数据。也就是说,这时只是浅表复制,而不是深层复制。

切片值为例,如此复制的时候,只是拷贝了它指向底层数组中某一个元素的指针,以及它的长度值容量值,而它的底层数组并不会被拷贝

另外还要注意,就算我们传入函数的是一个值类型的参数值,但如果这个参数值中的某个元素是引用类型的,那么我们仍然要小心。
如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

func main() {
complexArray1 := [3][]string{
[]string{"d", "e", "f"},
[]string{"g", "h", "i"},
[]string{"j", "k", "l"},
}

complexArray2 := modifyArray(complexArray1)
fmt.Printf("The modified array: %v\n", complexArray2)
fmt.Printf("The original array: %v\n", complexArray1)

}

func modifyArray(a [3][]string) [3][]string {
a[0]=[]string{"a","b","c"}
return a
}

变量complexArray1是[3][]string类型的,也就是说,虽然它是一个数组,但是其中的每个元素又都是一个切片。

这样一个值被传入函数的话,函数中对该参数值的修改会影响到complexArray1本身吗?
答案是会的

运行结果:

1
2
The modified array: [[a b c] [g h i] [j k l]]
The original array: [[d e f] [g h i] [j k l]]

函数传参相关原则:既不要把你程序的细节暴露给外界,也尽量不要让外界的变动影响到你的程序

GO:2.4 通道channel

作为 Go 语言最有特色的数据类型,通道(channel)完全可以与 goroutine(也可称为 go 程)并驾齐驱,共同代表 Go 语言独有的并发编程模式和编程哲学。


Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。)


这句话充分体现了 Go 语言最重要的编程理念。通道类型是后半句话的完美实现,我们可以利用通道在多个 goroutine 之间传递数据。

1、通道的基础知识

通道类型的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。它使用起来十分简单,并不会徒增我们的心智负担。

在声明并初始化一个通道的时候,我们需要用到 Go 语言的内建函数make。就像用make初始化切片那样,我们传给这个函数的第一个参数代表通道的具体类型的类型字面量。

在声明一个通道类型变量的时候,我们首先要确定该通道类型的元素类型,这决定了我们可以通过这个通道传递什么类型的数据。

比如,类型字面量chan int,其中的chan是表示通道类型的关键字,而int则说明了该通道类型的元素类型。又比如,chan string代表了一个元素类型为string的通道类型。

在初始化通道的时候,make函数除了必须接收这样的类型字面量作为参数,还可以接收一个int类型的参数。

后者是可选的,用于表示该通道的容量。所谓通道的容量,就是指通道最多可以缓存多少个元素值。由此,虽然这个参数是int类型的,但是它是不能小于0的。

容量为0时,我们可以称通道为非缓冲通道,也就是不带缓冲的通道。而当容量大于0时,我们可以称为缓冲通道,也就是带有缓冲的通道。非缓冲通道和缓冲通道有着不同的数据传递方式。

一个通道相当于一个先进先出(FIFO)的队列。也就是说,通道中的各个元素值都是严格地按照发送的顺序排列的,先被发送通道的元素值一定会先被接收。元素值的发送和接收都需要用到操作符<-。我们也可以叫它接送操作符。一个左尖括号紧接着一个减号形象地代表了元素值的传输方向。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
ch1 := make(chan int, 3)
ch1 <- 2
ch1 <- 1
ch1 <- 3
elem1 := <-ch1
fmt.Printf("The first element received from channel ch1: %v\n",
elem1)//2
}

声明并初始化了一个元素类型为int、容量为3的通道ch1,并用三条语句,向该通道先后发送了三个元素值2、1和3。

由于该通道的容量为 3,所以,我可以在通道不包含任何元素值的时候,连续地向该通道发送三个值,此时这三个值都会被缓存在通道之中。

2、对通道的发送和接收操作都有哪些基本的特性?

它们的基本特性如下:

  • 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。

  • 发送操作和接收操作中对元素值的处理都是不可分割的。

  • 发送操作在完全完成之前会被阻塞。接收操作也是如此。

1、第一个基本特性

在同一时刻,Go 语言的运行时系统(以下简称运行时系统)只会执行对同一个通道的任意个发送操作中的某一个。

直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。

类似的,在同一时刻,运行时系统也只会执行,对同一个通道的任意个接收操作中的某一个。

直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。

这里所谓的并发执行,你可以这样认为,多个代码块分别在不同的 goroutine 之中,并有机会在同一个时间段内被执行。

另外,对于通道中的同一个元素值来说,发送操作和接收操作之间也是互斥的。例如,虽然会出现,正在被复制进通道但还未复制完成的元素值,但是这时它绝不会被想接收它的一方看到和取走。

这里要注意的一个细节是,元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。

另一方面,元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步,第一步是生成正在通道中的这个元素值的副本,并准备给到接收方第二步是删除在通道中的这个元素值。

2、第二个基本特性

这里的“不可分割”的意思是,它们处理元素值时都是一气呵成的,绝不会被打断。

例如,发送操作要么还没复制元素值,要么已经复制完毕,绝不会出现只复制了一部分的情况。

又例如,接收操作在准备好元素值的副本之后,一定会删除掉通道中的原值,绝不会出现通道中仍有残留的情况。

这既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。

3、第三个基本特性

一般情况下,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。

在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到这句代码的阻塞解除。

更细致地说,在通道完成发送操作之后,运行时系统会通知这句代码所在的 goroutine,以使它去争取继续运行代码的机会。

另外,接收操作通常包含了“复制通道内的元素值”“放置副本到接收方”“删掉原值”三个步骤。

在所有这些步骤完全完成之前,发起该操作的代码也会一直阻塞,直到该代码所在的 goroutine 收到了运行时系统的通知并重新获得运行机会为止。

如此阻塞代码其实就是为了实现操作的互斥和元素值的完整。

3、发送操作和接收操作在什么时候可能被长时间的阻塞?

1、针对缓冲通道

如果通道已满,那么对它的所有发送操作都会被阻塞,直到通道中有元素值被接收走。

这时,通道会优先通知最早因此而等待的、那个发送操作所在的 goroutine,后者会再次执行发送操作。

由于发送操作在这种情况下被阻塞后,它们所在的 goroutine 会顺序地进入通道内部的发送等待队列,所以通知的顺序总是公平的。

相对的,如果通道已空,那么对它的所有接收操作都会被阻塞,直到通道中有新的元素值出现。这时,通道会通知最早等待的那个接收操作所在的 goroutine,并使它再次执行接收操作。

因此而等待的、所有接收操作所在的 goroutine,都会按照先后顺序被放入通道内部的接收等待队列。

2、针对非缓冲通道

情况要简单一些。

无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。

由此可见,非缓冲通道是在用同步的方式传递数据。也就是说,只有收发双方对接上了,数据才会被传递。并且,数据是直接从发送方复制到接收方的,中间并不会用非缓冲通道做中转。

相比之下,缓冲通道则在用异步的方式传递数据。在大多数情况下,缓冲通道会作为收发双方的中间件

正如前文所述,元素值会先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。

但是,当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。以上说的都是在正确使用通道的前提下会发生的事情。

下面我特别说明一下,由于错误使用通道而造成的阻塞。

对于值为nil的通道不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的 goroutine 中的任何代码,都不再会被执行。

注意,由于通道类型是引用类型,所以它的零值就是nil。换句话说,当我们只声明该类型的变量但没有用make函数对它进行初始化时,该变量的值就会是nil。我们一定不要忘记初始化通道!

4、发送操作和接收操作在什么时候会引发 panic?

1、对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发 panic。但是通道一旦关闭,再对它进行发送操作,就会引发 panic

2、另外,如果我们试图关闭一个已经关闭了的通道,也会引发 panic。注意,接收操作可以感知到通道的关闭的,并能够安全退出

更具体地说,当我们把接收表达式的结果同时赋给两个变量时,第二个变量的类型就是一定bool类型。它的值如果为false就说明通道已经关闭,并且再没有元素值可取了。

注意,如果通道关闭时,里面还有元素值未被取出,那么接收表达式的第一个结果,仍会是通道中的某一个元素值,而第二个结果值一定会是true。

因此,通过接收表达式的第二个结果值,来判断通道是否关闭是可能有延时的。

由于通道的收发操作有上述特性,所以除非有特殊的保障措施,我们千万不要让接收方关闭通道,而应当让发送方做这件事。

5、单向通道

我们在说“通道”的时候指的都是双向通道,即:既可以发也可以收的通道。

所谓单向通道就是,只能发不能收,或者只能收不能发的通道。一个通道是双向的,还是单向的是由它的类型字面量体现的。

接收操作符<-,把它用在通道的类型字面量中,那么它代表的就不是“发送”或“接收”的动作,而是表示通道的方向。
例:

1
2
3
4
5
6
var uselessChan = make(chan<- int, 1)
//1、声明并初始化uselessChan变量,类型是chan<- int,容量是1。

//2、chan<-,表示通道是单向的,并且只能发而不能收,称为发送通道

//3、如果<-chan,单向通道,只能收不能发,称为接收通道

注意,与发送操作和接收操作对应,这里的“发”和“收”都是站在操作通道的代码的角度上说的。

从上述变量的名字上你也能猜到,这样的通道是没用的。通道就是为了传递数据而存在的,声明一个只有一端(发送端或者接收端)能用的通道没有任何意义。那么,单向通道的用途究竟在哪儿呢?

6、单向通道有什么应用价值?

单向通道最主要的用途就是约束其他代码的行为

这需要从两个方面讲,都跟函数的声明有些关系。

1、第一个方面

先来看下面的代码:

1
2
3
func SendInt(ch chan<- int) {
ch <- rand.Intn(1000)
}

用func关键字声明了一个叫做SendInt的函数。这个函数只接受一个chan<- int类型的参数。在这个函数中的代码只能向参数ch发送元素值,而不能从它那里接收元素值。这就起到了约束函数行为的作用。

你可能会问,我自己写的函数自己肯定能确定操作通道的方式,为什么还要再约束?这个例子可能过于简单了,在实际场景中,这种约束一般会出现在接口类型声明中的某个方法定义上。请看这个叫Notifier的接口类型声明:

1
2
3
type Notifier interface {
SendInt(ch chan<- int)
}

在接口类型声明的花括号中,每一行都代表着一个方法的定义。接口中的方法定义与函数声明很类似,但是只包含了方法名称、参数列表和结果列表。

一个类型如果想成为一个接口类型的实现类型,那么就必须实现这个接口中定义的所有方法。因此,如果我们在某个方法的定义中使用了单向通道类型,那么就相当于在对它的所有实现做出约束。

在这里,Notifier接口中的SendInt方法只会接受一个发送通道作为参数,所以,在该接口的所有实现类型中的SendInt方法都会受到限制。这种约束方式还是很有用的,尤其是在我们编写模板代码或者可扩展的程序库的时候。

顺便说一下,我们在调用SendInt函数的时候,只需要把一个元素类型匹配的双向通道传给它就行了,没必要用发送通道,因为 Go 语言在这种情况下会自动地把双向通道转换为函数所需的单向通道。

1
2
intChan1 := make(chan int, 3)
SendInt(intChan1)

2、第二个方面

我们还可以在函数声明的结果列表中使用单向通道。如下所示:

1
2
3
4
5
6
7
8
9
func getIntChan() <-chan int {
num := 5
ch := make(chan int, num)
for i := 0; i < num; i++ {
ch <- i
}
close(ch)
return ch
}

函数getIntChan会返回一个<-chan int类型的通道,这就意味着得到该通道的程序,只能从通道中接收元素值。这实际上就是对函数调用方的一种约束了。

另外,我们在 Go 语言中还可以声明函数类型,如果我们在函数类型中使用了单向通道,那么就相等于在约束所有实现了这个函数类型的函数。

我们再顺便看一下调用getIntChan的代码:

1
2
3
4
intChan2 := getIntChan()
for elem := range intChan2 {
fmt.Printf("The element in intChan2: %v\n", elem)
}

我把调用getIntChan得到的结果值赋给了变量intChan2,然后用for语句循环地取出了该通道中的所有元素值,并打印出来。

这里的for语句也可以被称为带有range子句的for语句。它的用法我在后面讲for语句的时候专门说明。现在你只需要知道关于它的三件事:

  • 1、上述for语句会不断地尝试从通道intChan2中取出元素值。即使intChan2已经被关闭了,它也会在取出所有剩余的元素值之后再结束执行。
  • 2、通常,当通道intChan2中没有元素值时,这条for语句会被阻塞在有for关键字的那一行,直到有新的元素值可取。不过,由于这里的getIntChan函数会事先将intChan2关闭,所以它在取出intChan2中的所有元素值之后会直接结束执行。
  • 3、倘若通道intChan2的值为nil,那么这条for语句就会被永远地阻塞在有for关键字的那一行。

这就是带range子句的for语句与通道的联用方式。不过,它是一种用途比较广泛的语句,还可以被用来从其他一些类型的值中获取元素。除此之外,Go 语言还有一种专门为了操作通道而存在的语句:select语句。

7、select语句与通道怎样联用,应该注意些什么?

select语句只能与通道联用,它一般由若干个分支组成。每次执行这种语句的时候,一般只有一个分支中的代码会被运行。

select语句的分支分为两种,一种叫做候选分支,另一种叫做默认分支候选分支总是以关键字case开头,后跟一个case表达式和一个冒号,然后我们可以从下一行开始写入当分支被选中时需要执行的语句。

默认分支其实就是 default case,因为,当且仅当没有候选分支被选中时它才会被执行,所以它以关键字default开头并直接后跟一个冒号。同样的,我们可以在default:的下一行写入要执行的语句。

由于select语句是专为通道而设计的,所以每个case表达式中都只能包含操作通道的表达式,比如接收表达式。

当然,如果我们需要把接收表达式的结果赋给变量的话,还可以把这里写成赋值语句或者短变量声明。下面展示一个简单的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 准备好几个通道。
intChannels := [3]chan int{
make(chan int, 1),
make(chan int, 1),
make(chan int, 1),
}
// 随机选择一个通道,并向它发送元素值。
index := rand.Intn(3)
fmt.Printf("The index: %d\n", index)
intChannels[index] <- index
// 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。
select {
case <-intChannels[0]:
fmt.Println("The first candidate case is selected.")
case <-intChannels[1]:
fmt.Println("The second candidate case is selected.")
case elem := <-intChannels[2]:
fmt.Printf("The third candidate case is selected, the element is %d.\n", elem)
default:
fmt.Println("No candidate case is selected!")
}

1、在使用select语句的时候,我们首先需要注意下面几个事情。

  • 1、如果像上述示例那样加入了默认分支,那么无论涉及通道操作的表达式是否有阻塞,select语句都不会被阻塞。如果那几个表达式都阻塞了,或者说都没有满足求值的条件,那么默认分支就会被选中并执行。
  • 2、如果没有加入默认分支,那么一旦所有的case表达式都没有满足求值条件,那么select语句就会被阻塞。直到至少有一个case表达式满足条件为止。
  • 3、我们可能会因为通道关闭了,而直接从通道接收到一个其元素类型的零值。所以,在很多时候,我们需要通过接收表达式的第二个结果值来判断通道是否已经关闭。一旦发现某个通道关闭了,我们就应该及时地屏蔽掉对应的分支或者采取其他措施。这对于程序逻辑和程序性能都是有好处的。
  • 4、select语句只能对其中的每一个case表达式各求值一次。所以,如果我们想连续或定时地操作其中的通道的话,就往往需要通过在for语句中嵌入select语句的方式实现。但这时要注意,简单地在select语句的分支中使用break语句,只能结束当前的select语句的执行,而并不会对外层的for语句产生作用。这种错误的用法可能会让这个for语句无休止地运行下去。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
intChan := make(chan int, 1)
// 一秒后关闭通道。
time.AfterFunc(time.Second, func() {
close(intChan)
})
select {
case _, ok := <-intChan:
if !ok {
fmt.Println("The candidate case is closed.")
break
}
fmt.Println("The candidate case is selected.")
}

运行:

1
The candidate case is closed.

8、select语句的分支选择规则都有哪些?

规则如下面所示。

  • 1、对于每一个case表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式。比如,如果case表达式是包含了接收表达式的短变量声明时,那么在赋值符号左边的就可以是一个或两个表达式,不过此处的表达式的结果必须是可以被赋值的。当这样的case表达式被求值时,它包含的多个表达式总会以从左到右的顺序被求值。
  • 2、select语句包含的候选分支中的case表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的。结合上一条规则,在select语句开始执行时,排在最上边的候选分支中最左边的表达式会最先被求值,然后是它右边的表达式。仅当最上边的候选分支中的所有表达式都被求值完毕后,从上边数第二个候选分支中的表达式才会被求值,顺序同样是从左到右,然后是第三个候选分支、第四个候选分支,以此类推。
  • 3、对于每一个case表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该case表达式的求值就是不成功的。在这种情况下,我们可以说,这个case表达式所在的候选分支是不满足选择条件的。
  • 4、仅当select语句中的所有case表达式都被求值完毕后,它才会开始选择候选分支。这时候,它只会挑选满足选择条件的候选分支执行。如果所有的候选分支都不满足选择条件,那么默认分支就会被执行。如果这时没有默认分支,那么select语句就会立即进入阻塞状态,直到至少有一个候选分支满足选择条件为止。一旦有一个候选分支满足选择条件,select语句(或者说它所在的 goroutine)就会被唤醒,这个候选分支就会被执行。
  • 5、如果select语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行。注意,即使select语句是在被唤醒时发现的这种情况,也会这样做
  • 6、一条select语句中只能够有一个默认分支。并且,默认分支只在无候选分支可选时才会被执行,这与它的编写位置无关。
  • 7、select语句的每次执行,包括case表达式求值和分支选择,都是独立的。不过,至于它的执行是否是并发安全的,就要看其中的case表达式以及分支中,是否包含并发不安全的代码了。
    例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    package main

    import "fmt"

    var channels = [3]chan int{
    nil,
    make(chan int),
    nil,
    }

    var numbers = []int{1, 2, 3}

    func main() {
    select {
    case getChan(0) <- getNumber(0):
    fmt.Println("The first candidate case is selected.")
    case getChan(1) <- getNumber(1):
    fmt.Println("The second candidate case is selected.")
    case getChan(2) <- getNumber(2):
    fmt.Println("The third candidate case is selected")
    default:
    fmt.Println("No candidate case is selected!")
    }
    }

    func getNumber(i int) int {
    fmt.Printf("numbers[%d]\n", i)
    return numbers[i]
    }

    func getChan(i int) chan int {
    fmt.Printf("channels[%d]\n", i)
    return channels[i]
    }

运行:

1
2
3
4
5
6
7
channels[0]
numbers[0]
channels[1]
numbers[1]
channels[2]
numbers[2]
No candidate case is selected!

GO:2.3 字典的操作和约束

字典(map)存储的不是单一值的集合,而是键值对的集合。


什么是键值对?

它是从英文 key-value pair 直译过来的一个词。顾名思义,一个键值对就代表了一对键和值。
注意,一个“键”和一个“值”分别代表了一个从属于某一类型的独立值,把它们两个捆绑在一起就是一个键值对了。


在 Go 语言规范中,应该是为了避免歧义,他们将键值对换了一种称呼,叫做:“键 - 元素对”。我们也沿用这个看起来更加清晰的词来讲解。

1、为什么字典的键类型会受到约束?

Go 语言的字典类型其实是一个哈希表(hash table)的特定实现,在这个实现中,键和元素的最大不同在于,键的类型是受限的,而元素却可以是任意类型的。

如果要探究限制的原因,我们就先要了解哈希表中最重要的一个过程:映射

你可以把键理解为元素的一个索引,我们可以在哈希表中通过键查找与它成对的那个元素。

键和元素的这种对应关系,在数学里就被称为“映射”,这也是“map”这个词的本意,哈希表的映射过程就存在于对键 - 元素对的增、删、改、查的操作之中。

1
2
3
4
5
6
7
8
9
10
11
12
aMap := map[string]int{
"one": 1,
"two": 2,
"three": 3,
}
k := "two"
v, ok := aMap[k]
if ok {
fmt.Printf("The element of key %q: %d\n", k, v)
} else {
fmt.Println("Not found!")
}

比如,我们要在哈希表中查找与某个键值对应的那个元素值,那么我们需要先把键值作为参数传给这个哈希表。

哈希表会先用哈希函数(hash function)把键值转换为哈希值。哈希值通常是一个无符号的整数。一个哈希表会持有一定数量的桶(bucket),我们也可以叫它哈希桶,这些哈希桶会均匀地储存其所属哈希表收纳的键 - 元素对。

因此,哈希表会先用这个键哈希值的低几位去定位到一个哈希桶,然后再去这个哈希桶中,查找这个键。

由于键 - 元素对总是被捆绑在一起存储的,所以一旦找到了键,就一定能找到对应的元素值。随后,哈希表就会把相应的元素值作为结果返回。

只要这个键 - 元素对存在哈希表中就一定会被查找到,因为哈希表增、改、删键 - 元素对时的映射过程,与前文所述如出一辙。

现在我们知道了,映射过程的第一步就是:把键值转换为哈希值。

在 Go 语言的字典中,每一个键值都是由它的哈希值代表的。也就是说,字典不会独立存储任何键的值,但会独立存储它们的哈希值。

2、字典的键类型不能是哪些类型?

Go 语言字典的键类型不可以是函数类型、字典类型和切片类型

Go 语言规范规定,在键类型的值之间必须可以施加操作符==和!=。换句话说,键类型的值必须要支持判等操作。由于函数类型、字典类型和切片类型的值并不支持判等操作,所以字典的键类型不能是这些类型。

另外,如果键的类型是接口类型的,那么键值的实际类型也不能是上述三种类型,否则在程序运行过程中会引发 panic(即运行时恐慌)。
例:

1
2
3
4
5
var badMap2 = map[interface{}]int{
"1": 1,
[]int{2}: 2, // 这里会引发panic。
3: 3,
}

这里的变量badMap2的类型是键类型为interface{}、值类型为int的字典类型。这样声明并不会引起什么错误。或者说,我通过这样的声明躲过了 Go 语言编译器的检查。

注意,我用字面量在声明该字典的同时对它进行了初始化,使它包含了三个键 - 元素对。其中第二个键 - 元素对的键值是[]int{2},元素值是2。这样的键值也不会让 Go 语言编译器报错,因为从语法上说,这样做是可以的

但是,当我们运行这段代码的时候,Go 语言的运行时(runtime)系统就会发现这里的问题,它会抛出一个 panic,并把根源指向字面量中定义第二个键 - 元素对的那一行。我们越晚发现问题,修正问题的成本就会越高,所以最好不要把字典的键类型设定为任何接口类型。如果非要这么做,请一定确保代码在可控的范围之内。

还要注意,如果键的类型是数组类型,那么还要确保该类型的元素类型不是函数类型、字典类型或切片类型。

比如,由于类型[1][]string的元素类型是[]string,所以它就不能作为字典类型的键类型。另外,如果键的类型是结构体类型,那么还要保证其中字段的类型的合法性。无论不合法的类型被埋藏得有多深,比如map[[1][2][3][]string]int,Go 语言编译器都会把它揪出来。

  • 你可能会有疑问,为什么键类型的值必须支持判等操作?我在前面说过,Go 语言一旦定位到了某一个哈希桶,那么就会试图在这个桶中查找键值。具体是怎么找的呢?

首先,每个哈希桶都会把自己包含的所有键的哈希值存起来。Go 语言会用被查找键的哈希值与这些哈希值逐个对比,看看是否有相等的。如果一个相等的都没有,那么就说明这个桶中没有要查找的键值,这时 Go 语言就会立刻返回结果了。

如果有相等的,那就再用键值本身去对比一次。为什么还要对比?原因是,不同值的哈希值是可能相同的。这有个术语,叫做“哈希碰撞”。

所以,即使哈希值一样,键值也不一定一样。如果键类型的值之间无法判断相等,那么此时这个映射的过程就没办法继续下去了。最后,只有键的哈希值和键值都相等,才能说明查找到了匹配的键 - 元素对。

3、应该优先考虑哪些类型作为字典的键类型?

在 Go 语言中,有些类型的值是支持判等的,有些是不支持的。那么在这些值支持判等的类型当中,哪些更适合作为字典的键类型呢?

这里先抛开我们使用字典时的上下文,只从性能的角度看。在前文所述的映射过程中,“把键值转换为哈希值”以及“把要查找的键值与哈希桶中的键值做对比”, 明显是两个重要且比较耗时的操作

因此,可以说,求哈希和判等操作的速度越快,对应的类型就越适合作为键类型


对于所有的基本类型、指针类型,以及数组类型、结构体类型和接口类型,Go 语言都有一套算法与之对应。这套算法中就包含了哈希和判等。以求哈希的操作为例,宽度越小的类型速度通常越快。对于布尔类型、整数类型、浮点数类型、复数类型和指针类型来说都是如此。对于字符串类型,由于它的宽度是不定的,所以要看它的值的具体长度,长度越短求哈希越快


类型的宽度是指它的单个值需要占用的字节数。比如,bool、int8和uint8类型的一个值需要占用的字节数都是1,因此这些类型的宽度就都是1。

以上说的都是基本类型,再来看高级类型。

对数组类型的值求哈希实际上是依次求得它的每个元素的哈希值并进行合并,所以速度就取决于它的元素类型以及它的长度。细则同上。

与之类似,对结构体类型的值求哈希实际上就是对它的所有字段值求哈希并进行合并,所以关键在于它的各个字段的类型以及字段的数量。而对于接口类型,具体的哈希算法,则由值的实际类型决定。

我不建议你使用这些高级数据类型作为字典的键类型,不仅仅是因为对它们的值求哈希,以及判等的速度较慢,更是因为在它们的值中存在变数。

比如,对一个数组来说,我可以任意改变其中的元素值,但在变化前后,它却代表了两个不同的键值。

对于结构体类型的值情况可能会好一些,因为如果我可以控制其中各字段的访问权限的话,就可以阻止外界修改它了。把接口类型作为字典的键类型最危险。

如果在这种情况下 Go 运行时系统发现某个键值不支持判等操作,那么就会立即抛出一个 panic。在最坏的情况下,这足以使程序崩溃。

  • 那么,在那些基本类型中应该优先选择哪一个?
    答案是,优先选用数值类型和指针类型,通常情况下类型的宽度越小越好。如果非要选择字符串类型的话,最好对键值的长度进行额外的约束。
  • 那什么是不通常的情况?笼统地说,Go
    语言有时会对字典的增、删、改、查操作做一些优化。

比如,在字典的键类型为字符串类型的情况下;又比如,在字典的键类型为宽度为4或8的整数类型的情况下。

4、在值为nil的字典上执行读操作会成功吗,那写操作呢?

由于字典是引用类型,所以当我们仅声明而不初始化一个字典类型的变量的时候,它的值会是nil

  • 在这样一个变量上试图通过键值获取对应的元素值,或者添加键 - 元素对,会成功吗?
    这个问题虽然简单,但却是我们必须铭记于心的,因为这涉及程序运行时的稳定性。

除了添加键 - 元素对,我们在一个值为nil的字典上做任何操作都不会引起错误。当我们试图在一个值为nil的字典中添加键 - 元素对的时候,Go 语言的运行时系统就会立即抛出一个 panic。你可以运行一下 demo19.go 文件试试看。

GO:2.2 container包中的容器

Go 语言的链表实现在标准库的container/list代码包中。这个代码包中有两个公开的程序实体——List和Element,List 实现了一个双向链表(以下简称链表),而 Element 则代表了链表中元素的结构。

1、可以把自己生成的Element类型值传给链表吗?

这里用到了List的四种方法。

  • MoveBefore方法和MoveAfter方法,它们分别用于把给定的元素移动到另一个元素的前面和后面。
  • MoveToFront方法和MoveToBack方法,分别用于把给定的元素移动到链表的最前端和最后端。

在这些方法中,“给定的元素”都是Element类型的,Element类型是Element类型的指针类型,*Element的值就是元素的指针。

1
2
3
4
5
func (l *List) MoveBefore(e, mark *Element)
func (l *List) MoveAfter(e, mark *Element)

func (l *List) MoveToFront(e *Element)
func (l *List) MoveToBack(e *Element)

1、如果我们自己生成这样的值,然后把它作为“给定的元素”传给链表的方法,那么会发生什么?链表会接受它吗?

典型回答:不会接受,这些方法将不会对链表做出任何改动。因为我们自己生成的Element值并不在链表中,所以也就谈不上“在链表中移动元素”。更何况链表不允许我们把自己生成的Element值插入其中。

  • 在List包含的方法中,用于插入新元素的那些方法都只接受interface{}类型的值。这些方法在内部会使用Element值,包装接收到的新元素。
  • 这样做正是为了避免直接使用我们自己生成的元素,主要原因是避免链表的内部关联,遭到外界破坏,这对于链表本身以及我们这些使用者来说都是有益的。

2、List的方法还有下面这几种:

  • Front和Back方法分别用于获取链表中最前端和最后端的元素
  • InsertBefore和InsertAfter方法分别用于在指定的元素之前和之后插入新元素
  • PushFront和PushBack方法则分别用于在链表的最前端和最后端插入新元素。
1
2
3
4
5
6
7
8
func (l *List) Front() *Element
func (l *List) Back() *Element

func (l *List) InsertBefore(v interface{}, mark *Element) *Element
func (l *List) InsertAfter(v interface{}, mark *Element) *Element

func (l *List) PushFront(v interface{}) *Element
func (l *List) PushBack(v interface{}) *Element

这些方法都会把一个Element值的指针作为结果返回,它们就是链表留给我们的安全“接口”。拿到这些内部元素的指针,我们就可以去调用前面提到的用于移动元素的方法了。

2、为什么链表可以做到开箱即用?

List和Element都是结构体类型。结构体类型有一个特点,那就是它们的零值都会是拥有特定结构,但是没有任何定制化内容的值,相当于一个空壳。值中的字段也都会被分别赋予各自类型的零值。

  • 广义来讲,所谓的零值就是只做了声明,但还未做初始化的变量被给予的缺省值。每个类型的零值都会依据该类型的特性而被设定。
  • 比如,经过语句var a [2]int声明的变量a的值,将会是一个包含了两个0的整数数组。又比如,经过语句var s []int声明的变量s的值将会是一个[]int类型的、值为nil的切片。

那么经过语句var l list.List声明的变量l的值将会是什么呢?
[1] 这个零值将会是一个长度为0的链表。这个链表持有的根元素也将会是一个空壳,其中只会包含缺省的内容。

  • 那这样的链表我们可以直接拿来使用吗?
    答案是,可以的。这被称为“开箱即用”。Go 语言标准库中很多结构体类型的程序实体都做到了开箱即用。这也是在编写可供别人使用的代码包(或者说程序库)时,我们推荐遵循的最佳实践之一。

那么,语句var l list.List声明的链表l可以直接使用,这是怎么做到的呢?
关键在于它的“延迟初始化”机制。

1、延迟初始化

所谓的延迟初始化,你可以理解为把初始化操作延后,仅在实际需要的时候才进行。延迟初始化的优点在于“延后”,它可以分散初始化操作带来的计算量和存储空间消耗

例如,如果我们需要集中声明非常多的大容量切片的话,那么那时的 CPU 和内存空间的使用量肯定都会一个激增,并且只有设法让其中的切片及其底层数组被回收,内存使用量才会有所降低。

如果数组是可以被延迟初始化的,那么计算量和存储空间的压力就可以被分散到实际使用它们的时候。这些数组被实际使用的时间越分散,延迟初始化带来的优势就会越明显。


实际上,Go 语言的切片就起到了延迟初始化其底层数组的作用,你可以想一想为什么会这么说的理由。延迟初始化的缺点恰恰也在于“延后”。你可以想象一下,如果我在调用链表的每个方法的时候,它们都需要先去判断链表是否已经被初始化,那这也会是一个计算量上的浪费。在这些方法被非常频繁地调用的情况下,这种浪费的影响就开始显现了,程序的性能将会降低。


在这里的链表实现中,一些方法是无需对是否初始化做判断的。比如Front方法和Back方法,一旦发现链表的长度为0, 直接返回nil就好了。

又比如,在用于删除元素、移动元素,以及一些用于插入元素的方法中,只要判断一下传入的元素中指向所属链表的指针,是否与当前链表的指针相等就可以了。

如果不相等,就一定说明传入的元素不是这个链表中的,后续的操作就不用做了。反之,就一定说明这个链表已经被初始化了。

原因在于,链表的PushFront方法、PushBack方法、PushBackList方法以及PushFrontList方法总会先判断链表的状态,并在必要时进行初始化,这就是延迟初始化。

而且,我们在向一个空的链表中添加新元素的时候,肯定会调用这四个方法中的一个,这时新元素中指向所属链表的指针,一定会被设定为当前链表的指针。所以,指针相等是链表已经初始化的充分必要条件。

?List利用了自身以及Element在结构上的特点,巧妙地平衡了延迟初始化的优缺点,使得链表可以开箱即用,并且在性能上可以达到最优。

3、Ring与List的区别在哪儿?

container/ring包中的Ring类型实现的是一个循环链表,也就是我们俗称的环。其实List在内部就是一个循环链表。它的根元素永远不会持有任何实际的元素值,而该元素的存在就是为了连接这个循环链表的首尾两端。

所以也可以说,List的零值是一个只包含了根元素,但不包含任何实际元素值的空链表。
那么,既然Ring和List在本质上都是循环链表,那它们到底有什么不同呢?

最主要的不同有下面几种。

  • 1、Ring类型的数据结构仅由它自身即可代表,而List类型则需要由它以及Element类型联合表示。这是表示方式上的不同,也是结构复杂度上的不同。
  • 2、一个Ring类型的值严格来讲,只代表了其所属的循环链表中的一个元素,而一个List类型的值则代表了一个完整的链表。这是表示维度上的不同。
  • 3、在创建并初始化一个Ring值的时候,我们可以指定它包含的元素的数量,但是对于一个List值来说却不能这样做(也没有必要这样做)。循环链表一旦被创建,其长度是不可变的。这是两个代码包中的New函数在功能上的不同,也是两个类型在初始化值方面的第一个不同。
  • 4、仅通过var r ring.Ring语句声明的r将会是一个长度为1的循环链表,而List类型的零值则是一个长度为0的链表。别忘了List中的根元素不会持有实际元素值,因此计算长度时不会包含它。这是两个类型在初始化值方面的第二个不同。
  • 5、Ring值的Len方法的算法复杂度是 O(N) 的,而List值的Len方法的算法复杂度则是 O(1) 的。这是两者在性能方面最显而易见的差别。

其他的不同基本上都是方法方面的了。比如,循环链表也有用于插入、移动或删除元素的方法,不过用起来都显得更抽象一些,等等。

GO:2.1 数组和切片

1、数组(array)类型和切片(slice)类型的异同

1、相同

数组(array)类型和切片(slice)类型的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。

2、区别

它们最重要的不同是:数组类型的值(以下简称数组)的长度是固定的,而切片类型的值(以下简称切片)是可变长的。

数组的长度在声明它的时候就必须给定,并且之后不会再改变。可以说,数组的长度是其类型的一部分。比如,[1]string和[2]string就是两个不同的数组类型

切片的类型字面量中只有元素的类型,而没有长度。切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。
arrslice1

我们其实可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。

  • 也正因为如此,Go 语言的切片类型属于引用类型,同属引用类型的还有字典类型、通道类型、函数类型等;而 Go 语言的数组类型则属于值类型,同属值类型的有基础数据类型以及结构体类型。注意,Go 语言里不存在像 Java 等编程语言中令人困惑的“传值或传引用”问题。在 Go 语言中,我们判断所谓的“传值”或者“传引用”只要看被传递的值的类型就好了。如果传递的值是引用类型的,那么就是“传引用”。如果传递的值是值类型的,那么就是“传值”。从传递成本的角度讲,引用类型的值往往要比值类型的值低很多。我们在数组和切片之上都可以应用索引表达式,得到的都会是某个元素。我们在它们之上也都可以应用切片表达式,也都会得到一个新的切片

  • 通过调用内建函数len,得到数组和切片的长度。通过调用内建函数cap,我们可以得到它们的容量。但要注意,数组的容量永远等于其长度,都是不可变的。切片的容量却不是这样,并且它的变化是有规律可寻的。

2、怎样正确估算切片的长度和容量?

1、如下例子demo15.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

func main() {
// 示例1。
s1 := make([]int, 5)
//用内建函数make声明了一个[]int类型的变量s1,传给make函数的第二个参数是5,从而指明了该切片的长度

fmt.Printf("The length of s1: %d\n", len(s1))
fmt.Printf("The capacity of s1: %d\n", cap(s1))
fmt.Printf("The value of s1: %d\n", s1)

s2 := make([]int, 5, 8)
//同样的方式声明了切片s2,多传入了一个参数8以指明该切片的容量。

fmt.Printf("The length of s2: %d\n", len(s2))
fmt.Printf("The capacity of s2: %d\n", cap(s2))
fmt.Printf("The value of s2: %d\n", s2)
}

  • 切片s1和s2的容量都是多少?
    切片s1的容量是5,s2的容量是8

2、通过切片表达式基于某个数组或切片生成新切片的时候,情况就变得复杂起来了。
如下例子:

1
2
3
4
5
6
7
8
9
s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
//s3中有 8 个元素,分别是从1到8的整数,s3的长度和容量都是8。

s4 := s3[3:6]
//用切片表达式s3[3:6]初始化了切片s4

fmt.Printf("The length of s4: %d\n", len(s4))
fmt.Printf("The capacity of s4: %d\n", cap(s4))
fmt.Printf("The value of s4: %d\n", s4)

运行

1
2
3
The length of s4: 3
The capacity of s4: 5
The value of s4: [4 5 6]

  • 1、s4的长度是多少?
    长度是3

切片表达式中s3[3:6]取数范围为[3,6),即从索引3开始取,取到索引6但不包含索引6。

这里的3可被称为起始索引,6可被称为结束索引。那么s4的长度就是6减去3,即3。

可以说,s4中的索引从0到2指向的元素对应的是s3及其底层数组中索引从3到5的那 3 个元素。
arrslice2

  • 2、再来看s4的容量是多少?
    容量是5

    切片的容量代表了它的底层数组的长度,但这仅限于使用make函数或者切片值字面量初始化切片的情况。

更通用的规则是:一个切片的容量可以被看作是透过这个窗口最多可以看到的底层数组中元素的个数。

由于s4是通过在s3上施加切片操作得来的,所以s3的底层数组就是s4的底层数组。

又因为,在底层数组不变的情况下,切片代表的窗口可以向右扩展,直至其底层数组的末尾。

所以,s4的容量就是其底层数组的长度8, 减去上述切片表达式中的那个起始索引3,即5。

注意,切片代表的窗口是无法向左扩展的。也就是说,我们永远无法透过s4看到s3中最左边的那 3 个元素。

  • 把切片的窗口向右扩展到最大的方法。
    对于s4来说,切片表达式s4[0:cap(s4)]就可以做到。我想你应该能看懂。该表达式的结果值(即一个新的切片)会是[]int{4, 5, 6, 7, 8},其长度和容量都是5。

3、怎样估算切片容量的增长?

一旦一个切片无法容纳更多的元素,Go 语言就会想办法扩容。但它并不会改变原来的切片,而是会生成一个容量更大的切片,然后将把原有的元素和新元素一并拷贝到新切片中。在一般的情况下,你可以简单地认为新切片的容量(以下简称新容量)将会是原切片容量(以下简称原容量)的 2 倍。

但是,当原切片的长度(以下简称原长度)大于或等于1024时,Go 语言将会以原容量的1.25倍作为新容量的基准(以下新容量基准)新容量基准会被调整(不断地与1.25相乘),直到结果不小于原长度与要追加的元素数量之和(以下简称新长度)。最终,新容量往往会比新长度大一些,当然,相等也是可能的。

另外,如果我们一次追加的元素过多,以至于使新长度比原容量的 2 倍还要大,那么新容量就会以新长度为基准。注意,与前面那种情况一样,最终的新容量在很多时候都要比新容量基准更大一些。

4、切片的底层数组什么时候会被替换?

确切地说,一个切片的底层数组永远不会被替换。为什么?虽然在扩容的时候 Go 语言一定会生成新的底层数组,但是它也同时生成了新的切片。

它只是把新的切片作为了新底层数组的窗口,而没有对原切片,及其底层数组做任何改动。

在无需扩容时,append函数返回的是指向原底层数组的新切片,而在需要扩容时,append函数返回的是指向新底层数组的新切片。所以,严格来讲,“扩容”这个词用在这里虽然形象但并不合适。

只要新长度不会超过切片的原容量,那么使用append函数对其追加元素的时候就不会引起扩容。这只会使紧邻切片窗口右边的(底层数组中的)元素被新的元素替换掉

GO:1.4 程序实体

Go 语言中的程序实体包括变量、常量、函数、结构体和接口。 Go 语言是静态类型的编程语言,所以我们在声明变量或常量的时候,都需要指定它们的类型,或者给予足够的信息,这样才可以让 Go 语言能够推导出它们的类型。

1、变量

在 Go 语言中,变量的类型可以是其预定义的那些类型,也可以是程序自定义的函数、结构体或接口。常量的合法类型不多,只能是那些 Go 语言预定义的基本类型。它的声明方式也更简单一些。


1、声明变量有几种方式?

1、先声明后赋值
一个很简单的命令源码文件,命名为 demo7.go。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"flag"
"fmt"
)

func main() {
var name string // 先声明
flag.StringVar(&name, "name", "everyone", "The greeting object.") // 后赋值
flag.Parse()
fmt.Printf("Hello, %v!\n", name)
}

2、使用var声明变量的同时赋值

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"flag"
"fmt"
)

func main() {
var name = flag.String("name", "everyone", "The greeting object.")//声明的同时赋值
flag.Parse()
fmt.Printf("Hello, %v!\n", *name)。
}

flag.String函数返回的结果值的类型是string而不是string。类型string代表的是字符串的指针类型,而不是字符串类型。因此,这里的变量name代表的是一个指向字符串值的指针。

注:

  • 1、声明变量name的同时,还为它赋了值,而这时声明中并没有显式指定name的类型。之前的变量声明语句是var name string。这里利用了 Go 语言自身的类型推断,而省去了对该变量的类型的声明。
  • 2、类型推断是一种编程语言在编译期自动解释表达式类型的能力。
  • 3、类型推断只能用于对变量或常量的初始化。

3、使用”:=”短变量声明的同时赋值

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"flag"
"fmt"
)

func main() {
name := flag.String("name", "everyone", "The greeting object.")//声明的同时赋值
flag.Parse()
fmt.Printf("Hello, %v!\n", *name)。
}

注:

  • 1、短变量声明,实际上就是 Go 语言的类型推断再加上一点点语法糖,只能在函数体内部使用短变量声明
  • 在编写if、for或switch语句的时候,我们经常把它安插在初始化子句中,并用来声明一些临时的变量。而相比之下,第一种方式更加通用,它可以被用在任何地方。
    app1

2、Go 语言的类型推断可以带来哪些好处?

在写代码时,我们通过使用 Go 语言的类型推断,而节省下来的键盘敲击次数几乎可以忽略不计。但它真正的好处,往往会体现在我们写代码之后的那些事情上,比如代码重构

通过调用一个函数在声明name变量的同时为它赋值,这个函数是由我们自己定义的函数getTheFlag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"flag"
"fmt"
)

func main() {
var name = getTheFlag()
//不显式地指定变量name的类型,使得它可以被赋予任何类型的值。也就是说,变量name的类型可以在其初始化时,由其他程序动态地确定。
flag.Parse()
fmt.Printf("Hello, %v!\n", *name)
}

func getTheFlag() *string {
return flag.String("name", "everyone", "The greeting object.")
}

我们可以用getTheFlag函数包裹(或者说包装)那个对flag.String函数的调用,并把其结果直接作为getTheFlag函数的结果,结果的类型是*string。

这样一来,var name =右边的表达式,可以变为针对getTheFlag函数的调用表达式了。这实际上是对“声明并赋值name变量的那行代码”的重构。

1
2
重构:
我们通常把不改变某个程序与外界的任何交互方式和规则,而只改变其内部实现”的代码修改方式,叫做对该程序的重构。重构的对象可以是一行代码、一个函数、一个功能模块,甚至一个软件系统。

你会发现,你可以随意改变getTheFlag函数的内部实现,及其返回结果的类型,而不用修改main函数中的任何代码。

注:

  • 我们不显式地指定变量name的类型,使得它可以被赋予任何类型的值。也就是说,变量name的类型可以在其初始化时,由其他程序动态地确定。
  • 在你改变getTheFlag函数的结果类型之后,Go 语言的编译器会在你再次构建该程序的时候,自动地更新变量name的类型。
  • 通过这种类型推断,你可以体验到动态类型编程语言所带来的一部分优势,即程序灵活性的明显提升。但在那些编程语言中,这种提升可以说是用程序的可维护性和运行效率换来的。
  • Go 语言是静态类型的,所以一旦在初始化变量时确定了它的类型,之后就不可能再改变。这就避免了在后面维护程序时的一些问题。另外,请记住,这种类型的确定是在编译期完成的,因此不会对程序的运行效率产生任何影响。

Go 语言的类型推断可以带来哪些好处?
Go 语言的类型推断可以明显提升程序的灵活性,使得代码重构变得更加容易,同时又不会给代码的维护带来额外负担(实际上,它恰恰可以避免散弹式的代码修改),更不会损失程序的运行效率。


2、变量的重声明是什么意思?

1、变量的重声明:对已经声明过的变量再次声明。

2、这涉及了短变量声明。通过使用它,我们可以对同一个代码块中的变量进行重声明。

3、变量重声明的前提条件如下。

  • 1、由于变量的类型在其初始化时就已经确定了,所以对它再次声明时赋予的类型必须与其原本的类型相同,否则会产生编译错误。
  • 2、变量的重声明只可能发生在某一个代码块中。如果与当前的变量重名的是外层代码块中的变量,那么就是另外一种含义了
  • 3、变量的重声明只有在使用短变量声明时才会发生,否则也无法通过编译。如果要在此处声明全新的变量,那么就应该使用包含关键字var的声明语句,但是这时就不能与同一个代码块中的任何变量有重名了。
  • 4、被“声明并赋值”的变量必须是多个,并且其中至少有一个是新的变量。这时我们才可以说对其中的旧变量进行了重声明。

如:

1
2
var err error
n, err := io.WriteString(os.Stdout, "Hello, everyone!\n")

使用短变量声明,对新变量n和旧变量err进行了“声明并赋值”,这时也是对err的重声明。

3、如果一个变量与其外层代码块中的变量重名会出现什么状况?

1、作用域:

  • 1、程序实体的访问权限有三种:包级私有的、模块级私有的和公开的。这其实就是 Go 语言在语言层面,依据代码块对程序实体作用域进行的定义。
  • 2、包级私有和模块级私有访问权限对应的都是代码包代码块,公开的访问权限对应的是全域代码块。
  • 3、一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。对“高内聚,低耦合”这种程序设计思想的实践,就是从这里开始的。

2、如果一个变量与其外层代码块中的变量重名会出现什么状况?
如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

var block = "package"

func main() {
block := "function"
{
block := "inner"
fmt.Printf("The block is %s.\n", block)
}
fmt.Printf("The block is %s.\n", block)
}

这个命令源码文件中有四个代码块,它们是:

  • 全域代码块、
  • main包代表的代码块、
  • main函数代表的代码块,
  • 在main函数中的一个用花括号包起来的代码块。

在后三个代码块(main包代表的代码块、main函数代表的代码块、在main函数中的一个用花括号包起来的代码块)中分别声明了一个名为block的变量,并分别把字符串值”package”、”function”和”inner”赋给了它们。此外,我在后两个代码块的最后分别尝试用fmt.Printf函数打印出“The block is %s.”。这里的“%s”只是为了占位,程序会用block变量的实际值替换掉。

那么,该源码文件中的代码能通过编译吗?
能通过编译。运行后打印出的内容是:

1
2
The block is inner.
The block is function.

  • 对于同一个代码块而言,用短变量声明对已有变量进行重声明可以通过编译
  • 对于不同的代码块来说,其中的变量重名没什么大不了,照样可以通过编译。即使这些代码块有直接的嵌套关系也是如此,就像上述代码中的main包代码块、main函数代码块和那个最内层的代码块那样。

3、这其实有一个很有画面感的查找过程。这个查找过程不只针对于变量,还适用于任何程序实体。如下面所示。

  • 首先,代码引用变量的时候总会最优先查找当前代码块中的那个变量。注意,这里的“当前代码块”仅仅是引用变量的代码所在的那个代码块,并不包含任何子代码块。
  • 其次,如果当前代码块中没有声明以此为名的变量,那么程序会沿着代码块的嵌套关系,从直接包含当前代码块的那个代码块(父级代码块)开始,一层一层地查找。
  • 一般情况下,程序会一直查到当前代码包代表的代码块。如果仍然找不到,那么 Go 语言的编译器就会报错了。
  • 如果我们在当前源码文件中导入了其他代码包,那么引用其中的程序实体时,是需要以限定符为前缀的。所以程序在找代表变量未加限定符的名字(即标识符)的时候,是不会去被导入的代码包中查找的。

但有个特殊情况,如果我们把代码包导入语句写成import . “XXX”的形式(注意中间的那个“.”),那么就会让这个“XXX”包中公开的程序实体,被当前源码文件中的代码,视为当前代码包中的程序实体。 比如,如果有代码包导入语句import . fmt,那么我们在当前源码文件中引用fmt.Printf函数的时候直接用Printf就可以了。在这个特殊情况下,程序在查找当前源码文件后会先去查用这种方式导入的那些代码包。

4、从作用域的角度也可以说,虽然通过var block = “package”声明的变量作用域是整个main代码包,但是在main函数中,它却被那两个同名的变量“屏蔽”了。
相似的,虽然main函数首先声明的block的作用域,是整个main函数,但是在最内层的那个代码块中,它却是不可能被引用到的。反过来讲,最内层代码块中的block也不可能被该块之外的代码引用到,这也是打印内容的第二行是“The block is function.”的另一半原因。

4、不同代码块中的重名变量与变量重声明中的变量区别到底在哪儿?

1、为了方便描述,我就把不同代码块中的重名变量叫做“可重名变量”吧。注意,在同一个代码块中不允许出现重名的变量,这违背了 Go 语言的语法。

区别:

  • 变量重声明中的变量一定是在某一个代码块内的。注意,这里的“某一个代码块内”并不包含它的任何子代码块,否则就变成了“多个代码块之间”。而可重名变量指的正是在多个代码块之间由相同的标识符代表的变量。
  • 变量重声明是对同一个变量的多次声明,这里的变量只有一个。而可重名变量中涉及的变量肯定是有多个的。
  • 不论对变量重声明多少次,其类型必须始终一致,具体遵从它第一次被声明时给定的类型。而可重名变量之间不存在类似的限制,它们的类型可以是任意的。
  • 如果可重名变量所在的代码块之间,存在直接或间接的嵌套关系,那么它们之间一定会存在“屏蔽”的现象。但是这种现象绝对不会在变量重声明的场景下出现。

app2.png

2、不同类型的可重名变量例子

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"}
fmt.Printf("The element is %q.\n", container[1])
}

运行结果:

1
The element is "one".

上述代码中,有两个都叫做container的变量,分别位于main包代码块和main函数代码块。

main包代码块中的变量是切片(slice)类型的,另一个是字典(map)类型的。在main函数的最后,试图打印出container变量的值中索引为1的那个元素。

如果container的类型不是数组、切片或字典类型,那么索引表达式就会引发编译错误。

这正是利用 Go 语言语法,帮我们约束程序的一个例子;但是当我们想知道 container 确切类型的时候,利用索引表达式的方式就不够了。

当可重名变量的值被转换成某个接口类型值,或者它们的类型本身就是接口类型的时候,严格的类型检查就很有必要了。

5、怎样判断一个变量的类型?

看如下例子:

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"}
fmt.Printf("The element is %q.\n", container[1])
}

怎样在打印其中元素之前,正确判断变量container的类型?
使用“类型断言”表达式

1
value, ok := interface{}(container).([]string)

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"}
fmt.Printf("The element is %q.\n", container[1])

value, ok := interface{}(container).([]string)
fmt.Printf("The value is %v.\n", value)
fmt.Printf("The ok is %v.\n", ok)
}

运行结果:

1
2
3
The element is "one".
The value is [].
The ok is false.

1、这里有一条赋值语句。在赋值符号的右边,是一个类型断言表达式。

它包括了用来把container变量的值转换为空接口值的interface{}(container)。以及一个用于判断前者的类型是否为切片类型 []string 的 .([]string)。

这个表达式的结果可以被赋给两个变量,在这里由value和ok代表。变量ok是布尔(bool)类型的,它将代表类型判断的结果,true或false。

如果是true,那么被判断的值将会被自动转换为[]string类型的值,并赋给变量value,否则value将被赋予nil(即“空”)。这里的ok也可以没有。也就是说,类型断言表达式的结果,可以只被赋给一个变量,在这里是value。但是这样的话,当判断为否时就会引发异常。

这种异常在 Go 语言中被叫做panic,我把它翻译为运行时恐慌。因为它是一种在 Go 程序运行期间才会被抛出的异常,而“恐慌”二字是英文 Panic 的中文直译。除非显式地“恢复”这种“恐慌”,否则它会使 Go 程序崩溃并停止。所以,在一般情况下,我们还是应该使用带ok变量的写法。

如果将类型断言改为:

1
value, ok := interface{}(container).(map[int]string)

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"}
fmt.Printf("The element is %q.\n", container[1])

value, ok := interface{}(container).(map[int]string)
fmt.Printf("The value is %v.\n", value)
fmt.Printf("The ok is %v.\n", ok)
}

运行结果:

1
2
3
The element is "one".
The value is map[0:zero 1:one 2:two].
The ok is true.

1、类型断言表达式的语法形式是x.(T)。其中的x代表要被判断类型的值。这个值当下的类型必须是接口类型的,不过具体是哪个接口类型其实是无所谓的。

2、所以,当这里的container变量类型不是任何的接口类型时,我们就需要先把它转成某个接口类型的值。如果container是某个接口类型的,那么这个类型断言表达式就可以是container.([]string)。

3、在 Go 语言中,interface{}代表空接口,任何类型都是它的实现类型。这里的具体语法是interface{}(x),例如前面展示的interface{}(container)。

4、你可能会对这里的{}产生疑惑,为什么在关键字interface的右边还要加上这个东西?

  • 一对不包裹任何东西的花括号,除了可以代表空的代码块之外,还可以用于表示不包含任何内容的数据结构(或者说数据类型)。
  • 比如struct{},它就代表了不包含任何字段和方法的、空的结构体类型。而空接口interface{}则代表了不包含任何方法定义的、空的接口类型。
  • 当然了,对于一些集合类的数据类型来说,{}还可以用来表示其值不包含任何元素,比如空的切片值[]string{},以及空的字典值map[int]string{}。
    app3.png

6、类型转换规则中有哪些值得注意的地方?

1、类型转换表达式的语法形式:T(x)

其中的x可以是一个变量,也可以是一个代表值的字面量(比如1.23和struct{}{}),还可以是一个表达式。注意,如果是表达式,那么该表达式的结果只能是一个值,而不能是多个值。

在这个上下文中,x可以被叫做源值,它的类型就是源类型,而那个T代表的类型就是目标类型。如果从源类型到目标类型的转换是不合法的,那么就会引发一个编译错误。

2、三个注意点

  • 1、对于整数类型值、整数常量之间的类型转换,原则上只要源值在目标类型的可表示范围内就是合法的。

比如,之所以uint8(255)可以把无类型的常量255转换为uint8类型的值,是因为255在[0, 255]的范围内。

但需要特别注意的是,源整数类型的可表示范围较大,而目标类型的可表示范围较小的情况,比如把值的类型从int16转换为int8。请看下面这段代码:

1
2
var srcInt = int16(-255)
dstInt := int8(srcInt)//1

变量srcInt的值是int16类型的-255,而变量dstInt的值是由前者转换而来的,类型是int8。int16类型的可表示范围可比int8类型大了不少。

问题是,dstInt的值是多少?
首先你要知道,整数在 Go 语言以及计算机中都是以补码的形式存储的。这主要是为了简化计算机对整数的运算过程。补码其实就是原码各位求反再加 1。

比如,int16类型的值-255的补码是1111111100000001。如果我们把该值转换为int8类型的值,那么 Go 语言会把在较高位置(或者说最左边位置)上的 8 位二进制数直接截掉,从而得到00000001。又由于其最左边一位是0,表示它是个正整数,以及正整数的补码就等于其原码,所以dstInt的值就是1

一定要记住,当整数值的类型的有效范围由宽变窄时,只需在补码形式下截掉一定数量的高位二进制数即可。

类似的快刀斩乱麻规则还有:当把一个浮点数类型的值转换为整数类型值时,前者的小数部分会被全部截掉。

  • 2、虽然直接把一个整数值转换为一个string类型的值是可行的,但值得关注的是,被转换的整数值应该可以代表一个有效的 Unicode 代码点,否则转换的结果将会是”�”(仅由高亮的问号组成的字符串值)。
    字符’�’的 Unicode 代码点是U+FFFD。它是 Unicode 标准中定义的 Replacement Character,专用于替换那些未知的、不被认可的以及无法展示的字符。
    如:
    1
    string(-1)  //�"

由于-1肯定无法代表一个有效的 Unicode 代码点,所以得到的总会是”�”。

  • 3、string类型与各种切片类型之间的互转
    一个值在从string类型向[]byte类型转换时代表着以 UTF-8 编码的字符串会被拆分成零散、独立的字节。

除了与 ASCII 编码兼容的那部分字符集,以 UTF-8 编码的某个单一字节是无法代表一个字符的。

1
string([]byte{'\xe4', '\xbd', '\xa0', '\xe5', '\xa5', '\xbd'}) // 你好

比如,UTF-8 编码的三个字节\xe4、\xbd和\xa0合在一起才能代表字符’你’,而\xe5、\xa5和\xbd合在一起才能代表字符’好’。

一个值在从string类型向[]rune类型转换时代表着字符串会被拆分成一个个 Unicode 字符。

1
string([]rune{'\u4F60', '\u597D'}) // 你好

7、什么是别名类型?什么是潜在类型?

我们可以用关键字type声明自定义的各种类型。这些类型必须在 Go 语言基本类型和高级类型的范畴之内。在它们当中,有一种被叫做“别名类型”的类型。我们可以像下面这样声明它:

1
type MyString = string

这条声明语句表示,MyString是string类型的别名类型。顾名思义,别名类型与其源类型的区别只是在名称上,它们是完全相同的。

源类型与别名类型是一对概念,是两个对立的称呼。别名类型主要是为了代码重构而存在的。

Go 语言内建的基本类型中就存在两个别名类型。byte是uint8的别名类型,而rune是int32的别名类型。

一定要注意,如果我这样声明:

1
type MyString string  // 注意,这里没有等号。

MyString2和string就是两个不同的类型了。这里的MyString2是一个新的类型,不同于其他任何类型。

这种方式也可以被叫做对类型的再定义。我们刚刚把string类型再定义成了另外一个类型MyString2。
app4.png

对于这里的类型再定义来说,string可以被称为MyString2的潜在类型。潜在类型的含义是,某个类型在本质上是哪个类型。

潜在类型相同的不同类型的值之间是可以进行类型转换的。因此,MyString2类型的值与string类型的值可以使用类型转换表达式进行互转

但对于集合类的类型[]MyString2与[]string来说这样做却是不合法的,因为[]MyString2与[]string的潜在类型不同,分别是[]MyString2和[]string。

另外,即使两个不同类型的潜在类型相同,它们的值之间也不能进行判等或比较,它们的变量之间也不能赋值。

GO:1.3 库源码文件

1、什么是库源码文件?

1、库源码文件是不能被直接运行的源码文件,它仅用于存放程序实体,这些程序实体可以被其他代码使用(只要遵从 Go 语言规范的话)。

2、这里的“其他代码”可以与被使用的程序实体在同一个源码文件内,也可以在其他源码文件,甚至其他代码包中。

3、程序实体是什么?
在 Go 语言中,程序实体是变量、常量、函数、结构体和接口的统称

  • 我们总是会先声明(或者说定义)程序实体,然后再去使用。
  • 程序实体的名字被统称为标识符。标识符可以是任何 Unicode 编码可以表示的字母字符、数字以及下划线“_”,但是其首字母不能是数字。
  • 从规则上说,我们可以用中文作为变量的名字。但是不建议这样做。

2、怎样把命令源码文件中的代码拆分到其他库源码文件?

如果在某个目录下有一个命令源码文件 demo4.go,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"flag"
)

var name string

func init() {
flag.StringVar(&name, "name", "everyone", "The greeting object.")
}

func main() {
flag.Parse()
hello(name)
}

demo4.go 在调用了hello函数,函数hello被声明在了另外一个源码文件中,我把它命名为 demo4_lib.go,并且放在与 demo4.go 相同的目录main下(在同一个目录下的源码文件都需要被声明为属于同一个代码包。)。如下:

1
2
3
4
5
6
7
package main

import "fmt"

func hello(name string) {
fmt.Printf("Hello, %s!\n", name)
}

注:如果该目录下有一个命令源码文件,那么为了让同在一个目录下的文件都通过编译,其他源码文件应该也声明属于main包。

现在运行它们,比如,我们可以在这些文件所在的目录下运行如下命令并得到相应的结果。

1
2
$ go run demo4.go demo4_lib.go 
Hello, everyone!

或者,像下面这样先构建当前的代码包再运行。

1
2
3
$ go build puzzlers/article3/q1
$ ./q1
Hello, everyone!

1、在这里,我把 demo4.go 和 demo4_lib.go 都放在了一个相对路径为puzzlers/article3/q1的目录中。

2、在默认情况下,相应的代码包的导入路径会与此一致。我们可以通过代码包的导入路径引用其中声明的程序实体。但是,这里的情况是不同的。

3、注意,demo4.go 和 demo4_lib.go 都声明自己属于main包。我在前面讲 Go 语言源码的组织方式的时候提到过这种用法,即:源码文件声明的包名可以与其所在目录的名称不同,只要这些文件声明的包名一致就可以。

4、代码包声明的基本规则:

  • 同目录下的源码文件的代码包声明语句要一致。也就是说,它们要同属于一个代码包。这对于所有源码文件都是适用的。如果目录中有命令源码文件,那么其他种类的源码文件也应该声明属于main包。这也是我们能够成功构建和运行它们的前提。
  • 源码文件声明的代码包的名称可以与其所在的目录的名称不同。在针对代码包进行构建时,生成的结果文件的主名称与其父目录的名称一致。对于命令源码文件而言,构建生成的可执行文件的主名称会与其父目录的名称相同

###3、怎样把命令源码文件中的代码拆分到其他代码包?
1、把 demo4.go 另存为 demo5.go,并放到一个相对路径为puzzlers/article3/q2的目录中。

2、然后再创建一个相对路径为puzzlers/article3/q2/lib的目录,再把 demo4_lib.go 复制一份并改名为 demo5_lib.go 放到该目录中。

1
2
3
4
5
6
7
package lib5

import "fmt"

func Hello(name string) {
fmt.Printf("Hello, %s!\n", name)
}

这里修改了两个地方。

  • 第一个改动是,把代码包声明语句由package main改为了package lib5。注意,故意让声明的包名与其所在的目录的名称不同。
  • 第二个改动是,把全小写的函数名hello改为首字母大写的Hello。

    基于以上改动,我们再来看下面的几个问题。

1、代码包的导入路径总会与其所在目录的相对路径一致吗?

库源码文件 demo5_lib.go 所在目录的相对路径是puzzlers/article3/q2/lib,而它却声明自己属于lib5包。在这种情况下,该包的导入路径是puzzlers/article3/q2/lib,还是puzzlers/article3/q2/lib5?
1、首先,我们在构建或者安装这个代码包的时候,提供给go命令的路径应该是目录的相对路径,就像这样:

1
go install puzzlers/article3/q2/lib

该命令会成功完成。之后,当前工作区的 pkg 子目录下会产生相应的归档文件,具体的相对路径是:

1
pkg/darwin_amd64/puzzlers/article3/q2/lib.a

其中的darwin_amd64就是我在讲工作区时提到的平台相关目录。可以看到,这里与源码文件所在目录的相对路径是对应的。

2、为了进一步说明问题,需要先对 demo5.go 做两个改动。

  • 第一个改动是,在以import为前导的代码包导入语句中加入puzzlers/article3/q2/lib,也就是试图导入这个代码包。
  • 第二个改动是,把对hello函数的调用改为对lib.Hello函数的调用。其中的lib.叫做限定符,旨在指明右边的程序实体所在的代码包。不过这里与代码包导入路径的完整写法不同,只包含了路径中的最后一级lib,这与代码包声明语句中的规则一致。

现在,通过运行go run demo5.go命令试一试。错误提示会类似于下面这种。

1
2
./demo5.go:5:2: imported and not used: "puzzlers/article3/q2/lib" as lib5
./demo5.go:16:2: undefined: lib

  • 第一个错误提示的意思是,我们导入了puzzlers/article3/q2/lib包,但没有实际使用其中的任何程序实体。这在 Go 语言中是不被允许的,在编译时就会导致失败。
  • 这里还有另外一个线索,那就是“as lib5”。这说明虽然导入了代码包puzzlers/article3/q2/lib,但是使用其中的程序实体的时候应该以lib5.为限定符。这也就是第二个错误提示的原因了。Go 命令找不到lib.这个限定符对应的代码包。

3、为什么会是这样?
根本原因就是,我们在源码文件中声明所属的代码包与其所在目录的名称不同。请记住,源码文件所在的目录相对于 src 目录的相对路径就是它的代码包导入路径,而实际使用其程序实体时给定的限定符要与它声明所属的代码包名称对应。

有两个方式可以使上述构建成功完成。我在这里选择把 demo5_lib.go 文件中的代码包声明语句改为package lib。理由是,为了不让该代码包的使用者产生困惑,我们总是应该让声明的包名与其父目录的名称一致。

2、什么样的程序实体才可以被当前包外的代码引用?

1、为什么要把 demo5_lib.go 文件中的那个函数名称hello的首字母大写?
实际上这涉及了 Go 语言中对于程序实体访问权限的规则。超级简单,名称的首字母为大写的程序实体才可以被当前包外的代码引用,否则它就只能被当前包内的其他代码引用。通过名称,Go 语言自然地把程序实体的访问权限划分为了包级私有的和公开的对于包级私有的程序实体,即使你导入了它所在的代码包也无法引用到它。

3、对于程序实体,还有其他的访问权限规则吗?

1、答案是肯定的。在 Go 1.5 及后续版本中,我们可以通过创建internal代码包让一些程序实体仅仅能被当前模块中的其他代码引用。这被称为 Go 程序实体的第三种访问权限:模块级私有

2、具体规则是,internal代码包中声明的公开程序实体仅能被该代码包的直接父包及其子包中的代码引用。当然,引用前需要先导入这个internal包。对于其他代码包,导入该internal包都是非法的,无法通过编译