オブジェクトの寿命

オブジェクトは、誰からも参照されなくなったらガベージ コレクションの対象になります。この時点をもって、オブジェクトの寿命は尽きていると考えます。

この「誰かが参照している」というのは、以下のように判定します。

  1. 何もしなければ識別子のスコープを抜けた時点で参照が外れたことになる
  2. 明示的に別の値やnullを代入すれば、その時点で参照が外れたことになる

1つ目の制限 があるので、基本的に、識別子のスコープが、オブジェクトの寿命の最大範囲です。 例えば以下のようなコードから、変数のスコープ = オブジェクトの寿命になっていることが分かります。

using System;

class Sample
{
    public Sample()
    {
        Console.WriteLine("Sampleが作られました");
    }
    ~Sample()
    {
        Console.WriteLine("SampleがGCされました");
    }
}

public class Program
{
    public static void M()
    {
        {
            Console.WriteLine("Scope開始");
            var s = new Sample();

            // この時点ではまだ生きているので、GC しても無駄
            GC.Collect();

            Console.WriteLine("Scope終了");
        }

        // この時点で s に入っていた Sample インスタンスは寿命迎えてる
        // GC を強制起動すると回収されるはず
        GC.Collect();
    }
}
Scope開始
Sampleが作られました
Scope終了
SampleがGCされました

ラムダ式と変数の昇格

通常、ローカル変数に格納したオブジェクトの寿命は非常に短いです。戻り値で返したりしない限り、ブロック内だけで寿命を終えます。 ただ、C#にはいくつか、ただのローカル変数を、もう少し寿命の長いものに「昇格」(elevation)させてしまう構文があります。

その1つが匿名関数です。匿名関数は、外側のローカル変数を取り込んでしまえる(補足(capture)できる)機能を持っています。この場合、取り込んだローカル変数に入っているインスタンスの寿命が延びます。

using System;

class Sample
{
    public int Value { get; }

    public Sample(int value)
    {
        Value = value;
    }
    ~Sample()
    {
        Console.WriteLine("SampleがGCされました");
    }
}

public class Program
{
    public static Func<int> M()
    {
        Func<int> f;
        {
            var s = new Sample(1);
            f = () => s.Value;
            // 変数 s のスコープはここまで
        }

        // でも、f が内部で s を参照しているので、インスタンスの寿命が延びる
        // 変数 s のスコープを超えて、f のスコープ内でずっと生き残る
        // GC 起動しても回収されず
        GC.Collect();

        return f;
    }
}

詳細は「匿名デリゲートのコンパイル結果」で説明していますが、匿名関数から外部のローカル変数を参照すると、実際にはクラスが自動生成されて、フィールドが作られます。すなわち、ローカル変数だったものがフィールドに昇格します。この昇格により、格納されているインスタンスの寿命が延びます。

forステートメントのループ変数

ラムダ式の外部変数補足と合わせると、ループ変数のスコープに関して注意が必要になります。

まず、forステートメントですが、これのループ変数は、全ループで1つ、同じ変数扱いになります。 例えば、以下の2つのループ(forステートメントと、その下のwhileステートメントを使ったもの)は同じ意味になります。

public static void M(int n)
{
    for (int i = 0; i < n; i++)
    {
        Console.WriteLine(i);
    }

    {
        int i = 0;
        while(i < n)
        {
            Console.WriteLine(i);
            i++;
        }
    }
}

whileに書き換えたものを見てのとおり、ループの外側に1つの変数があり、それがずっと使いまわされます。

Action a = null;

for (int i = 0; i < 10; i++)
{
    a += () => Console.WriteLine(i); // この i はずっと共有
}
// ループを抜けたときには、i の値は 10 に置き換わってる

// 結果、10が10回表示される
a();

この結果(10が10回表示される)は意図通りでしょうか。0~9までの数字が1回ずつ表示される方を期待したいところですが、残念ながらそうはなりません。「0~9まで1回ずつ」という挙動を得るためには以下のように書く必要があります。

Action a = null;

for (int i = 0; i < 10; i++)
{
    var j = i;
    a += () => Console.WriteLine(j); // この j は1回1回別
}

