Types In Golang: Part2

string

Golang 里面的 string 是 immutable 的,它的 cap/len 是同一个值。我们可能需要涉及两个别的类型:

  • string 是一个 byte (uint8 ) 组成的对象,形式上可以脑补 Rust 的 &[u8] (感觉我说的不太对,有不是 bytes 组成的 string 么)

  • 我们需要涉及另一个类型 rune ,即 unicode。具体 string 可能由不定长的不同 unicode 构成,所以

    在Go中,所有的字符串常量都被视为是UTF-8编码的。 在编译时刻,非法UTF-8编码的字符 串常量将导致编译失败。 在运行时刻,Go运行时无法阻止一个字符串是非法UTF-8编码的。

字符串类型没有内置的方法。我们可以

  • 使用strings标准库 􏰀 提供的函数来进行各种字符串操作。
  • 调用内置函数 len 来获取一个字符串值的长度(此字符串中存储的字节数)。
  • 使用容器元素索引(第18章)语法aString[i]来获取aString中的第i个 byte。 表达 式 aString[i] 是不可寻址的。换句话说, aString[i] 不可被修改。 使用子切片语法(第18章)aString[start:end]来获取aString的一个子字符串。 这 里, start (包括)和 end (不包括)均为 aString 中存储的字节的下标。

对于标准编译器来说,一个字符串的赋值完成之后,此赋值中的目标值和源值将共享底层字节。 一个子切片表达式 aString[start:end] 的估值结果也将和基础字符串 aString 共享一部分底层 字节。

比较重要的是:索引的对象是 byte:

1
fmt.Printf("%T \n", hello[0]) // uint8

对于字符串而言,我们经常类型转换,比如把某个 string 丢给 etcd 发送请求。

  1. 一个字符串值可以被显式转换为一个字节切片(byte slice),反之亦然。 一个字节切片类型是 一个元素类型为内置类型byte的切片类型。 或者说,一个字节切片类型的底层类型为[]byte (亦即 []uint8 )。

  2. 一个字符串值可以被显式转换为一个码点切片(rune slice),反之亦然。 一个码点切片类型是一个元素类型为内置类型rune的切片类型。 或者说,一个码点切片类型的底层类型为 []rune (亦即 []int32 )。

在一个从码点切片到字符串的转换中,码点切片中的每个码点值将被UTF-8编码为一到四个字节至结果 字符串中。 如果一个码点值是一个不合法的Unicode码点值,则它将被视为Unicode替换字符(码点) 值0xFFFD(Unicode replacement character)。 替换字符值0xFFFD将被UTF-8编码为三个字节0xef 0xbf 0xbd。

当一个字符串被转换为一个码点切片时,此字符串中存储的字节序列将被解读为一个一个码点的UTF-8编码序列。 非法的UTF-8编码字节序列将被转化为Unicode替换字符值0xFFFD。

当一个字符串被转换为一个字节切片时,结果切片中的底层字节序列是此字符串中存储的字节序列的一 份深复制。

所以这个转化过程还是有复制的,TiDB 用这个特点写了个 hack.String, 挺有意思的:https://github.com/pingcap/tidb/blob/master/util/hack/hack.go

但是实际上,上述情景存在一些编译器的优化(我吐了),这是标准库中的优化

  • 一个 for-range 循环中跟随 range 关键字的从字符串到字节切片的转换; for i, v := range []byte(s) {}
  • 一个在映射元素索引语法中被用做键值的从字节切片到字符串的转换; v[string(bytes)]
  • 一个字符串比较表达式中被用做比较值的从字节切片到字符串的转换; if s < string(bytes)
  • 一个(至少有一个被衔接的字符串值为非空字符串常量的)字符串衔接表达式中的从字节切片到 字符串的转换。s += string(bytes)

for-range 遍历字符串的时候 key 是 byte start index, value 是 rune. 所以其实很好玩:

