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

文摘   科技   2024-11-10 08:00   江苏  
Zig 的元编程采用了简单且直观的设计,主要依赖于编译时执行(comptime)和反射机制。Zig 的元编程旨在保持语言核心简单,不引入额外的语法复杂性。类型和编译时值是语言的基本组成部分,所有元编程功能都以一种直观的方式集成到语言中


译|zouyee


为了帮助读者深入了解Kubernetes在各种应用场景下所面临的挑战和解决方案,以及如何进行性能优化。我们推出了<<Kubernetes经典案例30篇>>


Zig VS C++


Zig 和 C++ 的元编程有显著的区别,尤其是在设计哲学、可用性和灵活性方面。

设计理念和简洁性

  • Zig:Zig 的元编程采用了简单且直观的设计,主要依赖于编译时执行(comptime)和反射机制。Zig 的元编程旨在保持语言核心简单,不引入额外的语法复杂性。类型和编译时值是语言的基本组成部分,所有元编程功能都以一种直观的方式集成到语言中。

  • C++:C++ 的元编程高度依赖模板,使用了复杂的模板编译器逻辑。C++ 模板元编程(TMP)最初并不是专门为元编程设计的,而是后来演化为一种编译时功能。C++ 的 TMP 可以实现很多功能,但编写和调试代码通常较为困难,语法复杂,容易出错。

主要元编程机制

  • Zig:

    • 编译时执行:comptime 关键字允许代码在编译时运行。Zig 提供对类型和常量的直接操作,简化了编译时计算和代码生成。

    • 反射:Zig 通过内建的 @typeInfo 等函数提供编译时反射,允许程序查看类型的信息并基于这些信息进行条件编译。

    • 泛型:Zig 使用简单的泛型机制,借助 comptime 参数创建可以适应不同类型的结构体或函数。

  • C++:

    • 模板:C++ 主要依靠模板来实现元编程。模板可以应用于类和函数,允许代码在编译时生成多种类型的实现。

    • SFINAE 和模板特化:通过模板特化和 SFINAE(Substitution Failure Is Not An Error)实现条件编译和编译时推断,C++ 提供了强大但复杂的元编程能力。

    • constexpr:从 C++11 开始,constexpr 允许定义可在编译时计算的常量表达式,并扩展了编译时逻辑。

可读性和易用性

  • Zig:Zig 的元编程被设计为对普通开发者更友好。comptime 和 anytype 简化了编写泛型和编译时代码的过程,降低了复杂性,同时提升了代码的可读性。

  • C++:C++ 模板元编程通常被认为难以阅读和调试。由于模板的错误信息复杂且不直观,开发者在处理复杂的模板逻辑时可能会遇到困难。现代 C++ 的改进(如 constexpr 和概念)稍微缓解了这个问题,但复杂度仍然较高。

编译时反射和类型系统

  • Zig:Zig 提供了编译时类型反射,允许在编译时查询类型信息,并根据这些信息生成代码。Zig 的类型系统与元编程紧密结合,使得实现泛型和其他复杂结构更加灵活和简洁。

  • C++:C++ 没有原生的编译时反射,尽管有一些库和编译器扩展(如 Boost.Hana 和 libclang)可以实现类似功能。C++ 的类型系统庞大且复杂,元编程通常需要绕过类型系统的限制。

性能与优化

  • Zig:Zig 在设计上注重编译时的高效性和可控性。通过明确指定哪些代码在编译时执行,开发者可以更精确地优化程序的性能。此外,Zig 不会自动进行运行时的垃圾回收或不必要的内存分配,给予开发者更多的控制权。

  • C++:C++ 提供了丰富的编译时优化选项,并且编译器会尝试进行各种代码优化。然而,由于模板元编程和 SFINAE 的复杂性,编译时间可能会显著增加。C++ 的编译时逻辑也可以提高性能,但管理和理解这些逻辑可能会比较困难。

错误处理与调试

  • Zig:Zig 提供的错误处理机制相对直观,尤其是与 comptime 相关的错误可以更清晰地定位和处理。编译时错误信息简洁易懂,便于开发者迅速排查问题。

  • C++:C++ 模板元编程的错误消息可能会非常难以解析,尤其是在模板推断或 SFINAE 失败时。编译器的错误输出通常非常复杂,需要经验丰富的开发者才能快速解决。

应用场景

  • Zig:Zig 的元编程更适合于对性能和资源控制要求高的场景,如系统编程、嵌入式开发、编译时计算和简化的泛型编程。Zig 的设计哲学更倾向于直接、可控和高效的代码。

  • C++:C++ 的元编程能力非常强大,适合复杂的泛型库、模板库(如 STL 和 Boost)和需要高度抽象的项目。然而,它也可能会导致冗长、难以维护的代码,尤其是在大型项目中。


