在 Zig 实现接口
早在 2020 年 6 月,Nathan Michaels 就发表了一篇关于如何在 zig 中进行运行时多态(接口)的文章。然而,从那时起,社区已经从 @fieldParentPtr
转向使用胖指针。这是标准库现在使用的惯例,例如在 allocator 和 rand 中。这篇文章将介绍新惯例以及如何使用它。就像在 Nathan 的原始帖子中一样,我将创建一个正式的迭代器接口,它可以像这样使用:
while (iterator.next()) |val| {
// do something with val
}
阅读前的一些注意事项
在阅读本文的其余部分之前,我强烈建议您自己浏览标准库源代码。看看你能不能理解它。如果是这样,太好了,您无需阅读其余部分。一些开始的地方是 allocator 和 rand. 另一个资源是这个。这涵盖了另一个用例(用于计算表达式的节点),并以类似的方式执行操作。
迭代器接口
最简单的迭代器只需要一个函数。它应该返回迭代器中的下一个值,如果迭代器已完成那么返回 null
。(可选选项很棒的原因之一。例如,将其与没有可选选项的 java 语言进行比较, 相对应的迭代器接口需要 3 个方法才能实现。)
接口还需要某种方式来访问实现迭代器的结构数据,因此我们也存储了它们的指针。我们使用 *anyopaque
,因为我们不知道实现迭代器的大小和对齐方式。我们也可以使用 as usize
并每次在指针和整数之间进行转换。标准库使用 *anyopaque
,所以我将在这里这样使用。
const Iterator = struct {
const Self = @This();
ptr: *anyopaque
nextFn: fn(*anyopaque) ?u32,
};
这将是我们接口的基础。现在,要实现 Iterator
,您需要大量有关其内部的知识。因此,让我们创建一个初始化方法。
pub fn init(ptr: anytype) Self {
const Ptr = @TypeOf(ptr);
const ptr_info = @typeInfo(Ptr);
if (ptr_info != .Pointer) @compileError("ptr must be a pointer");
if (ptr_info.Pointer.size != .One) @compileError("ptr must be a single item pointer");
const alignment = ptr_info.Pointer.alignment;
const gen = struct {
pub fn nextImpl(pointer: *anyopaque) ?u32 {
const self = @ptrCast(Ptr, @alignCast(alignment, pointer));
return @call(.{.modifier=.always_inline}, ptr_info.Pointer.child.next, .{self});
}
};
return .{
.ptr = ptr,
.nextFn = gen.nextImpl,
};
}
这个新功能中有很多内容,所以让我们分解一下。
首先,我们检查是否 ptr
具有正确的类型。它应该是单个项目指针。如果不是,我们给出一个编译错误,所以实现者能知道为什么错误。之后我们得到对齐方式。这是必需的,因为我们使用 anyopaque
,它可以有任意的对齐方式。由于 zig 还没有匿名函数,我们创建 gen
以获取它的函数。这也将帮助我们以后更轻松地创建 vtable。
在 nextImpl
实现中,我们做两件事。首先,我们将指针投射到正确的类型和对齐方式。其次,我们调用底层函数。这就是我采用与大多数标准库不同的方法的地方。在标准库中,约定是将所有需要的方法传递给 init 函数。据我所知,这有两个主要好处:
- 它允许数据和功能分离。就个人而言,我想不出为什么要这样做的例子,但选项就在那里。
- 它允许实现器中的方法私有,因此用户必须通过接口调用该方法。
第一个在我的经验中非常罕见。第二个更有用,但我更喜欢我的方式,因为它对实现者的要求更少。该方式需要 prt_info.Pointer.child.next
部分,该部分从实现器获取函数,并在 next
函数不存在的情况下给出用户友好的编译器错误。其他所有内容与标准库示例中完全相同。出于性能原因,我们内联函数,因为它只是中继到另一个函数调用。
这是最大的部分。之后,我们只需使用指向数据和函数的指针创建结构。
我们仍然需要一种方法来调用下一个函数,因此我们添加的最后一个函数来完成接口是:
pub inline fn next(self: Self) ?u32 {
return self.nextFn(self.ptr);
}
同样,我们内联函数以提高性能。我们在存储的指针上调用该函数。该接口现在可供使用。
实现迭代器
就其本身而言,该接口毫无用处,因此让我们创建一个范围迭代器,该迭代器通过可选步骤从起始值迭代到结束值。它所需要的只是 3 个字段:(如果您希望能够重置它或其他一些功能,您可以添加一些其他字段。这是最低限度的。
const Range = struct {
const Self = @This();
start: u32 = 0,
end: u32,
step: u32 = 1,
};
当然,它还需要一个实现 next
:
pub fn next(self: *Self) ?u32 {
if (self.start >= self.end) return null;
const result = self.start;
self.start += self.step;
return result;
}
就这样。如果我们想创建一个迭代器,只需 Iterator.init(&range)
,其中 range 是 Range 的一个实例。为了让人们的生活更轻松,让我们再次遵循标准库约定,并创建一个函数来初始化 Range 本身内的迭代器:
pub fn iterator(self: *Self) Iterator {
return Iterator.init(self);
}
现在用户只需创建一个 range.iterator()
迭代器即可。看起来很像 arena.allocator()
不是吗?这是完全相同的模式。
要成为优秀的程序员,让我们在总结之前创建一个测试用例:
const std = @import("std");
test "Range" {
var range = Range{ .end=5 };
const iter = range.iterator();
try std.testing.expectEqual(@as(?u32, 0), iter.next());
try std.testing.expectEqual(@as(?u32, 1), iter.next());
try std.testing.expectEqual(@as(?u32, 2), iter.next());
try std.testing.expectEqual(@as(?u32, 3), iter.next());
try std.testing.expectEqual(@as(?u32, 4), iter.next());
try std.testing.expectEqual(@as(?u32, null), iter.next());
try std.testing.expectEqual(@as(?u32, null), iter.next());
}
现在测试应该通过并让您了解如何使用接口。
缺点
在某些情况下,此模式非常有用。但是,在结束之前,我想指出一些缺点。
首先是性能。这种模式可能会变得非常慢。它必须使用遵循大量指针和函数指针来找到答案。函数指针始终比直接函数调用慢。如果可以,请查看是否可以使用标记联合之类的东西来实现类似的东西。
其次,一个更微妙的问题是,原始实现器必须至少与其创建的接口生命周期一样长。这是因为接口存储指针,因此如果实现器不再处于活动状态,则指针无效。这意味着您无法从函数返回在函数中创建的接口:
fn thisWillCauseUndefinedBehaviour() Iterator {
var range = Range{.end=10};
return range.iterator();
}
当然,这个虚拟示例几乎永远不会出现在实际代码中,但类似的事情可能会发生。您可以通过传递分配器并存储在 range
堆上来解决此问题。但是,请确保之后释放它。
结论
我希望您对如何实现接口以及它们如何在标准库中工作有更好的理解。所有代码都可以在我的github上找到。(post.zig包含这篇文章中的代码.main.zig包含更多,如泛型和更多的实现器,如map和filter)。
这是我第一次写作,所以任何关于技术和写作方面的反馈都是值得赞赏的。英语也不是我的母语,所以请随时在任何地方纠正我。