3.2 数据类型
在本节,我们会集中精力学习Go语言的数据类型的概念、表现形式和各种操作方式。本节会涉及Go语言中除Channel
类型之外的绝大多数数据类型。此外,在本节的最后,我们还会讲到初始化这些数据类型值的各种方法。
3.2.1 基本数据类型
Go语言的基本数据类型并不多,并且大部分都与整数相关。Go语言把整数进行了比较细致的划分。下面我们通过表3-4来概览一下Go语言定义的所有基本数据类型。
表3-4 Go语言的基本数据类型
名称 | 宽度(字节) | 零值 | 说明 |
---|---|---|---|
bool | 1 | false | 布尔类型。其值不为真即为假。真用常量true 表示,假由常量false 表示 |
byte | 1 | 0 | 字节类型。它也可以看作是一个由8位二进制数代表的无符号整数类型 |
rune | 4 | 0 | rune 类型。它是由Go语言定义的特有的数据类型,专用于存储Unicode编码。它也可以被看作是一个由32位二进制数代表的有符号整数类型 |
int/uint | - | 0 | 有符号整数类型/无符号整数类型。其宽度与平台有关 |
int8/uint8 | 1 | 0 | 由8位二进制数代表的有符号整数类型/无符号整数类型 |
int16/uint16 | 2 | 0 | 由16位二进制数代表的有符号整数类型/无符号整数类型 |
int32/uint32 | 4 | 0 | 由32位二进制数代表的有符号整数类型/无符号整数类型 |
int64/uint64 | 8 | 0 | 由64位二进制数代表的有符号整数类型/无符号整数类型 |
float32 | 4 | 0.0 | 由32位二进制数代表的浮点数类型 |
float64 | 8 | 0.0 | 由64位二进制数代表的浮点数类型 |
complex64 | 8 | 0.0 | 由64位二进制数代表的复数类型。它由float32 类型的实部和float32 类型的虚部联合表示 |
complex128 | 16 | 0.0 | 由128位二进制数代表的复数类型。它由float64 类型的实部和float64 类型的虚部联合表示 |
string | - | "" | 字符串类型。一个字符串类型代表了一个字符串值的集合。而一个字符串值实质上是一个字节序列。注意:字符串类型的值是不可变的,即一旦创建其内容就不可被改变 |
上表列出了Go语言的全部18种基本数据类型。下面我们分别进行说明。
1. 布尔类型
布尔类型代表了布尔真值的集合。简单来说,布尔真值用于指示一个陈述在什么程度上是真的。在最简单且使用最广泛的情形中,布尔真值非真即假。因此,在Go语言中,布尔真值的集合也只有两个元素,由预定义标识符true
和false
来表示。布尔类型的值只可能是它们中的一个。Go语言使用bool
来表示布尔类型。布尔类型的值也简称为布尔值。
2. 数值类型
在Go语言中,可以代表数值的基本类型众多。它们的不同基本上仅仅体现在其值所用的字节数量和代表名称的标识符上。我们也把为了存储某个类型的值而需要使用的比特/字节的数量称为这个类型的宽度。为了避免可移植性问题,几乎所有的Go语言数值类型的宽度都被直接体现在了它的名称上。比如,我们从名称上就可以获知数值类型int8
和uint16
的宽度。这些数值类型名称最后面的数字即代表了这个类型所使用的比特(bit)的数量,而1个字节等于8个比特。因此,int8
类型的值需要使用1个字节,而uint16
类型的值需要使用2个字节。
不过这也有例外,代表字节的类型byte
以及专用于存储Unicode编码字符的类型rune
在名称中并未体现出它们所用的字节数量。但实际上我们可以把它们看作其他数值类型的别名类型。类型byte
可以被看作类型uint8
的别名类型,而类型rune
可以被看作是int32
的别名类型。此外,类型int
和uint
的名称中也没有任何关于宽度的信息。因为它们的宽度并不是唯一的。在386计算架构下,它的宽度为32比特,即4个字节。在amd64(有时也称为x86-64)计算架构下,它们的宽度为64比特,即8个字节。
注意,虽然Go语言一直标榜int
类型和uint
类型的实际宽度会根据计算架构的不同而不同,但在其1.0版本或更早版本中,类型int
和uint
在所有计算架构下都是由4个字节表示的。直到Go语言的1.1版本,在Go语言官方的编译器(gc)以及gcc的Go语言编译器(gccgo)中才真正使得这两个类型在amd64计算架构下的宽度为8个字节。
Go语言用数值类型的宽度来区分它们。显然,宽度这个属性对于一个数值类型来讲非常重要。至少对于Go语言是这样。那么这些宽度意味着什么呢?我们先来看表3-5。
表3-5 数值类型宽度的含义
字节(byte)数 | 比特(bit)数 | 数值范围 |
---|---|---|
1 | 8 | 8位无符号二进制数可以表示的数值的范围是0~255。8位有符号二进制数可以表示的数值的范围是-128~127 |
2 | 16 | 16位无符号二进制数可以表示的数值的范围是0~65535。16位有符号二进制数可以表示的数值的范围是-32768~32767 |
4 | 32 | 32位无符号二进制数可以表示的数值的范围是0~4294967295,约等于从0到42.94亿。32位有符号二进制数可以表示的数值的范围是-2147483648~2147483647,约等于从-21.47亿到21.47亿 |
8 | 64 | 64位无符号二进制数可以表示的数值的范围是0~18446744073709551615,约等于从0到1844亿亿。64位有符号二进制数可以表示的数值的范围是-9223372036854775808~ 9223372036854775807,约等于从-922亿亿到922亿亿 |
从表3-5我们可以看到,宽度每翻一番,都会使数值类型的可表示范围存在若干指数级的增长。并且,Go语言又把不同宽度的数值类型分为了有符号和无符号的。相对于其他编程语言(比如Java语言和Python语言)来讲,Go语言对数值类型进行了更加细致的划分。这主要是因为Go语言自称是一个用于系统编程的通用编程语言。它更加关注对系统资源的高效利用。Go语言希望编程人员能够根据实际情况选用匹配度最高的数值类型。这需要我们对数值类型的当前使用场景进行更细致的评估。有时候,这使我们不得不在最大数值范围和最小资源占用之间进行权衡和妥协。这明显是系统级编程语言的特征之一。不过,Go语言也为我们提供了第二种选择。我们可以直接使用int
类型或uint
类型,而无需为选择哪一种宽度的数值类型伤脑筋。虽然这可能会造成一定的系统资源浪费,但是也让我们可以把精力集中到程序设计本身上去。在实际编程过程中,我们往往会根据实际需要,混合使用这些数值类型。
下面我们来讨论数值类型的表示法。这里依然会使用“字面量”这个词。在这之前,我们提到过类型字面量。而这里的字面量狭义地指代一种表示值的标记方法。
在Go语言中,整数可以由整数字面量表示。具体的表示方法有3种:十进制表示法、八进制表示法和十六进制表示法。我们用以“0”为前缀的字面量表示八进制的整数,而用以“0x”或“0X”为前缀的字面量表示十六进制的整数。另外,为了表示十六进制的整数,我们使用从“a”到“f”(或从“A”到“F”)这6个英文字母来代表从10到15的数字。比如,十进制字面量56
代表整数56,八进制字面量056
代表整数46,而十六进制字面量0x56
则代表整数86。再比如,整数78若分别使用十进制字面量、八进制字面量和十六进制字面量表示,则为78
、0116
和0x4e
。
在Go语言中,与浮点数对应的数值类型有float32
和float64
。这两种类型分别用4个字节和8个字节的二进制数来代表浮点数。这两种类型的值都可以由浮点数字面量来表示。通常,一个浮点数字面量由整数部分和小数部分组成。在这两部分之间需要用小数点(英文句点“.”)分隔。浮点数字面量的整数部分和小数部分均由十进制数组成,比如56.78
。
另外,在浮点数字面量中还可以添加指数部分。指数部分一般由“e”或“E”后跟一个带有正负号的十进制数表示,比如“E+2”和“e-3”。指数部分应该被放在浮点数字面量的最后(最右边)。其含义是把“e”或“E”左边的数值乘以10的N次方或者除以10的N次方。其中N由“e”或“E”右边的十进制数代表,而这个十进制数的正负号决定了需要执行的是乘法还是除法。比如,浮点数字面量12E+2
代表浮点数1200.0(12乘以10的2次方),而浮点数字面量12e-3
则代表浮点数0.012(12除以10的3次方)。
浮点数字面量还有若干简写方法。当一个浮点数的小数部分为0时,我们可以把这个0省略掉。比如,浮点数1200.0的浮点数字面量可以被简写为1200.
。更进一步地,我们还可以把小数点省略掉,即1200
。如果一个浮点数的整数部分为0,我们也可以把这个0省略掉。比如,浮点数0.012的浮点数字面量可以被简写为.012
。但要注意,这里的小数点就不能再简化了。
这里强调一点,浮点数字面量中的各个部分只能由十进制数表示,而不能由八进制数和十六进制数表示。也就是说,浮点数字面量056.78
和56.78
都代表浮点数56.78。
现在我们来说说复数。复数可以由Go语言的类型complex64
和complex128
代表。complex64
类型值的实部和虚部各由一个float32
类型值表示,而complex128
类型值的实部和虚部各由一个float64
类型值表示。复数类型的值可以由复数字面量表示。实部和虚部的浮点数的表示方法即为浮点数字面量。在表示虚部的浮点数字面量的最后(最右边)需要追加小写字母“i”。在实部和虚部之间需要由加号“+”分隔。当一个复数的实部或虚部为0时,可以在其复数字面量表示中省略为0的部分。例如,复数字面量12e+2 + 43.4e-3i
、0.1i
和1E3
都是合法的。它们分别代表了复数1200+0.0434i、0+0.1i和1000+0i。
最后,我们来讨论Go语言的一个特有的数值类型rune
的值的表示方法。类型rune
的值由rune
字面量代表。rune
字面量可以表示一个rune
类型的常量。我们在本节开始处的表格中提到过,rune
类型专用于存储经过Unicode编码的字符。因此,一个rune
常量即是一个Unicode编码值。Unicode编码值也可以被叫作Unicode代码点。Unicode代码点的惯用表示方式是使用十六进制表示法来表示与它对应的数字值,并使用“U+”作为前缀。比如,英文字母字符“A”的Unicode代码点就是U+0041。一个rune
字面量由外层的单引号和内层的一个或多个字符组成。在包裹字符的单引号中不能出现单引号“'”和换行符“\n”。这样的一个rune
字面量就可以由与它对应的Unicode代码点来表示。
更确切地说,我们可以用5种方式来表示一个rune
字面量,具体如下。
该
rune
字面量所对应的字符。比如:'a'
、'ä'
或'一'
。当然,这个字符必须是Unicode编码规范所支持的。使用“\x”为前导并后跟两位十六进制数。这种方式可以表示宽度为一个字节的值,即一个ASCII编码值。
使用“\”为前导并后跟三位八进制数。这种表示法也只能表示有限宽度的值,即它只能用于表示对应数值在0和255之间的值。因此,它与上一个表示法的表示范围是一致的。
使用“\u”为前导并后跟四位十六进制数。它只能用于表示两个字节宽度的值。这种方式即为Unicode编码规范中的UCS-2表示法。不过,UCS-2表示法在不久之后就会被废止。
使用“\U”为前导并后跟八位十六进制数。这种方式即为Unicode编码规范中的UCS-4表示法。UCS-4表示法已经成为Unicode编码规范和相关国际标准中的规范编码格式。
这些表示法会分别用于不同情况下的rune
字面量的表示。这些表示法虽然都可以把一个rune
字面量表示为一个整数,但是它们却有不同的表示范围。正如上面描述的那样。
我们在本节开始处说过,Go语言的所有源代码都必须由Unicode编码规范的UTF-8编码格式进行编码。UTF-8编码格式是一种可变长度的Unicode编码方式。它可以用来对Unicode编码规范支持的任何字符进行编码。根据字符的不同,一个字符可以被UTF-8编码格式编码为1到4个不等的字节。这些字节序列都可以表示为一个或多个整数值。例如,英文字符“A”会被UTF-8编码格式编码为一个字节。其Unicode代码点是U+0041,也就是rune
字面量'\u0041'
。中文字符“一”会被UTF-8编码格式编码为3个字节(0xE4、0xB8和0x80)。其Unicode代码点是U+4E00,即为rune
字面量'\u4E00'
。
rune
字面量可以支持一类特殊的字符序列——转义符。转义符的表示方式是在“\”后面追加一个特定的单字符,参见表3-6。
表3-6 转义符说明
转义符 | Unicode代码点 | 说明 |
---|---|---|
\a | U+0007 | 告警铃声或蜂鸣声 |
\b | U+0008 | 退格符 |
\f | U+000C | 换页符 |
\n | U+000A | 换行符 |
\r | U+000D | 回车符 |
\t | U+0009 | 水平制表符 |
\v | U+000b | 垂直制表符 |
\ | U+005c | 反斜杠 |
\' | U+0027 | 单引号。仅在rune 字面量中有效 |
\" | U+0022 | 双引号。仅在string 字面量中有效 |
注意,在rune
字面量中,除了在上面表格中出现的转义符之外的以“\”为前导的字符序列都是不合法的。当然,在上表中的转义符“\"”也不能在rune
字面量中出现。
3. 字符串类型
在Go语言中,字符串类型属于预定义类型。字符串类型代表了一个字符串值的集合。在底层,一个字符串值即是一个字节的序列。长度为0的序列与一个空字符串相对应。字符串的长度即是底层字节序列中字节的个数。一个字符串常量的长度在编译期间就能够确定。
字符串字面量就是上面所说的字符串常量。它代表了一个连续的字符序列。其中,每一个字符都会被隐含地以Unicode编码规范的UTF-8编码格式编码为若干字节。字符串字面量有两种表示格式:原生字符串字面量和解释型字符串字面量。
原生字符串字面量是在两个反引号“`”之间的字符序列。在两个反引号之间,除了反引号之外的其他字符都是合法的。在两个反引号之间的所有内容都看作是这个原生字符串字面量的值。其内容是由若干非解释型字符组成的。所谓非解释型字符,就是在编译期间就可以确定的字符。在原生字符串字面量中,不存在任何转义符,所有的内容都是所见即所得的。这也包括换行符。需要注意的是,原生字符串字面量中的回车符会被编译器移除。
解释型字符串字面量是被两个双引号“"”包含的字符序列。解释型字符串中的转义字符都会被成功转义。值得注意的是,在字符串字面量中,转义符“\'”是不合法的,而转义符“\"”却是合法的。这与rune
字面量刚好相反。在字符串字面量中可以包含rune
字面量。我们可以在字符串字面量中加入任意数量的以各种方式表示的rune
字面量。
不过,在这之中也会有一些限制。以3个八进制数(形如'\101'
)和2个十六进制数(形如'\x41'
)表示的rune
字面量只能用于表示所属字符串字面量中的单字节字符。所谓单字节字符,就是其经过UTF-8编码格式编码后的字节序列的大小为1的字符。由于单字节字符的UTF-8编码值和ASCII编码值是相同的。所以,单字节字符也可以理解为ASCII编码标准所支持的字符。除上述两个表示法之外的其他方法表示的rune
字面量都可以用于代表其所属字符串字面量中的单个字符。当然,这样的字符也都是经过UTF-8编码格式编码的,且一个字符可能对应多个字节。
举个例子,在解释型字符串字面量中,rune
字面量'\101'
和'\x41'
都代表了单字节字符"A"
。而rune
字面量'\u4E00'
和'\U00004E00'
都与Unicode字符“一”相对应。中文字符“一”的Unicode代码点为U+4E00。它会被UTF-8编码格式编码为3个字节,即"\xE4\xB8\x80"
。
字符串字面量与rune
字面量的本质区别是在于它们所代表的Unicode字符的数量上。具体地讲,rune
字面量仅用于代表一个Unicode字符,无论这个Unicode字符会被UTF-8编码格式编码为几个字节。而字符串字面量则用于代表一个由若干个Unicode字符组成的序列。
最后需要注意,字符串值是不可变的!也就是说,我们不可能改变一个字符串的内容。我们对字符串的操作只会返回一个新字符串,而不是改变原字符串并返回。
以上,就是我们需要了解的Go语言基本数据类型的相关知识。
3.2.2 数组
一个数组就是一个由若干相同类型的元素组成的序列。在Go语言中,数组被称为Array。
1. 类型表示法
我们在声明一个数组类型的时候需要指明它的长度和元素类型。例如,下面的示例声明了一个长度为n
、元素类型为T
的数组类型:
[n]T
可以看到,在声明的左侧的是被方括号括起来的数组长度,而右侧则是数组的元素类型。用于表示数组类型的声明是类型字面量的一种。
注意,数组的长度是数组类型的一部分。只要类型声明中的数组长度不同,即使两个数组类型的元素类型相同,它们也还是不同的类型。例如,数组类型[2]string
和[3]string
就是两个不同的类型,虽然它们的元素类型都是string
。更重要的是,一旦我们在数据类型的声明中确定了它们的长度,就无法在任何时候改变它。也就是说,所有属于这个类型的数组的长度都是固定的。
在数组类型声明中所标识的长度可以由一个非负的整数字面量代表,也可以由一个表达式代表。如果是表达式,那么该表达式的结果值必须是一个int
类型的非负值。例如:
[2*3*4]byte
这个类型字面量表示了一个元素类型为byte
的数组类型。在方括号之中的是一个代表了数组长度的表达式。还要注意,这个表达式中只能出现整数字面量和代表了某个常量的标识符。
数组类型声明中的元素类型可以是任意一个有效的Go语言数据类型。也就是说,它可以是一个预定义数据类型、复合数据类型,或者我们自定义的数据类型。甚至,我们还可以把一个类型字面量作为数组的元素类型。例如:
[5]struct { name, address string } // “struct { ... }”是用于自定义匿名结构体类型的类型字面量
这意味着,虽然数组的元素类型只能是单一数据类型,但是因为这个单一数据类型可以是一个复合数据类型,所以我们可以使用数组构造出更多样的数据结构,而不只是把它当作包含若干相同类型元素的有序列表。
2. 值表示法
数组类型的值(以下简称为数组值)可以由复合字面量来表示。这个复合字面量会由表示数组类型的类型字面量和被花括号“{”和“}”括起来若干代表元素值的字面量或表达式组成,在多个元素值之间使用逗号“,
”分隔。例如,字面量:
[6]string{"Go", "Python", "Java", "C", "C++", "PHP"}
表示了一个长度为6、元素类型为string
的数组值,且已包含了6个元素值。
注意,上面的数组值中的每个元素值都会隐含的与一个索引值相对应。这些索引值标示了相应元素值在数组值中的位置。这些索引值一定都是非负的整数。默认情况下,在花括号中的第一个元素值会与索引值0相对应,之后的每个元素值的索引值都是在前一个元素值的索引值的基础上再加1。在上面的示例中,元素值"Go"
的索引值是0,而元素值"C"
的索引值是3。
我们也可以在编写这类复合字面量的时候指定元素值的索引值。在这种情况下,索引值和对应的元素值是以键值对的形式表示的。索引值为键,元素值为值,且它们之间以冒号“:”分隔,形如0: "Go"
。我们可以把上面的复合字面量改写为:
[6]string{0: "Go", 1: "Python", 2: "Java", 3: "C", 4: "C++", 5: "PHP"}
这个字面量也体现了在默认情况下的各个元素值与索引值的对应关系。
当然,我们也可以打乱它们默认的对应关系,例如:
[6]string{2: "Go", 1: "Python", 5: "Java", 4: "C", 0: "C++", 3: "PHP"}
或者,只显式地指定一部分元素值的索引值:
[6]string{5: "Go", 0: "Python", "Java", "C", "C++", 4: "PHP"}
索引值的指定方式是非常灵活的。但是需要满足下面两个条件。
- 指定的索引值必须在该数组的类型所体现的有效范围之内,即大于等于0并且小于数组类型中声明的长度。在上面的示例中,索引值只能是0、1、2、3、4或5。例如,数组值
[6]string{6: "Go", "Python", "Java", "C", "C++", "PHP"}
就是不合法的,因为我们为元素值"Go"指定的索引值不在索引值的有效范围之内。此外,需要特别注意,我们指定的索引值也不能导致后续元素值的索引值超出范围。例如,数组值
[6]string{"Go", "Python", "Java", "C", 5: "C++", "PHP"}
也是不合法的,虽然我们为元素值"C++"指定的索引值5并没有在索引值的有效范围之外。这是因为,根据元素值索引的递增规则推算,元素值"C++"
的下一个(紧挨在它右边的)元素值"PHP"
隐含对应的索引值是6,这显然超出了索引值的有效范围。
- 指定的索引值不能与其他元素值的索引值重复,不论其他元素值的是隐含对应的还是显示对应的。例如,数组值
[6]string{0: "Go", "Python", 1: "Java", "C", "C++", "PHP"}
是不合法的。这同样可以通过使用上面的索引值推算规则来检查。我们为元素值"Go"
指定了索引值0
。根据推算,元素值"Python"
的隐含索引值为1。同时,我们又为元素值"Java"
指定了索引值1。显然,出现了重复的索引值。这是不允许的。
现在,我们再来看此类复合字面量中用于表示数组值长度的那个整数。与用于表示数组类型的类型字面量相同,这里的整数可以由一个表达式代表,同时也必须符合相关的规则。但是,这里的长度还必须满足一个额外的条件,那就是,它必须大于或等于花括号中元素值的实际数量。例如,下面的数组值是合法的:
[8]string{"Go", "Python", "Java", "C", "C++", "PHP"}
可以看到,这个复合字面量中表示的数组值长度比元素值的实际数量大。这种情况下,在此数组值中未指定的元素将会被填充为元素类型(这里是string
类型)的零值。因此,这个数组值等同于下面的复合字面量:
[8]string{0: "Go", 1: "Python", 2: "Java", 3: "C", 4: "C++", 5: "PHP", 6: "", 7: ""}
为了更加清楚地体现填充的两个元素值,我们按照索引值推算规则为这个复合字面量中的每个元素值都显式地指定了索引值。可以看到,被填充的两个元素的值均为string
类型的零值,且它们的索引值分别6和7。
当然,我们还可以通过显式地指定索引值来改变被填充元素值的位置。例如,数组值
[8]string{1: "Go", "Python", 4: "Java", "C", "C++", "PHP"}
等同于
[8]string{0: "", 1: "Go", 2: "Python", 3: "", 4: "Java", 5: "C", 6: "C++", 7: "PHP"}
总之,当在方括号中的整数值与花括号中元素值的实际数量不同的时候,数组值的长度由前者指定。不过,我们也可以忽略掉这个在方括号中的整数值。请看下面的数组值:
[...]string{"Go", "Python", "Java", "C", "C++", "PHP"}
在这个复合字面量中的方括号中只有一个特殊标记…
。这表示我们在这里并不显式地指定数组值的长度,而让Go语言编译器为我们计算该值所包含的元素值的数量并以此确定这个长度的值。这种情况下,此数组值的长度完全由其中元素值的数量代表。因此,上面这个数组值的长度为6。这种表示方法在我们能够一次性确定数组中的全部元素值的时候是很有用的。它可以避免由于指定的长度和元素值的实际数量不相符而导致的多余零值元素或编译错误。
3. 属性和基本操作
数组类型属于值类型。因此,一个数组类型的变量在被声明之后就会拥有一个非空值。这个值所包含的元素值的数量与其类型中所声明的长度一致,并且其中的每个元素值都是其类型的元素类型的零值。在Go语言中,一个数组即是一个值。数组类型的变量即代表了整个数组,而并不代表一个指向数组的第一个元素值的指针,这有别于C语言中的数组。这就意味着,当我们将一个数组值赋给一个变量或者传递给一个函数的时候,会隐含地创建出此数组值的一个备份。为了避免这种隐含的备份,我们可以通过取址操作符获取到这个数组值的指针,并把这个指针用在变量赋值操作和函数参数传递的操作当中。
我们已经知道,数组值的长度是它所属的类型的一部分。我们可以使用Go语言的内建函数len
来获取这个长度。例如,调用表达式
len([...]string{"Go", "Python", "Java", "C", "C++", "PHP"})
的结果值是6,即为其中的数组值的长度。由此,我们可以判断出这个数组的类型是[6]string
。
在本章中,我们还会陆续看到很多Go语言的内建函数。我们会在它们出现时简单地介绍它们的用法。在本章的末尾,我们还会专门开辟一个小节来汇总和说明这些内建函数。
数组值中的每个元素值都有一个对应的索引值,用于标示出元素值的在数组中的位置。我们可以通过索引值访问数组值中的每一个元素。这在我们讲述索引表达式的时候已经有所说明。例如,索引表达式
[...]string{"Go", "Python", "Java", "C", "C++", "PHP"}[0]
的值就是"Go"
,而索引表达式
[...]string{"Go", "Python", "Java", "C", "C++", "PHP"}[5]
的值则是的值就是"PHP"
。再次强调,一个数组值的索引值的有效范围在0和数组类型中声明的长度再减1的整数之间。
索引值除了可以让我们访问到数组值中对应的元素之外,还可以被用于改变对应的元素。我们首先使用赋值语句将上面示例中的数组值赋给变量array1:
array1 := [6]string{"Go", "Python", "Java", "C", "C++", "PHP"}
还记得特殊标记:=
吗?它被用于在声明一个变量的同时对这个变量进行赋值。现在,我们要把与索引值2对应的元素修改为字符串类型值Clojure
。对应的赋值语句如下:
array1[2] = "Clojure"
在上面的示例中,索引表达式成为了赋值语句的一部分。这条语句被执行之后,变量array1
的值就会变更为
[6]string{"Go", "Python", "Clojure", "C", "C++", "PHP"}
注意,而当索引值不在其有效范围的时候,如果索引值由整数字面量代表,或用于表示该索引值的表达式中只包含了整数字面量和代表了某个常量的标识符(也就是说,索引值在编译期间就可以被确定),那么这一索引表达式会在编译期间造成一个编译错误,否则它会在程序运行期间引发一个运行时恐慌。
最后,数组值中元素的顺序会以它们的索引值为依据。索引值越小,对应元素的位置就越靠前。这在我们使用for
语句对数组值进行迭代的时候就会体现出来。下一章,我们会对for
语句进行详细的说明。
当需要详细的规划程序所用的内存的时候,数组类型是非常有用的。使用数组类型值可以完全避免耗时费力的内存二次分配操作,因为它的长度是不可变的。数组类型是切片类型的根基,数组值也为切片类型值提供了底层支持。我们马上就会讲到切片类型及其值的相关知识。
3.2.3 切片
切片可以看作是对数组的一种包装形式,其官方称谓是Slice。切片包装的数组称为该切片的底层数组。反过来说,切片是针对其底层数组中某个连续片段的描述符。
切片类型为了实现针对其底层数组中某个连续片段的操作提供了比数组类型更加通用、强大和便捷的接口。在编写Go语言代码的过程中,我们一般会使用切片类型值(以下简称切片值)而不是数组值来满足我们对数组的需要,除非确实需要明确的设定长度。
1. 类型表示法
用于表示一个切片类型的类型字面量由一对中间没有任何内容的方括号和代表其元素类型的标识符组成。对于一个元素类型为T
的切片类型来说,它的类型字面量就是
[]T
可以看出,长度并不是切片类型的一部分。它不会出现在表示切片类型的类型字面量中。同时,切片的长度是可变的。因此,相同类型的切片值可能会有不同的长度。
与数组类型声明一致,切片类型声明中的元素类型也可以是任意一个有效的Go语言数据类型。例如,类型字面量
[]rune
用于表示元素类型为rune
的切片类型。我们同样可以把一个匿名结构体类型作为切片类型的元素类型:
[]struct { name, department string }
2. 值表示法
切片值的表示与数组值非常地类似。它也是复合字面量的一种。例如:
[]string{"Go", "Python", "Java", "C", "C++", "PHP"}
可以看出,在描述所包含的元素的方式上,切片值与数组值毫无区别。唯一的区别就在于其中的类型字面量。上面示例中的切片值的类型为[]string。
同样的,切片值中的每个元素都有对应的索引值。它们的特性与数组值中的索引值相同。唯一不同的是,切片值中的索引值只需要满足不出现重复的要求即可,而不再受到切片值长度的限制。因为,在切片值所属的类型中根本就没有关于长度的规定。所以,下面的切片值是合法的:
[]string{8: "Go", 2: "Python", "Java", "C", "C++", "PHP"}
它等同于下面的复合字面量:
[]string{0: "", 1: "", 2: "Python", 3: "Java", 4: "C", 5: "C++", 6: "PHP", 7: "", 8: "Go"}
当然,切片值的长度还是需要在int
类型所能表示的非负值范围之内的。
3. 属性和基本操作
切片类型的零值为nil
。在初始化之前,一个切片类型的变量值为nil
。
切片类型中虽然没有关于长度的声明,但是值确实是有长度的。这些切片值的长度准确地体现了它们所包含的元素值的实际数量。我们可以使用内建函数len
来获取切片值的长度。例如,调用表达式
len([]string{8: "Go", 2: "Python", "Java", "C", "C++", "PHP"})
的结果值是9
。这个切片值实际上包含了6个被明确指定的string
类型值和3个被填充的string
类型的零值""
。
注意,在切片类型的零值(即nil
)上应用内建函数len
将会得到0。
除了长度之外,切片值还有一个很重要的属性——容量。在对这个属性进行说明之前,我们先来说说切片值的底层实现方式。一个切片值总会持有一个对某个数组值的引用。事实上,一个切片值一旦被初始化,就会与一个包含了其中元素值的数组值相关联。这个数组值被称为引用它的切片值的底层数组。
多个切片值可能会共用同一个底层数组。例如,如果我们把一个切片值复制成多个,或者针对其中的某个连续片段再切片成新的切片值,那么这些切片值所引用的都会是同一个底层数组。对切片值中的元素值的修改,实质上就是对其底层数组上的对应元素的修改。从这个角度看,切片值类似于指向底层数组的指针。反过来讲,对作为底层数组的数组值中元素值的改变,也会体现到引用该底层数组且包含该元素值的所有切片值上。
切片值的容量与它所持有的底层数组的长度有关。我们可以使用内建函数cap
来获取它。例如,调用表达式
cap([]string{8: "Go", 2: "Python", "Java", "C", "C++", "PHP"})
的结果值是9。在这个特例中,切片值的容量就等于它的长度。但是在很多情况下不会是这样。
这需要从切片值的底层数据结构讲起。一个切片值的底层数据结构中包含了一个指向底层数组的指针类型值、一个代表了切片长度的int
类型值和一个代表了切片容量的int
类型值。如图3-3所示。
图 3-3 切片值的底层数据结构
在切片值中存储着指向其底层数组的指针。这个指针体现了它们之间的引用关系。我们在使用复合字面量初始化一个切片值的时候,首先创建的是这个切片值所引用的底层数组。这个底层数组与这个切片值有着相同的元素类型、元素值及其排列顺序和长度。因此,这时的切片值的长度和容量一定是相同的。
与内建函数len
一样,对切片类型的零值应用内建函数cap
也会得到0
。
在上一节讲切片表达式的时候我们提到过,可以使用切片表达式从一个数组值或者切片值上“切”出一个连续片段,并生成一个新的切片值。例如:
array1 := [...]string{"Go", "Python", "Java", "C", "C++", "PHP"}
slice1 := array1[:4]
在这个示例中,变量slice1
的值的底层数组实际上就是变量array1
的值,它们的关系如图3-4所示。
再次重申,切片表达式的作用并不是复制数组值中某个连续片段所包含的元素值,而是创建一个新的切片值。在这个切片值中包含了指向这个连续片段中第一个元素值的指针。因此,使用切片表达式从数组值中获取片段的效率是非常高的。我们也常常通过修改切片值中的元素值来改变其底层数组的值。
图 3-4 在数组值上切出一个切片值1
我们从图3-4中可以获知,变量slice1
的值的长度为4
、容量为6
。很多读者可能会由此假设:一个切片值的容量可能就是其底层数组的长度。但是事实并非如此。为了否定这个假设,我们再创建一个切片值。代码如下:
slice2 := array1[3:]
变量slice2
的值的底层数组也是变量array1
的值,它们的关系如图3-5所示。
图 3-5 在数组值上切出一个切片值2
从图3-5可知,slice2
的值的容量与array1
的值的长度并不相等。实际上,一个切片值的容量是从其中的指针指向的那个元素值到底层数组的最后一个元素值的计数值。slice2
的值中的那个指针指向了array1
的值中的第4个元素,而从这个元素到array1
的值中的最后元素的元素计数值是3。因此,slice2
的值的容量就是3
。由此看来,切片值的容量的含义是其能够访问到的当前底层数组中的元素值的最大数量。
我们可以把切片值想象成朝向其底层数组的一个窗口。这个窗口是我们查看底层数组中的元素值的途径。这个值的长度就是我们当前可以看到的底层数组中的元素值的数量,而它的容量则表示了我们最多能够看到多少个当前底层数组中的元素值。
因此,我们可以很方便地对这个窗口进行扩展,以查看更多底层数组元素。但是,我们并不能直接通过再切片的方式来扩展窗口。例如,对于原始的slice1
的值来说,索引表达式
slice1[4]
会引起一个运行时恐慌。因为其中的索引值超出了这个切片值当前的长度,这是不允许的。正确的扩展窗口的方式如下:
slice1 = slice1[:cap(slice1)]
上面的代码通过再切片的方式把slice1
的窗口扩展到了最大,这样就能够看到最多的底层数组元素值了。这时,slice1
的值的长度等于其容量。
注意,一个切片值的容量是固定的。也就是说,我们能够看到的底层数组元素的最大数量是固定的。我们不能把切片值的窗口扩展到其容量之外。因此,下面这段代码会引起一个运行时恐慌:
slice1 = slice1[:cap(slice1)+1]
另外,一个切片值的窗口只能向一个方向扩展。这个方向也就是我们已经在上面演示过的,即索引值递增的方向。因此,我们不能使用再切片的方式把窗口向索引值递减的方向扩展。以变量slice2
为例,切片表达式
slice2[-2:]
是错误的,会引起一个运行时恐慌。记住,与索引值一致,切片值也不允许由负整数字面量代表。
那么我们怎样突破这种限制呢?怎样随意扩展切片值的容量呢?答案是创建一个新的切片值。别担心,切片值的创建成本非常低廉。我们刚刚说过,一个切片值仅包含了一个指针类型值和两个int
类型值。我们可以使用Go语言的内建函数append
对切片值进行扩展。笼统地讲,append
函数会将指定的若干元素值追加到原切片值的末端(有最大索引值的元素值的那一端)。如果需要更大的容量,它还会对原切片值进行扩容。append
函数可以接受一个切片类型的参数和一个可变长参数。我们在上一节讲表达式的时候已经介绍过可变长参数。一个可变长参数就是一个切片类型的参数,并且与它绑定的值的数量可以是任意的。append
函数的第一个参数应该与将要被扩展的切片值绑定。而它的可变长参数,也就是第二个参数,应该与作为扩展内容的一个或多个元素值绑定,并且这些元素值的类型必须与其第一个参数的元素类型相同。另外,append
函数是有结果的。这个结果的类型与其第一个参数的类型完全一致。
我们以被扩展之前的变量slice1
为例。这时的slice1
的值还只是包含了底层数组array1
中排在最前面的(索引值最小的)那4个元素值。现在,我们使用append
函数来扩展slice1
的值:
slice1 = append(slice1, "Ruby", "Erlang")
上述语句被执行后,切片类型变量slice1
的值及其底层数组(数组变量array1
的值)的状态如图3-6所示。
可以看出,slice1
的值的长度已经由原来的4增长到了6。这与它的容量是相同的。但是,由于这个值的长度还没有超出它的容量,所以也就没必要再创建一个新的底层数组出来。这时的slice1
的值为:
[]string{"Go", "Python", "Java", "C", "Ruby", "Erlang"}
图 3-6 扩展切片值1
注意新增的(最右边的)那两个元素值。它们实际上体现的是底层数组中的最右边两个元素值在被改变后的值。也就是说,array1
的值中的对应位置上的那两个元素值"C++"
和"PHP"
已经被变更为了"Ruby"
和"Erlang"
。现在的array1
的值为:
[6]string{"Go", "Python", "Java", "C", "Ruby", "Erlang"}
记住,切片值相当于朝向其底层数组的一个窗口,它准确地体现了其底层数组中的某个连续片段。它们在对应位置上的元素值在任何时候都是完全一致的。
另外,还有一点需要注意,如果我们想改变slice1
的值,那么我们必须将append
函数的结果再次赋给变量slice1
。举个反例,下面这段代码
slice3 := append(slice1, "Ruby", "Erlang")
并不会改变的slice1
的值,而是声明并初始化了一个新的变量slice3
。slice3
的值如下:
[]string{"Go", "Python", "Java", "C", "Ruby", "Erlang"}
注意,array1
的值中的第5个和第6个元素同样被改变了。变量slice1
、slice3
和array1
的值之间的关联如图3-7所示。
图 3-7 扩展切片值2
可以看到,slice1
的值和slice3
的值的底层数组是相同的。但不同的是,slice1
的值的长度依然为4,而slice3
的值长度为6。slice3
的值包含了array1
的值中所有的元素。换句话说,这时的slice3
的值是array1
的值的完整体现。
从这个示例可知,append
函数并不是在原切片值之上进行扩展的,而且是创建了一个新的切片值。在无需扩容的情况下,这个切片值会与原切片值共用一个底层数组,且其中的指针类型值和容量值都会与原切片值保持一致,正如前面讲述的slice1
和slice3
的值。而作为扩展内容的"Ruby"
和"Erlang"
,会被分别绑定到底层数组中的第5个和第6个元素上。这两个元素也正是处在slice3
的值比slice1
的值多出的那两个元素位置上。在这些内部的操作完成之后,新创建的切片值被赋给了变量slice3
。
下面来看看需要扩容的情况。我们忽略掉前面那条包含了变量slice3
的语句。也就是说,变量slice1
(已经被扩展了一次)的值为:
[]string{"Go", "Python", "Java", "C", "Ruby", "Erlang"}
再次对变量slice1
的值进行扩展,代码如下:
slice1 = append(slice1, "Lisp")
执行这条语句之后,变量slice1
的值的长度就超出了它的容量。这时将会有一个新的数组值被创建并初始化。这个新的数组值将作为在append
函数新创建的切片值的底层数组,并包含原切片值中的全部元素值以及作为扩展内容的所有元素值。这个底层数组的长度总是大于需要存储的元素值的总和。新切片值中的指针将指向其底层数组的第一个元素值,且它长度和容量都与其底层数组的长度相同。这与我们直接使用复合字面量来初始化切片值时的内部操作流程有很多相似之处。最后,这个新的切片值会被赋给变量slice1
。
我们在前面说过,内建函数append
的第二个参数是一个可变长参数,前面的示例中先后对变量slice1
的值扩展了一个和两个元素。作为利用可变长参数的另一个示例,我们还可以使用append
函数把两个元素类型相同的切片值连接起来。例如:
slice1 = append(slice1, slice2...)
这种将切片值直接传递给可变长参数的方式我们在讲表达式的时候也已经介绍过。当然,我们也可以把数组值作为第二个参数传递给append
函数。
最后,即使切片类型的变量的值为零值nil
,也会被看作是长度为0
的切片值,所以我们可以在值为nil
的切片类型的变量之上应用函数append
来追加元素值。像这样:
slice2 = nil
slice2 = append(slice2, slice1...)
或者:
var slice4 []string
slice4 = append(slice4, slice1...)
上面示例中的第一条语句用于声明(不包含初始化)一个变量。它总是以关键字var
作为开始,并后跟变量的名称和类型。未被初始化的变量的值为nil
。我们在下一节讲变量和常量的时候会再对变量的声明进行详细介绍。此外,变量slice4
的值是完全独立的,因为其底层数组还未与其他切片值共享。
好了,如果读者认为已经真正地理解了前面所讲的关于切片值的扩展方法的内容,我们就来看一个更加复杂的用法。
我们在上一节讲切片表达式的时候说过,还可以在切片表达式中添加第三个索引——容量上界索引。如果该索引被指定,那么作为切片表达式的求值结果的那个切片值(以下简称新切片值)的容量就不再是该切片表达式的操作对象的容量与该表达式中的元素下界索引之间的差值了,而是容量上界索引与元素下界索引之间的差值。又因为它们之间存在以下关系:
0 <= 元素下界索引 <= 元素上界索引 <= 容量上界索引 <= 被操作对象的容量
所以,指定容量上界索引的目的就是为了缩小新切片值的容量。那么,这有什么意义呢?它最重要的意义在于允许更加灵活的数据隔离策略。例如,我们有这样一个数组值:
var array2 [10]int = [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
并根据这个数组值创建了一个切片值:
slice5 := array2[2:6]
显然,变量slice5
的值的底层数组即是array2
的值。slice5
的值的容量为8
,即array2
的值的长度10
与切片表达式array2[2:6]
中的元素下界索引2
之间的差值。我们已经知道,切片值的容量就是能通过它访问和修改的底层数组元素值的最大数量。在这个例子中,我们可以直接通过在slice5
的值之上应用索引表达式来访问和修改array2
的值中对应索引值在[2,6)
范围之内的元素值。并且,在对slice5
的值进行再切片(即slice5[:cap(slice5)])之后,还可以访问和修改array2
的值中对应索引值最大的那四个元素值。
如果把slice5
的值作为数据载体传递给了另一个程序,是不是就意味着那个程序就可以随意地更改array2
的值中的某些元素值了呢?答案当然是肯定的。这等于暴露了程序中的部分实现细节,并公开了一个可以间接修改程序内部状态的方法。在很多情况下,这并不是我们想要的。
当然,我们可以通过生成并传递一个slice5
的值的副本来避免此类问题。但是,如果我们采用的是折中方案呢?如果我们就是想让另一个程序可以访问和修改array2
的值中对应索引值在[2,8)
范围之内的元素值呢?在无法指定(在Go 1.2之前,能在切片表达式中出现的只有元素下界索引和元素上界索引)或不指定容量上界索引的情况下,这是不可能的。因为,slice5
的值的容量上界索引不是由我们自己来控制的。它总是等于array2
的值中最后一个元素位置的索引值再加上1,即array2
的值的长度值。也就是说,array2
的值中对应索引值在[2,10)
范围之内的元素值,总是可以被slice5
的值的持有者访问和修改。
对容量上界索引的设定使得我们对切片值容量的精细控制成为了可能。例如,如果我们这样声明变量slice5
:
slice5 := array2[2:6:8]
那么就可以使slice5
的值的持有者只能访问和修改array2
的值中对应索引值在[2,8)
范围之内的元素值。即使通过
slice5 = slice5[:cap(slice5)]
把slice5
的窗口扩展到最大,我们也不可能通过它访问到array2
的值中对应索引值大于等于8
的那些元素值。此时,slice5
的值的容量为6
(容量上界索引与元素下界索引的差值)。对于再切片操作来说,被操作对象的容量是一个不可逾越的限制。因此,slice5
的值对其底层数组(array2
的值)的“访问权限”也就得到了严格的控制。另外,如果在slice5
的值之上的扩展超出了它的容量,如下所示:
slice5 = append(slice5, []int{10, 11, 12, 13, 14, 15}...)
那么它原有的底层数组就会被替换。这样也就彻底切断了通过slice5
访问和修改其原有底层数组中的元素值的途径。
总之,这种通过容量上界索引对切片值的容量进行设定的方式,对于精细控制切片值对其底层数组的“访问权限”来说是极其有效的。与普通的切片方式(比如array2[2:6]
)相比,它提供了更好的可控性。
关于切片表达式中的这3个索引,还有一个限制:当我们在切片表达式中指定容量上界索引的时候,元素上界索引是不能够省略的。但是,在这种情况下元素下界索引却是可以省略的。例如,切片表达式
slice5[:3:5]
是合法的,而切片表达式
slice5[0::5]
则会造成一个编译错误。
现在,停顿一会儿,请读者把切片表达式以及那3个索引的用法和作用记在心里。
最后,我们来看看怎样批量复制切片值中的元素。首先,新声明并初始化两个切片值:
sliceA := []string{"Notepad", "UltraEdit", "Eclipse"}
sliceB := []string{"Vim", "Emacs", "LiteIDE", "IDEA"}
可以看到,变量sliceA
的值中包含了3个元素,而变量sliceB
的值中包含了4个元素。现在,使用Go语言的内建函数copy
,将变量sliceB
的值中的元素复制到sliceA
的值中。代码如下:
n1 := copy(sliceA, sliceB)
内建函数copy
的作用是把源切片值(第二个参数值)中的元素值复制到目标切片值(第一个参数值)中,并且返回被复制的元素值的数量。这个结果的类型是int
的。copy
函数的两个参数的元素类型必须一致,且它实际复制的元素值的数量将等于长度较短的那个切片值的长度。例如,上述示例中的语句被执行后,变量sliceA
的值被修改为:
[]string{"Vim", "Emacs", "LiteIDE"}
由于sliceA
的值的长度是3
,所以copy
函数并没有复制sliceB
的值中的第四个元素。因此,前面示例中的变量n1
的值为3
。
注意,不像append
函数那样,copy
函数会改变与其第一个参数绑定的那个值。
如果我们把上面示例中的传递给copy
函数的两个参数交换位置,那么改变的就将会是变量sliceB
的值,改变后的值为:
[]string{"Notepad", "UltraEdit", "Eclipse", "IDEA"}
变量n1
的值依然会为3
。因为变量sliceA
的值中只有3个元素可被复制。
Go语言的切片类型相当于其他编程语言中的动态数组类型,其扩展机制也与那些动态数组类型非常类似。在Go语言中,切片类型的应用场景非常广泛。它比数组类型更灵活、更强大,但同时也更难于理解。希望读者通过对本小节的阅读和学习能够真正地理解Go语言的切片类型。
3.2.4 字典
在Go语言中,字典类型的官方称谓是Map,它是哈希表(Hash Table)的一个实现。哈希表是一个实现了关联数组的数据结构,是计算机科学领域最有用的数据结构之一。关联数组是用于代表键值对的集合的一种抽象数据类型。在一个键值对集合中,一个键最多能够出现一次。与这个抽象数据结构相关联的操作有4个。
向集合中添加键值对。
从集合中删除键值对。
修改集合中已存在的键值对的值。
查找一个特定键所对应的值。
哈希表可以通过一个哈希函数快速地建立起键值对的内部关联,并在此基础上实现上述操作。此外,在哈希表中的键值对之间是没有顺序关系的。哈希表的实现多种多样。Go语言的字典类型的内部特性属于Go语言运行时系统的实现细节,至今未出现在Go语言的规范中。
1. 类型表示法
在Go语言中,一般称键值对为键-元素对,并把字典类型值(以下简称字典值)中的每个键值都看作与其对应的元素值的索引。不过,我们在本书中仍然使用“键值对”这个名词,因为它更加通用。键值对代表了键的值和对应的元素的值构成的结对。所以,我们也说一个键值对是由一个键值和一个元素值组合而成的。
如果一个字典类型中的键的类型为K
,且元素的类型为T
,那么用于表示这个字典类型的类型字面量就是:
map[K]T
可以看到,一个字典类型的键类型和元素类型都是需要在其声明中指定的。字典类型声明中的元素类型可以是任意一个有效的Go语言数据类型。但是,它的键类型不能是函数类型、字典类型或切片类型。因为键的类型必须是可比较的,也就是说,键的值必须可以作为比较操作符==
和!=
的操作数。如果字典类型的键类型是接口类型,那么就要求在程序运行期间,该类型的字典值中的每一个键值的动态类型都必须是可比较的,否则在进行相应操作的时候会引发运行时异常。
下面举几个例子。这些用于表示字典类型的类型字面量都是合法的:
map[int]string
map[string]struct { name, department string }
map[string]interface{}
而下面这几个类型字面量就是不合法的:
map[[]int]string
map[map[int]string]string
在3.3.3节讲可比性与有序性的时候,我们会对数据类型的可比较性作详细论述。
2. 值表示法
字典值可以由复合字面量来表示。这个复合字面量会由表示字典类型的类型字面量和被花括号“{”和“}”括起来的若干键值对组成,且在多个键值对之间使用逗号“,”分隔。键值对中的键值和元素值之间需要用冒号“:”分隔。
例如,下面表示的是一个类型为map[string]bool
的值:
map[string]bool{"Vim": true, "Emacs": true, "LiteIDE": true, "Notepad": false}
当然,我们也可以这样表示一个不包含任何键值对的空字典值:
map[string]bool{}
3. 属性和基本操作
与指针类型和切片类型一样,字典类型是一个引用类型。与切片值相同,一个字典值总是会持有一个针对某个底层数据结构值的引用。这意味着,如果将一个字典值传递给一个会改变它的函数,那么这个改变对于函数的调用方来说也是可见的。作为对比,数组类型并不是引用类型。因此,一个数组值在作为参数被传递给某个函数并在此函数内部被改变之后,该函数的调用方并不能看到它的变化。其根本原因是,任何函数都只会拿到调用方传递给它的参数值的一个复制品。在很多编程语言中,这种传递参数值的方式常被称为“传值”。而与其对应的是以“传引用”的方式传递参数值。请记住,在Go语言中,只有“传值”而没有“传引用”。函数内部对参数值的改变是否会在该函数之外体现出来(或者说是否会反映到该参数值的源值上),只取决于这个被改变的值的类型是值类型还是引用类型。
也正因为字典类型是一个引用类型,它的零值是nil
。一个值为nil
的字典类型的变量类似于一个长度为0
的空字典。对它进行读取操作的时候并不会引起任何错误,但是对它的写操作(添加或删除键值对)将会引发一个运行时恐慌。而一个未被初始化的字典类型的变量的值就是nil
。
一个字典值的长度代表了它当前所包含的键值对的数量。我们可以使用内建函数len
来获取一个字典值的长度。正如之前所说,在一个值为nil
的字典类型的变量上应用len
函数会得到0
。
我们可以随时将一个键值对添加到一个字典值中,只要这个字典类型的值不是nil
。这个添加键值对的操作需要用到左侧为索引表达式的赋值语句。我们在这里声明并初始化一个字典类型的变量,如下所示:
editorSign := map[string]bool{"LiteIDE": true, "Notepad": false}
变量editorSign
是一个字典类型,它的元素类型是布尔类型,而键类型是字符串类型。现在,我们使用下面的赋值语句将一个由键值"Vim"
和元素值true
组成的键值对添加到变量editorSign
的值中:
editorSign["Vim"] = true
这很像是通过索引值把一个元素设置到一个数组值的指定位置上。其实,我们也可以把键的值想象成字典值中的元素值的“索引值”。因为与数组值中的索引值类似,我也可以使用键值对字典值中的某个元素值进行“定位”。但不同的是,字典值中的键的类型可以是多种多样的,字典值中的键的值可以是任意的,只要它们的类型符合该字典值的类型声明即可。在上面的示例中,如果在editorSign
的值中已存在了键为"Vim"
的键值对,那么这个赋值语句的作用就相当于更新该字典值中键为"Vim"
的键值对的元素值。否则,这个键值为"Vim"
、元素值为true
的键值对就会被添加到editorSign
的值中。
我们也可以通过索引表达式在一个字典值中查找并获取与指定键值对应的那个元素值。例如:
sign1 := editorSign["Vim"]
上面的变量sign1
的值将会是editorSign
中与键值"Vim"
对应的那个元素值。但是,当editorSign
的值中没有键为"Vim"
的键值对时,变量sign1
将会被赋予editorSign
的元素类型的零值,即false
。显然,这存在歧义。我们不知道false
真是在editorSign
中的键为"Vim"
的键值对中的那个元素值,还是意味着在editorSign
中根本就不存在这个键值对。这种情况的解决方案我们在上一节讲表达式的时候已经说过,可以通过如下方式来消除这个歧义:
sign1, ok := editorSign["Vim"]
关于变量sign1
的赋值依然遵循我们刚刚描述的规则,而变量ok
将会是布尔类型的。它的值表明了在editorSign
的值中是否存在键为"Vim"
的键值对。
删除字典值中的某个键值对需要用到Go语言的内建函数delete
。我们依然以变量editorSign
为例。如果要从editorSign
的值中删除掉以"Vim"
为键的键值对需要这样编写代码:
delete(editorSign, "Vim")
内建函数delete
需要两个参数。第一个参数就是我们要改变的那个字典值,而第二个参数就是我们要删除的键值对中的那个键的值。delete
函数会很“安静”。它没有结果,也不会在删除并不存在的键值对的时候产生错误或者引发运行时恐慌。
最后,需要注意:字典值并不是并发安全的!Go语言官方认为,在大多数使用字典值的地方并不需要多线程场景下的安全访问控制。为了少数的并发使用场景而强制要求所有的字典值都满足互斥操作将会降低大多数程序的速度,这是得不偿失的。我认为这样确实是合情合理的。
对一个非并发安全的字典值进行不受控制的并发访问很可能会导致程序行为的错乱。不过,我们可以很容易地扩展Go语言官方的字典类型来保证并发安全性。这需要使用标准库代码包sync
中的结构体类型RWMutex
。从名称上我们就可以猜到这是一个读写互斥量。它常常用于多线程环境下的并发读写控制。我们会在本书第8章中详细讲解它,并且还会使用它构造出一个并发安全的字典类型。
关于Go语言的字典类型,我们就暂时介绍到这里。
3.2.5 函数和方法
在Go语言中,函数类型是一等类型。这意味着可以把函数当作一个值来传递和使用。例如,函数类型的值(以下简称为函数值)既可以作为其他函数的参数,也可以作为其他函数的结果(之一)。另外,我们还可以利用函数类型的这一特性生成闭包。总之,作为一等类型的函数类型可以使程序更加灵活和稳固。下面我们就来进行讨论。
1. 类型表示法
函数类型指代了所有可以接受若干参数并能够返回若干结果的函数。声明一个函数类型总会以关键字func
作为开始。紧跟在关键字func
之后的应该是这个函数的签名,包括了参数声明列表和结果声明列表。参数声明列表在左,结果声明列表在右,中间由空格“ ”分隔。参数声明列表必须由圆括号括起来,多个参数声明之间需用逗号“,”来分隔。
参数声明的一般写法是参数名称在前,参数类型在后,中间以空格“ ”分隔。例如,我们这样声明一个名称为name
、类型为string
的参数:
name string
如果有一个参数列表,除了上述的名称为name
的参数之外,还包括一个名称为age
、类型为int
的参数。那么,这个参数列表应该这样编写:
(name string, age int)
注意,在同一个参数声明列表中的所有参数名称都必须是唯一的。
如果相邻两个参数属于同一数据类型,那么我们只需要写一次参数类型。例如,我们向上面的参数声明列表中添加一个名称为seniority
、类型为int
的参数:
(name string, age, seniority int)
这形同于:
(name string, age int, seniority int)
另外,我们也可以在函数类型声明的参数声明列表中略去所有参数的名称:
(string, int, int)
当然我们不推荐这种做法,因为它的可读性很差。我们应该尽量让阅读它的人轻易猜出其含义。
还记得可变长参数吗?我们可以再向这个参数声明列表中追加一个名称为informations
、类型为…string的可变长参数:
(name string, age int, seniority int, informations ...string)
注意,可变长参数必须是参数列表中的最后一个。所以,可变长参数也常常被称为“最后的参数”。
另一方面,函数类型声明的结果声明列表中一般包含若干个结果声明。结果声明列表的编写规则与参数声明基本一致。不过,它们之间存在两点区别。第一,只存在可变长参数的声明而不存在可变长结果的声明;第二,如果结果声明列表中只有一个结果声明且这个结果声明中并不包含结果的名称,那么就可以忽略掉它的圆括号,如下所示:
func (name string, age int, seniority int, informations ...string) bool
其中bool
就是这个函数类型的唯一结果的类型声明。该结果声明独自组成了该函数类型的结果声明列表。
如果我们需要命名这个结果,就应该这样编写:
func (name string, age int, seniority int, informations ...string) (done bool)
我们将这个函数类型的唯一结果命名为了done
。注意,这时的结果声明列表就必须被圆括号括起来了。命名的结果是很有用的。其名称可以作为附属于该函数类型声明的文档的一部分。阅读代码的人可以根据结果的名称大概猜出该结果的含义。这样,我们在编写这个函数类型的实现的时候,就会更加明确地知道需要返回怎样的结果了。
我们之所以说一个函数类型可以有一个结果声明的列表,是因为Go语言的函数类型可以有多个结果。这是Go语言的先进特性之一。不知道大家在用其他编程语言编写程序的时候是否遇到过这种情况。你需要使用整数来表示函数体内操作的结果。例如,使用-1
来表示操作失败、使用0
来表示操作成功,再使用大于0
的某个整数来表示受影响的数据的数量,也许还会使用小于-1
的某个整数表示操作失败的原因。比如:
func (name string, age int, seniority int) (result int)
可能你已经习惯这种“多合一”的表述方式了。但是现在让我来告诉你在Go语言程序中可以怎样做,请看下面这个函数类型声明:
func (name string, age int, seniority int) (effected uint, err error)
为函数声明多个结果可以让每个结果的职责更加单一。这既易于理解又方便使用。更值得称赞的是,我们可以利用这一特性将错误值作为结果(之一)返回给调用它的代码,而不是把错误抛(throw)出来,然后再不得不在调用它的地方编写若干代码来抓(catch)住这个错误。在上面这个函数类型声明中,第二个结果声明就体现了这样的错误值传递方式。
这样一来,我们既可以非常清晰地为可能出现的错误值提供一个单独的传递渠道,又可以用一种非常安静的方式来传递它。单独的传递渠道可以使函数非常清晰地表达出错误发生的可能性,但是又不会像throw-catch模式那样迫使外层代码掺杂一些有时并不必要的错误处理代码。这很合理,不是吗?我们会在下一章详细讨论Go语言的错误处理机制。
函数类型的多个结果声明的另一个好处是,可以利用它从不同的角度来体现函数的内部操作的结果。例如:
func (name string, age int, seniority int) (done bool, id uint, synchronized bool)
假设上面声明的函数类型专用于保存某项数据,它的3个结果的作用如下。
done
:用于表示数据是否被成功保存。id
:数据被保存后的ID。此ID可以被用来检索数据。synchronized
:用于表示此数据是否已被同步到相关系统中。
这样,该函数的调用方会更加清晰明了地获知具体的操作结果。同时,处理这些操作结果的代码也会更加简单和扁平化。
我们从Go语言的函数类型的声明方式上就可以看出,Go语言的函数是非常灵活多样的。再加之函数类型是Go语言中的一等类型,所以函数在Go语言程序中的用途相当广泛。下面,我们来看看怎么编写函数类型的实现。
2. 值表示法
函数类型的零值是nil
。因此,未被初始化的函数类型的变量的值就是nil
。我们在一个未被初始化的函数类型的变量上应用调用表达式会引发一个运行时恐慌。
函数类型的值被分为两类:命名函数值和匿名函数值。在很多时候,我们称命名函数值为命名函数,称匿名函数值为匿名函数。虽然可以这样称呼,但是我们应该牢记它们都是值的一种。
我们先来讨论命名函数。命名函数的声明一般由关键字func
、函数名称、函数的签名(由参数声明列表和结果声明列表)和函数体组成。其中,函数体就是由花括号“{”和“}”括起来的若干条Go语言语句的合称。如果在函数的签名中包含了结果声明列表,那么在该函数的函数体中的任何可到达的流程分支的最后一条语句都必须是终止语句。终止语句有很多种,比如以关键字return
或goto
开始的语句,又或者仅包含针对内建函数panic
的调用表达式的语句。其中的内建函数panic
用于产生一个运行时恐慌。关于终止语句的详细说明参见下一章。在此,我们仅以关键字return
开始的终止语句为例。假设有这样一个用于取模运算的Module
函数:
func Module(x, y int) int {
return x % y
}
该函数的参数声明列表包含了两个参数:x
和y
。它们都是int
类型的。同时,Module
函数还有一个未命名的结果声明,也是int
类型的。正因为存在这个结果声明,所以该函数体内的最后一条语句必须是终止语句。函数Module
在它的函数体内仅包含了一条语句。这条语句由关键字return
和一个由求余操作符和两个操作数组成的表达式。这两个操作数正是Module
函数的两个参数。Module
函数将它的两个参数作为操作数进行求余(也就是取模)操作,并将结果返回给调用方。注意,在关键字return
之后(右边)的结果必须在数量上与该函数的结果声明列表中的内容完全一致,且在对应位置的结果的类型上存在可赋予的关系,否则将不能通过编译。顺便提一句,以关键字return
开始的语句称为return
语句,跟在return
之后的内容称为return
的参数。
我们在前面说过,在声明一个函数类型的时候可以给它的结果命名。同样地,我们在编写函数的时候也可以给它的结果命名。我们可以给上述的Module
函数的结果命名,如下所示:
func Module(x, y int) (result int) {
return x % y
}
注意,在为这个唯一的结果命名之后就必须用圆括号将它括起来了。
实际上,为函数的结果命名会使它们能够以常规变量的形式存在,就像函数的参数那样。当结果被命名,它们在函数被调用时就会被初始化为对应的数据类型的零值。如果这样的函数的函数体中有一条不带任何参数的return
语句,那么在执行到这条return
语句的时候,作为结果的变量的当前值就会被返回给函数调用方。因此,我们可以稍微改造一下Module
函数的函数体中的语句,使它与命名结果的风格相适应:
func Module(x, y int) (result int) {
result = x % y
return
}
在Module
函数被调用时,变量result
被初始化为int
类型的零值0。当该函数的函数体中的第一条语句被执行时,变量result
被赋予了表达式x % y
的结果值。当该函数体中的无参数的return
语句被执行时,result
的当前值就会作为结果被返回给函数调用方。
对函数结果的命名可以使函数体内的代码更加简单和清晰,其中的哪一条语句赋操作了哪一个函数结果变得一目了然。我们也可以非常方便地使用编辑器的代码高亮功能找到这些语句。在函数体包含很多语句的时候,这种惯用法所体现出的便捷性会更为突出。
顺便提一下,命名函数的声明还可以省略掉函数体。这意味着,该函数会由外部程序(如汇编语言程序)实现,而不会由Go语言程序实现。
再来说匿名函数。匿名函数由函数字面量表示。函数字面量也是表达式的一种。顾名思义,匿名函数没有名字。在声明的内容上,匿名函数与命名函数的区别也只是少了一个函数名称。也就是说,函数字面量仅由关键字func
、函数的签名和函数体组成。我们稍加改动就可以把前面声明的Module
函数改写成匿名函数:
func (x, y int) (result int) {
result = x % y
return
}
函数字面量和函数类型声明很像,它比函数类型声明多了一个函数体。因此,函数字面量也可以看作是对某个函数类型的即时实现。
一个函数字面量可以被赋给一个变量,也可以被直接调用。这也充分体现了Go语言把函数作为值的这一特性。下面我们就来看看函数都有哪些属性以及怎样操作它。
3. 属性和基本操作
函数类型也是Go语言的数据类型之一。因此,我们可以把函数类型作为一个变量的类型。例如,我们可以这样声明一个变量:
var recorder func (name string, age int, seniority int) (done bool)
之后,所有符合这个函数类型的实现都可以被赋给变量recorder
,如下所示:
recorder = func(name string, age int, seniority int) (done bool) {
// 省略若干条语句
return
}
注意,被赋给变量recorder
的函数字面量必须与recorder
的类型拥有相同的函数签名。
熟悉面向对象编程的读者可能会意识到,这很像“面向接口编程”原则的一种实现方式。对设计模式有所了解的读者或许也可以从这一小段代码上联想到策略模式。正因为在Go语言中函数类型是一等类型,我们才能使用它实现程序的更细粒度的灵活性,而不用像Java语言那样,必须先要创建一个类(Class)再考虑实现某个接口的问题。对于那些为了一定的灵活性而不得不编写各种样板代码的语言来说,Go语言会让我们感到非常地得心应手。
在上面的示例中,我们将一个函数字面量赋给了变量recorder
。由于我们可以在一个函数类型的变量上直接应用调用表达式来调用它,所以下面这段代码是合法的:
done := recorder("Harry", 32, 10)
我们把调用变量recorder
(实为对它代表的那个函数的调用)后得到的结果值又赋给了新的变量。需要注意的是,被赋值的变量在数量上必须与函数的结果声明列表中的内容完全一致,且在对应位置的变量和结果的类型上存在可赋予的关系。这条规则同样适用于对命名函数进行调用并赋值的情况。
我们可以把函数类型的变量的值看作是一个函数值。所有的函数值都可以被调用,函数字面量也不例外。我们可以在函数字面量被编写出来的时候直接调用它,例如:
func(name string, age int, seniority int) (done bool) {
// 省略若干条语句
return
}("Harry", 32, 10)
函数既然可以作为变量的值,那么也就可以像其他值那样在函数之间传递。换句话说,一个函数既可以作为其他函数的参数,也可以作为其他函数的结果。
我们来举一个例子。现在要声明一个可以对一段文本进行加密的函数,同时,要求可以根据不同的应用场景实时地、频繁地对加密算法进行变更。根据上述需求,我们就不应该只声明一个加密函数,而应该声明一个能够生成加密函数的函数,然后在程序运行期间,根据不同的要求使用这个函数来生成需要的加密函数。
首先,我们应该确定可以向这个生成函数的函数提供加密算法的方式。最简单也是最直观的方式就是把加密算法封装成一个函数并作为参数传递进去。因为,在Go语言中,函数是封装一段代码的最小单元。此外,所有用于封装加密算法的函数都应该是同一个函数类型的,这有利于加密算法的无缝替换。因此,我们应该首先声明这样一个函数类型:
type Encipher func(plaintext string) []byte
在上一节我们已经使用过关键字type
,它专门用于声明自定义数据类型。这里声明的Encipher
类型实际上就是函数类型func(plaintext string) []byte
的一个别名类型。
这个函数接受一个string
类型的参数,并且返回一个元素类型为byte
的切片类型的结果。其实这代表了一类比较通用的加密算法的输入数据和输出数据。
在有了这个用于封装加密算法的函数类型之后,我们就可以声明那个可以生成加密函数的函数了。其声明如下:
func GenEncryptionFunc(encrypt Encipher) func(string) (ciphertext string) {
return func(plaintext string) string {
return fmt.Sprintf("%x", encrypt(plaintext))
}
}
可以看到,函数GenEncryptionFunc
的签名中包括了一个参数声明和一个结果声明。其中,参数声明中的参数类型就是我们刚刚定义的那个用于封装加密算法的函数类型。它后面的结果声明同样表示了一个函数类型的结果。这个函数类型正是GenEncryptionFunc
函数所生成的加密函数的类型。它接收一个string
类型的明文作为参数,并返回一个string
类型的密文作为结果。之所以密文是string
类型的,是因为要考虑到能够把密文作为文本保存的需求。
在GenEncryptionFunc
函数的函数体内直接返回了符合加密函数类型的匿名函数。这个匿名函数的函数体内也只包含了一条语句,这条语句做了两件事。首先,它调用名称为encrypt
的函数,把作为该匿名函数的参数的明文加密。然后,它使用标准库代码包fmt
中的Sprintf
函数,把encrypt
函数的调用结果转换成了字符串。这个字符串的内容实际上是用十六进制数表示的加密结果。而这个加密结果实际上是[]byte类型的。
对于这个被GenEncryptionFunc
函数返回的匿名函数来讲,其中的标识符encrypt
并不是在它的函数体内定义的。它是一个外来的标识符,是GenEncryptionFunc
函数中参数的名称。而这个参数代表了待定的加密算法函数。只有当我们调用GenEncryptionFunc
函数的时候,这个匿名函数中的标识符encrypt
才能够具有特定的意义——代表了某个加密算法函数。在这之后,对GenEncryptionFunc
函数的调用结果恰恰就是基于传递给它的那个加密算法函数(由参数encrypt
代表)生成的加密函数。
每一次调用GenEncryptionFunc
函数时,传递给它的那个加密算法函数都会一直被对应的加密函数引用着。只要生成的加密函数还可以被访问,其中的加密算法函数就会一直存在,而不会被Go语言的垃圾回收器回收。
熟悉函数式编程范式的读者可能会发现,这里恰恰实现了闭包。闭包这个词源自于通过“捕获”自由变量的绑定对函数文本执行的“闭合”动作。在上面的示例中,加密函数中的标识符encrypt
代表的就是自由变量,它在调用它的加密函数被生成的时候,与GenEncryptionFunc
函数的参数encrypt
的值进行了绑定,从而使得这个加密函数变得完整。我们也可以说,通过“捕获”自由变量encrypt
的绑定使GenEncryptionFunc
函数返回的加密函数“闭合”了。这就是闭包的典型应用。
为了使读者更加宏观和清晰地理解GenEncryptionFunc
函数所涉及的一些概念,我制作了一幅图,见图3-8。
图 3-8 GenEncryptionFunc
函数
实际上,只有当函数类型是一等类型并且其值可以作为其他函数的参数或结果的时候,我们才能够编写出实现闭包的代码。为什么我们可以在Go程序和Python程序中轻松地实现闭包,而在使用Java语言(1.8版本之前)编写的程序中却不能这样做?这就是根本原因。
函数类型是Go语言中非常重要的一个数据类型。它是Go语言支持函数式编程范式的重要体现,也是我们编写函数式风格代码的主要手段。此外,函数还可以附属于任何自定义的数据类型,或者与接口类型和结构体类型相结合作为针对某个或某些数据类型的操作方法。下面我们就对这个函数类型的重要演进形式——方法进行专门的论述。
4. 方法
方法就是附属于某个自定义的数据类型的函数。具体地说,一个方法就是一个与某个接收者关联的函数。因此,在方法的签名中不但包含了函数签名,还包含了一个与接收者有关的声明。也就是说,方法的声明包含了关键字func
、接收者声明、方法名称、参数声明列表、结果声明列表和方法体。其中的接收者声明、参数声明列表和结果声明列表被统称为方法签名,而方法体可以在某些情况下被忽略。一般情况下,一个接收者声明由被圆括号括起来的两个标识符组成。这两个标识符之间被空格“ ”分隔。左边的标识符代表了接收者的值在当前方法中的名称,而右边的标识符则代表了接收者的类型。前者又称为接收者标识符。下面我们来看一个例子:
type MyIntSlice []int
func (self MyIntSlice) Max() (result int) {
// 省略若干条语句
return
}
在这个示例中,我们首先自定义了一个数据类型MyIntSlice
。我们可以把这个自定义类型看作[]int类型的一个别名类型。在这之后,我们还声明了一个方法。在这个名称为Max
的方法中,接收者声明为(self MyIntSlice)
。其中,右边的标识符明确地表示了该方法所属的数据类型,即MyIntSlice
。而左边的接收者标识符则代表了MyIntSlice
类型的值在方法Max
中的名称,这为我们在该方法中使用这个值提供了前提条件。
下面,我们来看一看与方法声明中的接收者声明有关的几条编写规则。
- 接收者声明中的类型必须是某个自定义的数据类型,或者是一个与某个自定义数据类型对应的指针类型。但不论接收者的类型是哪一种,接收者的基本类型都会是那个自定义数据类型。例如,方法声明
func (self *MyIntSlice) Min() (result int)
中的接收者的类型是*MyIntSlice
,而其基本类型是MyIntSlice
。接收者的基本类型既不能是一个指针类型,也不能是一个接口类型。
接收者声明中的类型必须由非限定标识符代表。也就是说,方法所属的数据类型的声明必须与该方法声明处在同一个代码包内。
接收者标识符不能是空标识符“_”,并且必须在其所在的方法签名中是唯一的。
如果接收者的值(由接收者标识符代表)未在当前方法的方法体内被引用,那么我们就可以将这个接收者标识符从当前方法的接收者声明中删除掉。注意,虽然这条规则同样适用于方法声明和函数声明中的参数声明,但是并不推荐这么做,原因已经在前面说明。
我们常常把接收者类型是某个自定义数据类型的方法叫作该数据类型的值方法,而把接收者类型是与某个自定义数据类型对应的指针类型的方法叫作该数据类型的指针方法。可见,一个方法总是与其接收者的基本类型相关联的。
还要注意,对于一个接收者的基本类型来说,它所包含的方法的名称之间不能有重复。如果这个接收者的基本类型是一个结构体类型,那么还需要保证它包含的字段和方法的名称之间不能出现重复。
一个方法的类型与从其声明中去掉接收者声明之后的函数的类型相似。例如,方法
func (self *MyIntSlice) Min() (result int)
的类型是
func Min() (self *MyIntSlice, result int)
也就是说,我们把接收者声明中的两个标识符原样搬到参数声明列表的首位,就可以得到该方法的类型了。
但要注意:形如上述方法的类型表示的函数的值只能算是一个函数,而不能叫作方法。也就是说,这样的函数并没有与任何自定义数据类型相关联。
我们之前多次提到,接收者的类型可以是一个自定义的数据类型,也可以是一个与某个自定义数据类型对应的指针类型。那么在接收者的基本类型确定的情况下,我们应该怎样选择接收者的类型呢?实际上,这也是一个在值方法和指针方法之间做选择的问题。这里有两条很重要的规则。
在某个自定义数据类型的值上,只能够调用与这个数据类型相关联的值方法,而在指向这个值的指针值上,却能够调用与其数据类型关联的值方法和指针方法。从另一个角度讲,自定义数据类型的方法集合中仅包含了与它关联的所有值方法,而与它相对应的指针类型的方法集合中却包含了与它关联的所有值方法和所有指针方法。
在指针方法中一定能够改变接收者的值,而在值方法中,对接收者的值的改变对于该方法之外一般是无效的。这是因为,以接收者标识符代表的接收者的值实际上也是当前方法所属的数据类型的当前值的一个复制品。对于值方法来说,由于这个接收者的值就是一个当前值的复制品,所以对它的改变并不会影响到当前值。而对于指针方法来说,这个接收者的值则是一个当前值的指针的复制品。因此,依据这个指针来对当前值做变更,就等于直接对该值进行了改变。
对于上面的第一条规则,我们在实际编程过程中可能会遇到这种情况:虽然自定义数据类型的方法集合中不包含与它关联的指针方法,但是我们仍然能够通过这个类型的值调用到它的指针方法。其实,我们在3.1.6节讲调用表达式的时候已经说明原因。还记得由 (&s).m()表示的速记法吗?还不清楚的读者可以翻回到前面温习一下。
另外,上面的第二条规则有个例外:接收者的类型如果是引用类型的别名类型,那么在该类型值的值方法中对该值的改变也是对外有效的。在前面讲字典类型的时候我们说过,切片类型和字典类型都属于引用类型。除此之外,通道类型也属于引用类型,这在第7章会讲到。
请读者牢记上面的规则,我们在自己编写自定义数据类型及其方法或者对其他自定义数据类型的方法进行调用的时候都需要依照它们。
至此,我们几乎介绍了与Go语言的函数和方法相关的全部知识。我们在后面讲接口类型和结构体类型的时候还会再涉及它们。
3.2.6 接口
一个Go语言的接口由一个方法的集合代表。只要一个数据类型(或与其对应的指针类型)附带的方法集合是某一个接口的方法集合的超集,那么就可以判定该类型实现了这个接口。这意味着Go语言对接口的实现是非侵入式的。换句话说,要想实现一个接口,只需要实现其中的所有方法声明即可,而不需要在数据类型上添加任何特殊的标记。此外,一个接口类型的变量,可以与任何实现了这个接口类型的数据类型的值绑定。
一个数据类型可以拥有一个与它关联的方法集合。一个接口的方法集合,就是我们所说的通常意义上的接口——一组操作数据的方式。一个非接口类型的数据类型的方法集合决定了它是否实现了某个或某些接口。在本小节,我们会对Go语言的接口进行说明,并且介绍在一个非接口类型的数据类型上附加方法和实现接口的基本知识。
1. 类型表示法
接口由方法集合代表。相应地,接口类型的声明由若干个方法的声明组成。方法的声明由方法名称和方法签名构成。我们之前说过,方法是函数的一种。因此,这里的方法签名的编写规则和约束与函数签名完全一致。另外,在一个接口类型的声明中不允许出现重复的方法名称。
接口类型是一个很宽泛的概念,它是所有自定义的接口类型的统称。与其他自定义数据类型一样,我们在声明一个自定义的接口类型的时候要以关键字type
作为开始。除此之外,接口类型声明还包含了接口类型的名称、关键字interface
和由花括号“{”和“}”括起来的方法声明的集合。我们以标准库代码包sort
中的接口类型Interface
为例,其声明如下:
type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}
为了突出重点,我们去掉了该声明中的注释行。可以看到,这个接口类型的声明中包含了3个方法声明。每个方法声明都独占一行。
顺便提一句,只要一个数据类型实现了代码包sort
中的接口类型Interface
,就能够使用这个代码包中的函数对该数据类型的值进行排序。我们在下一章的实战演练环节中会用到它们。
至此,我们已经解释了编写一个接口类型声明所需的一切。如果读者已经理解了我们之前所讲的关于类型和函数的知识的话,那么一定会感觉接口类型的声明编写起来非常简单。
除此之外,我们还可以将一个接口类型嵌入到另一个接口类型中。请看下面的接口类型声明:
type Sortable interface {
sort.Interface
Sort()
}
在接口类型Sortable
中,我们嵌入了sort
中的接口类型Interface
。这是通过在前者的声明中加入代表后者的限定标识符来实现的。这个限定标识符与方法声明一样,需要独占一行。
接口类型Sortable
实际上包含了4个方法声明,它们的名称分别为Len
、Less
、Swap
和Sort
。其中前3个方法声明其实都是被包含在接口类型sort.Interface
中的。这就是在一个接口类型声明中嵌入另一个接口类型声明的作用——将一个接口的方法集合中的方法批量地添加到另一个接口的方法集合中。实际上,在一些编程语言中,这种嵌入常被叫作接口间的继承。所以,也可以说接口类型Sortable
继承了接口类型sort.Interface
。
Go语言并不提供典型的类型驱动的子类化方法,但是却靠这种嵌入的方式实现了同样的效果。类型嵌入同样体现了非侵入式的风格。它同样适用于结构体类型。请记住,一个接口类型只接受其他接口类型的嵌入。
关于接口类型的嵌入,有一个约束,那就是不能嵌入自身。这包括直接的嵌入和间接的嵌入。直接的嵌入如下:
type Interface1 interface {
Interface1
}
而间接的嵌入则像这样:
type Interface2 interface {
Interface3
}
type Interface3 interface {
Interface2
}
错误的接口类型嵌入会造成编译错误。另外,当前接口类型中声明的方法也不能与任何被嵌入其中的接口类型的方法重名,否则也会造成编译错误。
最后值得一提的是interface{}
。它是Go语言自身定义的一个特殊的接口类型——空接口。 空接口不包含任何方法声明的接口。也正因为如此,Go语言中所有数据类型都是它的实现。
2. 值表示法
严格来说,Go语言的接口类型没有相应的值表示法,因为接口是规范而不是实现。但是,我们在本小节开头说过,一个接口类型的变量可以被赋予任何实现了这个接口类型的数据类型的值。从这个角度讲,接口类型的值可以由任何其他数据类型的值来表示。
3. 属性和基本操作
接口的最基本属性就是它们的方法集合。关于方法集合在接口中起到的作用,我们在前面的内容中已经有所说明。因此,在这里我们重点介绍怎样编写接口类型的实现。
实现一个接口类型的可以是任何自定义的数据类型,只要这个数据类型附带的方法集合是该接口类型的方法集合的超集。
我们现在编写一个自定义的数据类型SortableStrings
:
type SortableStrings [3]string
这个自定义数据类型相当于[3]string
类型的一个别名类型。我们想让这个自定义数据类型实现sort.Interface
接口类型。这需要我们实现sort.Interface
中声明的全部方法。这些方法的实现都需要以类型SortableStrings
为接收者的类型。这些方法的声明如下:
func (self SortableStrings) Len() int {
return len(self)
}
func (self SortableStrings) Less(i, j int) bool {
return self[i] < self[j]
}
func (self SortableStrings) Swap(i, j int) {
self[i], self[j] = self[j], self[i]
}
有了上面这3个方法声明,SortableStrings
类型就已经是一个sort.Interface
接口类型的实现了。下面我们来验证一下。还记得我们在上一节中介绍过的类型断言表达式吗?我们就用它来进行这项验证:
_, ok := interface{}(SortableStrings{}).(sort.Interface)
注意,要想让这条语句编译通过,首先需要导入代码包sort
。我们可以看到,赋值语句的右边就是一个类型断言表达式,左边的两个标识符代表了这个表达式的求值结果。但是,我们在这里不关心类型转换后的结果,而只关注类型转换成功与否,所以第一个标识符为空标识符“_
”。标识符ok
代表了一个布尔类型的变量。在这里,这个变量的值一定是true
,因为SortableStrings
类型确实实现了接口类型sort.Interface
中声明的所有方法。
上述方式是验证一个数据类型是否是某个接口类型的实现的最简单也是最直接的方法。
一个接口类型可以被任意数量的数据类型实现。反过来讲,一个数据类型也可以同时实现多个接口类型。上面的自定义数据类型SortableStrings
也可以实现接口类型Sortable
,只要我们再编写一个这样的方法声明就可以了:
func (self SortableStrings) Sort() {
sort.Sort(self)
}
现在,SortableStrings
类型在实现了接口类型sort.Interface
的同时也实现了接口类型Sortable
。这意味着下面这条语句中的变量ok2
也会被赋予布尔值true
:
_, ok2 := interface{}(SortableStrings{}).(Sortable)
现在,我们把SortableStrings
类型包含的Sort
方法中的接收者类型由SortableStrings
改为*SortableStrings
。也就是说,我们把这个函数的接收者类型改为了与SortableStrings
类型对应的指针类型。这种情况下,SortableStrings
类型就不再是接口类型Sortable
的实现了。也就是说,变量ok2
的值现在应该是false
。因为,方法Sort
不再是一个值方法了,我们把它变成了一个指针方法。这个时候,只有与SortableStrings
类型的值对应的指针值才能够通过上面的类型断言,如下所示:
_, ok3 := interface{}(&SortableStrings{}).(Sortable)
这条语句执行后,变量ok3
的值将会是true
。这也印证了我们之前讲到过的与值方法和指针方法有关的规则。
不过,SortableStrings
类型还有存在一个很大的问题。请看下面的测试代码:
ss := SortableStrings{"2", "3", "1"}
ss.Sort()
fmt.Printf("Sortable strings: %v\n", ss)
在上面的测试代码中,我们用到了代码包fmt
。这是一个Go语言标准库中的代码包,其中包含了很多用于将目标字符串打印到各种输出上的函数。因此,要想让上面的几条语句通过编译,我们需要先导入代码包fmt
。代码包fmt
中的Printf
函数也用于打印字符串,不过它比Print
函数和Println
函数更加灵活。关于fmt
包及其中函数的更多信息,请读者查阅Go语言官方文档网站(http://godoc.org)上的相关信息。
在我们执行上面的测试代码后,计算机屏幕上会显示这样一行信息:
Sortable strings: [2 3 1]
其中[2 3 1]
是SortableStrings
类型值的字符串表示。SortableStrings
类型是[3]string
类型的别名类型,因此它沿用了[3]string
类型值的字符串表示方式。从上面的字符串表示来看,变量ss
的值并没有被排序,但是我们在打印它之前已经调用过Sort
方法了,这是怎么回事呢?
我们在上一小节说过,在值方法中,对接收者的值的改变在该方法之外是不可见的。在上面的示例中,SortableStrings
类型的Sort
方法实际上是通过函数sort.Sort
来对接收者的值进行排序的。sort.Sort
函数接受一个类型为sort.Interface
的参数值,并利用这个值的方法Len
、Less
和Swap
来修改其参数中的各个元素的位置以完成排序工作。再来看SortableStrings
类型,虽然它实现了接口类型sort.Interface
中声明的全部方法,但是这些方法都是值方法。这使得在这些方法中对接收者值的改变并不会影响到它的源值。因为,它们只是改变了源值的某个复制品。这就是Sort
方法失效的真正原因。这个问题在我们不了解sort.Sort
函数的内部运作机制时是不容易定位的。当我们把SortableStrings
类型的方法Len
、Less
和Swap
的接收者类型都改为*SortableStrings
之后,这个问题就会得到解决。但是,这时的SortableStrings
类型就已经不再是接口类型sort.Interface
的实现了。因此,前面示例代码中变量ok
的值会变为false
。
至此,上述示例代码已经能够完全体现出接口的实现方式以及值方法与指针方法之间的区别了。这里给读者留下一个小题目。
把本小节中的所有示例代码都放到一个命令源码文件中,并使用
go run
命令运行该文件中的代码。然后,根据上面的描述改动这些代码,并利用代码包fmt
中的函数打印出变量ok
、ok2
和ok3
以及在调用表达式ss.Sort()
被求值前后的ss
的值。
希望读者能够通过体会这些变量的值的变化,更深刻地理解与接口实现、值方法和指针方法有关的概念、规则和实际意义。
现在,我们再对SortableStrings
的类型声明稍作改动:
type SortableStrings []string // 去掉了方括号中的3。
这会产生哪些变化呢?请读者先自己试验一下,修改SortableStrings
的类型声明并再次编译或运行这些代码。
这实际上是将SortableStrings
由数组类型的别名类型改为了切片类型的别名类型。这使得与之关联的方法无法通过编译。
其中与索引表达式有关的错误,是由于索引表达式不能被应用在指向切片值的指针类型值上。这方面的说明我们在上一节讲索引表达式的时候已经提到。又由于内建函数len
的参数也不能是指向切片值的指针类型值,所以与SortableStrings
类型关联的Len
方法中的代码也会造成一个编译错误。
上面这两个问题的解决方法非常简单,即将方法Len
、Less
、Swap
和Sort
的接收者类型都由*SortableStrings
改回SortableStrings
。不用担心,我们在上一小节讲值方法和指针方法的选择规则的时候说过,对于引用类型的别名类型来说,值方法对接收者值的改变也会反映在其源值上。因此,经过上面的变更之后,SortableStrings
类型的这些方法的功能并不会受到任何影响。在进行了这些修改之后,读者应该再次运行包含了前面所有示例代码的那个命令源码文件,然后看看标准输出上出现的那些内容。
我们会在下一小节介绍结构体类型,它是Go语言中最灵活的一种数据类型。并且,与某个数据类型的别名类型相比,使用结构体类型来实现接口类型是更常用的一种做法。
3.2.7 结构体
结构体类型既可以包含若干个命名元素(又称为字段),又可以与若干个方法相关联。从面向对象编程的角度看,结构体类型中的字段代表了该类型的属性,而与它关联的方法则可以看作是针对这些属性的操作。
1. 类型表示法
结构体类型的声明可以包含若干个字段的声明。字段的声明由两个标识符组成,左边的标识符表示了该字段的名称,右边的标识符则代表了该字段的类型,两个标识符之间需用空格“ ”分隔。通常情况下,结构体类型声明中的每个字段声明都独占一行。并且,同一个结构体类型声明中的字段之间不能出现重名的情况。
与函数类型一样,结构体类型也分为命名结构体类型和匿名结构体类型。我们先来讨论命名结构体类型。
命名结构体类型的声明总是以关键字type
开始,并依次包含结构体类型的名称、关键字struct
和由花括号“{”和“}”括起来的字段声明列表。请看下面的示例:
type Sequence struct {
len int
cap int
Sortable
sortableArray sort.Interface
}
任何数据类型都可以成为结构体类型的字段的类型。当字段名称的首字母是大写字母时,我们就可以在任何位置(包括其他代码包)上通过其所属的结构体类型的值(下简称结构体值)和选择表达式访问到它们。否则,这些字段就是包级私有的。我们只有在该结构体声明所属的代码包中才能够对它们进行访问或者给它们赋值。另外,虽然我们可以把两个类型相同的字段写到同一行中:
len, cap int
但是为了清晰起见,我并不建议这样做。
如果一个字段声明中只有类型而没有指定名称的话,这个字段就叫作匿名字段。结构体类型Sequence
中的Sortable
就是一个匿名字段。匿名字段有时也被称为嵌入式的字段或结构体类型的嵌入类型。在形式上,这种嵌入的方式与接口类型间的嵌入很类似。但是,在意义上,这两种嵌入大不相同。
匿名字段的类型必须由一个数据类型的名称或者一个与非接口类型对应的指针类型的名称代表。更重要的是,代表匿名字段类型的非限定名称将被隐含地作为该字段的名称。如果匿名字段类型是一个指针类型的话,那么这个指针类型所指的数据类型的非限定名称就会被作为该字段的名称。所谓非限定名称就是由非限定标识符代表的名称。非限定标识符与我们在上一节讲过的限定标识符的含义相对立,指的是不包含代码包名称和点“.”的标识符。为了更加清晰地说明匿名类型的隐含名称,我们来看这样一个示例:
type Anonymities struct {
T1
*T2
P.T3
*P.T4
}
这个名为Anonymities
的结构体类型中包含了4个匿名字段。其中,T1
和P.T3
为非指针的数据类型,它们隐含的名称分别为T1
和T3
。T2
和P.T4
为指针类型,它们隐含的名称分别是T2
和T4
。请注意,匿名字段的隐含名称也不能与它所属的结构体类型中的其他字段名称重复。
结构体类型中的嵌入字段比接口类型间的嵌入有着更加复杂的含义。嵌入类型所附带的方法都会无条件地与被嵌入的结构体类型关联在一起,即它们也成为了被嵌入的结构体类型的方法。这意味着,结构体类型自动地实现了它包含的所有嵌入类型所实现的接口类型。但是,请注意,嵌入类型的方法的接收者类型仍然是该嵌入类型,而不是那个被嵌入的结构体类型。当我们在被嵌入的结构体值上调用实际上属于其中某个嵌入类型的方法的时候,这一调用会被自动转发到这个嵌入类型的值上。
现在,我们对Sequence
的声明进行一些改动:
type Sequence struct {
Sortable
sorted bool
}
如上,我们几乎去掉了所有的字段,只留下了Sortable
。现在,存储和操作可排序序列的功能都交给了匿名字段Sortable
。然后,我们又添加了一个布尔类型的字段sorted
,并用它来表示序列类型值是否已被排序。
如果我们有一个Sequence
类型的值seq
,那么就可以直接在这个值上调用Sortable
接口类型中包含的那些方法了,如seq.Sort()
。
假如Sequence
类型中也包含了一个与Sortable
接口类型的Sort
方法的名称和签名都相同的方法的话,那么调用表达式seq.Sort()
就一定是对Sequence
类型值自身附带的Sort
方法的调用。也就是说,在这种情况下,嵌入类型Sortable
的方法Sort
被隐藏了。
这种隐藏会带来一些便利。例如,如果我们需要在原有的排序操作上添加一些额外的功能的话,就可以很方便地声明这样一个同名方法:
func (self *Sequence) Sort() {
self.Sortable.Sort()
self.sorted = true
}
这个与Sequence
类型关联的Sort
方法把排序操作全权委托给了嵌入类型Sortable
的Sort
方法,并且在排序操作完成后还对Sequence
类型的sorted
字段进行了赋值。这就达到了对其匿名字段Sortable
的Sort
方法的功能进行无缝扩展的目的。这在无形中丰富了调用表达式seq.Sort()
的含义。熟悉设计模式的读者可能会由此联想到装饰器模式。
注意,如果这两个Sort
方法的名称相同但签名不同,那么嵌入类型Sortable
的方法Sort
也同样会被隐藏。这时,在Sequence
的类型值上调用Sort
方法的时候,就必须依据被该类型的Sort
方法的签名来编写调用表达式。假设Sequence
类型附带的那个名为Sort
的方法如下所示:
func (self *Sequence) Sort(quicksort bool) {
// 省略若干条语句
}
那么调用表达式seq.Sort()
就会造成一个编译错误,因为那个Sortable
的无参数的Sort
方法已经被隐藏了。我们不能对它进行调用,只能通过seq.Sort(true)
或seq.Sort(false)
来对Sequence
的Sort
方法进行调用。不过,我们总是可以使用调用表达式seq.Sortable.Sort()
来调用嵌入类型Sortable
的Sort
方法,不论被嵌入类型是否包含了同名的方法。
在上面的描述中,为了简洁,我们一直没有区分嵌入类型是一个非指针的数据类型,还是一个指针类型。实际上这两种情况是有区别的。假设有结构体类型S
和非指针类型的数据类型T
,则:
如果在
S
中包含了一个嵌入类型T
,那么S
和S
的方法集合中都会包含接收者类型为T
的方法。除此之外,S
的方法集合中还会包含接收者类型为*T
的方法。如果在
S
中包含了一个嵌入类型T
,那么S
和S
的方法集合中都会包含接收者类型为T
或*T
的所有方法。
其中S
和T
分别代表了指向S
的指针类型和指向T
的指针类型。与指针类型相关的知识我们会在下一小节介绍。
现在我们来讨论另外一个问题。对于嵌入类型的字段来说,我们同样可以像访问被嵌入的结构体类型的字段那样来访问它们。假设,我们有一个名为List
的结构体类型,并且在它的声明中嵌入了类型Sequence
:
type List struct {
Sequence
}
那么对于List
类型的值list
来说,选择表达式list.sorted
就代表着对嵌入的Sequence
类型值的字段sorted
的访问。但是,如果List
类型也有一个名称为sorted
的字段的话,那么其中的Sequence
类型值的sorted
字段就会被隐藏。选择表达式list.sorted
只代表对List
类型的sorted
字段的访问。不论这两个名称为sorted
的字段的类型是否相同,都会是这样。然而,我们同样可以通过选择表达式list.Sequence.sorted
访问到嵌入类型Sequence
的值的sorted
字段。
对于结构体类型的多层嵌入来讲,上述的规则同样适用。只要记住以下两点。
我们可以在被嵌入的结构体类型的值上像调用它自己的字段或方法那样调用任意深度的嵌入类型值的字段或方法。唯一的前提条件就是这些嵌入类型的字段或方法没有被隐藏。如果它们被隐藏,我们也可以通过链式的选择表达式或调用表达式访问或调用它们,如
list.Sequence.sorted
。被嵌入的结构体类型的字段或方法可以隐藏任意深度的嵌入类型的同名字段或方法。这包括,任何较浅层次的嵌入类型的字段或方法都会隐藏较深层次的嵌入类型包含的同名的字段或方法。注意,这种隐藏是可以交叉进行的,即字段可以隐藏方法,方法也可以隐藏字段,只要它们的名称相同即可。
此外,如果在同一嵌入层次中的两个嵌入类型拥有同名的字段或方法,那么涉及它们的选择表达式或调用表达式将会造成一个编译错误。因为编译器不能确定被选择或调用的目标。
好了,对结构体类型的嵌入类型的讨论就到这里。
现在我们来说说匿名结构体类型。匿名结构体类型比命名结构体类型少了关键字type
和类型名称,它的声明如下:
struct {
Sortable
sorted bool
}
匿名结构体类型在类型特性和声明规则方面与命名结构体类型是完全一致的。我们在前面提到过,可以在数组类型、切片类型或字典类型的声明中,将一个匿名的结构体类型作为它们的元素的类型。除此之外,我们还可以直接将匿名结构体类型作为一个变量的类型,如:
var anonym struct {
a int
b string
}
不过,更常用的做法是在声明以匿名结构体类型为类型的变量的同时对其初始化:
anonym := struct {
a int
b string
}{0, "string"}
与命名结构体类型相比,匿名结构体类型更像是“一次性”的类型。它不具有通用性,因此它常常被用在临时数据存储和传递的场景中。
最后值得一提的是,我们可以在结构体类型声明中的字段声明的后面添加一个字符串字面量标签,以作为对应字段的附加属性,如下所示:
type Person struct {
Name string `json:"name"`
Age uint8 `json:"age"`
Address string `json:"addr"`
}
如上所示,字段的字符串字面量标签一般由两个反引号“`”包裹的任意字符串组成。并且,它应该被添加在与其对应的字段的同一行的最右侧。在通常情况下,这种标签对于使用该结构体类型及其值的代码来说是不可见的。但是,我们可以用标准库代码包reflect
中提供的函数查看到结构体类型中字段的标签。因此,这种标签常常会在一些特殊应用场景下使用,比如,标准库代码包encoding/json
中的函数会根据这种标签的内容确定与该结构体类型中的字段对应的JSON节点的名称。
2. 值表示法
结构体值一般由复合字面量来表达。我们之前说过,复合字面量由类型字面量和由花括号“{”和“}”括起来的若干键值对组成。对于结构体值来讲,键值对中的键就是结构体类型中某个字段的名称,而值(或称元素)就是要赋给该字段的那个值。我们常常把用于表示结构体值的复合字面量简称为结构体字面量。在同一个结构体字面量中,一个字段名称只能出现一次。也就是说,我们只能在结构体字面量中对同一个字段赋值一次。以上面创建的结构体类型Sequence
为例,我们可以这样来表示它的值:
Sequence{Sortable: SortableStrings{"3", "2", "1"}, sorted: false}
类型SortableStrings
实现了接口类型Sortable
,因此我们可以把一个SortableStrings
类型的值赋给Sortable
字段。另外,我们将false
赋给了字段sorted
。
编写结构体字面量的方法不止一种。我们还可以忽略掉字段的名称,也就是说不添加结构体字面量中的键值对的键。不过,在这种情况下会有两个限制。
- 如果想要省略掉其中某个或某些键值对的键,那么其他的键值对的键也必须省略。也就是说,要么给出其中的每个值所对应的字段的名称,要么省略掉所有的字段名称。例如:
Sequence{SortableStrings{"3", "2", "1"}, sorted: false}
是不合法的,因为我们只指定了部分的值所对应字段的名称。这会造成编译错误。
- 多个字段值之间的顺序应该与结构体类型声明中的字段声明的顺序一致,并且不能够省略掉对任何一字段的赋值。这种限制对于不省略字段名称的字面量来说是不存在的。例如:
Sequence{sorted: false, Sortable: SortableStrings{"3", "2", "1"}}
和
Sequence{Sortable: SortableStrings{"3", "2", "1"}}
都是合法的结构体字面量。未被明确赋值的字段的值将会被其类型的零值填充。但是,
Sequence{false, SortableStrings{"3", "2", "1"}}
和
Sequence{SortableStrings{"3", "2", "1"}}
都是不合法的。它们都会使Go语言编译器报错。
此外,我们也可以在结构体字面量中不指定任何字段的值。例如,我们可以这样表示Sequence
类型的值:
Sequence{}
这种情况下,此值中的两个字段都会被赋予它们所属类型的零值。当然,我们也可以在之后改变这些字段的值,不过这需要在字段访问权限允许的情况下进行。也就是说,当字段名称的首字母是小写字母时,我们只能在结构体类型声明所属的代码包中访问到该类型的值中的字段,或者对它们进行赋值。这对于结构体字面量来说也是一样的。当结构体字面量处于其类型声明所属的代码包之外时,我们是不能对其中的名称首字母为小写字母的字段进行初始化的。
与数组类型相同,结构体类型属于值类型。结构体值的零值就是我们刚刚提到的那个不为任何字段赋值的结构体字面量。
3. 属性和基本操作
一个结构体类型的属性就是它所包含的字段和与它关联的方法。在访问权限允许的情况下,我们可以使用选择表达式访问结构体值中的字段,也可以使用调用表达式调用结构体值关联的方法。关于它们,我们刚刚已经介绍得足够多了。
需要强调的是,在Go语言中,只存在嵌入而不存在继承的概念。因此,我们不能把在前面声明的那个List
类型的值赋给一个Sequence
类型的变量。这样的赋值语句会造成一个编译错误。
另外,在一个结构体类型的别名类型的值上,我们既不能调用那个结构体类型的方法,也不能调用与那个结构体类型对应的指针类型的方法。这也是由于在Go语言中没有继承这种说法。别名类型也不是被它“别名”的那个数据类型(也可以称之为别名类型的源类型)的子类型。但是,别名类型内部的结构会与它的源类型一致。比如我们在前面提到过的结构体类型SortableStrings
。还记得吗?它的最新版本的声明是这样的:
type SortableStrings []string
可以确定的是,类型SortableStrings
的内部结构是与元素类型为string
的切片类型是一致的。正因为如此,我们才可以把一个SortableStrings
类型的值像下面这样转换为一个[]string类型的值:
[]string(SortableStrings{"4", "5", "6"})
反之亦然:
SortableStrings([]string{"4", "5", "6"})
对于一个结构体类型的别名类型来说,它拥有源类型的全部字段。但是,就像刚才说得那样,这个别名类型并没有继承与它的源类型关联的任何方法。下面举个例子。
如果我们没有把Sequence
类型嵌入到List
类型当中,而是把List
类型作为Sequence
类型的一个别名类型,那么它的声明将会是这样:
type List Sequence
这时,List
类型的值的表示方法与Sequence
类型的值的表示方法无异:
List{SortableStrings{"4", "5", "6"}, false}
如果有一个List
类型的值list
,那么选择表达式list.sorted
访问的就是这个List
类型的值的sorted
字段。当然,我们也可以通过选择表达式list.Sortable
访问这个值的嵌入字段Sortable
。但是,这个List
类型目前却不包含任何方法。
现在我们翻回来看结构体类型Sequence
。正因为别名类型存在这样的局限性,我们才在Sequence
类型中嵌入了接口类型Sortable
,而不是直接将Sequence
类型声明为一个接口类型Sortable
的某个实现类型(如SortableStrings
类型)的别名类型。不过这样做的原因不只上面这一个。比如,嵌入字段Sortable
能够用于存储所有实现了该接口类型的数据类型的值。这样的类型结构设计使得Sequence
类型可以在一定程度上模拟出泛型类型的一些特点。
泛型是强类型编程语言可以有的一种特性。它允许我们在编写代码时定义一些可变部分。这些可变部分就是泛型的参数,或者说是泛型类型的类型参数。泛型的参数可以代表一类程序实体。比如说,在声明SortableStrings
类型的时候,可以通过设定类型参数来指定哪些类型的值可以作为SortableStrings
类型值的元素值,然后在初始化SortableStrings
类型值的时候再去确定这个具体的类型。这样就可以避免为了支持不同类型的元素而编写多个Sortable
接口类型的实现了。如此一来,SortableStrings
类型就应该被更名为SortableSlice
。因为它允许使用方自己选择具体的元素类型,而不只是把其元素类型固定为string
类型。
然而,非常遗憾,虽然Go语言中很多预定义类型都属于泛型类型(比如数组类型、切片类型、字典类型和通道类型),但它却不支持自定义的泛型类型。比如,我们不能使一个自定义的结构体类型成为泛型类型。因此,我们只能在声明它们的时候就指定好一切,而不能出现任何可变的部分。
为了使Sequence
类型能够部分模拟泛型类型的行为特征,只向它嵌入Sortable
接口类型是不够的。我们需要对Sortable
接口类型进行扩展。记住,不论从修改最小化还是可维护性方面来看,扩展一个接口类型远远要比直接对这个接口类型进行修改要好得多。同时,这也符合“对修改关闭,对扩展开放”的面向对象设计原则。因此,我们应该创建一个新的接口类型,并将Sortable
接口类型嵌入其中。这个新的接口类型的声明如下:
type GenericSeq interface {
Sortable
Append(e interface{}) bool
Set(index int, e interface{}) bool
Delete(index int) (interface{}, bool)
ElemValue(index int) interface{}
ElemType() reflect.Type
Value() interface{}
}
可以看到,接口类型GenericSeq
中声明了用于添加、修改、删除、查询元素,以及获取元素类型的方法。并且,一个数据类型要想实现GenericSeq
接口类型,也必须实现Sortable
接口类型。
现在,我们将嵌入到Sequence
类型的Sortable
接口类型改为GenericSeq
接口类型。新版本的Sequence
类型的声明如下:
type Sequence struct {
GenericSeq
sorted bool
elemType reflect.Type
}
在这个类型声明中,我们还添加了一个reflect.Type
类型(即标准库代码包reflect
中的Type
类型)的字段elemType
。我们要用elemType
字段来缓存GenericSeq
字段中存储的值的元素类型。
另外,为了能够在改变GenericSeq
字段存储的值的过程中及时对字段sorted
和elemType
的值进行修改,我们还创建了几个与Sequence
类型关联的方法,它们的声明如下:
func (self *Sequence) Sort() {
self.GenericSeq.Sort()
self.sorted = true
}
func (self *Sequence) Append(e interface{}) bool {
result := self.GenericSeq.Append(e)
// 省略部分代码
self.sorted = false
// 省略部分代码
return result
}
func (self *Sequence) Set(index int, e interface{}) bool {
result := self.GenericSeq.Set(index, e)
// 省略部分代码
self.sorted = false
// 省略部分代码
return result
}
func (self *Sequence) ElemType() reflect.Type {
// 省略部分代码
self.elemType = self.GenericSeq.ElemType()
// 省略部分代码
return self.elemType
}
仔细的读者可能会发现,这些方法分别与接口类型GenericSeq
或Sortable
中声明的某个方法有着相同的方法名称和方法签名。也就是说,我们通过这种方式隐藏了GenericSeq
字段中存储的值的这些同名方法,并达到了对它们进行无缝扩展的效果。之所以说无缝,是因为这对于方法的调用方来说完全是透明的。方法调用方不用修改任何代码就可以获得这种扩展所带来的一切好处。这也是我们让这些方法的签名分别与其隐藏的那个方法的签名完全一致的原因。
到这里,我们描述了新版本的Sequence
类型以及相关的接口类型和方法的一部分。这部分代码运用到了我们至此讲述的很多知识。当然,在初始化Sequence
类型值的时候,我们还需要用到一个GenericSeq
接口类型的实现类型。不过,我们就不在此赘述了。关于这个实现类型以及Sequence
类型的完整实现代码,请参见与本书配套的goc2p项目中的seq.go文件。这个文件在该项目的src/basic目录中。
结构体类型是Go语言中最复杂的一个数据类型,尤其是在与类型嵌入有关的一些概念和规则方面。同时,结构体类型也是我们在实际开发过程中最常用的一种数据类型。在大多数场景中,它比那些预定义数据类型的别名类型更适合作为接口类型的实现。另外,它还是Go语言支持面向对象编程的主要体现。
3.2.8 指针
指针是一个代表着某个内存地址的值。这个内存地址往往是在内存中存储的另一个变量的值的起始位置。Go语言对指针的支持介于Java语言和C/C++语言之间。它既没有像Java语言那样取消了代码对指针的直接操作的能力,也避免了C/C++语言中由于对指针的滥用而造成的安全和可靠性问题。
Go语言的指针类型指代了指向一个给定类型的变量的指针。它常常被称为指针的基本类型。指针类型是Go语言的复合类型之一。
1. 类型表示法
我们可以通过在任何一个有效的数据类型的左边插入符号来得到与之对应的指针类型。例如,一个元素类型为
int
的切片类型所对应的指针类型是[]int
,而我们前文所提到的结构体类型Sequence
所对应的指针类型是Sequence
。注意,如果代表类型的是一个限定标识符(如sort.StringSlice
),那么表示与其对应的指针类型的字面量应该是sort.StringSlice
,而不是sort.*StringSlice
。
此外,在Go语言中还有一个专门用于存储内存地址的类型uintptr
。实际上,uintptr
类型与int
类型和uint
类型一样,也属于数值类型。它的值是一个能够保存一个指针值的32位或64位(与程序运行在怎样的计算架构之上有关)无符号整数。也可以说,它的值是指针类型值(以下简称指针值)的位模式(bit pattern)形式。我们在对指针值进行操作的时候会用到uintptr
类型。
2. 值表示法
如果一个变量v
的值是可寻址的,那么我们可以使用取址操作符&
取出与这个值对应的指针值。也就是说,表达式&v
就代表了指向变量v
的值的指针值。关于取址操作符&
的用法说明请参看3.1.5节。
这里需要特别解释一下“可寻址的”这个词的含义。如果某个值确实被存储在了计算机内存中,并且有一个内存地址可以代表这个值在内存中存储的起始位置,那么我们就可以说这个值以及代表它的变量是可寻址的。
3. 属性和基本操作
指针类型理所当然地属于引用类型。它的零值是nil
。
现在我们来看看在Go语言中都可以对指针值进行哪些操作。提到对指针值的操作就不得不从标准库代码包unsafe
讲起。从名称上就可以看出,代码包unsafe
中提供的都是不安全的操作。这些操作绕过了(或者说违反了)Go语言的类型安全机制。因此,我们必须仔细审查使用了代码包unsafe
中的程序实体的那些代码,以确保它们的类型安全性。
在代码包unsafe
中,有一个名为ArbitraryType
的类型。从类型声明上看,它是int
类型的一个别名类型。但是,正如其名,它实际上可以代表任意的Go语言表达式的结果类型。事实上,它也并不算是unsafe
包的一部分。在这里声明它仅出于代码文档化的目的。
除了上面这个文档性质的类型之外,unsafe
包还声明了一个名为Pointer
的类型。unsafe.Pointer
类型代表了ArbitraryType
类型的指针类型。这里有4个与unsafe.Pointer
类型相关的特殊转换操作。
- 一个指向其他类型值的指针值都可以被转换为一个
unsafe.Pointer
类型值。例如,如果有一个float32
类型的变量f32
,那么我们可以这样将与它的对应的指针值转换为一个unsafe.Pointer
类型的值:
pointer := unsafe.Pointer(&f32)
其中,在特殊标记:=
右边的就是用于进行转换操作的调用表达式。取址表达式&f32
的求值结果是一个*float32
类型的值。
- 一个
unsafe.Pointer
类型值可以被转换为一个与任何类型对应的指针类型的值。下面的代码用于将pointer
的值转换为与指向int
类型值的指针值,并赋值给变量vptr
:
vptr := (*int)(pointer)
注意,在特殊标记:=
右边的这个调用表达式中,左边的圆括号被用于优先运算,即让Go语言把int
看作一个类型,或者说一个整体。而右边的圆括号及其中的内容就代表着需要将变量pointer
所代表的值转换为右边圆括号中的那个类型的值。更加需要注意的是,int
类型与float32
类型在内存中的布局是不同的!如果我们在它们之上直接进行类型转换(对应的表达式为(
int)(&f32)
)是行不通的,会造成编译错误。当我们使用unsafe.Pointer
类型作为中转类型的时候,上面的转换操作看起来好像没什么问题。但是,我们一定会在使用取值表达式vptr
的时候遇到问题。对于内存上的同一段数据,把它分别作为int
类型的值和float32
类型的值来解析所得出的结果会是完全不同的。因此,在这种情况下,对取值表达式vptr
的求值肯定会产生一个不正确的结果。另外,在有些时候,它还会引发一个运行时恐慌。比如,如果我们把对变量vps
的赋值语句改为
vptr := (*string)(pointer)
那么对取值表达式*vptr
的求值就会引发一个运行时恐慌。
- 一个
unsafe.Pointer
类型值可以被转换为一个uintptr
类型的值。例如:
uptr := uintptr(pointer)
- 一个
uintptr
类型的值也可以被转换为一个unsafe.Pointer
类型值。例如:
pointer2 := unsafe.Pointer(uptr)
也正因为存在这些特殊转换操作,unsafe.Pointer
类型使程序绕过Go语言的类型系统并在任意的内存地址上进行读写操作成为了可能。这是非常危险的!必须万分小心地使用它!
另外,我们还可以利用上述特殊转换操作以及unsafe包
中声明的Offsetof
函数进行有限的指针运算。
我们以之前提到过的结构体类型Person
为例,它的声明是这样的:
type Person struct {
Name string `json:"name"`
Age uint8 `json:"age"`
Address string `json:"addr"`
}
现在,我们来初始化它的值,并把它的指针值赋给变量pp
:
pp := &Person{"Robert", 32, "Beijing, China"}
然后,我们利用上述特殊转换操作中的第一条和第三条获取这个结构体值在内存中的存储地址:
var puptr = uintptr(unsafe.Pointer(pp))
变量puptr
的值就是存储上面那个Person
类型值的内存地址。由于类型uintptr
的值实际上是一个无符号整数,所以我们可以在该类型的值上进行任何算术运算。例如:
var npp uintptr = puptr + unsafe.Offsetof(pp.Name)
这里我们用到了unsafe
包中的Offsetof
函数。unsafe.Offsetof
函数会返回作为参数的某字段(由相应的选择表达式表示)在其所属的结构体类型之中的存储偏移量。换句话说,该函数的结果值就是在内存中从存储这个结构体值的起始位置到存储其中某字段的值的起始位置之间的距离。这个存储偏移量(或者说距离)的单位是字节,它的值的类型是uintptr
。实际上,同一个结构体类型的值在内存中的存储布局是固定的。也就是说,对于同一个结构体类型和它的同一个字段来说,这个存储偏移量总是相同的。
我们现在知道了存储上面那个Person
类型值的内存地址,也知道了它的存储起始位置到其中的Name
字段值的存储偏移量。依此,我们把它们相加就会得到存储这个结构体值中的Name
字段值的内存地址。在上例中,我们把表示这个内存地址的值赋给了uintptr
类型的变量npp
。
在获得了这个存储Name
字段值的内存地址之后,我们可以利用上述特殊转换操作中的第二条和第四条将它还原成指向这个Name
字段值的指针类型值。代码如下:
var name *string = (*string)(unsafe.Pointer(npp))
这样一来,我们就可以很方便地通过取值表达式*name
获取到这个Name
字段的值了。它就是我们在之前初始化那个结构体值的时候赋给它的Name
字段的字符串值"Robert"
。
这里有一个恒等式可以对上述示例中的一些操作进行很好的总结:
uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f) == uintptr(unsafe.Pointer(&s.f))
原则上,我们只要获得了存储某个值的内存地址,就可以通过一定的算术运算得到存储在其他内存地址上的值甚至程序。只要能够获取到它们,对它们进行修改就不是一件难事。
在Go语言中的指针运算是相对繁琐的。这也体现了Go语言只对指针运算提供有限支持的策略。除非确实有必要,否则我们不提倡使用uintptr
类型和unsafe
包中声明的那些程序实体进行指针运算。但是,与某个数据类型对应的指针类型确实是会常常用到的。在很多应用场景中,它们也是被推荐使用的(如有必要请回顾3.2.5、3.2.6和3.2.7节)。
3.2.9 数据初始化
这里的数据初始化是指对某个数据类型的值或变量的初始化。在Go语言中,几乎所有的数据类型的值都可以使用字面量来进行表示和初始化。我们前面讲各个数据类型的时候都分别进行过详细说明。在大多数情况下,我们使用字面量就可以满足初始化值或变量的要求。
除了前面介绍过的这种数据初始化方式之外,Go语言还为我们提供了两个专门用于数据初始化的内建函数new
和make
。
这两个函数所做的事情是不同的。同时,它们所针对的数据类型也是不同的。这可能会令人迷惑,不过好在其中的规则还是非常简单的。
1. new
让我们先来看看new
函数。new
函数用于为值分配内存。但是与其他编程语言中的new
(比如Java语言中的关键字new
)不同的是,它并不会去初始化分配到的内存,而只会清零它。因此,调用表达式new(T)
被求值时,所做的是为T
类型的新值分配并清零一块内存空间,然后将这块内存空间的地址作为结果返回。而这个结果就是指向这个新的T
类型值的指针值。它的类型为T
。实际上,这个new
函数返回的T
类型值总会指向一个T
类型的零值。例如,调用表达式new(string)
的求值结果指向的是一个string
类型的零值""
,而调用表达式new([3]int)
的求值结果指向的则是一个[3]int
类型的零值[3]int{0, 0, 0}
。
正因为有这种干净的内存分配策略,使得我们可以在用内建函数new
创建某个数据类型的新值之后立刻就可以拿来使用,而不用担心在这个值中会遗留某些初始化的痕迹。我们以标准库代码包bytes
中的结构体类型Buffer
为例。bytes.Buffer
是一个尺寸可变的字节缓冲区。它的零值就是一个立即可用的空缓冲区。因此,调用表达式new(bytes.Buffer)
的求值结果就是一个指向一个空缓冲区的指针值。之后,我们就可以立即在这个缓冲区上进行读写操作了。显然,这与我们的期望相符。我们当然希望一个新的缓冲区中不包含任何残留数据,即使这些残留数据是对它初始化的时候留下的。相似的,标准库代码包sync
中的结构体类型Mutex
也是一个可以new
后即用的数据类型。它的零值就是一个处于未锁定状态的互斥量。
内建函数new
的这种特性为我们提供了一个关于自定义数据类型的可参考的设计规则。例如,在我们自定义一个结构体类型的时候就要考虑到,在其中的每个字段的值都分别为对应类型的零值的时候,这个结构体值就应该已经处于可用的状态。这样,我们在new
它的时候就能够得到一个立即可用的值的指针值,而不需要再做额外的初始化。
当然,在感觉一个类型的零值还无法让它变得可用的时候,我们可以使用相应的字面量来达到分配内存空间并初始化值的目的。前面我们已经讲过,我们可以在字面量中灵活的指定新值中的每一个元素的值。但是要注意,字面量所代表的是该类型的值,而不是指向该类型值的指针值。因此,我们在将它们与调用new
函数的调用表达式做等价替换的时候,还需要在字面量的前面加入取址操作符&
以表示指向该类型值的指针值。
2. make
内建函数make
所做的事情与new
大有不同。make
函数只能被用于创建切片类型、字典类型和通道类型的值,并返回一个已被初始化的(即非零值的)的对应类型的值。这么做的原因是与上面这3个复合类型的特殊结构有关的。我们在之前说过,它们都是引用类型。在它们的每一个值的内部都会保持着一个对某个底层数据结构值的引用。如果不对它们的值进行初始化,那么其中的这种引用关系是不会被建立起来的,同时相关的内部值也会不正确。在这种情况下,该类型的值也就不能够被使用,因为它们是不完整的,还处于未就绪的状态。这就意味着,在创建这3个引用类型的值的时候,必须将内存空间分配和数据初始化这两个步骤绑定在一起。也正是为了保证这些值的可用性,切片类型、字典类型和通道类型的零值都是nil
,而不是那个未被初始化的值。因此,当我们new
这3个引用类型并想创建它们的值的时候,得到的却是一个指向空值nil
的指针值。
除此之外,内建函数make
所接受的参数也与new
函数有所不同。make
函数除了会接受一个表示目标类型的类型字面量之外,还会接受一个或两个额外的参数。
对于切片类型来说,我们可以在把新值的长度和容量也传递给make
函数。例如,调用表达式
make([]int, 10, 100)
创建了一个新的[]int类型的值,这个值的长度为10
、容量为100
。当然,我们也可以省略掉最后一个参数,即不指定新值的容量。这种情况下,该值的容量会与其长度一致。示例如下:
s := make([]int, 10)
变量s
的类型是[]int的,而长度和容量都是10
。
在使用make
函数初始化一个切片值的过程中,该值会引用一个长度与其容量相同且元素类型与其元素类型一致的数组值。这个数组值就是该切片值的底层数组。该数组值中的每个元素都是当前元素类型的零值。但是,切片值只会展现出数量与其长度相同的元素。因此,调用表达式make([]int, 10, 100)所创建并初始化的值就是[]int{0 0 0 0 0 0 0 0 0 0}。
我们在使用make
函数创建字典类型的值的时候,也可以指定其底层数据结构的长度。但是,该字典值只会展示出我们明确“放入”的键值对。例如,调用表达式
make(map[string]int, 100)
所创建和初始化的值会是map[string]int{}
。虽然我们也可以忽略掉那个用于表示底层数据结构长度的参数(像这样:make(map[string]int)
),但是我还是建议:应该在性能敏感的应用场景下,根据这个字典值可能包含的键值对的数量以及“放入”它们的时间,仔细地设置该长度参数。
我们最后提一下对于通道类型(Channel)的值的数据初始化。我们到此为止还没有对通道类型进行过任何说明。不过,在本书的第7章中,我们会详细讲解这个特殊的引用类型。
我们可以这样使用make
函数创建一个通道类型的值:
make(chan int, 10)
其中的第一个参数表示的是通道的类型,而第二个参数则表示该通道的长度。与字典类型相同,第二个参数也可以被忽略掉。但是,忽略它的含义却与针对字典类型的情况有着很大的不同。关于这些知识,我们会在后面专门讲解。
请记住,make
函数只能被应用在引用类型的值的创建上。并且,它的结果是第一个参数所代表的类型的值,而不是指向这个值的指针值。如果我们想要获得该指针值的话,只能在调用make
函数的表达式的求值结果之上应用取址操作符&
,像这样:
m := make(map[string]int, 100)
mp := &m
到目前为止,我们已经介绍了3种创建值的的方法,即使用字面量、调用内建函数new
和调用内建函数make
。它们适用于不同的应用场景。
当然,在某些应用场景中,我们可以有多种选择。例如,在创建一个切片类型的值的时候,我们既可以使用字面量也可以使用make
函数。这种选择的结果往往取决于我们是否需要定制切片值中的某个或某些元素值。又例如,如果我们能够保证一个结构体类型的值在其中字段的值均为零值的情况下就能够处于可用状态的话,那么仅使用new
函数来初始化它与使用字面量进行初始化是基本等价的。不过要注意,这两种方法产生的结果的类型是不同的。
下面我们来总结一下在本小节中描述的一些规则,以便读者参考。
字面量可以被用于初始化几乎所有的Go语言数据类型的值,除了接口类型和通道类型。接口类型没有值,而通道类型的值只能使用
make
函数来创建。如果需要指向值的指针值,那么可以在表示该值的字面量之上进行取址操作。内建函数
new
主要被用于创建值类型的值。调用new
函数的表达式的结果值将会是指向被创建值的指针值,并且被创建值会是其所属数据类型的零值。因此,new
函数不适合被用来创建引用类型的值。其直接的原因是引用类型的值的零值都是nil
,是不可用的。内建函数
make
仅能被用于某些引用类型(切片类型、字典类型和通道类型)的值的创建。它在创建值之后还会对其进行必要的初始化。与new
函数不同,调用make
函数的表达式的结果值将会是被创建的值本身,而不是指向它的指针值。
请读者记住这些规则,这在我们根据具体场景来选择值的初始化方法的时候会非常有用。