HN 日本語サマリー

← 一覧へ戻る
プログラミング

We have traits at home

We have traits at home (axelmagn.com)

37 pointsby Ygg213 コメント

要約

この記事は、Zig言語でRustのトレイトやHaskellの型クラスに似た抽象化をどのように実現できるかを探求しています。著者は、コンパイル時に型契約を定義する「トレイト」という新しい慣習を提案し、動的インターフェースやコンパイル時ダックタイピングといった既存のアプローチと比較して、その柔軟性と明瞭さを強調しています。このパターンは、パフォーマンスが重要なコードやメタプログラミングに役立つ一方で、バイナリサイズの増加やパフォーマンス低下のリスクについても警告しています。

全文翻訳

トレイトは家にある

最近、Zig[1]でコードを書く時間が増え、それが私の魂にとって良いものとなっています。Zigにおける抽象化に関するいくつかの洞察、特に私が有用だと感じたものの、より広く採用されていないテクニックを共有したいと思います。

Zigのインターフェースについては、正当な理由から多くのインクが費やされてきました。言語機能ではありませんが、インターフェースはZigで実行時ポリモーフィズムを実装する事実上の方法です。しかし、Zigのコンパイル時(comptime)と、それが動的ディスパッチのパフォーマンスの悪夢を回避し、洗練されたデータ指向のコードを書くためにどのように活用できるかについては、さらに多くのことが語られています。

簡単に言うと、Zigでのポリモーフィズムは、通常、次のいずれかの方法で実現されます。

  1. 動的インターフェース: 不透明なポインタ、仮想テーブル、動的ディスパッチを使用して実行時ポリモーフィズムを実現します。
  2. コンパイル時ダックタイピング: 型リフレクション、switch文、コンパイル時のモノモーフィズムを使用して静的ポリモーフィズムを実現します。

この状況はこれまでのところ非常に効果的であることが証明されていますが、トレードオフがないわけではありません。

インターフェースは、他の言語で慣れ親しんだ型の契約を提供します。インターフェースを受け入れる関数は、メソッドシグネチャの保証がありますが、実装の保証はありません。しかし、インターフェースは設計上、実行時にモジュール化されているため、コンパイラにとってハードな障壁となり、パフォーマンスのオーバーヘッドを導入します。間接参照が多いため、モノモーフィックコードに適用できる最適化を効果的に適用できないことがよくあります。

パフォーマンスが重要なコードの場合、Zigコードは通常、コンパイル時ダックタイピングに依存します。関数引数はanytypeであるか、型引数Tによってパラメータ化され、関数実装はコンパイル時リフレクションを実行するか、コンパイル時にチェックできる型のメソッドに関する仮定を行います。実装は、C++のテンプレートやCのマクロと同様に、モノモーフィズされる可能性があります。これは実行時ダックタイピングよりもわずかに安全です。インターフェース違反は実行時ではなくコンパイル時に検出されるためですが、呼び出し側と呼び出し先は、渡すことができる型の契約を文書化し、それに従うように注意する必要があります。

他の静的型付け言語から来ている場合、この取り決めがかなり単純に見えるとしても、あなたは許されるでしょう。これはおそらく意図的です――Zigはシンプルであることを目指しています。しかし、RustのトレイトやHaskellの型クラスに慣れている場合、Zigにはすぐに使える同等のものがないように思われます。

私は、少し適応させることで、Zigのユーザーランドで型に対する同様の抽象化を表現することは完全に可能であると主張します。私は、Zigのユーザーランドでコンパイル時の型契約をトレイトとして定義するための新しい慣習を提案します。これにはライブラリや仕組みは必要ありません――実際、バニラZigに驚くほどよく適合します。これは、一般的な関数型プログラミングの世界、特にOCamlに触発されています。

定義: トレイトとは、与えられた型に対する関連する関数を含む、コンパイル時の構造体です。[9]

リードを埋もれさせないために、型に対する折り畳み(削減)のためのトレイトをどのように定義できるかの簡単な例から始めましょう。

