Share via


Windows と C++

コンパイル時の型チェックを Printf に追加する

Kenny Kerr

Kenny Kerr2015 年 5 月号のコラム (msdn.microsoft.com/magazine/dn913181) では、最新の C++ で printf を使いやすくするテクニックを取り上げました。そこでは、C++ の正式 string クラスと旧式 printf 関数のギャップを埋めるために、可変個引数テンプレートを使用して引数を変換する方法を示しました。なぜ、わざわざそんなことをするのでしょう。まず、printf は非常に高速です。開発者が安全かつ高度なコードを記述しながら、出力の書式を設定できるソリューションは間違いなく魅力的です。その結果が、次のようにシンプルなプログラムを可能にする Print 可変個引数関数テンプレートでした。

int main()
{
  std::string const hello = "Hello";
  std::wstring const world = L"World";
  Print("%d %s %ls\n", 123, hello, world);
}

内部の printf 関数は、次のように効率的に展開されます。

printf("%d %s %ls\n", Argument(123), Argument(hello), Argument(world));

その後、Argument 関数テンプレートがコンパイルされ、必要なアクセサー関数に変換されます。

printf("%d %s %ls\n", 123, hello.c_str(), world.c_str());

この方法は最新の C++ と従来の printf 関数の橋渡しとしては便利ですが、printf を使用して正しいコードを記述する難しさが解決するわけではありません。printf はやはり printf であり、変換指定子と、呼び出し元から提供される実際の引数との不整合を見つけ出すために利用しているのは、決して全知全能とはいえないコンパイラとライブラリです。

間違いなく、最新の C++ はもっと優秀です。多くの開発者が改善に努めてきました。問題は、開発者によって要件や優先事項が異なることです。一部の開発者はパフォーマンスへの影響が少なくなることを望み、型チェックと拡張性には単純に <iostream> を利用します。また、書式設定の状態を追跡するために、追加の構造と割り当てを必要とする興味深い機能を提供する複雑なライブラリを考案した開発者もいます。個人的には、基本的かつ高速な操作であるべきものに、パフォーマンスとランタイムのオーバーヘッドをもたらすソリューションには満足できません。C で小さく高速なものは、C++ でも小さく、高速で、そのうえ正しく使いやすくあるべきです。"低速" はその方程式に含まれるべきではありません。

では、何ができるでしょう。答えは簡単な抽象化です。ただし、その抽象化が、ハードコーディングした printf ステートメントに非常に近いものにコンパイルされる場合に限ります。重要なのは、%d や %s などの変換指定子がその後に続く引数や値のプレースホルダーに過ぎないと気付くことです。これらのプレースホルダーは対応する引数の型を推測しますが、問題は、その型が正しいかどうかを把握する方法がないことです。そこで、このような推測を確実にする実行時の型チェックを追加するのではなく、この擬似型情報を利用しないで、引数の型が自然に解決されるように、コンパイラに引数の型を推測させます。つまり、次のようには記述しません。

printf("%s %d\n", "Hello", 2015);

書式文字列を出力する実際の文字と、展開先のプレースホルダーに制限します。つまり、次のように、どの引数にも同じメタ文字をプレースホルダーとして使用することもできます。

Write("% %\n", "Hello", 2015);

実際には、後者に含まれる型情報は、前者の printf とまったく同じです。printf に余分な型情報を含める必要があったのは、C プログラミング言語に可変個引数テンプレートがなかっただけのことです。後者の方がはるかに簡潔です。printf のさまざまな書式指定子を照合して、適切な型の情報が指定されているかどうかを確認する処理がなくなれば、こんなに幸せなことはありません。

出力をコンソールに限定したくもありません。書式を文字列に変換する必要はあるでしょうか。その他のターゲットについてはどうでしょう。printf に取り組む際の課題の 1 つは、さまざまなターゲットへの出力をサポートしているのに、オーバーロードではなく、一般的には使いにくい独特の関数で printf が実行されることです。念のために言えば、C プログラミング言語では、オーバーロードやジェネリック プログラミングはサポートされていませんでした。しかし、歴史は繰り返さないようにしましょう。ここでは、文字列やファイルへの出力と同じくらい簡単かつ汎用的にコンソールに出力できるようにします。つまり、図 1 のようなことを可能にします。

図 1 型の推論による汎用出力

Write(printf, "Hello %\n", 2015);
FILE * file = // Open file
Write(file, "Hello %\n", 2015);
std::string text;
Write(text, "Hello %\n", 2015);

