实战

原文地址: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 中的 Usergoku 引用的内存不同。我们上面的代码有两个用户,每个用户都有自己的所有者。goku 的所有者是 main,而它的副本的所有者是 lookup

getPtr 方法返回的是指向 map 中值的指针,在我们的例子中,它返回的是 *User。问题就在这里,删除会使我们的 entry指针失效。在这个示例中,getPtrremove 的位置很近,因此问题也很明显。但不难想象,代码在调用 remove 时,并不知道 entry 的引用被保存在其他地方了。

在编写这个示例时,我并不确定会发生什么。删除有可能是通过设置内部标志来实现的,实际删除是惰性的。如果是这样的话,上面的示例在简单的情况下可能会 “奏效”,但在更复杂的情况下就会失败。这听起来非常难以调试。

除了不调用 remove 之外,我们还可以用几种不同的方法来解决这个问题。首先,我们可以使用 get 而不是 getPtr。这样 lookup 将返回一个 User 的副本,而不再是 *User。这样我们就有了三个用户:

  1. 定义在函数内部的 gokumain 函数是其所有者
  2. 调用 lookup.put 时,形式参数会得到 goku 一个的副本,lookup 是其所有者
  3. 使用 get 函数返回的 entrymain 函数是其所有者

由于 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,
};

上述代码中有许多微妙之处。首先,我们现在只有一个用户 gokulookupentry 中的值都是对 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,
};

上述代码虽然区分大小写,但无论我们如何完美地输入 Letocontains 总是返回 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});

dupestd.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 中得到一个二进制文件。通过使用 standardTargetOptionsstandardOptimizeOption,我们就能以命令行参数的形式覆盖默认值。例如,要为 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_cmdrun_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.zigadd.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 的路径。现在,我们需要将这个模块添加到现有的 exetests 变量中。由于我们的 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 模块,也就是我们在 calcbuild.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


我们已经涉猎了很多领域,探索了一些核心数据结构,并将之前的大块内容整合到了一起。我们的代码变得复杂了一些,不再那么注重特定的语法,看起来更像真正的代码。让我感到兴奋的是,尽管如此复杂,但代码大部分都是有意义的。如果暂时没有看懂,也不要放弃。选取一个示例并将其分解,添加打印语句,为其编写一些测试。亲自动手编写自己的代码,然后再回来阅读那些没有看懂的部分。