例外処理、第 14 部

Robert Schmidt
Microsoft Corporation

2000 年 1 月 20 日

前回、私は例外安全性の解説を始めました。今回は、テンプレート安全性について説明しようと思います。

テンプレートは、引数として渡される型と相互に作用します。テンプレートに渡される型が持つ、完全なセマンティックの概要は、一般に知ることはできません。そのため、それらがどこで例外を生成するかについても、確実に知ることはできません。普通、期待できるベストと言えば、それらの型がスローする可能性のある場所を見つけだすことです。こういった発見は、とてつもなく困難な場合があります。

以下の簡単なクラス テンプレートの例を考えてみましょう。

  template <typename T>
class wrapper
   {
public:
   wrapper()
      {
      }
   T get()
      {
      return value_;
      }
   void set(T const &value)
      {
      value_ = value;
      }
private:
   T value_;
   wrapper(wrapper const &);
   wrapper &operator=(wrapper const &);
   };

名前から想像できるように、wrapper は引数 T 型のオブジェクト 1 つを薄くラップします。get および set ルーチンは、ラップされた private の value_ の値を取り出したり、変更したりします。いつも登場するコピー コンストラクタとコピー代入演算子の 2 つは使われず、実装されていません。その一方で、3 番目のデストラクタは、コンパイラによって暗黙のうちに定義されています。

インスタンス化は簡単です。たとえば次は、1 つの int をラップします。

  wrapper<int> i;

この i の定義により、翻訳系がテンプレートに基づいてクラス定義 wrapper<int> をインスタンス化させます。

  template <>
class wrapper<int>
   {
public:
   wrapper()
      {
      }
   int get()
      {
      return value_;
      }
   void set(int const &value)
      {
      value_ = value;
      }
private:
   int value_;
   wrapper(wrapper const &);
   wrapper &operator=(wrapper const &);
   };

wrapper<int>int だけを受け入れ、int だけを参照します。つまり、組み込みの型と、組み込みの型への参照だけを処理します。そのため、例外が発生する条件は成立しません。wrapper<int> は例外をスローしませんし、例外をスローするルーチンも、(直接的にも間接的にも)一切呼び出しません。具体的な解説は省きますが、私を信用してください。wrapper<int> は例外セーフなのです。

クラス型引数

今度は、次の場合を考えてみます。

  wrapper<X> x;

ここでの X は、何らかのクラス型です。この定義を受けて、コンパイラは wrapper<X> クラスをインスタンス化します。

  template <>
class wrapper<X>
   {
public:
   wrapper()
      {
      }
   X get()
      {
      return value_;
      }
   void set(X const &value)
      {
      value_ = value;
      }
private:
   X value_;
   wrapper(wrapper const &);
   wrapper &operator=(wrapper const &);
   };

一見、この定義は無害で、例外が発生しないように見えるかもしれません。しかし、以下を考えてください。

  • wrapper<X> には、X のサブオブジェクトが含まれています。そのサブオブジェクトは生成の必要がありますが、これは X のデフォルト コンストラクタを呼び出されることを意味します。そのコンストラクタが例外をスローする可能性があります。

  • wrapper<X>::get は、一時的な X オブジェクトを生成して返します。一時オブジェクトをつくるために、getX のコピー コンストラクタを呼び出します。そのコンストラクタが例外をスローする可能性があります。

  • wrapper<X>::set は、式 value_ = value を評価します。これは実際には、X の代入演算子の呼び出しです。この演算子が例外をスローする可能性があります。

wrapper<int> ではスローすることのない組み込み操作だったものが、wrapper<X> ではスローする可能性のある関数呼び出しになっています。テンプレートも構文の見かけも同じですが、意味が大きく異なります。

この曖昧さを考慮して、用心深い戦略を採用しなければなりません。wrapper はクラス型に応じてインスタンス化されます。それらのクラス型のメンバについては、クラスに例外仕様がないと仮定し、メンバはすべて例外をスローする可能性があると想定しましょう。

wrapper を安全にする

また、wrapper のメンバは例外をリークしないことを wrapper の仕様が保証すると仮定します。少なくとも、それら wrapper のメンバに、throw() 例外仕様を加えなければなりません。また、例外をリークする可能性のある以下の点も修正しなければなりません。

  1. wrapper::wrapper 内で value_ を生成する。

  2. wrapper::get 内で value_ を返す。

  3. wrapper::set 内で value_ を割り当てている。

