用 Zig 扩展 Lua 1:Hello World

用Zig编写Lua扩展模块的演练,从hello world开始。涵盖构建Lua可加载的共享库、C ABI边界,以及跨边界传递值所需的Lua C API部分。

中文
复制

最近我第三、四次带人上手 Zig 和 Lua。这个次数足够写一篇博客了。

本文假设你懂 Lua 的基本写法,但不假设你了解 Zig——我们会一起跑通 “Hello World”。这个系列的目标是让你最终能从 Lua 里调用自己写的 Zig 函数。过程中会顺带讲一点 Zig 的语法、构建系统,以及怎么在自己的代码里用别人的 Zig 代码。

文中的代码在 Zig 0.17 和 master 分支上都能跑。如果你照着写的时候遇到下面任意一种情况,发邮件告诉我,我会把文章改掉:

  • Zig 最新的稳定版不是 0.17.0
  • 代码在最新稳定版或 master 分支上编译不过

Hello World - One

这是 Zig 版的 “Hello World”。

// hello.zig
const std = @import("std");

pub fn main() void {
    std.debug.print("Hello World!\n", .{});
}

稍后再解释,先跑起来。

安装 Zig

可以从 Ziglang.org 下载 Zig 的发布版本。我推荐这样装。找到和你系统匹配的操作系统和架构,下载对应的压缩包。第一组选项是 master 分支,后面一组是最新的稳定版。解压之后,把压缩包里的内容移动或软链到你的 PATH 上。压缩包里有一个 zig 二进制文件和几个文件夹,基本上要一起挪。

如果你在这些细节上比我讲究,那大概也不需要我帮忙。很多 Linux 发行版都打包了 Zig。

要运行上面的文件,在终端里执行 zig run hello.zig。run 是 zig 的子命令。直接不带参数运行 zig 会列出所有子命令。

理解 Hello World

学 Zig 之前先会 Lua 是件好事,因为很多概念是对应的。一个 Lua 文件隐含地就是一个函数,同样,每个 Zig 文件隐含地就是一个命名空间,在 Zig 里也就是一个 struct。

@import() 与 comptime

在 Lua 中,加载一个文件会执行其中代码的“脚本”部分。同样,在 Zig 中,编译(也就是“语义分析”)一个文件也会执行其中代码的“脚本”部分。在上面的 Hello World 程序里,就有这么一小块:下面两行 Zig 代码在语法上几乎一模一样。

const std = @import("std");
const the_answer = 42;

有意思的是,它们在语义上也基本一致:两者都声明了一个常量,作用域为其所在的最大块(如果不在函数内,就是命名空间),值取 = 号右侧的内容。

你可能会说,一个区别在于那个古怪的 @ 符号。@import() 看起来像函数调用,而它差不多就是:开头的 @ 符号告诉你,@import() 是一个编译器内建函数,而不是用户代码。编译器内建函数拥有普通函数不可能有的特权。@import() 的特权就是:你必须传给它一个字符串字面量。在 Zig 的其他任何函数里,下面这种写法都是允许的。

const literal = "literal string";
userlandFunction(literal);

但除此之外,@import() 其实没什么特别的。它的任务是把传给它的字符串字面量与真实存在的 Zig 源文件匹配起来,这些文件可能来自你的项目、你依赖的项目,或者标准库。所以严格来说,它的作用无法在用户代码中复现,但它在编译期运作的魔力其实并不是 @import() 独有的,而 const the_answer = 42; 这一行也并非不同。看看下面这段 Lua 代码。

local std = require 'std'
local the_answer = 42

当包含这段代码的文件被加载时,Lua 会执行这两条语句。第一条会把一个假想的、名为 std 的库传给 require(也就是 Lua 的 @import(),但与 @import() 不同,它至少可以被用户代码覆盖,哪怕无法被实现),结果存入一个名为 std 的局部变量。第二条把 42 存入名为 the_answer 的局部变量。

所以,关键结论是:Zig 代码可以(而且确实会)在编译期执行。经验法则是,编译期代码执行是急切的,调用(普通)函数是唯一的例外。如果你想在编译期调用一个名为 foo 的函数,可以写 comptime foo()。有些上下文——比如文件的顶层作用域(或 struct 之类的其他容器),或者你隐式声明某个东西的类型时——本身就以 comptime 这个词开头;如果你在多余的地方加上 comptime,编译器会报错,让你按正确的写法来。

