概述
本章深入探讨了在包注册模块之后发生的事情——名称如何变成具体的导入,编译器何时打开文件,以及哪些钩子控制发现(参见build_runner.zig)。我们将对模块图进行建模,阐明文件系统路径和已注册命名空间之间的区别,并展示如何在不散布脆弱的#ifdef式逻辑的情况下保护可选的助手。
在此过程中,我们将探讨编译时导入、特定于测试的发现以及使用@hasDecl进行安全探测,同时加强Zig 0.15.2中引入的写入器API更改,以便每个示例都可作为正确使用stdout的参考(参见v0.15.2和File.zig)。
学习目标
- 追溯构建运行器如何将注册的模块名称扩展为依赖感知的模块图。24
- 区分文件系统相对导入和构建注册的模块,并预测在有歧义的情况下哪个会胜出(参见Build.zig和22)。
- 识别触发模块发现的每一种机制:直接导入、
comptime块、test声明、导出和入口点探测(参见start.zig和testing.zig)。 - 应用编译时保护,使可选工具从发布工件中消失,同时保持调试构建的丰富检测(参见19和builtin.zig)。
- 使用
@hasDecl和相关的反射助手来检测功能,而不依赖于有损的字符串比较或未经检查的假设(参见meta.zig和15)。 - 记录并测试发现策略,以便协作者理解构建图何时会包含额外的模块。13
模块图映射
编译器将每个翻译单元转换为一个类似结构体的命名空间。导入对应于该图中的边,构建运行器为其提供一个预先注册的命名空间列表,以便模块即使在磁盘上没有同名文件的情况下也能确定性地解析。
在底层,这些命名空间存在于Zcu编译状态中,与内部池、文件和分析工作队列一起:
模块解析在评估@import边时遍历此命名空间图,使用与增量编译和符号解析相同的Zcu和InternPool机制。
根、和命名空间
根模块是编译器视为入口点的任何文件。从该根,你可以通过@import("root")检查自己,通过@import("std")访问捆绑的标准库,并通过@import("builtin")访问编译器提供的元数据。以下探测打印每个命名空间公开的内容,并演示基于文件系统的导入(extras.zig)如何参与同一个图。19
const std = @import("std");
const builtin = @import("builtin");
const root = @import("root");
const extras = @import("extras.zig");
pub fn helperSymbol() void {}
pub fn main() !void {
var stdout_buffer: [512]u8 = undefined;
var file_writer = std.fs.File.stdout().writer(&stdout_buffer);
const out = &file_writer.interface;
try out.print("root has main(): {}\n", .{@hasDecl(root, "main")});
try out.print("root has helperSymbol(): {}\n", .{@hasDecl(root, "helperSymbol")});
try out.print("std namespace type: {s}\n", .{@typeName(@TypeOf(@import("std")))});
try out.print("current build mode: {s}\n", .{@tagName(builtin.mode)});
try out.print("extras.greet(): {s}\n", .{extras.greet()});
try out.flush();
}
$ zig run 01_root_namespace.zigroot has main(): true
root has helperSymbol(): true
std namespace type: type
current build mode: Debug
extras.greet(): extras namespace discovered via file path调用std.fs.File.stdout().writer(&buffer)反映了0.15.2的写入器API:我们缓冲、打印和刷新以避免截断输出,同时保持无分配器。
由构建图注册的名称
当你调用b.createModule或exe.addModule时,你会注册一个命名空间名称(例如"logging")和一个根源文件。该构建图中的任何@import("logging")都会指向注册的模块,即使调用者旁边有一个logging.zig文件。只有在找不到注册的命名空间时,编译器才会回退到相对于导入文件的基于路径的解析。这就是通过build.zig.zon获取的依赖项如何公开其模块:构建脚本在用户代码执行之前很久就构建了图。24
编译器强制规定一个给定的文件只属于一个模块。编译错误测试套件包括一个案例,其中同一个文件既作为注册模块导入,又作为直接文件路径导入,这被拒绝了:
const case = ctx.obj("file in multiple modules", b.graph.host);
case.addDepModule("foo", "foo.zig");
case.addError(
\comptime {
\ _ = @import("foo");
\ _ = @import("foo.zig");
\}
, &[_][]const u8{
":1:1: error: file exists in modules 'foo' and 'root'",
":1:1: note: files must belong to only one module",
":1:1: note: file is the root of module 'foo'",
":3:17: note: file is imported here by the root of module 'root'",
});这表明一个文件可以是一个注册模块的根,也可以通过基于路径的导入成为根模块的一部分,但不能同时两者都是。
发现触发器和时机
模块发现始于导入字符串在编译时已知的那一刻。编译器分波解析依赖图,一旦在comptime上下文中评估一个导入,就立即将新模块排队。15
导入、和评估顺序
comptime块在语义分析期间运行。如果它包含_ = @import("tooling.zig");,构建运行器会立即解析并解析该模块——即使运行时从未引用它。使用显式策略(标志、优化模式或构建选项),以便此类导入是可预测的而不是令人惊讶的。
抵制在@import中内联字符串连接的诱惑;无论如何,Zig都要求导入目标是编译时已知的字符串,所以优先使用一个记录意图的单个常量。
测试、导出和入口探测
test块和pub export声明也触发发现。当你运行zig test时,编译器会导入每个带有测试的模块,注入一个合成的main,并调用std.testing工具助手。类似地,std.start会检查根模块中的main、_start和平台特定的入口点,并在此过程中拉入这些声明引用的任何模块。这就是为什么即使是休眠的测试助手也必须置于comptime保护之后;否则,仅仅因为存在一个test声明,它们就会泄漏到生产工件中。19
在Zig编译器自己的构建中,从测试声明到,再到测试运行器和命令的路径如下:
这清楚地表明,添加声明不仅会引入,还会将你的模块连接到由驱动的测试构建和执行管道中。
条件发现模式
可选工具不应需要你的存储库有单独的分支。相反,应从编译时数据驱动发现,并对命名空间进行反思以决定激活什么。15
使用优化模式门控模块
优化模式内置于builtin.mode中。用它来仅在为调试构建时导入昂贵的诊断工具。下面的示例在调试构建期间连接debug_tools.zig,并在ReleaseFast构建中跳过它,同时还演示了Zig 0.15.2中必需的缓冲写入器模式。
const std = @import("std");
const builtin = @import("builtin");
pub fn main() !void {
comptime {
if (builtin.mode == .Debug) {
_ = @import("debug_tools.zig");
}
}
var stdout_buffer: [512]u8 = undefined;
var file_writer = std.fs.File.stdout().writer(&stdout_buffer);
const out = &file_writer.interface;
try out.print("build mode: {s}\n", .{@tagName(builtin.mode)});
if (comptime builtin.mode == .Debug) {
const debug = @import("debug_tools.zig");
try out.print("{s}\n", .{debug.banner});
} else {
try out.print("no debug tooling imported\n", .{});
}
try out.flush();
}
$ zig run 02_conditional_import.zigbuild mode: Debug
debug tooling wired at comptime$ zig run -OReleaseFast 02_conditional_import.zigbuild mode: ReleaseFast
no debug tooling imported因为@import("debug_tools.zig")位于comptime条件之后,所以ReleaseFast二进制文件甚至不会解析该助手,从而保护构建免于意外依赖于仅调试的全局变量。
使用进行安全探测
与其假设一个模块导出了某个特定的函数,不如探测它。在这里,我们公开了一个plugins命名空间,它要么转发到plugins_enabled.zig,要么返回一个空结构体。@hasDecl在编译时告诉我们可选的install钩子是否存在,从而启用了在每个构建模式下都有效的安全运行时分支。15
const std = @import("std");
const plugins = @import("plugins.zig");
pub fn main() !void {
var stdout_buffer: [512]u8 = undefined;
var file_writer = std.fs.File.stdout().writer(&stdout_buffer);
const out = &file_writer.interface;
if (comptime @hasDecl(plugins.namespace, "install")) {
try out.print("plugin discovered: {s}\n", .{plugins.namespace.install()});
} else {
try out.print("no plugin available; continuing safely\n", .{});
}
try out.flush();
}
$ zig run 03_safe_probe.zigplugin discovered: Diagnostics overlay instrumentation active$ zig run -OReleaseFast 03_safe_probe.zigno plugin available; continuing safely注意,我们在命名空间类型本身(plugins.namespace)上测试声明。这使得根模块与插件的内部结构无关,并避免了字符串类型的特性切换。19
操作指南
注意与警告
练习
- 扩展
01_root_namespace.zig,使其迭代@typeInfo(@import("root")).Struct.decls,打印一个排序的符号表以及每个符号所在的模块。15 - 修改
02_conditional_import.zig,将调试工具置于构建选项布尔值之后(例如-Ddev-inspect=true),并记录构建脚本将如何在第22章中通过b.addOptions来配置该选项。22 - 创建一个兄弟模块,仅当
builtin.mode == .Debug时才使用comptime { _ = @import("helper.zig"); },然后编写一个测试,断言该助手在ReleaseFast中永远不会编译。13