超越C++:Ziglang 元编程一文打尽

如果你以前只有在宏、泛型或代码生成场景中体会过编译时执行,那么可以在Zig语言中好好体会一番。

Zig是由Andrew Kelley开发的一种新的通用编程语言。尽管仍在积极开发中(当前版本0.13),但我认为这种语言已经展现出了巨大的潜力。Zig的目标是成为更好的C,类似于Rust可以被理解为更好的C++。它没有垃圾回收,没有内置事件循环,也没有其他运行时机制。它像C一样精简,并且实际上可以与C轻松互操作。有关完整概述,请访问官网。

如果你对Zig操作的抽象级别有一般的了解,那么也就不会对在运行时没有反射感到惊讶;在编译时无法做的事情,可以在编译时完成。

Phase distinction

阶段区分是指在编程语言中,类型和术语之间有严格分隔的一种特性。Luca Cardelli 提出了一个简明的规则,用于判断语言是否保持了阶段区分:如果 A 是一个编译时术语,并且 B 是 A 的一个子术语,那么 B 也必须是一个编译时术语。

大多数静态类型语言都遵循阶段区分原则。然而,一些拥有特别灵活且表达能力强的类型系统的语言(尤其是依赖类型的编程语言)允许像操作普通术语一样操作类型。类型可以被传递给函数或作为结果返回。

具有阶段区分的语言可能会为类型和运行时变量设置单独的命名空间。在优化编译器中,阶段区分标记了哪些表达式可以安全删除的边界。

理论

阶段区分通常与静态检查结合使用。通过基于演算的系统,阶段区分消除了在不同类型和术语之间实施线性逻辑的必要性。

介绍

阶段区分将编译时的处理与运行时的处理区分开来。

一种简单的语言,其术语包括:

1
t ::= true | false | x | λx : T . t | t t | if t then t else t

以及类型:

1
T ::= Bool | T -> T 

注意类型和术语是如何分开的。在编译时,类型用于验证术语的正确性。然而,在运行时,类型并不起任何作用。

编译时与运行时

编译时与运行时源自“Phase distinction”理论。这个概念是较难理解,尤其是对于编程语言背景不太深的人来说。为了解决这个问题,我觉得有帮助的办法是回答下面几个问题:

  1. 程序满足了哪些不变性?
  2. 在这个阶段可能出什么问题?
  3. 如果这个阶段成功了,我们知道什么后置条件?
  4. 如果有输入和输出,它们是什么?

编译时

程序不需要满足任何不变性。实际上,它甚至不需要是一个格式正确的程序。你可以把一段 HTML 代码交给编译器,编译器会直接报错……

编译时可能出的问题:

  • 语法错误
  • 类型检查错误
  • (极少情况)编译器崩溃

如果编译成功,我们知道什么?

  • 程序格式正确——在所使用的语言中是一个有意义的程序。
  • 程序可以开始运行。(程序可能会立刻失败,但至少我们可以尝试运行。)

输入和输出是什么?

  • 输入是正在编译的程序,加上任何头文件、接口、库,或其他为了编译而需要导入的内容。
  • 输出通常是汇编代码、可重定位的目标代码,或者甚至是一个可执行程序。或者如果出错了,输出是一堆错误信息。

运行时

我们对程序的不变性一无所知——它们完全由程序员设定。运行时的不变性很少只靠编译器来保证;这通常需要程序员的帮助。

运行时可能出的问题:

  • 运行时错误:
    • 除零错误
    • 解引用空指针
    • 内存不足
  • 还可能有程序自身检测到的错误:
    • 尝试打开一个不存在的文件
    • 试图查找网页却发现提供的 URL 格式不正确

如果运行时成功,程序就会顺利完成(或继续运行)而不会崩溃。

输入和输出完全由程序员决定。例如,文件、屏幕上的窗口、网络数据包、发送到打印机的作业等等。假如程序发射导弹,那也是一个输出,并且只能在运行时发生 :-)

