Chapter 17Generic Apis And Type Erasure

通用API与类型擦除

概述

Zig中的泛型不过是使用comptime值参数化的普通函数,然而这种简单性却隐藏了惊人的表达能力。在本章中,我们将15中的反射技术转化为严谨的API设计模式:构建能力契约、使用anytype转发具体类型,以及在不牺牲正确性的前提下保持调用站点的人机工程学。

我们还将介绍另一个极端——运行时类型擦除——其中不透明指针和手写的虚表(vtables)允许你将异构行为存储在统一的容器中。这些技术补充了16中的查找表生成,并为随后的完全泛型优先级队列项目做准备。有关发布说明,请参见v0.15.2

学习目标

  • 构建编译时契约,在代码生成之前验证用户提供的类型,并提供清晰的诊断信息。
  • anytype包装任意写入器和策略,保留零成本抽象,同时保持调用站点的整洁。参见Writer.zig
  • 应用anyopaque指针和显式虚表以安全地擦除类型,对齐状态并处理生命周期,而不会出现未定义行为。

编译时契约作为接口

一个Zig函数只要接受comptime参数,就变成了泛型函数。通过将这种灵活性与能力检查(@hasDecl@TypeOf,甚至自定义谓词)相结合,你可以编码丰富的结构化接口,而无需重量级的特性系统。15 我们首先来看看一个指标聚合器契约如何将错误推送到编译时,而不是依赖运行时断言。

验证结构化要求

下面的computeReport接受一个分析器类型,该类型必须公开StateSummaryinitobservesummarizevalidateAnalyzer助手使这些要求变得明确;忘记一个方法会得到一个精确的@compileError而不是一个神秘的实例化失败。我们用RangeAnalyzerMeanVarianceAnalyzer演示这个模式。

Zig
const std = @import("std");

fn validateAnalyzer(comptime Analyzer: type) void {
    if (!@hasDecl(Analyzer, "State"))
        @compileError("Analyzer must define `pub const State`.");
    const state_alias = @field(Analyzer, "State");
    if (@TypeOf(state_alias) != type)
        @compileError("Analyzer.State must be a type.");

    if (!@hasDecl(Analyzer, "Summary"))
        @compileError("Analyzer must define `pub const Summary`.");
    const summary_alias = @field(Analyzer, "Summary");
    if (@TypeOf(summary_alias) != type)
        @compileError("Analyzer.Summary must be a type.");

    if (!@hasDecl(Analyzer, "init"))
        @compileError("Analyzer missing `pub fn init`.");
    if (!@hasDecl(Analyzer, "observe"))
        @compileError("Analyzer missing `pub fn observe`.");
    if (!@hasDecl(Analyzer, "summarize"))
        @compileError("Analyzer missing `pub fn summarize`.");
}

fn computeReport(comptime Analyzer: type, readings: []const f64) Analyzer.Summary {
    comptime validateAnalyzer(Analyzer);

    var state = Analyzer.init(readings.len);
    for (readings) |value| {
        Analyzer.observe(&state, value);
    }
    return Analyzer.summarize(state);
}

const RangeAnalyzer = struct {
    pub const State = struct {
        min: f64,
        max: f64,
        seen: usize,
    };

    pub const Summary = struct {
        min: f64,
        max: f64,
        spread: f64,
    };

    pub fn init(_: usize) State {
        return .{
            .min = std.math.inf(f64),
            .max = -std.math.inf(f64),
            .seen = 0,
        };
    }

    pub fn observe(state: *State, value: f64) void {
        state.seen += 1;
        state.min = @min(state.min, value);
        state.max = @max(state.max, value);
    }

    pub fn summarize(state: State) Summary {
        if (state.seen == 0) {
            return .{ .min = 0, .max = 0, .spread = 0 };
        }
        return .{
            .min = state.min,
            .max = state.max,
            .spread = state.max - state.min,
        };
    }
};

