8月21日、Raymond Chenが「Reducing C++ template bloat by factoring out the type-dependent portions of the function, practical exam」と題した記事を公開した。C++のテンプレート関数からラムダ依存部分を切り離すことでテンプレート肥大化を解消する実践的手法が、段階的なリファクタリングの形で解説されている。
最終的な結論は鮮やかだ。ラムダを受け取るテンプレート関数は、引数の型を適切な共通型に変えるだけでテンプレートを完全に撤廃できる。コードの見た目はほぼ変わらず、呼び出し元の修正も不要。それでいてコンパイル時間の短縮とバイナリサイズの削減が得られる。
ラムダを受け取るテンプレート関数の「肥大化問題」
C++でラムダを受け取る関数をテンプレートで実装するのはごく自然なパターンだ。しかし、ラムダは呼び出しのたびに固有の型を生成するという性質を持つ。テンプレート関数にラムダを渡すたびにコンパイラは新たな関数インスタンスを生成し、それがコンパイル時間の増大とバイナリサイズの膨張につながる。これが「テンプレート肥大化(template bloat)」だ。大規模なコードベースやリソースの限られた組み込み・モバイル環境では無視できないコストになる。
記事の出発点は次のコードだ。
template<typename Lambda>
bool Widget::QueueToWorkerThread(Lambda&& lambda)
{
CreateWorkerThreadIfNeeded();
return m_dispatcherQueue.TryEnqueue(
std::forward<Lambda>(lambda));
}
呼び出しのたびに異なる型のラムダが渡されるため、この関数はその都度インスタンス化される。特定のプロジェクトでは数十〜数百の異なるラムダがこの関数を通ることもあり、肥大化の原因になりやすい。
段階的なリファクタリング:共通型への変換
解決策の核心は「型に依存する処理をテンプレートの外に押し出す」ことだ。
この関数でラムダに対して行っている処理は、winrt::DispatcherQueueHandler(WinRTのディスパッチャーキューハンドラー型)の構築に使うことだけだ。であれば、ラムダをまずDispatcherQueueHandlerへ変換し、非テンプレートのワーカー関数に渡す構造にリファクタリングできる。
bool Widget::QueueToWorkerThreadWorker(
winrt::DispatcherQueueHandler const& handler)
{
CreateWorkerThreadIfNeeded();
return m_dispatcherQueue.TryEnqueue(handler);
}
template<typename Lambda>
bool Widget::QueueToWorkerThread(Lambda&& lambda)
{
winrt::DispatcherQueueHandler handler(std::forward<Lambda>(lambda));
return QueueToWorkerThreadWorker(handler);
}
テンプレート部分は「ラムダを共通型に変換する薄いラッパー」に限定され、実際の処理は非テンプレートのQueueToWorkerThreadWorkerが担う。これにより、コア処理のインスタンス化を1回に抑えられる。
Chenは処理順序の変化についても丁寧に言及している。元の実装ではCreateWorkerThreadIfNeeded()が例外を投げるとラムダはmove-from状態になるが、このようなラムダは呼び出し元で使い捨てにされることがほとんどなので実害はない。また、DispatcherQueueHandlerの構築が失敗した場合にCreateWorkerThreadIfNeeded()が呼ばれないケースも、このユースケースでは問題にならないとされている。
さらに一歩:テンプレートを完全に排除する
ここからが本記事の核心だ。
「ラムダをDispatcherQueueHandlerに変換する」という処理は、関数の引数として渡す時点でC++の変換コンストラクタが自動的に行う。変換コンストラクタとは、異なる型から自クラスのオブジェクトを構築するコンストラクタのことで、explicitが付いていなければ暗黙の型変換として機能する。つまりテンプレートそのものが不要になる。
bool Widget::QueueToWorkerThread(
winrt::DispatcherQueueHandler const& handler)
{
CreateWorkerThreadIfNeeded();
return m_dispatcherQueue.TryEnqueue(handler);
}
呼び出し元がラムダを渡すと、DispatcherQueueHandlerの変換コンストラクタがコールサイトで自動的に起動し、関数に届く時点ですでにDispatcherQueueHandler型になっている。テンプレートが完全に消え、通常の関数1つで済む。
リファクタリングの3段階を整理すると次のとおりだ。
| ステップ | 構造 | 効果 |
|---|---|---|
| 出発点 | template<typename Lambda>でラムダを直接受け取る |
呼び出しごとにインスタンス化が発生 |
| 中間形 | テンプレートを薄いラッパーに限定、ロジックを非テンプレートに移す | コア処理のインスタンス化を排除 |
| 最終形 | 変換コンストラクタに変換を委ね、テンプレートを完全撤廃 | 通常の関数1つに収束 |
std::functionへの応用
WinRT固有の話ではなく、std::functionや独自のCallableラッパーを持つ設計であれば同じ原則をそのまま適用できる。たとえば、std::function<void()>を引数に取るように変更するだけで、任意の引数なしラムダを受け取れる非テンプレート関数が実現する。
bool Widget::QueueToWorkerThread(std::function<void()> const& handler)
{
CreateWorkerThreadIfNeeded();
return m_dispatcherQueue.TryEnqueue(handler);
}
ただしstd::functionには動的メモリ確保のオーバーヘッドがあるため、パフォーマンスが最優先の場面ではDispatcherQueueHandlerのような専用型を使う方が適切だ。テンプレートを撤廃するコストと実行時コストのトレードオフを意識したい。
今回紹介した手法は、Chenが以前から繰り返し解説してきた「型依存部分だけをテンプレートに残す」という原則の実践例だ。ラムダのような多様な型を受け取りたい場合でも、共通の型へ変換できる仕組みがあれば、テンプレートを書く必要はない。「テンプレートを書く前に、共通型へ変換できないか考える」という一手間が、大規模コードベースでの保守性を大きく左右する。
詳細はReducing C++ template bloat by factoring out the type-dependent portions of the function, practical examを参照していただきたい。