如果您只体验过宏、泛型或代码形式的编译时执行,请准备好对 Zig 的功能感到惊讶。

什么是Zig

Zig是由Andrew Kelley开发的一种新型通用编程语言。虽然仍在开发中,但我认为该语言已经显示出巨大的前景。Zig 的目标是成为一个更好的 C,类似于 Rust 可以被认为是一个更好的C++。Zig 没有垃圾回收,没有内置的事件循环,也没有其他运行时机制。它就像 C 一样精简,事实上它可以很容易地与 C 进行互操作。有关完整的概述,请查看 https://ziglang.org

现在您已经大致了解了 Zig 的抽象级别,因此对于不支持运行时反射你应该不会感到惊讶; 但是不能在运行时做的事情,仍然可以在编译时做到。

在编译时运行代码

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

编译时函数调用

下面的代码使用函数来确定静态分配数组的长度。

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 在函数内定义编译时块。以下示例是一个不区分大小写的字符串比较函数,该函数针对两个字符串之一进行硬编码的用例进行了优化。编译时执行可确保函数不会被滥用。

// Compares two strings ignoring case (ascii strings only).
// Specialzied version where `uppr` is comptime known and *uppercase*.
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 build-exe ieq.zig                                       
/Users/loriscro/ieq.zig:8:17: error: `uppr` must be all uppercase
                @compileError("`uppr` must be all uppercase");
                ^
/Users/loriscro/ieq.zig:24:30: note: called from here
    const x = insensitive_eql("Hello", "hElLo");
                             ^

编译时代码省略

Zig 可以静态解析依赖于编译时已知值的控制流表达式。例如,您可以强制循环展开 while / for 循环和 / switch 语句中的 if elide 分支。以下程序要求用户输入一个数字,然后迭代地对其应用操作列表:

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 调用基本上解析为:

var acc: i64 = 0;
acc +%= num;
acc *%= num;
acc -%= num;
acc -%= num;
return acc;

要测试这是否确实如此,请将程序代码粘贴到 https://godbolt.org 中,选择 Zig 作为目标语言,然后选择大于 0.4.0 的 Zig 版本(在撰写本文时,您必须选择“zig trunk”)。Godbolt 将编译代码并向您展示生成的程序集。右键单击一行代码,上下文菜单将允许您跳转到该行对应的汇编代码。您会注意到, for 循环和 都不 switch 对应于任何程序集。删除关键字 inline ,它们现在将显示。

泛型

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

泛型函数

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

/// 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;
}

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

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

// This is the line in `apply_ops` where we parsed a number
return fmt.parseInt(i64, user_input, 10);

// This is the stdlib implementation of `parseInt`
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);
    }
}

泛型 structs

在描述如何创建泛型结构之前,下面简要介绍一下结构在 Zig 中的工作方式。

const std = @import("std");
const math = std.math;
const assert = std.debug.assert;

// A struct definition doesn't include a name.
// Assigning the struct to a variable gives it a name.
const Point = struct {
    x: f64,
    y: f64,
    z: f64,
		
    // A struct definition can also contain namespaced functions.
    // This has no impact on the struct layout.
    // Struct functions that take a Self parameter, when
    // invoked through a struct instance, will automatically
    // fill the first argument, just like methods do.
    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的文档中提取的示例。这是一个双重链接的侵入性列表。

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

该函数返回 type ,这意味着它只能在 comptime 调用。它定义了两个结构:

  • 主体 LinkedList 结构
  • 结构, Node 命名空间在主结构内

就像结构可以命名空间函数一样,它们也可以命名空间变量。这对于创建复合类型时的侦测特别有用。以下是 LinkedList 如何与我们之前 Point 的结构组合。

// To try this code, paste both definitions in the same file.
const PointList = LinkedList(Point);
const p = Point{ .x = 0, .y = 2, .z = 8 };

var my_list = PointList{};

// A complete implementation would offer an `append` method.
// For now let's add the new node manually.
var node = PointList.Node{ .data = p };
my_list.first = &node;
my_list.last = &node;
my_list.len = 1;

Zig 标准库包含几个充实的链表实现。

编译时反射

现在我们已经涵盖了所有的基础知识,我们终于可以转向使Zig元编程真正强大和有趣的事情了。

我们已经看到了一个反射的例子 parseInt 正在检查 T.is_signed ,但在本节中,我想重点介绍反射的更高级用法。我将通过代码示例介绍这个概念。

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

这个(几乎是无用的)函数可以将任何值作为输入,并创建一个包含它的两个副本的数组。以下调用都是正确的。

make_couple_of(5); // creates [2]comptime_int{5, 5}
make_couple_of(i32(5)); // creates [2]i32{5, 5}
make_couple_of(u8); // creates [2]type{u8, u8}
make_couple_of(type); // creates [2]type{type, type}
make_couple_of(make_couple_of("hi")); 
// creates [2][2][2]u8{[2][2]u8{"hi","hi"}, [2][2]u8{"hi","hi"}}

类型的 anytype 参数非常强大,允许构造优化但“动态”的函数。在下一个示例中,我将从标准库中获取更多代码,以展示此功能的更有用用法。

下面的代码是 的实现,我们在前面的 math.sqrt 示例中使用它来计算两点之间的欧氏距离。

// I moved part of the original definition to
// a separate function for better readability.
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) 字符串匹配的开关。代码非常容易理解,他提供了一些关于所有其他次要功能如何使用户抽象变得简单、有趣和安全使用的见解。