结构体与 comptime:编译期计算的威力

本文基于 Zig 0.16。

前几篇我们完成了基础语法、错误处理和内存分配器,现在终于可以触及 Zig 最迷人、也最具革命性的特性——编译期计算(comptime)。但在此之前,我们先花几分钟快速了解 Zig 的结构体与方法,它们是你理解 comptime 的基石。

如果你来自 Go 或 Rust,Zig 的结构体一眼就能认出:它既像 Go 的 struct 一样声明字段,又像 Rust 的 impl 一样在内部定义方法。Zig 用一种统一的方式做到了这两件事。

结构体与方法

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
31
32
33
const std = @import("std");

// 结构体定义
const Vec2 = struct {
    x: f32,
    y: f32,

    // 方法(在结构体内部定义)
    fn length(self: *const Vec2) f32 {
        return @sqrt(self.x * self.x + self.y * self.y);
    }

    fn add(self: *const Vec2, other: Vec2) Vec2 {
        return .{
            .x = self.x + other.x,
            .y = self.y + other.y,
        };
    }

    // 静态方法(不需要 self)
    fn zero() Vec2 {
        return .{ .x = 0, .y = 0 };
    }
};

pub fn main() void {
    const v1 = Vec2{ .x = 3.0, .y = 4.0 };
    const v2 = Vec2{ .x = 1.0, .y = 2.0 };

    const len = v1.length(); // 5.0
    const sum = v1.add(v2);  // {4.0, 6.0}
    const zero = Vec2.zero(); // {0, 0}
}

几个关键观察点:

  • self 不是关键字——Zig 中 self 只是一个约定俗成的参数名,你也可以叫它 this 或任意合法标识符。这与 Go 的 method receiver(func (v *Vec2) length() f32)形成鲜明对比:Go 的 receiver 是语言特性,Zig 的方法就是普通函数——只不过恰好定义在结构体内部,可以用点号语法调用。
  • 对 Rust 开发者的补充:Rust 的 impl Vec2 { fn length(&self) -> f32 } 将方法与类型定义分离。Zig 选择合在一起,风格更接近 C++ 和 Java——所有成员函数写在 struct 大括号内。
  • 静态方法没有 self,通过 Vec2.zero() 直接调用,等价于 C++ 的 static 方法或 Rust 的 impl 块中的关联函数。
  • 返回值使用匿名结构体字面量.{ .x = ..., .y = ... }——这是 Zig 从 C99 的 designated initializer 吸收的风格,简洁且可读。

编译期计算(Comptime):为什么重要

在接触具体语法前,先理解 comptime 的意义。

Rust 有 const fn——可以在编译期执行某些函数,但限制严格(不能分配内存、不能操作类型)。C++ 有 constexpr——功能更强但仍然受限,且语法复杂度高。

Zig 的 comptime 走得更远:任何 Zig 代码都可以在编译期执行。分配内存?可以。操作类型?可以。运行任意复杂度的算法?可以。编译期就是 Zig 的"另一个运行环境",语法完全相同。

这意味着:

  • 零运行时开销的泛型:没有 vtables,没有动态分发,没有擦除——编译器在编译期展开特化版本。
  • 元编程不需要第二门语言:不需要 Rust 的宏(macro)、Go 的 go:generate、C 的预处理器。Zig 用同一门语言完成一切。
  • 反射不会拖慢运行时@typeInfo 在编译期求值,生成的是已经计算好的结果。

基本用法

comptime 执行任意代码

comptime 关键字修饰表达式或代码块,编译器就会在编译期执行它:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const std = @import("std");

// 编译期计算的函数
fn fibonacci(n: u32) u32 {
    if (n < 2) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// 编译期执行
const fib_10 = comptime fibonacci(10); // = 55

注意 fibonacci 本身是一个普通的运行时函数——没有 const 前缀,没有特殊标记。是调用处的 comptime 关键字告诉编译器:“请在编译期运行这个函数,把结果嵌入二进制文件。”

这就是 comptime 的第一层威力:同一段代码,可在编译期运行,也可在运行时运行。无需维护两套实现。

类型是一等公民:List(comptime T: type)

comptime 最强大的用法是与 type 参数结合——类型可以作为值传递给函数,函数可以返回类型。

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 类型是一等公民,可以作为参数
fn List(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,

        fn get(self: *const @This(), index: usize) T {
            return self.items[index];
        }
    };
}

// 使用泛型类型
const IntList = List(i32);
const FloatList = List(f64);