/// T は、空の値と結合操作の下で Reducer を形成します
fn Reducer(T: type) type {
    return struct {
        empty: T,
        combine: fn (T, T) T,

        fn reduce(self: @This(), xs: []const T) T {
            var accum = self.empty;
            for (xs) |x| accum = self.combine(accum, x);
            return accum;
        }
    };
}

/// i32 は、0 と加算の下で sum reducer を形成します
const sum_trait = impl(Reducer(i32), struct {
    pub const empty = 0;
    pub fn combine(x: T, y: T) T { return x + y; }
});

/// i32 は、1 と乗算の下で product reducer を形成します
const product_trait = impl(Reducer(i32), struct {
    pub const empty = 1;
    pub fn combine(x: T, y: T) T { return x * y; }
});

export fn run(buf: [*c]i32, n: usize) i32 {
    const sum = sum_trait.reduce(buf[0..n]);
    const product = product_trait.reduce(buf[0..n]);
    return sum + product;
}

うーん。これはバターのように滑らかではありませんか?[10] 契約は明確で、LSPのオートコンプリートがそれを拾い、コンパイラはそれをインライン化してループを展開することさえ知っています。

トレイトは単に関数と型の袋であり、受信関数に、定義を担当しないサブタスクをどのように実行するかを伝えます。これらはvtableと非常によく似ていますが、実行時ではなくコンパイル時に評価される点が異なります。

従来のポリモーフィズム vs トレイト

トレイトを正当化するために、一般的な動機付けの例である算術演算を通じてZigのポリモーフィズムをツアーしてみましょう。長年にわたり、ユーザー定義型で数学を行うのがいかに厄介かについて、いくつかの不満を見てきました。明示的な制御フローを追求する中で、Zigは演算子オーバーロードを回避しており、これは他の型言語から来ている場合には衝撃的かもしれません。

RationalとComplexのようなユーザー定義型があると想像し、それらの基本的な算術演算を定義したいとします。おそらく、次のようなものになるでしょう。

const Rational = struct {
    numerator: i32,
    denominator: i32,

    fn add(lhs: Rational, rhs: Rational) Rational {...}
    fn sub(lhs: Rational, rhs: Rational) Rational {...}
    fn mul(lhs: Rational, rhs: Rational) Rational {...}
    fn div(lhs: Rational, rhs: Rational) Rational {...}
};

const Complex = struct {
    real: f32,
    imaginary: f32,

    fn add(lhs: Complex, rhs: Complex) Complex {...}
    fn sub(lhs: Complex, rhs: Complex) Complex {...}
    fn mul(lhs: Complex, rhs: Complex) Complex {...}
    fn div(lhs: Complex, rhs: Complex) Complex {...}
};

それらで何かをするために、メビウス変換を計算できるとしましょう:(a * x + b) / (c * x + d)。これは複素数と有理数の両方で役立ちます。簡単にするために、関数へのすべての引数が同じ型であると仮定します。

コンパイル時ダックタイピング

メビウス変換を素朴に実装するとしたら、組み込み演算子を使用できます。

fn mobius(T: type, x: T, a: T, b: T, c: T, d: T) T {
    return (a * x + b) / (c * x + d);
}

これは、入力がすべて同じ型の浮動小数点数である限り、うまく機能します。整数では除算が失敗し、もちろんユーザー定義型では機能しませんが、これは出発点です。

もちろん、両方のユーザー定義型が同じ名前と類似したシグネチャを持つ算術関数を持っていることに気づき、それもダックタイピングに適しているかもしれません。

fn mobius(T: type, x: T, a: T, b: T, c: T, d: T) T {
    const numerator = a.mul(x).add(b);
    const denominator = c.mul(x).add(d);
    return numerator.div(denominator);
}

これはユーザー定義型では機能しますが、Zigのプリミティブ型では明らかに機能しません。それらを独自の関数を持つ独自のユーザー定義型でラップする必要があります。さらに問題なのは、これが一種のオブジェクト指向パターンに私たちを結びつけることです。この抽象化契約に従う事実上の方法は、構造体にメソッドを定義することです。