Comptime Zig 与 Lua 的差异

Lua 和 Zig 的一个差异在于,Zig 的顶层作用域不是函数,因此你无法在顶层作用域运行下面这段过程式代码。

var number = 1;
for (0..6) |i| {
    number = number * (2 * i + 1);
}

如果想在编译期运行一些过程式代码,就把它放进一个 block 里:

const number = comptime final: {
    var number = 1;
    for (0..6) |i| {
         number = number * (2 * i + 1);
    }
    break :final number;
}

注意 block 可以命名,也可以产出一个值。Lua 中的等价写法是

local number = 1
do
  for i =0, 6 do
    number = number * (2 * i + 1)
  end  
end

在 Lua 里,do end 这个 block 并非必需,但在 Zig 里是必需的。

另一个主要差异是,在 Lua 中,连函数声明都可以是“匿名”的:

local main = function() print("Hello World!") end

在 Zig 中,声明函数的唯一方式就是给它起名字。想把函数赋给某个东西,你得先退回到 container 作用域。后面我们会看到一个这样的例子。

公开声明

正如 Lua 中的变量可以是全局的或局部的,其中一种情况(全局)是默认值,Zig 中 container 作用域的值(称为声明)也是如此。这里“局部”是默认值,意味着该声明只对同一文件内的代码可见。因此我们必须把 main 标记为 pub,因为这个函数会被不属于我们文件的 Zig 代码调用!通常这些代码来自 Zig 标准库的 start.zig 文件,不过这一行为可以被覆盖。与 Rust 类似,Zig 使用 fn 而不是 function,也不像 C 那样什么都不写,并且把返回类型放在参数列表之后。

对 std.debug.print 的调用除了 .{} 之外应该都不陌生。这种 .{} 语法是 Zig 中初始化 struct、array 或 union 这类 container 的典型方式;你在里面通过写 .field = value 来填充字段。这个小 . 无处不在,一开始往往让一些人不太舒服。它的存在是为了让 Zig 的语法(从语言理论的意义上说)更容易解析。

泛型

函数 std.debug.print 的签名是

pub fn print(comptime fmt: []const u8, args: anytype) void {
    // ...
}

这是一个泛型函数的例子。Zig 中所有普通函数(至少在原则上)都对它们的 comptime 参数、anytype 参数的类型,以及它们闭包捕获的任何 container 作用域 comptime 值泛型。fmt 字符串被标记为 comptime 已知,这样 args 的类型就能在编译期得到校验,必要的代码也能在编译期生成。(顺便说一句,[]const u8 表示不可变字节的 slice。在 Rust 中类似的类型可能是 [&]u8。Zig 字符串字面量以 UTF-8 编码,并以 nul 结尾,但 Zig 没有更专门的“字符串”类型。)这里我们不打印任何东西,所以传入 .{},在这个情况下它被当作空 tuple 处理。

一位同好可能会又惊又喜地发现,std.debug.print 和其他字符串格式化函数完全是用 comptime Zig 在用户态实现的,既没有借助宏,也没有特殊的内建权限。

最后值得一提的是,我们的程序其实并没有完全按预期运行:“Hello World!” 被打印到了 stderr,而不是 stdout。这是 std.debug.print 有意为之的行为。要写入 stdout 需要多写一些样板代码,等我们把 Lua 配置好之后再补上。

添加构建脚本

到目前为止一切顺利。要用 Lua,我们还得用上 Zig 的构建系统。先添加一个 build.zig 脚本来运行我们的 hello.zig 文件。

从这里开始

// build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    _ = b;
}

在脚本所在目录下调用 zig build,它就会被编译并执行。文件名 build 和那唯一的参数都是必需的。

接下来的两行按惯例是这样:

--- build.zig
+++ build.zig
@@ -4,3 +4,5 @@
pub fn build(b: *std.Build) void {
-    _ = b;
+    const target = b.standardTargetOptions(.{});
+    const optimize = b.standardOptimizeOption(.{});
}

target 选项用来指定代码编译的目标,比如操作系统、机器架构和 ABI。传入 .{}(在这里会强制转换为 options 结构体的默认值)后,用户就可以在命令行上用 -Dtarget= 前缀指定自己想要的目标,不指定则默认为 native 目标。optimize 选项可以是 debug(默认值)、fast、safe 或 small。后三个是“release”构建变体,分别优先考虑执行速度、内存安全特性(如溢出和边界检查)以及磁盘上的代码体积。

