コンパイルエラーと仲良くなるイディオム


はじめに

色々とD言語の標準ライブラリなどを読んでいて勉強になった、よく見かけるイディオムを1つご紹介したいと思います。

今回のネタは、よくテンプレートで制約に使われる「isXXX」という名前のテンプレートを作るパターンとその中身の話です。

理解するとコンパイルエラーととても仲良くなれるイディオムなので、一度理解するとメタプログラミングが捗るかもしれません。
また、「型がどんなものか調べる」という機能をまとめたものなので、C++20でconceptとして採択予定の機能とも非常によく似ています。

TL;DR

  • 意味の分からないコンパイルエラーは生産性を下げる
  • is式で「コンパイルエラーを握りつぶす」という発想
  • 実際に判定するときののテンプレート
template isHoge(T)
{
    enum isHoge = is(typeof({
        // Tを使って好きに書く
    }));
}

紹介するパターンの例

早速ですが、以下を見て、もうわかるよ!という強い方々は以降流し読みで大丈夫です。

よくあるパターン
template isAddable(T)
{
    enum T = is(typeof({
            T a = void;
            T b = void;
            T x = a + b;
        }));
}

T add(T)(T a, T b) if (isAddable!T)
{
    return a + b;
}

亜種として以下のように書かれている場合もありますが、めちゃめちゃ細かい説明が増えるので、なんでこうなっているのかは今回触れません。(過去の不具合やら評価時のコンテキストやら)
とりあえず上の例をベースに書いていきますが、やってることはほぼ同じだと思ってください。

稀に見るパターンその1
template isAddable(T)
{
    enum T = is(typeof((inout int n = 0){
            T a = void;
            T b = void;
            T x = a + b;
        }()));
}
稀に見るパターンその2
template isAddable(T)
{
    enum T = __traits(compiles, {
            T a = void;
            T b = void;
            T x = a + b;
        });
}

パターンを導入するモチベーション

D言語には、ダックタイピングやメタプログラミングをサポートする機能が数多くあります。
今回のパターンは、主にtemplateを使ってダックタイピングする際に活躍します。

例として以下のような足し算をする関数addを作ってみます。
これは引数がテンプレートでTとしてパラメータ化されていて、数値でも文字列でも構造体でも何でも渡すことができるよ、という関数になっています。

何でも渡せる
T add(T)(T a, T b)
{
    return a + b;
}

問題点

こちらのadd関数、文字列を渡せばエラーになりますし、実際には「足し算ができる型」しか受け付けたくない状況です。

そしてエラーになると大抵「演算子オーバーロードがなんとかかんとか!」と怒られます。
残念ながら、使ってる側としては意味が分からないです。

ほかにも、特定の名前の関数を実装しているか?その引数は何?戻り値は何?newしたいんだけど?などなど、関数の処理内容によって色々な要求があります。

こういうのはコンパイル時に判定して適切なエラーにしたいよね!
それ、D言語なら簡単にできるよ!ということです。

テンプレート制約とは

D言語では、テンプレートを使うときに「型が妥当なものか静的(コンパイル時)にチェックするif文」を書くことができます。
これをテンプレート制約(Template Constraints)と呼びます。

ずばり、制約が満たされないとコンパイルエラーになる、という機能です。
内部的によくわからないエラーになる前に引っ掛けて、引数の型不一致で呼び出せなくしてしまおう、という考えです。

実際に書くと以下のようになります。

floatかdoubleだけを受け付ける
T add(T)(T a, T b)
    if (is(T == float) || is(T == double)) // 何か増えた
{
    return a + b;
}
足し算できる型だけを受け付ける
T add(T)(T a, T b)
    if (is(typeof({ T a = void; T b = void; T x = a + b; }))) // 長くなった
{
    return a + b;
}

中身は後述しますが、これで一応型に対するチェックが書けています。

具体的な型をチェックする方はもう見たままですね。
足し算のほうが汎用的で柔軟な記述ができるので、こちらをメインで読み解いていきます。

構文と条件式

テンプレート制約自体は、やや珍しい位置にifが書いてある以外、おおむね普通のif文と同じです。

上記の例はわざとインデントしませんでしたが、少しインデントを変えてやれば多少読みやすくなります。
(インデントすれば良いわけではないけど)

T add(T)(T a, T b)
//関数のシグネチャはそのままなので置いといて

//ここから下を丸ごとif文だと思うと、条件を満たすときだけ処理する、と読めなくもない
if (is(typeof({
    // 中身は実際に使うときと同じようなことが書いてある
    T a = void;
    T b = void;
    T x = a + b;
})))
{
    return a + b;
}

更に条件式だけ抜き出して、多めにインデントを付けると以下のような構成になっています。

is(
    typeof(
        {
            T a = void;
            T b = void;
            T x = a + b;
        }
    )
)

条件式の意味

今回のパターンでは、条件式を、is式、typeof式、関数リテラル、の3つの組み合わせで実現しています。

結果だけ言ってしまえば、書いた処理がコンパイルできればtrueになる、コンパイルできないならfalseになる、というイディオムです。
C++にもSFINAEとか色々ありますが、それをもうめっちゃ汎用的にしたやつです。

ベタ書きすると何が何やらなので、順を追って読んでいきます。

関数リテラル

条件で一番重要な以下の部分です。

関数リテラル
{
    T a = void;
    T b = void;
    T x = a + b;
}

これは、T型の変数を定義し、足し算し、結果を型Tの変数に代入する、という処理と同じですね。なんとなく{}でスコープ切っているだけに見えます。

当然ですが、ここにはnewでも添え字アクセスでも好きに書いて良く、それができないとコンパイルエラーになります。(重要)