さもなければ、throw() 仕様違反の罪で、std::unexpected に服すことになるでしょう。

リーク 1:デフォルト コンストラクタ

インターフェイス安全性のために、wrapper のデフォルト コンストラクタに関数 try ブロックを使うのは、誰もが考える方法だと思われます。

  wrapper() throw()
   try : T()
      {
      }
   catch (...)
      {
      }

十分魅力的に見えますが、これは動作しません。C++ 標準規格(15.3/16 節「例外処理」)には次のようにあります。

コンストラクタ、またはデストラクタの関数 try ブロックでは、ハンドラの終端に制御が到達すると、処理されている例外が再スローされます。それ以外の場合、関数 try ブロックのハンドラの終端に制御が達すると、関数は戻ります。関数 try ブロックの終端を抜けていくことは、値を返さない return と同等になります。その場合、値を返す関数では、動作は未定義となります。

翻訳:上記の関数 try ブロックは、以下に記述したように振る舞います。

  X::X() throw()
   try : T()
      {
      }
   catch (...)
      {
      throw;
      }

しかし私は、こんなことがしたいのではありません。

実際にしたいのは次のとおりのことです。

  X::X() throw()
   try
      {
      }
   catch (...)
      {
      return;
      }

しかし、これは 15 節とぶつかります。

コンストラクタの関数 try ブロックのハンドラ内に return 命令があるプログラムは、文法違反になります。

標準規格を隅々まで調べ上げ、関数 try ブロックをサポートするいくつかの翻訳系でもテストをしてみました。しかし、私が望んでいるような動作をさせる方法は見つかりませんでした。どのような方法を使っても、キャッチされた例外は依然として再スローされ、throw() 仕様に違反していまい、私の目標であるインターフェイス安全性は無効になるのです。

**教訓:**関数 try ブロックは、インターフェイス安全性を実施するために使用することはできません。

**付随的教訓 1:**使える場所では、コンストラクタがスローしない、基本とメンバのサブオブジェクトを使用します。

**付随的教訓 2:**よき市民として正しく振る舞い、他が付随的教訓 #1 に応えられるように、いかなるコンストラクタからもスローしてはいけません(この教訓は、第 13 部で示した意見の矛盾を解消します)。

標準規格のルールは非常に奇妙です。というのも、関数 try ブロックの真価、つまり、サブオブジェクトのコンストラクタ(例えば T::T() など)によってスローされた例外を、サブオブジェクトが含まれているコンストラクタ(wrapper::wrapper())に渡る前に捕らえるという役割が損なわれるからです。実のところ、関数 try ブロックは、こういった例外をキャッチできる、唯一の方法です。それなのに、その例外をキャッチしても、内々に処理できないのです!

関数 try ブロックは例外をマップしたり、変換したりできます。

  X::X()
   try
      {
      throw 1;
      }
   catch (int)
      {
      throw 1L; // map int exception to long exception
      }

この点に関して、このブロックは unexpected ハンドラにとても良く似ています。実際これが、このブロックの設計された目的なのではないかと疑っています。つまり、少なくともコンストラクタについては、これは例外ハンドラというよりはむしろ例外フィルタです。これについては、もっと調査して、これらのルールに隠された論理的な説明を発見したいと思っています。見つけたことは、みなさんに報告します。

少なくとも現時点では、あまり直接的でない解決法を採ることが宿命のようです。

  template <typename T>
class wrapper
   {
public:
   wrapper() throw()
         : value_(NULL)
      {
      try
         {
         value_ = new T;
         }
      catch (...)
         {
         }
      }
   // ...
private:
   T *value_;
   // ...
   };

wrapper::wrapper() に入る前に生成されていた被ラップ オブジェクトは、ここでは wrapper::warapper() の本体の中で生成されています。このように変更すれば、関数 try ブロックのない状態で「通常の」形で例外をキャッチできます。

ここでは value_T から T * に変わったので、getset を、それぞれポインタを処理するように変えなければなりません。

  T get()
   {
   return *value_;
   }

void set(T const &value)
   {
   *value_ = value;
   }

リーク 1A:operator new

コンストラクタの try ブロックには次のステートメントがあります。

  value_ = new T;

これは、operator new を暗黙のうちに呼び出して、*value_ のメモリを割り当てます。この operator new 関数はスローする可能性があります。

