テンプレートクラスの「特殊化」と「明示的インスタンス生成」を同時に使う場合は注意が必要だ

テンプレート関数の明示的インスタンス生成で解説したように,テンプレートクラスとかテンプレート関数を明示的にインスタンス化しておくことで,

  • ヘッダファイルの肥大化(コンパイル速度の低下)
  • 望ましくない実装の公開

といった問題を避けられる.一方,テンプレートクラスやテンプレート関数で,ある型についてのみ特殊な実装を行う,「テンプレートの特殊化」という技術がある(過去の記事で何度か使っている).本記事では,インスタンス化と特殊化を同時に使うと,思わぬ落し穴にはまることを,サンプルを交えながら解説する.
以下の内容は標準規格などで確認できていないので,g++(4.3.3)に限った話かもしれないことを注意しておく.

まず,以下の3つのファイルからなるユニット unit1 を用意する:

ヘッダ:

/*! \file    unit1.h
    \date    Feb.19, 2009
*/
#ifndef unit1_h
#define unit1_h
#include <iostream>
namespace hogehoge
{

template <typename T>
struct TTest
{
  T x;
  void print (void) const;
};

}  // end of namespace hogehoge
#endif // unit1_h

ここでは,テンプレート構造体 TTest を定義している. TTest<T>::print の実装はこのファイルでは与えられていないから,これを include するだけではこの構造体を利用できない.

実装ヘッダ:

/*! \file    unit1_impl.h
    \date    Feb.19, 2009
*/
#ifndef unit1_impl_h
#define unit1_impl_h
#include "unit1.h"
namespace hogehoge
{

template <typename T>
void TTest<T>::print (void) const
{
  std::cout<<"x is "<<x<<std::endl;
}

}  // end of namespace hogehoge
#endif // unit1_impl_h

ここでは,TTest<T>::print の実装を与えている.よって, unit1_impl.h を include すれば,すべての型に対して TTest が利用できるようになる.

特殊化と明示的インスタンス化:

/*! \file    unit1.cpp
    \date    Feb.19, 2009
*/
#include "unit1.h"
#include "unit1_impl.h"
#include <iomanip>
namespace hogehoge
{
using namespace std;

// specialization (特殊化)
template <>
void TTest<int>::print (void) const
{
  cout<<"x= 0x"<<hex<<x<<dec<<endl;
}

// explicit instantiation (明示的インスタンス化)
template class TTest<int>;
template class TTest<double>;

}  // end of namespace hogehoge

このファイルでは,コンパイルコストを下げる目的で, TTest<T> を T=int と T=double に対して明示的にインスタンス化している.さらに, TTest<int>::print については特殊な実装を与えている(部分的特殊化).この unit1.cpp をコンパイルして生成されたオブジェクトファイルをリンクすれば, unit1.h の include だけで, TTest<int> と TTest<double> が使えるようになる.つまり, unit1_impl.h (実装) を include する必要がなくなるのだ.

確認プログラム:

/*! \file    main1.cpp
    \date    Feb.19, 2009
*/
#include "unit1.h"
using namespace hogehoge;

int main(int argc, char**argv)
{
  TTest<int> a={10};
  TTest<double> b={2.5};
  a.print();
  b.print();
  return 0;
}

このプログラムで unit1 を確認しよう.コンパイルは以下の順で行う.

g++ -Wall unit1.cpp -c
g++ -Wall main1.cpp -c
g++ -Wall main1.o unit1.o

実行すると

x= 0xa
x is 2.5

という結果が得られ,特殊化に成功していること,インスタンス化によって main1.cpp から unit1_impl.h を include する必要がなくなっていることがわかる.理解できない人は, unit1.cpp のインスタンス化の部分をコメントアウトしてみよう.リンカエラーが起きるはずだ.

さて.ここからが本題だ.別のユニット unit2 を用意する.

ヘッダ:

