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()
のreturn
でv
がコピーできないために起きており、ここでのv
はstd::unique_ptr
のconst
左辺値なので当然コピーはできません。しかし、コードのどこでもfunc()
をstd::unique_ptr
で呼んではいません、なぜこれはエラーになるのでしょうか?
起きていること
コードをよく見ると、直接的ではないもののfunc()
をstd::unique_ptr
で呼んでいる箇所が1つだけあります。それは、std::invocable<decltype(func<T>), T>
という式で、これはstd::invocable
コンセプトによってT
によってfunc<T>()
が呼び出し可能かを調べています。関数f()
の引数型T
経由で、ここでstd::unique_ptr
がfunc()
にわたっています。
(コンセプトはこの様に使用した時にそれ単体で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 if
はC++17で導入された新しめの言語機能であり、ユーザーフレンドリーになってることが期待されます。しかし実際にはconstexpr if
はif
文の一種でしかなく、構文的には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の場合はこちらに来ることが期待される ... } }
この局所的なコードのコンパイルにおいて、コンパイル時には次の順番で処理が実行されます
この時、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
によってF
がArgs
で呼び出し可能かどうかを調べています。
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_ptr
がstd::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 if
のfalse
となる(選ばれなかった)ステートメントはインスタンス化されません。そのため、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::value
がbool
型定数であるメタ関数を任意個数受けて、その&&
/||
を求めるメタ関数ですが、その評価に当たっては短絡評価が規定されています。そのため、条件をメタ関数に落とすことができるならば、この方法を用いてもインスタンス化を短絡評価させることができます。
ただ、この例はあまり適切ではない例で、func<T>
の出現(インスタンス化)を遅延させるために別の型に埋め込まなければならなくなっており、どのみち名前空間に不要なものがばらまかれてしまっています・・・
最後にこの古のメタ関数にたどり着いたところで、冒頭の例の問題とはまさにこれらのメタ関数が導入された理由の一つでもある事に気づきます。つまり、T1::value && T2::value && T3::value && ...
というようにすると、この式の評価よりも前にTn::value
のインスタンス化が要求されてしまい、そのいずれかがハードエラーを起こす場合にまさに冒頭の例と同じ問題にぶつかります。std::conjunction
およびstd::disjunction
はその内部で先頭から1つづつTn::value
を呼んで行き結果が確定したところでインスタンス化と評価を停止することで、インスタンス化の短絡評価を行うためのユーティリティです。
constexpr if
とコンセプトという真新しい言語機能に惑わされてしまっただけで、本質的な問題はTMPの時代から変わっていなかったわけでした・・・。