設計や実装の点からは、Write 関数テンプレート (ドライバーとして機能) を 1 つと、任意の数のターゲットまたはターゲット アダプター (一般に呼び出し元が特定するターゲットに基づいて限定される) が必要です。ターゲットは、必要に応じて開発者が簡単に追加できるようにします。では、どのようなしくみにしましょう。ターゲットを template パラメーターにするのが 1 つの選択肢です。たとえば、次のようにします。

template <typename Target, typename ... Args>
void Write(Target & target,
  char const * const format, Args const & ... args)
{
  target(format, args ...);
}
int main()
{
  Write(printf, "Hello %d\n", 2015);
}

これは、ある程度機能します。printf に想定されるシグネチャに従う他のターゲットを作成することができ、適切に機能するはずです。シグネチャに従って、出力を文字列に追加する関数オブジェクトを作成できます。

struct StringAdapter
{
  std::string & Target;
  template <typename ... Args>
  void operator()(char const * const format, Args const & ... args)
  {
    // Append text
  }
};

こうすれば、この関数オブジェクトを同じ Write 関数テンプレートに使用できます。

std::string target;
Write(StringAdapter{ target }, "Hello %d", 2015);
assert(target == "Hello 2015");

最初は、非常に洗練されているうえに、柔軟性が高いように思えました。あらゆる種類のアダプター関数や関数オブジェクトを作成できます。しかし実際に使ってみると、すぐに面倒だと感じるようになります。ターゲットとして単純に文字列を直接渡し、その型に基づいて文字列を適応する処理を Write 関数テンプレートに委ねる方がはるかに望ましいと思われます。そこで、出力を追加するため、または書式設定するために、要求されるターゲットをオーバーロード関数のペアと一致させる処理を Write 関数テンプレートに担当させます。簡単なコンパイル時の間接指定が非常に効果があります。書き込む必要がある出力のほとんどがプレースホルダーを使わない単なるテキストであることから、オーバーロードを 1 つではなくペアで追加します。まず、単純にテキストを追加します。文字列用の Append 関数は次のとおりです。

void Append(std::string & target,
  char const * const value, size_t const size)
{
  target.append(value, size);
}

次に、printf 用に Append のオーバーロードを用意します。

template <typename P>
void Append(P target, char const * const value, size_t const size)
{
  target("%.*s", size, value);
}

ここでは、関数ポインターを使用してテンプレートを printf シグネチャと対応付ける処理を回避していますが、これにはさらに柔軟性があります。というのも、他の関数をこの同じ実装にバインドできる可能性があり、コンパイラがポインターの間接指定の影響を受けることがありません。また、ファイル出力やストリーム出力用にオーバーロードも用意できます。

void Append(FILE * target,
  char const * const value, size_t const size)
{
  fprintf(target, "%.*s", size, value);
}

もちろん、書式設定された出力は依然として不可欠です。文字列用の AppendFormat 関数は次のとおりです。

template <typename ... Args>
void AppendFormat(std::string & target,
  char const * const format, Args ... args)
{
  int const back = target.size();
  int const size = snprintf(nullptr, 0, format, args ...);
  target.resize(back + size);
  snprintf(&target[back], size + 1, format, args ...);
}

ターゲットのサイズを変更して、テキストを直接文字列に書式設定する前に、まず、追加で必要な領域を判断します。バッファーに十分な空き領域があるかどうかをチェックすれば、snprintf を 2 度呼び出さなくてもかまわないように思えます。しかし、snprintf を常に 2 度呼び出すことには理由があります。非公式のテストでは、容量に合わせてサイズを変更するよりも、snprintf を 2 度呼び出す方がコストがかからないことが判明したためです。領域の確保は必要なくなりますが、余分な文字がゼロに設定され、コストが高くなる傾向があります。ただし、これは非常に主観的なもので、データのパターンやターゲット文字列を再利用する頻度に左右されます。以下は、printf 用の AppendFormat 関数です。

template <typename P, typename ... Args>
void AppendFormat(P target, char const * const format, Args ... args)
{
  target(format, args ...);
}

ファイル出力用のオーバーロードは、同じように簡単です。

template <typename ... Args>
void AppendFormat(FILE * target,
  char const * const format, Args ... args)
{
  fprintf(target, format, args ...);
}