Phase distinction


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

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

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

理论

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

介绍

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

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

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


以及类型:

 T ::= Bool | T -> T

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



Zig编译时与运行时

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

1. 程序满足了哪些不变性?

2. 在这个阶段可能出什么问题?

3. 如果这个阶段成功了,我们知道什么后置条件?

4. 如果有输入和输出,它们是什么?


编译时

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

编译时可能出的问题:

1. 语法错误

2. 类型检查错误

3. (极少情况)编译器崩溃

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

1. 程序格式正确——在所使用的语言中是一个有意义的程序。

2. 程序可以开始运行。(程序可能会立刻失败,但至少我们可以尝试运行。)

输入和输出是什么?

1. 输入是正在编译的程序,加上任何头文件、接口、库,或其他为了编译而需要导入的内容。

2. 输出通常是汇编代码、可重定位的目标代码,或者甚至是一个可执行程序。或者如果出错了,输出是一堆错误信息。


运行时

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

运行时可能出的问题:

 1. 运行时错误:

   a. 除零错误

   b. 解引用空指针

   c. 内存不足

 2.还可能有程序自身检测到的错误:

  a. 尝试打开一个不存在的文件

  b. 试图查找网页却发现提供的 URL 格式不正确

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

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


Zig compile-time与元编程


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

1. 类型在编译时是有效值。

2. 大多数运行时代码也可以在编译时工作。

3. 结构体字段在编译时是鸭子类型(duck-typed)。

4. Zig 标准库提供了一些工具来进行编译时反射。

Zig 示例代码

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

编译时运行代码

让我们从基础知识开始:使用`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` 关键字,可以强制在编译时执行代码块。在这个例子中,变量 `x` 和 `y` 是等价的:

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

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语句中省略分支。下面的程序要求用户输入一个数字,然后迭代地对其应用一系列操作:

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](https://godbolt.org/),选择Zig作为目标语言,然后选择大于0.4.0的Zig版本。Godbolt将编译代码并显示生成的汇编代码。右键单击代码行,会弹出一个上下文菜单,让你跳转到相应的汇编代码。你会注意到for循环和switch都没有对应的汇编代码。删除`inline`关键字,它们现在将会显示出来。

comptime_int和 comptime_float

1. `comptime_int` 是一种特殊类型,在编译时没有大小限制,并具有任意精度。它可以转换为能够容纳其值的任何整数类型,也可以转换为浮点数。

2. `comptime_float` 是 `f128` 类型,不能转换为整数,即使其值是一个整数。

test "comptime_int" {
const a = 12;
const b = a + 10;


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


常见问题

1. 在 `comptime` 执行中没有“同级”类型解析。

2. 所有 `comptime` 值都不遵循常规的生命周期规则,具有“静态”生命周期(可以认为这些值是垃圾回收的)。

3. 允许结构体字段使用 `anytype`,这将使结构体成为编译时类型。

4. 可以使用 `comptime var` 来创建编译时闭包。


泛型

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

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

1. 泛型函数

由于泛型编程与`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`类型的值执行内省。在之前的示例中,我们从用户输入解析了一个整数,并请求了一个特定类型的整数。解析函数使用该信息从其泛型实现中省略了一些代码。

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

2. 泛型结构体

在描述如何创建泛型结构体之前,先简要介绍一下Zig中结构体的工作原理。

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文档中提取的示例。它是一个双向链接的列表。

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

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

1. 主LinkedList结构体

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

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

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 时的反射示例,但在这一节中,我想专注于更高级的反射用法。我将通过一个代码示例来介绍这个概念。

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

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

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 类型的参数非常强大,允许构建经过优化但仍然“动态”的函数。在 Zig 中,类型是 `type` 类型的值,仅在编译时可用。对于下一个例子,我将从标准库中提取一些代码,展示这种功能的更有用的用法。

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

// 为了更好的可读性,我将原始定义的一部分移动到单独的函数中。
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仍然是一种新语言,ZSF仍在定期实现和测试更多功能。学习Zig是一个很好的决定,因为它作为一种更好的C语言,有着光明的前景。




由于笔者时间、视野、认知有限,本文难免出现错误、疏漏等问题,期待各位读者朋友、业界专家指正交流。

参考文献

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/




真诚推荐你关注



Kubernetes经典案例30篇

ziglang30分钟速成

Rust vs. Zig:究竟谁更胜一筹?性能、安全性等全面对决!




来个“分享、点赞、在看”👇

DCOS
CNCF 云原生基金会大使,CoreDNS 开源项目维护者。主要分享云原生技术、云原生架构、容器、函数计算等方面的内容,包括但不限于 Kubernetes,Containerd、CoreDNS、Service Mesh,Istio等等