C++ Conceptとエラーメッセージ

C++20 Conceptに少し慣れようと思い、練習がてら雑JSONを出力できるライブラリを書いてみようと思った。

C++20となると色々考えるべきことが増える(std::u8stringをどうするか? とか)が、今回はそのへんは主題ではないのでできるだけ今までのC++の範囲内で短くまとめることにする。

コンセプトを使うとどれくらい単純化されるのか見てみよう。とりあえず、STLコンテナが来たときに、それが連想配列か普通の配列かを区別したい。手っ取り早く、key_typemapped_typeを持っているやつは連想配列ということにしてしまおう。

rangeライブラリが実装されていないのでrangeコンセプトを定義する必要がある。ライブラリの名前が何も思いつかなかったのでconceptからcptにした。

#include <concepts>

namespace cpt
{
template<typename T>
concept range = requires(T& t) {
    std::begin(t); // std::begin(t)が呼べる
    std::end  (t); // 同上
};

template<typename T>
concept has_value_type = requires {
    typename T::value_type; // T::value_type が存在する
};

template<typename T>
concept has_key_mapped_type = requires {
    typename T::key_type;
    typename T::mapped_type;
};

template<typename T>
concept map_like_container =
    range<T> && has_value_type<T> && has_key_mapped_type<T>;

template<typename T>
concept vector_like_container =
    range<T> && has_value_type<T> && !has_key_mapped_type<T>;
} // cpt

これを使うと、std::vectorなどの配列とstd::mapなどの連想配列は以下のようにしてわけられる。

// 普通の配列をJSONにする
template<vector_like_container T>
inline std::string to_json(const T& v)
{
    std::string retval;
    for(const auto& elem : v)
    {
         retval += ' ';
         retval += to_json(elem);
         retval += ',';
    }
    if(retval.empty()) {retval.resize(2);}
    retval.front() = '[';
    retval.back()  = ']';
    return retval;
}

// 連想配列をJSONにする
template<map_like_container T>
inline std::string to_json(const T& m)
{
    std::string retval;
    for(const auto& [key, val] : m)
    {
         retval += ' ';
         retval += to_json(key);
         retval += ':';
         retval += to_json(val);
         retval += ',';
    }
    if(retval.empty()) {retval.resize(2);}
    retval.front() = '{';
    retval.back()  = '}';
    return retval;
}

従来typenameだった部分をvector_like_containermap_like_containerにすればいいだけだ。驚きの簡単さ! 我々はSFINAEの意味のわからん記述から解放されるのだ!

整数や浮動小数点数のコンセプトは標準でサポートされている。

template<std::integral T>
inline std::string to_json(const T i)
{
    return std::to_string(i);
}

template<std::floating_point T>
inline std::string to_json(const T f)
{
    return std::to_string(f);
}

あれ、分ける意味なかった……。

そんなこんなで一瞬で実装が終わる。さて、これにユーザー定義型を渡してみよう。ユーザー定義型がSTLコンテナと同じインターフェースを持っているとかでなければ、何にもマッチせずコンパイルエラーになるはずだ。さてどんなエラーになる?

struct X {int i;};

上記の構造体をto_jsonに渡したコードを、我らがwandboxで実行してみよう。

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

prog.cc: In function 'int main()':
prog.cc:113:36: error: no matching function for call to 'to_json(X)'
  113 |     std::cout << cpt::to_json(X{42}) << std::endl;
      |                                    ^
prog.cc:41:20: note: candidate: 'std::string cpt::to_json(bool)'
   41 | inline std::string to_json(const bool b)
      |                    ^~~~~~~
prog.cc:41:39: note:   no known conversion for argument 1 from 'X' to 'bool'
   41 | inline std::string to_json(const bool b)
      |                            ~~~~~~~~~~~^
prog.cc:48:20: note: candidate: 'std::string cpt::to_json(T) [with T = X; std::string = std::__cxx11::basic_string<char>]'
   48 | inline std::string to_json(const T i)
      |                    ^~~~~~~
