[C++] constexpr ifとコンセプトと短絡評価と

constexpr if(構文としてはif constexpr)の条件にはboolに変換可能な任意の定数式を使用できます。複数の条件によって分岐させたい場合、自然に&&もしくは||によって複数の条件式をつなげることになるでしょう。そしてその場合、条件式には左から右への評価順序と短絡評価を期待するはずです。

auto func(const auto& v) {
  return v; // コピーされる
}

template<typename T>
void f(T&&) {
  // Tが整数型かつfuncで呼び出し可能
  // Tが整数型ではない場合は右辺の条件(funcの呼び出し可能性)はチェックされないことが期待される
  if constexpr (std::integral<T> && std::invocable<decltype(func<T>), T>) {
    ...
  } else {
    // Tがunique_ptrの場合はこちらに来ることが期待される
    ...
  }
}

int main() {
  std::unique_ptr<int> up{};

  f(std::move(up)); // ng
}

エラーはfunc()returnvがコピーできないために起きており、ここでのvstd::unique_ptrconst左辺値なので当然コピーはできません。しかし、コードのどこでもfunc()std::unique_ptrで呼んではいません、なぜこれはエラーになるのでしょうか?

起きていること

コードをよく見ると、直接的ではないもののfunc()std::unique_ptrで呼んでいる箇所が1つだけあります。それは、std::invocable<decltype(func<T>), T>という式で、これはstd::invocableコンセプトによってTによってfunc<T>()が呼び出し可能かを調べています。関数f()の引数型T経由で、ここでstd::unique_ptrfunc()にわたっています。

(コンセプトはこの様に使用した時にそれ単体で1つの式になり、bool型のprvalueを生成する定数式となります)

とはいえ、その直前(左辺)の条件ではstd::integral<T>によってTが整数型であることを要求しており、std::unique_ptrは当然整数型ではないのでその条件はfalseとなり、短絡評価によってstd::invocable<decltype(func<T>), T>は評価されないことが期待されます。しかし、ここでfunc()std::unique_ptrがわたってエラーが起きているということは、どうやらその期待は裏切られている様です。

このことは、std::integral<T>を恒偽式に置き換えてやるとよりわかりやすくなります。

template<typename T>
void f(T&&) {
  if constexpr (false && std::invocable<decltype(func<T>), T>) {
    ...
  } else {
    // Tがunique_ptrの場合はこちらに来ることが期待される
    ...
  }
}

どうやらconstexpr ifの条件式では短絡評価が起きていない様に見えます。

そしてこのことは主要な3コンパイラで共通していることから、どうやら仕様に則った振る舞いの様です。

if constexpr (A && B)としたら、A -> Bの順番で評価されてほしいしA = falseならBは評価されないでほしい気持ちがあります(これは||でも同様でしょう)。そうならないのが自然な振る舞いなはずはありません・・・

constexpr ifの扱い

constexpr ifC++17で導入された新しめの言語機能であり、ユーザーフレンドリーになってることが期待されます。しかし実際にはconstexpr ifif文の一種でしかなく、構文的にはconstexprがあるかないか程度の違いであり、その条件式の扱いはifのそれに準じています(そのため、実はcostexpr ifにも初期化式が書けます)。では、ifの条件式の扱いがそうなっているのでしょうか?

ifの条件式に関しては、文脈的にbool変換可能な式という程度にしか指定されておらず、constexpr ifに関してはそれが定数式であることが追加で要求されるくらいです。つまり、if/if constexprの条件式で短絡評価が起こるのかにはifは関与していません。

文脈的にbool変換可能な式というのはめちゃくちゃ広いですが、今回問題となるのはその中でも&&||の2つだけです(前述のように演算子オーバーロードは考慮しません)。実は&&||の組み込み演算子が短絡評価するかどうかは実装定義だったりするのでしょうか?

これもそんなはずはなく、(A && BもしくはA || Bとすると)どちらの演算子もそのオペランドの評価順序はA -> Bの順で評価されることが規定され、なおかつ短絡評価に関してもきちんと規定されており未規定や実装定義などではありません。C++適合実装は、場所がifであるかどうかに関係なく、A && BもしくはA || Bという式の実行において、式Aの結果に応じた短絡評価を行わなければなりません。

さて、ここまで掘り返しても冒頭のエラーの原因がわかりません。一体何が起きているのか・・・?

コンセプトはテンプレート(重要!!)

回り道をしましたが、実はここで起きていることはそれら以前の問題です。

