2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Zig] ダックタイピングのスゝメ

Posted at

はじめに

zigOOP言語ではないため継承のような機構はなく、異なる2つの型を同一視する(is-a構造にする)ことはできません。

もしどうしても同一視したいシチュエーションに遭遇した場合は、動的束縛によるtrait object1 (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引数として、同一視したい型を受け取り、
その型のinitdeinit、およびfooを呼び出している。

このように記述すると、zigコンパイラは、暗黙に渡された型のメンバ制約をチェックし、そのメンバの存在を要請します 3(なければコンパイルエラー)。

具体的には、以下のチェック。

  • SomeTypeAllocatorを受け取りSomeTypeのポインタとError unionを返すinit関数が提供されていること
  • SomeTypeSomeType型のポインタとAllocatorを受け取りvoidを返すdeinit関数が提供されていること
  • SomeTypeSomeType型のポインタと2つのi32の値を受け取りboolを返すfoo関数が提供されていること

このように同一視したい型のライフサイクルがローカルスコープに閉じている場合、より簡易な記述を行うことができる。

上記例では、メンバ関数に対する制約を記述したが、同一のメンバフィールドを持つのであれば、フィールドもダックタイピングできる。

trait objectはいらない子?

あくまでダックタイピングによる代用は同一視したい型がローカルスコープに閉じている場合にのみ行えることであり、フィールドやコレクションに保持するような、よりライフサイクルが長くなる場合や別の関数に渡す場合は、依然trait objectが必要となります。

まとめ

comptimeつおい

  1. どこかでallocator patterみたいな名称を見かけた(実際allocatorの実装で使用されている)が出所が見つからなかったため、trait objectという名前にしておいた。

  2. 必須ではないが、一枚挟んでおいた方がコードの見通しがちょっとだけ良くなる程度の話

  3. F#を知っていれば、静的に解決される型パラメーター(SRTP)を思い浮かべていただけると分かり良いかも

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?