これで、Write ドライバー関数用のビルディング ブロックを 1 つ用意できました。他に必要なビルディング ブロックは、引数の書式設定を処理する汎用の方法です。2015 年 3 月号のコラムで示したテクニックはシンプルで洗練されたものでしたが、printf がサポートする型に直接対応しない値を処理する機能が欠けていました。また、引数展開や、ユーザー定義型などの複雑な引数値は扱いませんでした。ここでも、オーバーロード関数のセットを使用すると、非常に洗練された方法で問題を解決できます。Write ドライバー関数が各引数を WriteArgument 関数に渡すとします。文字列の場合は次のようになります。

template <typename Target>
void WriteArgument(Target & target, std::string const & value)
{
  Append(target, value.c_str(), value.size());
}

WriteArgument 関数のバリエーションは、常に 2 つの引数を受け取ります。最初の引数はジェネリック型のターゲットを表し、2 つ目の引数は書き込み用の特定の引数です。ここでは、Append 関数が存在することを利用して、ターゲットを一致させ、値をターゲットの末尾に追加する処理を行います。WriteArgument 関数は実際のターゲットを認識する必要がありません。ターゲット アダプター関数を避けることができるかもしれませんが、そうすると WriteArgument のオーバーロードの二次成長が発生します。次に、引数が整数の場合の WriteArgument 関数を示します。

template <typename Target>
void WriteArgument(Target & target, int const value)
{
  AppendFormat(target, "%d", value);
}

この場合、WriteArgument 関数はターゲットに一致する AppendFormat 関数を想定します。Append および AppendFormat のオーバーロードと同様に、追加で WriteArgument 関数を作成するのが簡単です。この手法が優れている点は、引数アダプターが 2015 年 3 月号のバージョンと同じく、値をスタックに積み上げて printf 関数に返す必要がないことです。代わりに、WriteArgument オーバーロードでは、実際には出力のスコープを設定してターゲットを直接書き込むようにします。つまり、複雑な型を引数に使用でき、一時ストレージを利用してテキスト表記に書式設定することもできます。以下に、GUID 用の WriteArgument オーバーロードを示します。

template <typename Target>
void WriteArgument(Target & target, GUID const & value)
{
  wchar_t buffer[39];
  StringFromGUID2(value, buffer, _countof(buffer));
  AppendFormat(target, "%.*ls", 36, buffer + 1);
}

Windows StringFromGUID2 関数を置き換えて直接書式設定して、おそらく、パフォーマンスを向上し移植性を高めることもできましたが、上記の方がその能力と柔軟性が明確に示されます。ユーザー定義型は、WriteArgument オーバーロードを追加することで簡単にサポートできます。ここではユーザー定義型をオーバーロードと呼んでいますが、厳密にはそうとは言えません。出力ライブラリでは、一般的なターゲットと引数の一連のオーバーロードを提供できることは明らかですが、Write ドライバー関数ではアダプター関数をオーバーロードであると想定すべきではありません。代わりに、アダプター関数を標準 C++ ライブラリで定義されている非メンバーの begin 関数と end 関数のように扱うべきです。非メンバーの begin 関数と end 関数は拡張可能で、標準および非標準のあらゆる種類のコンテナーに正確に適合できます。それは、この 2 つの関数が std 名前空間に存在する必要がないためです。しかし、対応する型の名前空間に対してローカルにします。同様に、それらのターゲットと引数アダプター関数は開発者のターゲットやユーザー定義引数をサポートするために、他の名前空間に存在できる必要があります。では、Write ドライバー関数はどのようになるでしょう。最初は、次のように Write 関数を 1 つだけ用意します。

template <typename Target, unsigned Count, typename ... Args>
void Write(Target & target,
  char const (&format)[Count], Args const & ... args)
{
  assert(Internal::CountPlaceholders(format) == sizeof ... (args));
  Internal::Write(target, format, Count - 1, args ...);
}

最初に必要なのは、書式文字列内のプレースホルダーの数と可変個引数パラメーター パック内の引数の数が同じかどうかを確認することです。ここでは、ランタイム アサートを使用していますが、実際にはコンパイル時に書式文字列をチェックする static_assert にすべきです。残念ながら、Visual C++ ではまだ対応されていません。そこで、コンパイラがこれに対応するようになったら、コンパイル時に書式文字列をチェックするコードに簡単に更新できるように作成します。そのためには、内部の CountPlaceholders 関数を constexpr にします。