const MeanVarianceAnalyzer = struct {
    pub const State = struct {
        count: usize,
        sum: f64,
        sum_sq: f64,
    };

    pub const Summary = struct {
        mean: f64,
        variance: f64,
    };

    pub fn init(_: usize) State {
        return .{ .count = 0, .sum = 0, .sum_sq = 0 };
    }

    pub fn observe(state: *State, value: f64) void {
        state.count += 1;
        state.sum += value;
        state.sum_sq += value * value;
    }

    pub fn summarize(state: State) Summary {
        if (state.count == 0) {
            return .{ .mean = 0, .variance = 0 };
        }
        const n = @as(f64, @floatFromInt(state.count));
        const mean = state.sum / n;
        const variance = @max(0.0, state.sum_sq / n - mean * mean);
        return .{ .mean = mean, .variance = variance };
    }
};

pub fn main() !void {
    const readings = [_]f64{ 21.0, 23.5, 22.1, 24.0, 22.9 };

    const range = computeReport(RangeAnalyzer, readings[0..]);
    const stats = computeReport(MeanVarianceAnalyzer, readings[0..]);

    std.debug.print(
        "Range -> min={d:.2} max={d:.2} spread={d:.2}\n",
        .{ range.min, range.max, range.spread },
    );
    std.debug.print(
        "Mean/variance -> mean={d:.2} variance={d:.3}\n",
        .{ stats.mean, stats.variance },
    );
}
运行
Shell
$ zig run chapters-data/code/17__generic-apis-and-type-erasure/comptime_contract.zig
输出
Shell
Range -> min=21.00 max=24.00 spread=3.00
Mean/variance -> mean=22.70 variance=1.124

契约仍然是零成本的:一旦验证通过,分析器方法就会内联,就好像你编写了专门的代码一样,同时仍然为下游用户提供可读的诊断信息。

诊断能力差距

由于validateAnalyzer集中了检查,你可以随着时间的推移扩展接口——例如,通过要求pub const SummaryFmt = []const u8——而无需触及每个调用点。当采用者升级并遗漏新的声明时,编译器会精确地报告缺少哪个要求。这种“快速失败,具体失败”的策略对于内部框架尤其有效,并防止模块之间发生静默漂移。37

权衡与批处理考虑

保持契约谓词的廉价。任何超过少数几个@hasDecl检查或直接类型比较的情况都应该放在一个可选特性标志后面,或者缓存在一个comptime var中。在广泛实例化的助手中进行大量分析会迅速增加编译时间——如果一个泛型花费的时间超出了预期,请使用zig build --verbose-cc进行分析。40

幕后:InternPool和泛型实例

computeReport使用具体的分析器实例化时,编译器会通过共享的InternPool解析所有相关的类型和值。这个结构确保每个唯一的分析器StateSummary和函数类型在代码生成之前都具有单一的规范标识。

graph TB IP["InternPool"] subgraph "线程" LOCALS["locals: []Local<br/>(每个线程一个)"] SHARDS["shards: []Shard<br/>(并发写入)"] TIDWIDTH["tid_width / tid_shift_*"] end subgraph "核心存储" ITEMS["items: []Item"] EXTRADATA["extra_data: []u32"] STRINGS["string_bytes"] LIMBS["limbs: []Limb"] end subgraph "依赖跟踪" SRCHASHDEPS["src_hash_deps"] NAVVALDEPS["nav_val_deps"] NAVTYDEPS["nav_ty_deps"] INTERNEDDEPS["interned_deps"] end subgraph "符号表" NAVS["navs: []Nav"] NAMESPACES["namespaces: []Namespace"] CAUS["caus: []Cau"] end subgraph "特殊索引" NONE["Index.none"] UNREACHABLE["Index.unreachable_value"] TYPEINFO["Index.type_info_type"] ANYERROR["Index.anyerror_type"] end IP --> LOCALS IP --> SHARDS IP --> TIDWIDTH IP --> ITEMS IP --> EXTRADATA IP --> STRINGS IP --> LIMBS IP --> SRCHASHDEPS IP --> NAVVALDEPS IP --> NAVTYDEPS IP --> INTERNEDDEPS IP --> NAVS IP --> NAMESPACES IP --> CAUS IP --> NONE IP --> UNREACHABLE IP --> TYPEINFO IP --> ANYERROR

