C#のUsing Statementと、C# 8.0で導入されたUsing Declarationについて

先日、Unity 2020がリリースされました。

Unity 2020 LTSをリリースしました|Unity Japan(ユニティ・テクノロジーズ・ジャパン)|note

Unity 2020では C# 8.0が使えるようになり、このブログでもnull許容参照型について取り上げました。

その中で、地味ではありますがUsing Declarationが地味に便利だったので、備忘録としてブログにかきます。

この記事のまとめ

  • Using Statementを用いることでブロックを抜けると確実にDisposeを呼び出すことができるが、ネストが深くなるなど可読性に問題があった
  • C# 8.0で導入されたUsing Declarationにより、変数の定義の手前にusingを記述すると、その変数が定義されたスコープを抜けると自動的にDisposeを呼び出されるようになった

System.IDisposableについて

C#ではアンマネージドなリソースを破棄するためのメカニズムを提供するために、System.IDisposableというインターフェイスが用意されています。

アンマネージドなリソースは.NETのガベージコレクターに管理を任せられないリソースで、このようなリソースを含むオブジェクトを作成する場合、オブジェクトの使用が終了したらそのリソースを明示的に破棄する必要があります。

例えばFile.OpenTextで取得できるStreamReaderは、利用中にファイルハンドラを開いているため、 利用後にDisposeを呼び出してファイルハンドラを閉じる必要があります。

Disposeはその性質上(たとえ例外などが発生したとしても)確実に呼び出される必要があります。例えばtry-finally句を用いて定義したオブジェクトのDisposeを呼び出すという方法があります。

var sr = File.OpenText(path)
try
{
    // srに対して処理を行う
}
finally
{
    // finally句で確実にDisposeを呼び出す
    sr.Dispose();
}

しかし定義と破棄のペアをもれなく書く必要があるため、ヒューマンエラーの元になります。

Using StatementによるDispose呼び出しの自動化

using ステートメント - C# リファレンス | Microsoft Docs

そこでC#ではUsing Statementという仕組みがあります。上記の処理を下記のように記述できます。

using (var sr = File.OpenText(path))
{
    // srに対して処理を行う
}
// usingのスコープを抜けた瞬間に
// Disposeが呼び出される

つまり、using句で定義したオブジェクトのDisposeがそのスコープを抜けると自動で呼び出されるようになります。 これにより、変数の宣言に対して破棄は自動化されるため、上記で懸念となるヒューマンエラーは解消されます。

ちなみにこの構文では、途中で例外が発生してもDisposeは確実に呼び出されます。確かめてみます。

// HogeはDisposeが呼び出されると
// "Hoge(Name = name).Dispose() called"
// という文字列を出力する
public class Hoge : IDisposable
{
    public string Name { get; }

    public Hoge(string name) { Name = name; }

    public void Dispose()
    {
        WriteLineAndFlush($"Hoge(Name = {Name}).Dispose() called");
    }
}

// 念の為WriteLineのあとFlushを呼ぶメソッド
static void WriteLineAndFlush(string? line)
{
    Console.Out.WriteLine(line);
    Console.Out.Flush();
}

static void TestCase()
{
    try
    {
        using (var hoge1 = new Hoge("hoge1"))
        {
            WriteLineAndFlush("in using hoge1");
            using (var hoge2 = new Hoge("hoge2"))
            {
                WriteLineAndFlush("in using hoge2");
                using (var hoge3 = new Hoge("hoge3"))
                {
                    WriteLineAndFlush("in using hoge3");
                }

                WriteLineAndFlush("out using hoge3");
            }

            WriteLineAndFlush("out using hoge2");

            throw new Exception("Itotekina exception");
        }

        WriteLineAndFlush("out using hoge1");
    }
    catch (Exception e)
    {
        WriteLineAndFlush($"Exception = {e.Message}");
    } 
}

HogeクラスはDisposeが呼び出されたタイミングでコンソールにDisposeに下記のようなメッセージを出力します。

var hoge = new Hoge("TestName");
hoge.Dispose();
// => Hoge(Name = TestName).Dispose() called

