オフィスアワーがそろそろ始まるよ!()

comptime

Zen言語の重要な機能の1つとして、comptimeがあります。これはある式がコンパイル時計算可能であることを意味します。comptimeの概念は、可能な限りコンパイル時に計算を行うことで実行時の計算コストを減らすとともに、ソースコードを読みやすく保つことに役立ちします。

Zenのコンセプトの1つは、一貫した構文でソースコードを記述することで、可読性の高いコードを書けるようにすることです。他言語にあるようなマクロはコンパイル時 (正確にはコンパイル前) に機能する便利なものです。しかし、専用の構文が必要で、かつマクロを展開した後にどのようなコードになるか、わかりにくいです。

Zenではコンパイル時に計算できる式に、comptimeキーワードを使用することでコンパイル時にその式を計算します。コンパイル時に式が計算できなければコンパイルエラーになります。それ以外は、通常のZenのコードと何も変わりません。そのため、いかなるトリックも使わず、Zenでは柔軟なコードを記述することができます。

comptimeのコンセプトは、明示的あるいは暗黙的に、Zenで書かれたコードに現れます。

  • comptime 式 / comptime { ブロック }
  • comptime変数
  • comptime関数引数
  • グローバルスコープの式

まずは、comptimeの使い方とコンセプトに慣れ親しんでいきましょう。

comptimeの使い方

comptime 式 / comptime { ブロック }

式やブロックの前にcomptimeキーワードを付けるだけで、式やブロックがコンパイル時に評価、実行されます。式やブロックがコンパイル時計算可能でない場合、もしくは、式やブロックがパニックを発生させた場合、コンパイルエラーになります。

例として、下記のような2つの引数を加算するadd関数を考えます。

examples/ch12-comptime/src/comptime.zen:4:6

fn add(a: u32, b: u32) u32 {
    return a + b;
}

このadd関数を実行時とコンパイル時とで、それぞれ使用する方法は、次の通りです。

examples/ch12-comptime/src/comptime.zen:8:16

test "comptime const and runtime const" {
    const runtime_result = add(1, 2);
    ok(runtime_result == 3);
    
    const comptime_result = comptime add(1, 2);
    comptime {
        ok(comptime_result == 3);
    }
}

add関数の呼び出しにcomptimeを付けることで、add関数はコンパイル時に実行されます。続くcomptimeブロックではコンパイル時にテストを実施しています。

comptimeを付けずに計算したruntime_resultは実行時に計算されるため、コンパイル時計算可能ではありません。そのため、この値をコンパイル時にテストしようとすると、コンパイルエラーになります。

    comptime {
        ok(runtime_result == 3)
    }

先ほどのテストに上記コードを追加すると、次のコンパイルエラーが発生します。これはruntime_resultがコンパイル時計算可能でないことを意味しています。

$ zen build test
error[E04000]: unable to evaluate constant expression
        ok(comptime_result == 3);
                           ~

comptimeブロック内のok関数でテストが失敗した場合も、コンパイルエラーになります。

    comptime {
        ok(comptime_result == 4)
    }

上のように、テストが失敗するようにコードを修正すると、次のコンパイルエラーが発生します。これは、コンパイル時にcomptimeブロックがパニックを発生させたことを意味します。

 zen build test
lib/zen/std/testing.zen:19:9: error[E04039]: encountered @panic() at compile-time
        @panic("test failure");
        ~
comptime.zen:14:11: note[E00029]: called from here
        ok(comptime_result == 4);
          ~

comptime変数

変数定義時にcomptimeキーワードをつけることで、その変数への読み書きがコンパイル時に実行されることを保証します。comptime varであれば値の更新も可能です。ただし、更新する値もコンパイル時計算可能でなければなりません。

examples/ch12-comptime/src/comptime.zen:18:29

test "comptime variables" {
    comptime var result = comptime add(1, 2);
    comptime ok(result == 3);
    ok(result == 3);

    result = comptime add(result, 1);
    comptime ok(result == 4);
    ok(result == 4);

    // Compile error: cannot store runtime value in compile time variable
    // result = add(3, 4);
}

