はじめに
using の中で例外が出たとき、Dispose は呼ばれるのでしょうか。呼ばれるなら、外側の catch より先でしょうか、後でしょうか。
この記事では、この問いに加えて「using宣言ではいつ呼ばれるか」「呼び忘れたら GC が代わりに呼ぶか」の3つを、コンソールアプリで実験して確かめます。
1. usingの中で例外が出たとき
コード
try
{
using (var r = new MyResource())
{
Console.WriteLine("ブロック内");
throw new Exception("失敗");
}
}
catch (Exception e)
{
Console.WriteLine($"catch: {e.Message}");
}
class MyResource : IDisposable
{
public MyResource() => Console.WriteLine("取得");
public void Dispose() => Console.WriteLine("Dispose");
}
問
出力結果はどうなるか?
回答
取得
ブロック内
Dispose
catch: 失敗
usingはtry-finallyに書き換わる
実行する前は「取得 → ブロック内 → catch」と出て、Dispose は出ないと予想していました。using はブロックの最後まで進んだときに Dispose を呼ぶもので、途中で throw されたら、そのまま呼び出し元に戻ると考えていたためです。
実際には Dispose が呼ばれ、しかも catch より先に出ました。
using は、コンパイル時に try-finally に書き換えられます。確かめるために、using を使わずに同じ動きを自分で書いてみました。
var r = new MyResource();
try
{
try
{
Console.WriteLine("ブロック内");
throw new Exception("失敗");
}
finally
{
r.Dispose();
}
}
catch (Exception e)
{
Console.WriteLine($"catch: {e.Message}");
}
取得
ブロック内
Dispose
catch: 失敗
using 版と同じ出力になりました。
最初は、catch と finally を同じ try に付けて書いていました。
var r = new MyResource();
try
{
Console.WriteLine("ブロック内");
throw new Exception("失敗");
}
catch (Exception e)
{
Console.WriteLine($"catch: {e.Message}");
}
finally
{
r.Dispose();
}
取得
ブロック内
catch: 失敗
Dispose
こちらは catch が先になります。
違いは catch の位置です。例外は、型が合う一番近い catch まで外側へ伝わり、その途中にある finally を必ず通ります。using が作るのは finally だけで、catch は作りません。そのため、例外は using の中では止まらず、Dispose を通ってから外側の catch に届きます。これが Dispose が catch より先に出る理由です。
newがtryの手前にある理由
書き換えたコードでは、new MyResource() が try の中ではなく手前にあります。
コンストラクタの中で例外が出た場合、インスタンスはできておらず、解放すべきものがまだ手に入っていません。try が守るのは「取得に成功した後」の部分だけで、取得に成功したものだけを finally で解放する形になっています。なお、実際にコンパイラが生成するコードでは、finally の中で null チェックをしてから Dispose を呼んでいます。
2. using宣言のとき
C# 8 からは、{ } を付けずに using var r = ...; と書く using宣言が使えます。ブロック形式と比べました。ここからは、MyResource を名前を受け取る形に変えています。
コード
Block();
Console.WriteLine("---");
Declaration();
Console.WriteLine("全体の終了");
void Block()
{
using (var r = new MyResource("A"))
{
Console.WriteLine("A 使用");
}
Console.WriteLine("Block の後続処理");
}
void Declaration()
{
using var r = new MyResource("B");
Console.WriteLine("B 使用");
Console.WriteLine("Declaration の後続処理");
}
class MyResource : IDisposable
{
private readonly string _name;
public MyResource(string name)
{
_name = name;
Console.WriteLine($"{_name} 取得");
}
public void Dispose() => Console.WriteLine($"{_name} Dispose");
}
問
「A Dispose」と「B Dispose」は、それぞれどこに出るか?
回答
A 取得
A 使用
A Dispose
Block の後続処理
---
B 取得
B 使用
Declaration の後続処理
B Dispose
全体の終了
Disposeは、変数を囲む一番内側の { } を抜けるときに呼ばれる
最初は「B Dispose」が「B 取得」の直後に出ると予想していました。using var r = ...; の行で using が終わると考えたためです。
外れたので、次は「ファイル内のすべての処理が終わったとき」だと考えました。それを確かめるために Console.WriteLine("全体の終了"); を足したところ、「B Dispose」は「全体の終了」より先に出ました。
using宣言の Dispose は、その変数を囲んでいる一番内側の { } を抜けるときに呼ばれます。今回はそれが Declaration メソッドでした。if や for の { } の中で宣言すれば、そのブロックを抜けるときに呼ばれます。
using宣言はネストが浅くなって読みやすい一方で、メソッドの最後まで解放されません。長いメソッドの途中で早めに手放したいもの(ファイルやDB接続など)には、ブロック形式が向いています。
3. Disposeを呼び忘れたとき
コード(MyResource は2と同じ)
Forget();
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("GCを強制的に走らせた");
void Forget()
{
var r = new MyResource("C");
Console.WriteLine("C 使用(Disposeを呼ばずに終わる)");
}
GC.Collect() はふだんは書かないものですが、GC が走ったことをはっきりさせるために、実験として強制的に呼んでいます。
問
「C Dispose」は出るか?
回答
C 取得
C 使用(Disposeを呼ばずに終わる)
GCを強制的に走らせた
GCはDisposeを呼ばない
GC を強制的に走らせても、Dispose は呼ばれませんでした。
Dispose 自体は、中身の決まっていないただのメソッドです。何をするかは、そのクラスを作った人が決めます。実際のクラスでは、多くの場合「.NET のメモリの外にあるもの」を返す処理が書かれています。たとえば FileStream の Dispose は OS に開いてもらったファイルを閉じ、DB接続の Dispose は接続をプールに返します。
これらは OS や DB の側で持っているものなので、GC からは見えません。GC が回収するのは、オブジェクトが使っていたメモリだけです。オブジェクトのメモリが回収されても、OS 側のファイルが閉じられるとは限りません。
もう1つの理由は、GC がいつ走るかをプログラムから決められないことです。ファイルや接続を使い終わった時点で確実に返すには、自分で Dispose を呼ぶ(using で囲む)必要があります。
なお、GC が呼ぶ可能性があるのは、~MyResource() のように書くファイナライザです。ファイナライザはいつ動くかが決まっておらず、呼び忘れたときの保険として使われるもので、Dispose の代わりにはなりません。今回の MyResource にはファイナライザを書いていないので、何も呼ばれていません。