constexprで書けない純粋な関数

今週はThe continuing evolution of C++の講演を聞いてきました。C++20では

  • concept
  • module
  • contract

を入れるというような話もしていました。どれもよい機能です。conceptは入ったらいいと皆が思うからこそ、微妙な点で意見が食い違ってなかなか入らないのですよね。さすがにそろそろ入ってほしいです。moduleはあまり話題になっていないので知らなかったです。コンパイルが速くなる誰もがうれしい機能のはずですが、つまみ食い的に使って楽になるような機能ではないことが話題に上らない理由なんでしょうか。inline変数などヘッダ前提の機能も着実に追加されていったりしていますし、ヘッダスタイルはまだまだ残りそうです。contractはプログラミングパラダイムを変えうるわけですが、だんだんと追加していくタイプの機能になるのでしょうか。


さて、先週は副作用を利用して関数の中身がどうなっているかを調べる話を書きました。

この方法に類似した面白い話題があります。それは、純粋関数型言語で定義できない純粋な関数 - sumiiの日記という問題です。純粋という単語の使われ方が紛らわしいですが、

  • 純粋関数型言語→副作用のあるような機能を実現する構文がない関数型言語
  • 純粋な関数→(内部で副作用を使ってもいいが)参照透明な関数

という意味です。

constexprは、C++11の段階では確かに純粋関数型言語でした。 しかし、C++14では関数内で完結する副作用が許されたため、あくまで純粋な関数しか書けないとはいえ、純粋関数型言語では書けないような関数が書けるようになったのです。C++17でラムダ式の呼び出しもconstexpr指定されるようになったため、さらに書きやすくなりました。

では実際にC++11では書けなかったような関数を書いてみましょう。

……

実際に書いてみるとわかりますが、普通にやってもこのようなことはできません。

局所的にせよ副作用のあるようなラムダ式はなんらかのキャプチャをしているはずなので、関数ポインタに変換できません*1

つまり、「関数ポインタをとれる関数」かつ「constexpr文脈で評価できる関数(static変数とかを持っていない)」の範囲であれば、副作用を許したところで表現力は変わらないように思われます(本当にそうなのかよくわかっていません)。

ラムダ式を引数に取れるよう、関数ポインタ以外に関数オブジェクトもとれるようなテンプレート関数にするとどうでしょうか。

実際に書いてみたのがこちらです。

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

BOTTOM、つまり無限ループになることを確かめる方法は原理的にないのですが、このくらいの単純な関数なら、再帰深度制限で落ちれば無限ループと判断してよいでしょう。

……というのはちょっと罠で、clang++でstatic_assert( f() == nullptr )と書いてしまうと、f()の型はstd::nullptr_tだから恒真、と判断してしまい、関数呼び出しすらしないようです。

g++で確かめることにしましょう。

ところであまり素直ではない書き方になっているのが気になります。

先ほど書いたように、関数ポインタを受け取る関数を作るのでは、ラムダ式を受け取ることができなくて詰みます。

そうするとジェネリックラムダで受け取りたくなるわけですが、自己再帰関数関数が書けなくて詰みます*2

自前でtemplate<...>と書けばいいのかというとそうでもなくて、関数テンプレートは、テンプレート引数を指定せず関数の引数に入れることができなくて詰みます*3

というわけでテンプレートなoperator()を持った関数オブジェクトを作っているわけです。自己再帰(*this)(unit)みたいになっていて微妙ですが、仕方ありません。

ただ、先のコードでは、C++14以降でしか書けないようなconstexpr関数があることを示したことにはならないような気がします。 オーバーロード関数を作ってしまえば、引数に与えられる関数ごとに挙動を変えることは非常に簡単です。 そういうような書き方をするとその多相関数は「一様」でなくなってしまうのですが、C++11でも書けるには書けるということです。

結局、C++14になって局所的な副作用が許されたからといってconstexprで書ける関数の範囲は変わらないということなんでしょうか。 関数ポインタをとれるという条件(あるいは、ヒープが使えないという条件下での型システム)によって、純粋関数型言語で書ける関数に限定されてしまうというのは不思議な気がします。

*1:C++的には、ただの関数と情報を持っているクロージャーでは当然サイズが違うので同じ型を付けられません(C++言語での型というのにはストレージをどれだけ使うかというのまで含まれてしまいます)。サイズが違っても同じ型に見せるには、動的確保が必要ですが、constexpr文脈では残念ながらできません。

*2:let recみたいなのがなく、ラムダ式の中で自己再帰する方法がありません。自前でfixみたいなものを作ればできないこともないですが、見た目がまるっきり変わってしまいます。

*3:関数テンプレートはあくまでテンプレートであり、C++の世界の値ではありません。つまり、多相関数をそのまま受け取る方法がありません。ランク1多相が偽物なのでランク2多相が書けなくなってしまっています。