ゴーザー・ザ・デストラクタ

Bobby Schmidt
Microsoft Corporation

2000 年 10 月 20 日

C# への移行を検討している C++ プログラマを悩ませている数々の問題の中で、最も気がかりなのはおそらく、正確に同期する、または確定的デストラクタが C# にない点でしょう。

DevelopMentor によるメーリング リスト DOTNET に登録している方は、この問題を巡る大論争を目の当たりにしているはずです。メーリング リストの参加者たちの感情が一般的な C++ プログラマの意見を代表しているとすれば、皆さんの多くが、デストラクタを省略したことは Microsoft の戦略ミスだと考えていることになります。

私は、C#(そして暗黙的に .NET Runtime)が真のデストラクタをサポートすべきかどうかの泥沼の論争に加わるつもりはありません。C# がデストラクタを備えていないこと、そして C++ から移行する多くのプログラマがデストラクタのサポートを望んでいるという事実を受け入れるだけです。そのような状況をふまえ、私は C++ の視点から問題を分析し、C# の動作を C++ に近づけるために C++ のプログラマがとるべきアプローチを提言します。

このコラムの目的を考えて、まず C++ のデストラクタの 3 つの基本的な特性を明確にします。

  • C++ のデストラクタは、プログラマが使い方を意識したり、ソースコードで明示的に書いたりしなくても暗黙理に呼び出される。

  • C++ のデストラクタは、実行経路の予測可能な場所で呼び出される。

  • C++ のデストラクタは、予測可能な、相対的な順序で呼び出される。

これらの特性をより詳しく説明し、C# のメカニズムをその特性に近づけるための提案をします。

暗黙理に呼び出される

C++ のデストラクタは、オブジェクトが破棄されるときに暗黙理に呼び出されます。

  // C++

class X
   {
   };

void f()
   {
   X x;
   // ...
   } // x.~X() called here implicitly

プログラマは明示的にデストラクタを呼び出すこともできます。

  // C++

X x;
// ...
x.~X();

ただし通常は、オブジェクトのインプレース置き換えのために次のような使い方をします。

  // C++

X x(1);       // allocate and construct x
// ...
x.~X();       // call destructor, but don't deallocate x's memory
new(&x) X(2); // construct within x's memory

これに対して、C# も Microsoft がデストラクタと呼んでいる関数をサポートしています。これらの関数は、C++ のデストラクタと同じく暗黙理に呼び出されます。この場合は、C# ランタイム システムのガベージ コレクタによって呼び出されます。基本構文は見慣れている形式です。

  // C#

class X
   {
   ~X()
      {
      // ...
      }
   }

または、もっと冗長な構文でも宣言できます。

  // C#

class X
   {
   protected override void Finalize()
      {
      // ...
      base.Finalize();
      }
   }

どちらの定義も効果は同じです。実際に ildasm.exe を使って、生成されたそれぞれの中間言語(IL)を見ると、どちらも C# コンパイラが Finalize という名前を出力していることがわかります。

~X による base.Finalize の暗黙的な呼び出しについて疑問に思うかもしれません。C++ では、派生オブジェクトのデストラクタは、基本ククラスのサブオブジェクトを自動的に破棄するようになっています。一方、C# のファイナライザは本来、他のファイナライザ(基本クラスのファイナライザも含め)を呼び出しません。しかし、派生オブジェクトとその基本オブジェクトの両方を 1 回の呼び出しでファイナライズしたい場合もあります。 さらに、C# で記述するクラスにはすべて、必ず 1 つの基本オブジェクトがあります(その基本オブジェクトが暗黙的に System.Object である場合もありますが)。このため base.Finalize() の呼び出しは必ず実際の呼び出しとして解決されます。