良いニュースは、すべてがコンパイル時にモノモーフィズされるため、コンパイラがこれらの関数呼び出しを単純化およびインライン化できることです。

インターフェースとVTable

これを実行時に動的に機能させたい場合、VTableと不透明なポインタを使用してこれらの数学演算を動的にディスパッチするNumインターフェースを宣言できるかもしれません。

const Num = struct {
    value: [32]u8,
    vtable: *const VTable,

    const VTable = struct {
        add: *const fn([*]u8, [*]u8) Num,
        sub: *const fn([*]u8, [*]u8) Num,
        mul: *const fn([*]u8, [*]u8) Num,
        div: *const fn([*]u8, [*]u8) Num,
    };

    fn add(lhs: Num, rhs: Num) Num { ... }
    fn sub(lhs: Num, rhs: Num) Num { ... }
    fn mul(lhs: Num, rhs: Num) Num { ... }
    fn div(lhs: Num, rhs: Num) Num { ... }

    fn mobius(x: Num, a: Num, b: Num, c: Num, d: Num) {
        const numerator = a.mul(x).add(b);
        const denominator = c.mul(x).add(d);
        return numerator.div(denominator);
    }
}

次に、ComplexとRationalを、ここでは再現するには少し面倒ですが、算術関数をラップするfn num(self: *Self) Num(またはそれに類するもの)を公開するように配線する必要があります。もちろん、少し注意が必要になります。関数呼び出しで複素数と有理数を混在させることを妨げるものは何もありません。これは未定義の動作につながります。おそらく、少し過剰なエンジニアリングで安全チェックを追加できますが、ここでは触れません。

これはコンパイル時ダックタイピングよりも厄介なだけでなく、パフォーマンスも低下します。数学演算を実行したいときはいつでも動的ディスパッチを要求することになり、コンパイラを妨げ、大量の数学を行う場合には大幅なパフォーマンスインパクトがあります。

トレイト

メビウス関数を定義するとき、私たちは本当に「どのような型を扱っているかはわからないが、算術演算が必要だ」と言いたいのです。このような場合、必要な関数をコンパイル時に渡すのが最も理にかなっていると思います。これは、非常に細かいレベルでのコンパイル時依存性注入です。シグネチャはかなり冗長ですが、実行時インターフェースよりも実際に書くべきボイラープレートは少なくなります。

fn mobius(
    comptime T: type,
    comptime add: fn (T, T) T,
    comptime mul: fn (T, T) T,
    comptime div: fn (T, T) T,
    x: T,
    a: T,
    b: T,
    c: T,
    d: T,
) T {
    const numerator = add(mul(a, x), b);
    const denominator = add(mul(c, x), d);
    return div(numerator, denominator);
}

これは、非常に細かいレベルでのコンパイル時依存性注入に効果的です。シグネチャはかなり冗長ですが、ComplexまたはRationalのために書くべきボイラープレートは、実行時インターフェースよりも実際には少なくなります。

mobius(Complex, Complex.add, Complex.mul, Complex.div, x, a, b, c, d);
mobius(Real, Real.add, Real.mul, Real.div, x, a, b, c, d);

ここから、トレイトに飛躍するのはそれほど大きなことではありません。私たちは、算術関数を1つの構造体にまとめることによって、関数シグネチャを効果的にクリーンアップしています。[11]

fn Num(T: type) type {
    return struct {
        add: fn (T, T) T,
        sub: fn (T, T) T,
        mul: fn (T, T) T,
        div: fn (T, T) T,
    };
}

fn mobius(T: type, num: Num(T), x: T, a: T, b: T, c: T, d: T) T {
    const numerator = num.add(num.mul(a, x), b);
    const denominator = num.add(num.mul(c, x), d);
    return num.div(numerator, denominator);
}

ここから、通常はトレイトの実装を型の定義の一部として宣言します。

