C#でクラスを作ろう(6)/ファイナライザ(デストラクタ)

3月 18, 2020

このシリーズについて

C言語やC++言語などを学んではいるけどクラスをあまり作ったことが無い、という方を対象にしています。
このシリーズでは、C#でクラスを作るための基本的な構文を解説しています。C++やJavaなどと共通している概念も多いですが、サンプルコードは基本的にC#で解説します。ところどころ、C++特有の概念を解説することもあります。

コンストラクタと対になるのが、ファイナライザ(デストラクタ)です。オブジェクトが破棄されるときに自動的に呼び出されます。

しかし、C#をはじめとする近年のプログラミング言語では、ファイナライザはほとんど使用されません。その理由は後述するとして、まずは構文を見ていきましょう。

スポンサーリンク

ファイナライザ構文

class Item
{
    public int[] Values { get; set; }
    //コンストラクタ
    public Item(int value1, int value2, int value3)
    {
        this.Values = new int[3];
        this.Values[0] = value1;
        this.Values[1] = value2;
        this.Values[2] = value3;
        Console.WriteLine("初期化完了");
    }
 
    //ファイナライザ
    ~Item()
    {
        Console.WriteLine("ファイナライザ");
    }
}

ファイナライザは、クラス名と同名の"Item"の前にチルダ"~"を付けて定義します。引数や戻り値を指定することはできません。また、publicやprivateなどのアクセス修飾子を付けることもできません。

オブジェクトはいつ破棄されるのか

さてここで問題なのは、オブジェクトがいつ破棄されるのかということです。近年のプログラミング言語にはガベージコレクションという自動オブジェクト管理機構が備わっており、プログラマーが明示的に「このオブジェクトを破棄する」と記述する必要はありません。というか、できません。

クラスの中で生成した別のオブジェクトを確実に破棄しメモリリークを防ぐというのが、ファイナライザの主な使用目的です。上の例ではクラス内でint型の配列を生成してValuesに割り当てていますが、こういうものを確実に破棄したいわけです。

しかし、このようにクラス内部で生成された別のオブジェクトや配列などもガベージコレクションの対象であるため、明示的に破棄する必要はありません。というか、できません。

従って、ガベージコレクションが備わった近年のプログラミング言語では、ファイナライザを使う場面はほとんどありません。

C++のデストラクタ

C#の先祖にあたるC++にはガベージコレクションが無いため、デストラクタ(ファイナライザのC++での呼び名)が有効に活用されます。

class Item
{
public:
    int* _values;
    Item(int value1, int value2, int value3);
    ~Item();
};
 
Item::Item(int value1, int value2, int value3)
{
    _values = new int[3];
    _values[0] = value1;
    _values[1] = value2;
    _values[2] = value3;
}
 
Item::~Item()
{
    delete[] _values;
}
 
int main()
{
    Item* item = new Item(5, 10, 15); //コンストラクタ
    //いろいろ使った後…
    delete item;  //ここでデストラクタが呼ばれる
}

このItemクラスの内部ではint型の配列が生成されています。もしデストラクタが無いと、単純にmainの最後でdelete itemとしただけでは、この内部で生成された配列は破棄されません。破棄されなかった配列は永久にメモリ内に残り続けることになり、メモリリークとなってしまいます。

このようなことを避けるために、デストラクタを定義し、デストラクタ内で配列を破棄するようにします。こうしておけば、Itemクラスのオブジェクトが破棄されたときに必ずデストラクタが呼ばれ、Itemクラス内で生成されたオブジェクトや配列なども同時に破棄することができるようになります。

C++のデストラクタには、publicやprivateなどのアクセス修飾子を付けることができます。privateデストラクタを使ったマニアックなテクニックもあるのですが、基本的にはpublicで問題ありません。

アンマネージリソースの破棄

C#の話に戻ります。

ファイルI/OのためのオブジェクトやGDIグラフィックオブジェクトなどは、内部でアンマネージリソースを使用しています。アンマネージリソースとは、ガベージコレクションの管理下に無いリソースのことです。

for (int i = 0; i < 10000; i++)
{
    //System.IO.FileStreamはアンマネージリソースを使用している
    FileStream fs = new FileStream(@"C:\Test.txt");
    //ここでfsをいろいろ使う…
}
 
for (int i = 0; i < 10000; i++)
{
    //System.Drawing.Bitmapはアンマネージリソースを使用している
    Bitmap bmp = new Bitmap(1024, 1024);
    //ここでbmpをいろいろ使う…
}

このようなコードを書いた場合、10000回のループの中で、1回目に生成されたオブジェクトの中にあるアンマネージリソースが破棄されないまま、2回目のオブジェクトの生成が行われます。それをどんどん繰り返すうちに、破棄されないアンマネージリソースが溜まっていってしまいます。これにより、メモリリークを引き起こしてしまいます。

これではマズいので、アンマネージリソースを内部で使用しているクラスには、Disposeというメソッドが用意されています。