1
2
3
4
5
6
func main()  {
s := "你妈死了,我是你哥哥,我们俩都是你妈的儿子"
for i, rn := range s {
fmt.Println(i, rn, string(rn))
}
}

可以试着运行一下这个,至于遍历 bytes, 你可以:

1
for i, v := range []byte(s) {}

函数

函数我其实不是很想介绍,但是我们需要注意一下 builtin 的这种内置函数,这种往往靠开洞之类的方法,在 这些函数声明在 builtin 􏰀 和 unsafe 􏰀 标准库中, 可以支持泛型甚至类型作为参数,其中 len cap 这些也有可能编译期获得值。

另外,很多时候 Go 编译器会希望你定义完整个函数,但是写个 panic(“unimplemented”) 其实也可以,实际上Go 有个 https://golang.org/ref/spec#Terminating_statements ,满足这个标准的可以当函数的结尾。

channel

channel 是个 mpmc 的模型,一些 goroutine可以向 此通道发送数据,另外一些goroutine可以从此通道接收数据。

随着一个数据值的传递(发送和接收),一些数据值的所有权从一个协程转移到了另一个协程。 当一 个协程发送一个值到一个通道,我们可以认为此协程释放了一些值的所有权。 当一个协程从一个通道 接收到一个值,我们可以认为此协程获取了一些值的所有权。

(突然想到 Send 和 Sync 这俩 trait)

channel 的类型

字面形式chan T表示一个元素类型为T的双向通道类型。 编译器允许从此类型的值中接收和向此 类型的值中发送数据。
字面形式chan<- T表示一个元素类型为T的单向发送通道类型。 编译器不允许从此类型的值中 接收数据。

字面形式<-chan T表示一个元素类型为T的单向接收通道类型。 编译器不允许向此类型的值中 发送数据。

双向通道chan T的值可以被隐式转换为单向通道类型chan<- T和<-chan T,但反之不行(即使显式 也不行)。 类型chan<- T和<-chan T的值也不能相互转换。

一个容量为0的通道值称为一个非 缓冲通道(unbuffered channel),一个容量不为0的通道值称为一个缓冲通道(buffered channel)。

通道类型的零值也使用预声明的nil来表示。 一个非零通道值必须通过内置的make函数来创建。

所以 channel 比较是靠“内部成员是不是同一个”来判断的。

channel 操作和语义
  1. close(ch) 关闭非 <-chan
  2. ch <- v 给 ch 发送 v
  3. <-ch 接收一个值
  4. cap(ch) 查询容量
  5. len(ch) 查询内部已有元素的长度

Go中大多数的基本操作都是未同步的。换句话说,它们都不是并发安全的。 这些操作包括赋值、传 参、和各种容器值操作等。 但是,除了并发地关闭一个通道和向此通道发送数据这种情形,上面这些 所有列出的操作都已经同步过了,因此它们可以在并发协程中安全运行而无需其它同步操作。 我们在 编程中应该避免并发地关闭一个通道和向此通道发送数据这种情形, 因为这种情形属于不良设计(原 因将在下面解释)。

所以 close 和 send 逻辑应该合理的拆分。

注意:通道的赋值和其它类型值的赋值一样,是未同步的。 同样,将刚从一个通道接收出来的值赋给 另一个值也是未同步的。

如果被查询的通道为一个nil零值通道,则cap和len函数调用都返回0。 这两个操作是如此简单,所 以后面将不再对它们进行详解。 事实上,这两个操作在实践中很少使用。

操作 nil channel closed channel channel
close panic panic close it
ch <- blocking forever panic blocking or send success
<-ch blocking forever never panic blocking or recv success

其实根据我理解,我总结了一下:

  • Golang 的 close 是“不可重复调用” 的,close nil, 被 close 的 channel 都会产生 panic
  • sender 应该知道 channel 的情况,给 nil 发送会 blocking forever, 给 closed 发送会 panic
  • receiver 某种情况下不知道,所以它从 nil 接收会 block forever, 从 closed 接收消息必定不会 blocking