以後は、C# のデストラクタのことをファイナライザと呼ぶことにします。この呼び方には 3 つの目的があります。

  • 「デストラクタ」という用語を避けることで、この関数が C++ のデストラクタと同じ動作をするという誤解を招くのを防ぐ。

  • 「ファイナライザ」という用語は、この関数の目的、すなわちメモリの解放前にオブジェクトのリソースの最終的な後始末をするという動作をより的確に表している。

  • この言葉はコンパイラによって出力され、ランタイムによって参照される実際の関数名(Finalize)にも合致している。

暗黙的なファイナライザの呼び出しを観察するために、次のプログラムを実行します。

  // C#

class X
   {
   public X()
      {
      System.Console.WriteLine(" X()");
      }
   ~X()
      {
      System.Console.WriteLine("~X()");
      }
   }

class main
   {
   static void Main()
      {
      new X();
      System.GC.Collect();
      System.GC.WaitForPendingFinalizers();
      }
   }

/* output

 X()
~X()

*/

System.GC メソッドは、ガベージ コレクタに対して、ファイナライザの暗黙的な呼び出しを強制的にスケジュールさせ、呼び出しが完了するのを待ちます。

つまらない名前に記号付きのエイリアスを割り当てることができるのは、~XFinalize に限ったことではありません。後で、operator+ 関数の例を紹介します。この関数用に生成された IL を読むと、コンパイラは関数名を op_Addition として出力していることがわかります。

**考察:**すべての .NET 言語が関数の名前に ~+ などの記号を使用できるわけではありません。C# コンパイラは上記のように「ネイティブな」名前を変換することによって、他の .NET 言語と可能な限り馴染むようにしています。

予測可能な場所で呼び出される

C++ デストラクタは、次の 5 つの場所で呼び出されることが明確に決まっています。

  • 静的オブジェクトの場合は、プログラムの終了時。

  • 自動オブジェクトまたは一時オブジェクトの場合は、オブジェクトが破棄されるとき。

  • 動的オブジェクトの場合は、delete 式によって。

  • 完全生成コンテナ内のサブオブジェクトの場合は、コンテナのデストラクタが終了するとき。

  • 部分生成コンテナ内のサブオブジェクトの場合は、コンテナのコンストラクタが(例外処理によって)終了するとき。

それぞれのケースの例:

  // C++

class X
   {
   };

X s;

class C
   {
   C()
      {
      throw 0; // case #5 -- m destroyed
      }
   ~C()
      {
      } // case #4 -- m destroyed
   X m;
   };

void f()
   {
   X a;
   X *d = new X;
   delete d; // case #3 -- *d destroyed
   }         // case #2 --  a destroyed

int main()
   {
   f();
   return 0;
   } // case #1 -- s destroyed

C++ プログラムの実行経路を文字通りの物理的な経路として考えた場合、あるオブジェクトに関する実行経路は、順番に実行される 3 つのセグメントに分割できます。

  • セグメント 1。オブジェクトのデストラクタが呼び出される前。

  • セグメント 2。オブジェクトのデストラクタが呼び出される瞬間。

  • セグメント 3。オブジェクトのデストラクタが呼び出された後。

セグメント 2 は、コードの流れにおいては概念上の1点であり、他の 2 つのセグメントはそこから、それぞれ経路の始点または終点まで延びます。

(シングル スレッド プログラムだけを想定しています。ご了承ください)。

C# セグメント

C# のプログラムにも、オブジェクトについても制御の流れを示すセグメントが 3 つあります。

  • セグメント 1。オブジェクトのファイナライザが呼び出される前。

  • セグメント 2。オブジェクトのファイナライザが呼び出される可能性のある時点。

  • セグメント 3。オブジェクトのファイナライザが呼び出された後。

セグメント 2 は、C++ では 1 つの点ですが、C# では範囲を持つ本当のセグメントです。つまり、C# のプログラムには、コードの流れにおいてオブジェクトのファイナライザが呼び出されるポイントが複数あるということです。

