オブジェクトの寿命
オブジェクトは、誰からも参照されなくなったらガベージ コレクションの対象になります。この時点をもって、オブジェクトの寿命は尽きていると考えます。
この「誰かが参照している」というのは、以下のように判定します。
- 何もしなければ識別子のスコープを抜けた時点で参照が外れたことになる
- 明示的に別の値や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 return
やawait
をまたいで使うためです。
にもかかわらず、たとえyield return
やawait
をまたいでなくてもすべて昇格します。
これは、デバッグ実行時に変数の中身を覗けるようにするためです。
しかし、デバッグ実行のためというなら、デバッグ ビルドの際だけでいいはずです。
そこで、C# 6ではそう変更しました。リリース ビルドすると、yield return
やawait
をまたがないものは通常のローカル変数にとどまります。
昇格が起きない分、オブジェクトの寿命が短くなります。
例えば、先ほどのコードですが、まったく同じものを、C# 6以降のコンパイラーを使って、リリース設定でコンパイルすると、結果は以下のように変わります。
1
2
SampleがGCされました
3
1
SampleがGCされました
2
3