在 Zig 中實現介面

Violeshnv發表於2024-05-19

在 Zig 中實現介面

實現介面的關鍵是遵循特定的函式呼叫約定,包含介面通常由函式原型和定義組成,表示了一組要被實現的方法或行為。實現介面的關鍵在於確保函式的引數型別和返回值等方面與介面定義一致。

  1. 抽象:可以透過使用函式指標、結構體和指標、泛型等技術來實現。抽象的目的是將具體的實現細節與介面定義分離開來,以便於在不同的實現之間切換或擴充套件功能。
  2. 約束:可以透過定義一組介面函式或方法以及相應的引數和返回型別來實現。介面的實現需要滿足一定的約束條件,以保證介面的一致性和可互換性。

現在有針對一個函式 foo() 有兩個實現 Impl1Impl2,討論它們的介面實現。(對於需要可變型別的介面情況,通常將 this 的型別改為 this: *This 就可以了)。

const std = @import("std");
const testing = std.testing;

const Impl1 = struct {
    a: []const u8,
    const This = @This();
    fn foo(this: This, value: u8) u32 {
        return value + (std.fmt.parseInt(u32, this.a, 10) catch unreachable);
    }
};

const Impl2 = struct {
    b: u64,
    const This = @This();
    fn foo(this: This, value: u8) u32 {
        _ = value;
        return @intCast(this.b);
    }
};

使用泛型實現

鴨子型別

在 Zig 中,我們可以使用泛型函式和 anytype 型別來實現鴨子型別的介面。

fn foo(impl: anytype, value: u8) u32 {
    return impl.foo(value);
}

test "duck" {
    const impl1 = Impl1{ .a = "123" };
    const impl2 = Impl2{ .b = 456 };
    try testing.expectEqual(128, foo(impl1, 5));
    // 例項引用還是例項指標不影響,因為 Zig 會隱式解引用指標
    try testing.expectEqual(456, foo(&impl2, 5));
}

優點:

  • 簡單易懂

缺點:

  • anytype 範圍太大,對程式碼提示不友好
  • impl 的限制太少,對呼叫者不友好
  • 是函式級別的介面,不是類級別的介面

以下兩種方法是對鴨子型別的改進。

轉發

在轉發方法中,我們可以建立一個包含例項指標的介面結構體,並將函式呼叫轉發到具體例項。

fn Interface(comptime Impl: type) type {
    return struct {
        impl: Impl,
        const This = @This();
        fn foo(this: This, value: u8) u32 {
            // 為例項引用和例項指標提供統一轉發
            return switch (@typeInfo(Impl)) {
                // Impl 為指標型別
                .Pointer => |p| p.child.foo(this.impl.*, value),
                inline else => Impl.foo(this.impl, value),
            };
        }
    };
}

fn interface(impl: anytype) Interface(@TypeOf(impl)) {
    return Interface(@TypeOf(impl)){ .impl = impl };
}

test "fn (type) type" {
    // 介面包含例項
    const interface1 = interface(Impl1{ .a = "123" });
    // 介面包含例項指標
    const impl2 = Impl2{ .b = 456 };
    const interface2 = interface(&impl2);
    try testing.expectEqual(128, interface1.foo(5));
    try testing.expectEqual(456, interface2.foo(5));
}

優點:

  • 透過反射判斷型別,可以包含 Impl 例項,也可以包含 *Impl 指標
  • 提供更好的程式碼提示

缺點:

  • 對每個 Impl 都對應一個 Interface,即 Interface(Impl1)Interface(Impl2) 是不同型別。這是基於泛型的介面共有的缺點。

例項 std.io.SeekableStream

pub fn SeekableStream(
    comptime Context: type,
    comptime SeekErrorType: type,
    comptime GetSeekPosErrorType: type,
    comptime seekToFn: fn (context: Context, pos: u64) SeekErrorType!void,
    comptime seekByFn: fn (context: Context, pos: i64) SeekErrorType!void,
    comptime getPosFn: fn (context: Context) GetSeekPosErrorType!u64,
    comptime getEndPosFn: fn (context: Context) GetSeekPosErrorType!u64,
) type {...}

此處將需要轉發的函式直接寫在引數中,比較繁瑣,但相對的可以使用不在類內定義的函式。

Trait

Trait 是一種在編譯時透過反射將介面型別的函式等效於實際型別的函式的方法。其中 Trait 是隻包含函式型別作為成員的結構體。

fn Trait(comptime Impl: type) type {
    return struct {
        fn foo(_: Impl, value: u8) u32 {
            return value;
        }
        foo: @TypeOf(foo),
        // 沒有預設實現時,直接給出型別
        // foo: fn (impl: Impl, value: u8) u32,
    };
}

fn Interface(comptime trait: fn (type) type, comptime Impl: type) t: {
    const T = trait(Impl);
    var info = @typeInfo(T).Struct;

    // [0..].* 用於快速創造陣列
    // "hello"[0..].* 等價於 [_:0]u8{ 'h', 'e', 'l', 'l', 'o' };
    var fields = info.fields[0..].*;
    for (&fields) |*f| {
        // 在 Impl, T 中搜尋函式
        if (@hasDecl(Impl, f.name)) {
            f.default_value = @field(Impl, f.name);
        } else if (@hasDecl(T, f.name)) {
            f.default_value = @field(T, f.name);
        } else if (f.default_value == null) {
            @compileError("trait match failed");
        }
    }

    info.fields = &fields;
    break :t @Type(std.builtin.Type{ .Struct = info });
} {
    return .{};
}