一般的に、セグメント 2 はオブジェクトが参照されなくなった場所から始まります。その場所から先は、ガベージ コレクタが、オブジェクトのファイナライザを呼び出すそれ以降の場所をスケジュールします(かなり離れた場所になる場合もあります)。ファイナライザが実際に呼び出された時点が、セグメントの終わりとなります。プログラムの編成と、ガベージ コレクタの設定によっては、プログラムが終了するときにもファイナライザが呼び出されないこともあります。その場合、セグメント 3 は存在しません。

C++ に近づける

C# のファイナライザを強制的に C++ のデストラクタに近づけることができます。1 つの簡単な方法は、ファイナライザを明示的に呼び出すことです。

  // C#

class X
   {
   ~X()
      {
      }
   static void Main()
      {
      X x = new X();
      x.Finalize(); // calls ~X()
      }
   }

メソッドを ~X として宣言し、Finalize として呼び出している点に注目してください。前に述べたように、どちらも同じ関数のエイリアス名です。

~X という表記は暗黙理に Finalizeprotected として宣言するので、FinalizeX、または X から派生したクラスの中からしか呼び出せません。

  // C#

class X
   {
   ~X()
      {
      }
   }

class main
   {
   static void Main()
      {
      X x = new X();
      x.Finalize(); // error
      }
   }

この手法は一部の名前の付いていない一時的オブジェクト(すべてではない)にも適用できます。

  // C#

class X
   {
   X(int value)
      {
      this.value = value;
      }
   public static X operator+(X left, X right)
      {
      return new X(left.value + right.value);
      }
   private int value;
   static void Main()
      {
      X a, b, c;
      a = b = c = new X(0);
      (a + b).Finalize();      // OK
      (a + b + c).Finalize();  // misses temporary yielded by a + b
      }
   }

複数のファイナライズを防ぐ

C# のファイナライザを明示的に呼び出したときでも、ガベージ コレクタによって暗黙理に再度呼び出されるのを防ぐことはできません。そのため、1 つのオブジェクトが 2 回破棄されるのを防ぐ必要があります。.NET Framework SDK のドキュメントでは、Dispose イディオムと呼ばれる解決策を勧めています。

  // C#

class X
   {
   public X(int n)
      {
      this.n = n;
      }
   ~X()
      {
      System.Console.WriteLine("~X() {0}", n);
      }
   public void Dispose()
      {
      Finalize();
      System.GC.SuppressFinalize(this);
      }
   private int n;
   };

class main
   {
   static void f()
      {
      X x1 = new X(1);
      X x2 = new X(2);
      x1.Dispose();
      }
   static void Main()
      {
      f();
      System.GC.Collect();
      System.GC.WaitForPendingFinalizers();
      }
   };

/* output

~X() 1
~X() 2

*/

この仕組みは次のとおりです。

  • x1.Dispose はファイナライザを明示的に呼び出します。また、x1.DisposeSystem.GC.SuppressFinalize を呼び出し、ガベージ コレクタが暗黙理に 2 度目のファイナライザの呼び出しを行わないようにします。

  • x2.Dispose(したがって System.GC.SuppressFinalize も)は呼び出されないため、ガベージ コレクタは x2 のファイナライザを呼び出すことができます。この暗黙の呼び出しは Main が終了する直前に行われます。

  • Finalize メソッドは、実際のリソース解放処理をすべて実行します。Dispose は単純に Finalize に処理を移したのち、以降のガベージ コレクタによる同じ Finalize の呼び出しを止めます。

このメカニズムは万能ではありません。プログラマとガベージ コレクタが同じオブジェクトの後始末をしようとするのを防ぎますが、Dispose(したがって Finalize も)が 2 回呼び出されることを防ぐことはできません。解決策としては、Finalize を包み込むガード オブジェクトを用意し、このメソッドが 1 回しか呼び出されないようにする方法があります。