关键属性:

  • 内容寻址存储:每个唯一的类型/值存储一次,由一个Index标识。
  • 线程安全:shards通过细粒度锁定实现并发写入。
  • 依赖跟踪:从源哈希、Navs和内部值映射到依赖的分析单元。
  • 特殊值:为anyerror_typetype_info_type等常见类型预分配索引。

使用包装器进行转发

一旦你信任具体类型的能力,你通常希望包装或适配它,而无需具现化一个特性对象。anytype是完美的工具:它将具体类型复制到包装器的签名中,保留了单态化性能,同时允许你构建装饰器链。15 下一个示例展示了一个可重用的“带前缀的写入器”,它对固定缓冲区和可增长列表同样适用。

一个可重用的带前缀的写入器

我们创建了两个接收器:一个来自重新组织的std.Io命名空间的固定缓冲区流,以及一个带有其自己的GenericWriter的堆支持的ArrayList包装器。withPrefix通过@TypeOf捕获它们的具体写入器类型,返回一个结构体,其print方法在转发到内部写入器之前添加一个标签。

Zig
const std = @import("std");

fn PrefixedWriter(comptime Writer: type) type {
    return struct {
        inner: Writer,
        prefix: []const u8,

        pub fn print(self: *@This(), comptime fmt: []const u8, args: anytype) !void {
            try self.inner.print("[{s}] ", .{self.prefix});
            try self.inner.print(fmt, args);
        }
    };
}

fn withPrefix(writer: anytype, prefix: []const u8) PrefixedWriter(@TypeOf(writer)) {
    return .{
        .inner = writer,
        .prefix = prefix,
    };
}

const ListSink = struct {
    allocator: std.mem.Allocator,
    list: std.ArrayList(u8) = std.ArrayList(u8).empty,

    const Writer = std.io.GenericWriter(*ListSink, std.mem.Allocator.Error, writeFn);

    fn writeFn(self: *ListSink, chunk: []const u8) std.mem.Allocator.Error!usize {
        try self.list.appendSlice(self.allocator, chunk);
        return chunk.len;
    }

    pub fn writer(self: *ListSink) Writer {
        return .{ .context = self };
    }

    pub fn print(self: *ListSink, comptime fmt: []const u8, args: anytype) !void {
        try self.writer().print(fmt, args);
    }

    pub fn deinit(self: *ListSink) void {
        self.list.deinit(self.allocator);
    }
};

pub fn main() !void {
    var stream_storage: [256]u8 = undefined;
    var fixed_stream = std.Io.fixedBufferStream(&stream_storage);
    var pref_stream = withPrefix(fixed_stream.writer(), "stream");
    try pref_stream.print("value = {d}\n", .{42});
    try pref_stream.print("tuple = {any}\n", .{.{ 1, 2, 3 }});

    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var sink = ListSink{ .allocator = allocator };
    defer sink.deinit();

    var pref_array = withPrefix(sink.writer(), "array");
    try pref_array.print("flags = {any}\n", .{.{ true, false }});
    try pref_array.print("label = {s}\n", .{"generic"});

    std.debug.print("Fixed buffer stream captured:\n{s}", .{fixed_stream.getWritten()});
    std.debug.print("ArrayList writer captured:\n{s}", .{sink.list.items});
}
运行
Shell
$ zig run chapters-data/code/17__generic-apis-and-type-erasure/prefixed_writer.zig
输出
Shell
Fixed buffer stream captured:
[stream] value = 42
[stream] tuple = .{ 1, 2, 3 }
ArrayList writer captured:
[array] flags = .{ true, false }
[array] label = generic