prog.cc:48:20: note: constraints not satisfied
In file included from prog.cc:1:
/opt/wandbox/gcc-head/include/c++/11.0.0/concepts: In instantiation of 'std::string cpt::to_json(T) [with T = X; std::string = std::__cxx11::basic_string<char>]':
prog.cc:113:36:   required from here
/opt/wandbox/gcc-head/include/c++/11.0.0/concepts:102:13:   required for the satisfaction of 'integral<T>' [with T = X]
/opt/wandbox/gcc-head/include/c++/11.0.0/concepts:102:24: note: the expression 'is_integral_v<_Tp> [with _Tp = X]' evaluated to 'false'
  102 |     concept integral = is_integral_v<_Tp>;
      |                        ^~~~~~~~~~~~~~~~~~
prog.cc:54:20: note: candidate: 'std::string cpt::to_json(T) [with T = X; std::string = std::__cxx11::basic_string<char>]'
   54 | inline std::string to_json(const T f)
      |                    ^~~~~~~
prog.cc:54:20: note: constraints not satisfied
In file included from prog.cc:1:
/opt/wandbox/gcc-head/include/c++/11.0.0/concepts: In instantiation of 'std::string cpt::to_json(T) [with T = X; std::string = std::__cxx11::basic_string<char>]':
prog.cc:113:36:   required from here
/opt/wandbox/gcc-head/include/c++/11.0.0/concepts:111:13:   required for the satisfaction of 'floating_point<T>' [with T = X]
/opt/wandbox/gcc-head/include/c++/11.0.0/concepts:111:30: note: the expression 'is_floating_point_v<_Tp> [with _Tp = X]' evaluated to 'false'
  111 |     concept floating_point = is_floating_point_v<_Tp>;
      |                              ^~~~~~~~~~~~~~~~~~~~~~~~
prog.cc:59:20: note: candidate: 'std::string cpt::to_json(const string&)'
   59 | inline std::string to_json(const std::string& s)
      |                    ^~~~~~~
prog.cc:59:47: note:   no known conversion for argument 1 from 'X' to 'const string&' {aka 'const std::__cxx11::basic_string<char>&'}
   59 | inline std::string to_json(const std::string& s)
      |                            ~~~~~~~~~~~~~~~~~~~^
prog.cc:66:20: note: candidate: 'std::string cpt::to_json(const T&) [with T = X; std::string = std::__cxx11::basic_string<char>]'
   66 | inline std::string to_json(const T& v)
      |                    ^~~~~~~
prog.cc:66:20: note: constraints not satisfied
prog.cc: In instantiation of 'std::string cpt::to_json(const T&) [with T = X; std::string = std::__cxx11::basic_string<char>]':
prog.cc:113:36:   required from here
prog.cc:8:9:   required for the satisfaction of 'range<T>' [with T = X]
prog.cc:29:9:   required for the satisfaction of 'vector_like_container<T>' [with T = X]
prog.cc:8:17:   in requirements with 'T& t' [with T = X]
prog.cc:9:15: note: the required expression 'std::begin(t)' is invalid
    9 |     std::begin(t);
      |     ~~~~~~~~~~^~~
prog.cc:10:15: note: the required expression 'std::end(t)' is invalid
   10 |     std::end  (t);
      |     ~~~~~~~~~~^~~
cc1plus: note: set '-fconcepts-diagnostics-depth=' to at least 2 for more detail
prog.cc:82:20: note: candidate: 'std::string cpt::to_json(const T&) [with T = X; std::string = std::__cxx11::basic_string<char>]'
   82 | inline std::string to_json(const T& m)
      |                    ^~~~~~~
prog.cc:82:20: note: constraints not satisfied
prog.cc: In instantiation of 'std::string cpt::to_json(const T&) [with T = X; std::string = std::__cxx11::basic_string<char>]':
prog.cc:113:36:   required from here
prog.cc:8:9:   required for the satisfaction of 'range<T>' [with T = X]
prog.cc:25:9:   required for the satisfaction of 'map_like_container<T>' [with T = X]
prog.cc:8:17:   in requirements with 'T& t' [with T = X]
prog.cc:9:15: note: the required expression 'std::begin(t)' is invalid
    9 |     std::begin(t);
      |     ~~~~~~~~~~^~~