Dispose イディオムにはさらに 2 つの短所があります。

  • Dispose イディオムを使う場合は、計画的で明確な取り組みがプログラマに求められます。C++ のデストラクタの効果の多くはその人目につかないという性質からきています。デストラクタは暗黙理に呼び出され、プログラマはそれを意識して覚えている必要はなく、デストラクタの存在を物語る証拠でコードを埋め尽くす必要もありません。

  • Dispose イディオムは例外に対する安定性がありません。オブジェクトによって例外がスローされる場合には、そのスローに対応し、計画的に適切な Dispose メソッドを呼び出す必要があります。

次は、この 2 点を示す例です。

  // C#

void f()
   {
   X x1 = new X();    // might throw
   try
      {
      X x2 = new X(); // might throw
      // ...
      x2.Dispose();
      }
   finally
      {
      x1.Dispose();
      }
   }

これを C++ における同等の構造と比較してみましょう。

  // C++

void f()
   {
   std::auto_ptr<X> x1(new X); // might throw
   std::auto_ptr<X> x2(new X); // might throw
   // ...
   } // all destructors called implicitly here

予測可能な順序で呼び出される

C++ のデストラクタは、構築の順序に従って後入れ先出し方式(LIFO)の順序で呼び出されます。

  // C++

void f()
   {
   X x1;
   X x2;
   X x3;
   x1.f();
   // ...
   } x1.~X(), x2.~X(), and x3.~X() called in that order

C# の Dispose イディオムでは、手作業で強制的に同じ順序にしなければなりません。

  // C#

void f()
   {
   X x1 = new X();
   X x2 = new X();
   X x3 = new X();
   x1.f();
   // ...
   x3.Dispose();
   x2.Dispose();
   x2.Dispose();
   }

手作業での順序付けは、例外、特に構造中の例外に対処する場合に大きな労力を伴います。

  // C#

class X
   {
   public void f()
      {
      // ...
      }
   public void Dispose()
      {
      // ...
      }
   }

class main
   {
   public static void Main()
      {
      try
         {
         X x1 = new X();       // may throw
         try
            {
            x1.f();            // may throw
            X x2 = new X();    // may throw
            try
               {
               X x3 = new X(); // may throw
               // ...
               x3.Dispose();
               }
            finally
               {
               x2.Dispose();
               }
            }
         finally
            {
            x1.Dispose();
            }
         }
      finally
         {
         }
      }
   }