const Complex = struct {
    // フィールド...
    const num = Num(Complex) { .add = add, .sub = sub, .mul = mul, .div = div };
    // 関数 (add, sub, mul, div...)
};

mobius(Complex, Complex.num, x, a, b, c, d);

このアプローチの本当に好きな点は、型と関数の間の契約について非常に明確でありながら、非常に柔軟であることです。たとえば、Numの定義をプリミティブ型であるi32やf32に拡張したいと想像してみてください。これを行うにはいくつかの方法がありますが、おそらく整数型と浮動小数点型に適したものを生成する関数を定義するでしょう。

fn builtinNum(T: type) Num(T) {
    const inner = struct {
        fn add(lhs: T, rhs: T) T { return lhs + rhs; }
        fn sub(lhs: T, rhs: T) T { return lhs - rhs; }
        fn mul(lhs: T, rhs: T) T { return lhs * rhs; }
    };
    const div = switch (@typeInfo(T)) {
        .int, .comptime_int => struct {
            fn div(lhs: T, rhs: T) T { return @divTrunc(lhs, rhs); }
        }.div,
        .float, .comptime_float => struct {
            fn div(lhs: T, rhs: T) T { return lhs / rhs; }
        }.div,
        else => @compileError("unsupported type: " ++ @typeName(T)),
    };
    return .{ .add = inner.add, .sub = inner.sub, .mul = inner.mul, .div = div };
}

mobius(f32, builtinNum(f32), x, a, b, c, d);

トレイトはコンパイラにも透過的であり、喜んでモノモーフィズしてインライン化します。ほとんどの場合、特にホットループやデータレイアウトについて意見を述べないライブラリ関数では、実行時ポリモーフィズムよりもこちらを好みます。

制限事項

トレイトは既知の型に明示的に条件付けされる必要があることに注意してください。インターフェースとは異なり、「数値を与えてほしい、どんな種類でも構わない」と言う方法はありません。一部のアプリケーションでは、関連型を持つことができれば便利でしょう。

fn Iter(Elem: type) type {
    return struct {
        T: type,
        next: fn (T) ?Elem,
    };
}

残念ながら、上記は有効なZigではなく、言語レベルでどのように機能するかは不明です。代わりに、トレイトは操作対象の型が事前に知られていることを要求します。

fn Iter(T: type, Elem: type) type {
    return struct {
        next: fn (T) ?Elem,
    };
}

これに対する人間工学的な回避策はまだ見つかっていません。トレイトは型消去とはあまりうまく連携しません。

トレイトヘルパー

続行する前に、トレイトの操作を合理化したいくつかの小さなヘルパー関数を紹介したいと思います。

impl

トレイトをプログラミングパターンおよび慣習として採用する場合、関数を定義してそれらを渡すことがすぐに多くなります。すでに上で、匿名構造体型を定義して関数をそこから引き出すために、多くの使い捨ての内部構造体型を定義していることに気づいたでしょう。これは、トレイト実装を宣言するための次の便利な関数につながり、ボイラープレートの一部を削除します。

pub fn impl(comptime Trait: type, comptime Impl: type) Trait {
    comptime {
        var result: Trait = undefined;
        for (@typeInfo(Trait).@"struct".field_names) |field_name| {
            @field(result, field_name) = @field(Impl, field_name);
        }
        return result;
    }
}

私はこれを至る所で、匿名構造体からトレイト実装を派生させるために使用しています。たとえば、builtinNumをより簡潔で関数型にするために使用できます。型リフレクションが機能するためには、宣言がパブリックである必要があることに注意してください。

fn builtinNum(T: type) Num(T) {
    return impl(Num(T), struct {
        pub fn add(lhs: T, rhs: T) T { return lhs + rhs; }
        pub fn sub(lhs: T, rhs: T) T { return lhs - rhs; }
        pub fn mul(lhs: T, rhs: T) T { return lhs * rhs; }

        pub const div = switch (@typeInfo(T)) {
            .int, .comptime_int => struct {
                fn div(lhs: T, rhs: T) T { return @divTrunc(lhs, rhs); }
            }.div,
            .float, .comptime_float => struct {
                fn div(lhs: T, rhs: T) T { return lhs / rhs; }
            }.div,
            else => @compileError("unsupported type: " ++ @typeName(T)),
        };
    });
}
getTrait