for (int i = 0; i < 10000; i++)
{
    //System.IO.FileStreamはアンマネージリソースを使用している
    FileStream fs = new FileStream(@"C:\Test.txt");
    //ここでfsをいろいろ使う…
    fs.Dispose();   //Disposeメソッドによる破棄
}

このように明示的にDisposeメソッドを呼び出すことで、内部のアンマネージリソースを破棄することができます。ファイナライザのように自動的には呼び出されないので、プログラマーの責任で明示的にDisposeメソッドを呼び出す必要があります。

Disposeという名前はC#特有のキーワードではありません。「アンマネージリソースを破棄するためのメソッドにはDisposeという名前を付けましょう」という一種のルールです。このルールを守らずに、CloseとかEndとかの名前の破棄用メソッドを定義しているクラスも世の中にはありますが、Dispose以外の名前を使うことは推奨はされていません。

もう少し厳密には、DisposeメソッドはIDisposableインターフェイスで定義されたメソッドです。インターフェイスについては、後の回で詳しく説明します。

Disposeが必要かどうかの判断

クラスの中にあるフィールドやプロパティが、intやstringなどのプリミティブ型、およびその配列などだけで構成されている場合は、クラス内部の全てがガベージコレクションの管理下にあります。つまり、Disposeは必要ありません。データの集合体として使われるだけのほとんどのクラスはこれに該当します。

一方、ファイルI/Oやグラフィックなど、OSに近いリソースを使用しているクラスはアンマネージリソースを使用している可能性があり、Disposeが必要となります。

自分がそれらのクラスを使う側なら、そのクラスにDisposeというメソッドが定義されていれば、オブジェクトを使用しなくなったときにDisposeを呼び出す必要がある、と覚えておきましょう。

逆に自分がクラスを作る側なら、どこでアンマネージリソース(=Disposeが定義されているクラスのオブジェクト)を使用しているかに注目します。

Class Item
{
    public void SomeMethod()
    {
        //System.IO.FileStreamはアンマネージリソースを使用している
        FileStream fs = new FileStream(@"C:\Test.txt");
        //ここでfsをいろいろ使う…
        fs.Dispose();   //Disposeメソッドによる破棄
    }
}

多くの場合、アンマネージリソースを使う期間はあまり長くありません。上の例の場合のように、ほんの一瞬だけFileStreamオブジェクトを使い、SomeMethodメソッドのスコープより外側にまでFileStreamを持ち出さないようにして使うことがほとんどです。このような場合にはDisposeメソッドは必要ありません。

一方、クラス全体でアンマネージリソースを共有することもあります。

Class Item
{
    private FileStream _fs;  //ずっと使い続けるアンマネージリソース
 
    public Item(string path)
    {
        _fs = new FileStream(path);  //_fsは最初から最後まで使い続ける
    }
    public void SomeMethod1()
    {
        //_fsを使って何かする
    }
    public void SomeMethod2()
    {
        //_fsを使って何かする
    }
 
    public void Dispose()
    {
        _fs.Dispose();
    }
}

コンストラクタでクラス内部のFileStreamを生成し、それをずっと保持したままいろいろなメソッドで使いまわせるようにしておくというクラス設計の例です。

このような場合は、Disposeメソッドを定義して、FileStreamもちゃんと破棄できるような機構を用意しておくべきです。

しかしそれでも、このクラスを使う側がDisposeメソッドを呼び忘れることがあるかもしれません。そういう場合も考慮して、ファイナライザを定義します。

class Item
{
    //中略...
    public void Dispose()
    {
        _fs.Dispose();
    }
 
    //ファイナライザ
    ~Item()
    {
        this.Dispose();
    }
}

万が一Disposeを呼び出すことを忘れてしまった場合でも、ガベージコレクションの回収のタイミングでDisposeが呼び出されるよう、ファイナライザを使って2段構えにしておくという発想です。

実は、FileStreamクラス自身やBitmapクラス自身にも、Disposeが呼び出されなかったときのことを考慮して、ファイナライザが定義されています。少し上の例で10000回FileStreamを生成する例がありましたが、その例でもいずれはDisposeが呼び出されるので、永久に使用メモリが増大するようなことはありません。ただし、ガベージコレクションの気分次第ではメモリ不足に陥る危険性はあります。運がよければDisposeを忘れても大丈夫、くらいに思っておきましょう。

Disposeパターン

Disposeというものを解説するために簡単な例で示しましたが、もっと厳密で確実にアンマネージリソースを破棄するには、Disposeパターンという定型的な手法があります。先の章で解説するような内容も含んでいるので、ここではDisposeパターンについては詳しく立ち入りません。

C#のライブラリが提供するクラスでは基本的に、アンマネージリソースは使用する短い間だけ、生成→Disposeをすればいいように設計されています。使ったらすぐ捨てる。最初のうちはこれで十分です。

そして、ほとんどの場合はC#ではファイナライザについて意識することはありません。C++のデストラクタと同じ構文ですが、C#とC++では使われ方が大きく違うということだけ頭の片隅に置いておきましょう。

スポンサーリンク