std.Io.fixedBufferStreamstd.io.GenericWriter在Zig 0.15.2中都经过了完善,以强调显式的写入器上下文,这就是为什么我们每次都将分配器传递给ListSink.writer()fixed_buffer_stream.zig

的护栏

在仅仅转发调用的助手函数中优先使用anytype;使用显式的comptime T: type参数导出公共API,以便文档和工具保持准确。如果一个包装器接受anytype但深入检查了@TypeInfo,请记录期望,并考虑将谓词移动到可重用的验证器中,就像我们处理分析器那样。这样,未来的重构可以在不重写包装器的情况下升级约束。37

用于结构化契约的助手

anytype包装器需要理解它正在转发的值的形状时,std.meta提供了小巧、可组合的“视图”函数。它们在标准库中广泛用于实现泛型助手,这些助手在编译时适应数组、切片、可选值和联合体。

graph TB subgraph "类型提取器" CHILD["Child(T)"] ELEM["Elem(T)"] SENTINEL["sentinel(T)"] TAG["Tag(T)"] ACTIVETAG["activeTag(union)"] end subgraph "输入类型" ARRAY["数组"] VECTOR["向量"] POINTER["指针"] OPTIONAL["可选值"] UNION["联合体"] ENUM["枚举"] end ARRAY --> CHILD VECTOR --> CHILD POINTER --> CHILD OPTIONAL --> CHILD ARRAY --> ELEM VECTOR --> ELEM POINTER --> ELEM ARRAY --> SENTINEL POINTER --> SENTINEL UNION --> TAG ENUM --> TAG UNION --> ACTIVETAG

关键类型提取函数:

  • Child(T):从数组、向量、指针和可选值中提取子类型(参见meta.zig:83-91)。
  • Elem(T):从内存跨度类型中获取元素类型(参见meta.zig:102-118)。
  • sentinel(T):返回哨兵值(如果存在)(参见meta.zig:134-150)。
  • Tag(T):从枚举和联合体中获取标签类型(参见meta.zig:628-634)。
  • activeTag(u):返回联合值的活动标签(参见meta.zig:651-654)。

内联成本和特化

每个不同的具体写入器都会实例化一个包装器的新副本。利用这一点——附加编译时已知的前缀,嵌入字段偏移量,或门控一个仅对微小对象触发的inline for。如果包装器可能应用于数十种类型,请使用zig build-exe -femit-bin=仔细检查代码大小,以避免二进制文件膨胀。41

使用虚表进行运行时类型擦除

有时你需要在运行时持有一组异构的策略:日志后端、诊断通过或通过配置发现的数据接收器。Zig的解决方案是包含函数指针的显式虚表加上你自行分配的*anyopaque状态。编译器停止强制结构,因此维护对齐、生命周期和错误传播成为你的责任。

类型化状态,擦除的句柄

下面的注册表管理两个文本处理器。每个工厂分配一个强类型状态,将其转换为*anyopaque,并将其与函数指针的虚表一起存储。助手函数statePtrstateConstPtr使用@alignCast恢复原始类型,确保我们从不违反对齐要求。

Zig
const std = @import("std");

const VTable = struct {
    name: []const u8,
    process: *const fn (*anyopaque, []const u8) void,
    finish: *const fn (*anyopaque) anyerror!void,
};

fn statePtr(comptime T: type, ptr: *anyopaque) *T {
    const aligned = @as(*align(@alignOf(T)) anyopaque, @alignCast(ptr));
    return @as(*T, @ptrCast(aligned));
}

fn stateConstPtr(comptime T: type, ptr: *anyopaque) *const T {
    const aligned = @as(*align(@alignOf(T)) anyopaque, @alignCast(ptr));
    return @as(*const T, @ptrCast(aligned));
}