在编译时运行代码

让我们从基础知识开始:使用comptime关键字可以在编译时运行任意代码。

1
2
3
4
5
6
7
8
fn multiply(a: i64, b: i64) i64 {
return a * b;
}

pub fn main() void {
const len = comptime multiply(4, 5);
const my_static_array: [len]u8 = undefined;
}

需要注意的是,函数定义没有任何说明在编译时可用的属性。这只是一个普通的函数,我们在调用点请求其在编译时执行。

在编译时定义块

你还可以使用comptime在函数内定义编译时块。以下示例是一个处理不区分大小写的字符串比较函数,针对其中一个字符串是硬编码的情况进行了优化。编译时执行确保函数不被滥用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn insensitive_eql(comptime uppr: []const u8, str: []const u8) bool {
comptime {
var i = 0;
while (i < uppr.len) : (i += 1) {
if (uppr[i] >= 'a' and uppr[i] <= 'z') {
@compileError("`uppr` must be all uppercase");
}
}
}
var i = 0;
while (i < uppr.len) : (i += 1) {
const val = if (str[i] >= 'a' and str[i] <= 'z')
str[i] - 32
else
str[i];
if (val != uppr[i]) return false;
}
return true;
}

pub fn main() void {
const x = insensitive_eql("Hello", "hElLo");
}

该程序的编译失败并产生以下输出。

编译时代码消除

Zig可以静态解析依赖于编译时已知值的控制流表达式。例如,你可以强制在while/for循环上进行循环展开,并从if/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
34
35
36
const builtin = @import("builtin");
const std = @import("std");
const fmt = std.fmt;
const io = std.io;

const Op = enum {
Sum,
Mul,
Sub,
};

fn ask_user() !i64 {
var buf: [10]u8 = undefined;
std.debug.warn("A number please: ");
const user_input = try io.readLineSlice(buf[0..]);
return fmt.parseInt(i64, user_input, 10);
}

fn apply_ops(comptime operations: []const Op, num: i64) i64 {
var acc: i64 = 0;
inline for (operations) |op| {
switch (op) {
.Sum => acc +%= num,
.Mul => acc *%= num,
.Sub => acc -%= num,
}
}
return acc;
}

pub fn main() !void {
const user_num = try ask_user();
const ops = [4]Op{.Sum, .Mul, .Sub, .Sub};
const x = apply_ops(ops[0..], user_num);
std.debug.warn("Result: {}\n", x);
}

该代码的有趣部分是for循环。inline关键字强制进行循环展开,循环体内有一个在编译时解析的switch语句。简而言之,在前面示例中对apply_ops的调用基本上解析为:

1
2
3
4
5
6
var acc: i64 = 0;
acc +%= num;
acc *%= num;
acc -%= num;
acc -%= num;
return acc;

为了测试这是否确实发生了,将程序代码粘贴到https://godbolt.org,选择Zig作为目标语言,然后选择大于0.4.0的Zig版本。Godbolt将编译代码并显示生成的汇编代码。右键单击代码行,会弹出一个上下文菜单,让你跳转到相应的汇编代码。你会注意到for循环和switch都没有对应的汇编代码。删除inline关键字,它们现在将会显示出来。

泛型

comptime关键字指示在编译时解析的代码区域和值。在前面的示例中,我们使用它执行类似于模板元编程的操作,但它也可用于泛型编程,因为类型是有效的编译时值。

泛型函数

由于泛型编程与comptime参数相关,Zig没有传统的菱形括号语法。除此之外,泛型的基本用法与其他语言非常相似。以下代码是从标准库中提取的Zig的mem.eql实现,用于测试两个切片是否相等。

1
2
3
4
5
6
7
8
/// Compares two slices and returns whether they are equal.
pub fn eql(comptime T: type, a: []const T, b: []const T) bool {
if (a.len != b.len) return false;
for (a) |item, index| {
if (b[index] != item) return false;
}
return true;
}