template<typename T>
void f(T&&) {
  // Tが整数型かつfuncで呼び出し可能
  // Tが整数型ではない場合は右辺の条件(funcの呼び出し可能性)はチェックされないことが期待される
  if constexpr (std::integral<T> && std::invocable<decltype(func<T>), T>) {
    ...
  } else {
    // Tがunique_ptrの場合はこちらに来ることが期待される
    ...
  }
}

この局所的なコードのコンパイルにおいて、コンパイル時には次の順番で処理が実行されます

  1. f()インスタンス
  2. cosntexpr ifの条件式のインスタンス
  3. constexpr ifの条件式の評価
  4. constexpr ifの分岐

この時、2番目の条件式のインスタンス化においては主に、テンプレートパラメータTに依存するもののインスタンス化が行われます。インスタンス化が必要なものとはつまりテンプレートのことであり、そこにはコンセプトも含まれています。そう、コンセプトはテンプレートの一種なので、単体の定数式として評価する前にコンセプトそのもののインスタンス化が必要となります。

この例でのコンセプトのインスタンス化では、std::integral<T>std::invocable<decltype(func<T>), T>の2つのコンセプトのインスタンス化が行われます。前者は今回関係ないことが分かっているので、後者を詳しく見てみます。

まず、std::invocableの定義は次のようになっています

template<class F, class... Args>
concept invocable = requires(F&& f, Args&&... args) {
  invoke(std::forward<F>(f), std::forward<Args>(args)...);
};

コンセプトの定義は基本的にはこのrequires式内に求める要件式を並べて行い(また、複数のrequires式をつなげることもでき)、コンセプトのインスタンス化に伴って、その字句順(requires式の並び順)(たぶん)にテンプレートパラメータの置換(substitution)とテンプレートのインスタンス化(以降単にインスタンス化)が行われていきます。その際、requires式内部ではインスタンス化に伴ってill-formed(コンパイルエラー/ハードエラー)になるようなことが起きたとしても、そのrequires式がその式の評価時にfalseとなるだけでハードエラーを起こしません。

requires式のインスタンス化とその式の値の評価も、注意深く規格署の記述を読むと、テンプレートパラメータ置換およびインスタンス化と評価は段階が異なることが読み取れ(る気がし)ます。

この時、次のような記述([expr.prim.req]/5)によって、インスタンス化された式がハードエラーを起こす場合がある事が指定されています

requires式内の要件式に無効な型や式を含むものが含まれていて
それがtemplated entityの宣言内に表示されない場合
プログラムはill-formed

templated entityというのはテンプレート定義内の何かの事で、ここではrequires式に含まれる色々なものの事です。その宣言内に含まれない場合というのは要するに、コンセプト定義に直接見えていないような場合ということで、requires式に書かれた各要件式の内部で、別の式等が何かハードエラーを起こす場合のことを言っています(とおもいます・・・)。

さて、std::invocableは1つのrequires式だけからなるコンセプトで、その1つのrequires式は1つの単純要件だけを持ち、それはstd::invokeによってFArgsで呼び出し可能かどうかを調べています。

std::invocable<decltype(func<T>), T>の場合、最終的にはfunc<std::unique_ptr>()が呼び出し可能かが問われることになります。そして、そのチェックは宣言のみのチェックではなく、invoke(std::forward<F>(f), std::forward<Args>(args)...);という式の有効性のチェックとなるため、テンプレートパラメータの置換とそのインスタンス化が発生します。

func<std::unique_ptr>()インスタンス化されると、そのreturn文でコピーができないことからハードエラーを起こしますが、それはまさにinvocableコンセプト定義のrequires式の要件式に直接現れずにその呼び出し先の関数本体内で発生するため、これはrequires式の範囲外となりそのままハードエラーになります。

冒頭の例の謎のエラーはまさに、この場合に起こるエラーです。つまりは、式として短絡評価されるか以前のところ(テンプレートのインスタンス化)でエラーが起きています。

この場合にconstexpr ifに求めるべきだったのは、その条件式においてその評価順序に応じたインスタンス化と短絡評価によるインスタンス化そのもののスキップだったわけです。当然そんなことはC++17でも20でも要求されておらず、現在のコンパイラはそのようなことはしてくれません。想像ですが、インスタンス化そのものがスキップされると定義の存在有無が変化し、それはひいてはODRに関係してくる気がします。

回避手段