const Processor = struct {
    state: *anyopaque,
    vtable: *const VTable,

    pub fn name(self: *const Processor) []const u8 {
        return self.vtable.name;
    }

    pub fn process(self: *Processor, text: []const u8) void {
        _ = @call(.auto, self.vtable.process, .{ self.state, text });
    }

    pub fn finish(self: *Processor) !void {
        try @call(.auto, self.vtable.finish, .{self.state});
    }
};

const CharTallyState = struct {
    vowels: usize,
    digits: usize,
};

fn charTallyProcess(state_ptr: *anyopaque, text: []const u8) void {
    const state = statePtr(CharTallyState, state_ptr);
    for (text) |byte| {
        if (std.ascii.isAlphabetic(byte)) {
            const lower = std.ascii.toLower(byte);
            switch (lower) {
                'a', 'e', 'i', 'o', 'u' => state.vowels += 1,
                else => {},
            }
        }
        if (std.ascii.isDigit(byte)) {
            state.digits += 1;
        }
    }
}

fn charTallyFinish(state_ptr: *anyopaque) !void {
    const state = stateConstPtr(CharTallyState, state_ptr);
    std.debug.print(
        "[{s}] vowels={d} digits={d}\n",
        .{ char_tally_vtable.name, state.vowels, state.digits },
    );
}

const char_tally_vtable = VTable{
    .name = "char-tally",
    .process = &charTallyProcess,
    .finish = &charTallyFinish,
};

fn makeCharTally(allocator: std.mem.Allocator) !Processor {
    const state = try allocator.create(CharTallyState);
    state.* = .{ .vowels = 0, .digits = 0 };
    return .{ .state = state, .vtable = &char_tally_vtable };
}

const WordStatsState = struct {
    total_chars: usize,
    sentences: usize,
    longest_word: usize,
    current_word: usize,
};

fn wordStatsProcess(state_ptr: *anyopaque, text: []const u8) void {
    const state = statePtr(WordStatsState, state_ptr);
    for (text) |byte| {
        state.total_chars += 1;
        if (byte == '.' or byte == '!' or byte == '?') {
            state.sentences += 1;
        }
        if (std.ascii.isAlphanumeric(byte)) {
            state.current_word += 1;
            if (state.current_word > state.longest_word) {
                state.longest_word = state.current_word;
            }
        } else if (state.current_word != 0) {
            state.current_word = 0;
        }
    }
}

fn wordStatsFinish(state_ptr: *anyopaque) !void {
    const state = statePtr(WordStatsState, state_ptr);
    if (state.current_word > state.longest_word) {
        state.longest_word = state.current_word;
    }
    std.debug.print(
        "[{s}] chars={d} sentences={d} longest-word={d}\n",
        .{ word_stats_vtable.name, state.total_chars, state.sentences, state.longest_word },
    );
}

const word_stats_vtable = VTable{
    .name = "word-stats",
    .process = &wordStatsProcess,
    .finish = &wordStatsFinish,
};

fn makeWordStats(allocator: std.mem.Allocator) !Processor {
    const state = try allocator.create(WordStatsState);
    state.* = .{ .total_chars = 0, .sentences = 0, .longest_word = 0, .current_word = 0 };
    return .{ .state = state, .vtable = &word_stats_vtable };
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var arena = std.heap.ArenaAllocator.init(gpa.allocator());
    defer arena.deinit();
    const allocator = arena.allocator();

    var processors = [_]Processor{
        try makeCharTally(allocator),
        try makeWordStats(allocator),
    };

    const samples = [_][]const u8{
        "Generic APIs feel like contracts.",
        "Type erasure lets us pass handles without templating everything.",
    };

    for (samples) |line| {
        for (&processors) |*processor| {
            processor.process(line);
        }
    }

    for (&processors) |*processor| {
        try processor.finish();
    }
}
运行
Shell
$ zig run chapters-data/code/17__generic-apis-and-type-erasure/type_erasure_registry.zig
输出
Shell
[char-tally] vowels=30 digits=0
[word-stats] chars=97 sentences=2 longest-word=10