ほとんどの場合、トレイト実装を明示的に渡すことに問題はありません。私は習慣になり、それをそれほど大きな問題だとは思いません。トレイトファンクターはボイラープレートを削減するのをかなり簡単にし、実装方法を明示的に制御できるのが好きです。

しかし、自分の構造体に対処している場合、99%の時間は探しているトレイト実装が構造体自体に定数として宣言されていることになります。私は、最も一般的なケースを簡単に取得または構築するために、以下の関数を使用します。

fn getTrait(Trait: type, Impl: type) Trait {
    comptime {
        // メンバーとして明示的に宣言されたトレイトを探す
        for (@typeInfo(Impl).@"struct".decl_names) |decl_name| {
            if (@TypeOf(@field(Impl, decl_name)) == Trait) {
                return @field(Impl, decl_name);
            }
        }
        // それ以外の場合はエラーを発生させる
        @compileError("trait " ++ @typeName(Trait) ++ " not found on " ++ @typeName(Impl));
    }
}

これは基本的に「まあ、ダックタイピングだ。自分でやってくれ」と言うのと同じです。明示的であるからといって、怠惰になれないわけではありません。このサイクルをダックタイピングに戻すことさえできます。getTraitに依存してトレイトを型から抽出するトレイトファンクターを定義することさえできます。

fn duckMobius(T: type) fn(T, T, T, T) T {
    comptime {
        const num = getTrait(Num(T), T);
        return makeMobius(T, num);
    }
}

トレイトファンクター

ヘルパーをいくつか定義したので、Numの例に戻りましょう。

Zigのトレイトは、Rustのトレイト、Haskellの型クラス、またはOCamlのモジュールに似ています。RustやHaskellでは、コンパイラによって管理されるグローバルレジストリから解決されます。これは非常に便利に感じられるかもしれませんが、Zigの明示的な制御フローの原則に違反します。RustやHaskellでは、特に問題が発生してLSPが正しく解決しない場合、トレイトがどこで定義されているかを調べるのにかなりの時間がかかることがあります。また、コンパイル時間の長期化の原因にもなり得ます。これらの言語でジェネリックトレイトを解決することは、しばしば証明の評価と同等であり、時間がかかることがあります。

一方、OCamlのモジュールはZigのトレイトと非常によく似ています。モジュールは第一級オブジェクトであり、それらを消費する関数に引数として渡されます。どのモジュールが型に適用されるかの推論はありません――それらを見つけて明示的に渡します。Numをモジュールとして定義した場合、次のようになります。

(* signature *)
module type NUM = sig
  type t
  val add : t -> t -> t
  val sub : t -> t -> t
  val mul : t -> t -> t
  val div : t -> t -> t
end

(* Implementation on float *)
module Float : NUM with type t = float = struct
  type t = float
  let add = ( +. )
  let sub = ( -. )
  let mul = ( *. )
  let div = ( /. )
end

(* function *)
let mobius (type a) (module N : NUM with type t = a) x a b c d =
  let numerator = N.add (N.mul a x) b in
  let denominator = N.add (N.mul c x) d in
  N.div numerator denominator

(* Usage *)
let res = mobius (module Float) x a b c d)

OCamlのモジュールは第一級値であるため、さらに一歩進めることができます。上記のようにmobiusを定義する代わりに、OCamlでは「モジュールファンクター」を利用するのが一般的です。これは単にモジュールを引数として受け取り、新しいモジュールを返す関数です。mobiusを特定の数値型に基づいて定義したい場合、代わりに次のように記述するかもしれません。

