














“派生类(子类)对象可以在程序中代替其基类(超类)对象。”
里氏替换原则
与 C# 这种面向对象的编程语言不同,Zig 提供了多种方式让我们可以在程序中应用里氏替换:
基于我们的使用场景,我们需要像选择 Allocator 一样选择合适的替换方式。
vtable 模式是 Zig 中最接近其他 OOP 语言的实现接口的方式,Zig 中的 Allocator、Writer 等内置接口的实现都采用了 vtable 模式。这个模式由以下部分组成:
*anyopaque,表示指向任意类型的指针以 std.mem.Allocator 接口为例,其定义如下:
/// The type erased pointer to the allocator implementation.
///
/// Any comparison of this field may result in illegal behavior, since it may
/// be set to `undefined` in cases where the allocator implementation does not
/// have any associated state.
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
alloc: *const fn (*anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8,
resize: *const fn (*anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool,
remap: *const fn (*anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8,
free: *const fn (*anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void,
};要实现新的 Allocator 接口,只要实现 VTable 中定义的 4 个函数即可。如果我们的新实现需要访问内部状态,则可以将内部状态数据结构作为 *anyopaque 保存到 ptr 字段。这样在 VTable 函数中就可以通过下面的方式来访问:
fn alloc(context: *anyopaque, len: usize, alignment: std.mem.Alignment, return_address: usize) ?[*]u8 {
const self: *MyAllocator = @ptrCast(@alignCast(context));
// bla bla bla
}当我们需要构造 MyAllocator 的实例并将其作为 Allocator 接口传递给消费者时,为 MyAllocator 添加一个 allocator方法:
// In MyAllocator struct
pub fn allocator(self: *MyAllocator) std.mem.Allocator {
return .{
.ptr = self,
.vtable = &.{
.alloc = alloc,
.resize = resize,
.remap = remap,
.free = free,
},
};
}
// when using MyAllocator
var my_allocator = MyAllocator.init();
doSomeStuff(my_allocator.allocator());从代码上看,这要比很多编程语言的 class MyAllocator : IAllocator 要繁琐太多了,但这也给我们解锁了一些新的灵活性,比如我们可以为接口本身添加成员方法,Allocator 接口就是通过这种方式对外提供了 VTable 之外的丰富的函数。 这样的接口几乎就是一个没有成员字段的抽象类了
除了接口方法之外,我们还可以在接口 struct 上定义一些所有实现都会用到的数据字段,这样该接口的实现就不用重复声明这些数据结构了。std.Io.Writer 就是这么一个典型的例子,因为现实的代码中几乎只用有缓冲的 Io ,所以 Writer 接口内部就提供了用于存储缓冲数据的字段,具体的 Writer 实现就不需要各自实现缓冲特性了。
总的来说,VTable 模式是非常符合 OOP 直觉的类型替换方式,但它的语法确实有些过于复杂以至于看起来不那么优雅了。如果我们只是需要在一个函数中根据场景的调用不同的实现,那么可以试试用 anytype 来替换。
想象这么一个场景,我们需要测试一个业务函数在半夜的行为,那么在编写测试的时候我们当然不能等到半夜在去执行,通常的做法是提供一个 TimeProvider 抽象,让具体的业务函数依赖这个 TimeProvider 类型获取当前时间:
pub fn doSomething(time_provider: TimeProvider, bla, bla, bla) void {
// do some time aware stuff
if (isNight(time_provider.now())) {
// do some thing at night.
}
}借助 anytype, 我们可以省掉 TimeProvider定义:
pub const RealTimeProvider = struct {
pub fn now(self: @This()) Timestamp {
return self.realtime();
}
// other fields and methods omitted.
}
pub const MockTimeProvider = struct {
timestamp: Timestamp,
pub fn now(self: @This()) Timestampe {
return self.timestamp;
}
}
pub fn doSomething(time_provider: anytype, bla, bla, bla) void {
// do some time aware stuff
if (isNight(time_provider.now())) {
// do some thing at night.
}
}
test {
var mock_time = MockTimeProvider{ .timestamp = 42 };
doSomething(mock_time);
}
pub fn doOtherThing() {
var time_provider = RealTimeProvider.init();
doSomething(time_provider);
}anytype 是一个 comptime 特性,这意味着调用 doSomething 时的参数类型必须在编译期确定,而不是像 VTable 一样可以在运行时切换。在编译时,Zig 编译器会检查传给 doSomething 的值是否提供了一个 now 函数满足 doSomething 对其函数签名的要求。
另外,anytype 只能用作函数的参数类型,而不能用于 struct 字段类型:
const MyStruct = {
time_provider: anytype, // won't compile
}作为 anytype 的替代,我们可以使用 comptime type 参数让我们可以在 struct 中存储编译期确定的任意类型:
pub fn MyStruct (comptime T: type) type {
return struct {
time_provider: T,
pub fn printNow(self: @This()) void {
std.debug.print("{a}", self.time_provider.now());
}
};
}
const MyStructWithRealTime = MyStruct(RealTimeProvider);
const MyStructWithMockTime = MyStruct(MockTimeProvider);使用 comptime type 会把我们的类型变成一个高阶类型,只有在传入特定的实现 T 之后才能用来创建实例。
跟其他 comptime 特性类似,Zig 只有在编译过程中才能确定 T 的类型信息,这也就意味着目前的 LSP 工具没法提供关于 T 的任何信息。当我们敲下 self.time_provider. 的时候,IDE 无法给出任何补全提示。
除此之外,使用高阶类型还会带来另一个限制:引用了 MyStruct 的类型要么其本身是高阶类型要么必须决定 T 的实现。
fn A (comptime T: type) type {
return struct {
my_struct: MyStruct(T),
};
}
// or
const A = struct {
my_struct: MyStruct(RealTimeProvider),
}对于数据容器来说,这个特性不算什么特别明显的限制。但是在使用依赖注入模式的业务代码中, 高阶类型的参数 T 可替换性就像传染病一样——所有要保持 T 可被替代的类型其本身也必须是高阶类型。
如果我们可以在某一处确定 T 的全部实现,那么我们就可以借助 union(enum) 类型来终止高阶类型的传染:
pub const TimeProvider = union(enum) {
real: RealTimeProvider,
mock: MockTimeProvider,
pub fn now(self: @This()) Timestamp {
return switch (self) {
inline else => |impl| impl.now()
};
}
}这里我们就像使用 vtable 一样构造了引用具体实现的 Wrapper (TimeProvider),跟 vtable 不同的是我们会用 union(enum) 列举全部的可用实现,并且虚函数表也变成 union 类型的一个个成员方法。
使用这个模式的好处显而易见,TimeProvider 不再是高阶类型,他可以被简单地使用。我们还避免了 *anyopaque 、 anytype 、 comptime T: type 这些让 LSP 难以分析特性,开发体验也好上不少。但这样做的代价也很明显,如果我们要添加新的 TimeProvider 实现,我们就必须要修改 TimeProvider 本身。
union(eunm)模式。anytype 就足够了。此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。