channel 此外还需要维护 FIFO 的语义,这其实暗示很麻烦的实现,实际上很多内存 channel 都不想做这一点。

可以认为一个 channel 维护了3个 queue:

  1. 接收数据协程队列。此队列是一个没有长度限制的链表。 此队列中的协程均处于阻塞状态,它们 正等待着从此通道接收数据。
  2. 发送数据协程队列。此队列也是一个没有长度限制的链表。 此队列中的协程亦均处于阻塞状态, 它们正等待着向此通道发送数据。 此队列中的每个协程将要发送的值(或者此值的指针,取决于 具体编译器实现)和此协程一起存储在此队列中。
  3. 数据缓冲队列。这是一个循环队列,它的长度为此通道的容量。此队列中存放的值的类型都为此 通道的元素类型。 如果此队列中当前存放的值的个数已经达到此通道的容量,则我们说此通道已 经处于满槽状态。 如果此队列中当前存放的值的个数为零,则我们说此通道处于空槽状态。 对 于一个非缓冲通道(容量为零),它总是同时处于满槽状态和空槽状态。

注意其中的 blocking 行为,在 select 的时候你会需要它们的。此外:

一个非零通道被关闭之后,此通道上的后续数据接收操作将永不会阻塞。 此通道的 缓冲队列中存储数据仍然可以被接收出来。 伴随着这些接收出来的缓冲数据的第二个可选返回(类型 不确定布尔)值仍然是true。 一旦此缓冲队列变为空,后续的数据接收操作将永不阻塞并且总会返回 此通道的元素类型的零值和值为false的第二个可选返回结果。

通道操作情形C: 当一个协程成功获取到一个非零且尚未关闭的通道的锁并且准备关闭此通道时,下面 两步将依次执行:

  1. 如果此通道的接收数据协程队列不为空(这种情况下,缓冲队列必为空),此队列中的所有协程 将被依个弹出,并且每个协程将接收到此通道的元素类型的一个零值,然后恢复至运行状态。
  2. 如果此通道的发送数据协程队列不为空,此队列中的所有协程将被依个弹出,并且每个协程中都将产生一个panic(因为向已关闭的通道发送数据)。 这就是我们在上面说并发地关闭一个通道和 向此通道发送数据这种情形属于不良设计的原因。 事实上,并发地关闭一个通道和向此通道发送 数据将产生数据竞争。

其他几个 case 也可以看看。

此外需要注意:

  • <- 接收到的元素全是值复制(我个人感觉传指针怪怪的?不知有没有什么 例子)
  • goroutine 和 channel 中, channel 只有没有 goroutine 引用才会被 gc, goroutine 同理。所以要小心 leak.
  • goroutine 允许你从 nil 和 closed 中 recv, 返回一个 ok 的 bool。这也允许你 for-range 使用
1
2
3
4
5
6
7
8
9
10
11
for {
for v, ok <- ch
if !ok {
break
}
// ...
}

for v := range ch {
// ...
}
select channel

所有的非阻塞 case 操作中将有一个被随机选择执行(而不是按照从上到下的顺序),然后执行此 操作对应的 case 分支代码块。

在所有的 case 操作均为阻塞的情况下,如果 default 分支存在,则 default 分支代码块将得到执 行; 否则,当前协程将被推入所有阻塞操作中相关的通道的发送数据协程队列或者接收数据协程 队列中,并进入阻塞状态。

一种很常用的模式是:

1
2
3
4
5
6
select {
case <-ch:
// do something
default:
// 跳过
}

select 会评估所有的 arm, 给 channel lock 并且尝试是否是 non-blocking 的

1
2
3
4
5
6
7
8
9
10
11
12
13
import "fmt"

func main() {
ch := make(chan int)
select {
case ch<- 114514:
fmt.Println("Send done")
case v := <- ch:
fmt.Printf("Receive %d\n", v)
}

fmt.Println("done")
}