constexpr unsigned CountPlaceholders(char const * const format)
{
  return (*format == '%') +
    (*format == '\0' ? 0 : CountPlaceholders(format + 1));
}

少なくとも constexpr に関して Visual C++ が C++14 に完全に準拠するようになれば、Write 関数内のアサートを static_assert に単純に置き換えることができます。次に、内部のオーバーロード Write 関数に着目し、コンパイル時に引数固有の出力を展開するようにします。ここでは、コンパイラを利用して、展開される可変個引数パラメーター パックを満たすために、内部の Write 関数の必要なオーバーロードを生成して呼び出すことができます。

template <typename Target, typename First, typename ... Rest>
void Write(Target & target, char const * const value,
  size_t const size, First const & first, Rest const & ... rest)
{
  // Magic goes here
}

そのしくみについては、後ほど説明します。最終的には、コンパイラでは引数が不足するため、操作を完了するには、可変個引数でないオーバーロードが必要になります。

template <typename Target>
void Write(Target & target, char const * const value, size_t const size)
{
  Append(target, value, size);
}

どちらの内部 Write 関数も値と値のサイズを受け取ります。可変個引数を受け取る Write 関数テンプレートは、値に少なくとも 1 つのプレースホルダーがあることを想定する必要があります。可変個引数でない引数を受け取る Write 関数はそのような想定を行う必要がなく、Append ジェネリック関数を使用して書式文字列の末尾に書き込むだけです。可変個引数数を受け取る Write 関数がその引数を書き込むには、まず、先頭から最初のプレースホルダーまたはメタ文字を見つけるまでの文字を書き込む必要があります。

size_t placeholder = 0;
while (value[placeholder] != '%')
{
  ++placeholder;
}

それを見つけて初めて、その前の文字を書き込むことができます。

assert(value[placeholder] == '%');
Append(target, value, placeholder);

最初の引数を書き込み、引数とプレースホルダーが残っていない状態になるまで処理を繰り返します。

WriteArgument(target, first);
Write(target, value + placeholder + 1, size - placeholder - 1, rest ...);

これで、図 1 の汎用出力をサポートできるようになります。さらに、非常に簡単に GUID を文字列に変換することもできます。

std::string text;
Write(text, "{%}", __uuidof(IUnknown));
assert(text == "{00000000-0000-0000-C000-000000000046}");

もう少し興味深い操作として、ベクトルの視覚化について考えてみます。

std::vector<int> const numbers{ 1, 2, 3, 4, 5, 6 };
std::string text;
Write(text, "{ % }", numbers);
assert(text == "{ 1, 2, 3, 4, 5, 6 }");

これを行うには、図 2 に示すように、引数としてベクトルを受け取る WriteArgument 関数テンプレートを作成する必要があるだけです。

図 2 ベクトルの可視化

template <typename Target, typename Value>
void WriteArgument(Target & target, std::vector<Value> const & values)
{
  for (size_t i = 0; i != values.size(); ++i)
  {
    if (i != 0)
    {
      WriteArgument(target, ", ");
    }
    WriteArgument(target, values[i]);
  }
}

ここでは、ベクトルの要素の型を強制していないことがわかります。つまり、同じ実装を使用して、文字列のベクトルも可視化できます。

std::vector<std::string> const names{ "Jim", "Jane", "June" };
std::string text;
Write(text, "{ % }", names);
assert(text == "{ Jim, Jane, June }");

当然、疑問が生じます。プレースホルダーをさらに展開する必要がある場合はどうすればよいでしょう。コンテナー用の WriteArgument を記述することはできますが、出力を微調整する柔軟性は備えていません。アプリの配色用パレットを定義する必要があり、原色と補色を用意するとします。

std::vector<std::string> const primary = { "Red", "Green", "Blue" };
std::vector<std::string> const secondary = { "Cyan", "Yellow" };

Write 関数を使用すれば、快適に書式設定が行われます。

Write(printf,
  "<Colors>%%</Colors>",
  primary,
  secondary);

しかし、求める出力ではありません。

<Colors>Red, Green, BlueCyan, Yellow</Colors>

明らかに不適切です。どれが原色でどれが補色かを把握できるように、色のマークアップにします。おそらく、次のようになります。

<Colors>
  <Primary>Red</Primary>
  <Primary>Green</Primary>
  <Primary>Blue</Primary>
  <Secondary>Cyan</Secondary>
  <Secondary>Yellow</Secondary>