接下来把我们的 hello.zig 代码放进构建系统所说的“模块”里。模块可以变成可执行文件、C 风格的库,也可以被编译进更多的 Zig 模块(可能位于依赖它的项目中)。模块需要一个根源文件,我们的根源文件会接收目标和优化选项。

--- build.zig
+++ build.zig
@@ -5,2 +5,8 @@
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
+	
+    const module = b.addModule("hello-lua", .{
+        .root_source_file = b.path("hello.zig"),
+        .target = target,
+        .optimize = optimize,
+    });

顺便说一句,如果省略 optimize 后面那个尾随逗号,Zig 的自动格式化工具 zig fmt 会把整个函数调用放到一行。如果你写成一行但在 optimize 后面保留了逗号,zig fmt 就会把它排成上面的样子。如果你在用 LSP 支持,这个自动格式化工具很可能在保存时就会运行。某些语法错误会让 zig fmt 什么都不做,所以你得先自己把这些错误解决掉。

b.path() 调用是 Zig Build 系统处理文件系统的方式。这层额外的间接层让构建系统能统一处理那些可能属于依赖项、也可能由构建过程中更早步骤生成的文件路径。这里应当使用相对路径。顺便提一下 @import() 的两个限制。除了像 std 这样的具名模块之外,@import() 也可以接受相对路径。这些相对路径不能超出当前模块根源文件所在目录之下的子树。此外,每个文件最多只能属于一个模块。

因此在 A/root.zig、B/main.zig 和 B/utils.zig 这种情况下,root.zig 文件不能用 @import 来包含 main.zig 或 utils.zig,而 main.zig 和 utils.zig 可以互相包含(甚至可以同时互相包含)。如果 main.zig 和 root.zig 都想导入 utils.zig,那么任何同时包含这两者的项目都必须组织成 utils.zig 是一个被两个项目共同导入的模块。

接下来我们把模块编译成可执行文件。

--- build.zig
+++ build.zig
@@ -8,5 +8,11 @@
    const module = b.addModule("hello-lua", .{
        .root_source_file = b.path("hello.zig"),
        .target = target,
        .optimize = optimize,
    });
+
+    const exe = b.addExecutable(.{
+        .name = "hello-lua",
+        .root_module = module,
+    });
+    b.installArtifact(exe);

这是一个(暂时)完整的构建脚本;用 zig build 运行它,会把 hello.zig 编译成名为 hello-lua 的可执行文件,并放到相对于你运行 zig build 的位置的 zig-out/bin 中。构建系统也能在编译完成后运行编译产物,但需要我们主动要求它这么做。

那就来要求一下:

--- build.zig
+++ build.zig
@@ -18,2 +18,8 @@
    b.installArtifact(exe);
+
+    const run_step = b.step("run", "Run the executable");
+    const run = b.addRunArtifact(exe);
+    run.addPassthruArgs();
+    run_step.dependOn(&run.step);
+    run.step.dependOn(b.getInstallStep());
}

第一行新增的代码在 Build 图中创建了一个名为 “run” 的新“步骤”。这个步骤可以通过调用 zig build run 来运行。第二个参数是帮助文本,用户运行 zig build -h 时会看到它显示在该步骤旁边。

第二行在构建图中创建了一个步骤来运行程序 exe,第三行则把用户在 zig build run 中、位于 -- 参数之后添加的所有命令行参数传给 exe 的调用。第四行和第五行乍看之下有点奇怪。第四行表示我们命名的步骤 run_step 依赖于 run.step。这意味着 zig build run 需要 run 才能执行,这符合我们的预期。

& 运算符和 C 里的完全一样:Zig 有指针,但没有更聪明的东西。step 是 run 上的一个字段,如果你开着 LSP,会看到它是一个指针。. 运算符会根据左侧的类型自动充当 C 的 . 或 -> 运算符。所以 &foo.bar 取的是 foo 上 bar 字段的地址(如果 foo 是指针类型,则取 foo.* 上的)。

最后一行说明可执行文件会先安装再运行。严格来说这并非必需,但

来源: Robbie Lyman← 返回首页