prog.cc:10:15: note: the required expression 'std::end(t)' is invalid
   10 |     std::end  (t);
      |     ~~~~~~~~~~^~~

やはりといえばそうなのだが、長い。

上から読んでいけば、「to_json(X{42})にマッチする関数はありません」「to_json(const bool)には以下の理由でマッチしません」「to_json(const T i)には……」と書いてあるので簡単にエラーの理由がわかるのだけれど、慣れている人は一行目で理由を察するし、慣れていない人は長さに圧倒されて気を失う。いつもの光景だ。

さて、コンセプトは複雑怪奇なSFINAEを滅ぼすことでプログラマのコストを下げる、またライブラリのユーザーにしてもSFINAE由来の謎のクソ長いエラーメッセージが「このコンセプトにマッチしません」というメッセージになってWin-Win、という話が巷には溢れている。このエラーメッセージをもっと短くわかりやすくできないものか?

考えてみよう。まず、今回大量のエラーメッセージが出てきてしまうのは、複数の関数が潜在的にマッチできる状況にあるからだ。そのどれもがマッチしない理由をコンパイラは説明しなければならない。ではまず、マッチし得る関数が一つしかない状況を作ろう。とりあえず今までのto_json関数を全部detail名前空間に入れる。そして単一の関数to_jsonをライブラリ名前空間のトップに置く。そしてその関数がdetail名前空間内の関数に飛ばす、という流れを作ろう。

namespace cpt
{
namespace detail
{
inline std::string to_json(...);
}

template<typename T>
inline std::string to_json(const T& v)
{
    return detail::to_json(v);
}
}

これによって、cpt::to_jsonの呼び出しはすべてこの関数に集約される。いわば門番になるわけだ。そうしたところで、このライブラリで出力できる型のすべてにマッチするようなコンセプトを書く。

これは、これまでオーバーロード解決に使ったコンセプトと、直接書いた型とsame_asになるようなコンセプトをすべてorで繋ぐと作れる。ちょっとダルいがこの程度なら許容範囲だ。

template<typename T>
concept supported_types =
    std::same_as<T, bool>        ||
    std::integral<T>             ||
    std::floating_point<T>       ||
    std::same_as<T, std::string> ||
    map_like_container<T>        ||
    vector_like_container<T>     ;

template<supported_types T>
inline std::string to_json(const T& v)
{
    return detail::to_json(v);
}

これを使うことでエラーメッセージはどうなるか?

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

prog.cc: In function 'int main()':
prog.cc:122:36: error: use of function 'std::string cpt::to_json(const T&) [with T = X; std::string = std::__cxx11::basic_string<char>]' with unsatisfied constraints
  122 |     std::cout << cpt::to_json(X{42}) << std::endl;
      |                                    ^
prog.cc:103:20: note: declared here
  103 | inline std::string to_json(const T& t)
      |                    ^~~~~~~
prog.cc:103:20: note: constraints not satisfied
prog.cc: In instantiation of 'std::string cpt::to_json(const T&) [with T = X; std::string = std::__cxx11::basic_string<char>]':
prog.cc:122:36:   required from here
prog.cc:33:9:   required for the satisfaction of 'supported_types<T>' [with T = X]
prog.cc:38:34: note: no operand of the disjunction is satisfied
   34 |     std::same_as<T, bool>        ||
      |     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
   35 |     std::integral<T>             ||
      |     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
   36 |     std::floating_point<T>       ||
      |     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
   37 |     std::same_as<T, std::string> ||
      |     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
   38 |     map_like_container<T>        ||
      |     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~
   39 |     vector_like_container<T>     ;
      |     ~~~~~~~~~~~~~~~~~~~~~~~~      
cc1plus: note: set '-fconcepts-diagnostics-depth=' to at least 2 for more detail

目論見通り、supported_types<T>が満たされていないという表示だけが残った。さらにsupported_typesの内訳まで書いてくれている。

最初のエラーメッセージに比べてかなり単純でわかりやすくなったように思う。これは……実はいいアイデアでは? ただsupported_typesを定義するのがだるいという問題は残るけれど。