</Colors>

そこで、このレベルの拡張性を提供できる WriteArgument 関数をもう 1 つ追加します。

template <typename Target, typename Arg>
void WriteArgument(Target & target, Arg const & value)
{
  value(target);
}

操作が反転しているように見えます。値をターゲットに渡すのではなく、ターゲットが値に渡されています。このようにすると、バインド関数を単なる値ではなく引数として提供できます。ユーザー定義の値だけでなく、ユーザー定義の動作をアタッチできます。ここで、求めている処理を実行する WriteColors 関数を示します。

void WriteColors(int (*target)(char const *, ...),
  std::vector<std::string> const & colors, std::string const & tag)
{
  for (std::string const & color : colors)
  {
    Write(target, "<%>%</%>", tag, color, tag);
  }
}

これは関数テンプレートではなく、1 つのターゲット用にハードコーディングする必要があることがわかります。これはターゲット固有のカスタマイズですが、Write ドライバー関数によって直接提供されるジェネリック型の推論を利用しない可能性も示しています。しかし、どうすれば大きな書き込み操作に組み込むことができるでしょう。次のように記述することを思い付くかもしれません。

Write(printf,
  "<Colors>\n%%</Colors>\n",
  WriteColors(printf, primary, "Primary"),
  WriteColors(printf, secondary, "Secondary"));

このコードがコンパイルされないということもありますが、実際には正しいシーケンスのイベントが提供されません。このコードが機能したとすると、最初の <Colors> タグの前に色が出力されることになります。明らかに、引数の場合と同様、指定された順序で呼び出されます。これは、新しい WriteArgument 関数テンプレートで実現できます。WriteColors が後で呼び出されるように、WriteColors の呼び出しをバインドする必要があります。さらに簡単に Write ドライバー関数を使用できるように、便利なバインド ラッパーを提供します。

template <typename F, typename ... Args>
auto Bind(F call, Args && ... args)
{
  return std::bind(call, std::placeholders::_1,
    std::forward<Args>(args) ...);
}

この Bind 関数テンプレートによって、書き込み先となる最終的なターゲット用にプレースホルダーが予約されます。色パレットは次のように正しく書式設定されます。

Write(printf,
  "<Colors>%%</Colors>\n",
  Bind(WriteColors, std::ref(primary), "Primary"),
  Bind(WriteColors, std::ref(secondary), "Secondary"));

期待どおりのタグ付きの出力になります。ref ヘルパー関数は厳密には必要ありませんが、呼び出しラッパー用のコンテナーをコピーできないようにします。

納得できませんか。可能性は無限です。ワイド文字と通常文字の両方に対して効率よく文字列引数を処理できます。

template <typename Target, unsigned Count>
void WriteArgument(Target & target, char const (&value)[Count])
{
  Append(target, value, Count - 1);
}
template <typename Target, unsigned Count>
void WriteArgument(Target & target, wchar_t const (&value)[Count])
{
  AppendFormat(target, "%.*ls", Count - 1, value);
}

このようにすると、さまざまな文字セットを使用して出力を簡単かつ安全に書き込むことができます。

Write(printf, "% %", "Hello", L"World");

出力を書き込む必要が特にないか最初はなかったとしても、必要な領域を計算する必要がある場合はどうでしょう。問題ありません。領域を集計する新しいターゲットを作成できます。

void Append(size_t & target, char const *, size_t const size)
{
  target += size;
}
template <typename ... Args>
void AppendFormat(size_t & target,
  char const * const format, Args ... args)
{
  target += snprintf(nullptr, 0, format, args ...);
}

これで必要なサイズを非常に簡単に計算できます。

size_t count = 0;
Write(count, "Hello %", 2015);
assert(count == strlen("Hello 2015"));

このソリューションは、パフォーマンス上のメリットの大半を維持しつつ、最終的には printf を直接使用する際に付いて回る型の欠点を解決するといえます。最新の C++ は、C と C++ で従来知られているパフォーマンスを維持しつつ、信頼性の高い型チェックが備わった生産性の高い環境を求める開発者のニーズを満たすことができます。


Kenny Kerr は、カナダを拠点とするコンピューター プログラマであり、Pluralsight の執筆者です。また、Microsoft MVP でもあります。彼のブログは kennykerr.ca (英語) で、Twitter は twitter.com/kennykerr (英語) でフォローできます。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの James McNellis に心より感謝いたします。