コンパイル時フォーマット文字列チェック
{fmt}ライブラリおよび<format>
には、コンパイル時のフォーマット文字列チェック機能が実装されています。
#include <format> #include <fmt/core.h> int main() { // 共にコンパイルエラーを起こす auto str = std::format("{:d}", "I am not a number"); fmt::print("{:d}", "I am not a number"); }
{:d}
は10進整数値1つのためのフォーマット指定であるのに、引数として整数値ではなく文字列が渡っているためにエラーとなっています。
これは先に{fmt}ライブラリで実装されたものが、遅れてC++20 <format>
に導入されたものです。一見すると言語やコンパイラの特別のサポート無くしてはこのようなことはできないように思われますが、これは純粋にライブラリ機能として実装されています。
{fmt}ライブラリを追うのは辛かったので、<format>
がどのようにこれを達成しているかを見てみることにします。
basic-format-string
クラス
std::format
の宣言を見てみると、次のようになっています。
template<class... Args> string format(format-string<Args...> fmt, const Args&... args); template<class... Args> wstring format(wformat-string<Args...> fmt, const Args&... args);
どちらも第一引数にフォーマット文字列を取り、第二引数以降でフォーマット対象の変数列を受け取ります。format-string
みたいなのは説明専用の型で、フォーマット文字列を構成しているものです。
template<class charT, class... Args> struct basic-format-string; template<class... Args> using format-string = basic-format-string<char, type_identity_t<Args>...>; template<class... Args> using wformat-string = basic-format-string<wchar_t, type_identity_t<Args>...>;
basic-format-string
クラスは説明専用のもので、実際の型名は実装によって異なります。その実装は次のように描かれています
template<class charT, class... Args> struct basic-format-string { private: basic_string_view<charT> str; public: template<class T> consteval basic-format-string(const T& s); };
そのコンストラクタの効果については次のように規定されています。
Constraints: const T& models convertibleto<basicstring_view
>.
Effects: Direct-non-list-initializes str with s.
Remarks: A call to this function is not a core constant expression ([expr.const]) unless there exist args of types Args such that str is a format string for args.
この3つめのRemarks指定がまさに、コンパイル時フォーマット文字列チェックを規定しています。
consteval
コンストラクタ
A call to this function is not a core constant expression ([expr.const]) unless there exist args of types Args such that str is a format string for args.
を訳すと(Powerd by DeepL)
この関数の呼び出しは、
str
がargs
のフォーマット文字列であるようなArgs
型のargs
が存在しない限り、コア定数式ではない。
ややこしいですが、std::format
の引数として与えられたフォーマット文字列str
とフォーマット対象のargs
について、str
が正しくそのフォーマット文字列となっていなければこのコンストラクタの呼び出しはコア定数式でない、と言っており、コア定数式でないものは定数式で実行できません。
ところで、このコンストラクタにはconsteval
指定がなされています。consteval
はC++20から追加された言語機能で、consteval
指定された関数は必ずコンパイル時に実行されなければならず、さもなければコンパイルエラーとなります。それはconsteval
コンストラクタにおいても同様です。
このRemarks指定とconsteval
の効果を合わせると、フォーマット文字列str
がArgs...
に対して正しくない場合にコンパイルエラー、となるわけです。
実装例
規定は分かりましたが、それだけでフォーマット文字列チェックができるわけではありません。結局、ユーザーランドで規定に沿うように実装することが可能なのかどうかが知りたいことです。
C++20に強い人ならここまでのことで実装イメージが浮かんでいるでしょうが、一応書いてみることにします。なお、フォーマット文字列チェック実装については主題ではないので深入りしません。
// 定数式で呼べない関数 void format_error(); // フォーマット文字列チェック処理 // 詳細は省略するが定数式で実行可能なように実装されているとする template<typename CharT, typename... Args> consteval void fmt_checker(std::basic_string_view<CharT> str) { // ... if (/*かっこが足りないとかの時*/) { format_error(); // 定数式で実行できないため、ここに来るとコンパイルエラー } // ... if (/*型が合わない時*/) { throw "invalid type specifier"; // throw式は定数式で実行不可 } // ... } template<typename CharT, typename... Args> struct basic_format_string { std::basic_string_view<CharT> str; template<typename T> requires std::convertible_to<const T&, std::basic_string_view<charT>> consteval basic_format_string(const T& s) : str(s) { fmt_checker<CharT, Args...>(str); } }; template<class... Args> using format_string = basic_format_string<char, std::type_identity_t<Args>...>; // std::format template<class... Args> std::string format(format_string<Args...> fmt, const Args&... args) { // フォーマット済み文字列を作成する部分は省略 return std::vformat(fmt.str, std::make_format_args(args...)); }
一応標準ライブラリのものを使っているところにはstd::
を付加していますが、実際はこのような実装もstd
名前空間内で実装されるので不要です。
format()
の引数として構築されたbasic_format_string
(format_string
)のコンストラクタ本体において、フォーマット文字列チェックを行うfmt_checker()
を呼び出します。fmt_checker()
が行うフォーマット文字列チェック機能は定数式で実行可能なように実装されているものとして、受け取ったフォーマット文字列basic_format_string::str
をそこに渡してfmt_checker()
が完了すればフォーマット文字列チェックは完了です。
basic_format_string
のコンストラクタおよびfmt_checker()
はconsteval
関数であるので、一連のフォーマット文字列チェック機能は定数式で必ず実行される事になります。
fmt_checker()
においてフォーマット文字列エラーが発生した場合コンパイルエラーとしなければなりませんが、実行環境がconsteval
コンテキストなので、定数式で実行できない事をしようとすればコンパイルエラーを引き起こすことができます。それは例えば、非constexpr
関数の呼び出しやthrow
式の実行などがあります。
fmt_checker()
にはフォーマット文字列str
および、format()
に指定された残りの戻り値の型Args
が正しく伝わっています。str
の内容とArgs
の各型を順番にチェックしていけば、指定されたフォーマット文字列に対して正しい引数が指定されているか?という事までチェックできます。
このようなformat()
には任意の文字列型を渡すことができます。basic_format_string
ではなく。
int main() { // 文字配列 -> basic_format_stringへの暗黙変換 // basic_format_stringのコンストラクタがconstevalのため、必ずコンパイル時に実行される auto str = format("{:d}", "I am not a number"); }
このforamt()
の呼び出しの第一引数においては、文字配列(任意の文字列型)->basic_format_string
の一時オブジェクト->basic_format_string
の左辺値、のような変換によってforamt()
第一引数のfmt
が構築されています(一時オブジェクトから左辺値への変換はコピー省略によって省略されるはずです)。
basic_format_string
の宣言された唯一つのコンストラクタはexplicit
されていないテンプレートコンストラクタなので、string_view
に変換可能な任意の文字列型から暗黙変換によって呼び出すことができます。そして、そのコンストラクタはconsteval
なので暗黙変換からフォーマット文字列チェックまで必ずコンパイル時に実行されます。
もしこれがconstexpr
だと、フォーマット文字列に間違いがあった時に必ずしもコンパイルエラーにすることができません。constexpr
変数の初期化式のようにどうしても定数式で実行しなければならない所以外では、constexpr
関数の実行中に定数式で実行できない物に出会った場合に定数式を中断して実行時処理に切り替えることを暗黙に行うため、consteval
と同様のコンパイルエラーを起こせません。特に、関数引数にはconstexpr
を付加できないため、暗黙変換をトリガーとしたコンパイル時フォーマット文字列チェックを強制できません。
すなわち、このコンパイル時フォーマット文字列チェックを支えているのは、consteval
という機能なわけです。consteval
自体は<format>
と無関係に導入されており、コンパイル時フォーマット文字列チェックは何らの言語サポートを受けたものではありません。加えて、暗黙変換というのもミソなところで、暗黙変換をトリガーとする事によってコンパイル時チェックを走らせるための追加の何かをする必要がなくなっています。うーんかしこい!!
このように、コンパイル時フォーマット文字列チェックは純粋にC++20の範囲内で実装することができます。
応用例
たとえば、宇宙船演算子の戻り値型である比較カテゴリ型は0
リテラルのみと比較可能とされています。これはstd::nullptr_t
を用いて実装することができますが、コンパイル時フォーマット文字列チェックと同様のアプローチによって実装することができそうです。
#include <iostream> #include <ranges> struct lzero { consteval lzero(int&& n) { if (n != 0) { throw "Compare with zero only!"; } } }; struct dummy_cct { friend bool operator==(const dummy_cct&, lzero) { return true; } }; int main() { dummy_cct c{}; std::cout << std::boolalpha; std::cout << (c == 0) << std::endl; std::cout << (c != 0) << std::endl; std::cout << (0 == c) << std::endl; std::cout << (0 != c) << std::endl; }
エラーになる例
int main() { dummy_cct c{}; std::cout << std::boolalpha; // 共にng std::cout << (c == 1) << std::endl; std::cout << (c == -1) << std::endl; }
この方法の利点としては、nullptr
との比較ができなくなる所と、0
リテラル以外との比較は未定義動作と規定されている未定義動作をコンパイルエラーとして実装できることでしょうか。
なお、lzero
のコンストラクタ引数をint&&
としているのは、左辺値(すなわち変数)を受けないようにするためです。
int main() { dummy_cct c{}; std::cout << std::boolalpha; constexpr int n = 0; std::cout << (c == n) << std::endl; // ng }
なるべく確実にリテラルだけを受け取るようにしたいわけです。しかしこれでも完璧ではなく、式の結果を受け取れてしまいます・・・
このように、このテクニックは色々面白い応用が効きそうな無限の可能性があります。わくわくしますね!