[C++] std::formatあるいは{fmt}のコンパイル時フォーマット文字列チェックの魔術

コンパイル時フォーマット文字列チェック

{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 convertible­to<basic­string_­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)

この関数の呼び出しは、strargsのフォーマット文字列であるようなArgs型のargsが存在しない限り、コア定数式ではない。

ややこしいですが、std::formatの引数として与えられたフォーマット文字列strとフォーマット対象のargsについて、strが正しくそのフォーマット文字列となっていなければこのコンストラクタの呼び出しはコア定数式でない、と言っており、コア定数式でないものは定数式で実行できません。

ところで、このコンストラクタにはconsteval指定がなされています。constevalC++20から追加された言語機能で、consteval指定された関数は必ずコンパイル時に実行されなければならず、さもなければコンパイルエラーとなります。それはconstevalコンストラクタにおいても同様です。

このRemarks指定とconstevalの効果を合わせると、フォーマット文字列strArgs...に対して正しくない場合にコンパイルエラー、となるわけです。

実装例

規定は分かりましたが、それだけでフォーマット文字列チェックができるわけではありません。結局、ユーザーランドで規定に沿うように実装することが可能なのかどうかが知りたいことです。

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_stringformat_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
}

なるべく確実にリテラルだけを受け取るようにしたいわけです。しかしこれでも完璧ではなく、式の結果を受け取れてしまいます・・・

このように、このテクニックは色々面白い応用が効きそうな無限の可能性があります。わくわくしますね!

参考文献

この記事のMarkdownソース