幸い私達の wrapper::wrapper() は、T コンストラクタと operator new のどちらからのスローでもキャッチします。このお陰でインターフェイス安全性が維持されます。しかし、以下の重要な違いに注意してください。

  • T コンストラクタがスローすると、operator delete が暗黙のうちに呼び出され、割り当てられたメモリを解放します(ここでは配置 new に対応する operator delete が存在すると仮定しています。これは第 8部第 9部 で説明しています)。

  • operator new がスローしても、operator delete が暗黙のうちに呼び出されることはありません。

一般に、この 2 番目の点が問題になることはありません。operator new がスローする一般的な原因はメモリ割り当ての失敗です。したがって、operator delete が解放すべきものが何もないということです。しかし、operator new がメモリを割り当てて、別の何らかの理由でスローした場合、operator new には割り当てたメモリを解放する責任があります。言い換えれば、operator new 自体に実装安全性が備わっていなければならない、ということです。

operator new[] による配列の割り当ての場合も同様です。)

リーク 1B:デストラクタ

wrapper に実装安全性を与えるためには、new による割り当てを解放するためのデストラクタが必要です。

  ~wrapper() throw()
   {
   delete value_;
   }

たやすいことのように思えます。でも、ちょっと待ってください!結局 delete value_ は、*value_ のデストラクタを呼び出します。そのデストラクタがスローする可能性があります。~wrapper のインターフェイスを安全にするためには、try ブロックを追加しなければなりません。

  ~wrapper() throw()
   {
   try
      {
      delete value_;
      }
   catch (...)
      {
      }
   }

これでもまだ十分ではありません。*value_ のデストラクタがスローした場合には、operator delete は呼び出されないので、*value_ のメモリも解放されません。このため、実装安全性を加える必要があります。

  ~wrapper() throw()
   {
   try
      {
      delete value_;
      }
   catch (...)
      {
      operator delete(value_);
      }
   }

これでもまだ完成ではありません。標準ライブラリでは、operator delete が以下のように宣言されています。

  void operator delete(void *) throw();

これはスローしません。しかし、スローする operator delete を誰かが定義してしまうことを、防げるものは何もありません。さらに安全を考えて、以下のように記述しなければなりません。

  ~wrapper() throw()
   {
   try
      {
      delete value_;
      }
   catch (...)
      {
      try
         {
         operator delete(value_);
         }
      catch (...)
         {
         }
      }
   }

しかし、これでも危険です。以下のステートメントを考えてみてください。

  delete value_;

これは、暗黙のうちに operator delete を呼び出します。その関数がスローすると、例外は catch 句に到着します。そして・・・また戻って同じ operator delete を呼び出してしまうのです!その結果、プログラムは、例外が発生する原因となっている状況に立て続けに 2 回もさらされることになります。これでは、堅牢なソフトウェアの作り方とはいえません。

最後に、new によって生成されるオブジェクトのコンストラクタがスローすると operator delete が暗黙のうちに呼び出されるということを思い出してください。この暗黙のうちに呼び出された operator delete もスローしてしまうと、プログラムの例外処理が二重になり、terminate を呼び出すことになります。

**教訓:**先行するスローの処理が進行中に呼び出される可能性がある関数からは、スローしてはいけません。特に、以下からのスローは絶対禁止です。

  • デストラクタ

  • operator delete

  • operator delete[]

では、勉強家のためにちょっとした演習です。value_auto_ptr に置き換えてみてください。wrapper コンストラクタを書き換えて、wrapper デストラクタの役割を(もしあれば)解決してください。この間、例外安全性は維持します。

延長戦

元々は、1 回のコラムで例外安全性を終わらせるつもりでした。ところが、これが既に 2 回目のコラムで、十分 3 回目が書けるほどの材料もまだ持っています。次回を最後にすることを誓います!次回は getset に関連した例外安全性の問題についてです。また、約束の推薦図書についても書く予定です。

Robert Schmidt は MSDN のテクニカル ライターです。このほかに彼が寄稿している雑誌に、C/C++ Users Journal があります。そこでは、彼は編集補助およびコラムニストとして活躍しています。これまでのキャリアで、ラジオ DJ、野生生物飼育係、天文学者、ビリヤード場管理者、探偵、新聞配達夫、そして大学講師を経験しています。

Deep C++用語集