Go 有两个分配原语,内置函数 new 和 make。它们的用途不同,适用于不同的类型,这可能会让人困惑,但规则很简单。首先讨论 new。它是一个内置函数,用于分配内存,但与某些其他语言中的同名函数不同,它并不初始化内存,只是清零它。也就是说,new(T) 为类型 T 的新项分配零值存储并返回其地址,返回值的类型为 *T。在 Go 的术语中,它返回一个指向新分配的类型 T 的零值的指针。
数据
使用 new 进行分配
由于
new 返回的内存是零值,因此在设计数据结构时,最好安排每种类型的零值可以在不进一步初始化的情况下使用。这意味着数据结构的用户可以使用 new 创建一个实例并立即开始工作。例如,bytes.Buffer 的文档中指出:“Buffer 的零值是一个准备使用的空缓冲区。”类似地,sync.Mutex 没有显式构造函数或 Init 方法。相反,sync.Mutex 的零值被定义为未锁定的互斥锁。零值可用性属性是可传递的。考虑以下类型声明。
SyncedBuffer 类型的值在分配或声明后可以立即使用。以下代码片段中,p 和 v 都可以正常工作,而无需进一步的初始化。构造函数和复合字面量
有时,零值并不足够,这时需要一个初始化构造函数,例如来自
os 包的示例。这里有很多样板代码。我们可以使用 复合字面量 来简化它,这是一种每次评估时都会创建新实例的表达式。
请注意,与 C 不同,返回局部变量的地址是完全可以的;与变量关联的存储在函数返回后仍然存在。实际上,获取复合字面量的地址每次评估时都会分配一个新实例,因此我们可以将最后两行合并。
复合字面量的字段按顺序排列,必须全部存在。然而,通过将元素显式标记为 field
:value 对,初始化器可以以任何顺序出现,缺失的字段将保留其相应的零值。因此,我们可以这样写:作为限制情况,如果复合字面量不包含任何字段,它将为该类型创建一个零值。表达式
new(File) 和 &File{} 是等价的。复合字面量也可以用于数组、切片和映射,字段标签作为索引或映射键。以下示例中,初始化在
Enone、Eio 和 Einval 的值无关紧要,只要它们是不同的。使用 make 进行分配
回到分配。内置函数
make(T, *args*) 的用途与 new(T) 不同。它仅创建切片、映射和通道,并返回一个 初始化的(而不是 零值的)类型 T 的值(而不是 *T)。这种区别的原因在于,这三种类型在内部代表必须初始化的数据结构的引用。切片,例如,是一个包含指向数据(数组内部)、长度和容量的三项描述符,直到这些项被初始化,切片为 nil。对于切片、映射和通道,make 初始化内部数据结构并准备值以供使用。例如:这会分配一个包含 100 个整数的数组,然后创建一个切片结构,长度为 10,容量为 100,指向数组的前 10 个元素。(在创建切片时,可以省略容量;有关更多信息,请参见切片部分。)相比之下,
new([]int) 返回一个指向新分配的零值切片结构的指针,即指向一个 nil 切片值的指针。以下示例说明了
new 和 make 之间的区别。请记住,
make 仅适用于映射、切片和通道,并且不返回指针。要获取显式指针,可以使用 new 或显式取变量的地址。数组
数组在规划内存的详细布局时非常有用,有时可以帮助避免分配,但主要是切片的构建块,切片将在下一节中讨论。为了为该主题奠定基础,以下是关于数组的一些内容。
Go 中数组的工作方式与 C 有重大差异。在 Go 中:
- 数组是值。将一个数组赋值给另一个数组会复制所有元素。
- 特别是,如果将数组传递给函数,它将接收一个 副本,而不是指向它的指针。
- 数组的大小是其类型的一部分。类型
[10]int和[20]int是不同的。
值属性可能有用,但也可能很昂贵;如果你想要类似 C 的行为和效率,可以传递指向数组的指针。
但即使这种风格也不是 Go 的习惯用法。使用切片更为合适。
切片
切片包装数组,为数据序列提供更通用、更强大和更方便的接口。除非是具有显式维度的项目(例如变换矩阵),否则在 Go 中大多数数组编程都是使用切片而不是简单数组。
切片持有对底层数组的引用,如果将一个切片赋值给另一个切片,两个切片将引用同一个数组。如果一个函数接受切片参数,对切片元素的更改将对调用者可见,类似于传递指向底层数组的指针。
File 类型的 Read 方法签名如下:该方法返回读取的字节数和错误值(如果有)。要读取更大缓冲区
buf 的前 32 个字节,可以对缓冲区进行切片。这种切片操作是常见且高效的。实际上,暂时不考虑效率,以下代码片段也会读取缓冲区的前 32 个字节。
切片的长度可以在仍然适合底层数组的限制内进行更改;只需将其赋值为自身的切片。切片的 容量 可以通过内置函数
cap 访问,报告切片可以假设的最大长度。以下是一个将数据附加到切片的函数。如果数据超出容量,切片会重新分配。返回的切片如下所示:我们必须在之后返回切片,因为尽管
Append 可以修改 slice 的元素,但切片本身(运行时数据结构,包含指针、长度和容量)是按值传递的。附加到切片的概念非常有用,因此被内置函数
append 捕获。为了理解该函数的设计,我们需要一些额外的信息,因此我们稍后会回到这个主题。二维切片
Go 的数组和切片是一维的。要创建等效的 2D 数组或切片,必须定义数组的数组或切片的切片,如下所示:
由于切片是可变长度的,因此每个内部切片的长度可以不同。这在我们的
LinesOfText 示例中是常见的:每一行的长度独立。有时需要分配 2D 切片,这种情况可能在处理像素的扫描行时出现。实现这一点有两种方法。一种是独立分配每个切片;另一种是分配一个单一数组并将每个切片指向其中。使用哪种方法取决于你的应用。如果切片可能增长或缩小,则应独立分配以避免覆盖下一行;如果不是,使用单次分配构造对象可能更有效。以下是两种方法的示例。首先是逐行分配:
现在作为一次分配,切片成行:
映射
映射是一种方便而强大的内置数据结构,将一种类型(键)的值与另一种类型(元素或 值)的值关联。键可以是任何定义了相等操作符的类型,例如整数、浮点数和复数、字符串、指针、接口(只要动态类型支持相等)、结构体和数组。切片不能用作映射键,因为未定义相等性。与切片一样,映射持有对底层数据结构的引用。如果将映射传递给更改映射内容的函数,所做的更改将在调用者中可见。
映射可以使用通常的复合字面量语法构造,使用冒号分隔的键值对,因此在初始化时很容易构建它们。
赋值和获取映射值的语法与对数组和切片的操作类似,只是索引不需要是整数。
尝试使用不在映射中的键获取映射值将返回该类型条目的零值。例如,如果映射包含整数,则查找不存在的键将返回
0。可以通过将映射的值类型设置为 bool 来实现集合。将映射条目设置为 true 以将值放入集合,然后通过简单的索引测试它。有时需要区分缺失条目和零值。
"UTC" 是否存在,还是因为它根本不在映射中而为 0?可以通过一种多重赋值的形式进行区分。出于明显的原因,这被称为“逗号 ok”习惯用法。在此示例中,如果
tz 存在,seconds 将被适当地设置,ok 将为 true;如果不存在,seconds 将被设置为零,ok 将为 false。以下是一个将其与良好的错误报告结合在一起的函数:要在不担心实际值的情况下测试映射中的存在,可以在值的常规变量位置使用 空白标识符 (
_)。要删除映射条目,请使用内置函数
delete,其参数是要删除的映射和键。即使键已经缺失,执行此操作也是安全的。打印
Go 中的格式化打印使用了一种类似于 C 的
printf 家族的风格,但更加丰富和通用。这些函数位于 fmt 包中,名称以大写字母开头:fmt.Printf、fmt.Fprintf、fmt.Sprintf 等等。字符串函数(如 Sprintf 等)返回一个字符串,而不是填充提供的缓冲区。你不需要提供格式字符串。对于
Printf、Fprintf 和 Sprintf,还有另一对函数,例如 Print 和 Println。这些函数不接受格式字符串,而是为每个参数生成默认格式。Println 版本在参数之间插入空格并在输出末尾附加换行符,而 Print 版本仅在两侧的操作数都不是字符串时添加空格。以下示例中的每一行都产生相同的输出。格式化打印函数
fmt.Fprint 等的第一个参数可以是任何实现了 io.Writer 接口的对象;变量 os.Stdout 和 os.Stderr 是熟悉的实例。这里的实现与 C 开始有所不同。首先,数字格式(如
%d)不接受符号或大小标志;相反,打印例程使用参数的类型来决定这些属性。输出为:
如果你只想要默认转换(例如整数的十进制),可以使用通用格式
%v(表示“值”);结果与 Print 和 Println 的输出完全相同。此外,该格式可以打印 任何 值,甚至是数组、切片、结构体和映射。以下是打印前面定义的时区映射的示例。输出为:
对于映射,
Printf 和其他函数按键的字母顺序对输出进行排序。打印结构体时,修改后的格式
%+v 会用字段名称注释结构体的字段,而对于任何值,备用格式 %#v 会以完整的 Go 语法打印该值。输出为:
(注意前面的 & 符号。)该引用字符串格式也可以通过
%q 应用于 string 或 []byte 类型的值。如果可能,备用格式 %#q 将使用反引号。%q 格式也适用于整数和字符,生成单引号的字符常量。还有一个方便的格式 %T,可以打印值的 类型。输出为:
如果你想控制自定义类型的默认格式,只需在该类型上定义一个签名为
String() string 的方法。对于我们的简单类型 T,可以这样定义:将以以下格式打印:
我们的
String 方法能够调用 Sprintf,因为打印例程是完全可重入的,可以以这种方式包装。然而,需要理解这个方法的一个重要细节:不要通过调用 Sprintf 的方式构造 String 方法,以至于无限递归。这种情况可能发生在 Sprintf 调用尝试直接打印接收者作为字符串时,这将再次调用该方法。这个常见且容易犯的错误示例如下。修复起来也很简单:将参数转换为基本字符串类型,因为它没有该方法。
在 初始化部分 中,我们将看到另一种避免这种递归的技术。
另一种打印技术是将打印例程的参数直接传递给另一种打印例程。
Printf 的签名使用类型 ...interface{} 作为最后一个参数,以指定可以在格式之后出现的任意数量的参数(任意类型)。在函数
Printf 内,v 充当类型 []interface{} 的变量,但如果将其传递给另一个可变参数函数,它将表现得像一个常规参数列表。以下是我们之前使用的 log.Println 函数的实现。它将其参数直接传递给 fmt.Sprintln 进行实际格式化。我们在嵌套调用
Sprintln 时写 ...,以告诉编译器将 v 视为参数列表;否则它将仅将 v 作为单个切片参数传递。打印的内容远不止于此。请查阅
fmt 包的 godoc 文档以获取详细信息。顺便说一句,
... 参数可以是特定类型,例如 ...int,用于选择最小值的最小函数:附加
现在我们有了需要解释
append 内置函数设计的缺失部分。append 的签名与我们上面定义的自定义 Append 函数不同。从结构上讲,它像这样:其中 T 是任意给定类型的占位符。实际上,你不能编写一个 Go 函数,其中类型
T 是由调用者确定的。这就是 append 需要编译器支持的原因。append 的作用是将元素附加到切片的末尾并返回结果。需要返回结果,因为与我们手动编写的 Append 一样,底层数组可能会改变。这个简单示例:打印出
[1 2 3 4 5 6]。因此,append 的工作方式有点像 Printf,收集任意数量的参数。但如果我们想要实现我们的
Append 函数,并将一个切片附加到另一个切片呢?很简单:在调用位置使用 ...,正如我们在对 Output 的调用中所做的。以下代码片段产生与上面相同的输出。如果没有
...,它将无法编译,因为类型不匹配;y 不是 int 类型。初始化
尽管在表面上看起来与 C 或 C++ 中的初始化没有太大不同,但 Go 中的初始化更为强大。可以在初始化期间构建复杂结构,并且在初始化对象之间的顺序问题(即使在不同包之间)也会得到正确处理。
常量
Go 中的常量就是常量。它们在编译时创建,即使在函数中作为局部变量定义,也只能是数字、字符(符号)、字符串或布尔值。由于编译时的限制,定义它们的表达式必须是常量表达式,编译器能够计算。例如,
1<<3 是一个常量表达式,而 math.Sin(math.Pi/4) 不是,因为对 math.Sin 的函数调用需要在运行时发生。在 Go 中,枚举常量是通过
iota 计数器创建的。由于 iota 可以是表达式的一部分,并且表达式可以隐式重复,因此很容易构建复杂的值集合。可以将
String 方法附加到任何用户定义类型,使得任意值能够自动格式化自己以便于打印。虽然通常会应用于结构体,但这种技术对于标量类型(如浮点类型 ByteSize)也很有用。表达式
YB 打印为 1.00YB,而 ByteSize(1e13) 打印为 9.09TB。在这里使用
Sprintf 来实现 ByteSize 的 String 方法是安全的(避免无限递归),不是因为转换,而是因为它使用 %f,这不是字符串格式:Sprintf 仅在需要字符串时调用 String 方法,而 %f 需要浮点值。变量
变量可以像常量一样初始化,但初始化器可以是运行时计算的常规表达式。
init 函数
最后,每个源文件可以定义自己的无参
init 函数,以设置所需的状态。(实际上,每个文件可以有多个 init 函数。)而且“最后”意味着最后:init 在包中的所有变量声明评估其初始化器之后调用,而这些初始化器只有在所有导入的包初始化之后才会被评估。除了无法表达为声明的初始化之外,
init 函数的一个常见用途是在实际执行开始之前验证或修复程序状态的正确性。