编程语言Zig有什么与众不同的

2022-11-28 15:54:52 浏览数 (1)

作者 | Erik Engheim

译者 | 马可薇

策划 | Tina

Zig 允许在编译期执行代码,这有什么意义?

Zig 的吉祥物“零号(Zero the Ziguana)”

编程语言专家曾对 Zig 编程语言的创造者 Andrew Kelley 说,在编译时运行代码是个蠢主意。尽管如此,Kelley 还是去实现了这个想法,而多年以后,这个蠢主意已经成为了 Zig 的招牌。这一特征在 Zig 中用关键字 comptime 标识,代表需要在编译时运行的代码或者是需要的变量。Zig 可以在编译时运行代码的能力让开发者们可以在不明确任何泛型或模板支撑的情况下,编写通用代码或是进行元编程。让我们来通过代码例子更直观地了解编译时运行是什么意思,以及其为什么重要。以这段简单的函数为例,在 a 和 b 两个数之间取最大值。不使用泛型或 comptime 代码的话,我们就需要将这个函数的具体变量类型写死,比如这里用的 Zig 中 32 位整数 i32 。

代码语言:javascript复制
fn maximum(a: i32, b: i32) i32 {
    var result: i32 = undefined;

    if (a > b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

和 C/C 一样,Zig 中可执行的程序通常都会有个 main 函数,我们可以在主函数里面调用最大值函数。在下面的代码,暂时不用管 stdout 的调用或者在 print 函数前的 try 关键词,后者和 Zig 的错误处理有关,在本文中并不涉及。

代码语言:javascript复制
pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    const a = 10;
    const b = 5;

    const biggest = maximum(a, b);

    try stdout.print("Max of {} and {} is {}n", .{ a, b, biggest });
}

很明显,这个解决方案有很大局限性。首先,maximum 只能处理 32 位整数。C 语言编程者大概对这个问题并不陌生,C 预处理的宏就是用来解决这个问题的。Andrew Kelley 为避免依赖 C 的宏,专门设计了 Zig。可以说,Zig 存在的原因本质上就是 Andrew 想用 C 编程,但又不想折腾宏这类烦人的东西。comptime 的诞生的意义完全就是为了取代 C 的宏。

让我们再看看 Zig 对这类问题的解决方案。先在 Zig 中定义一个泛型 maxiumum 函数,用 anytype 和 @TypeOf(a) 替代 i32 类型参数。在 maximum 函数在被调用时,将默认 anytype 为提供的参数类型。请注意,Zig 不是动态编程语言,在用不同参数类型调用 maximum 时,Zig 的编译情况也会不同。a 和 b 的类型依旧会在编译时决定,而非运行时。

虽然在编译时确定输入参数的类型不是不行,但这么一来变量和返回类型就难处理了。anytype 不能用作是返回类型,因为我们不能在函数调用处再确定变量的具体类型。因此,我们需要用编译器内联函数 @TypeOf 在编译时生成返回类型,比如用 @TypeOf(a) 在编译时确定参数 a 的类型,或者是用来指定返回变量 result 的类型:

代码语言:javascript复制
fn maximum(a: anytype, b: anytype) @TypeOf(a) {
    var result: @TypeOf(a) = undefined;

    if (a > b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

虽然确实有了一定的提升,但还有别的问题:

  1. 没有限制用非数字参数调用 maximum 的情况
  2. 如果 b 值更大,那么返回值会有会超出 @TypeOf(a) 范围的情况

要想检测 a 和 b 的类型是否正确,我们可以创建一个在编译时运行的函数来检测参数是否是数字。定义函数 assertNumber 只有一个代表类型的参数 T,参数之前加上的 comptime,告诉编译器这是要在编译时必须已知的参数。

另外还需要注意下 switch 条件语句。在 Zig 里,switch 也可以返回数值,因此我们用参数 T 的类型做开关,如果 T 符合数字类型,那么 switch 条件语句就会返回 true,并将其赋给 is_num 变量。非数字类型则用 else 默认返回 false。

代码语言:javascript复制
fn assertNumber(comptime T: type) void {
    const is_num = switch (T) {
        i8, i16, i32, i64 => true,
        u8, u16, u32, u64 => true,
        comptime_int, comptime_float => true,
        f16, f32, f64 => true,
        else => false,
    };

    if (!is_num) {
        @compileError("Inputs must be numbers");
    }
}

// testing function
pub fn main() !void {
    assertNumber(bool);
}

在这个函数定义中另一个值得关注的点是 @compileError ,一个用来将编译器错误信息返回给用户的编译时内联函数。在这段代码中,我们给参数 assertNumber 提供了非数字的类型 bool,尝试编译这段程序后,我们会收到以下这段错误信息:

代码语言:javascript复制
assert-number.zig:11:9: error: Inputs must be numbers
        @compileError("Inputs must be numbers");
        ^
assert-number.zig:17:17: note: called from here
    assertNumber(bool);
                ^
assert-number.zig:16:21: note: called from here
pub fn main() !void {

也就是说,我们可以在运行无效代码时,用代码本身给用户输出更加有价值的错误信息。下面让我们用 assertNumber 检查 maximum 函数的输入。为保证返回类型范围足够,我们可以让两个输入参数类型必须相同:

代码语言:javascript复制
fn maximum(a: anytype, b: anytype) @TypeOf(a) {
    const A = @TypeOf(a);
    const B = @TypeOf(b);

    assertNumber(A);
    assertNumber(B);

    var result: @TypeOf(a) = undefined;

    if (A != B) {
        @compileError("Inputs must be of the same type");
    }

    if (a > b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

在运行时调用 maximum 会替换用编译结果替换所有编译时代码。但目前这种解决方案还没有解决我们原始函数的所有问题。我们强制使 a 和 b 保持同样的类型,那么如果我们想要对比有符号的 8-bit 和有符号的 32-bit 整数,也就是 Zig 中的参数类型 i8 和 i32 呢?那么我们就必须保证返回类型是 i32,目前的方案并不能做到这一点。我们需要的是一个能够在编译时运行,对比 a 与 b 的类型,并返回最长比特类型的函数。

想做到这点,那么我们还需要以下两个函数:

  • nbits 函数,用于计算类型 T 的比特长度
  • largestType 函数,用于返回 A 和 B 两个类型中比特最长的一个

注意在下面的这个例子中我们用了 comptime 来标记参数的类型,以告知 Zig 这些输入在编译时必须已知,编译器内联函数 @typeInfo 用于在编译时返回用于描述类型的复合对象 info,其中包含了类型是否带符号,类型需要多少比特来表示的信息。

代码语言:javascript复制
fn nbits(comptime T: type) i8 {
    return switch (@typeInfo(T)) {
        .Float => |info| info.bits,
        .Int => |info| info.bits,
        else => 64,
    };
}

fn largestType(comptime A: type, comptime B: type) type {
    if (nbits(A) > nbits(B)) {
        return A;
    } else {
        return B;
    }
}

fn maximum(a: anytype, b: anytype) largestType(@TypeOf(a),
                                               @TypeOf(b)) {
    var result: @TypeOf(a) = undefined;

    if (a > b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

可能例子里的 switch 语句表示得不是很清楚,让我再解释下。@typeInfo(T) 所返回的类型是联合类型(union type)std.builtin.TypeInfo ,这种类型和结构(struct)有些相似,都包含多个共享内存的字段。因此我们需要使用 switch 条件语句找到具体是在使用.Int 还是.Float 字段。|info|语法在 Zig 中是用来解包数值的,在这里我们用它来找描述类型的结构。info 对象会有两种类型 TypeInfo.Int 或者 TypeInfo.Float,但这两种 struct 类型都会有一个 bits 字段。在我们改进后的 maximum 函数里,我们没有明确指定返回值,而是调用了 largestType 函数并将它的返回值用做了 maximum 返回值的类型。尽管看起来很怪,但这确实是可行的,因为 Zig 编译器在编译时调用 largestType 的确只依赖了已知信息。编译器会根据每次 maximum 的调用创建不同变体,对不同的输入类型和输出类型进行编译。

用编译时的代码实现泛型

Zig 中 comptime 的强大可以通过对泛型的实现来证明。在下面的例子中的 minimum 函数对习惯于泛型或基于模板编程的开发者来说很是熟悉。其中的关键区别在于,类型参数 T 是作为一般参数输入的。对于 C 、Java 和 C# 的开发者来说,这个函数一般会以 minimum(x, y) 的形式调用,但对于 Zig 开发者来说,minimum(i8, x, y) 足矣。

代码语言:javascript复制
fn minimum(comptime T: type, a: T, b: T) T {
    assertNumber(T);

    var result: T = undefined;
    if (a < b) {
        result = a;
    } else {
        result = b;
    }

    return result;
} 

在 C/C 、Java 或 Swift 等语言中,我们通常可以从输入参数中推断变量类型。但在 Zig 中,这种类型推断不再可行,因为参数 T 被用作为一般参数,得不到特殊待遇了。虽然这让 comptime 弱势于泛型,但好处是 comptime 用起来更加灵活了。我们可以用 comptime 代码定义泛用类型,比如我们可以用 2D 矢量类来表示力、速度以及位置等信息。

查看英文原文:

What Makes the Zig Programming Language Unique? by Erik Engheim(https://erikexplores.substack.com/p/what-makes-the-zig-programming-language)

声明:本文为InfoQ翻译,未经许可禁止转载。

0 人点赞