/*! \file    unit2.h
    \date    Feb.19, 2009
*/
#ifndef unit2_h
#define unit2_h
#include <string>
namespace hogehoge
{

void str_print (const std::string &str);

}  // end of namespace hogehoge
#endif // unit2_h

ここでは, str_print という関数を定義しているだけ.

実装:

/*! \file    unit2.cpp
    \date    Feb.19, 2009
*/
#include "unit2.h"
#include "unit1.h"
#include "unit1_impl.h"
  // TTest<T> は T=string に対してインスタンス化されていないから
  // 実装ファイルを include する必要がある
namespace hogehoge
{
using namespace std;

void str_print (const std::string &str)
{
  TTest<string> test= {str};
  test.print();
}

// void dummy (void)
// {
//   TTest<int> test1= {50};
//   TTest<double> test2= {5.0};
//   test1.print();
//   test2.print();
// }

}  // end of namespace hogehoge

ここでは, str_print の実装を与えている.この関数では TTest<string> を利用していて,これは unit1 でインスタンス化されていないから, unit1_impl.h を include していることに注意しよう. dummy は今は気にしない.

確認プログラム:

/*! \file    main2.cpp
    \date    Feb.19, 2009
*/
#include "unit1.h"
#include "unit2.h"
using namespace hogehoge;

int main(int argc, char**argv)
{
  str_print ("hoge");
  TTest<int> a={10};
  TTest<double> b={2.5};
  a.print();
  b.print();
  return 0;
}

コンパイルは,

g++ -Wall unit1.cpp -c
ar r unit1.a unit1.o
g++ -Wall unit2.cpp -c
g++ -Wall main2.cpp -c
g++ -Wall unit2.o main2.o unit1.a

とする.2行目でアーカイブ化しているのは, unit1 がライブラリだと想定しているからだ(これも後の話と絡む).実行すると,

x is hoge
x= 0xa
x is 2.5

という結果が得られ,ちゃんと TTest<int>::print が特殊化されているし,何も問題がない.

ところが,unit2.cpp の dummy 関数のコメントアウトを消して,上と同じ手順でコンパイル,実行してみよう.すると,

x is hoge
x is 10
x is 2.5

という結果になる.なんと,TTest<int>::print の特殊化が消えているではないか.

なぜこんなことが起こったのか.

unit2.cpp の dummy 関数で TTest<int>::print を使用したことにより, unit2 で TTest<int> が生成されたから.

と考えるかも知れない.が,そうではない.ためしに dummy 関数を

void dummy (void)
{
  TTest<int> test1= {50};
//   TTest<double> test2= {5.0};
  test1.print();
//   test2.print();
}

のように変えてみよう.まだ TTest<int>::print を使用しているため, unit2 で TTest<int> が生成される.だが,この結果は,

x is hoge
x= 0xa
x is 2.5

となる.特殊化が有効になっている.

おそらく,正解は以下のようなものだ(と考えられる):

  • unit2 で TTest<int>::print と TTest<double>::print を使用したことにより, unit1.a に含まれる unit1.o をリンクする必要がないとリンカが判断した
  • このため unit1.o をリンクせずに実行ファイルが生成された

もし, unit1.a ではなく, unit1.o を直接リンクしていたら,特殊化が有効になっているという事実が,これを裏付けている:

g++ -Wall unit1.cpp -c
g++ -Wall unit2.cpp -c
g++ -Wall main2.cpp -c
g++ -Wall unit2.o main2.o unit1.o

のようにコンパイルし実行すると(dummyはまったくコメントアウトされていないものを使用),

x is hoge
x= 0xa
x is 2.5

のように特殊化された結果が得られるのだ.

ここまでのまとめ.

上のような結果は,テンプレートの特殊化と明示的インスタンス化を同時に使うことで発生し,コンパイル時には発見されない,原因を見付けにくいバグとなり得る.頻繁に起きる問題ではないが,だからこそ解決しにくいのではないかと思われる.テンプレートの特殊化と明示的インスタンス化を同時に使う場合はご注意.

で,対策は?

眠いからまた今度ということで.