跟踪生命周期——arena分配器比处理器寿命长,因此擦除的指针保持有效。切换到作用域分配器将需要在虚表中有一个匹配的destroy钩子,以避免悬空指针。10, Allocator.zig

标准分配器作为虚表示例

标准库的std.mem.Allocator本身就是一个类型擦除接口:每个分配器实现都提供一个具体的状态指针和一个函数指针的虚表。这与上面的注册表模式类似,但以整个生态系统都依赖的形式存在。

graph TB ALLOC["Allocator"] PTR["ptr: *anyopaque"] VTABLE["vtable: *VTable"] ALLOC --> PTR ALLOC --> VTABLE subgraph "虚表函数" ALLOCFN["alloc(*anyopaque, len, alignment, ret_addr)"] RESIZEFN["resize(*anyopaque, memory, alignment, new_len, ret_addr)"] REMAPFN["remap(*anyopaque, memory, alignment, new_len, ret_addr)"] FREEFN["free(*anyopaque, memory, alignment, ret_addr)"] end VTABLE --> ALLOCFN VTABLE --> RESIZEFN VTABLE --> REMAPFN VTABLE --> FREEFN subgraph "高级API" CREATE["create(T)"] DESTROY["destroy(ptr)"] ALLOCAPI["alloc(T, n)"] FREE["free(slice)"] REALLOC["realloc(slice, new_len)"] end ALLOC --> CREATE ALLOC --> DESTROY ALLOC --> ALLOCAPI ALLOC --> FREE ALLOC --> REALLOC

Allocator类型在Allocator.zig:7-20中定义为一个类型擦除接口,带有一个指针和虚表。虚表包含四个基本操作:

  • alloc:返回一个指向len字节的指针,具有指定的对齐方式,失败时返回null(参见Allocator.zig:29)。
  • resize:尝试就地扩展或收缩内存(参见Allocator.zig:48)。
  • remap:尝试扩展或收缩内存,允许重新定位(参见Allocator.zig:69)。
  • free:释放并使内存区域失效(参见Allocator.zig:81)。

的安全注意事项

anyopaque的声明对齐方式为1,因此每次向下转换都必须使用@alignCast断言真实的对齐方式。跳过该断言是违法行为,即使该指针在运行时恰好正确对齐。当所有权跨越多个模块时,考虑将分配器和清理函数存储在虚表内部。

何时升级到模块或包

手动虚表对于少量封闭的行为集很有用。一旦接口表面积增加,就迁移到模块级注册表,该注册表公开返回类型化句柄的构造函数。消费者仍然接收擦除的指针,但模块可以强制执行不变量并共享对齐、清理和恐慌诊断的辅助代码。19

注意与警告

  • 偏爱小巧、揭示意图的验证器助手——冗长的validateX函数很容易被提取到可重用的编译时实用程序中。15
  • anytype包装器为每个具体类型生成一个实例。在广泛使用的库中公开它们时,请分析二进制文件大小。41
  • 类型擦除将正确性责任推给程序员。在开发构建中添加断言、日志记录或调试切换,以证明向下转换和生命周期保持有效。39

练习

  • 扩展validateAnalyzer以要求一个可选的summarizeError函数,并在测试中演示自定义错误集。13
  • PrefixedWriter添加一个flush功能,在编译时检测内部写入器是否公开了该方法并进行相应调整。meta.zig
  • 引入第三个处理器,将哈希流式传输到std.crypto.hash.sha2.Sha256上下文中,然后在完成后以十六进制打印摘要。52, sha2.zig

替代方案和边缘情况

  • 如果编译时验证依赖于来自其他包的用户提供的类型,请添加冒烟测试,以便在集成构建之前发现回归。22
  • 当只有少数几种策略存在时,优先使用带有有效载荷变体的union(enum);一旦从“少数”到“多数”,虚表就派上用场了。08
  • 对于从共享对象加载的插件系统,将擦除的状态与显式ABI安全的中继(trampolines)配对,以保持可移植性。33

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.