上のコードで、resultの値はcomptime add(result, 1)で更新されています。この値の更新は、実行時にresultの値を参照した際にも反映されています。

最後のコメントアウトのように、実行時に計算する値でresultを更新しようとすると、コンパイルエラーになります。

comptime関数引数

関数引数にcomptimeを使用することが可能です。6章 ジェネリクスでは、型を引数として渡すためにcomptimeを使う方法を説明しています。

comptimeで引数に渡せるものは、型に限りません。コンパイル時計算可能なものであれば、関数の引数として渡すことができます。

例えば、コンパイル時に配列の要素数を受け取り、配列を作った上でインデックスで各要素を初期化する関数は、次のようになります。

examples/ch12-comptime/src/comptime.zen:31:43

fn initArrayWithIndex(comptime n: usize) [n]usize {
    var array: [n]usize = undefined;
    for (array) |*element, index| {
        element.* = index;
    }
    return array;
}

test "comptime integer parameter" {
    // zero_to_file: [5]usize = { 0, 1, 2, 3, 4 };
    const zero_to_five = initArrayWithIndex(5);
    ok(@typeOf(zero_to_five) == [5]usize);
}

グローバルスコープの式

グローバルスコープ (関数の外) にある式は、暗黙のcomptimeです。例えば、グローバルスコープで関数呼び出しを行った場合、その関数はコンパイル時計算可能でなければなりません。

comptime関数引数で作成したinitArrayWithIndex関数を使って説明します。

次のコードでは、global_scope_variableinitArrayWithIndex(5)関数の戻り値で初期化されています。このinitArrayWithIndex(5)関数の呼び出しはコンパイル時に実行されます。

examples/ch12-comptime/src/comptime.zen:31:51

fn initArrayWithIndex(comptime n: usize) [n]usize {
    var array: [n]usize = undefined;
    for (array) |*element, index| {
        element.* = index;
    }
    return array;
}
// initArrayWithIndex(5)はコンパイル時に実行される
const global_scope_variable = initArrayWithIndex(5);
test "expression in global scope" {
    comptime {
        ok(@typeOf(global_scope_variable) == [5]usize);
        ok(global_scope_variable[4] == 4);
    }
}

このことにより、グローバル変数を複雑な関数で初期化することができます。加えて、グローバル変数がいつ初期化されるか、ということに頭を悩ませる必要もありません。

comptimeを使う場所

comptimeの使い方について、説明をしました。では、どのような時にcomptimeを使うべきなのでしょうか?大きく分けて4つあります。

  1. 定数をコンパイル時定数にする
  2. コンパイル時に条件分岐する
  3. コンパイル時にループ展開する
  4. ジェネリクス

それぞれについて、さらに詳しく解説します。

定数をコンパイル時定数にする

Zenのconstには2つの意味があります。実行時定数コンパイル時定数です。この両者を意識的に使い分けることで、Zenのプログラムはより効率的に動作するようになります。

実行時定数は、実行時に値が初期化された後、その値を変更できない定数です。実行時定数の初期値はプログラム実行時に計算されるため、実行時の計算コストがかかります。それに対して、コンパイル時定数は、コンパイル時に値が決定する定数であり、実行時にはその計算コストがかかりません。

再び、add関数を題材にしましょう。add関数は基本的には実行時に計算されます。

捕捉: 基本的には、と書いたのは、この程度の関数であればコンパイラの最適化によって定数化される可能性が高いためです。しかし、可能な限り、コンパイラの最適化に頼らないコンパイル時定数化を試みた方が良いでしょう。

examples/ch12-comptime/src/comptime.zen:4:6

fn add(a: u32, b: u32) u32 {
    return a + b;
}

このadd関数を使う2つのコードを比較します。comptimeをつけた方は、確実にu32の定数になります。一方、comptimeを付けていない方は、関数呼び出し、加算、関数から復帰、に関わる実行時間およびメモリのコストがかかる可能性があります。

    const runtime_result = add(1, 2);
    const comptime_result = comptime add(1, 2);