以上操作会得到:

1
2
3
4
5
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select]:
main.main()
.../cmd/chan_example.go:7 +0xe3

method

有 method 需要有几个限制:

  1. T 必须是一个定义类型(第14章);
  2. T 必须和此方法声明定义在同一个代码包中;
  3. T 不能是一个指针类型;
  4. T 不能是一个接口类型。

method 会 implict 生成函数,让 go 编译器去 mangling。

对每一个为值类型属主 T 声明的方法,编译器将自动隐式地为其对应的指针类型属主 T 声明一个相应的 同名方法。 以上面的为类型Book声明的Pages方法为例,编译器将自动为类型Book声明一个同名方 法:

也就是说 (T) call() 被声明后,(*T) call 会被 implicit 的定义,传 (*v) 作为参数,而生成的函数 function call,会是一个值复制。类型T的方法集总是类型T的方法集的子集。上述第一个括号里的被称为 receiver type*。

这点可以在 Go-FAQ 找到:https://golang.org/doc/faq#different_method_sets

所以我们需要考虑是(*T) 还是 (T) 实现:

对于值类型属主还是指针类型属主都可以接受的方法声明,下面列出了一些考虑因素:

  • 太多的指针可能会增加垃圾回收器的负担。 如果一个值类型的尺寸太大,那么属主参数在传参的时候的复制成本将不可忽略。 指针类型都是 小尺寸类型。 关于各种不同类型的尺寸,请阅读值复制代价(第34章)一文。
  • 在并发场合下,同时调用为值类型属主和指针类型属主方法比较易于产生数据竞争。
  • sync 标准库包中的类型的值不应该被复制,所以如果一个结构体类型内嵌(第24章)了这些类 型,则不应该为这个结构体类型声明值类型属主的方法。

如果实在拿不定主意在一个方法声明中应该使用值类型属主还是指针类型属主,那么请使用指针类型属 主。

interface

interface 是一个很常用,但是用起来大家其实很模糊的东西。我们需要实际上脑袋想清楚:

  • i = v 会不会产生复制,还是引用的 copy
  • i1 = i2 会有如何影响
  • interface 内部如何维护具体类型,完整信息。

在Go中,如果类型T实现了一个接口类型I,则类型T的值都可以隐式转换到类型I。 换句话说,类型 T的值可以赋给类型I的可修改值。 当一个T值被转换到类型I(或者赋给一个I值)的时候,

  • 如果类型T是一个非接口类型,则此T值的一个复制将被包裹在结果(或者目标)I值中。 此操作 的时间复杂度为 O(n) ,其中 n 为 T 值的尺寸。
  • 如果类型 T 也为一个接口类型,则此 T 值中当前包裹的(非接口)值将被复制一份到结果(或者目 标)I值中。 官方标准编译器为此操作做了优化,使得此操作的时间复杂度为O(1),而不 是O(n)。

也就是说,本身会发生一个 copy 过程。

可以看一段比较有趣的代码:

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
package main

import "fmt"

type Book struct {
name string
}

func (b Book) About() string {
return fmt.Sprintf("Book(name:%s)", b.name)
}

type Aboutable interface {
About() string
}

func main() {
var aboutable Aboutable
if aboutable == nil {
fmt.Println("aboutable is nil")
}
fmt.Println(aboutable) // <nil>
var bookPtr *Book
aboutable = bookPtr
if aboutable == nil {
fmt.Println("aboutable is nil")
} else {
fmt.Println("aboutable is not nil")
}

fmt.Println(aboutable) // <nil>

// panic: value method main.Book.About called using nil *Book pointer
//aboutable.About()


b := Book{name: "卡拉马佐夫兄弟"}
aboutable = b

fmt.Println(aboutable.About()) // Book(name:卡拉马佐夫兄弟)
//aboutableBook := aboutable.(Book)
}

