150
45

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

C++Advent Calendar 2022

Day 19

C++ コンパイル時「出力」 ~C++にできないことはない~

Last updated at Posted at 2022-12-18

これは、 C++ でコンパイル時に出力まで済ませようとした話です。
コンパイラは GCC に限ります。

はじめに

もうすぐクリスマスですね!
クリスマスにすることといえば……、

そう、コンパイル時処理ですね!!

コンパイル時処理

C++ のコンパイル時処理は非常に強力で、様々なことがコンパイル時にできてしまいます。

普通はコンパイル時に決まる定数の計算に使われますが、これを悪用利用してコンパイル時に処理がすべて終わるようなものも書くことができます。
例として、コンパイル時 FizzBuzz を書いてみます。

コンパイル時 FizzBuzz (C++20)
#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_ostreamstd::printf などを使うようなコードはコンパイル時には動きません。
なので、出力だけは実行時に行うしかありません。

こればっかりはどうしようもないです……。

エラーメッセージの利用

コンパイル時に出力はできない?
本当にそうでしょうか?
コンパイル時に出力されているものがあるじゃないですか。
皆さん見たことありますよね?

そう、エラーメッセージです!!

static_assert

C++ ではコンパイル時アサートstatic_assert を使うことで任意のエラーメッセージを出力することができます。
static_assert の第1引数が false になるようにしてアサーションを失敗させることで、第2引数に指定したエラーメッセージが表示されます。

次に static_assert によってエラーメッセージを出力する例を挙げます。

static_assert_sample.cpp
int main() {
    static_assert(false, "Hello, World!");
}
エラーメッセージ (GCC)
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 の問題点

それでは、次のようなコードではどうでしょうか?

static_assert_sample2.cpp
int main() {
    constexpr const char* s = "Hello, World!";
    static_assert(false, s);
}
エラーメッセージ (GCC, 一部抜粋)
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");

これでコンパイル時に決まる文字列をコンパイル時に出力することができました!

エスケープシーケンスの利用

皆さんの言いたいことは分かります。
上の方法での出力は次のようになります。

image.png

いや、見にくいったりゃありゃしない!!

出力できてるといえばできてるけど、もうちょっとどうにかならんか!?

GCC の static_assert

ここで朗報!
なんと、 GCCstatic_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

constexpr_fizzbuzz.cpp
#include "constexpr_print.hpp"

int main() {
    constexpr_print(fizzbuzz<30>::value);
}

result.png

コンパイル時に計算した値が、そのままコンパイル時に出力されています!
これは正真正銘のコンパイル時出力です!

補足

上の例では FizzBuzz を 30 まで実行していますが、これ以上 FizzBuzz を続けようとするとなぜか出力がうまくいきませんでした。
出力できるエラーメッセージの数に制限があるのかもしれません。
普通に私が埋め込んだバグだったので、修正しました(修正箇所: たった1文字)。ごめんなさい。

また、コンパイルコマンドの -DCONSTEXPR_PRINT_MOVE_UP_ADJUSTMENT=9 によってカーソルを上に移動させるときの行数を調整しています。
これは、ターミナルの横幅によってエラーメッセージが改行されて行数が変わるのを調整するためです。
とても面倒くさいですが、妥協しました。

おわりに

本記事では、コンパイル時に出力しようとする中で出会った問題と解決策を書きました。
妥協した点もかなりありますが、なんとかコンパイル時出力(っぽいもの)を実現することができました。

昔からかなり遊ばれてきた C++ のコンパイル時処理ですが、 C++20 でできることがさらに増えました。
もしかしたら、未来の C++ でコンパイル時に出力する手段が提供されるかもしれませんね。

C++ のコンパイル時処理は無限の可能性を持っています。
皆でこの可能性を掘り出していきましょう。

レッツエンジョイ、コンパイル時処理!

150
45
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
150
45

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?