// 結果、0~9が1回ずつ表示される
a();

foreachステートメントのループ変数

Ver. 5.0

同様の件について、foreachステートメントでは、C# 5.0を境に仕様変更がありました。

C# 4.0以前では、forステートメントと同じで、ループ変数がループ全体で共有されていました。 一方、C# 5.0以降では、ループ1回1回別扱いされるように変更されています。 すなわち、whileを使って書き直すなら以下のようになります。

public static void M(IEnumerable<int> list)
{
    foreach (var i in list)
    {
        Console.WriteLine(i);
    }

    {
        // C# 4.0 以前
        var e = list.GetEnumerator();
        using (e as IDisposable)
        {
            int i; // ループの外
            while (e.MoveNext())
            {
                i = e.Current;
                Console.WriteLine(i);
            }
        }
    }

    {
        // C# 5.0 以降
        var e = list.GetEnumerator();
        using (e as IDisposable)
        {
            while (e.MoveNext())
            {
                var i = e.Current; // ループの中
                Console.WriteLine(i);
            }
        }
    }
}

当然、以下のように、匿名関数で変数を取り込んだ際の挙動が変わります。

Action a = null;

foreach (var i in Enumerable.Range(0, 10))
{
    // C# 4.0 以前: この i はずっと共有
    // C# 5.0 以降: この i は1回1回別
    a += () => Console.WriteLine(i);
}

// C# 4.0 以前: 9が10回表示される
// C# 5.0 以降: 0~9が1回ずつ表示される
a();

便利になる方向への変更なので概ね問題は起こしませんが、もしも、C# 4.0以前を使う必要がある場合には注意が必要です。 最新のコンパイラーと同じ感覚で上記のようなコードを書くと、C# 4.0以前のコンパイラーではバグになったりします。

イテレーターと非同期メソッド

ローカル変数がフィールドに昇格してしまうものがあと2つあります。イテレーター非同期メソッドです。

これらは、結構大々的なクラスの自動生成を行っていて、ローカル変数がフィールドに格上げされます。 例えば、以下のようなコードを実行すると、Sampleのインスタンスはプログラム終了直前まで回収されません。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

class Sample
{
    ~Sample()
    {
        Console.WriteLine("SampleがGCされました");
    }
}

public class Program
{
    public static void M()
    {
        foreach (var i in Iterator()) ;
        AsyncMethod().Wait();
    }

    static IEnumerable<int> Iterator()
    {
        var s = new Sample();
        yield return 1;
        Console.WriteLine("1");

        // s はずっと生き残ってる。回収されない
        GC.Collect();

        yield return 2;
        Console.WriteLine("2");

        // 同上。回収されない
        GC.Collect();

        yield return 3;
        Console.WriteLine("3");
    }

    static async Task AsyncMethod()
    {
        var s = new Sample();
        await Task.Delay(1);
        Console.WriteLine("1");

        // s はずっと生き残ってる。回収されない
        GC.Collect();

        await Task.Delay(1);
        Console.WriteLine("2");

        // 同上。回収されない
        GC.Collect();

        await Task.Delay(1);
        Console.WriteLine("3");
    }
}
1
2
3
1
2
3
SampleがGCされました
SampleがGCされました
Ver. 6

C# 5.0以前の場合、すべてのローカル変数が問答無用で軒並みフィールドに昇格していました。 元々、昇格が必要な理由はyield returnawaitをまたいで使うためです。 にもかかわらず、たとえyield returnawaitをまたいでなくてもすべて昇格します。 これは、デバッグ実行時に変数の中身を覗けるようにするためです。

しかし、デバッグ実行のためというなら、デバッグ ビルドの際だけでいいはずです。 そこで、C# 6ではそう変更しました。リリース ビルドすると、yield returnawaitをまたがないものは通常のローカル変数にとどまります。 昇格が起きない分、オブジェクトの寿命が短くなります。 例えば、先ほどのコードですが、まったく同じものを、C# 6以降のコンパイラーを使って、リリース設定でコンパイルすると、結果は以下のように変わります。

1
2
SampleがGCされました
3
1
SampleがGCされました
2
3

更新履歴

ブログ