(* MakeMobius は NUM モジュールを受け取り、mobius 関数を持つ新しいモジュールを返します *)
module MakeMobius (N : NUM) = struct
  let mobius x a b c d =
    let numerator = N.add (N.mul a x) b in
    let denominator = N.add (N.mul c x) d in
    N.div numerator denominator
end

(* usage *)
module FloatMobius = MakeMobius (Float)
let res = FloatMobius.mobius x a b c d

これは実質的にカリー化です。モジュール引数を今適用し、値引数を後で適用します。しかし、これは常に多くの型引数を取らないmobius関数を導き出し、そのモジュールは後続の構築で使用できます。これはZigに直接翻訳されます。トレイトパターンに厳密に従いたい場合、次のようになります。

fn Mobius(T: type) type {
    return struct {
        mobius: fn(T, T, T, T, T) T,
    };
}
fn makeMobius(T: type, num: Num(T)) Mobius(T) {
    return impl(Mobius(T), struct {
        fn mobius(x: T, a: T, b: T, c: T, d: T) T {
            const numerator = num.add(num.mul(a, x), b);
            const denominator = num.add(num.mul(c, x), d);
            return num.div(numerator, denominator);
        }
    });
}
const complex_mobius = MakeMobius(Complex, Complex.num);
complex_mobius.mobius(x, a, b, c, d);

これはうまく機能しますが、ファンクターをMobius(T)に移動する方がさらにシンプルだと思います。

fn Mobius(T: type) type {
    return struct {
        mobius: fn(T, T, T, T, T) T,

        fn make(num: Num(T)) Mobius(T) {
            return impl(Mobius(T), struct {
                fn mobius(x: T, a: T, b: T, c: T, d: T) T {
                    const numerator = num.add(num.mul(a, x), b);
                    const denominator = num.add(num.mul(c, x), d);
                    return num.div(numerator, denominator);
                }
            });
        }
    };
}
const complex_mobius = Mobius(Complex).make(Complex.num);
complex_mobius.mobius(x, a, b, c, d);

しかし、このような単一の関数については、それが別の関数にトレイトとして消費される必要があると知っていない限り、完全なトレイトよりも型カリー化関数として実装することに誘惑されるでしょう。

fn makeMobius(T: type, num: Num(T)) fn(T, T, T, T) T {
    return struct {
        fn mobius(x: T, a: T, b: T, c: T, d: T) T {
            const numerator = num.add(num.mul(a, x), b);
            const denominator = num.add(num.mul(c, x), d);
            return num.div(numerator, denominator);
        }
    }.mobius;
}

const complexMobius = makeMobius(Complex, Complex.num);
complexMobius(x, a, b, c, d);

Zigのコンパイル時の汎用性により、これらはすべてコンパイラにとって多かれ少なかれ同等です。それは単なるスタイル上の選択であり、状況に応じて異なります。

また、技術的にはbuiltinNumを定義したときにすでにトレイトファンクターを使用していることに注意してください。それは深く技術的な概念ではありません。単にトレイト実装を構築する関数です。

便利なトレイト

トレイトを徹底的に紹介したので、それらで何ができるかについて話しましょう。

このセクションには、私が十分に頻繁に手を伸ばすトレイトがいくつかあり、それらを保持するファイルを用意しています。これらはすべてかなり平凡な(しかし驚くほど適用可能な)抽象化であり、他の言語からそのまま持ち込まれました。

Into / From

型を別の型に変換する必要が頻繁にあります。多くの場合、上流型をどのように解釈したいかによって、これを行う方法は複数あります。IntoとFromを使用して、ある型から別の型への変換メソッドを正規化します。

/// T は Out に変換できます
fn Into(T: type, Out: type) type {
    return struct {
        into: fn(T) Out,
    };
}

/// T は In から派生できます
fn From(T: type, In: type) type {
    return struct {
        from: fn(In) T,
    };
}
Index

時々、反復処理の代わりに、値へのキーアクセスが必要になります。コンテナはスライスまたはハッシュテーブルである可能性があります――気にしません。このために、Indexを使用します。

