Zigのcomptimeを引数に持つ関数が実行時にどのコードが動くのかの推測したい
あくまでもドキュメントを読んだ自分の推測。
あくでも推測。
あとで、LLVM の中間表現などで確証を得たいが他の最適化があるかもしれない懸念もある。
考えたいこと
以下のドキュメントに書かれている通り、
inlineループを使うと一部はコンパイル時に評価され、一部は実行時に評価される。
This combined with the fact that we can inline loops allows us to write a function which is partially evaluated at compile-time and partially at run-time.
これはstd.debug.print()のような身近な関数でも用いられている。 だが、どんな仕掛けで部分的にコンパイル時の評価と実行時の評価を実現しているか疑問に思った。
今まで知っている言語は特定の関数全体はコンパイル時に評価可能というもので、コンパイル時に決定している引数に応じて部分的にコンパイル時に評価される関数は初めてな気がする。
こういう仕組みならコンパイラがやっていることを実装できそうだなと思える方法を推測できたので残したい。
対象のコード
code:zig(ts)
const expect = @import("std").testing.expect;
const CmdFn = struct {
name: []const u8,
func: fn(i32) i32,
};
CmdFn {.name = "one", .func = one},
CmdFn {.name = "two", .func = two},
CmdFn {.name = "three", .func = three},
};
fn one(value: i32) i32 { return value + 1; }
fn two(value: i32) i32 { return value + 2; }
fn three(value: i32) i32 { return value + 3; }
fn performFn(comptime prefix_char: u8, start_value: i32) i32 {
var result: i32 = start_value;
comptime var i = 0;
inline while (i < cmd_fns.len) : (i += 1) {
if (cmd_fnsi.name0 == prefix_char) { result = cmd_fnsi.func(result); }
}
return result;
}
test "perform fn" {
try expect(performFn('t', 1) == 6);
try expect(performFn('o', 0) == 1);
try expect(performFn('w', 99) == 99);
}
変形
上記のperformFn()を変形していく。
comptime var iとinline while (i < cmd_fns.len)はコンパイル時に決定するするためフラットに書き下せる。
cmd_fns.lenも3と決定するためループの終わりはコンパイル時に分かる。
そのため以下のようにinline whileがない状態でフラットにifが並ぶと考えられる。
code:zig(ts)
fn performFn(comptime prefix_char: u8, start_value: i32) i32 {
var result: i32 = start_value;
if (cmd_fns0.name0 == prefix_char) { result = cmd_fns0.func(result); }
if (cmd_fns1.name0 == prefix_char) { result = cmd_fns1.func(result); }
if (cmd_fns2.name0 == prefix_char) { result = cmd_fns2.func(result); }
return result;
}
このフラットに並ぶ姿がイメージできた時に、ある意味繰り返しを使って実行前に動的にコードを生成していると解釈できるようになった。
上記のcmd_fnsの内容もコンパイル時に決定するため、以下のように書き下せる。
code:zig(ts)
fn performFn(comptime prefix_char: u8, start_value: i32) i32 {
var result: i32 = start_value;
if ("one"0 == prefix_char) { result = one(result);
}
if ("two"0 == prefix_char) { result = two(result);
}
if ("three"0 == prefix_char) { result = three(result);
}
return result;
}
上記のコードで"two"も"three"も共に't'で始まっていることに注目。
上記の状態でもまだコンパイル時に決定することがある。
それは comptime prefix_char: u8 と if (...[0] == prefix_char) { ... } の部分。
performFnの呼び出しは、performFn('t', ...)とperformFn('o', ...)とperformFn('w', ...)だけなので t, o, w に特化したperformFn()に分けて考える。
performFn('t', ...)だけを考えると以下のperformFn_t()を呼び出しているのと等価だと考えられる。わかりやすさのために通過しないifはコメントとして残している。
code:zig(ts)
// 注目:'t'に特化!(comptime prefix_char が 't' に限定した時)
fn performFn_t(start_value: i32) i32 {
var result: i32 = start_value;
//if ("one"0 == prefix_char) { // result = one(result);
//}
if (true) { // prefix_char == 't' なので常に true
result = two(result);
}
if (true) { // prefix_char == 't' なので常に true
result = three(result);
}
return result;
}
if(true)のブロック内部だけ残して、't'に特化したperformFn_tは以下のようになると考えられる。
以下のperformFn_t(1)こそperformFn('t', 1)の実行時に評価される内容だと推測している。
code:zig(ts)
// 't'に特化
fn performFn_t(start_value: i32) i32 {
var result: i32 = start_value;
result = two(result);
result = three(result);
return result;
}
(上記のtwo(result)、three(result)もコンパイル時に決定してそれぞれがresult + 2とresult + 3になるかもしれないが、こういう系はZigに限らずコンパイラがインライン化しそう領域で、そもそも対象コードに外界からの値がないので全てがコンパイル時に決定できそうなのでここで止めておくことにした。)
一番最初のコードではtry expect(performFn('t', 1) == 6);となっており、start_value=1で始まる。つまり以下のように6だと分かりtry expect(... == 6)の6と一致することが分かる。
code:zig(ts)
var result: i32 = 1;
result = two(result); // result <- (result: 1) + 2
// この時点で reuslt == 3
result = three(result); // result <- (result: 3) + 3
// この時点で result == 6
return result;
コンパイル時に検出する
せっかくなので実行前に問題を検出する例に変更する。
具体的にはperformFn('w', 99)のwはone, two, threeのどの先頭の文字ともマッチしないためコンパイルエラーにさせたい。
以下の「// 追加」のコードを増やした。
code:zig(ts)
fn performFn(comptime prefix_char: u8, start_value: i32) i32 {
var result: i32 = start_value;
comptime var i = 0;
comptime var cmd_used = false; // 追加
inline while (i < cmd_fns.len) : (i += 1) {
if (cmd_fnsi.name0 == prefix_char) { cmd_used = true; // 追加
result = cmd_fnsi.func(result); }
}
// 追加
if (!cmd_used) {
@compileError("prefix '" ++ _u8{prefix_char} ++ "' not found "); }
return result;
}
実装は単純で if に入ったら cmd_used = true;をしてフラグをtrueにして、cmd_usedの状態をみて、@compileError()するか決めている。よくあるマクロのようにマクロ用の構文を覚えることなく、通常のプログラムの知識 + αで記述できている。
これでperformFn('w', 99)はコンパイル時に「 error: prefix 'w' not found」というエラーを発生させられる。
感想
やはりZigのcomptimeという解決策が面白い。comptimeはやみつきになりそうな気がしている。
一般的な"%d"のようなフォーマット指定は実行時評価で、マシンに実行時に無駄な処理をさせてそうな感覚と不整合が実行前に検出しにくい仕組みだという印象があった。いまどきのコンパイラが特定の関数を特別扱いして最適化や警告するのかもしれないが、特別扱いなしにマシンに最適なコードが生み出される解決策の方が良い。あと以前AppleがこれがらみでWi-Fiに繋がらなくなるバグも出していたと思う。 コンパイル時に解決できたとしてもRustのようなマクロによる解決ぐらいかな思っていが、コンパイル時に値が決定していることを保証する引数を導入することで解決するZigが非常に興味深かった。ほぼ通常の構文の知識だけで実現できている点が新鮮だった。裏を返せばどこがコンパイル時に評価されるかがマクロと比較して読み取りづらいかもしれない。