(幸い、C# は try/finally ブロックをサポートしています。C++ スタイルの try/catch ブロックしかなければ、前述の解決策ははるかに冗長なものになったでしょう)。

このような解決策の場合、部分的に生成されたオブジェクトに対して Dispose が呼び出されないように注意する必要がありますが、完全に生成されたオブジェクトに対しては確実に Dispose が呼び出されるようにする必要があります。上記の例では、x1.Dispose()x1 = new X() で例外がスローされた場合には呼び出されませんが、x1.f() で例外がスローされた場合には呼び出されます。

この類の「解決策」は、起こるのを待っている事故のようなものです。そこで別の解決策を提供します。オブジェクトの作成に合わせて正しい場所で手動で Dispose 呼び出しを挿入するのではなく、適切な LIFO 順序で Dispose を自動的に呼び出すエージェントをオブジェクトに登録するという方法です。

  using System;
using System.Collections;
using System.Reflection;

class Agent
   {
   public void InvokeAll()
      {
      foreach (Object o in objects)
         {
         Type type = o.GetType();
         type.InvokeMember("Dispose",
               BindingFlags.InvokeMethod, null, o, null);
         }
      objects.Clear();
      }
   private Stack objects = new Stack();
   public void Push(Object o)
      {
      objects.Push(o);
      }
   }

class X
   {
   public X(int n)
      {
      Console.WriteLine("      X() {0}", n);
      this.n = n;
      }
   ~X()
      {
      Dispose();
      }
   public void Dispose()
      {
      Console.WriteLine("Dispose() {0}", n);
      GC.SuppressFinalize(this);
      }
   private int n;
   };

class main
   {
   public static void Main()
      {
      Agent a = new Agent();
      for (int i = 0; i <3; ++i)
         a.Push(new X(i));
      a.InvokeAll();
      }
   }

/* output

      X() 1
      X() 2
      X() 3
Dispose() 3
Dispose() 2
Dispose() 1

*/

クラス Destructor は 3 つのメンバを宣言します。

  • System.Object のプライベート スタック。この例のすべての C# のオブジェクト(X オブジェクトも含む)は、暗黙的に System.Object から派生することを思い出してください。この規則の唯一の例外は、それ自身からは派生できない System.Object だけです。

  • パブリック メソッドの PushSystem.Object をスタックにプッシュします。

  • パブリック メソッドの InvokeAll。スタックされた System.ObjectDispose メソッドを呼び出します。

  • 最も興味深いのは最後のメソッドです。InvokeAll はスタック上の各オブジェクトを検査するときに、リフレクションを使用してオブジェクトの真のランタイムの型情報を(System.Type オブジェクトとして)取得します。その型情報には、何よりもプッシュされたオブジェクトのメソッドの一覧が含まれています。これらのメソッドのうち、InvokeAllDispose という名のメソッドを検索し、呼び出そうとします。

foreach ステートメントは、Push とは逆の順序、すなわち C++ のデストラクタの正しい順序でスタックを反復処理します。また、InvokeAll の呼び出しはスタックされたオブジェクトのブロックの終わりで起こるので、反復処理(そして Dispose メソッドの呼び出し)はブロックが終了するときに起こります。

こうして、ローカル ファイナライザは正しい場所で正しい順序で呼び出されます。

この解決策は完全とはいえませんが、問題を解決する手がかりを与えてくれるはずです。拡張が可能な場所としては次の場所があります。

  • InvokeAll は、スタックされたすべてのオブジェクトがパラメータをとらない、Dispose という名のメソッドを持っていると想定しています。

  • 呼び出された Dispose メソッドのいずれかが例外をスローすると、InvokeAll は途中で終了します。

  • Main が(例外によって)早期に終了してしまうと、スタックされたファイナライザはいずれも実行されません。

  • このイディオムは Dispose の例ほどではないにしても、プログラマに相当な労力を要求します。特に、オブジェクトを 1 つ 1 つ Push し、オブジェクトがスコープから外れるときに InvokeAll を呼び出さなければなりません。

  • この手法はローカル オブジェクトのデストラクタのみに適用できます。

インターフェイス

私が示したような場当たり的な Dispose メソッドを実装するのではなく、Dispose を宣言する共通のインターフェイスを定義して、自己破棄型のすべてのオブジェクト(X など)にそのインターフェイスを実装することもできます。このような共通のインターフェイス(ここでは Microsoft の命名パターンに従い IDispose と呼びますが)を用意すれば、Agent をよりシンプルでもっとタイプ セーフなものに書き換えることもできます。

  // C#

class Agent
   {
   public void InvokeAll()
      {
      foreach (IDisposable disposable in objects)
         disposable.Dispose();
      objects.Clear();
      }
   private Stack objects = new Stack();
   public void Push(IDisposable disposable)
      {
      objects.Push(disposable);
      }
   }

おわりに

このコラムは、とても C# デストラクタの問題に関する究極的な回答とはいえません。他の方たちの発言にも興味があるならば、DevelopMentor.DOTNET mailing list のアーカイブをお勧めします。(また、もし皆さんがまだリストに登録していなければ、登録することを強くお勧めします)。

私の次のコラムが出るまでには、Visual Studio.NET Beta 1 と .NET Framework SDK Beta 1 がリリースされていると思います。次回は、C# の標準化作業に関する最新報告とともに、ベータ版の内容を紹介する予定です。