Chapter 25Module Resolution And Discovery Deep

模块解析与发现(深入概念)

概述

本章深入探讨了包注册模块之后发生的事情——名称如何变成具体的导入,编译器何时打开文件,以及哪些钩子控制发现(参见build_runner.zig)。我们将对模块图进行建模,阐明文件系统路径和已注册命名空间之间的区别,并展示如何在不散布脆弱的#ifdef式逻辑的情况下保护可选的助手。

在此过程中,我们将探讨编译时导入、特定于测试的发现以及使用@hasDecl进行安全探测,同时加强Zig 0.15.2中引入的写入器API更改,以便每个示例都可作为正确使用stdout的参考(参见v0.15.2File.zig)。

学习目标

  • 追溯构建运行器如何将注册的模块名称扩展为依赖感知的模块图。24
  • 区分文件系统相对导入和构建注册的模块,并预测在有歧义的情况下哪个会胜出(参见Build.zig22)。
  • 识别触发模块发现的每一种机制:直接导入、comptime块、test声明、导出和入口点探测(参见start.zigtesting.zig)。
  • 应用编译时保护,使可选工具从发布工件中消失,同时保持调试构建的丰富检测(参见19builtin.zig)。
  • 使用@hasDecl和相关的反射助手来检测功能,而不依赖于有损的字符串比较或未经检查的假设(参见meta.zig15)。
  • 记录并测试发现策略,以便协作者理解构建图何时会包含额外的模块。13

模块图映射

编译器将每个翻译单元转换为一个类似结构体的命名空间。导入对应于该图中的边,构建运行器为其提供一个预先注册的命名空间列表,以便模块即使在磁盘上没有同名文件的情况下也能确定性地解析。

在底层,这些命名空间存在于Zcu编译状态中,与内部池、文件和分析工作队列一起:

graph TB ZCU["Zcu"] subgraph "编译状态" INTERNPOOL["intern_pool: InternPool"] FILES["files: MultiArrayList(File)"] NAMESPACES["namespaces: MultiArrayList(Namespace)"] end subgraph "源跟踪" ASTGEN["astgen_work_queue"] SEMA["sema_work_queue"] CODEGEN["codegen_work_queue"] end subgraph "线程" WORKERS["comp.thread_pool"] PERTHREAD["per_thread: []PerThread"] end subgraph "符号管理" NAVS["导航值 (Navs)"] UAVS["未绑定匿名值 (Uavs)"] EXPORTS["single_exports / multi_exports"] end ZCU --> INTERNPOOL ZCU --> FILES ZCU --> NAMESPACES ZCU --> ASTGEN ZCU --> SEMA ZCU --> CODEGEN ZCU --> WORKERS ZCU --> PERTHREAD ZCU --> NAVS ZCU --> UAVS ZCU --> EXPORTS

模块解析在评估@import边时遍历此命名空间图,使用与增量编译和符号解析相同的ZcuInternPool机制。

根、和命名空间

根模块是编译器视为入口点的任何文件。从该根,你可以通过@import("root")检查自己,通过@import("std")访问捆绑的标准库,并通过@import("builtin")访问编译器提供的元数据。以下探测打印每个命名空间公开的内容,并演示基于文件系统的导入(extras.zig)如何参与同一个图。19

Zig
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();
}
运行
Shell
$ zig run 01_root_namespace.zig
输出
Shell
root 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.createModuleexe.addModule时,你会注册一个命名空间名称(例如"logging")和一个根源文件。该构建图中的任何@import("logging")都会指向注册的模块,即使调用者旁边有一个logging.zig文件。只有在找不到注册的命名空间时,编译器才会回退到相对于导入文件的基于路径的解析。这就是通过build.zig.zon获取的依赖项如何公开其模块:构建脚本在用户代码执行之前很久就构建了图。24

编译器强制规定一个给定的文件只属于一个模块。编译错误测试套件包括一个案例,其中同一个文件既作为注册模块导入,又作为直接文件路径导入,这被拒绝了:

Zig
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编译器自己的构建中,从测试声明到,再到测试运行器和命令的路径如下:

graph TB subgraph "测试声明层" TESTDECL["测试声明<br/>test关键字"] DOCTEST["doctests<br/>命名测试"] ANON["匿名测试<br/>未命名测试"] TESTDECL --> DOCTEST TESTDECL --> ANON end subgraph "std.testing命名空间" EXPECT["expect()<br/>expectEqual()<br/>expectError()"] ALLOCATOR["testing.allocator<br/>泄漏检测"] FAILING["failing_allocator<br/>OOM模拟"] UTILS["expectEqualSlices()<br/>expectEqualStrings()"] EXPECT --> ALLOCATOR ALLOCATOR --> FAILING end subgraph "测试运行器" RUNNER["test_runner.zig<br/>默认运行器"] STDERR["stderr输出"] SUMMARY["测试摘要<br/>通过/失败/跳过计数"] RUNNER --> STDERR RUNNER --> SUMMARY end subgraph "执行" ZIGTEST["zig test命令"] BUILD["测试构建"] EXEC["执行测试"] REPORT["报告结果"] ZIGTEST --> BUILD BUILD --> EXEC EXEC --> REPORT end TESTDECL --> EXPECT EXPECT --> RUNNER RUNNER --> ZIGTEST style EXPECT fill:#f9f9f9 style RUNNER fill:#f9f9f9 style TESTDECL fill:#f9f9f9