如你所见,Ttype类型的变量,后续的参数将其用作泛型参数。这样,就可以使用mem.eql与任何类型的切片。

还可以对type类型的值执行内省。在之前的示例中,我们从用户输入解析了一个整数,并请求了一个特定类型的整数。解析函数使用该信息从其泛型实现中省略了一些代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
return fmt.parseInt(i64, user_input, 10);

// 这是`parseInt`的stdlib实现
pub fn parseInt(comptime T: type, buf: []const u8, radix: u8) !T {
if (!T.is_signed) return parseUnsigned(T, buf, radix);
if (buf.len == 0) return T(0);
if (buf[0] == '-') {
return math.negate(try parseUnsigned(T, buf[1..], radix));
} else if (buf[0] == '+') {
return parseUnsigned(T, buf[1..], radix);
} else {
return parseUnsigned(T, buf, radix);
}
}

泛型结构体

在描述如何创建泛型结构体之前,先简要介绍一下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
const std = @import("std");
const math = std.math;
const assert = std.debug.assert;

// 结构体定义不包括名称。
// 将结构体分配给变量会为其赋予名称。
const Point = struct {
x: f64,
y: f64,
z: f64,

// 结构体定义还可以包含命名空间函数。
// 当通过结构体实例调用带有Self参数的结构体函数时,
// 将自动填充第一个参数,就像方法一样。
const Self = @This();
pub fn distance(self: Self, p: Point) f64 {
const x2 = math.pow(f64, self.x - p.x, 2);
const y2 = math.pow(f64, self.y - p.y, 2);
const z2 = math.pow(f64, self.z - p.z, 2);
return math.sqrt(x2 + y2 + z2);
}
};

pub fn main() !void {
const p1 = Point{ .x = 0, .y = 2, .z = 8 };
const p2 = Point{ .x = 0, .y = 6, .z = 8 };

assert(p1.distance(p2) == 4);
assert(Point.distance(p1, p2) == 4);
}

现在我们可以深入讨论泛型结构体了。要创建泛型结构体,只需创建一个接受类型参数的函数,并在结构体定义中使用该参数。以下是从Zig文档中提取的示例。它是一个双向链接的列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
fn LinkedList(comptime T: type) type {
return struct {
pub const Node = struct {
prev: ?*Node = null,
next: ?*Node = null,
data: T,
};

first: ?*Node = null,
last: ?*Node = null,
len: usize = 0,
};
}

该函数返回一个类型,这意味着它只能在编译时调用。它定义了两个结构体:

  • 主LinkedList结构体
  • 命名空间内的Node结构体,嵌套在主结构体中

就像结构体可以对函数进行命名空间分组一样,它们也可以对变量进行命名空间分组。在创建复合类型时,这对内省非常有用。以下是LinkedList如何与先前的Point结构体组合的示例。

1
2
3
4
5
6
7
8
9
10
11
const PointList = LinkedList(Point);
const p = Point{ .x = 0, .y = 2, .z = 8 };

var my_list = PointList{};

// 完整实现需要提供一个`append`方法。
// 现在我们手动添加新节点。
var node = PointList.Node{ .data = p };
my_list.first = &node;
my_list.last = &node;
my_list.len = 1;

Zig标准库中包含了一些完成度非常高的链表实现。

编译时反射

现在我们已经涵盖了所有基础知识,我们终于可以进入 Zig 元编程真正强大且有趣的内容。

在之前的例子中,我们已经看到了在 parseInt 中检查 T.is_signed 时的反射示例,但在这一节中,我想专注于更高级的反射用法。我将通过一个代码示例来介绍这个概念。

1
2
3
fn make_couple_of(x: anytype) [2]@typeOf(x) {
return [2]@typeOf(x) {x, x};
}

这个几乎没什么用的函数可以接受任何值作为输入,并创建一个包含两个副本的数组。以下调用都是正确的:

1
2
3
4
5
6
make_couple_of(5); // 创建 [2]comptime_int{5, 5}
make_couple_of(i32(5)); // 创建 [2]i32{5, 5}
make_couple_of(u8); // 创建 [2]type{u8, u8}
make_couple_of(type); // 创建 [2]type{type, type}
make_couple_of(make_couple_of("hi"));
// 创建 [2][2][2]u8{[2][2]u8{"hi","hi"}, [2][2]u8{"hi","hi"}}

anytype 类型的参数非常强大,允许构建经过优化但仍然“动态”的函数。对于下一个例子,我将从标准库中提取一些代码,展示这种功能的更有用的用法。

以下代码是 math.sqrt 的实现,我们在先前的例子中用它来计算两点之间的欧几里德距离。

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
// 为了更好的可读性,我将原始定义的一部分移动到单独的函数中。
fn decide_return_type(comptime T: type) type {
if (@typeId(T) == TypeId.Int) {
return @IntType(false, T.bit_count / 2);
} else {
return T;
}
}

pub fn sqrt(x: anytype) decide_return_type(@typeOf(x)) {
const T = @typeOf(x);
switch (@typeId(T)) {
TypeId.ComptimeFloat => return T(@sqrt(f64, x)),
TypeId.Float => return @sqrt(T, x),
TypeId.ComptimeInt => comptime {
if (x > maxInt(u128)) {
@compileError(
"sqrt not implemented for " ++
"comptime_int greater than 128 bits");
}
if (x < 0) {
@compileError("sqrt on negative number");
}
return T(sqrt_int(u128, x));
},
TypeId.Int => return sqrt_int(T, x),
else => @compileError("not implemented for " ++ @typeName(T)),
}
}

这个函数的返回类型有点奇怪。如果看一下 sqrt 的签名,它在应声明返回类型的地方调用了一个函数。在 Zig 中,这是允许的。原始代码实际上内联了一个 if 表达式,但出于更好的可读性,我将其移到了一个单独的函数中。

那么 sqrt 对其返回类型想要做什么呢?当我们传入整数值时,它应用了一个小优化。在这种情况下,函数将其返回类型声明为原始输入的比特大小的一半的无符号整数。这意味着,如果我们传入一个 i64 值,该函数将返回一个 u32 值。这主要考虑到平方根函数的作用。然后,声明的其余部分使用反射进一步类型化,并在适当的情况下报告编译时错误。

总的来说,编译时执行非常出色,特别是当语言非常具有表达力时。没有良好的编译时元编程,人们必须借助宏或代码生成,或者更糟糕地在运行时执行许多无用的工作。

如果你希望想看到Zig更酷的例子,请看一下 Andrew 本人的这篇博文。他使用了一些上述技术来为编译时已知的字符串列表生成完美的哈希函数。其结果是用户可以创建一个在 O(1) 时间内匹配字符串的开关。代码非常易于理解,他还提供了关于如何轻松、有趣和安全地使用所有其他次要功能的一些独特见解。

Zig 元编程

Zig 的元编程由几个基本概念驱动:

  • 类型在编译时是有效值。
  • 大多数运行时代码也可以在编译时工作。
  • 结构体字段在编译时是鸭子类型(duck-typed)。
  • Zig 标准库提供了一些工具来进行编译时反射。
  • 示例:多分派(multiple dispatch)。

Zig 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const std = @import("std");

fn foo(x: anytype) @TypeOf(x) {
// 注意,这个 if 语句是在编译时执行的,而不是在运行时。
if (@TypeOf(x) == i64) {
return x + 2;
} else {
return 2 * x;
}
}

pub fn main() void {
var x: i64 = 47;
var y: i32 = 47;

std.debug.print("i64-foo: {}\n", .{foo(x)});
std.debug.print("i32-foo: {}\n", .{foo(y)});
}