この記述でD言語的には、代入の右側など、式として評価する場所でzれば「デリゲートを作る関数リテラル」になりますが、書き方が色々あるので、細かくは以下を参照してください。
https://dlang.org/spec/expression.html#function_literals

今回のパターン的にはここにTの使い方を色々書いて、満たすならデリゲートができる(当たり前)、満たされないならコンパイルエラーになる(当たり前)、という動作を活用していきます。

補足
T a = void;

普段はあまり使いませんが、これは変数を宣言しても中身を初期化しないよ、という記述です。

D言語は宣言した変数はすべて中身が初期化されます。intなら0、doubleならNaN、クラスならnullです。
構文的には、未初期化=よくわからんものを入れておけ、というニュアンスですかね。

関数としては「値を使う」だけで「変数が宣言できる」という条件は不要なので = void;と書くことでそのあたりのチェックをスキップしているイメージです。
たとえば構造体の変数を宣言するとコンストラクタが走りますが、コンストラクタがprivateだったりするとそれだけでコンパイルエラーになってしまうので、コンストラクタ避けですね。

厳密にはもうちょっと適切な書き方がありますが、多くの場合これで十分なのでこの書き方が多いです。

typeof式

関数リテラルが収まっている typeof(...) の部分です。

静的評価をする上で、このtypeofは関数リテラルの型を取得しているだけです。
内部的なことはよく知らないので簡単に済ませますが、以下の動作をしています。

  • 関数リテラルがコンパイルエラーになる場合
    • _error_というエラー型が得られる
      • これを放っておくとコンパイラがエラー吐いて激おこ
  • 関数リテラルがコンパイルエラーにならない場合
    • void function() pure nothrow @nogc @safeという型が得られる
      • 書いた内容から推論されるので場合によって異なる

is式

typeofが収まっている is(...) の部分です。

これは型などいろいろなものを渡して、性質/特性を調べて、trueやfalseを返す機能があります。

同時に、_error_型を渡したときfalseを返してコンパイルエラーにならないよう封じ込めてくれます。
その他普通の型はtrueを返します。

細かい書き方は本当にたくさんあって覚えられないので、以下のページを参照してください。
ようこそ!ここが闇の入り口です!
https://dlang.org/spec/expression.html#is_expression

組み合わせる

さて、ここまでの機能を組み合わせることで「関数リテラルがコンパイルできるかどうか」をtrue/falseに変換できるようになります!

任意の型を使って処理を書いた関数リテラルを作り、

  • コンパイルエラーなら_error_型が生成されるが、is式で握りつぶしてfalseにする
  • 正常なら適当な型が生成されてis式がtrueを返す

ということです。

これで先ほど挙げた以下の例も読めるはず!すごい!

is(
    typeof(
        {
            T a = void;
            T b = void;
            T x = a + b;
        }
    )
)

そしてパターン化へ

D言語では、上記の条件式の部分を「template」として抜き出すことができます。
ひとまず書けるようにしたら次は再利用性、扱いやすくしましょう、ということですね。

実際に抜き出してみる

ここで冒頭の例に戻ります。

よくあるパターン
template isAddable(T)
{
    enum isAddable = is(typeof({
            T a = void;
            T b = void;
            T x = a + b;
        }));
}

T add(T)(T a, T b) if (isAddable!T)
{
    return a + b;
}

これで無事、足し算ができる型のみを受け付けるadd関数がキレイに書けました!

抜き出したもの

is(...)の部分をtemplateの中に納めただけです。

このisAddableテンプレートは、型Tを受け取り、T x = a + b;ができればtrue、そうでなければfalseになるテンプレートになっています。

テンプレート制約に限らず、単体テストでstatic assertを書くときや、static ifの条件にも使えます。便利。

埋め込むときのポイントは以下の2点です。

  • テンプレートの中はenumで宣言します
    • 結果はコンパイル時定数で、かつ型を省略してもisboolを返すので自動推論されるため
  • テンプレートの評価部分がテンプレート名と同じにします
    • これにより利用時の記述が簡略化できます
      • isAddable!T.isAddable.isAddableが省略できる

公式の説明

さて、こちら標準ライブラリの中ではstd.traitsなどでよく見かけるものです。

メタプログラミングは標準ライブラリも後押ししてくれているわけですが、
これらは公式で何かパターン化されているのでしょうか?

D言語公式サイトで説明を探すと、まさに主なターゲットとなる「テンプレート制約」のページに記載がありました。
下記のページで同様のテンプレート作ると良いよ、と言っています。

名称について

ところがこちら、よく見る割に呼称不明の謎パターンになってきています。(個人の感想です)

上記URLを読んでみても名前は見つからず、色々と不便なので暫定として名前が決まればなーと思っています。
(説明しづらいったらない)

具体的には、公式のHTMLやC++の類似機能から「Conceptテンプレート」と呼ぶのが良いかなと思っていますが、他に良い名前がある方、正式名称をご存知の方がいらっしゃれば教えてください!

まとめ

まとめると「意図的にコンパイルエラーを起こしてis式でfalseに変換するイディオムがある」というお話でした。

幾分書きやすいSFINAEという感じですが、D言語が強いと言われる側面が何となくでも伝わったら幸いです。

そして実際にパターンとして押さえておくポイントは以下のあたりになります。

  • 型の判定に専用のテンプレートを作る
    • テンプレートの名前はisXXXXにしておく
    • 判定は is(typeof({ ... })) のパターンを使えば、使い方をそのまま書くだけで良い
    • enum テンプレート名 = is(...)と書くことで使うときが楽
      • テンプレートをインスタンス化したらそのまま使えるので記述が減る
  • テンプレートを作ったら、テンプレート制約などと組み合わせて要所でコンパイルエラーにする

以上!