// 沒有定義 foo()
const Impl3 = struct {};

test "trait" {
    const impl1 = Impl1{ .a = "123" };
    const impl2 = Impl2{ .b = 456 };
    const Interface1 = Interface(Trait, Impl1);
    const Interface2 = Interface(Trait, Impl2);
    const Interface3 = Interface(Trait, Impl3);
    try testing.expectEqual(128, Interface1.foo(impl1, 5));
    try testing.expectEqual(456, Interface2.foo(impl2, 5));
    try testing.expectEqual(5, Interface3.foo(undefined, 5));
}

優點:

  • 不包含例項指標
  • 介面建立時會檢查 Trait 的所有函式是否被滿足
  • 可以提供預設實現

缺點:

  • 使用 @Type 建立介面型別,影響程式碼提示
  • 呼叫函式時要將例項也作為引數(因為沒有例項指標)

以上方法之間的界限並不分明,比如 Trait 可以認為是編譯期的轉發;轉發也可以刪去例項指標 impl,然後在呼叫時加入例項引數,或者修改轉發函式提供預設實現。

使用指標實現

使用指標實現介面,介面的型別是固定的,可以嵌入到其他結構體中。而將泛型的介面作為成員可能需要再加上一層泛型,將泛型的影響擴大。

const PointerBased = struct { interface: PointerBasedInterface };

fn GenericBased(comptime GenericBasedInterface: type) type {
    return struct { interface: GenericBasedInterface };
}

union(enum)

const Interface = union(enum) {
    impl1: *Impl1,
    impl2: *Impl2,
    const This = @This();
    fn foo(this: This, value: u8) u32 {
        return switch (this) {
            // 對於每個例項 t 是不同型別的,為了捕獲它,需要 inline else
            // 並且新增更多 Impl 時不需要修改函式
            inline else => |t| t.foo(value),
            // 等價於
            // .impl1 => |t1| t1.foo(value),
            // .impl2 => |t2| t2.foo(value),
            // // ...
        };
    }
};

test "union(enum)" {
    var impl1 = Impl1{ .a = "123" };
    var impl2 = Impl2{ .b = 456 };
    const interface1 = Interface{ .impl1 = &impl1 };
    const interface2 = Interface{ .impl2 = &impl2 };
    try testing.expectEqual(128, interface1.foo(5));
    try testing.expectEqual(456, interface2.foo(5));
}

優點:

  • 簡單
  • 這是比較符合 Zig 的寫法,沒有特定需求的情況下應該優先選擇這種方法

缺點:

  • 必須知道所有可能的實現才能構建 union(enum)

虛擬函式表

自擬虛擬函式表和例項指標。

const Impl1 = struct {
    a: []const u8,
    const This = @This();
    fn foo(ctx: *anyopaque, value: u8) u32 {
        const this: *This = @alignCast(@ptrCast(ctx));
        return value + (std.fmt.parseInt(u32, this.a, 10) catch unreachable);
    }
};

const Impl2 = struct {
    b: u64,
    const This = @This();
    fn foo(ctx: *anyopaque, value: u8) u32 {
        const this: *This = @ptrCast(@alignCast(ctx));
        _ = value;
        return @intCast(this.b);
    }
};

const Interface = struct {
    ptr: *anyopaque,
    vtable: *const struct {
        foo: *const fn (ctx: *anyopaque, value: u8) u32,
    },

    const This = @This();
    fn foo(this: This, value: u8) u32 {
        return this.vtable.foo(this.ptr, value);
    }
};

fn interface(comptime T: type, ctx: *T) Interface {
    return .{
        .ptr = ctx,
        .vtable = &.{
            .foo = T.foo,
        },
    };
}

test "VTABLE" {
    var impl1 = Impl1{ .a = "123" };
    var impl2 = Impl2{ .b = 456 };
    const interface1 = interface(Impl1, &impl1);
    const interface2 = interface(Impl2, &impl2);
    try testing.expectEqual(128, interface1.foo(5));
    try testing.expectEqual(456, interface2.foo(5));
}

優點:

  • 自擬虛擬函式表,也就是實現了虛擬函式,最靈活

缺點:

  • 每個 Impl 需要將介面的實現的第一個引數修改為任意型別 *anyopaque(其他方法對實現沒有要求)。

例項 std.mem.Allocatorstd.heap.GeneralPurposeAllocator

ptr: *anyopaque,
vtable: *const VTable,

pub const VTable = struct {
    alloc: *const fn (ctx: *anyopaque,
                      len: usize, ptr_align: u8, ret_addr: usize) ?[*]u8,
    resize: *const fn (ctx: *anyopaque,
                       buf: []u8, buf_align: u8, new_len: usize, ret_addr: usize) bool,
    free: *const fn (ctx: *anyopaque,
                     buf: []u8, buf_align: u8, ret_addr: usize) void,
};
pub fn allocator(self: *Self) Allocator {
    return .{
        .ptr = self,
        .vtable = &.{
            .alloc = alloc,
            .resize = resize,
            .free = free,
        },
    };
}

相關文章