デバッグモードでビルドすると、アセンブリは次のようになります。comptimeがついている方は、定数の3をメモリに格納しているだけです。一方、comptimeがついていない方は、引数を準備し、add関数を呼び出し、その結果 (eaxに格納されている) をメモリに格納しています。

    # const runtime_result = add(1, 2);
    mov     edi, 1
    mov     esi, 2
    call    add # add関数を呼び出し
    mov     dword ptr [rbp - 4], eax

    # const comptime_result = comptime add(1, 2);
    mov     dword ptr [rbp - 8], 3

Zenは、OSや組込みシステムといったハードウェアリソースを極力減らさなければならない領域では特に、可能な限りコンパイル時定数を使用するべきでしょう。

また、自明な計算で算出できる数値計算やテーブルの生成もcomptimeで行うと良いでしょう。非常に単純な例ですが、弧度法で[0..180)の範囲を保持するテーブルを考えてみましょう。

examples/ch12-comptime/src/comptime.zen:58:65

const table = initTable();
fn initTable() [180]f64 {
    var t: [180]f64 = undefined;
    for (t) | *element, i | {
        element.* = @intToFloat(f64, i) * std.math.pi / 180;
    }
    return t;
}

これは簡単な例ですが、もっと複雑なテーブルでも外部ジェネレータに頼らず、簡単に生成することができます。もちろん、生成したテーブルをあらかじめテストすることも容易です。

examples/ch12-comptime/src/comptime.zen:67:69

test "comptime generated table" {
    ok(table[30] == std.math.pi / @intToFloat(f64, 6));
}

コンパイル時に条件分岐する

ifやswitchの条件式がコンパイル時計算可能である場合、コンパイラの最適化によりifやswitchは暗黙的に展開されます。このことを利用して、ターゲット環境に最適化された実行バイナリを容易に作成できます。

例えば、次のようにOSの種別ごとに異なる値を返すswitchByOsを考えます。著者はLinuxを使用しているため、.linux => 1,が選択されます。それ以外のコードはコンパイルされません

const builtin = @import("builtin");

fn switchByOs() i32 {
    return switch (builtin.os) {
        .windows => 0,
        .linux => 1,
        .macosx => 2,
        else => 3,
    };
}

これは、上記コードをコンパイルしてアセンブリを見ると一目でわかります。これは、1を返す関数になっています。

switchByOs:
        mov     eax, 1
        ret

上の結果は--release-safeモードでビルドした結果を掲載しています。デバックモードでも分岐が実行バイナリに残らないことは同じですが、プロローグとエピローグがノイズになるため、--release-safeでビルドした結果を掲載しています。

コンパイル時計算可能な値をifやswitchの条件式として与えることで、コードサイズを小さく、実行時間を短くすることが可能です。

コンパイル時にループ展開する

コンパイル時計算可能な値を利用することで、最適なループ展開を促すことができます。これには、comptimeinline while / inline forを組み合わせます。

下のコードは、comptime variを1ずつインクリメントし、iが偶数の場合のみ、resultに加算します。

examples/ch12-comptime/src/comptime.zen:78:87

test "inline loop using comptime var" {
    var result: u32 = 0;
    comptime var i = 0;
    inline while ( i < 10 ) : (i += 1) {
        if (i % 2 == 0) {
            result += i;
        }
    }
    ok(result == 20);
}

ここでiはコンパイル時計算可能ですので、コンパイル時のループ展開ではiが偶数の時以外のコードは取り除かれます。

上記コードは、下のようなコードに展開されます。

    var result: u32 = 0;
    result += 0;
    result += 2;
    result += 4;
    result += 6;
    result += 8;
    ok(result == 20);

ジェネリクス

ジェネリクスは実際に使用される型に対してのみ、コードが実体化されます。また、コードが実体化された後は、各型ごとにコンパイラがコードを最適化します。

詳しくは、[6.3 ジェネリクス]を参照して下さい。

☰ 人の生きた証は永遠に残るよう ☰
Copyright © 2018-2019 connectFree Corporation. All rights reserved.
Zen, the Zen three-circles logo and The Zen Programming Language are trademarks of connectFree corporation in Japan and other countries.