この記事は、
Metaprogramming is less fun in D
を自分用に翻訳したものを 許可を得て 公開するものである。 コードのコメントも翻訳してある。 ソースに原文を併記してあるので、誤字や誤訳などを見つけたら今すぐ Pull requestだ!
講演者の静かな声と、ほとんどC++のコードのみを映し出すスライド(通常キーノートに期待するものではありません)にもかかわらず、 Louis DionneのMeeting C++ 2016でのメタプログラミングについてのトーク は本当に面白いものでした。
私が最後にC++でメタプログラミングをしてから何年も立ち、 私がテンプレートの信じられないほど巧妙な黒魔術の数々をまとめて、ついにそれを動かした時の古い記憶、感情をこのトークは思い起こさせました。 なんて狡猾なんだろう、私は最高のハッカーだ。 あなたもその気持ちを知っているでしょう。
私は状況が大きく変わったことを知って嬉しかったです。 ダブルコロンによってあちこち装飾された、何重にもネストした山括弧でコードを散らかすことはもはや一般的ではありません。 Hanaによって あなたは普通のランタイム関数またはオペレーターコールのように見えるコードを書きますが、その関数は裏ではそれらの引数の型からの情報に作用するジェネリクスになります。 実行時の状態はこれらの操作に関係せず、生成されるマシンコードはすべて最適化されます。 素晴らしい。
ケーススタディ:イベントシステム
このトークで議論されている例(だいたい38:30に始まります)を見てみましょう。 あなたがそれをすでに見ているならば、このパートをスキップすることもできます。 これ以降の議論で参考になるよう、私はここにサンプルの全体をコピーします。
名前でイベントを識別するイベントシステムを考えてみましょう。 イベントが発生した時に呼ばれるコールバック(ハンドラ)を複数登録することができます。 その後イベントを発生させると、そのイベントに登録されたすべてのコールバックが実行されます。
int main() {
event_system events({"foo", "bar", "baz"});
events.on("foo", []() { std::cout << "foo triggered!" << '\n'; });
events.on("foo", []() { std::cout << "foo again!" << '\n'; });
events.on("bar", []() { std::cout << "bar triggered!" << '\n'; });
events.on("baz", []() { std::cout << "baz triggered!" << '\n'; });
events.trigger("foo");
events.trigger("baz");
// events.trigger("unknown"); // おっと!ランタイムエラーです!
}
Javaスタイルの実行時のみの実装から始めましょう。 イベント名に応じた関数のvectorを探すためにハッシュマップを使います。 はじめ、空のvectorが各イベントのマップに挿入されています。
struct event_system {
using Callback = std::function<void()>;
std::unordered_map<std::string, std::vector<Callback>> map_;
explicit event_system(std::initializer_list<std::string> events) {
for (auto const& event : events)
map_.insert({event, {}});
}
コールバックを登録するために、マップから正しいvectorを探し、その末尾にコールバックをプッシュします。
template <typename F>
void on(std::string const& event, F callback) {
auto callbacks = map_.find(event);
assert(callbacks != map_.end() &&
"trying to add a callback to an unknown event");
callbacks->second.push_back(callback);
}
そしてイベントを発生させるとそのイベントのvectorの中にあるすべてのコールバックが呼ばれます。
void trigger(std::string const& event) {
auto callbacks = map_.find(event);
assert(callbacks != map_.end() &&
"trying to trigger an unknown event");
for (auto& callback : callbacks->second)
callback();
}
これでもいいのですが、発生しうるイベントには何があるか、それがいつ発生するべきかはたいてい設計時点でわかっています。 ならば、イベントが発生するたびに毎回マップを検索するコストを払う必要はないでしょう? 更に悪いことに、イベントの名前を打ち間違えた場合、運が悪いと手遅れになってから気づくことになります。
コンパイル時ルックアップ
Hanaは上のコードをちょっと修正するだけでコンパイル時のルックアップを可能にし、そのような苛立ちから我々を救ってくれます。 まず、実行時のそれと同じ場所のコンパイル時文字列リテラルによってコールサイトを書き換えます。
int main() {
auto events = make_event_system("foo"_s, "bar"_s, "baz"_s);
events.on("foo"_s, []() { std::cout << "foo triggered!" << '\n'; });
events.on("foo"_s, []() { std::cout << "foo again!" << '\n'; });
events.on("bar"_s, []() { std::cout << "bar triggered!" << '\n'; });
events.on("baz"_s, []() { std::cout << "baz triggered!" << '\n'; });
// events.on("unknown"_s, []() {}); // コンパイルエラー!
events.trigger("foo"_s); // オーバーヘッドはありません
events.trigger("baz"_s);
// events.trigger("unknown"_s); // コンパイルエラー!
}
各イベント名の_s
サフィックスに注目してください。
それはおそらく2020年にC++標準になるであろう
特殊文字列リテラルオペレータ
を必要としますが、ClangとGCCではすでに実装されています。
使わない理由はありませんね?
このオペレータは文字列をオブジェクトの型に持つステートレスなオブジェクトを構築します。
たとえば"foo"_s
はhana::string<'f', 'o', 'o'>
のインスタンスになります。
hana::mapでの実装
実行時マップを、各イベントのコールバックのvectorをちょっとした
テンプレートパラメータパック拡張マジック
で宣言するhana::map
で置き換えましょう。
template <typename ...Events>
struct event_system {
using Callback = std::function<void()>;
hana::map<hana::pair<Events, std::vector<Callback>>...> map_;
これでデフォルトでevent_system
が構築し、デフォルトでmap_
を構築することができ、
その結果として含まれるすべてのコールバックのvectorが空のvectorに初期化されます。
template <typename ...Events>
event_system<Events...> make_event_system(Events ...events) {
return {};
}
最終的に、実行時ルックアップをコンパイル時のそれと置き換えます。
template <typename Event, typename F>
void on(Event e, F callback) {
auto is_known_event = hana::contains(map_, e);
static_assert(is_known_event,
"trying to add a callback to an unknown event");
map_[e].push_back(callback);
}
template <typename Event>
void trigger(Event e) const {
auto is_known_event = hana::contains(map_, e);
static_assert(is_known_event,
"trying to trigger an unknown event");
for (auto& callback : map_[e])
callback();
}
アクセスすべきvectorはコンパイル時に決定され、上記の各関数テンプレートのインスタンス化はそのvectorにアクセスするだけです。 たとえば下のように各イベントの関数を手書きするのと比べて追加の実行時コストはないでしょう。
template <typename F>
void trigger_foo(F callback) {
for (auto& callback : callbacks_foo)
callback();
}
アプリケーションがイベントを頻繁に発生させるとき、動的なディスパッチを性的なものに置き換えると顕著な高速化になります。
下のチャートは1つのコールバックが登録されているイベントのtrigger
を呼ぶ時間を表しています。
hana::map
を基にしたコンパイル時ルックアップは大体unordered_map
の実行時ルックアップの14倍速く、
ただstd::function
を呼び出すことと比べて15%遅いだけです。
両方を同時に得る
発生するイベントが実行時にのみ決まる場合があります。たとえば:
std::string e = read_from_stdin();
events.trigger(e);
このイベントシステムはそのようなケースをハンドリングするために容易に拡張できます。 最初の実行時オンリールックアップを使ったバージョンのように、unordered mapを使います。 コールバックvectorを2重に持ちたくないため、マップの値はすでに静的マップに保存されているvectorへのポインタです。
std::unordered_map<std::string, std::vector<Callback>* const> dynamic_;
event_system() {
hana::for_each(hana::keys(map_), [&](auto event) {
dynamic_.insert({event.c_str(), &map_[event]});
});
}
実行時に決定するイベントを発生させられるようにするには、純実行時実装と同じようにトリガメソッドをオーバーロードするだけです。
void trigger(std::string const& event) {
auto callbacks = dynamic_.find(event);
assert(callbacks != dynamic_.end() &&
"trying to trigger an unknown event");
for (auto& callback : *callbacks->second)
callback();
}
もっとうまくできないか?
トークのこの時点(59:30)で、Louisがこう言っています:
コンパイル時と実行時のルックアップのサポートに必要なのはシングルオーバーロードです。 それは非常に強力で、私は他にそれが可能な言語を知りません。
D言語がそれをもっとうまくできるのは明らかでした、10分で同等の実装を作ることができました。 しかし私は理解できます: もし私が何年もの努力の積み重ねの結果を宣伝するならば、私は人々が他のものを探すことを望みません。 ;)
Dをご存じでない? 「ちゃんとしたC++」として聞いたことがあるかもしれませんが、それは完全には真実ではありません。 Dは悪い決定の荷物や癖を抱えてもいます。 時間の経過とともに、全く異なるスタイルの追加がアドホックな標準ライブラリに集まってきます。 ドキュメントは時に時代遅れになっているか、探しているものが抜けています。 そのコミュニティも、それが業界から受けたサポートもC++で受けたそれとは比較にならないほど小さなものです。 しかしそのすべてをさておいて、何年もDを様々なタスクに使用した上で、 “信頼性・保守性の高い・読みやすいコードを書いて仕事をサクサク進める必要のある 現実的なプログラマのための、 現実的な言語” 1 であるという約束に恥じないものであるということに私は同意せざるを得ません。
そこで、Dがこのオーバーロードトリックをすることができ、表現力でC++/Hanaの組み合わせと少なくともこのユースケースで肩を並べるものかどうか見てみましょう。
インターフェース
コールサイトで期待されるものから始めましょう。
void main() {
EventSystem!("foo", "bar", "baz") events;
events.on!"foo"(() { writeln("foo triggered!"); });
events.on!"foo"(() { writeln("foo again!"); });
events.on!"bar"(() { writeln("bar triggered!"); });
events.on!"baz"(() { writeln("baz triggered!"); });
// events.on!"unknown"(() {}); // コンパイルエラー!
events.trigger!"foo";
events.trigger!"baz";
events.trigger("bar"); // 動的ディスパッチのオーバーロード
// events.trigger!"unknown"; // コンパイルエラー!
}
C++でやったのとなかなか良く似ています。 Dでの最も重要な違いは、コンパイル時文字列のための特殊なシンタックスがなく、普通の文字列がテンプレートパラメータとして渡せることです。 そのため、型でエンコードされた文字列によるステートレスなオブジェクトのアイデアは意味がありません (しかし実装することは技術的に可能です。下を見てください)。
EventSystem
構造体テンプレートはただイベント名を引数として渡すだけでインスタンス化でき、
すべてのメンバがデフォルト静的初期化子で事足り、ファクトリ関数を必要としません。
関数テンプレートon
とtrigger
はコンパイル時string
も受け入れます。
各文字列はシングルトークンのため、空の実行時引数リストと同じように、テンプレート引数リストの括弧は省略することができます。
この小さな構文の特徴はtrigger!("foo")()
をより乱雑さのないtrigger!"foo"
にします。
実行時とコンパイル時引数の区別は、標準の言語規則で決まっています。
_s
サフィックスがコンパイル時エンティティを暗示する特殊なケースを覚えておく必要はありません。
foo
やbaz
を静的ディスパッチで発生させることに注目してください。
しかしbar
を発生させるとき、実行時に評価されるイベント名を受け入れるオーバーロードも利用できるでしょう。
コンパイル時ルックアップ
ここから実装です:
struct EventSystem(events...) {
alias Callback = void delegate();
Callback[][events.length] callbacks_; // C++の std::array<std::vector<Callback>, sizeof...(events)> のようなもの
Phobosにはhana::map
と同等のものはありません。
しかしマップ内のすべての値は同じ型のため、その固定長配列を宣言し、イベント名を配列のインデックスに対応させるために
staticIndexOf
を使うことができます。
events...
としてこの構造体テンプレートが受け取る引数リストは型や、
テンプレート名、異なるプリミティブまたはコンポジット型の定数を含む様々なコンパイル時エンティティを含みます。
特に、文字列はうまく行くでしょう。
on
とtrigger
の実装もC++バージョンとよく似ていますが、
これは要求されたコールバックのvectorのインデックスを探し、そのインデックスを介して配列からvectorを取得します。
void on(string event)(Callback c) {
enum index = staticIndexOf!(event, events);
static assert(index >= 0,
"trying to add a callback to an unknown event: " ~ event);
callbacks_[index] ~= c;
}
void trigger(string event)() {
enum index = staticIndexOf!(event, events);
static assert(index >= 0,
"trying to trigger an unknown event: " ~ event);
foreach (callback; callbacks_[index])
callback();
}
C++バージョンでauto
を使用していた場所のenum
に注目してください。
この型も自動的に推論されますが、enum
を使うとindex
をコンパイル時に計算することが強制され、
static assert
でのみ使用することができます。
このルックアップ(staticIndexOf
によるコンパイル時線形探索)はインスタンス化あたり(つまりキーあたり)一度のみ行われ、
結びつける実行時コストはありません。
また、静的に判明するインデックスによる配列のインデクシングには実行時のオーバヘッドはありません。
trigger()のオーバーロード
そして、動的なキーを受け付けるようtrigger
をオーバーロードします。
テンプレートと非テンプレートオーバーロードの両方が隣り合って有るのは珍しいことではないため、
実行時ルックアップの実装のみに専念しましょう。
マップオブジェクトの構築中にある(自分で試すことができます)
組み込み連想配列を使うこともできますが、
少数のキーに対しては、すべてのキーと要求されたキーとを比較する線形探索でもそんなに悪くないはずです。
void trigger(string event) {
foreach (i, e; events) {
if (event == e) {
foreach (c; callbacks_[i])
c();
return;
}
}
assert(false, "trying to trigger an unknown event: " ~ event);
}
何が起きているのでしょう?
外側のforeach
は、string
のコンパイル時タプルであるevents
をイテレートしています。
これは本当はループではありません – コンパイラはこの静的foreach
の本文を一度タプルの各要素に対してペーストし、
その要素をe
に、インデックスをindex
に置き換えます。
結果としてまるで3つのif
が連続してあるようになります:
if (event == "foo") {
foreach (callback; callbacks_[0])
callback();
return;
}
if (event == "bar") {
foreach (callback; callbacks_[1])
callback();
return;
}
if (event == "baz") {
foreach (callback; callbacks_[2])
callback();
return;
}
1対1
これで全てです。 両方のバージョンをダウンロードし、しばらくコードを眺めコンパイルすることができます。 DバージョンはDMD、GDCまたはLDCでコンパイルできます。 私のマシン上では、C++のそれと比べて著しく速くコンパイルされました(最低10回の連続試行、すべてx86_64で-O3でコンパイル):
C++ (g++, clang++) | D (gdc, ldc2) | |
---|---|---|
GCC/GDC 6.2.0 | 0.98s | 0.45s |
Clang 3.9.1/LDC 1.1.0 | 1.09s | 0.67s |
小さなトイプログラムのコンパイルにかかる時間から重要な結論を引き出すことはできませんが、一般的に、 Dは高速なコンパイルができるよう設計されており、 取り込まれるライブラリコードの量がはるかに少ないというのは言うまでもありません。
そしてイベントの名前を打ち間違えた時どうなるか? Dフロントエンド(3つの主要なコンパイラ全てで共通)はエラーメッセージの量と有用性のバランスを取ろうとし、ここでは非常にうまく機能します。 我々が見ることになるのはこれだけです:
es.d(23): Error: static assert "trying to trigger an unknown event: unknown"
es.d(102): instantiated from here: trigger!"unknown"
C++ではどうでしょう?
私のシェルでは、static_assert
からの重要なメッセージを見るために大体100行の大きく強調されたメッセージをスクロールしなければなりませんでした
(ClangのハイライトはGCCのものよりは悪くなかったですが)。
昔はC++のエラーメッセージを人が消化できるものに変換するツールがありました。
あれはまだあるんでしょうか?
最後にチェックするのはパフォーマンスを失っていないかどうかです。
下のグラフで静的、動的ディスパッチのDバージョン vs. C++バージョンの比較を見ることができます。
Dバージョンは両方のケースで速いように見えますが、これは限られたマイクロベンチマークです。
Dの動的ルックアップは小さなセットを好む違うアルゴリズムを使っているためより良い結果を出しているのかもしれません。
静的ルックアップの~10%のスピードアップは、std::function
が各コールでdelegate
がするよりも少し多くの仕事をしている可能性が高いです
(アセンブリをチェックしてください: D、C++)。
より大きなアプリケーションではコールバックでなにか意味のあることをしていることには気づきません。
ので、2つのバージョンは同等に良い結果を出していると仮定しましょう。
結論として、Dでは複雑なライブラリに頼ることなくC++と同様の結果が得られました。 Dの実装は明快で、少ない言語の基本機能と標準ライブラリのみを使っています。 超絶クレバーになった感じはせず、新しいことを学ぶ必要もありませんでした。 しかしそれはまさに保守性の高く、理解しやすいコードに期待することではないですか?
本当により良いのでしょうか?それについて絶対的なものさしはありません、しかし他の要素が比較的重要でない場合は、 おそらく私は外部ライブラリに依存せず、コンパイルが速く、 なにかうまく行かなかった時に恐ろしいエラーメッセージを大量に生成しないソリューションに賛成するでしょう。
Dでのhana::map相当のもの
しかしDでhana::map
のように振る舞うデータストラクチャが必要になったらどうでしょうか?
結局、常に同じ型の値を持つとは限りませんし、整数インデックスを介して使うのはちょっとプロフェッショナルな感じがしません。
これは可能なのでしょうか?
前提条件
唯一の障害はコンパイル時エンティティ(型または値)はオーバーロードされたインデクシングオペレータへの実行時引数として使えないことであることがわかりました。
これを克服するため、Hanaが使うのと同じ技術をDに適用することができます。
単一の値または型でパラメータ化された構造体テンプレートを考えてみましょう。
私はそれをなんと呼ぶか知らないため、Entity
としましょう。
struct Entity(arg...)
if (arg.length == 1)
{
static if (is(arg[0])) {
alias Type = arg[0];
} else static if (is(typeof(arg[0]) T)) {
alias Type = T;
enum value = arg[0];
}
}
たとえばEntity!"foo"()
またはEntity!double()
と書くことで具体的な構造体型をインスタンス化し、オブジェクトを生成できます。
そのようなオブジェクトは状態を持ちませんが、異なるテンプレート関数のインスタンス化をされます。
普通の関数呼び出しと異なり、構造体オブジェクトの構築には括弧が必要で、Hanaの_c
や_s
サフィックスと比べて少し冗長になります。
より簡潔にするにはいくつか方法があり、パラメータ化されたenum
はそのひとつです:
enum c(arg...) = Entity!arg();
static assert(is(c!int.Type == int));
static assert(is(c!"foo".Type == string));
static assert(c!"foo".value == "foo");
static assert(c!42.value == 42);
ユーザ定義リテラルのよく似たシンタックスがstd.conv.octal
で最初に使われたと伝えられています。
値の格納
マップそのものに取り掛かりましょう。 異なる型の値を保持でき(std.typecons.Tupleのように)、 キーは型かコンパイル時の値で、このようにインスタンス化される何かがほしいです:
struct Bar {}
Map!(
"foo", int, // 文字列 "foo" をint型の値にマップします
Bar, string, // 型 Bar をstring型の値にマップします
"한", string[]) map; // 文字列 "한" をstring[]型の値にマップします
不連続なキーと値を分割し、値の記憶域を宣言する必要があります:
struct Map(spec...) {
alias Keys = Even!spec;
alias Values = Odd!spec;
Values values;
Even
とOdd
は標準のものではありませんが、素早く実装することができます:
template Stride(size_t first, size_t stride, A...) {
static if (A.length > first)
alias Stride = AliasSeq!(A[first], Stride!(stride, stride, A[first .. $]));
else
alias Stride = AliasSeq!();
}
alias Odd(A...) = Stride!(1, 2, A);
alias Even(A...) = Stride!(0, 2, A);
values
は型リストValues
のすべての型の要素からなる組み込みタプルです。
これはmap.values[2]
のように整数定数を使ってインデックスできます。
先ほど見た「静的foreach
」を使いイテレートすることもできます。
これはキーまたは値を自由にイテレートすることができるということを意味します。
やってみましょう:
// 構造体リテラル構文で初期化
auto map =
Map!("foo", int, Bar, string, "한", string[])(
42, "baz", ["lorem", "ipsum", "dolor"]);
// キーでイテレート
foreach (K; map.Keys)
writeln(K.stringof);
// 値の型でイテレート
foreach (V; map.Values)
writeln(V.stringof);
// 値でイテレート
foreach (value; map.values)
writeln(value);
オペレータ
Hanaは与えられたキーがマップに存在するかをチェックするための自由な関数テンプレートcontains
を提供します。
Dでは、ふつうin
オペレータが使われます。
これはopBinaryRight
を実装することでオーバーロードすることができます。
それはコンパイル時の情報(引数の型)のみに依存するため、static
として宣言できます:
static bool opBinaryRight(string op, Key...)(Entity!Key key)
if (op == "in")
{
enum index = staticIndexOf!(Key, Keys);
return index >= 0;
}
動作するのを見てみましょう:
static assert(c!"foo" in map);
static assert(c!Bar in map);
static assert(c!"한" in map);
static assert(c!42 !in map);
コンパイル時キーを使って値をルックアップするにはstaticIndexOf
を使い、もしキーが見つからなかった時は意味のあるメッセージを添えます:
private template IndexOf(alias Key) {
enum IndexOf = staticIndexOf!(Key, Keys);
static assert(IndexOf >= 0,
"trying to access a nonexistent key: " ~ Key);
}
これはインデクシングオペレーターたちを実装するのに使えます。
なぜ1つではなく「たち」と言ったのか?
operator[]
が代入演算子のように更に操作できる左辺参照を返すC++とは違い、
Dでリードオンリーインデクシング、
単純な代入のインデクシング、
複合代入のインデクシング、
のオペレータは別々にオーバーロードされます。
auto opIndex(Key...)(Entity!Key key) const {
return values[IndexOf!Key];
}
auto opIndexAssign(T, Key...)(auto ref T value, Entity!Key key) {
return values[IndexOf!Key] = value;
}
auto opIndexOpAssign(string op, T, Key...)(auto ref T value, Entity!Key key) {
return mixin(`values[IndexOf!Key] ` ~ op ~ `= value`);
}
すべてのケースで、T
とKey
は引数の型から推測されます。
IndexOf!Key
の評価とvalues
のインデクシングはともにコンパイル時に完了します。
テストしてみましょう:
// コンパイル時ルックアップ、実行時代入
map[c!"foo"] = 42; // opIndexAssign
map[c!Bar] = "baz";
map[c!"한"] ~= "lorem"; // opIndexOpAssign!"~"
map[c!"한"] ~= "ipsum";
map[c!"한"] ~= "dolor";
// コンパイル時ルックアップ、実行時比較
assert(map[c!"foo"] == 42); // opIndex
assert(map[c!Bar] == "baz");
assert(map[c!"한"] == ["lorem", "ipsum", "dolor"]);
パフォーマンス
これで全部です!
我々はhana::map
のように、実行時風シンタックスでコンパイル時にルックアップとイテレーションができるマップを手にしました。
それは何ら特別なものではありません。
ただの日常の何でもないワンライナーの集まりです。
実装したMap
を使ってEventSystem
を修正してみたいかもしれません。
我慢できないなら、同じアーカイブに実装があります。
このバージョンと前のバージョンの間でコンパイル時間の計測可能な違いは私には検出できません。
そして下のグラフでわかるように、実行時のパフォーマンスも同じです。
問題はこれがDでの正しいデザインチョイスかどうかです。
オペレータ[]
とin
はget
やcontains
のような「普通の」テンプレート関数で簡単に置き換えられる糖衣構文で、
厄介なc!arg
を作る必要はありません。
コンパイル時エンティティのリストや、値のタプルの要素のイテレーションは組み込み言語機能で、どちらも必要ありません。
要約
これはDでHanaのエミュレートを試みる最初のものではありません。 たとえば型オブジェクトに関するこの投稿 を見てください、型のクイックソートの興味深い例があります。 普通の実行時のコードのように見えるメタ関数を作るHanaのトリックはDにも適用できますが、Dの組み込みメタプログラミング機能のおかげで、 それでコードの可読性やプログラマの生産性が大幅に向上したりはしません。 新しいCTFEエンジン が完成しマージされるまでは、おそらく無用に ビルド時間を長くする だけです。
トークはC++20の提案された機能で可能になることのいくつかの例で締めくくられます。 名前付き引数、型リストのforeach、リフレクションによるJSONへのシリアライズ。 その全てが何年も前にDで、より洗練された構文に、より少ない知的努力によって、メタプログラミングからC++でそれをする時の面白さをすべて取り除いた上で可能だったことなのであなたは驚かないかもしれません。