对于一个非接口类型和接口类型对,它们的实现关系信息包括两部分的内容:

  1. 动态类型(即此非接口类型)的信息。
  2. 一个方法表(切片类型),其中存储了所有此接口类型指定的并且为此非接口类型(动态类型)声明的方法。

可以实现 Golang 的多态:

比如,当方法i.m被调用时,其实被调用的是方法t.m。 一个接口值可以通过包裹不同动态类型的动态值来表现出各种不同的行为,这称为多态。

但我觉得还是很鸡肋的,对不同类型需要一堆 builtin 或者写很多遍, 感觉很蛋疼。

反射与类型

我们可以注意到之前的:

1
2
3
4
5
b := Book{name: "卡拉马佐夫兄弟"}
aboutable = b

fmt.Println(aboutable.About()) // Book(name:卡拉马佐夫兄弟)
aboutableBook := aboutable.(Book)

类比起来,我们已经拥有了对一定的底层类型的转换,可以理解成static_cast,可能需要类似 C++ 的 dynamic_cast<> 的转换,也就是:

  1. 将一个接口值转换为一个非接口类型(此非接口类型必须实现了此接口值的接口类型)。

  2. 将一个接口值转换为另一个接口类型(前者接口值的类型可以实现了也可以未实现后者目标接口

    类型)。

以上依靠:

在一个类型断言表达式i.(T)中,i称为断言值,T称为断言类型。 一个断言可能成功或者失败。

还是有一些不同的,实际上 dynamic_cast 对象是指针,而这里很难拿到interface 中原对象的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
b := Book{name: "卡拉马佐夫兄弟"}
aboutable = b

fmt.Println(aboutable.About()) // Book(name:卡拉马佐夫兄弟)
aboutableBook := aboutable.(Book)
fmt.Println(aboutableBook.About()) // Book(name:卡拉马佐夫兄弟)

aboutableBook.name = "罪与罚"
fmt.Println(aboutable.About()) // Book(name:卡拉马佐夫兄弟)

//bptr, ok := (aboutable).(*Book)
//if !ok {
// fmt.Println("cast error")
//} else {
// fmt.Println(bptr.About())
//}

下面是编译不通过的, 除非你一开始放的就是 *Book, 可以转回来。否则这仍然是一个 copy 行为。

此外还有个很神秘的语法:switch type:

1
2
3
4
switch x.(type) {
case []int:
//...
}

我真的觉得这个语法太神秘了,这似乎是个 builtin。

此外,类似 Java 逆变,协变的概念:

一个 []T 类型的值不能直接被转换为类型 []I ,即使类型 T 实现了接口类型I

类型内嵌

似乎是由于 Go 组合优于继承的哲学,Go 允许类型内嵌,它的规则有点奇怪:

  1. 一个类型名 T 只有在它既不表示一个定义的指针类型也不表示一个基类型为指针类型或者接口类型 的指针类型的情况下在可以被用作内嵌字段。
  2. 一个指针类型 *T 只有在 T 为一个类型名并且 T 既不表示一个指针类型也不表示一个接口类型的时 候才能被用作内嵌字段。
  • T 本身要求不是 pointer/基类型不是 pointer/interface
  • *T 要求 T 是类型名 基类型不是 pointer/interface
  • (我以前不知道的是,类型竟然能内嵌 interface, 神了)

被内嵌类型的方法会被提升,这是一个语法糖,如果重复定义,可以参考以下逻辑:

只有深度最浅的一个完整形式的选择器(并且最浅者只有一个)可以被缩写为x.y。 换句话说, x.y 表示深度最浅的一个选择器。其它完整形式的选择器被此最浅者所遮挡(压制)。 如果有多个完整形式的选择器同时拥有最浅深度,则任何完整形式的选择器都不能被缩写为 x.y 。 我们称这些同时拥有最浅深度的完整形式的选择器发生了碰撞。

而外层类型也会 implicit 实现这些方法。