泛型类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn Vec2Of(comptime T: type) type {
return struct {
x: T,
y: T
};
}

const V2i64 = Vec2Of(i64);
const V2f64 = Vec2Of(f64);

pub fn main() void {
var vi = V2i64{.x = 47, .y = 47};
var vf = V2f64{.x = 47.0, .y = 47.0};

std.debug.print("i64 vector: {}\n", .{vi});
std.debug.print("f64 vector: {}\n", .{vf});
}

编译时执行

使用 comptime 关键字,可以强制在编译时执行代码块。在这个例子中,变量 xy 是等价的:

1
2
3
4
5
6
7
test "comptime blocks" {
var x = comptime fibonacci(10);

var y = comptime blk: {
break :blk fibonacci(10);
};
}

comptime_int和 comptime_float

  • comptime_int 是一种特殊类型,在编译时没有大小限制,并具有任意精度。它可以转换为能够容纳其值的任何整数类型,也可以转换为浮点数。
  • comptime_floatf128 类型,不能转换为整数,即使其值是一个整数。
1
2
3
4
5
6
7
test "comptime_int" {
const a = 12;
const b = a + 10;

const c: u4 = a;
const d: f32 = b;
}

编译时函数参数

Zig 的函数参数可以标记为 comptime,这意味着传入的值必须在编译时已知。

1
2
3
4
5
6
7
8
9
10
11
fn Matrix(
comptime T: type,
comptime width: comptime_int,
comptime height: comptime_int,
) type {
return [height][width]T;
}

test "returning a type" {
expect(Matrix(f32, 4, 4) == [4][4]f32);
}

常见问题

  • comptime 执行中没有“同级”类型解析。
  • 所有 comptime 值都不遵循常规的生命周期规则,具有“静态”生命周期(可以认为这些值是垃圾回收的)。
  • 允许结构体字段使用 anytype,这将使结构体成为编译时类型。
  • 可以使用 comptime var 来创建编译时闭包。

反射

在 Zig 中,类型是 type 类型的值,仅在编译时可用。

1
2
3
4
test "branching on types" {
const a = 5;
const b: if (a < 10) f32 else i32 = 5;
}

可以使用内建的 @typeInfo 来反射类型,这会返回一个标记联合(tagged union)。


泛型类型与反射

创建泛型类型

使用 @This 获取最内层的结构体、联合或枚举的类型。

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
fn Vec(
comptime count: comptime_int,
comptime T: type,
) type {
return struct {
data: [count]T,
const Self = @This();

fn abs(self: Self) Self {
var tmp = Self{ .data = undefined };
for (self.data) |elem, i| {
tmp.data[i] = if (elem < 0) -elem else elem;
}
return tmp;
}

fn init(data: [count]T) Self {
return Self{ .data = data };
}
};
}

const eql = @import("std").mem.eql;

test "generic vector" {
const x = Vec(3, f32).init([_]f32{ 10, -10, 5 });
const y = x.abs();
expect(eql(f32, &y.data, &[_]f32{ 10, 10, 5 }));
}

动态绑定

Zig 使用 anytype 绑定任意类型,实现基于调用类型的绑定。

1
2
3
fn makeCoupleOf(x: anytype) [2]@TypeOf(x) {
return [2]@TypeOf(x){ x, x };
}

通过这些功能,Zig 的元编程提供了灵活而强大的编译时能力,允许在编译阶段实现类型检查、类型推断和代码生成等高级功能。

参考链接

  1. https://kristoff.it/blog/what-is-zig-comptime/
  2. https://en.wikipedia.org/wiki/Phase_distinction
  3. https://stackoverflow.com/questions/846103/runtime-vs-compile-time
  4. https://en.wikipedia.org/wiki/Phase_distinction
  5. https://www.cnblogs.com/cdaniu/p/15456650.html
  6. https://ikrima.dev/dev-notes/zig/zig-metaprogramming/