とはいえ、constexpr ifコンパイル時の条件によってインスタンス化の抑制を行うものなので、その条件式はほとんどの場合に何かしらのテンプレートパラメータに依存することになるでしょう。テンプレートのインスタンス化に伴うハードエラー回避のために短絡評価を期待するのはある種自然な発想であるため、この問題は割と深刻かもしれません(それでも、実行時ifと異なり問題はコンパイル時の謎のエラーとして報告されるのでマシではあります)。

そのため、短絡評価を期待通りに行ってもらう方法を考えてみます。

関数に制約をかける

この場合の例にアドホックな解決策ですが、呼び出そうとする関数が適切に制約されていることによってstd::invokeの呼び出し先がなくなれば、このような場合にstd::invokeの内部の呼び出し先内でのエラー発生を回避できます。

template<typename T, typename F>
void f(T&&, F&&) {
  // Tが整数型かつfuncで呼び出し可能
  // Tが整数型ではない場合は右辺の条件(funcの呼び出し可能性)はチェックされないことが期待される
  if constexpr (std::integral<T> && std::invocable<F, T>) {
    
  } else {
    // Tがunique_ptrの場合はこちらに来ることが期待される
  }
}

int main() {
  std::unique_ptr<int> up{};

  f(std::move(up), [](const std::copyable auto& v) { return v; }); // ok
  f(std::move(up), [](const               auto& v) { return v; }); // ng
}

例示のために少し書き換えています。

この場合、std::invocableコンセプト定義内のrequires式内のstd::invokeによる制約式では、呼び出すべき関数が見つからない(std::unique_ptrstd::copyableではない)ことからstd::invokeそのものがエラーになり、それはrequires式の範囲内なのでハードエラーになる代わりにそのrequires式の式としての評価結果がfalseになり、std::invocable<F, T>falseに評価されます。

とはいえ、このような回避手段はこの問題を理解したうえで適用可能であるかを調べる必要があり、いつでも可能な汎用的なソリューションではありません。

条件式を分ける

&&でつながれた条件式であれば複数のif constexpr文に分割することができるかもしれません。

auto func(const auto& v) {
  return v; // コピーされる
}

template<typename T>
void f(T&&) {

  if constexpr (std::integral<T>) {
    // Tがunique_ptrの場合はここには来ない
    if constexpr (std::invocable<decltype(func<T>), T>) {
      ...
    }
    ...
  } else {
    // Tがunique_ptrの場合はこちらに来ることが期待される
    ...
  }
}

int main() {
  std::unique_ptr<int> up{};

  f(std::move(up)); // ok
}

costexpr iffalseとなる(選ばれなかった)ステートメントインスタンス化されません。そのため、costexpr ifがネストしていれば疑似的に短絡評価のようになります。

この場合は、分岐が増えることによって考慮すべきパスが増加することに注意が必要です

template<typename T>
void f(T&&) {

  if constexpr (std::integral<T>) {
    // Tがunique_ptrの場合はここには来ない
    if constexpr (std::invocable<decltype(func<T>), T>) {
      ...
      return;
    }
    ...
  } else {
    // Tがunique_ptrの場合はこちらに来ることが期待される
    ...
    return;
  }

  // ここに来る場合がある
  std::unreachable();
}

あと||はどうしようもありません。ド・モルガンで頑張れる可能性はありますが・・・

requires式と入れ後要件を使う

@yohhoyさんご提供の方法です。

auto func(const auto& v) {
  return v; // コピーされる
}

template<typename T>
void f(T&&) {

  if constexpr (requires { requires std::is_integral_v<T>; requires std::invocable<decltype(func<T>), T>; }) {
    // Tがunique_ptrの場合はここには来ない
    ...
  } else {
    // Tがunique_ptrの場合はこちらに来る
    ...
  }
}

int main() {
  std::unique_ptr<int> up{};

  f(std::move(up)); // ok
}

実はrequires式はコンセプト定義やrequires節の外側でも書くことができて、その場合も内部の要件をチェックした結果のbool型のprvalueを生成する定数式になります。そのため、costexpr ifの条件式でも使用することができます。

上記のrequires式を取り出して見やすくすると

requires { 
  requires std::is_integral_v<T>;
  requires std::invocable<decltype(func<T>), T>;
}

requires式内部でrequires expr;のようにしているこの書き方は入れ子要件の制約と呼び、そのインスタンス化及び評価の順序は、字句順すなわちこのようにフォーマットした場合の上から順番に行われます。そのため、実質的にこれは&&条件を書いたのと同様になっており、なおかつコンセプトのrequires式はその要件を満たさない(falseに評価された)式が出現するとそこでインスタンス化と評価を停止するため短絡評価が期待できます。

