はじめに
zig
はOOP
言語ではないため継承のような機構はなく、異なる2つの型を同一視する(is-a
構造にする)ことはできません。
もしどうしても同一視したいシチュエーションに遭遇した場合は、動的束縛によるtrait object
1 (Rust
言語で見かけるやつ)を自前で実装する必要があります。
例えば以下のように
pub const TraitObject = struct {
const Self = @This();
ptr: *anyopaque, // 同一視したい構造へのポインタ
vtbl: struct {
foo: *const fn (ctx: *anyopaque, p1: i32, p2: p32) bool,
// 複数のメソッドをオーバーライドさせたい場合はその分だけ記述
....
},
pub fn foo(self: Self, p1: i32, p2: i32) bool {
return self.vtbl.foo(self.ptr, p1, p2);
}
};
というのを用意しておいた上で、
同一視したい型は、例えば以下のように記述します。
const SomeObject = struct {
const Self = @This();
pub fn (allocator: std.mem.Allocator) *Self {
const self = try allocator.create(Self);
self.* = .{
// ...
};
return self;
}
pub fn deinit(self: *Self, allocator: std.mem.Allocator) void {
allocator.destroy(self);
}
pub fn dispatchFoo(ptr: *anyopaque, p1: i32, p2:i32) bool {
const self: *Self = @ptrCast(@alignCast(ptr);
return self.foo(p1, p2);
}
pub fn foo(self: Self, p1: i32, p2:i32) bool {
// ...
}
}
// 使う側
pub fn main() !void {
const allocator = std.mem.page_allocator;
const o1 = try SomeObject.init(allocator);
defer o1.deinit(allocator);
const to1 = TraitObject{ .ptr = o1, .vtbl = .{ .foo = SomeObject.dispatchFoo } };
const v1 = 10;
const v2 = 20;
std.debug.print("{}\n", .{to1.foo(v1, v2)});
}
上記例を見てもわかるように、結構めんどいです。
foo
関数を呼びたいだけなのに、anyopaque
から元の方に戻すキャストを挟むための関数が挟まっているあたりが特に 2。
異なる型を同一視したい理由は、条件により振る舞いを分岐したいからと思われます。
もし利用する型のライフサイクルがローカルスコープで完結する場合、trait object
を用意しなくても、コンパイル時実行の機構によるダックタイピングを用いることでもう少し楽ができます。
ダックタイピング
まずは記述例から。
// 呼び出し元で以下のような関数を提供する
fn invokeAction(comptime SomeType: type, allocator: std.mem.Allocator, p1: i32, p2: i32) !void {
const obj = try SomeType.init(allocator);
defer obj.deinit(allocator);
std.debug.print("{}\n", .{obj.foo(p1, p2)});
}
// 使う側
pub fn main() void {
const allocator = std.mem.page_allocator;
const v1 = 10;
const v2 = 20;
try invokeAction(SomeObject, allocator, v1, v2);
}
invokeAction
ではcomptime
引数として、同一視したい型を受け取り、
その型のinit
、deinit
、およびfoo
を呼び出している。
このように記述すると、zig
コンパイラは、暗黙に渡された型のメンバ制約をチェックし、そのメンバの存在を要請します 3(なければコンパイルエラー)。
具体的には、以下のチェック。
-
SomeType
はAllocator
を受け取りSomeType
のポインタとError union
を返すinit
関数が提供されていること -
SomeType
はSomeType
型のポインタとAllocator
を受け取りvoid
を返すdeinit
関数が提供されていること -
SomeType
はSomeType
型のポインタと2つのi32
の値を受け取りbool
を返すfoo
関数が提供されていること
このように同一視したい型のライフサイクルがローカルスコープに閉じている場合、より簡易な記述を行うことができる。
上記例では、メンバ関数に対する制約を記述したが、同一のメンバフィールドを持つのであれば、フィールドもダックタイピングできる。
trait objectはいらない子?
あくまでダックタイピングによる代用は同一視したい型がローカルスコープに閉じている場合にのみ行えることであり、フィールドやコレクションに保持するような、よりライフサイクルが長くなる場合や別の関数に渡す場合は、依然trait object
が必要となります。
まとめ
comptime
つおい