Zen言語の重要な機能の1つとして、comptime
があります。これはある式がコンパイル時計算可能であることを意味します。comptime
の概念は、可能な限りコンパイル時に計算を行うことで実行時の計算コストを減らすとともに、ソースコードを読みやすく保つことに役立ちします。
Zenのコンセプトの1つは、一貫した構文でソースコードを記述することで、可読性の高いコードを書けるようにすることです。他言語にあるようなマクロはコンパイル時 (正確にはコンパイル前) に機能する便利なものです。しかし、専用の構文が必要で、かつマクロを展開した後にどのようなコードになるか、わかりにくいです。
Zenではコンパイル時に計算できる式に、comptime
キーワードを使用することでコンパイル時にその式を計算します。コンパイル時に式が計算できなければコンパイルエラーになります。それ以外は、通常のZenのコードと何も変わりません。そのため、いかなるトリックも使わず、Zenでは柔軟なコードを記述することができます。
comptime
のコンセプトは、明示的あるいは暗黙的に、Zenで書かれたコードに現れます。
まずは、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 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
を使用することが可能です。6章 ジェネリクスでは、型を引数として渡すためにcomptime
を使う方法を説明しています。
comptime
で引数に渡せるものは、型に限りません。コンパイル時計算可能なものであれば、関数の引数として渡すことができます。
例えば、コンパイル時に配列の要素数を受け取り、配列を作った上でインデックスで各要素を初期化する関数は、次のようになります。
initArrayWithIndex
の引数 n
が comptime
で指定され、コンパイル時に値が計算可能であるため var array: [n]usize
のように型の一部として使用することができます。
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_five: [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_variable
はinitArrayWithIndex(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を使うべきなのでしょうか?大きく分けて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.tag) {
.windows => 0,
.linux => 1,
.macosx => 2,
else => 3,
};
}
これは、上記コードをコンパイルしてアセンブリを見ると一目でわかります。これは、1
を返す関数になっています。
switchByOs:
mov eax, 1
ret
上の結果は
--release-safe
モードでビルドした結果を掲載しています。デバックモードでも分岐が実行バイナリに残らないことは同じですが、プロローグとエピローグがノイズになるため、--release-safe
でビルドした結果を掲載しています。
コンパイル時計算可能な値をifやswitchの条件式として与えることで、コードサイズを小さく、実行時間を短くすることが可能です。
コンパイル時計算可能な値を利用することで、最適なループ展開を促すことができます。これには、comptime
とinline while
/ inline for
を組み合わせます。
下のコードは、comptime var
のi
を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-2020 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.