これは、 C++ でコンパイル時に出力まで済ませようとした話です。
コンパイラは GCC に限ります。
はじめに
もうすぐクリスマスですね!
クリスマスにすることといえば……、
そう、コンパイル時処理ですね!!
コンパイル時処理
C++ のコンパイル時処理は非常に強力で、様々なことがコンパイル時にできてしまいます。
普通はコンパイル時に決まる定数の計算に使われますが、これを悪用利用してコンパイル時に処理がすべて終わるようなものも書くことができます。
例として、コンパイル時 FizzBuzz を書いてみます。
#include <array>
#include <string_view>
#include <algorithm>
#include <concepts>
#include <iostream>
template <std::unsigned_integral T>
constexpr std::size_t digits(T x) {
std::size_t d = 1;
while (x /= 10) ++d;
return d;
}
template <std::size_t N>
struct fizzbuzz {
static constexpr std::size_t size =
fizzbuzz<N - 1>::size + (
N % 15 == 0 ? 9 :
N % 3 == 0 || N % 5 == 0 ? 5 :
digits(N) + 1
);
static constexpr std::array<char, size> data = [] {
using namespace std::literals::string_view_literals;
std::array<char, size> data;
auto it = std::ranges::copy(fizzbuzz<N - 1>::data, data.begin()).out;
if (N % 15 == 0) std::ranges::copy("FizzBuzz\n"sv, it);
else if (N % 3 == 0) std::ranges::copy("Fizz\n"sv, it);
else if (N % 5 == 0) std::ranges::copy("Buzz\n"sv, it);
else {
auto it = data.rbegin();
*it++ = '\n';
auto n = N;
do *it++ = '0' + n % 10;
while (n /= 10);
}
return data;
}();
static constexpr std::string_view value{ data.data(), size };
};
template <>
struct fizzbuzz<0> {
static constexpr std::size_t size = 0;
static constexpr std::array<char, size> data {};
static constexpr std::string_view value{ data.data(), size };
};
int main() {
std::cout << fizzbuzz<100>::value << std::endl;
}
これをコンパイルして実行すると FizzBuzz が出力されます。
もちろん、 FizzBuzz の処理はコンパイル時に終わっていて、実行時は出力するだけです。
簡単な例として FizzBuzz を挙げましたが、もっとすごいものにはコンパイル時レイトレーシングやコンパイル時 C コンパイラなどがあります。(何を食べたらこんなことしようと思うんだ?)
しかし、これらすべてに共通するのは、出力は実行時に行われているということです。
コンパイル時出力はできない
出力を実行時に行うようにしているのは、コンパイル時に出力することができないからです。
現在の C++20 では、 std::basic_ostream
や std::printf
などを使うようなコードはコンパイル時には動きません。
なので、出力だけは実行時に行うしかありません。
こればっかりはどうしようもないです……。
エラーメッセージの利用
コンパイル時に出力はできない?
本当にそうでしょうか?
コンパイル時に出力されているものがあるじゃないですか。
皆さん見たことありますよね?
そう、エラーメッセージです!!
static_assert
C++ ではコンパイル時アサート、 static_assert
を使うことで任意のエラーメッセージを出力することができます。
static_assert
の第1引数が false になるようにしてアサーションを失敗させることで、第2引数に指定したエラーメッセージが表示されます。
次に static_assert
によってエラーメッセージを出力する例を挙げます。
int main() {
static_assert(false, "Hello, World!");
}
static_assert_sample.cpp: In function 'int main()':
static_assert_sample.cpp:2:19: error: static assertion failed: Hello, World!
2 | static_assert(false, "Hello, World!");
| ^~~~~
error: static assertion failed: Hello, World!
のように、指定したエラーメッセージが出力されていることが分かります。
static_assert の問題点
それでは、次のようなコードではどうでしょうか?
int main() {
constexpr const char* s = "Hello, World!";
static_assert(false, s);
}
static_assert_sample2.cpp: In function 'int main()':
static_assert_sample2.cpp:3:26: error: expected string-literal before 's'
3 | static_assert(false, s);
| ^
︙
指定したエラーメッセージが出力されません!
static_assert
の第2引数は文字列リテラルである必要があります。
つまり、エラーメッセージはコンパイル前に決まる必要があるということです。
なので、たとえ constexpr な変数であっても static_assert
の第2引数に渡すことはできません。
これは困りました。
これでは、コンパイル時に決まる値を出力することができません。
全部書く!
コンパイル前に決まらなければいけないけど、コンパイル時に決まる値を使いたい。
そんなとき、どうすればいいか、皆さんご存じですか?
そう、全部書けばいいのです!!
何を全部書くかというと、出力しうる文字列の static_assert
を全部です。
これで、場合分けによって出力をコンパイル時に変えることができます。
とはいえ、そんなことをすると汎用性のハの字もなくなってしまいます。
そこで、出力しうる文字について全部書くことを考えます。
文字といっても Unicode にある文字を全部書いてたらきりがないので、ここでは ASCII 文字を考えます。
つまり、こういうことです。
constexpr char c = (出力する文字);
static_assert(c != '\x00', "\x00");
static_assert(c != '\x01', "\x01");
static_assert(c != '\x02', "\x02");
static_assert(c != '\x03', "\x03");
static_assert(c != '\x04', "\x04");
︙
static_assert(c != '\x7d', "\x7d");
static_assert(c != '\x7e', "\x7e");
static_assert(c != '\x7f', "\x7f");
これでコンパイル時に決まる文字列をコンパイル時に出力することができました!
エスケープシーケンスの利用
皆さんの言いたいことは分かります。
上の方法での出力は次のようになります。
いや、見にくいったりゃありゃしない!!
出力できてるといえばできてるけど、もうちょっとどうにかならんか!?
GCC の static_assert
ここで朗報!
なんと、 GCC の static_assert
、エスケープシーケンスが使えます!
エスケープシーケンスを使えば、カーソルを強制的に移動させることができます。
これで一か所にまとめて出力できそうです。
使えそうなエスケープシーケンスを下の表にまとめます。
エスケープシーケンス | 効果 |
---|---|
\x1b[nE |
カーソルを n 行下の行の先頭に移動 |
\x1b[nF |
カーソルを n 行上の行の先頭に移動 |
\x1b[nG |
カーソルを現在の行の先頭から n 文字目に移動 (1-indexed) |
\x1b[J |
カーソルより後ろをすべて削除 |
さあ、ここで困ったことに、行の末尾(その行で出力された最も右の位置)に移動する操作がありません。
これでは、行をさかのぼれても、1つ前の出力の次の位置に出力することができません。
左から何文字目に出力するかは計算すればわかりますが、 static_assert
のメッセージの内容はコンパイル時に変えることはできないので、コンパイル時に計算した値によってカーソルを動かすことはできません。
全部書く!(2回目)
出力する位置はコンパイル時に分かるけど、カーソルを動かすメッセージはコンパイル前に決まらなければいけない。
そんなとき、どうすればいいか、皆さんご存じですよね?
そう、全部書けばいいのです!!(2回目)
左から1文字目に出力するとき、2文字目に出力するとき、……と、適当な文字数まですべて書いていれば、コンパイル時にその中から選択することができます。
しかしながら、これをしようとすると、1文字目に A
を出力するケース、1文字目に B
を出力するケース、1文字目に C
を出力するケース、……、2文字目に A
を出力するケース、2文字目に B
を出力するケース、2文字目に C
を出力するケース、……、と、各出力位置について各文字を出力する static_assert
を書くことになるので、コードがとんでもないことになります。
さすがにこれは避けたいです。
インクルード再帰
そこで、プリプロセッサの力を借ります。
プリプロセスはコンパイルの前に行われるので、コンパイル前に決まらなければいけない static_assert
のメッセージの指定にも使えるというわけです。
GCC では __INCLUDE_LEVEL__
というマクロが使えます。これは、インクルードの深度を表す整数に置換されるマクロです。
これと、 #include __FILE__
による自身のインクルードを組み合わせると、プリプロセッサで再帰によるループを書くことができます。
これを使って、想定するすべての出力位置に対して場合分けをするコードを短く書くことができます。
// 初めて来るときに __INCLUDE_LEVEL__ == 1 である(このファイルがソースファイルにインクルードされる)ことを想定しています
#if __INCLUDE_LEVEL__ == 1
# define STR2(...) #__VA_ARGS__
# define STR(...) STR2(__VA_ARGS__)
constexpr std::size_t x = (出力する位置); // 0-indexed
constexpr char c = (出力する文字);
std::get<x>(std::tuple{
#endif
#if __INCLUDE_LEVEL__ <= 128
[](auto c_getter) {
constexpr char c = c_getter();
static_assert(c != '\x00', "\x1b[8F\x1b[" STR(__INCLUDE_LEVEL__) "F\x1b[" STR(__INCLUDE_LEVEL__) "G\x00\x1b[J");
static_assert(c != '\x01', "\x1b[8F\x1b[" STR(__INCLUDE_LEVEL__) "F\x1b[" STR(__INCLUDE_LEVEL__) "G\x01\x1b[J");
static_assert(c != '\x02', "\x1b[8F\x1b[" STR(__INCLUDE_LEVEL__) "F\x1b[" STR(__INCLUDE_LEVEL__) "G\x02\x1b[J");
︙
static_assert(c != '\x7e', "\x1b[8F\x1b[" STR(__INCLUDE_LEVEL__) "F\x1b[" STR(__INCLUDE_LEVEL__) "G\x7e\x1b[J");
static_assert(c != '\x7f', "\x1b[8F\x1b[" STR(__INCLUDE_LEVEL__) "F\x1b[" STR(__INCLUDE_LEVEL__) "G\x7f\x1b[J");
},
# include __FILE__
#endif
#if __INCLUDE_LEVEL__ == 1
})([=] { return c; });
#endif
ここでラムダ式を使ったり c_gettter
なるものを使ったりと面倒なことをしているのは、 static_assert
の評価を遅延させるためです。
これをしないと出力位置で場合分けする前に static_assert
が評価されてしまうので、すべての出力位置についてのエラーメッセージが出力されてしまいます。
指定するメッセージの "\x1b[8F"
はエラーメッセージが出力された分カーソルを上に戻すため、 "\x1b[" STR(__INCLUDE_LEVEL__) "F"
はエラーメッセージのファイルのトレース(再帰インクルードするたびに行数が増える)が出力された分カーソルを上に戻すため、 "\x1b[" STR(__INCLUDE_LEVEL__) "G"
は出力位置にカーソルを移動させるため、 "x1b[J"
はカーソルより後ろに残ったエラーメッセージを削除するためである。
これで、指定した出力位置に出力できるようになりました。
あとはコンパイル時に出力位置を計算してやれば、きれいに一か所に出力されるはずです。
ラムダ式で包んで文字列を渡す
コンパイル時出力をする上で最後に問題になってくるのは、出力する文字列をどう渡すかです。
もちろん、出力する文字列は constexpr でなくてはならないので、引数で渡すことはできません。
static で constexpr な変数に文字列を格納すればテンプレートパラメータに渡すことはできますが、文字列リテラルを直接渡せないのは少し不便です。
そこで、ラムダ式を使います!
constexpr に実行可能なラムダ式を constexpr な文脈で実行すると、その結果は constexpr で取り出せます。
これにより、テンプレートパラメータに渡せないようなオブジェクトでも、ラムダ式に引数に包んで渡すことで constexpr なまま渡すことができます。
なぜこんなことができるのかはよくわかってませんが、便利なので使っています。
#include <string_view>
void f(std::string_view s) {
//static_assert(s == "Hello"); // ng: s は constexpr でない
}
template <auto S>
void g() {
constexpr std::string_view s = S;
static_assert(s == "Hello");
}
template <class S_GETTER>
void h(S_GETTER s_getter) {
constexpr std::string_view s = s_getter();
static_assert(s == "Hello");
}
#define h2(s) h([] { return (s); })
int main() {
//f("Hello");
static constexpr char s[] = "Hello";
g<s>(); // ok
//g<"Hello">(); // ng
h([] { return "Hello"; }); // ok
h2("Hello"); // ok
}
コンパイル時出力
さあ、これで準備が整いました。
コンパイル時出力の時間です。
はじめに示したコンパイル時 FizzBuzz の出力部分をコンパイル時出力に置き換えてみます。
コード全体は「全部書く!」をしていてとても長いので、 GitHub Gist のリンクを張っておきます。
興味のある人は見てください。
コード全体: https://gist.github.com/Raclamusi/b365019f7605192162d5e35ffaed0a54
#include "constexpr_print.hpp"
︙
int main() {
constexpr_print(fizzbuzz<30>::value);
}
コンパイル時に計算した値が、そのままコンパイル時に出力されています!
これは正真正銘のコンパイル時出力です!
補足
上の例では FizzBuzz を 30 まで実行していますが、これ以上 FizzBuzz を続けようとするとなぜか出力がうまくいきませんでした。
出力できるエラーメッセージの数に制限があるのかもしれません。
普通に私が埋め込んだバグだったので、修正しました(修正箇所: たった1文字)。ごめんなさい。
また、コンパイルコマンドの -DCONSTEXPR_PRINT_MOVE_UP_ADJUSTMENT=9
によってカーソルを上に移動させるときの行数を調整しています。
これは、ターミナルの横幅によってエラーメッセージが改行されて行数が変わるのを調整するためです。
とても面倒くさいですが、妥協しました。
おわりに
本記事では、コンパイル時に出力しようとする中で出会った問題と解決策を書きました。
妥協した点もかなりありますが、なんとかコンパイル時出力(っぽいもの)を実現することができました。
昔からかなり遊ばれてきた C++ のコンパイル時処理ですが、 C++20 でできることがさらに増えました。
もしかしたら、未来の C++ でコンパイル時に出力する手段が提供されるかもしれませんね。
C++ のコンパイル時処理は無限の可能性を持っています。
皆でこの可能性を掘り出していきましょう。
レッツエンジョイ、コンパイル時処理!