这清楚地表明,添加声明不仅会引入,还会将你的模块连接到由驱动的测试构建和执行管道中。

条件发现模式

可选工具不应需要你的存储库有单独的分支。相反,应从编译时数据驱动发现,并对命名空间进行反思以决定激活什么。15

使用优化模式门控模块

优化模式内置于builtin.mode中。用它来仅在为调试构建时导入昂贵的诊断工具。下面的示例在调试构建期间连接debug_tools.zig,并在ReleaseFast构建中跳过它,同时还演示了Zig 0.15.2中必需的缓冲写入器模式。

Zig
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();
}
运行(调试)
Shell
$ zig run 02_conditional_import.zig
输出
Shell
build mode: Debug
debug tooling wired at comptime
运行(ReleaseFast)
Shell
$ zig run -OReleaseFast 02_conditional_import.zig
输出
Shell
build mode: ReleaseFast
no debug tooling imported

因为@import("debug_tools.zig")位于comptime条件之后,所以ReleaseFast二进制文件甚至不会解析该助手,从而保护构建免于意外依赖于仅调试的全局变量。

使用进行安全探测

与其假设一个模块导出了某个特定的函数,不如探测它。在这里,我们公开了一个plugins命名空间,它要么转发到plugins_enabled.zig,要么返回一个空结构体。@hasDecl在编译时告诉我们可选的install钩子是否存在,从而启用了在每个构建模式下都有效的安全运行时分支。15

Zig
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();
}
运行(调试)
Shell
$ zig run 03_safe_probe.zig
输出
Shell
plugin discovered: Diagnostics overlay instrumentation active
运行(ReleaseFast)
Shell
$ zig run -OReleaseFast 03_safe_probe.zig
输出
Shell
no plugin available; continuing safely

注意,我们在命名空间类型本身(plugins.namespace)上测试声明。这使得根模块与插件的内部结构无关,并避免了字符串类型的特性切换。19

命名空间卫生清单

  • 记录构建注册了哪些模块以及原因;将列表视为公共API的一部分,以便消费者知道哪些@import调用是稳定的。22
  • 优先重新导出小的、类型化的结构体,而不是将整个助手模块转储到根命名空间;这使得@hasDecl探测快速且可预测。
  • 混合使用文件系统和注册的导入时,选择不同的名称,这样调用者就不会疑惑他们得到的是哪个模块。24

操作指南

  • 在你的CI管道中包含发现测试:编译调试和发布版本,确保可选工具恰好开关一次。13
  • 在运行实验之前,使用zig build --fetch(来自第24章),以便依赖图完全缓存且确定。24
  • 避免由环境变量或时间戳驱动的comptime导入;它们会破坏可重现性,因为依赖图现在依赖于可变的主机状态。
  • 如有疑问,请在专用的调试实用程序中通过反射(@typeInfo(@import("root")))打印模块图,以便队友可以检查当前的命名空间表面。15

注意与警告

  • std.fs.File.stdout().writer(&buffer)是在Zig 0.15.2中发出文本的规范方式;忘记刷新将在这些示例和您自己的工具中截断输出。
  • 注册的模块名称优先于相对文件。为供应商代码选择唯一的名称,以便本地助手不会意外地遮蔽依赖项。24
  • @hasDecl@hasField纯粹在编译时运行;它们不检查运行时状态。将它们与显式策略(标志、选项)结合使用,以避免在钩子被其他地方门控时出现误导性的“功能存在”横幅。15

练习

  • 扩展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

注意事项、替代方案、边缘情况

  • 在多包工作区中,模块名称必须保持全局唯一;当两个依赖项注册@import("log")时,考虑用包名前缀以避免冲突。23
  • 当针对没有文件系统的独立环境时,配置构建运行器以通过b.addAnonymousModule提供合成模块;否则基于路径的导入将失败。
  • 禁用std.start会移除对main的自动搜索;准备好手动导出_start并自己处理参数解码。19

总结

  • 模块解析是确定性的:注册的命名空间获胜,文件系统路径作为备用,并且所有导入都在编译时发生。
  • 发现触发器超出了普通导入的范围——comptime块、测试、导出和入口探测都影响哪些模块加入图。19
  • 编译时保护(builtin.mode,构建选项)和反射助手(@hasDecl)让你能够提供丰富的调试工具,而不会污染发布二进制文件。15

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.