Go 开发者注意:Go 1.18+ 的泛型使用 [T any] 类型参数语法,编译器也会进行 monomorphization(单态化)。但 Zig 的方式更底层、更灵活——comptime 可以接受任何编译期已知的值,不限于类型。

Rust 开发者注意:Rust 的泛型需要 trait bound(fn get<T: Copy>(...)),Zig 不需要——只要代码中操作对 T 有效即可编译。这是"编译期鸭子类型":T 支持 [] 索引吗?支持就编过。

版本提示(Zig 0.16):在 Zig 的早期版本中,@Type 函数可用来动态构造类型——从一个描述类型结构的编译期值生成新的类型。Zig 0.16 已移除 @Type,将其拆分为更专门的独立内置函数:@Int@Struct@Union@Enum@Pointer@Fn@Tuple@EnumLiteral。本篇所有示例使用 @typeInfo 读取类型信息,是只读操作,不受此变更影响。即使未来版本继续演进类型构造 API,comptime 的基本概念和反射用法仍然有效。

编译期反射

Zig 内置函数 @typeInfo 可以在编译期获取任何类型的结构化描述——它返回一个 tagged union,描述了类型的结构(字段、参数、方法等)。

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn printTypeInfo(comptime T: type) void {
    const info = @typeInfo(T);

    comptime {
        std.debug.print("类型名: {}\n", .{@typeName(T)});
        std.debug.print("类型大小: {} bytes\n", .{@sizeOf(T)});
        std.debug.print("对齐: {} bytes\n", .{@alignOf(T)});
    }
}

// 使用
printTypeInfo(i32);
printTypeInfo(f64);

comptime { ... } 块内的代码全部在编译期执行,生成 print 调用的结果直接编译进终端输出。这与运行时 print 不同——编译期执行时,std.debug.print 的目标是编译器的错误/日志输出,而非运行时的标准输出。

其他常用的编译期类型查询内置函数:

  • @typeName(T):返回类型的字符串表示(编译期 []const u8
  • @sizeOf(T):返回类型占用字节数(等价于 C 的 sizeof
  • @alignOf(T):返回类型的对齐要求

编译期循环:inline for

与类型反射最常搭配使用的,是 inline for——一种在编译期展开的循环结构。普通的 for 在运行时迭代,inline for 在编译期展开,适用于需要在编译期遍历"值的序列"的场景——比如结构体的字段列表。

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
fn tupleLength(comptime T: type) usize {
    const info = @typeInfo(T);
    return info.Struct.fields.len;
}

// 遍历结构体字段
fn printFields(comptime T: type) void {
    comptime {
        const fields = @typeInfo(T).Struct.fields;
        inline for (fields) |field| {
            std.debug.print("字段: {}, 类型: {}\n", .{
                field.name,
                field.type,
            });
        }
    }
}

const Point = struct {
    x: f32,
    y: f32,
    z: f32,
};

// 编译期打印所有字段信息
comptime printFields(Point);

inline for 与传统泛型编程的区别在于:它操作的是编译期的"值",而非运行时的"对象"。结构体字段在编译期就是已知集合,@typeInfo(T).Struct.fields 返回的是一个编译期切片(comptime-known slice),可以用 inline for 逐项展开。

这意味着你可以写出自动序列化、自动验证、自动映射的基础设施,且全部零运行时开销——预知所有字段名和类型,编译器为你生成最优代码。

Go/Rust 开发者注意:Zig 的 comptime 比 Rust 的 const fn 和 Go 的泛型强大得多。你可以在编译期执行任意代码、操作类型、甚至分配内存(编译期内存)。这是 Zig 实现泛型、元编程的基础。Rust 的 proc macro 虽然也能做到类似的事,但需要单独维护一套宏语法和运行环境;Go 的 go:generate 则完全是外部工具的拼凑。Zig 的答案更简单:用同一门语言,同一套语法,在编译期执行。

小结

本篇我们进入了 Zig 的设计核心:

概念ZigGoRust
方法定义struct 内部直接定义method receiver分离的 impl 块
self/this显式普通参数隐式 receiver&self 语法糖
泛型机制comptime 参数类型参数 [T]trait bound
编译期计算任意 Zig 代码受限的 const fn
类型反射@typeInforeflecttrait 内省
元编程comptime 同一语言go:generate 外部工具proc macro 不同语法

comptime 的核心理念可以提炼为一条:编译期和运行时是同一连续谱。同一套语法、同一套函数、同一套计算规则——区别只在于前面加了 comptime 关键字。

下一篇将是本系列的终章——标准库常用数据结构ArrayListStringHashMapAllocator 实践等。我们将把这些概念串联起来,写一些真正可用的 Zig 程序。