TestCase()メソッドでは、hoge2オブジェクトのスコープが外れたあとに例外が呼び出されます。hoge1オブジェクトも宣言済みなのでhoge1オブジェクトのDisposeも呼び出されることを期待します。

実行すると、下記が出力されます。

in using hoge1
in using hoge2
in using hoge3
Hoge(Name = hoge3).Dispose() called
out using hoge3
Hoge(Name = hoge2).Dispose() called
out using hoge2
Hoge(Name = hoge1).Dispose() called
Exception = Itotekina exception

上記のように例外が投げられた場合でも、例外をキャッチする前に(つまりException = Itotekina exceptionが出力されるまえに)Hoge(Name = hoge1).Dispose() calledが出力されている、つまりDisposeが呼び出されていることが確認できました。

しかし、上記のように扱うオブジェクトが増えれば増えるほどネストが深くなり、可読性が下がるといった問題もあります。

一応この回避策の1つとして、下記のようにUsing Statementを連鎖させる記述をすることができます。

using (var hoge1 = new Hoge("hoge1"))
using (var hoge2 = new Hoge("hoge2"))
using (var hoge3 = new Hoge("hoge3"))
{
    throw new Exception("Itotekina exception");
}

また、1つのusingステートメント内に複数のオブジェクトを宣言することもできます。

using (Hoge hoge1 = new Hoge("hoge1"), hoge2 = new Hoge("hoge2"), hoge3 = new Hoge("hoge3"))
{
    throw new Exception("Itotekina exception");
}

ただし、この場合はステートメント内の型を明記する必要があるのと、宣言する変数はすべて同じ型である必要があります。

ちなみにいずれも出力は下記のとおりです。

Hoge(Name = hoge3).Dispose() called
Hoge(Name = hoge2).Dispose() called
Hoge(Name = hoge1).Dispose() called
Exception = Itotekina exception

Using Declaration

Pattern based using and using declarations - C# 8.0 specification proposals | Microsoft Docs

上記のUsing Statementをより簡素に書くためにC# 8.0ではUsing Declarationという記法が追加されました。

using (var hoge1 = new Hoge("hoge1"))
{
    using (var hoge2 = new Hoge("hoge2"))
    {
        using (var hoge3 = new Hoge("hoge3"))
        {
        }
    }
}

例えば上記のコードはUsing Declarationを用いて下記のように書くことができます。

{
    using var hoge1 = new Hoge("hoge1");
    using var hoge2 = new Hoge("hoge2");
    using var hoge3 = new Hoge("hoge3");
}
// このタイミングでhoge1, hoge2, hoge3の
// Disposeが呼び出される。

Using Declarationの利用方法は簡単で、変数宣言の前にusingを書くだけです。Using Declarationを用いると、そのインスタンスがスコープを抜けた直後にDisposeが呼び出されるようになります。

結果は下記のようになります。このように宣言した順番と逆順でDisposeが呼び出されていることが確認できます。

Hoge(Name = hoge3).Dispose() called
Hoge(Name = hoge2).Dispose() called
Hoge(Name = hoge1).Dispose() called

余談: ILレベルでのUsing Declaration

下記のUsing StatementおよびUsing Declarationで記述されたコードは、コンパイル後のILは全く同じになります。

// Using Statement
using (var hoge1 = new Hoge("hoge1"))
{
    using (var hoge2 = new Hoge("hoge2"))
    {
    }
}

// Using Declaration
{
    using var hoge1 = new Hoge("hoge1");
    using var hoge2 = new Hoge("hoge2");
}

上記のコードを一度ILに変換し、それをC#にデコンパイルしたコードは下記のとおりです。

Hoge hoge = new Hoge("hoge1");
try
{
    Hoge hoge2 = new Hoge("hoge2");
    try
    {
    }
    finally
    {
        if (hoge2 != null)
        {
            ((IDisposable)hoge2).Dispose();
        }
    }
}
finally
{
    if (hoge != null)
    {
        ((IDisposable)hoge).Dispose();
    }
}

内部的にはtry-finallyに変換されているんですね。

Reference