/// T は Key と Value のインデックスを形成します
fn Index(T: type, Key: type, Value: type) type {
    return struct {
        get: fn(T, Key) ?Value,
    };
}
Iterator / Iterable

別の一般的なケース:型を反復処理する関数がありますが、どのような反復可能なデータ構造を扱っているかは気にしません。通常、2つのトレイトが必要です。1つはIterableコレクション用、もう1つはコレクション内の進行状況を追跡するIterator用です。

/// T は Elem 要素を反復処理するために使用できます
fn Iterator(T: type, Elem: type) type {
    return struct {
        next: fn(*T) ?Elem,
    };
}

/// T は Elem 要素の Iter イテレータを返すことができます
fn Iterable(T: Type, Elem: type, Iter: type) type {
    return struct {
        iter: fn(T) IterPair,
        pub const IterPair = struct {impl: Iter, trait: Iterator(Iter, Elem)};
    };
}

これは少し厄介であることに気づくでしょう。なぜなら、通常、イテレータの実装が何であるかは気にしないからです。おそらく配列上のusizeインデックスにすぎません。Iterable(T: type, Elem: type)だけを定義できれば便利でしょう。私はそれを厄介なままにしておくことにしました。通常は大きな問題ではありません。

警告

この記事を読んで、「私のコードをすべてトレイトを使用するように変換する必要がある」と思ってもらいたくはありません。トレイトは総体的なパターンと見なされるべきではありません。ZigコードがRustやHaskellのように見えるようにしたい最後のことは、それです。

トレイトは時々のおやつです。ホットループ内で抽象化を必要とする関数に役立ちます。そこでは、モノモーフィズムと静的ディスパッチを望んでいることがわかっています。それらは、いくつかのクールなメタプログラミングトリックのための理解可能なビルディングブロックになる可能性があります。しかし、ほとんどの場合、私はそれらをプルベースの方法でのみ使用します。トレイトを消費すると便利な関数があります。なぜなら、それは実装の詳細の一部を決定していないからです。

不適切に使用すると、トレイトはバイナリのサイズを爆発させ、実際にはパフォーマンスを低下させる可能性があります。コンパイル時パラメータにパラメータ化された各関数は、独自のテンプレート化されたアセンブリを取得し、これらのネストを数層深くすると、組み合わせ爆発に陥りやすくなります。これはCPUキャッシュをスワップさせ、状況によっては動的ディスパッチやvtableよりも簡単にパフォーマンスが低下する可能性があります。

励ましの言葉

トレイトは最終的に便利で非常に強力です。特にホットループ内の抽象化のために、それらを利用するコードベースをさらに多く見たいと思います。それらが言語機能よりもデザインパターンであるという点が気に入っており、これにより、いつ、どのように使用するかを明示的に制御できます。私は長い間このようなソリューションを探していましたが、それを見つけられて嬉しいです。

他のZigユーザーもこのトリックに気づいたと確信していますが、まだ実際には見ていません。その理由の一部は、それがライブラリにはあまり適していないことだと思います。それは、あなたが使用または無視することを選択できるより多くの慣習です。

読んでいる人にとって、トレイトが適切なツールであることを願っています。

  1. 執筆時点でのZig 0.17。↩
  2. Zig Interfaces by Karl Seguin ↩
  3. 比喩的に ↩
  4. Zig Interface Revisited by William Wong ↩
  5. Inside Zig's new Writer by Joe McKay ↩
  6. Interfaces in Zig by Brad Cypert ↩
  7. A Practical Guide to Applying Data-Oriented Design by Andrew Kelley ↩
  8. Zig Comptime is Bonkers Good by Scott Redit ↩
  9. 「トレイト」はOCamlモジュールに直接触発されていますが、「モジュール」という用語はZigではすでに別のものを指しています。↩
  10. [画像: Feels Good] ↩
  11. これは数値トレイトの非常に単純な定義です。実際には、Rustのstd::opsやHaskellのNumを調べることになるでしょう。↩