ただ、短絡評価に関しては、「requires式の結果を決定する条件に出会うと、インスタンス化と評価を停止する」のように規定されており短絡評価を指定しているかは微妙です。実際、MSVCは短絡評価をしないような振る舞いをする場合があります(上記の例ではMSVCでも回避可能でしたが)。

また、これはも||で使用できません。ド・モルガンで頑張ることはできるかもしれませんが。

コンセプトに埋め込む

@yohhoyさんご提供の方法その2です。

前項の方法のrequires式を1つのコンセプトに纏めてしまう方法です。

#include <iostream>
#include <concepts>
#include <memory>

auto func(const auto& v) {
  return v; // コピーされる
}

// constecpr if条件を抽出したコンセプト定義
template<typename T>
concept C = std::integral<T> && std::invocable<decltype(func<T>), T>;

template<typename T>
void f(T&&) {

  if constexpr (C<T>) {
    // Tがunique_ptrの場合はここには来ない
  } else {
    // Tがunique_ptrの場合はこちらに来ることが期待される
  }
}

int main() {
  std::unique_ptr<int> up{};

  f(std::move(up)); // ok
}

コンセプト定義内でrequires式を用いずに直接bool型の定数式を指定することもできます。この場合のインスタンス化と評価の順序の規定は複雑ですが、コンセプトの定義内でこのように書いている場合は&&でも||でも同様に、字句順あるいは左から右にインスタンス化と評価が進行します(またこれは、requires式が複数ある場合のインスタンス化と評価の順序と同様です)。

さらに、requires式内部ではなくコンセプト定義で直接このように書いた場合は、&&||の結果に従った適切な短絡評価が規定されています。従って、この場合はstd::integral<T>falseと評価されればstd::invocable<decltype(func<T>), T>は評価もインスタンス化もされません。

前項のrequires式と入れ後要件による方法は移植可能性に懸念があるのと、||で使用できないのが問題でしたが、この方法であればそれらの問題点はすべて解消され、インスタンス化と評価時に短絡評価が保証されます。

std::conjunction/std::disjunctionを使用する

前項のコンセプトに切り出してしまう方法はほぼ完璧な方法ですが、コンセプトが名前空間内でしか定義できないことから、名前空間を汚すのが忌避されるかもしれません。短絡評価を行いつつ条件式はその場での使い捨てにしたい場合の方法として、古のTMPのユーティリティであるstd::conjunction/std::disjunctionを使用する方法があります。

#include <iostream>
#include <concepts>
#include <memory>

auto func(const auto& v) {
  return v; // コピーされる
}

// func<T>のインスタンス化を遅延させるラッパ型
template<typename T>
struct C {
  static constexpr value = std::invocable<decltype(func<T>), T>;
};

template<typename T>
void f(T&&) {

  if constexpr (std::conjunction_v< std::is_integral<T>, C<T> >) {
    // Tがunique_ptrの場合はここには来ない
  } else {
    // Tがunique_ptrの場合はこちらに来ることが期待される
  }
}

int main() {
  std::unique_ptr<int> up{};

  f(std::move(up)); // ok
}

std::conjunctionおよびstd::disjunctionは、T::valuebool型定数であるメタ関数を任意個数受けて、その&&/||を求めるメタ関数ですが、その評価に当たっては短絡評価が規定されています。そのため、条件をメタ関数に落とすことができるならば、この方法を用いてもインスタンス化を短絡評価させることができます。

ただ、この例はあまり適切ではない例で、func<T>の出現(インスタンス化)を遅延させるために別の型に埋め込まなければならなくなっており、どのみち名前空間に不要なものがばらまかれてしまっています・・・

最後にこの古のメタ関数にたどり着いたところで、冒頭の例の問題とはまさにこれらのメタ関数が導入された理由の一つでもある事に気づきます。つまり、T1::value && T2::value && T3::value && ...というようにすると、この式の評価よりも前にTn::valueインスタンス化が要求されてしまい、そのいずれかがハードエラーを起こす場合にまさに冒頭の例と同じ問題にぶつかります。std::conjunctionおよびstd::disjunctionはその内部で先頭から1つづつTn::valueを呼んで行き結果が確定したところでインスタンス化と評価を停止することで、インスタンス化の短絡評価を行うためのユーティリティです。

constexpr ifとコンセプトという真新しい言語機能に惑わされてしまっただけで、本質的な問題はTMPの時代から変わっていなかったわけでした・・・。

参考文献

この記事のMarkdownソース