Zig 标准库中的实现接口的惯用法与模式
原文链接: https://zig.news/yglcode/code-study-interface-idiomspatterns-in-zig-standard-libraries-4lkj
引言
在 Java 和 Go 中,可以使用“接口”(一组方法或方法集)定义基于行为的抽象。通常接口包含所谓的虚表(vtable
)
以实现动态分派。Zig 允许在结构体、枚举、联合和不透明类型中声明函数和方法,尽管 Zig 尚未支持接口作为一种语言特性。
Zig 标准库应用了一些代码习语或模式以达到类似效果。
类似于其他语言中的接口,Zig 的代码习语和模式实现了:
- 在编译时对实例/对象方法与接口类型进行类型检查,
- 在运行时进行动态分派。
这里有一些显著的不同:
- 在 Go 中,接口的定义与实现是独立的。可以在任何位置给一个类型实现新接口,只需保证其方法签名与新接口一致即可。无需像在 Java 中那样,需要回过头去修改类型定义,来实现新的接口。
- Go 的接口只包含用于动态分派的
vtab
,并且推荐 vtable 中方法即可能少 ,例如io.Reader
和io.Writer
只有一个方法。 常见的工具函数如io.Copy
、CopyN
、ReadFull
、ReadAtLeast
等,作为包函数提供,内部使用这些小接口。 与之相比,Zig 的接口,如std.mem.Allocator
,同时包含vtable
和一些工具方法,因此方法会多一些。
以下是 Zig 的代码习语/模式在动态分派方面的学习笔记,代码摘自 Zig 标准库并以简单示例重录。为了专注于 vtab/动态分派,工具方法被移除, 代码稍作修改以适应 Go 中不依赖具体类型的“小”接口模式。
完整代码位于此仓库,你可以使用 zig test interfaces.zig
运行它。
背景设定
让我们使用经典的面向对象编程示例,创建一些形状:点(Point
)、盒子(Box
)和圆(Circle
)。
|
|
接口1:枚举标签联合
Loris Cro 在“使用 Zig 0.10.0 轻松实现接口” 中介绍了使用枚举标签联合作为接口的方法。这是最简单的解决方案,尽管你必须在联合类型中显式列出所有“实现”该接口的变体类型。
|
|
我们可以如下测试:
|
|
接口2:vtable 和动态分派的第一种实现
Zig 已从最初基于嵌入式 vtab
和 #fieldParentPtr()
的动态分派切换到基于“胖指针”接口的以下模式;
请查阅此文章了解更多细节“Allocgate 将在 Zig 0.9 中到来…”。
接口 std.mem.Allocator
使用了这种模式,所有标准分配器,如 std.heap.[ArenaAllocator, GeneralPurposeAllocator, ...]
都有一个方法 allocator() Allocator
来暴露这个接口。
以下代码稍作改动,将接口从实现中分离出来。
|
|
我们可以如下测试:
|
|
接口3:vtable 和动态分派的第二种实现
在上述第一种实现中,通过 Shape2.init()
将 Box
“转换”为接口 Shape2
时,会对 box
实例进行类型检查,
以确保其实现了 Shape2
的方法(包括名称的匹配签名)。第二种实现中有两个变化:
vtable
内联在接口结构中(可能的缺点是,接口大小增加)。- 需要根据接口进行类型检查的方法被显式地作为函数指针传入,这可能允许传入不同的方法,只要它们具有相同的参数/返回类型。
例如,如果
Box
有额外的方法,stopAt(i32,i32)
或甚至scale(i32,i32)
,我们可以将它们替换为move()
。 接口std.rand.Random
和所有std.rand.[Pcg, Sfc64, ...]
使用这种模式。
|
|
我们可以如下测试:
|
|
接口4:使用嵌入式 vtab 和 @fieldParentPtr() 的原始动态分派
接口 std.build.Step
和所有构建步骤 std.build.[RunStep, FmtStep, ...]
仍然使用这种模式。
|
|
我们可以如下测试:
|
|
接口5:编译时的泛型接口
所有上述接口都侧重于 vtab
和动态分派:接口值将隐藏其持有的具体值的类型。因此,你可以将这些接口值放入数组中并统一处理。
通过 Zig 的编译时计算,你可以定义泛型算法,它可以与提供代码函数体所需的方法或操作符的任何类型一起工作。例如, 我们可以定义一个泛型算法:
|
|
如上所示,“shape”可以是任何类型,只要它提供 move()
和 draw()
方法。所有类型检查都发生在编译时,并且没有动态分派。
接下来,我们可以定义一个泛型接口,捕获泛型算法所需的方法;我们可以用它来适应具有不同方法名称的某些类型/实例到所需的 API。
接口 std.io.[Reader, Writer]
以及 std.fifo
和 std.fs.File
使用这种模式。
由于这些泛型接口没有擦除其持有的值的类型信息,它们是不同的类型。因此,你不能将它们放入数组中以统一处理。
|
|
我们可以如下测试:
|
|