这是本节的多页打印视图。
点击此处打印.
返回本页常规视图.
Learning Zig 中文翻译
《学习 Zig》系列教程最初由 Karl Seguin 编写,该教程行文流畅,讲述的脉络由浅入深,深入浅出,是入门 Zig 非常不错的选择。因此,Zig 中文社区将其翻译成中文,便于在中文用户内阅读与传播。
初次接触 Zig 的用户可以按序号依次阅读,对于有经验的 Zig 开发者可按需阅读感兴趣的章节。
关于原作者
Karl Seguin 在多个领域有着丰富经验,前微软 MVP,他撰写了大量文章,是多个微软公共新闻组的活跃成员。现居新加坡。他还是以下教程的作者:
可以在 http://openmymind.net 找到他的博客,或者通过 @karlseguin 在 Twitter 上关注他。
翻译原则
技术文档的翻译首要原则是准确,但在准确的前提下如何保证『信、达、雅』?这是个挑战,在翻译本教程时,在某些情况下会根据上下文进行意译,便于中文读者阅读。
最后,感谢翻译者的无私贡献。❤️️
离线阅读
在本仓库的 release 页面会定期将本教程导出为 PDF 格式,读者可按需下载。
读者也可以使用右侧导航栏中的『整节打印』将当前版本教程保存为 PDF 格式。
1 - 前言
欢迎阅读 Zig 编程语言入门指南《学习 Zig》。本指南旨在让你轻松掌握 Zig。本指南假定你已有编程经验,语言不限。
Zig 目前正在紧锣密鼓地开发中,Zig 语言及其标准库都在不断发展。本指南以最新的 Zig 开发版本为目标。不过,部分代码有可能编译不通过。如果你下载了最新版本的 Zig,但在运行某些代码时遇到问题,请提 issue。
2 - 安装 Zig
原文地址:https://www.openmymind.net/learning_zig/#install
Zig 官网的下载页面中包含常见平台的预编译二进制文件。在这个页面上,你可以找到最新开发版本和主要版本的二进制文件。本指南所跟踪的最新版本可在页面顶部找到。
对于我的电脑,我会下载 zig-macos-aarch64-0.12.0-dev.161+6a5463951.tar.xz
。你使用的可能是不同的平台或更新的版本。展开压缩包,这里面会有一个名为 zig
的二进制文件,你可以按照自己喜欢的方式,为其设置别名(alias)或添加到你的路径(PATH)中。
现在,你可以运行 zig zen
和 zig version
来测试是否安装正确。
译者注:建议读者使用版本管理工具来安装 Zig,具体可参考:《Zig 多版本管理》。
1
2
3
4
5
6
7
8
9
10
11
12
| git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
cat <<'EOF' >> $HOME/.bashrc
source "$HOME/.asdf/asdf.sh"
source "$HOME/.asdf/completions/asdf.bash"
EOF
asdf plugin-add zig https://github.com/zigcc/asdf-zig.git
# 安装最新版
asdf install zig latest
asdf global zig latest
zig version
|
3 - 语言概述 - 第一部分
原文地址:https://www.openmymind.net/learning_zig/language_overview_1
Zig 是一种强类型编译语言。它支持泛型,具有强大的编译时元编程功能,并且不包含垃圾收集器。许多人认为 Zig 是 C 的现代替代品。因此,该语言的语法与 C 类似,比较明显的就是以分号结尾的语句和以花括号分隔的块。
Zig 代码如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| const std = @import("std");
// 如果 `main` 不是 `pub` (public),此代码将无法编译
pub fn main() void {
const user = User{
.power = 9001,
.name = "Goku",
};
std.debug.print("{s}'s power is {d}\n", .{user.name, user.power});
}
pub const User = struct {
power: u64,
name: []const u8,
};
|
如果将上述内容保存到 learning.zig
文件,并运行 zig run learning.zig
,会得到以下输出:Goku's power is 9001
。
这是一个简单的示例,即使你是第一次看到 Zig,大概率能够看懂这段代码。尽管如此,下面的内容我们还是来逐行分析它。
请参阅安装 Zig 部分,以便快速启动并运行它。
模块引用
很少有程序是在没有标准库或外部库的情况下以单个文件编写的。我们的第一个程序也不例外,它使用 Zig 的标准库来进行打印输出。 Zig 的模块系统非常简单,只依赖于 @import
函数和 pub
关键字(使代码可以在当前文件外部访问)。
以 @
开头的函数是内置函数。它们是由编译器提供的,而不是标准库提供的。
我们通过指定模块名称来引用它。 Zig 的标准库以 std
作为模块名。要引用特定文件,需要使用相对路径。例如,将 User
结构移动到它自己的文件中,比如 models/user.zig
:
1
2
3
4
5
| // models/user.zig
pub const User = struct {
power: u64,
name: []const u8,
};
|
在这种情况下,可以用如下方式引用它:
1
2
| // main.zig
const User = @import("models/user.zig").User;
|
如果我们的 User
结构未标记为 pub
我们会收到以下错误:'User' is not marked 'pub'
。
models/user.zig
可以导出不止一项内容。例如,再导出一个常量:
1
2
3
4
5
6
7
| // models/user.zig
pub const MAX_POWER = 100_000;
pub const User = struct {
power: u64,
name: []const u8,
};
|
这时,可以这样导入两者:
1
2
3
| const user = @import("models/user.zig");
const User = user.User;
const MAX_POWER = user.MAX_POWER;
|
此时,你可能会有更多的困惑。在上面的代码片段中,user
是什么?我们还没有看到它,如果使用 var
来代替 const
会有什么不同呢?或者你可能想知道如何使用第三方库。这些都是好问题,但要回答这些问题,需要掌握更多 Zig 的知识点。因此,我们现在只需要掌握以下内容:
- 如何导入 Zig 标准库
- 如何导入其他文件
- 如何导出变量、函数定义
代码注释
下面这行 Zig 代码是一个注释:
1
| // 如果 `main` 不是 `pub` (public),此代码将无法编译
|
Zig 没有像 C 语言中类似 /* ... */
的多行注释。
基于注释的文档自动生成功能正在试验中。如果你看过 Zig 的标准库文档,你就会看到它的实际应用。//!
被称为顶级文档注释,可以放在文件的顶部。三斜线注释 (///
) 被称为文档注释,可以放在特定位置,如声明之前。如果在错误的地方使用这两种文档注释,编译器都会出错。
函数
下面这行 Zig 代码是程序的入口函数 main
:
每个可执行文件都需要一个名为 main
的函数:它是程序的入口点。如果我们将 main
重命名为其他名字,例如 doIt
,并尝试运行 zig run learning.zig
,我们会得到下面的错误:'learning' has no member named 'main'
。
忽略 main
作为程序入口的特殊作用,它只是一个非常基本的函数:不带参数,不返回任何东西(void)。下面的函数会稍微有趣一些:
1
2
3
4
5
6
7
8
9
10
| const std = @import("std");
pub fn main() void {
const sum = add(8999, 2);
std.debug.print("8999 + 2 = {d}\n", .{sum});
}
fn add(a: i64, b: i64) i64 {
return a + b;
}
|
C 和 C++ 程序员会注意到 Zig 不需要提前声明,即在定义之前就可以调用 add
函数。
接下来要注意的是 i64
类型:64 位有符号整数。其他一些数字类型有: u8
、 i8
、 u16
、 i16
、 u32
、 i32
、 u47
、 i47
、 u64
、 i64
、 f32
和 f64
。
包含 u47
和 i47
并不是为了测试你是否还清醒; Zig 支持任意位宽度的整数。虽然你可能不会经常使用这些,但它们可以派上用场。经常使用的一种类型是 usize
,它是一个无符号指针大小的整数,通常是表示某事物长度、大小的类型。
除了 f32
和 f64
之外,Zig 还支持 f16
、 f80
和 f128
浮点类型。
虽然没有充分的理由这样做,但如果我们将 add
的实现更改为:
1
2
3
4
| fn add(a: i64, b: i64) i64 {
a += b;
return a;
}
|
a += b
这一行会报下面的错误:不能给常量赋值
。这是一个重要的教训,我们稍后将更详细地回顾:函数参数是常量。
为了提高可读性,Zig 中不支持函数重载(用不同的参数类型或参数个数定义的同名函数)。暂时来说,以上就是我们需要了解的有关函数的全部内容。
结构体
下面这行代码创建了一个 User
结构体:
1
2
3
4
| pub const User = struct {
power: u64,
name: []const u8,
};
|
由于我们的程序是单个文件,因此 User
仅在定义它的文件中使用,因此我们不需要将其设为 pub
。但这样一来,我们就看不到如何将声明暴露给其他文件了。
结构字段以逗号终止,并且可以指定默认值:
1
2
3
4
| pub const User = struct {
power: u64 = 0,
name: []const u8,
};
|
当我们创建一个结构体时,必须对每个字段赋值。例如,在一开始的定义中 power
没有默认值,因此下面这行代码将报错:missing struct field: power
。
1
| const user = User{.name = "Goku"};
|
但是,使用默认值定义后,上面的代码可以正常编译。
结构体可以有方法,也可以包含声明(包括其他结构),甚至可能包含零个字段,此时的作用更像是命名空间。
1
2
3
4
5
6
7
8
9
10
11
12
| pub const User = struct {
power: u64 = 0,
name: []const u8,
pub const SUPER_POWER = 9000;
pub fn diagnose(user: User) void {
if (user.power >= SUPER_POWER) {
std.debug.print("it's over {d}!!!", .{SUPER_POWER});
}
}
};
|
方法只是普通函数,只是说可以用 struct.method()
方式调用。以下两种方法等价:
1
2
3
4
5
| // 调用 user 的 diagnose
user.diagnose();
// 上面代码等价于:
User.diagnose(user);
|
大多数时候你将使用struct.method()
语法,但方法作为普通函数的语法糖在某些场景下可以派上用场。
if
语句是我们看到的第一个控制流。这很简单,对吧?我们将在下一部分中更详细地探讨这一点。
diagnose
在定义 User
类型中,接受 User
作为其第一个参数。因此,我们可以使用struct.method()
的语法来调用它。但结构内的函数不必遵循这种模式。一个常见的例子是用于结构体初始化的 init
函数:
1
2
3
4
5
6
7
8
9
10
11
| pub const User = struct {
power: u64 = 0,
name: []const u8,
pub fn init(name: []const u8, power: u64) User {
return User{
.name = name,
.power = power,
};
}
}
|
init
的命名方式仅仅是一种约定,在某些情况下,open
或其他名称可能更有意义。如果你和我一样,不是 C++ 程序员,可能对 .$field = $value,
这种初始化字段的语法感到奇怪,但你很快就会习惯它。
当我们创建 "Goku"
时,我们将 user
变量声明为 const
:
1
2
3
4
| const user = User{
.power = 9001,
.name = "Goku",
};
|
这意味着我们无法修改 user
的值。如果要修改变量,应使用 var
声明它。另外,你可能已经注意到 user
的类型是根据赋值对象推导出来的。我们也可以这样明确地声明:
1
2
3
4
| const user: User = User{
.power = 9001,
.name = "Goku",
};
|
在有些情况下我们必须显式声明变量类型,但大多数时候,去掉显式的类型会让代码可读性更好。类型推导也可以这么使用。下面这段代码和上面的两个片段是等价的:
1
2
3
4
| const user: User = .{
.power = 9001,
.name = "Goku",
};
|
不过这种用法并不常见。比较常见的一种情况是从函数返回结构体时会用到。这里的类型可以从函数的返回类型中推断出来。我们的 init
函数可能会这样写:
1
2
3
4
5
6
7
| pub fn init(name: []const u8, power: u64) User {
// instead of return User{...}
return .{
.name = name,
.power = power,
};
}
|
就像我们迄今为止已经探索过的大多数东西一样,今后在讨论 Zig 语言的其他部分时,我们会再次讨论结构体。不过,在大多数情况下,它们都是简单明了的。
数组和切片
我们可以略过代码的最后一行,但鉴于我们的代码片段包含两个字符串 "Goku"
和 {s}'s power is {d}\n
,你可能会对 Zig 中的字符串感到好奇。为了更好地理解字符串,我们先来了解一下数组和切片。
数组的大小是固定的,其长度在编译时已知。长度是类型的一部分,因此 4 个有符号整数的数组 [4]i32
与 5 个有符号整数的数组 [5]i32
是不同的类型。
数组长度可以从初始化中推断出来。在以下代码中,所有三个变量的类型均为 [5]i32
:
1
2
3
4
5
6
7
8
9
| const a = [5]i32{1, 2, 3, 4, 5};
// 我们已经在结构体中使用过 .{...} 语法,
// 它也适用于数组
const b: [5]i32 = .{1, 2, 3, 4, 5};
// 使用 _ 让编译器推导长度
const c = [_]i32{1, 2, 3, 4, 5};
|
另一方面,切片是指向数组的指针,外加一个在运行时确定的长度。我们将在后面的部分中讨论指针,但你可以将切片视为数组的视图。
如果你熟悉 Go,你可能已经注意到 Zig 中的切片有点不同:没有容量,只有指针和长度。
1
2
| const a = [_]i32{1, 2, 3, 4, 5};
const b = a[1..4];
|
在上述代码中, b
是一个长度为 3 的切片,并且是一个指向 a
的指针。但是因为我们使用编译时已知的值来对数组进行切片(即 1
和 4
)所以长度 3
在编译时也是已知。 Zig 编译器能够分析出来这些信息,因此 b
不是一个切片,而是一个指向长度为 3 的整数数组的指针。具体来说,它的类型是 *const [3]i32
。所以这个切片的示例被 Zig 编译器的强大推导能力挫败了。
在实际代码中,切片的使用可能会多于数组。无论好坏,程序的运行时信息往往多于编译时信息。不过,在下面这个例子中,我们必须欺骗 Zig 编译器才能得到我们想要的示例:
1
2
3
4
| const a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];
|
b
现在是一个切片了。具体来说,它的类型是 []const i32
。你可以看到,切片的长度并不是类型的一部分,因为长度是运行时属性,而类型总是在编译时就完全已知。在创建切片时,我们可以省略上界,创建一个到要切分的对象(数组或切片)末尾的切片,例如 const c = b[2..]
。
如果我们将 end
声明为 const
那么它将成为编译时已知值,这将导致 b
是一个指向数组的指针,而不是切片。我觉得这有点令人困惑,但它并不是经常出现的东西,而且也不太难掌握。我很想在这一点上跳过它,但无法找到一种诚实的方法来避免这个细节。
学习 Zig 让我了解到,类型具有很强的描述性。它不仅仅是一个整数或布尔值,甚至是一个有符号的 32 位整数数组。类型还包含其他重要信息。我们已经讨论过长度是数组类型的一部分,许多示例也说明了可变性(const-ness)也是数组类型的一部分。例如,在上一个示例中,b 的类型是 []const i32
。你可以通过下面的代码来验证这一点:
1
2
3
4
5
6
7
8
9
| const std = @import("std");
pub fn main() void {
const a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];
std.debug.print("{any}", .{@TypeOf(b)});
}
|
如果我们尝试写入 b
,例如 b[2] = 5
,我们会收到编译时错误:cannot assign to constant.
。这就是因为 b
类型是 const
导致。
为了解决这个问题,你可能会想要进行以下更改:
1
2
| // 将 const 替换为 var
var b = a[1..end];
|
但你会得到同样的错误,为什么?作为提示,b
的类型是什么,或者更通俗地说,b
是什么?切片是指向数组(部分)的长度和指针。切片的类型总是从它所切分的对象派生出来的。无论 b
是否声明为 const
,都是一个 [5]const i32
的切片,因此 b 必须是 []const i32
类型。如果我们想写入 b
,就需要将 a
从 const
变为 var
。
1
2
3
4
5
6
7
8
9
| const std = @import("std");
pub fn main() void {
var a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];
b[2] = 99;
}
|
这是有效的,因为我们的切片不再是 []const i32
而是 []i32
。你可能想知道为什么当 b
仍然是 const
时,这段代码可以执行。这时因为 b
的可变性是指 b
本身,而不是 b
指向的数据。好吧,我不确定这是一个很好的解释,但对我来说,这段代码突出了差异:
1
2
3
4
5
6
7
8
9
| const std = @import("std");
pub fn main() void {
var a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];
b = b[1..];
}
|
上述代码不会编译;正如编译器告诉我们的,我们不能给常量赋值。但如果将代码改成 var b = a[1..end]
,那么代码就是正确的了,因为 b
本身不再是常量。
在了解 Zig 语言的其他方面(尤其是字符串)的同时,我们还将发现更多有关数组和切片的知识。
字符串
我希望我能说,Zig 里有字符串类型,而且非常棒。遗憾的是,它没有。最简单来说,字符串是字节(u8)的序列(即数组或切片)。实际上,我们可以从 name
字段的定义中看到这一点:name: []const u8
.
按照惯例,这类字符串大多数都是用 UTF-8 编码,因为 Zig 源代码本身就是 UTF-8 编码的。但这并不是强制的,而且代表 ASCII 或 UTF-8 字符串的 []const u8
与代表任意二进制数据的 []const u8
实际上并没有什么区别。怎么可能有区别呢,它们是相同的类型。
根据我们所学的数组和切片知识,你可以正确地猜测 []const u8
是对常量字节数组的切片(其中字节是一个无符号 8 位整数)。但我们的代码中没有任何地方对数组进行切分,甚至没有数组,对吧?我们所做的只是将 "Goku"
赋值给 user.name
。这是怎么做到的呢?
你在源代码中看到的字符串字面量有一个编译时已知的长度。编译器知道 "Goku"
的长度是 4,所以你会认为 "Goku"
最好用数组来表示,比如 [4]const u8
。但是字符串字面形式有几个特殊的属性。它们被存储在二进制文件的一个特殊位置,并且会去重。因此,指向字符串字面量的变量将是指向这个特殊位置的指针。也就是说,"Goku"
的类型更接近于 *const [4]u8
,是一个指向 4 常量字节数组的指针。
还有更多。字符串字面量以空值结束。也就是说,它们的末尾总是有一个 \0
。在内存中,"Goku"
实际上是这样的:{'G', 'o', 'k', 'u', 0}
,所以你可能会认为它的类型是 *const [5]u8
。但这样做充其量只是模棱两可,更糟糕的是会带来危险(你可能会覆盖空结束符)。相反,Zig 有一种独特的语法来表示以空结尾的数组。"Goku"
的类型是 *const[4:0]u8
,即 4 字节以空结尾的数组指针。当我们讨论字符串时,我们关注的是以空结尾的字节数组(因为在 C 语言中字符串通常就是这样表示的),语法更通用:[LENGTH:SENTINEL]
,其中 SENTINEL
是数组末尾的特殊值。因此,虽然我想不出为什么需要它,但下面的语法是完全正确的:
1
2
3
4
5
6
7
8
9
| const std = @import("std");
pub fn main() void {
// an array of 3 booleans with false as the sentinel value
const a = [3:false]bool{false, true, false};
// This line is more advanced, and is not going to get explained!
std.debug.print("{any}\n", .{std.mem.asBytes(&a).*});
}
|
上面代码会输出:{ 0, 1, 0, 0}
。
我一直在犹豫是否要加入这个示例,因为最后一行非常高级,我不打算解释它。从另一个角度看,如果你愿意的话,这也是一个可以运行的示例,你可以用它来更好地研究我们到目前为止讨论过的一些问题。
如果我的解释还可以接受,那么你可能还有一点不清楚。如果 "Goku"
是一个 *const [4:0]u8
,那么我们为什么能将它赋值给一个 []const u8
值呢?答案很简单:Zig 会自动进行类型转化。它会在几种不同的类型之间进行类型转化,但最明显的是字符串。这意味着,如果函数有一个 []const u8
参数,或者结构体有一个 []const u8
字段,就可以使用字符串字面形式。由于以空结尾的字符串是数组,而且数组的长度是已知的,因此这种转化代价比较低,即不需要遍历字符串来查找空结束符。
因此,在谈论字符串时,我们通常指的是 []const u8
。必要时,我们会明确说明一个以空结尾的字符串,它可以被自动转化为一个 []const u8
。但请记住,[]const u8
也用于表示任意二进制数据,因此,Zig 并不像高级编程语言那样有字符串的概念。此外,Zig 的标准库只有一个非常基本的 unicode 模块。
当然,在实际程序中,大多数字符串(以及更通用的数组)在编译时都是未知的。最典型的例子就是用户输入,程序编译时并不知道用户输入。这一点我们将在讨论内存时再次讨论。但简而言之,对于这种在编译时不能确定值的数据(长度当然也就无从得知),我们将在运行时动态分配内存。我们的字符串变量(仍然是 []const u8
类型)将是指向动态分配的内存的切片。
comptime 和 anytype
在我们未解释的最后一行代码中,涉及的知识远比表面看到的多:
1
| std.debug.print("{s}'s power is {d}\n", .{user.name, user.power});
|
我们只是略微浏览了一下,但它确实提供了一个机会来强调 Zig 的一些更强大的功能。即使你还没有掌握,至少也应该了解这些功能。
首先是 Zig 的编译时执行(compile-time execution)概念。编译时执行是 Zig 元编程功能的核心,顾名思义,就是在编译时而不是运行时运行代码。在本指南中,我们将对编译时可能实现的功能进行浅显介绍,更多高级功能读者可以参考其他资料。
你可能想知道上面这行代码中需要编译时执行的是什么。print
函数的定义要求我们的第一个参数(字符串格式)是编译时已知的:
1
2
| // 注意变量"fmt"前的"comptime"
pub fn print(comptime fmt: []const u8, args: anytype) void {
|
原因是 print
会进行额外的编译时检查,而这在大多数其他语言中是不会出现的。什么样的检查呢?假设你把格式改为 it's over {d}/n
,但保留了两个参数。你会得到一个编译时错误:unused argument in 'it's over {d}'
。它还会进行类型检查:将格式字符串改为{s}'s power is {s}\n
,你会这个错误invalid format string 's' for type 'u64'
。如果在编译时不知道字符串的格式,就不可能在编译时进行这些检查。因此,需要一个编译时已知的值。
comptime
会对编码产生直接影响的地方是整数和浮点字面的默认类型,即特殊的 comptime_int
和 comptime_float
。这行代码是无效的:var i = 0
。comptime
代码只能使用编译时已知的数据,对于整数和浮点数,这类数据由特殊的 comptime_int
和 comptime_float
类型标识。这种类型的值可以在编译时执行。但你可能不会把大部分时间花在编写用于编译时执行的代码上,因此它并不是一个特别有用的默认值。你需要做的是给变量一个显式类型:
1
2
| var i: usize = 0;
var j: f64 = 0;
|
注意,如果我们使用const
,就不会出现这个错误,因为错误的关键在于 comptime_int
必须是常量。
在以后的章节中,我们将在探索泛型时进一步研究 comptime
。
我们这行代码的另一个特别之处在于奇怪的 .{user.name, user.power}
,根据上述 print
的定义,我们知道它映射到 anytype
类型的变量。这种类型不应与 Java 的 Object 或 Go 的 any(又名 interface{})混淆。相反,在编译时,Zig 会为传递给它的所有类型专门创建一个单独的 print
函数。
这就引出了一个问题:我们传递给它的是什么?我们以前在让编译器推断结构类型时见过 .{...}
符号。这与此类似:它创建了一个匿名结构字面。请看这段代码
1
2
3
| pub fn main() void {
std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});
}
|
会输出:
struct{comptime year: comptime_int = 2023, comptime month: comptime_int = 8}
在这里,我们给匿名结构的字段取名为 year
和 month
。在原始代码中,我们没有这样做。在这种情况下,字段名会自动生成 0、1、2 等。虽然它们都是匿名结构字面形式的示例,但没有字段名称的结构通常被称为“元组”(tuple)。print
函数希望接收一个元组,并使用字符串格式中的序号位置来获取适当的参数。
Zig 没有函数重载,也没有可变函数(vardiadic,具有任意数量参数的函数)。但它的编译器能根据传入的类型创建专门的函数,包括编译器自己推导和创建的类型。
4 - 语言概述 - 第二部分
原文地址:https://www.openmymind.net/learning_zig/language_overview_2
本部分继续上一部分的内容:熟悉 Zig 语言。我们将探索 Zig 的控制流和结构以外的类型。通过这两部分的学习,我们将掌握 Zig 语言的大部分语法,这让我们可以继续深入 Zig 语言,同时也为如何使用 std 标准库打下了基础。
控制流
Zig 的控制流很可能是我们所熟悉的,但它与 Zig 语言的其他特性协同工作是我们还没有探索过。我们先简单概述控制流的基本使用,之后在讨论依赖控制流的相关特性时,再来重新回顾。
你会注意到,我们使用 and
和 or
来代替逻辑运算符 &&
和 ||
。与大多数语言一样,and
和 or
会短路执行,即如果左侧为假,and
的右侧运算符就不会执行;如果左侧为真,or
的右侧就不会执行。在 Zig 中,控制流是通过关键字完成的,因此要使用 and
和 or
。
此外,比较运算符 ==
在切片(如 []const u8
,即字符串)间不起作用。在大多数情况下,需要使用 std.mem.eql(u8,str1,str2)
,它将比较两个片段的长度和字节数。
Zig 中,if
、else if
和 else
也很常见:
1
2
3
4
5
6
7
8
| // std.mem.eql 将逐字节进行比较,对于字符串来说它是大小写敏感的。
if (std.mem.eql(u8, method, "GET") or std.mem.eql(u8, method, "HEAD")) {
// 处理 GET 请求
} else if (std.mem.eql(u8, method, "POST")) {
// 处理 POST 请求
} else {
// ...
}
|
std.mem.eql
的第一个参数是一个类型,这里是 u8
。这是我们看到的第一个泛型函数。我们将在后面的部分进一步探讨。
上述示例比较的是 ASCII 字符串,不区分大小写可能更合适,这时 std.ascii.eqlIgnoreCase(str1, str2)
可能是更好的选择。
虽然没有三元运算符,但可以使用 if/else 来代替:
1
| const super = if (power > 9000) true else false;
|
switch
语句类似于if/else if/else
,但具有穷举的优点。也就是说,如果没有涵盖所有情况,编译时就会出错。下面这段代码将无法编译:
1
2
3
4
5
6
7
8
9
10
| fn anniversaryName(years_married: u16) []const u8 {
switch (years_married) {
1 => return "paper",
2 => return "cotton",
3 => return "leather",
4 => return "flower",
5 => return "wood",
6 => return "sugar",
}
}
|
编译时会报错:switch
必须处理所有的可能性。由于我们的 years_married
是一个 16 位整数,这是否意味着我们需要处理所有 64K 中情况?是的,不过我们可以使用 else
来代替:
1
2
3
| // ...
6 => return "sugar",
else => return "no more gifts for you",
|
在进行匹配时,我们可以合并多个 case
或使用范围;在进行处理时,可以使用代码块来处理复杂的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| fn arrivalTimeDesc(minutes: u16, is_late: bool) []const u8 {
switch (minutes) {
0 => return "arrived",
1, 2 => return "soon",
3...5 => return "no more than 5 minutes",
else => {
if (!is_late) {
return "sorry, it'll be a while";
}
// todo, something is very wrong
return "never";
},
}
}
|
虽然 switch
在很多情况下都很有用,但在处理枚举时,它穷举的性质才真正发挥了作用,我们很快就会谈到枚举。
Zig 的 for
循环用于遍历数组、切片和范围。例如,我们可以这样写:
1
2
3
4
5
6
7
8
| fn contains(haystack: []const u32, needle: u32) bool {
for (haystack) |value| {
if (needle == value) {
return true;
}
}
return false;
}
|
for
循环也可以同时处理多个序列,只要这些序列的长度相同。上面我们使用了 std.mem.eql
函数,下面是其大致实现:
1
2
3
4
5
6
7
8
9
10
| pub fn eql(comptime T: type, a: []const T, b: []const T) bool {
// if they aren't the same length, they can't be equal
if (a.len != b.len) return false;
for (a, b) |a_elem, b_elem| {
if (a_elem != b_elem) return false;
}
return true;
}
|
一开始的 if
检查不仅是一个很好的性能优化,还是一个必要的防护措施。如果我们去掉它,并传递不同长度的参数,就会出现运行时 panic
。for
在作用于多个序列上时,要求其长度相等。
for
循环也可以遍历范围,例如:
1
2
3
| for (0..10) |i| {
std.debug.print("{d}\n", .{i});
}
|
在 switch
中,范围使用了三个点,即 3...6
,而这个示例中,范围使用了两个点,即 0..10
。这是因为在 switch 中,范围的两端都是闭区间,而 for 则是左闭右开。
与一个(或多个)序列组合使用时,它的作用就真正体现出来了:
1
2
3
4
5
6
7
8
| fn indexOf(haystack: []const u32, needle: u32) ?usize {
for (haystack, 0..) |value, i| {
if (needle == value) {
return i;
}
}
return null;
}
|
这是对可空类型的初步了解。
范围的末端由 haystack
的长度推断,不过我们也可以写出 0..haystack.len
,但这没有必要。for
循环不支持常见的 init; compare; step
风格,对于这种情况,可以使用 while
。
因为 while
比较简单,形式如下:while (condition) { }
,这有利于更好地控制迭代。例如,在计算字符串中转义序列的数量时,我们需要将迭代器递增 2 以避免重复计算 \\
:
1
2
3
4
5
6
7
8
9
10
11
12
13
| var escape_count: usize = 0;
{
var i: usize = 0;
// 反斜杠用作转义字符,因此我们需要用一个反斜杠来转义它。
while (i < src.len) {
if (src[i] == '\\') {
i += 2;
escape_count += 1;
} else {
i += 1;
}
}
}
|
我们在临时变量 i
和 while
循环周围添加了一个显式的代码块。这缩小了 i
的作用范围。这样的代码块可能会很有用,尽管在这个例子中可能有些过度。不过,上述例子已经是 Zig 中最接近传统的 for(init; compare; step)
循环的写法了。
while
可以包含 else
子句,当条件为假时执行 else
子句。它还可以接受在每次迭代后要执行的语句。多个语句可以用 ; 分隔。在 for
支持遍历多个序列之前,这一功能很常用。上述语句可写成
1
2
3
4
5
6
7
8
9
10
11
12
| var i: usize = 0;
var escape_count: usize = 0;
// 改写后的
while (i < src.len) : (i += 1) {
if (src[i] == '\\') {
// +1 here, and +1 above == +2
// 这里 +1,上面也 +1,相当于 +2
i += 1;
escape_count += 1;
}
}
|
Zig 也支持 break
和 continue
关键字,用于跳出最内层循环或跳转到下一次迭代。
代码块可以附带标签(label),break
和 continue
可以作用在特定标签上。举例说明:
1
2
3
4
5
6
| outer: for (1..10) |i| {
for (i..10) |j| {
if (i * j > (i+i + j+j)) continue :outer;
std.debug.print("{d} + {d} >= {d} * {d}\n", .{i+i, j+j, i, j});
}
}
|
break
还有另一个有趣的行为,即从代码块中返回值:
1
2
3
4
5
| const personality_analysis = blk: {
if (tea_vote > coffee_vote) break :blk "sane";
if (tea_vote == coffee_vote) break :blk "whatever";
if (tea_vote < coffee_vote) break :blk "dangerous";
};
|
像这样有返回值的的块,必须以分号结束。
稍后,当我们讨论带标签的联合(tagged union)、错误联合(error unions)和可选类型(Optional)时,我们将看到控制流如何与它们联合使用。
枚举
枚举是带有标签的整数常量。它们的定义很像结构体:
1
2
3
4
5
6
| // 可以是 "pub" 的
const Status = enum {
ok,
bad,
unknown,
};
|
与结构体一样,枚举可以包含其他定义,包括函数,这些函数可以选择性地将枚举作为第一个参数:
1
2
3
4
5
6
7
8
9
10
| const Stage = enum {
validate,
awaiting_confirmation,
confirmed,
err,
fn isComplete(self: Stage) bool {
return self == .confirmed or self == .err;
}
};
|
如果需要枚举的字符串表示,可以使用内置的 @tagName(enum)
函数。
回想一下,结构类型可以使用 .{...}
符号根据其赋值或返回类型来推断。在上面,我们看到枚举类型是根据与 self
的比较推导出来的,而 self
的类型是 Stage
。我们本可以明确地写成:return self == Stage.confirmed
或 self == Stage.err
。但是,在处理枚举时,你经常会看到通过 .$value
这种省略具体类型的情况。这被称为枚举字面量。
switch
的穷举性质使它能与枚举很好地搭配,因为它能确保你处理了所有可能的情况。不过在使用 switch
的 else
子句时要小心,因为它会匹配任何新添加的枚举值,而这可能不是我们想要的行为。
带标签的联合 Tagged Union
联合定义了一个值可以具有的一系列类型。例如,这个 Number
可以是整数、浮点数或 nan(非数字):
1
2
3
4
5
6
7
8
9
10
11
12
| const std = @import("std");
pub fn main() void {
const n = Number{.int = 32};
std.debug.print("{d}\n", .{n.int});
}
const Number = union {
int: i64,
float: f64,
nan: void,
};
|
一个联合一次只能设置一个字段;试图访问一个未设置的字段是错误的。既然我们已经设置了 int
字段,如果我们试图访问 n.float
,就会出错。我们的一个字段 nan
是 void
类型。我们该如何设置它的值呢?使用 {}
:
1
| const n = Number{.nan = {}};
|
使用联合的一个难题是要知道设置的是哪个字段。这就是带标签的联合发挥作用的地方。带标签的联合将枚举与联合定义在一起,可用于 switch
语句中。请看下面这个例子:
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
| pub fn main() void {
const ts = Timestamp{.unix = 1693278411};
std.debug.print("{d}\n", .{ts.seconds()});
}
const TimestampType = enum {
unix,
datetime,
};
const Timestamp = union(TimestampType) {
unix: i32,
datetime: DateTime,
const DateTime = struct {
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
};
fn seconds(self: Timestamp) u16 {
switch (self) {
.datetime => |dt| return dt.second,
.unix => |ts| {
const seconds_since_midnight: i32 = @rem(ts, 86400);
return @intCast(@rem(seconds_since_midnight, 60));
},
}
}
};
|
请注意, switch
中的每个分支捕获了字段的类型值。也就是说,dt
是 Timestamp.DateTime
类型,而 ts
是 i32
类型。这也是我们第一次看到嵌套在其他类型中的结构。DateTime
本可以在联合之外定义。我们还看到了两个新的内置函数:@rem
用于获取余数,@intCast
用于将结果转换为 u16
(@intCast
从返回值类型中推断出我们需要 u16
)。
从上面的示例中我们可以看出,带标签的联合的使用有点像接口,只要我们提前知道所有可能的实现,我们就能够将其转化带标签的联合这种形式。
最后,带标签的联合中的枚举类型可以自动推导出来。我们可以直接这样做:
1
2
3
4
5
| const Timestamp = union(enum) {
unix: i32,
datetime: DateTime,
...
|
这里 Zig 会根据带标签的联合,自动创建一个隐式枚举。
可选类型 Optional
在类型前加上问号 ?
,任何值都可以声明为可选类型。可选类型既可以是 null
,也可以是已定义类型的值:
1
2
| var home: ?[]const u8 = null;
var name: ?[]const u8 = "Leto";
|
明确类型的必要性应该很清楚:如果我们只使用 const name = "Leto"
,那么推导出的类型将是非可选的 []const u8
。
.?
用于访问可选类型后面的值:
1
| std.debug.print("{s}\n", .{name.?});
|
但如果在 null
上使用 .?
,运行时就会 panic
。if
语句可以安全地取出可选类型背后的值:
1
2
3
4
5
6
| if (home) |h| {
// h is a []const u8
// we have a home value
} else {
// we don't have a home value
}
|
orelse
可用于提取可选类型的值或执行代码。这通常用于指定默认值或从函数中返回:
1
2
3
4
| const h = home orelse "unknown"
// 或直接返回函数
const h = home orelse return;
|
不过,orelse 也可以带一个代码块,用于执行更复杂的逻辑。可选类型还可以与 while
整合,经常用于创建迭代器。我们这里忽略迭代器的细节,但希望这段伪代码能说明问题:
1
2
3
| while (rows.next()) |row| {
// do something with our row
}
|
未定义的值 Undefined
到目前为止,我们看到的每一个变量都被初始化为一个合理的值。但有时我们在声明变量时并不知道它的值。可选类型是一种选择,但并不总是合理的。在这种情况下,我们可以将变量设置为未定义,让其保持未初始化状态。
通常这样做的一个地方是创建数组,其值将由某个函数来填充:
1
2
| var pseudo_uuid: [16]u8 = undefined;
std.crypto.random.bytes(&pseudo_uuid);
|
上述代码仍然创建了一个 16 字节的数组,但它的每个元素都没有被赋值。
错误 Errors
Zig 中错误处理功能十分简单、实用。这一切都从错误集(error sets)开始,错误集的使用方式类似于枚举:
1
2
3
4
5
6
| // 与第 1 部分中的结构一样,OpenError 也可以标记为 "pub"。
// 使其可以在其定义的文件之外访问
const OpenError = error {
AccessDenied,
NotFound,
};
|
任意函数(包括 main
)都可以返回这个错误:
1
2
3
4
5
6
7
8
| pub fn main() void {
return OpenError.AccessDenied;
}
const OpenError = error {
AccessDenied,
NotFound,
};
|
如果你尝试运行这个程序,你会得到一个错误:expected type 'void', found 'error{AccessDenied,NotFound}'
。这是有道理的:我们定义了返回类型为 void
的 main
函数,但我们却返回了另一种东西(很明显,它是一个错误,而不是 void
)。要解决这个问题,我们需要更改函数的返回类型。
1
2
3
| pub fn main() OpenError!void {
return OpenError.AccessDenied;
}
|
这就是所谓的错误联合类型,它表示我们的函数既可以返回 OpenError
错误,也可以返回 void
(也就是什么都没有)。到目前为止,我们已经非常明确:我们为函数可能返回的错误创建了一个错误集,并在函数的错误联合类型中使用了该错误集。但是,说到错误,Zig 有一些巧妙的技巧。首先,我们可以让 Zig 通过使用 !return_type
来推导错误集,而不是将 error union
指定为 error_set!return_type
。因此,我们可以(也推荐)将我们 main
函数定义为:
其次,Zig 能够为我们隐式创建错误集。我们可以这样做,而不需要提前声明:
1
2
3
| pub fn main() !void {
return error.AccessDenied;
}
|
完全显式和隐式方法并不完全等同。例如,引用具有隐式错误集的函数时,需要使用特殊的 anyerror
类型。类库开发人员可能会发现显式的好处,比如可以达到代码即文档的效果。不过,我认为隐式错误集和推导错误联合类型都很实用;我在平时编程中,大量使用了这两种方法。
错误联合类型的真正价值在于 Zig 语言提供了 catch
和 try
来处理它们。返回错误联合类型的函数调用时,可以包含一个 catch
子句。例如,一个 http 服务器库的代码可能如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
| action(req, res) catch |err| {
if (err == error.BrokenPipe or err == error.ConnectionResetByPeer) {
return;
} else if (err == error.BodyTooBig) {
res.status = 431;
res.body = "Request body is too big";
} else {
res.status = 500;
res.body = "Internal Server Error";
// todo: log err
}
};
|
switch
的版本更符合惯用法:
1
2
3
4
5
6
7
8
9
10
11
| action(req, res) catch |err| switch (err) {
error.BrokenPipe, error.ConnectionResetByPeer) => return,
error.BodyTooBig => {
res.status = 431;
res.body = "Request body is too big";
},
else => {
res.status = 500;
res.body = "Internal Server Error";
}
};
|
这看起来花哨,但老实说,你在 catch
中最有可能做的事情就是把错误信息给调用者:
1
| action(req, res) catch |err| return err;
|
这种模式非常常见,因此 Zig 提供了 try
关键字用于处理这种情况。上述代码的另一种写法如下:
鉴于必须处理错误,这一点尤其有用。多数情况下的做法就是使用 try
或 catch
。
Go 开发人员会注意到,try
比 if err != nil { return err }
的按键次数更少。
大多数情况下,你都会使用 try
和 catch
,但 if
和 while
也支持错误联合类型,这与可选类型很相似。在 while
的情况下,如果条件返回错误,则执行 else
子句。
有一种特殊的 anyerror
类型可以容纳任何错误。虽然我们可以将函数定义为返回 anyerror!TYPE
而不是 !TYPE
,但两者并不等同。anyerror
是全局错误集,是程序中所有错误集的超集。因此,在函数签名中使用 anyerror
很可能表示这个函数虽然可以返回错误,而实际上它大概率不会返回错误。 anyerror
主要用在可以是任意错误类型的函数参数或结构体字段中(想象一下日志库)。
函数同时返回可选类型与错误联合类型的情况并不少见。在推导错误集的情况下,形式如下:
1
2
3
4
5
| // 载入上次保存的游戏
pub fn loadLast() !?Save {
// TODO
return null;
}
|
使用此类函数有多种方法,但最简洁的方法是使用 try
来解除错误,然后使用 orelse
来解除可选类型。下面是一个大致的模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| const std = @import("std");
pub fn main() void {
// This is the line you want to focus on
const save = (try Save.loadLast()) orelse Save.blank();
std.debug.print("{any}\n", .{save});
}
pub const Save = struct {
lives: u8,
level: u16,
pub fn loadLast() !?Save {
//todo
return null;
}
pub fn blank() Save {
return .{
.lives = 3,
.level = 1,
};
}
};
|
虽然我们还未涉及 Zig 语言中更高级的功能,但我们在前两部分中看到的是 Zig 语言重要组成部分。它们将作为一个基础,让我们能够探索更复杂的话题,而不用被语法所困扰。
5 - 编码风格
原文地址:https://www.openmymind.net/learning_zig/style_guide
本小节的主要内容是介绍 Zig 编译器强制遵守的 2 条规则,以及 Zig 标准库的命名惯例(naming convention)。
未使用变量 Unused Variable
Zig 编译器禁止未使用变量
,例如以下代码会导致两处编译错误:
1
2
3
4
5
6
7
8
9
10
| const std = @import("std");
pub fn main() void {
const sum = add(8999, 2);
}
fn add(a: i64, b: i64) i64 {
// notice this is a + a, not a + b
return a + a;
}
|
第一个编译错误,源自于sum
是一个未使用的本地常量。第二个编译错误,在于在函数add
的所有形参中,b
是一个未使用的函数参数。对于这段代码来说,它们是比较明显的漏洞。但是在实际编程中,代码中包含未使用变量和函数形参并非完全不合理。在这种情况下,我们可以通过将未使用变量赋值给_
(下划线)的方法,避免编译器报错:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| const std = @import("std");
pub fn main() void {
_ = add(8999, 2);
// or
const sum = add(8999, 2);
_ = sum;
}
fn add(a: i64, b: i64) i64 {
_ = b;
return a + a;
}
|
除了使用_ = b
之外,我们还可以直接用_
来命名函数add
的形参。但是,在我看来,这样做会牺牲代码的可读性,读者会猜测,这个未使用的形参到底是什么:
1
| fn add(a: i64, _: i64) i64 {
|
值得注意的是,在上述例子中,std
也是一个未使用的符号,但是当前这种用法并不会导致任何编译错误。可能在未来,Zig 编译器也将此视为错误。
变量覆盖 Variable Shadowing
Zig 不允许使用同名的变量。下面是一个读取 socket
的例子,这个例子包含了一个变量覆盖的编译错误:
1
2
3
4
5
6
7
8
| fn read(stream: std.net.Stream) ![]const u8 {
var buf: [512]u8 = undefined;
const read = try stream.read(&buf);
if (read == 0) {
return error.Closed;
}
return buf[0..read];
}
|
上述例子中,read
变量覆盖了read
函数。我并不太认同这个规范,因为它会导致开发者为了避免覆盖而使用短且无意义的变量名。例如,为了让上述代码通过编译,需要将变量名read
改成n
。
我认为,这个规范并不能使代码可读性提高。在这个场景下,应该是开发者,而不是编译器,更有资格选择更有可读性的命名方案。
命名规范
除了遵守以上这些规则以外,开发者可以自由地选择他们喜欢的命名规范。但是,理解 Zig 自身的命名规范是有益的,因为大部分你需要打交道的代码,如 Zig 标准库,或者其他三方库,都采用了 Zig 的命名规范。
Zig 代码采用 4 个空格进行缩进。我个人会因为客观上更方便,使用tab
键。
Zig 的函数名采用了驼峰命名法(camelCase),而变量名会采用小写加下划线(snake case)的命名方式。类型则采用的是 PascalCase 风格。除了这三条规则外,一个有趣的交叉规则是,如果一个变量表示一个类型,或者一个函数返回一个类型,那么这个变量或者函数遵循 PascalCase。在之前的章节中,其实已经见到了这个例子,不过,可能你没有注意到:
1
| std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});
|
我们已经看到过一些内置函数:@import
,@rem
和@intCast
。因为这些都是函数,他们的命名遵循驼峰命名法。@TypeOf
也是一个内置函数,但是他遵循 PascalCase,为何?因为他返回的是一个类型,因此它的命名采用了类型命名方法。当我们使用一个变量,去接收@TypeOf
的返回值,这个变量也需要遵循类型命名规则(即 PascalCase):
1
2
| const T = @TypeOf(3);
std.debug.print("{any}\n", .{T});
|
zig
命令包含一个 fmt
子命令,在给定一个文件或目录时,它会根据 Zig 的编码风格对文件进行格式化。但它并没有包含所有上述的规则,比如它能够调整缩排,以及花括号{
的位置,但是它不会调整标识符的大小写。
6 - 指针
原文地址:https://www.openmymind.net/learning_zig/pointers
Zig 不包含垃圾回收器。管理内存的重任由开发者负责。这是一项重大责任,因为它直接影响到应用程序的性能、稳定性和安全性。
我们将从指针开始讨论,这本身就是一个重要的话题,同时也是训练我们从面向内存的角度来看待程序数据的开始。如果你已经对指针、堆分配和悬挂指针了如指掌,那么可以跳过本小节和下一小节,直接阅读堆内存和分配器,这部分内容与 Zig 更为相关。
下面的代码创建了一个 power
为 100 的用户,然后调用 levelUp
函数将用户的 power
加一。你能猜到它的输出结果吗?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| const std = @import("std");
pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
// this line has been added
levelUp(user);
std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}
fn levelUp(user: User) void {
user.power += 1;
}
pub const User = struct {
id: u64,
power: i32,
};
|
这里我设置了一个陷阱,此段代码将无法编译:局部变量从未被修改。这是指 main
函数中的 user
变量。一个从未被修改的变量必须声明为 const。你可能会想:但在 levelUp
函数中我们确实修改了 user
,这怎么回事?让我们假设 Zig 编译器弄错了,并试着糊弄它。我们将强制让编译器看到 user
确实被修改了:
1
2
3
4
5
6
7
8
9
10
| const std = @import("std");
pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
user.power += 0;
// 代码的其余部分保持不变。
|
现在我们在 levelUp
中遇到了一个错误:不能赋值给常量。我们在第一部分中看到函数参数是常量,因此 user.power += 1
是无效的。为了解决这个错误,我们可以将 levelUp
函数改为
1
2
3
4
| fn levelUp(user: User) void {
var u = user;
u.power += 1;
}
|
虽然编译成功了,但输出结果却是User 1 has power of 100
,而我们代码的目的显然是让 levelUp
将用户的 power
提升到 101。这是怎么回事?
要理解这一点,我们可以将数据与内存联系起来,而变量只是将类型与特定内存位置关联起来的标签。例如,在 main
中,我们创建了一个User
。内存中数据的简单可视化表示如下
1
2
3
4
5
| user -> ------------ (id)
| 1 |
------------ (power)
| 100 |
------------
|
有两点需要注意:
- 我们的
user
变量指向结构的起点 - 字段是按顺序排列的
请记住,我们的user
也有一个类型。该类型告诉我们 id
是一个 64 位整数,power
是一个 32 位整数。有了对数据起始位置的引用和类型,编译器就可以将 user.power
转换为:访问位置在结构体第 64 位上的一个 32 位整数。这就是变量的威力,它们可以引用内存,并包含以有意义的方式理解和操作内存所需的类型信息。
默认情况下,Zig 不保证结构的内存布局。它可以按字母顺序、大小升序或插入填充(padding)某些字段。只要它能正确翻译我们的代码,它就可以为所欲为。这种自由度可以实现某些优化。只有在声明 packed struct
时,我们才能获得内存布局的有力保证。我们还可以创建一个 extern struct
,这样可以保证内存布局与 C 应用程序二进制接口 (ABI) 匹配。尽管如此,我们对user
的可视化还是合理而有用的。
下面是一个稍有不同的可视化效果,其中包括内存地址。这些数据的起始内存地址是我想出来的一个随机地址。这是user
变量引用的内存地址,也是第一个字段 id
的值所在的位置。由于 id
是一个 64 位整数,需要 8 字节内存。因此,power
必须位于 $start_address + 8
上:
1
2
3
4
5
| user -> ------------ (id: 1043368d0)
| 1 |
------------ (power: 1043368d8)
| 100 |
------------
|
为了验证这一点,我想介绍一下取地址符运算符:&
。顾名思义,取地址运算符返回一个变量的地址(它也可以返回一个函数的地址,是不是很神奇?)保留现有的 User
定义,试试下面的代码:
1
2
3
4
5
6
7
| pub fn main() void {
const user = User{
.id = 1,
.power = 100,
};
std.debug.print("{*}\n{*}\n{*}\n", .{&user, &user.id, &user.power});
}
|
这段代码输出了user
、user.id
、和user.power
的地址。根据平台等差异,可能会得到不同的输出结果,但都会看到user
和user.id
的地址相同,而user.power
的地址偏移量了 8 个字节。输出的结果如下:
1
2
3
| learning.User@1043368d0
u64@1043368d0
i32@1043368d8
|
取地址运算符返回一个指向值的指针。指向值的指针是一种特殊的类型。类型T
的值的地址是*T
。因此,如果我们获取 user
的地址,就会得到一个 *User
,即一个指向 User
的指针:
1
2
3
4
5
6
7
8
9
10
| pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
user.power += 0;
const user_p = &user;
std.debug.print("{any}\n", .{@TypeOf(user_p)});
}
|
我们最初的目标是通过levelUp
函数将用户的power
值增加 1 。我们已经让代码编译通过,但当我们打印power
时,它仍然是原始值。虽然有点跳跃,但让我们修改代码,在 main
和 levelUp
中打印 user
的地址:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
user.power += 0;
// added this
std.debug.print("main: {*}\n", .{&user});
levelUp(user);
std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}
fn levelUp(user: User) void {
// add this
std.debug.print("levelUp: {*}\n", .{&user});
var u = user;
u.power += 1;
}
|
如果运行这个程序,会得到两个不同的地址。这意味着在 levelUp
中被修改的 user
与 main
中的user
是不同的。这是因为 Zig 传递了一个值的副本。这似乎是一个奇怪的默认行为,但它的好处之一是,函数的调用者可以确保函数不会修改参数(因为它不能)。在很多情况下,有这样的保证是件好事。当然,有时我们希望函数能修改参数,比如 levelUp
。为此,我们需要 levelUp
作用于 main
中 user
,而不是其副本。我们可以通过向函数传递 user
的地址来实现这一点:
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
| const std = @import("std");
pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
// no longer needed
// user.power += 1;
// user -> &user
levelUp(&user);
std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}
// User -> *User
fn levelUp(user: *User) void {
user.power += 1;
}
pub const User = struct {
id: u64,
power: i32,
};
|
我们必须做两处改动。首先是用 user
的地址(即 &user
)来调用 levelUp
,而不是 user
。这意味着我们的函数参数不再是 User
,取而代之的是一个 *User
,这是我们的第二处改动。
我们不再需要通过 user.power += 0;
来强制修改 user 的那个丑陋的技巧了。最初,我们因为 user 是 var 类型而无法让代码编译,编译器告诉我们它从未被修改。我们以为编译器错了,于是通过强制修改来“糊弄”它。但正如我们现在所知道的,在 levelUp 中被修改的 user 是不同的;编译器是正确的。
现在,代码已按预期运行。虽然在函数参数和内存模型方面仍有许多微妙之处,但我们正在取得进展。现在也许是一个好时机来说明一下,除了特定的语法之外,这些都不是 Zig 所独有的。我们在这里探索的模型是最常见的,有些语言可能只是向开发者隐藏了很多细节,因此也就隐藏了灵活性。
方法
一般来说,我们会把 levelUp
写成 User
结构的一个方法:
1
2
3
4
5
6
7
8
| pub const User = struct {
id: u64,
power: i32,
fn levelUp(user: *User) void {
user.power += 1;
}
};
|
这就引出了一个问题:我们如何调用带有指针参数的方法?也许我们必须这样做:&user.levelUp()
?实际上,只需正常调用即可,即 user.levelUp()。Zig 知道该方法需要一个指针,因此会正确地传递值(通过引用传递)。
我最初选择函数是因为它很明确,因此更容易学习。
常量函数参数
我不止一次暗示过,在默认情况下,Zig 会传递一个值的副本(称为 “按值传递”)。很快我们就会发现,实际情况要更微妙一些(提示:嵌套对象的复杂值怎么办?)
即使坚持使用简单类型,事实也是 Zig 可以随心所欲地传递参数,只要它能保证代码的意图不受影响。在我们最初的 levelUp
中,参数是一个User
,Zig 可以传递用户的副本或对 main.user
的引用,只要它能保证函数不会对其进行更改即可。(我知道我们最终确实希望它被改变,但通过采用 User
类型,我们告诉编译器我们不希望它被改变)。
这种自由度允许 Zig 根据参数类型使用最优策略。像 User 这样的小类型可以通过值传递(即复制),成本较低。较大的类型通过引用传递可能更便宜。只要代码的意图得以保留,Zig 可以使用任何方法。在某种程度上,使用常量函数参数可以做到这一点。
现在你知道函数参数是常量的原因之一了吧。
也许你会想,即使与复制一个非常小的结构相比,通过引用传递怎么会更慢呢?我们接下来会更清楚地看到这一点,但要点是,当 user
是指针时,执行 user.power
会增加一点点开销。编译器必须权衡复制的代价和通过指针间接访问字段的代价。
指向指针的指针
我们之前查看了main
函数中 user
的内存结构。现在我们改变了 levelUp
,那么它的内存会是什么样的呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| main:
user -> ------------ (id: 1043368d0) <---
| 1 | |
------------ (power: 1043368d8) |
| 100 | |
------------ |
|
............. empty space |
............. or other data |
|
levelUp: |
user -> ------------- (*User) |
| 1043368d0 |----------------------
-------------
|
在 levelUp
中,user
是指向 User
的指针。它的值是一个地址。当然不是任何地址,而是 main.user
的地址。值得明确的是,levelUp
中的 user
变量代表一个具体的值。这个值恰好是一个地址。而且,它不仅仅是一个地址,还是一个类型,即 *User
。这一切都非常一致,不管我们讨论的是不是指针:变量将类型信息与地址联系在一起。指针的唯一特殊之处在于,当我们使用点语法时,例如 user.power
,Zig 知道 user
是一个指针,就会自动跟随地址。
通过指针访问字段时,有些语言可能会使用不同的运算符。
重要的是要理解,levelUp
函数中的user
变量本身存在于内存中的某个地址。就像之前所做的一样,我们可以亲自验证这一点:
1
2
3
4
| fn levelUp(user: *User) void {
std.debug.print("{*}\n{*}\n", .{&user, user});
user.power += 1;
}
|
上面打印了user
变量引用的地址及其值,这个值就是main
函数中的user
的地址。
如果user
的类型是*User
,那么&user
呢?它的类型是**User
, 或者说是一个指向User
指针的指针。我可以一直这样做,直到内存溢出!
我们可以使用多级间接指针,但这并不是我们现在所需要的。本节的目的是说明指针并不特殊,它只是一个值,即一个地址和一种类型。
嵌套指针
到目前为止,User
一直很简单,只包含两个整数。很容易就能想象出它的内存,而且当我们谈论『复制』 时,也不会有任何歧义。但是,如果 User 变得更加复杂并包含一个指针,会发生什么情况呢?
1
2
3
4
5
| pub const User = struct {
id: u64,
power: i32,
name: []const u8,
};
|
我们已经添加了name
,它是一个切片。回想一下,切片由长度和指针组成。如果我们使用名字Goku
初始化user
,它在内存中会是什么样子?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
------| 1182145c0 |
| -------------
|
| ............. empty space
| ............. or other data
|
---> ------------- (1182145c0)
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
|
新的name
字段是一个切片,由len
和ptr
字段组成。它们与所有其他字段一起按顺序排放。在 64 位平台上,len
和ptr
都将是 64 位,即 8 字节。有趣的是name.ptr
的值:它是指向内存中其他位置的地址。
由于我们使用了字符串字面形式,user.name.ptr
将指向二进制文件中存储所有常量的区域内的一个特定位置。
通过多层嵌套,类型可以变得比这复杂得多。但无论简单还是复杂,它们的行为都是一样的。具体来说,如果我们回到原来的代码,levelUp
接收一个普通的 User
,Zig 提供一个副本,那么现在有了嵌套指针后,情况会怎样呢?
答案是只会进行浅拷贝。或者像有些人说的那样,只拷贝了变量可立即寻址的内存。这样看来,levelUp
可能只会得到一个 user
残缺副本,name
字段可能是无效的。但请记住,像 user.name.ptr
这样的指针是一个值,而这个值是一个地址。它的副本仍然是相同的地址:
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
| main: user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
| 1182145c0 |-------------------------
levelUp: user -> ------------- (id: 1043368ec) |
| 1 | |
------------- (power: 1043368f4) |
| 100 | |
------------- (name.len: 1043368f8) |
| 4 | |
------------- (name.ptr: 104336900) |
| 1182145c0 |-------------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- (1182145c0) <---
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
|
从上面可以看出,浅拷贝是可行的。由于指针的值是一个地址,复制该值意味着我们得到的是相同的地址。这对可变性有重要影响。我们的函数不能更改 main.user
中的字段,因为它得到了一个副本,但它可以访问同一个name
,那么它能更改 name
吗?在这种特殊情况下,不行,因为 name
是常量。另外,Goku
是一个字符串字面量,它总是不可变的。不过,只要花点功夫,我们就能明白浅拷贝的含义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| const std = @import("std");
pub fn main() void {
var name = [4]u8{'G', 'o', 'k', 'u'};
const user = User{
.id = 1,
.power = 100,
// slice it, [4]u8 -> []u8
.name = name[0..],
};
levelUp(user);
std.debug.print("{s}\n", .{user.name});
}
fn levelUp(user: User) void {
user.name[2] = '!';
}
pub const User = struct {
id: u64,
power: i32,
// []const u8 -> []u8
name: []u8
};
|
上面的代码会打印出Go!u
。我们不得不将name
的类型从[]const u8
更改为[]u8
,并且不再使用字符串字面量(它们总是不可变的),而是创建一个数组并对其进行切片。有些人可能会认为这前后不一致。通过值传递可以防止函数改变直接字段,但不能改变指针后面有值的字段。如果我们确实希望 name
不可变,就应该将其声明为 []const u8
而不是 []u8
。
不同编程语言有不同的实现方式,但许多语言的工作方式与此完全相同(或非常接近)。虽然所有这些看似深奥,但却是日常编程的基础。好消息是,你可以通过简单的示例和片段来掌握这一点;它不会随着系统其他部分复杂性的增加而变得更加复杂。
递归结构
有时你需要一个递归结构。在保留现有代码的基础上,我们为 User
添加一个可选的 manager
字段,类型为 ?User
。同时,我们将创建两个User
,并将其中一个指定为另一个的管理者:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| const std = @import("std");
pub fn main() void {
const leto = User{
.id = 1,
.power = 9001,
.manager = null,
};
const duncan = User{
.id = 1,
.power = 9001,
.manager = leto,
};
std.debug.print("{any}\n{any}", .{leto, duncan});
}
pub const User = struct {
id: u64,
power: i32,
manager: ?User,
};
|
这段代码无法编译:struct 'learning.User' depends on itself
。这个问题的根本原因是每种类型都必须在编译时确定大小,而这里的递归结构体大小是无法确定的。
我们在添加 name
时没有遇到这个问题,尽管 name
可以有不同的长度。问题不在于值的大小,而在于类型本身的大小。name 是一个切片,即 []const u8
,它有一个已知的大小:16 字节,其中 len
8 字节,ptr
8 字节。
你可能会认为这对任何 Optional
或 union
来说都是个问题。但对于它们来说,最大字段的大小是已知的,这样 Zig 就可以使用它。递归结构没有这样的上限,该结构可以递归一次、两次或数百万次。这个次数会因User
而异,在编译时是不知道的。
我们通过 name
看到了答案:使用指针。指针总是占用 usize
字节。在 64 位平台上,指针占用 8 个字节。就像Goku
并没有与 user
一起存储一样,使用指针意味着我们的manager
不再与user
的内存布局绑定。
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
| const std = @import("std");
pub fn main() void {
const leto = User{
.id = 1,
.power = 9001,
.manager = null,
};
const duncan = User{
.id = 1,
.power = 9001,
// changed from leto -> &leto
.manager = &leto,
};
std.debug.print("{any}\n{any}", .{leto, duncan});
}
pub const User = struct {
id: u64,
power: i32,
// changed from ?const User -> ?*const User
manager: ?*const User,
};
|
你可能永远不需要递归结构,但这里并不是介绍数据建模的教程,因此不过多进行介绍。这里主要是想讨论指针和内存模型,以及更好地理解编译器的意图。
很多开发人员都在为指针而苦恼,因为指针总是难以捉摸。它们给人的感觉不像整数、字符串或User
那样具体。虽然你现在不必完全理解这些概念,但掌握它们是值得的,而且不仅仅是为了 Zig。这些细节可能隐藏在 Ruby、Python 和 JavaScript 等语言中,其次是 C#、Java 和 Go。它影响着你如何编写代码以及代码如何运行。因此,请慢慢来,多看示例,添加调试打印语句来查看变量及其地址。你探索得越多,就会越清楚。
7 - 栈内存
原文地址:https://www.openmymind.net/learning_zig/stack_memory
通过深入研究指针,我们了解了变量、数据和内存之间的关系。因此,我们对内存的分布有了一定的了解,但我们还没有讨论如何管理数据以及内存。对于运行时间短和简单的脚本来说,这可能并不重要。在 32GB 笔记本电脑时代,你可以启动程序,使用几百兆内存读取文件和解析 HTTP 响应,做一些了不起的事情,然后退出。程序退出时,操作系统会知道,它给程序分配的内存可以被回收,并用于其他用途了。
但对于运行数天、数月甚至数年的程序来说,内存就成了有限而宝贵的资源,很可能会被同一台机器上运行的其他进程抢占。根本不可能等到程序退出后再释放内存。这就是垃圾回收器的主要工作:了解哪些数据不再使用,并释放其内存。在 Zig 中,你就是垃圾回收器。
我们编写的大多数程序都会使用内存的三个区域。第一个是全局空间,也就是存储程序常量(包括字符串字面量)的地方。所有全局数据都被嵌入到二进制文件中,在编译时(也就是运行时)完全已知,并且不可更改。这些数据在程序的整个生命周期中都存在,从不需要增加或减少内存。除了会影响二进制文件的大小外,我们完全不必担心这个问题。
内存的第二个区域是调用栈,也是本小节的主题。第三个区域是堆,将在下一小节讨论。
三块内存区域实际上没有真正的物理差别。操作系统和可执行文件创造了“内存区域”这个概念。
栈帧
迄今为止,我们所见的所有数据都是常量,存储在二进制的全局数据部分或作为局部变量。局部表示该变量只在其声明的范围内有效。在 Zig 中,范围从花括号开始到结束。大多数变量的范围限定在一个函数内,包括函数参数,或一个控制流块,比如 if。但是,正如所见,你可以创建任意块,从而创建任意范围。
在上一部分中,我们可视化了 main
和 levelUp
函数的内存,每个函数都有一个 User:
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
| main: user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
| 1182145c0 |-------------------------
levelUp: user -> ------------- (id: 1043368ec) |
| 1 | |
------------- (power: 1043368f4) |
| 100 | |
------------- (name.len: 1043368f8) |
| 4 | |
------------- (name.ptr: 104336900) |
| 1182145c0 |-------------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- (1182145c0) <---
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
|
levelUp
紧接在 main
之后是有原因的:这是我们的简化版调用栈。当我们的程序启动时,main
及其局部变量被推入调用栈。当 levelUp
被调用时,它的参数和任何局部变量都会被添加到调用栈上。重要的是,当 levelUp
返回时,它会从栈中弹出。 在 levelUp
返回并且控制权回到 main
后,我们的调用栈如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| main: user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
| 1182145c0 |-------------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- (1182145c0) <---
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
|
当一个函数被调用时,其整个栈帧被推入调用栈——这就是我们需要知道每种类型大小的原因之一。尽管我们可能直到特定的代码行执行时,才能知道我们 user
的名字的长度(假设它不是一个常量字符串字面量),但我们知道我们的函数有一个 User
类型的变量,除了其他字段,只需要 8 字节来存储name.len
和 8 字节来存储name.ptr
。
当函数返回时,它的栈帧(最后推入调用栈的帧)会被弹出。令人惊讶的事情刚刚发生:levelUp
使用的内存已被自动释放!虽然从技术上讲,这些内存可以返回给操作系统,但据我所知,没有任何实现会真正缩小调用栈(不过,在必要时,实现会动态增加调用栈)。不过,用于存储 levelUp
堆栈帧的内存现在可以在我们的进程中用于另一个堆栈帧了。
在普通程序中,调用堆栈可能会变得很大。在一个典型程序所使用的所有框架代码和库之间,你最终会发现深层嵌套的函数。通常情况下,这并不是问题,但有时你可能会遇到堆栈溢出错误。当我们的调用栈空间耗尽时,就会发生这种情况。这种情况通常发生在递归函数中,即函数会调用自身。
与全局数据一样,调用栈也由操作系统和可执行文件管理。程序启动时,以及此后启动的每个线程,都会创建一个调用栈(其大小通常可在操作系统中配置)。调用栈在程序的整个生命周期中都存在,如果是线程,则在线程的整个生命周期中都存在。程序或线程退出时,调用栈将被释放。我们的全局数据包含所有程序的全局数据,而调用栈只包含当前执行的函数层次的栈帧。这样做既能有效利用内存,又能简化堆栈帧的管理。
悬空指针
栈帧的简洁和高效令人惊叹。但它也很危险:当函数返回时,它的任何本地数据都将无法访问。这听起来似乎很合理,毕竟这是本地数据,但却会带来严重的问题。请看这段代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| const std = @import("std");
pub fn main() void {
const user1 = User.init(1, 10);
const user2 = User.init(2, 20);
std.debug.print("User {d} has power of {d}\n", .{user1.id, user1.power});
std.debug.print("User {d} has power of {d}\n", .{user2.id, user2.power});
}
pub const User = struct {
id: u64,
power: i32,
fn init(id: u64, power: i32) *User{
var user = User{
.id = id,
.power = power,
};
return &user;
}
};
|
粗瞥一眼,预期会有下面的输出:
1
2
| User 1 has power of 10
User 2 has power of 20
|
但实际上:
1
2
| User 2 has power of 20
User 9114745905793990681 has power of 0
|
你可能会得到不同的结果,但根据我的输出,user1
继承了user2
的值,而user2
的值是无意义的。这段代码的关键问题是User.init
返回局部user
的地址&user
。这被称为悬空指针,是指引用无效内存的指针。它是许多段错误(segfaults)的源头。
当一个栈帧从调用栈中弹出时,我们对该内存的任何引用都是无效的。尝试访问该内存的结果是未定义的。你可能会得到无意义的数据或段错误。我们可以试图理解我的输出,但这不是我们想要或甚至可以依赖的行为。
这类错误的一个挑战是,在有垃圾回收器的语言中,上述代码完全没有问题。例如,Go 会检测局部变量 user
超出了 init 函数的作用域,并在需要时确保其有效性(Go 如何做到这一点是一个实现细节,但它有几个选项,包括将数据移动到堆中,这就是下一部分要讨论的内容)。
而另一个问题,很遗憾地说,它是一个难以发现的错误。在我们上面的例子中,我们显然返回了一个局部地址。但这种行为可以隐藏在嵌套函数和复杂数据类型中。你是否看到了以下不完整代码的任何可能问题:
1
2
3
4
| fn read() !void {
const input = try readUserInput();
return Parser.parse(input);
}
|
无论Parser.parse
返回什么,它都将比变量input
存在更久。如果Parser
持有对 input
的引用,那将是一个悬空指针,等待着让我们的应用程序崩溃。理想情况下,如果 Parser
需要 input
生命周期尽可能长,它将复制 input
,并且该复制将与它自己的生命周期绑定(更多内容在下一部分)。但此处没有执行这一步骤。Parser
的文档可能会对它对 input
的期望或它如何使用 input
提供一些说明。缺少这些信息,我们可能需要深入代码来弄清楚。
为了解决我们上面例子里的错误,有个简单的方法是改变 init
,使它返回一个 User
而不是*User
(指向 User
的指针)。我们可以使用 return user
而非 return &user
。但这并不总是可能的。数据经常需要超越函数作用域的严格界限。为此,我们有了第三个内存区域–堆,这也是下一部分的主题。
在深入研究堆之前,我们要知道,在本指南结束之前,我们还将看到最后一个关于悬挂指针的示例。到那时,我们已经掌握了足够多的语言知识,可以给出一个不太复杂的示例。我之所以想重提这个话题,是因为对于来自垃圾回收语言的开发人员来说,这很可能会导致错误和挫败感。这一点你会掌握的。归根结底,就是要意识到数据的生命周期。
8 - 堆内存和分配器
原文地址:https://www.openmymind.net/learning_zig/heap_memory
迄今为止,我们所接触到的一切都有个限制,需要预先知道大小。数组总是有一个编译时已知的长度(事实上,长度是类型的一部分)。我们所有的字符串都是字符串字面量,其长度在编译时是已知的。
此外,我们所见过的两种内存管理策略,即全局数据和调用栈,虽然简单高效,但都有局限性。这两种策略都无法处理动态大小的数据,而且在数据生命周期方面都很固定。
本部分分为两个主题。第一个主题是第三个内存区域–堆的总体概述。另一个主题是 Zig 直接而独特的堆内存管理方法。即使你熟悉堆内存,比如使用过 C 语言的 malloc
,你也会希望阅读第一部分,因为它是 Zig 特有的。
堆
堆是我们可以使用的第三个也是最后一个内存区域。与全局数据和调用栈相比,堆有点像蛮荒之地:什么都可以使用。具体来说,在堆中,我们可以在运行时创建大小已知的内存,并完全控制其生命周期。
调用堆栈之所以令人惊叹,是因为它管理数据的方式简单且可预测(通过压入和弹出堆栈帧)。这一优点同时也是缺点:数据的生命周期与它在调用堆栈中的位置息息相关。堆则恰恰相反。它没有内置的生命周期,因此我们的数据可长可短。这个优点也是它的缺点:它没有内置的生命周期,所以如果我们不释放数据,就没有人会释放。
让我们来看一个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| const std = @import("std");
pub fn main() !void {
// we'll be talking about allocators shortly
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
// ** The next two lines are the important ones **
var arr = try allocator.alloc(usize, try getRandomCount());
defer allocator.free(arr);
for (0..arr.len) |i| {
arr[i] = i;
}
std.debug.print("{any}\n", .{arr});
}
fn getRandomCount() !u8 {
var seed: u64 = undefined;
try std.posix.getrandom(std.mem.asBytes(&seed));
var random = std.Random.DefaultPrng.init(seed);
return random.random().uintAtMost(u8, 5) + 5;
}
|
我们稍后将讨论 Zig 的分配器,目前需要知道的是分配器是 std.mem.Allocator
类型。我们使用了它的两种方法:alloc
和 free
。分配内存可能出错,故我们用 try
捕获调用 allocator.alloc
产生的错误。目前唯一可能的错误是 OutOfMemory
。其参数主要告诉我们它是如何工作的:它需要一个类型(T)和一个计数,成功时返回一个类型为 []T
的切片。它分配发生在运行时期间,必须如此,因为我们的计数只在运行时才可知。
一般来说,每次 alloc
都会有相应的 free
。alloc
分配内存,free
释放内存。不要让这段简单的代码限制了你的想象力。这种 try alloc
+ defer free
的模式很常见,这是有原因的:在我们分配内存的地方附近释放相对来说是万无一失的。但同样常见的是在一个地方分配,而在另一个地方释放。正如我们之前所说,堆没有内置的生命周期管理。你可以在 HTTP 处理程序中分配内存,然后在后台线程中释放,这是代码中两个完全独立的部分。
defer 和 errdefer
说句题外话,上面的代码介绍了一个新的语言特性:defer
,它在退出作用域时执行给定的代码。『作用域退出』包括到达作用域的结尾或从作用域返回。严格来说, defer
与分配器或内存管理并无严格关系;你可以用它来执行任何代码。但上述用法很常见。
Zig 的 defer
类似于 Go 的 defer
,但存在一个主要区别。在 Zig 中,defer
将在其包含作用域的末尾运行。在 Go 中,defer
是在包含函数的末尾运行。除非你是 Go 开发人员,否则 Zig 的做法可能更不令人惊讶。
与defer
相似的是 errdefer
,它作用与之类似,是在退出作用域时执行给定的代码,但只在返回错误时执行。在进行更复杂的设置时,如果因为出错而不得不撤销之前的分配,这将非常有用。
以下示例在复杂性上有所增加。它展示了 errdefer
和一个常见的模式,即在 init
函数中分配内存,并在 deinit
中释放:
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
| const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Game = struct {
players: []Player,
history: []Move,
allocator: Allocator,
fn init(allocator: Allocator, player_count: usize) !Game {
var players = try allocator.alloc(Player, player_count);
errdefer allocator.free(players);
// store 10 most recent moves per player
var history = try allocator.alloc(Move, player_count * 10);
return .{
.players = players,
.history = history,
.allocator = allocator,
};
}
fn deinit(game: Game) void {
const allocator = game.allocator;
allocator.free(game.players);
allocator.free(game.history);
}
};
|
这段代码主要突显两件事:
errdefer
的作用。在正常情况下,player
在 init
分配,在 deinit
释放。但有一种边缘情况,即 history
初始化失败。在这种情况下,我们需要撤销 players
的分配。- 我们动态分配的两个切片(
players
和 history
)的生命周期是基于我们的应用程序逻辑的。没有任何规则规定何时必须调用 deinit
或由谁调用。这是件好事,因为它为我们提供了任意的生命周期,但也存在缺点,就是如果从未调用 deinit
或调用 deinit
超过一次,就会出现混乱和错误。
init
和 deinit
的名字并不特殊。它们只是 Zig 标准库使用的,也是社区采纳的名称。在某些情况下,包括在标准库中,会使用 open
和 close
,或其他更适当的名称。
双重释放和内存泄漏
上面提到过,没有规则规定什么时候必须释放什么东西。但事实并非如此,还是有一些重要规则,只是它们不是强制的,需要你自己格外小心。
第一条规则是不可释放同一内存两次。
1
2
3
4
5
6
7
8
9
10
11
12
| const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var arr = try allocator.alloc(usize, 4);
allocator.free(arr);
allocator.free(arr);
std.debug.print("This won't get printed\n", .{});
}
|
可以预见到,最后一行代码不会被打印出来。这是因为我们释放了相同的内存两次。这被称为双重释放,是无效的。要避免这种情况似乎很简单,但在具有复杂生命周期的大型项目中,却很难发现。
第二条规则是,无法释放没有引用的内存。这听起来似乎很明显,但谁负责释放内存并不总是很清楚。下面的代码声明了一个转小写的函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| const std = @import("std");
const Allocator = std.mem.Allocator;
fn allocLower(allocator: Allocator, str: []const u8) ![]const u8 {
var dest = try allocator.alloc(u8, str.len);
for (str, 0..) |c, i| {
dest[i] = switch (c) {
'A'...'Z' => c + 32,
else => c,
};
}
return dest;
}
|
上面的代码没问题。但以下用法不是:
1
2
3
4
5
| // 对于这个特定的代码,我们应该使用 std.ascii.eqlIgnoreCase
fn isSpecial(allocator: Allocator, name: [] const u8) !bool {
const lower = try allocLower(allocator, name);
return std.mem.eql(u8, lower, "admin");
}
|
这是内存泄漏。allocLower
中创建的内存永远不会被释放。不仅如此,一旦 isSpecial
返回,这块内存就永远无法释放。在有垃圾收集器的语言中,当数据变得无法访问时,垃圾收集器最终会释放无用的内存。
但在上面的代码中,一旦 isSpecial
返回,我们就失去了对已分配内存的唯一引用,即 lower
变量。而直到我们的进程退出后,这块内存块才会释放。我们的函数可能只会泄漏几个字节,但如果它是一个长时间运行的进程,并且重复调用该函数,未被释放的内存块就会逐渐累积起来,最终会耗尽所有内存。
至少在双重释放的情况下,我们的程序会遭遇严重崩溃。内存泄漏可能很隐蔽。不仅仅是根本原因难以确定。真正的小泄漏或不常执行的代码中的泄漏甚至很难被发现。这是一个很常见的问题,Zig 提供了帮助,我们将在讨论分配器时看到。
创建与销毁
std.mem.Allocator
的alloc
方法会返回一个切片,其长度为传递的第二个参数。如果想要单个值,可以使用 create
和 destroy
而不是 alloc
和 free
。
前面几部分在学习指针时,我们创建了 User
并尝试增强它的功能。下面是基于堆的版本:
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
| const std = @import("std");
pub fn main() !void {
// again, we'll talk about allocators soon!
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
// create a User on the heap
var user = try allocator.create(User);
// free the memory allocated for the user at the end of this scope
defer allocator.destroy(user);
user.id = 1;
user.power = 100;
// this line has been added
levelUp(user);
std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}
fn levelUp(user: *User) void {
user.power += 1;
}
pub const User = struct {
id: u64,
power: i32,
};
|
create
方法接受一个参数,类型(T)。它返回指向该类型的指针或一个错误,即 !*T
。也许你想知道,如果我们创建了User
, 但没有设置 id
, power
时会发生什么。这就像将这些字段设置为未定义(undefined),其行为也是未定义的。意即,属性没有初始化时,在访问未初始化的变量,行为也是未定义,这意味着程序可能会出现不可预测的行为,比如返回错误的值、崩溃等问题。
当我们探索悬空指针时,函数错误地返回了本地user
的地址:
1
2
3
4
5
6
7
8
9
10
| pub const User = struct {
fn init(id: u64, power: i32) *User{
var user = User{
.id = id,
.power = power,
};
// this is a dangling pointer
return &user;
}
};
|
在这种情况下,返回一个 User
可能更有意义。但有时你会希望函数返回一个指向它所创建的东西的指针。当你想让生命周期不受调用栈的限制时,你就会这样做。为了解决上面的悬空指针问题,我们可以使用create
方法:
1
2
3
4
5
6
7
8
9
10
| // 我们的返回类型改变了,因为 init 现在可以失败了
// *User -> !*User
fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{
const user = try allocator.create(User);
user.* = .{
.id = id,
.power = power,
};
return user;
}
|
我引入了新的语法,user.* = .{...}
。这有点奇怪,我不是很喜欢它,但你会看到它。右侧是你已经见过的内容:它是一个带有类型推导的结构体初始化器。我们可以明确地使用 user.* = User{...}
。左侧的 user.*
是我们如何去引用该指针所指向的变量。&
接受一个 T 类型并给我们一个 *T
类型。.*
是相反的操作,应用于一个 *T
类型的值时,它给我们一个 T 类型。即,&
获取地址,.*
获取值。
请记住,create
返回一个 !*User
,所以我们的 user
是 *User
类型。
分配器 Allocator
Zig 的核心原则之一是无隐藏内存分配。根据你的背景,这听起来可能并不特别。但这与 C 语言中使用标准库的 malloc 函数分配内存的做法形成了鲜明的对比。在 C 语言中,如果你想知道一个函数是否分配内存,你需要阅读源代码并查找对 malloc 的调用。
Zig 没有默认的分配器。在上述所有示例中,分配内存的函数都使用了一个 std.mem.Allocator
参数。按照惯例,这通常是第一个参数。所有 Zig 标准库和大多数第三方库都要求调用者在分配内存时提供一个分配器。
这种显式性有两种形式。在简单的情况下,每次函数调用都会提供分配器。这样的例子很多,但 std.fmt.allocPrint
是你迟早会用到的一个。它类似于我们一直在使用的 std.debug.print,只是分配并返回一个字符串,而不是将其写入 stderr
:
1
2
| const say = std.fmt.allocPrint(allocator, "It's over {d}!!!", .{user.power});
defer allocator.free(say);
|
另一种形式是将 Allocator
传递给 init
,然后由对象内部使用。这种方法不那么明确,因为你已经给了对象一个分配器来使用,但你不知道哪些方法调用将实际分配。对于长寿命对象来说,这种方法更实用。
注入分配器的优势不仅在于显式,还在于灵活性。std.mem.Allocator
是一个接口,提供了 alloc
、free
、create
和 destroy
函数以及其他一些函数。到目前为止,我们只看到了 std.heap.GeneralPurposeAllocator
,但标准库或第三方库中还有其他实现。
Zig 没有用于创建接口的语法糖。一种类似于接口的模式是带标签的联合(tagged unions),不过与真正的接口相比,这种模式相对受限。整个标准库中也探索了一些其他模式,例如 std.mem.Allocator
。本指南不探讨这些接口模式。
如果你正在构建一个库,那么最好接受一个 std.mem.Allocator
,然后让库的用户决定使用哪种分配器实现。否则,你就需要选择正确的分配器,正如我们将看到的,这些分配器并不相互排斥。在你的程序中创建不同的分配器可能有很好的理由。
通用分配器 GeneralPurposeAllocator
顾名思义,std.heap.GeneralPurposeAllocator
是一种通用的、线程安全的分配器,可以作为应用程序的主分配器。对于许多程序来说,这是唯一需要的分配器。程序启动时,会创建一个分配器并传递给需要它的函数。我的 HTTP 服务器库中的示例代码就是一个很好的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| const std = @import("std");
const httpz = @import("httpz");
pub fn main() !void {
// create our general purpose allocator
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
// get an std.mem.Allocator from it
const allocator = gpa.allocator();
// pass our allocator to functions and libraries that require it
var server = try httpz.Server().init(allocator, .{.port = 5882});
var router = server.router();
router.get("/api/user/:id", getUser);
// blocks the current thread
try server.listen();
}
|
我们创建了 GeneralPurposeAllocator
,从中获取一个 std.mem.Allocator
并将其传递给 HTTP 服务器的 init
函数。在一个更复杂的项目中,声明的变量allocator
可能会被传递给代码的多个部分,每个部分可能都会将其传递给自己的函数、对象和依赖。
你可能会注意到,创建 gpa
的语法有点奇怪。什么是GeneralPurposeAllocator(.{}){}
?
我们之前见过这些东西,只是现在都混合了起来。std.heap.GeneralPurposeAllocator
是一个函数,由于它使用的是 PascalCase
(帕斯卡命名法),我们知道它返回一个类型。(下一部分会更多讨论泛型)。也许这个更明确的版本会更容易解读:
1
2
3
4
5
6
| const T = std.heap.GeneralPurposeAllocator(.{});
var gpa = T{};
// 等同于:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
也许你仍然不太确信 .{}
的含义。我们之前也见过它:.{}
是一个具有隐式类型的结构体初始化器。
类型是什么,字段在哪里?类型其实是 std.heap.general_purpose_allocator.Config
,但它并没有直接暴露出来,这也是我们没有显式给出类型的原因之一。没有设置字段是因为 Config 结构定义了默认值,我们将使用默认值。这是配置、选项的中常见的模式。事实上,我们在下面几行向 init
传递 .{.port = 5882}
时又看到了这种情况。在本例中,除了端口这一个字段外,我们都使用了默认值。
std.testing.allocator
希望当我们谈到内存泄漏时,你已经足够烦恼,而当我提到 Zig 可以提供帮助时,你肯定渴望了解更多这方面内容。这种帮助来自 std.testing.allocator
,它是一个 std.mem.Allocator
实现。目前,它基于通用分配器(GeneralPurposeAllocator)实现,并与 Zig 的测试运行器进行了集成,但这只是实现细节。重要的是,如果我们在测试中使用 std.testing.allocator
,就能捕捉到大部分内存泄漏。
你可能已经熟悉了动态数组(通常称为 ArrayLists)。在许多动态编程语言中,所有数组都是动态的。动态数组支持可变数量的元素。Zig 有一个通用 ArrayList,但我们将创建一个专门用于保存整数的 ArrayList,并演示泄漏检测:
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
| pub const IntList = struct {
pos: usize,
items: []i64,
allocator: Allocator,
fn init(allocator: Allocator) !IntList {
return .{
.pos = 0,
.allocator = allocator,
.items = try allocator.alloc(i64, 4),
};
}
fn deinit(self: IntList) void {
self.allocator.free(self.items);
}
fn add(self: *IntList, value: i64) !void {
const pos = self.pos;
const len = self.items.len;
if (pos == len) {
// we've run out of space
// create a new slice that's twice as large
var larger = try self.allocator.alloc(i64, len * 2);
// copy the items we previously added to our new space
@memcpy(larger[0..len], self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
|
有趣的部分发生在 add
函数里,当 pos == len
时,表明我们已经填满了当前数组,并且需要创建一个更大的数组。我们可以像这样使用IntList
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| const std = @import("std");
const Allocator = std.mem.Allocator;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try IntList.init(allocator);
defer list.deinit();
for (0..10) |i| {
try list.add(@intCast(i));
}
std.debug.print("{any}\n", .{list.items[0..list.pos]});
}
|
代码运行并打印出正确的结果。不过,尽管我们在 list
上调用了 deinit
,还是出现了内存泄漏。如果你没有发现也没关系,因为我们要写一个测试,并使用 std.testing.allocator
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| const testing = std.testing;
test "IntList: add" {
// We're using testing.allocator here!
var list = try IntList.init(testing.allocator);
defer list.deinit();
for (0..5) |i| {
try list.add(@intCast(i+10));
}
try testing.expectEqual(@as(usize, 5), list.pos);
try testing.expectEqual(@as(i64, 10), list.items[0]);
try testing.expectEqual(@as(i64, 11), list.items[1]);
try testing.expectEqual(@as(i64, 12), list.items[2]);
try testing.expectEqual(@as(i64, 13), list.items[3]);
try testing.expectEqual(@as(i64, 14), list.items[4]);
}
|
@as
是一个执行类型强制的内置函数。如果你好奇什么我们的测试要用到这么多,那么你不是唯一一个。从技术上讲,这是因为第二个参数,即 actual
,被强制为第一个参数,即 expected
。在上面的例子中,我们的期望值都是 comptime_int
,这就造成了问题。包括我在内的许多人都认为这是一种奇怪而不幸的行为。
如果你按照步骤操作,把测试放在 IntList
和 main
的同一个文件中。Zig 的测试通常写在同一个文件中,经常在它们测试的代码附近。当使用 zig test learning.zig
运行测试时,我们会得到了一个令人惊喜的失败:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| Test [1/1] test.IntList: add... [gpa] (err): memory address 0x101154000 leaked:
/code/zig/learning.zig:26:32: 0x100f707b7 in init (test)
.items = try allocator.alloc(i64, 2),
^
/code/zig/learning.zig:55:29: 0x100f711df in test.IntList: add (test)
var list = try IntList.init(testing.allocator);
... MORE STACK INFO ...
[gpa] (err): memory address 0x101184000 leaked:
/code/test/learning.zig:40:41: 0x100f70c73 in add (test)
var larger = try self.allocator.alloc(i64, len * 2);
^
/code/test/learning.zig:59:15: 0x100f7130f in test.IntList: add (test)
try list.add(@intCast(i+10));
|
此处有多个内存泄漏。幸运的是,测试分配器准确地告诉我们泄漏的内存是在哪里分配的。你现在能发现泄漏了吗?如果没有,请记住,通常情况下,每个 alloc
都应该有一个相应的 free
。我们的代码在 deinit
中调用 free
一次。然而在 init
中 alloc
被调用一次,每次调用 add
并需要更多空间时也会调用 alloc
。每次我们 alloc
更多空间时,都需要 free
之前的 self.items
。
1
2
3
4
5
6
7
| // 现有的代码
var larger = try self.allocator.alloc(i64, len * 2);
@memcpy(larger[0..len], self.items);
// 添加的代码
// 释放先前分配的内存
self.allocator.free(self.items);
|
将items
复制到我们的 larger
切片中后, 添加最后一行free
可以解决泄漏的问题。如果运行 zig test learning.zig
,便不会再有错误。
ArenaAllocator
通用分配器(GeneralPurposeAllocator)是一个合理的默认设置,因为它在所有可能的情况下都能很好地工作。但在程序中,你可能会遇到一些固定场景,使用更专业的分配器可能会更合适。其中一个例子就是需要在处理完成后丢弃的短期状态。解析器(Parser)通常就有这样的需求。一个 parse
函数的基本轮廓可能是这样的
1
2
3
4
5
6
7
8
9
10
| fn parse(allocator: Allocator, input: []const u8) !Something {
const state = State{
.buf = try allocator.alloc(u8, 512),
.nesting = try allocator.alloc(NestType, 10),
};
defer allocator.free(state.buf);
defer allocator.free(state.nesting);
return parseInternal(allocator, state, input);
}
|
虽然这并不难管理,但 parseInternal
内可能还会申请临时内存,当然这些内存也需要释放。作为替代方案,我们可以创建一个 ArenaAllocator
,一次性释放所有分配:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| fn parse(allocator: Allocator, input: []const u8) !Something {
// create an ArenaAllocator from the supplied allocator
var arena = std.heap.ArenaAllocator.init(allocator);
// this will free anything created from this arena
defer arena.deinit();
// create an std.mem.Allocator from the arena, this will be
// the allocator we'll use internally
const aa = arena.allocator();
const state = State{
// we're using aa here!
.buf = try aa.alloc(u8, 512),
// we're using aa here!
.nesting = try aa.alloc(NestType, 10),
};
// we're passing aa here, so any we're guaranteed that
// any other allocation will be in our arena
return parseInternal(aa, state, input);
}
|
ArenaAllocator
接收一个子分配器(在本例中是传入 init
的分配器),然后创建一个新的 std.mem.Allocator
。当使用这个新的分配器分配或创建内存时,我们不需要调用 free 或 destroy。当我们调用 arena.deinit
时,会一次性释放所有该分配器申请的内存。事实上,ArenaAllocator
的 free
和 destroy
什么也不做。
必须谨慎使用 ArenaAllocator
。由于无法释放单个分配,因此需要确保 ArenaAllocator
的 deinit
会在合理的内存增长范围内被调用。有趣的是,这种知识可以是内部的,也可以是外部的。例如,在上述代码中,由于状态生命周期的细节属于内部事务,因此在解析器中利用 ArenaAllocator
是合理的。
像 ArenaAllocator
这样的具有一次性释放所有申请内存的分配器,会破坏每一次 alloc
都应该有相应 free
的规则。不过,如果你收到的是一个 std.mem.Allocator
,就不应对其底层实现做任何假设。
我们的 IntList
却不是这样。它可以用来存储 10 个或 1000 万个值。它的生命周期可以以毫秒为单位,也可以以周为单位。它无法决定使用哪种类型的分配器。使用 IntList 的代码才有这种知识。最初,我们是这样管理 IntList
的:
1
2
3
4
5
| var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try IntList.init(allocator);
defer list.deinit();
|
我们可以选择 ArenaAllocator
替代:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const aa = arena.allocator();
var list = try IntList.init(aa);
// 说实话,我很纠结是否应该调用 list.deinit。
// 从技术上讲,我们不必这样做,因为我们在上面调用了 defer arena.deinit()。
defer list.deinit();
...
|
由于 IntList
接受的参数是 std.mem.Allocator
, 因此我们不需要做什么改变。如果 IntList
内部创建了自己的 ArenaAllocator
,那也是可行的。允许在ArenaAllocator
内部创建ArenaAllocator
。
最后举个简单的例子,我上面提到的 HTTP 服务器在响应中暴露了一个 ArenaAllocator
。一旦发送了响应,它就会被清空。由于ArenaAllocator
的生命周期可以预测(从请求开始到请求结束),因此它是一种高效的选择。就性能和易用性而言,它都是高效的。
固定缓冲区分配器 FixedBufferAllocator
我们要讨论的最后一个分配器是 std.heap.FixedBufferAllocator
,它可以从我们提供的缓冲区(即 []u8
)中分配内存。这种分配器有两大好处。首先,由于所有可能使用的内存都是预先创建的,因此速度很快。其次,它自然而然地限制了可分配内存的数量。这一硬性限制也可以看作是一个缺点。另一个缺点是,free
和 destroy
只对最后分配/创建的项目有效(想想堆栈)。调用释放非最后分配的内存是安全的,但不会有任何作用。
译者注:这不是覆盖的问题。FixedBufferAllocator
会按照栈的方式进行内存分配和释放。你可以分配新的内存块,但只能按照后进先出(LIFO)的顺序释放它们。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| const std = @import("std");
pub fn main() !void {
var buf: [150]u8 = undefined;
var fa = std.heap.FixedBufferAllocator.init(&buf);
// this will free all memory allocate with this allocator
defer fa.reset();
const allocator = fa.allocator();
const json = try std.json.stringifyAlloc(allocator, .{
.this_is = "an anonymous struct",
.above = true,
.last_param = "are options",
}, .{.whitespace = .indent_2});
// We can free this allocation, but since we know that our allocator is
// a FixedBufferAllocator, we can rely on the above `defer fa.reset()`
defer allocator.free(json);
std.debug.print("{s}\n", .{json});
}
|
输出内容:
1
2
3
4
5
| {
"this_is": "an anonymous struct",
"above": true,
"last_param": "are options"
}
|
但如果将 buf
更改为 [120]u8
,将得到一个内存不足的错误。
固定缓冲区分配器(FixedBufferAllocators)的常见模式是 reset
并重复使用,竞技场分配器(ArenaAllocators)也是如此。这将释放所有先前的分配,并允许重新使用分配器。
由于没有默认的分配器,Zig 在分配方面既透明又灵活。std.mem.Allocator
接口非常强大,它允许专门的分配器封装更通用的分配器,正如我们在ArenaAllocator
中看到的那样。
更广泛地说,我们希望堆分配的强大功能和相关责任是显而易见的。对于大多数程序来说,分配任意大小、任意生命周期的内存的能力是必不可少的。
然而,由于动态内存带来的复杂性,你应该注意寻找替代方案。例如,上面我们使用了 std.fmt.allocPrint
,但标准库中还有一个 std.fmt.bufPrint
。后者使用的是缓冲区而不是分配器:
1
2
3
4
5
6
7
8
9
10
| const std = @import("std");
pub fn main() !void {
const name = "Leto";
var buf: [100]u8 = undefined;
const greeting = try std.fmt.bufPrint(&buf, "Hello {s}", .{name});
std.debug.print("{s}\n", .{greeting});
}
|
该 API 将内存管理的负担转移给了调用者。如果名称较长或 buf
较小,bufPrint
可能会返回 NoSpaceLeft
的错误。但在很多情况下,应用程序都有已知的限制,例如名称的最大长度。在这种情况下,bufPrint
更安全、更快速。
动态分配的另一个可行替代方案是将数据流传输到 std.io.Writer
。与我们的 Allocator
一样,Writer
也是被许多具体类型实现的接口。上面,我们使用 stringifyAlloc
将 JSON 序列化为动态分配的字符串。我们本可以使用 stringify
将其写入到一个 Writer 中:
1
2
3
4
5
6
7
8
9
10
11
| const std = @import("std");
pub fn main() !void {
const out = std.io.getStdOut();
try std.json.stringify(.{
.this_is = "an anonymous struct",
.above = true,
.last_param = "are options",
}, .{.whitespace = .indent_2}, out.writer());
}
|
Allocator
通常是函数的第一个参数,而 Writer
通常是最后一个参数。ಠ_ಠ
在很多情况下,用 std.io.BufferedWriter
封装我们的 Writer
会大大提高性能。
我们的目标并不是消除所有动态分配。这行不通,因为这些替代方案只有在特定情况下才有意义。但现在你有了很多选择。从堆栈到通用分配器,以及所有介于两者之间的东西,比如静态缓冲区、流式 Writer
和专用分配器。
9 - 泛型
原文地址:https://www.openmymind.net/learning_zig/generics
在上一小节中,我们创建了一个名为 IntList
的动态数组。该数据结构的目标是保存数目不定的数值。虽然我们使用的算法适用于任何类型的数据,但我们的实现与 i64 值绑定。这就需要使用泛型,其目的是从特定类型中抽象出算法和数据结构。
许多语言使用特殊的语法和特定的泛型规则来实现泛型。而在 Zig 中,泛型并不是一种特定的功能,而更多地体现了语言的能力。具体来说,泛型利用了 Zig 强大的编译时元编程功能。
我们先来看一个简单的例子,以了解我们的想法:
1
2
3
4
5
6
7
8
9
10
11
12
13
| const std = @import("std");
pub fn main() !void {
var arr: IntArray(3) = undefined;
arr[0] = 1;
arr[1] = 10;
arr[2] = 100;
std.debug.print("{any}\n", .{arr});
}
fn IntArray(comptime length: usize) type {
return [length]i64;
}
|
上述代码会打印了 { 1, 10, 100 }
。有趣的是,我们有一个返回类型的函数(因此函数是 PascalCase)。这也不是普通的类型,而是由函数参数动态确定的类型。这段代码之所以能运行,是因为我们将 length
声明为 comptime
。也就是说,我们要求任何调用 IntArray
的人传递一个编译时已知的长度参数。这是必要的,因为我们的函数返回一个类型,而类型必须始终是编译时已知的。
函数可以返回任何类型,而不仅仅是基本类型和数组。例如,只需稍作改动,我们就可以让函数返回一个结构体:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| const std = @import("std");
pub fn main() !void {
var arr: IntArray(3) = undefined;
arr.items[0] = 1;
arr.items[1] = 10;
arr.items[2] = 100;
std.debug.print("{any}\n", .{arr.items});
}
fn IntArray(comptime length: usize) type {
return struct {
items: [length]i64,
};
}
|
也许看起来很奇怪,但 arr
的类型确实是 IntArray(3)
。它和其他类型一样,是一个类型,而 arr
和其他值一样,是一个值。如果我们调用 IntArray(7)
,那就是另一种类型了。也许我们可以让事情变得更简洁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| const std = @import("std");
pub fn main() !void {
var arr = IntArray(3).init();
arr.items[0] = 1;
arr.items[1] = 10;
arr.items[2] = 100;
std.debug.print("{any}\n", .{arr.items});
}
fn IntArray(comptime length: usize) type {
return struct {
items: [length]i64,
fn init() IntArray(length) {
return .{
.items = undefined,
};
}
};
}
|
乍一看,这可能并不整齐。但除了匿名和嵌套在一个函数中之外,我们的结构看起来就像我们目前看到的其他结构一样。它有字段,有函数。你知道人们常说『如果它看起来像一只鸭子,那么就就是一只鸭子』。那么,这个结构看起来、游起来和叫起来都像一个正常的结构,因为它本身就是一个结构体。
希望上面这个示例能让你熟悉返回类型的函数和相应的语法。为了得到一个更典型的范型,我们需要做最后一个改动:我们的函数必须接受一个类型。实际上,这只是一个很小的改动,但 type
会比 usize
更抽象,所以我们慢慢来。让我们进行一次飞跃,修改之前的 IntList
,使其能与任何类型一起工作。我们先从基本结构开始:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| fn List(comptime T: type) type {
return struct {
pos: usize,
items: []T,
allocator: Allocator,
fn init(allocator: Allocator) !List(T) {
return .{
.pos = 0,
.allocator = allocator,
.items = try allocator.alloc(T, 4),
};
}
};
}
|
上面的结构与 IntList
几乎完全相同,只是 i64
被替换成了 T
。我们本可以叫它 item_type
。不过,按照 Zig 的命名约定,type
类型的变量使用 PascalCase
风格。
无论好坏,使用单个字母表示类型参数的历史都比 Zig 要悠久得多。在大多数语言中,T 是常用的默认值,但你也会看到根据具体语境而变化的情况,例如哈希映射使用 K 和 V 来表示键和值参数类型。
如果你对上述代码还有疑问,可以着重看使用 T 的两个地方:items:[]T
和 allocator.alloc(T, 4)
。当我们要使用这个通用类型时,我们将使用
1
| var list = try List(u32).init(allocator);
|
编译代码时,编译器会通过查找每个 T
并将其替换为 u32
来创建一个新类型。如果我们再次使用 List(u32)
,编译器将重新使用之前创建的类型。如果我们为 T
指定一个新值,例如 List(bool)
或 List(User)
,就会创建与之对应的新类型。
为了完成通用的 List,我们可以复制并粘贴 IntList
代码的其余部分,然后用 T
替换 i64
:
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
| const std = @import("std");
const Allocator = std.mem.Allocator;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try List(u32).init(allocator);
defer list.deinit();
for (0..10) |i| {
try list.add(@intCast(i));
}
std.debug.print("{any}\n", .{list.items[0..list.pos]});
}
fn List(comptime T: type) type {
return struct {
pos: usize,
items: []T,
allocator: Allocator,
fn init(allocator: Allocator) !List(T) {
return .{
.pos = 0,
.allocator = allocator,
.items = try allocator.alloc(T, 4),
};
}
fn deinit(self: List(T)) void {
self.allocator.free(self.items);
}
fn add(self: *List(T), value: T) !void {
const pos = self.pos;
const len = self.items.len;
if (pos == len) {
// we've run out of space
// create a new slice that's twice as large
var larger = try self.allocator.alloc(T, len * 2);
// copy the items we previously added to our new space
@memcpy(larger[0..len], self.items);
self.allocator.free(self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
}
|
我们的 init
函数返回一个 List(T)
,我们的 deinit
和 add
函数使用 List(T)
和 *List(T)
作为参数。在我们的这个简单的示例中,这样做没有问题,但对于大型数据结构,编写完整的通用名称可能会变得有点繁琐,尤其是当我们有多个类型参数时(例如,散列映射的键和值需要使用不同的类型)。@This()
内置函数会返回它被调用时的最内层类型。一般来说,我们会这样定义 List(T)
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| fn List(comptime T: type) type {
return struct {
pos: usize,
items: []T,
allocator: Allocator,
// Added
const Self = @This();
fn init(allocator: Allocator) !Self {
// ... same code
}
fn deinit(self: Self) void {
// .. same code
}
fn add(self: *Self, value: T) !void {
// .. same code
}
};
}
|
Self
并不是一个特殊的名称,它只是一个变量,而且是 PascalCase
风格,因为它的值是一种类型。我们可以在之前使用 List(T)
的地方用 Self
来替代。
我们可以创建更复杂的示例,使用多种类型参数和更先进的算法。但归根结底,泛型代码的关键点与上述简单示例相差无几。在下一部分,我们将在研究标准库中的 ArrayList(T)
和 StringHashMap(V)
时再次讨论泛型。
10 - 实战
原文地址:https://www.openmymind.net/learning_zig/coding_in_zig
在介绍了 Zig 语言的大部分内容之后,我们将对一些主题进行回顾,并展示几种使用 Zig 编程时一些实用的技巧。在此过程中,我们将介绍更多的标准库,并介绍一些稍复杂些的代码片段。
悬空指针 Dangling Pointers
我们首先来看看更多关于悬空指针的例子。这似乎是一个奇怪的问题,但如果你之前主要使用带垃圾回收的语言,这可能是你学习 Zig 最大的障碍。
你能猜到下面的输出是什么吗?
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
| const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var lookup = std.StringHashMap(User).init(allocator);
defer lookup.deinit();
const goku = User{.power = 9001};
try lookup.put("Goku", goku);
// returns an optional, .? would panic if "Goku"
// wasn't in our hashmap
const entry = lookup.getPtr("Goku").?;
std.debug.print("Goku's power is: {d}\n", .{entry.power});
// returns true/false depending on if the item was removed
_ = lookup.remove("Goku");
std.debug.print("Goku's power is: {d}\n", .{entry.power});
}
const User = struct {
power: i32,
};
|
当我运行这个程序时,我得到了
1
2
| Goku's power is: 9001
Goku's power is: -1431655766
|
这段代码引入了 Zig 的 std.StringHashMap
,它是 std.AutoHashMap
的特定版本,键类型设置为 []const u8
。即使你不能百分百确定发生了什么,也可以猜测我的输出与我们从 lookup
中删除条目后的第二次打印有关。注释掉删除的调用,输出就正常了。
理解上述代码的关键在于了解数据在内存的中位置,或者换句话说,了解数据的所有者。请记住,Zig 参数是按值传递的,也就是说,我们传递的是值的浅副本。我们 lookup
中的 User
与 goku
引用的内存不同。我们上面的代码有两个用户,每个用户都有自己的所有者。goku
的所有者是 main
,而它的副本的所有者是 lookup
。
getPtr
方法返回的是指向 map
中值的指针,在我们的例子中,它返回的是 *User
。问题就在这里,删除会使我们的 entry
指针失效。在这个示例中,getPtr
和 remove
的位置很近,因此问题也很明显。但不难想象,代码在调用 remove
时,并不知道 entry
的引用被保存在其他地方了。
在编写这个示例时,我并不确定会发生什么。删除有可能是通过设置内部标志来实现的,实际删除是惰性的。如果是这样的话,上面的示例在简单的情况下可能会 “奏效”,但在更复杂的情况下就会失败。这听起来非常难以调试。
除了不调用 remove
之外,我们还可以用几种不同的方法来解决这个问题。首先,我们可以使用 get
而不是 getPtr
。这样 lookup
将返回一个 User
的副本,而不再是 *User
。这样我们就有了三个用户:
- 定义在函数内部的
goku
,main
函数是其所有者 - 调用
lookup.put
时,形式参数会得到 goku
一个的副本,lookup
是其所有者 - 使用
get
函数返回的 entry
,main
函数是其所有者
由于 entry
现在是 User
的独立副本,因此将其从 lookup
中删除不会再使其失效。
另一种方法是将 lookup
的类型从 StringHashMap(User)
改为 StringHashMap(*const User)
。这段代码可以工作:
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
| const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
// User -> *const User
var lookup = std.StringHashMap(*const User).init(allocator);
defer lookup.deinit();
const goku = User{.power = 9001};
// goku -> &goku
try lookup.put("Goku", &goku);
// getPtr -> get
const entry = lookup.get("Goku").?;
std.debug.print("Goku's power is: {d}\n", .{entry.power});
_ = lookup.remove("Goku");
std.debug.print("Goku's power is: {d}\n", .{entry.power});
}
const User = struct {
power: i32,
};
|
上述代码中有许多微妙之处。首先,我们现在只有一个用户 goku
。lookup
和 entry
中的值都是对 goku
的引用。我们对 remove
的调用仍然会删除 lookup
中的值,但该值只是 user
的地址,而不是 user
本身。如果我们坚持使用 getPtr
,那么被 remove
后,我们就会得到一个无效的 **User
。在这两种解决方案中,我们都必须使用 get
而不是 getPtr
,但在这种情况下,我们只是复制地址,而不是完整的 User
。对于占用内存较多的对象来说,这可能是一个很大的区别。
如果把所有东西都放在一个函数中,再加上一个像 User
这样的小值,这仍然像是一个人为制造的问题。我们需要一个能让数据所有权成为当务之急的例子。
所有权 Ownership
我喜欢哈希表(HashMap),因为这是每个人都知道并且会经常使用的结构。它们有很多不同的用例,其中大部分你可能都用过。虽然哈希表可以用在一个短期查找的地方,但通常用于长期查找,因此插入其内的值需要同样长的生命周期。
这段代码将使用终端中输入的名称来填充我们的 lookup
。如果名字为空,就会停止提示循环。最后,它会检测 Leto
是否出现在 lookup
中。
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
| const std = @import("std");
const builtin = @import("builtin");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var lookup = std.StringHashMap(User).init(allocator);
defer lookup.deinit();
// stdin is an std.io.Reader
// the opposite of an std.io.Writer, which we already saw
const stdin = std.io.getStdIn().reader();
// stdout is an std.io.Writer
const stdout = std.io.getStdOut().writer();
var i: i32 = 0;
while (true) : (i += 1) {
var buf: [30]u8 = undefined;
try stdout.print("Please enter a name: ", .{});
if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
var name = line;
// Windows平台换行以`\r\n`结束
// 所以需要截取\r以获取控制台输入字符
if (builtin.os.tag == .windows) {
name = @constCast(std.mem.trimRight(u8, name, "\r"));
}
if (name.len == 0) {
break;
}
try lookup.put(name, .{.power = i});
}
}
const has_leto = lookup.contains("Leto");
std.debug.print("{any}\n", .{has_leto});
}
const User = struct {
power: i32,
};
|
上述代码虽然区分大小写,但无论我们如何完美地输入 Leto
,contains
总是返回 false
。让我们通过遍历 lookup
打印其值来调试一下:
1
2
3
4
5
6
| // 将这段代码放在 while 循环之后
var it = lookup.iterator();
while (it.next()) |kv| {
std.debug.print("{s} == {any}\n", .{kv.key_ptr.*, kv.value_ptr.*});
}
|
这种迭代器模式在 Zig 中很常见,它依赖于 while 和可选类型(Optional
)之间的协同作用。我们的迭代器返回指向键和值的指针,因此我们用 .*
对它们进行反引用,以访问实际值而不是地址。输出结果将取决于你输入的内容,但我得到的是
1
2
3
4
5
6
7
8
9
10
11
| Please enter a name: Paul
Please enter a name: Teg
Please enter a name: Leto
Please enter a name:
�� == learning.User{ .power = 1 }
��� == learning.User{ .power = 0 }
��� == learning.User{ .power = 2 }
false
|
值看起来没问题,但键不一样。如果你不确定发生了什么,那可能是我的错。之前,我故意误导了你的注意力。我说哈希表通常声明周期会比较长,因此需要同等生命周期的值(value)。事实上,哈希表不仅需要长生命周期的值,还需要长生命周期的键(key)!请注意,buf
是在 while
循环中定义的。当我们调用 put
时,我们给了哈希表插入一个键值对,这个键的生命周期比哈希表本身短得多。将 buf
移到 while
循环之外可以解决生命周期问题,但每次迭代都会重复使用缓冲区。由于我们正在更改底层的键数据,因此它仍然无法工作。
对于上述代码,实际上只有一种解决方案:我们的 lookup
必须拥有键的所有权。我们需要添加一行并修改另一行:
1
2
3
4
5
| // 用这两行替换现有的 lookup.put
const owned_name = try allocator.dupe(u8, name);
// name -> owned_name
try lookup.put(owned_name, .{.power = i});
|
dupe
是 std.mem.Allocator
中的一个方法,我们以前从未见过。它会分配给定值的副本。代码现在可以工作了,因为我们的键现在在堆上,比 lookup
的生命周期更长。事实上,我们在延长这些字符串的生命周期方面做得太好了,以至于引入了内存泄漏。
你可能以为当我们调用 lookup.deinit 时,键和值就会被释放。但 StringHashMap 并没有放之四海而皆准的解决方案。首先,键可能是字符串文字,无法释放。其次,它们可能是用不同的分配器创建的。最后,虽然更先进,但在某些情况下,键可能不属于哈希表。
唯一的解决办法就是自己释放键值。在这一点上,创建我们自己的 UserLookup
类型并在 deinit
函数中封装这一清理逻辑可能会比较合理。一种简单的改法:
1
2
3
4
5
6
7
8
| // 用以下的代码替换现有的 defer lookup.deinit();
defer {
var it = lookup.keyIterator();
while (it.next()) |key| {
allocator.free(key.*);
}
lookup.deinit();
}
|
这里的 defer
逻辑使用了一个代码快,它释放每个键,最后去释放 lookup
本身。我们使用的 keyIterator
只会遍历键。迭代器的值是指向哈希映射中键的指针,即 *[]const u8
。我们希望释放实际的值,因为这是我们通过 dupe
分配的,所以我们使用 key.*
.
我保证,关于悬挂指针和内存管理的讨论已经结束了。我们所讨论的内容可能还不够清晰或过于抽象。当你有更实际的问题需要解决时,再重新讨论这个问题也不迟。不过,如果你打算编写任何稍具规模(non-trivial)的程序,这几乎肯定是你需要掌握的内容。当你觉得可以的时候,我建议你参考上面这个示例,并自己动手实践一下。引入一个 UserLookup
类型来封装我们必须做的所有内存管理。尝试使用 *User
代替 User
,在堆上创建用户,然后像处理键那样释放它们。编写覆盖新结构的测试,使用 std.testing.allocator
确保不会泄漏任何内存。
ArrayList
现在你可以忘掉我们的 IntList
和我们创建的通用替代方案了。Zig 标准库中有一个动态数组实现:std.ArrayList(T)
。
它是相当标准的东西,但由于它如此普遍需要和使用的数据结构,值得看看它的实际应用:
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
| const std = @import("std");
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var arr = std.ArrayList(User).init(allocator);
defer {
for (arr.items) |user| {
user.deinit(allocator);
}
arr.deinit();
}
// stdin is an std.io.Reader
// the opposite of an std.io.Writer, which we already saw
const stdin = std.io.getStdIn().reader();
// stdout is an std.io.Writer
const stdout = std.io.getStdOut().writer();
var i: i32 = 0;
while (true) : (i += 1) {
var buf: [30]u8 = undefined;
try stdout.print("Please enter a name: ", .{});
if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
var name = line;
if (builtin.os.tag == .windows) {
name = @constCast(std.mem.trimRight(u8, name, "\r"));
}
if (name.len == 0) {
break;
}
const owned_name = try allocator.dupe(u8, name);
try arr.append(.{.name = owned_name, .power = i});
}
}
var has_leto = false;
for (arr.items) |user| {
if (std.mem.eql(u8, "Leto", user.name)) {
has_leto = true;
break;
}
}
std.debug.print("{any}\n", .{has_leto});
}
const User = struct {
name: []const u8,
power: i32,
fn deinit(self: User, allocator: Allocator) void {
allocator.free(self.name);
}
};
|
以上是哈希表代码的基于 ArrayList(User)
的另一种实现。所有相同的生命周期和内存管理规则都适用。请注意,我们仍在创建 name
的副本,并且仍在删除 ArrayList
之前释放每个 name
。
现在是指出 Zig 没有属性或私有字段的好时机。当我们访问 arr.items
来遍历值时,就可以看到这一点。没有属性的原因是为了消除阅读 Zig 代码中的歧义。在 Zig 中,如果看起来像字段访问,那就是字段访问。我个人认为,没有私有字段是一个错误,但我们可以解决这个问题。我已经习惯在字段前加上下划线,表示『仅供内部使用』。
由于字符串的类型是 []u8
或 []const u8
,因此 ArrayList(u8)
是字符串构造器的合适类型,比如 .NET 的 StringBuilder
或 Go 的 strings.Builder
。事实上,当一个函数的参数是 Writer
而你需要一个字符串时,就会用到 ArrayList(u8)
。我们之前看过一个使用 std.json.stringify
将 JSON 输出到 stdout
的示例。下面是将 JSON 输出到 ArrayList(u8)
的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var out = std.ArrayList(u8).init(allocator);
defer out.deinit();
try std.json.stringify(.{
.this_is = "an anonymous struct",
.above = true,
.last_param = "are options",
}, .{.whitespace = .indent_2}, out.writer());
std.debug.print("{s}\n", .{out.items});
}
|
Anytype
在语言概述的第一部分中,我们简要介绍了 anytype
。这是一种非常有用的编译时 duck 类型。下面是一个简单的 logger:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| pub const Logger = struct {
level: Level,
// "error" is reserved, names inside an @"..." are always
// treated as identifiers
const Level = enum {
debug,
info,
@"error",
fatal,
};
fn info(logger: Logger, msg: []const u8, out: anytype) !void {
if (@intFromEnum(logger.level) <= @intFromEnum(Level.info)) {
try out.writeAll(msg);
}
}
};
|
info
函数的 out
参数类型为 anytype
。这意味着我们的 logger 可以将信息输出到任何具有 writeAll 方法的结构中,该方法接受一个 []const u8
并返回一个 !void
。这不是运行时特性。类型检查在编译时进行,每使用一种类型,就会创建一个类型正确的函数。如果我们试图调用 info
,而该类型不具备所有必要的函数(本例中只有 writeAll
),我们就会在编译时出错:
1
2
| var l = Logger{.level = .info};
try l.info("sever started", true);
|
会得到如下错误:
1
| no field or member function named 'writeAll' in 'bool'
|
使用 ArrayList(u8)
的 writer
就可以运行:
1
2
3
4
5
6
7
8
9
10
11
12
| pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var l = Logger{.level = .info};
var arr = std.ArrayList(u8).init(allocator);
defer arr.deinit();
try l.info("sever started", arr.writer());
std.debug.print("{s}\n", .{arr.items});
}
|
anytype
的一个最大缺点就是文档。下面是我们用过几次的 std.json.stringify
函数的签名:
1
2
3
4
5
6
7
8
| // 我**讨厌**多行函数定义
// 不过,鉴于你可能在小屏幕上阅读这个指南,因此这里破一次例。
fn stringify(
value: anytype,
options: StringifyOptions,
out_stream: anytype
) @TypeOf(out_stream).Error!void
|
第一个参数 value: anytype
是显而易见的,它是要序列化的值,可以是任何类型(实际上,Zig 的 JSON 序列化器不能序列化某些类型,比如 HashMap)。我们可以猜测,out_stream
是写入 JSON 的地方,但至于它需要实现什么方法,你和我一样猜得到。唯一的办法就是阅读源代码,或者传递一个假值,然后使用编译器错误作为我们的文档。如果有更好的自动文档生成器,这一点可能会得到改善。不过,我希望 Zig 能提供接口,这已经不是第一次了。
@TypeOf
在前面的部分中,我们使用 @TypeOf
来帮助我们检查各种变量的类型。从我们的用法来看,你可能会认为它返回的是字符串类型的名称。然而,鉴于它是一个 PascalCase 风格函数,你应该更清楚:它返回的是一个 type
。
我最喜欢用 anytype
与 @TypeOf
和 @hasField
内置函数搭配使用,以编写测试帮助程序。虽然我们看到的每个 User
类型都非常简单,但我还是要请大家想象一下一个有很多字段的更复杂的结构。在许多测试中,我们需要一个 User
,但我们只想指定与测试相关的字段。让我们创建一个 userFactory
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| fn userFactory(data: anytype) User {
const T = @TypeOf(data);
return .{
.id = if (@hasField(T, "id")) data.id else 0,
.power = if (@hasField(T, "power")) data.power else 0,
.active = if (@hasField(T, "active")) data.active else true,
.name = if (@hasField(T, "name")) data.name else "",
};
}
pub const User = struct {
id: u64,
power: u64,
active: bool,
name: [] const u8,
};
|
我们可以通过调用 userFactory(.{})
创建默认用户,也可以通过 userFactory(.{.id = 100, .active = false})
来覆盖特定字段。这只是一个很小的模式,但我非常喜欢。这也是迈向元编程世界的第一步。
更常见的是 @TypeOf
与 @typeInfo
配对,后者返回一个 std.builtin.Type
。这是一个功能强大的带标签的联合(tagged union),可以完整描述一个类型。std.json.stringify
函数会递归地调用它,以确定如何将提供的 value
序列化。
构建系统
如果你通读了整本指南,等待着深入了解如何建立更复杂的项目,包括多个依赖关系和各种目标,那你就要失望了。Zig 拥有强大的构建系统,以至于越来越多的非 Zig 项目都在使用它,比如 libsodium。不幸的是,所有这些强大的功能都意味着,对于简单的需求来说,它并不是最容易使用或理解的。
事实上,是我不太了解 Zig 的构建系统,所以无法解释清楚。
不过,我们至少可以获得一个简要的概述。为了运行 Zig 代码,我们使用了 zig run learning.zig
。有一次,我们还用 zig test learning.zig
进行了一次测试。运行和测试命令用来玩玩还行,但如果要做更复杂的事情,就需要使用构建命令了。编译命令依赖于带有特殊编译入口的 build.zig
文件。下面是一个示例:
1
2
3
4
5
6
7
| // build.zig
const std = @import("std");
pub fn build(b: *std.Build) !void {
_ = b;
}
|
每个构建程序都有一个默认的『安装』步骤,可以使用 zig build install
运行它,但由于我们的文件大部分是空的,你不会得到任何有意义的工件。我们需要告诉构建程序我们程序的入口是 learning.zig
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| const std = @import("std");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// setup executable
const exe = b.addExecutable(.{
.name = "learning",
.target = target,
.optimize = optimize,
.root_source_file = b.path("learning.zig"),
});
b.installArtifact(exe);
}
|
现在,如果运行 zig build install
,就会在 ./zig-out/bin/learning
中得到一个二进制文件。通过使用 standardTargetOptions
和 standardOptimizeOption
,我们就能以命令行参数的形式覆盖默认值。例如,要为 Windows
构建一个大小优化的程序版本,我们可以这样做:
1
| zig build install -Doptimize=ReleaseSmall -Dtarget=x86_64-windows-gnu
|
除了默认的『安装』步骤外,可执行文件通常还会增加两个步骤:『运行』和『测试』。一个库可能只有一个『测试』步骤。对于基本的无参数即可运行的程序来说,只需要在构建文件的最后添加四行:
1
2
3
4
5
6
7
| // 在这行代码后添加下面的代码: b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Start learning!");
run_step.dependOn(&run_cmd.step);
|
这里通过 dependOn
的两次调用创建两个依赖关系。第一个依赖关系将我们的 run_cmd
与内置的安装步骤联系起来。第二个是将 run_step
与我们新创建的 run_cmd
绑定。你可能想知道为什么需要 run_cmd
和 run_step
。我认为这种分离是为了支持更复杂的设置:依赖于多个命令的步骤,或者在多个步骤中使用的命令。如果运行 zig build --help
并滚动到顶部,你会看到新增的 run
步骤。现在你可以执行 zig build run
来运行程序了。
要添加『测试』步骤,你需要重复刚才添加的大部分运行代码,只是不再使用 b.addExecutable
,而是使用 b.addTest
:
1
2
3
4
5
6
7
8
9
10
| const tests = b.addTest(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("learning.zig"),
});
const test_cmd = b.addRunArtifact(tests);
test_cmd.step.dependOn(b.getInstallStep());
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&test_cmd.step);
|
我们将该步骤命名为 test
。运行 zig build --help
会显示另一个可用步骤 test
。由于我们没有进行任何测试,因此很难判断这一步是否有效。在 learning.zig
中,添加
1
2
3
| test "dummy build test" {
try std.testing.expectEqual(false, true);
}
|
现在运行 zig build test
时,应该会出现测试失败。如果你修复了测试,并再次运行 zig build test
,你将不会得到任何输出。默认情况下,Zig 的测试运行程序只在失败时输出结果。如果你像我一样,无论成功还是失败,都想要一份总结,那就使用 zig build test --summary all
。
这是启动和运行构建系统所需的最低配置。但是请放心,如果你需要构建你的程序,Zig 内置的功能大概率能覆盖你的需求。最后,你可以(也应该)在你的项目根目录下使用 zig init
,让 Zig 为你创建一个文档齐全的 build.zig
文件。
第三方依赖
Zig 的内置软件包管理器相对较新,因此存在一些缺陷。虽然还有改进的余地,但它目前还是可用的。我们需要了解两个部分:创建软件包和使用软件包。我们将对其进行全面介绍。
首先,新建一个名为 calc
的文件夹并创建三个文件。第一个是 add.zig
,内容如下:
1
2
3
4
5
6
7
8
9
10
| // 哦,下面的函数定义中有语法之前没讲过,看看 b 的类型和返回类型!!
pub fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
return a + b;
}
const testing = @import("std").testing;
test "add" {
try testing.expectEqual(@as(i32, 32), add(30, 2));
}
|
这个例子可能看起来有点傻,一整个软件包只是为了加两个数值,但它能让我们专注于打包方面。接下来,我们将添加一个同样愚蠢的:calc.zig
:
1
2
3
4
5
6
7
8
9
| pub const add = @import("add.zig").add;
test {
// By default, only tests in the specified file
// are included. This magic line of code will
// cause a reference to all nested containers
// to be tested.
@import("std").testing.refAllDecls(@This());
}
|
我们将其分割为 calc.zig
和 add.zig
,以证明 zig build
可以自动构建和打包所有项目文件。最后,我们可以添加 build.zig:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| const std = @import("std");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const tests = b.addTest(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("calc.zig"),
});
const test_cmd = b.addRunArtifact(tests);
test_cmd.step.dependOn(b.getInstallStep());
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&test_cmd.step);
}
|
这些都是我们在上一节中看到的内容的重复。有了这些,你就可以运行 zig build test --summary all
。
回到我们的 learning
项目和之前创建的 build.zig
。首先,我们将添加本地 calc
作为依赖项。我们需要添加三项内容。首先,我们将创建一个指向 calc.zig
的模块:
1
2
3
4
5
6
| // 你可以把这些代码放在构建函数的顶部,
// 即调用 addExecutable 之前。
const calc_module = b.addModule("calc", .{
.root_source_file = b.path("PATH_TO_CALC_PROJECT/calc.zig"),
});
|
你需要调整 calc.zig
的路径。现在,我们需要将这个模块添加到现有的 exe
和 tests
变量中。由于我们的 build.zig 变得越来越复杂,我们将尝试稍微组织一下:
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
| const std = @import("std");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const calc_module = b.addModule("calc", .{
.root_source_file = b.path("PATH_TO_CALC_PROJECT/calc.zig"),
});
{
// 设置我们的 "run" 命令。
const exe = b.addExecutable(.{
.name = "learning",
.target = target,
.optimize = optimize,
.root_source_file = b.path("learning.zig"),
});
// 添加这些代码
exe.root_module.addImport("calc", calc_module);
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Start learning!");
run_step.dependOn(&run_cmd.step);
}
{
// 设置我们的 "test" 命令。
const tests = b.addTest(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("learning.zig"),
});
// 添加这行代码
tests.root_module.addImport("calc", calc_module);
const test_cmd = b.addRunArtifact(tests);
test_cmd.step.dependOn(b.getInstallStep());
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&test_cmd.step);
}
}
|
现在,可以在项目中 @import("calc")
:
1
2
3
| const calc = @import("calc");
...
calc.add(1, 2);
|
添加远程依赖关系需要花费更多精力。首先,我们需要回到 calc
项目并定义一个模块。你可能认为项目本身就是一个模块,但一个项目(project)可以暴露多个模块(module),所以我们需要明确地创建它。我们使用相同的 addModule
,但舍弃了返回值。只需调用 addModule
就足以定义模块,然后其他项目就可以导入该模块。
1
2
3
| _ = b.addModule("calc", .{
.root_source_file = b.path("calc.zig"),
});
|
这是我们需要对库进行的唯一改动。因为这是一个远程依赖的练习,所以我把这个 calc
项目推送到了 GitHub,这样我们就可以把它导入到我们的 learning
项目中。它可以在 https://github.com/karlseguin/calc.zig 上找到。
回到我们的 learning
项目,我们需要一个新文件 build.zig.zon
。ZON 是 Zig Object Notation 的缩写,它允许以人类可读格式表达 Zig 数据,并将人类可读格式转化为 Zig 代码。build.zig.zon
的内容包括:
1
2
3
4
5
6
7
8
9
10
11
| .{
.name = "learning",
.paths = .{""},
.version = "0.0.0",
.dependencies = .{
.calc = .{
.url = "https://github.com/karlseguin/calc.zig/archive/d1881b689817264a5644b4d6928c73df8cf2b193.tar.gz",
.hash = "12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
},
},
}
|
该文件中有两个可疑值,第一个是 url 中的 d1881b689817264a5644b4d6928c73df8cf2b193<。这只是 git 提交的哈希值。第二个是哈希值。据我所知,目前还没有很好的方法来告诉我们这个值应该是多少,所以我们暂时使用一个假值。
要使用这一依赖关系,我们需要对 build.zig
进行一处修改:
1
2
3
4
5
6
7
8
| // 将这些代码:
const calc_module = b.addModule("calc", .{
.root_source_file = b.path("calc/calc.zig"),
});
// 替换成:
const calc_dep = b.dependency("calc", .{.target = target,.optimize = optimize});
const calc_module = calc_dep.module("calc");
|
在 build.zig.zon
中,我们将依赖关系命名为 calc
,这就是我们要加载的依赖关系。在这个依赖关系中,我们将使用其中的 calc
模块,也就是我们在 calc
的 build.zig.zon
中命名的模块。
如果你尝试运行 zig build test
,应该会看到一个错误:
1
2
3
4
5
| hash mismatch: manifest declares
122053da05e0c9348d91218ef015c8307749ef39f8e90c208a186e5f444e818672da
but the fetched package has
122036b1948caa15c2c9054286b3057877f7b152a5102c9262511bf89554dc836ee5
|
将正确的哈希值复制并粘贴回 build.zig.zon
,然后再次尝试运行 zig build test
,现在一切应该都正常了。
听起来很多,我希望能精简一些。但这主要是你可以从其他项目中复制和粘贴的东西,一旦设置完成,你就可以继续了。
需要提醒的是,我发现 Zig 对依赖项的缓存偏激。如果你试图更新依赖项,但 Zig 似乎检测不到变化。这时,我会删除项目的 zig-cache
文件夹以及 ~/.cache/zig
。
我们已经涉猎了很多领域,探索了一些核心数据结构,并将之前的大块内容整合到了一起。我们的代码变得复杂了一些,不再那么注重特定的语法,看起来更像真正的代码。让我感到兴奋的是,尽管如此复杂,但代码大部分都是有意义的。如果暂时没有看懂,也不要放弃。选取一个示例并将其分解,添加打印语句,为其编写一些测试。亲自动手编写自己的代码,然后再回来阅读那些没有看懂的部分。
11 - 总结
原文总结:https://www.openmymind.net/learning_zig/conclusion
有些读者可能会认出我是各种『The Little $TECH Book』 的作者(译者注:原作者还写过 The Little Go Book、The Little MongoDB Book),并想知道为什么这本书不叫『The Little Zig Book』。事实上,我不确定 Zig 是否适合『小』这个范畴。部分挑战在于,Zig 的复杂性和学习曲线会因个人背景和经验的不同而大相径庭。如果你是一个经验丰富的 C 或 C++ 程序员,那么简明扼要地总结一下这门语言可能就够了,这种情况下你可能会更需要Zig 的官方文档。
虽然我们在本指南中涉及了很多内容,但仍有大量内容我们尚未触及。我不希望这让你气馁或不知所措。所有语言的学习都是循序渐进的,通过本教程,你有了一个良好基础,也可以把它当作参考资料,可以开始学习 Zig 语言中更高级的功能。坦率地说,我没有涉及的部分我本身就理解有限,因此无法很好的解释。但这并不妨碍我使用 Zig 编写有意义的东西,比如一个流行的 HTTP 服务器。
最后,我想强调一件完全被略过的事情,你之前可能有所耳闻,即 Zig 与 C 代码交互非常容易。因为 Zig 的生态还很年轻,标准库也很小,所以在某些情况下,使用 C 库可能是最好的选择。例如,Zig 标准库中没有正则表达式模块,使用 C 语言库就是一个合理的选择。我曾为 SQLite 和 DuckDB 编写过 Zig 库,这很简单。如果你基本遵循了本指南中的所有内容,应该不会有任何问题。
希望本资料对你有所帮助,也希望你能在编程过程中获得乐趣。