連載:[完全版]究極のC#プログラミング

Chapter7 ラムダ式(後編)

川俣 晶
2009/11/02

7.10 オーバーロードの解決

 ラムダ式はオーバーロードの解決にも影響する。難しい話ではないが、理解していないと引っ掛かることもあると思うので、簡単なサンプルコードを掲載しておく。

 次のリスト7.23は、int型を返すラムダ式を、double型を返すデリゲート型を引数に受け取るメソッドに渡す例である。整数から実数への暗黙の変換は存在するので、このコードは問題なく動作する。

using System;

class Program
{
  static void Output(Func<double> f)
  {
    Console.WriteLine("Output by double: {0}",f());
  }

  static void Main(string[] args)
  {
    Output(() => 123); // 出力:Output by double: 123
  }
}
リスト7.23 暗黙変換により適合するラムダ式

 このプログラムに同じ名前のメソッドを追加してみよう(リスト7.24参照)。ただし、こちらではint型を返すデリゲート型を引数に持つ。

using System;

class Program
{
  static void Output(Func<double> f)
  {
    Console.WriteLine("Output by double: {0}",f());
  }

  // 新規追加メソッド
  static void Output(Func<int> f)
  {
    Console.WriteLine("Output by int: {0}", f());
  }

  static void Main(string[] args)
  {
    Output(() => 123); // 出力:Output by int: 123
  }
}
リスト7.24 メソッドの追加によりオーバーロードの解決が変化

 見てのとおり、呼び出しのラムダ式はいっさい変更していないが、別のメソッドを呼び出すようになった。これは、ラムダ式がオーバーロードの解決に影響を与えているためである。ラムダ式が持つ型が、オーバーロードの解決メカニズムに影響を与え、より適したメソッドを選択させたのである。そのため、より適した型を持つメソッドが追加されると、それによって別のメソッドが呼び出されるようになったわけである。

 これにより、ラムダ式の引数や戻り値の型を推定するために外部の情報に依存するのと同様に、オーバーロードの解決はラムダ式自身が持つ型に依存するわけである。つまり、依存は双方向である。

【C#olumn】値型と参照型の相違は何か?

 ここではラムダ式が変数をキャプチャすることの問題を考えてみよう。

 その前に、1つクイズを出そう。

 C#(あるいは.NET Framework)に存在する型は、主に値型と参照型に分類できる。たとえば、int型は値型だが、string型は参照型である。自分で型を定義する場合は、structキーワードを使うと値型になり、classキーワードを使うと参照型になる。

 では、値型と参照型はいったい何が違っているのだろうか?

 少し考えてみよう。

 最も基本的な相違は、値型は「値」そのものを受け渡すのに対して、参照型は「参照」を受け渡す点だろう。たとえば、メソッドの引数にint型の値を渡すと値の複製が作られてメソッドに渡される(refやoutを付けると参照で渡すこともできるが、基本は値)。一方、string型の値を渡すと、値の複製を作るのではなく、実体(オブジェクト)への参照がメソッドに渡される。

 では、ほかに相違はないのだろうか?

 継承やコンストラクタなどの相違もあるが、実際に使ううえで重要な意味を持つのは、やはり寿命の違いだろう。たとえば、メソッド内のローカル変数として記述した、値型の変数に入れた値は、スコープを抜けた時点で即座に消えてなくなる。しかし、参照型の変数に入れた値は参照であって実体ではない。そのため、参照が消えても即座に実体が消えるわけではない。それが消えるのは、ほかの参照もすべて消滅したうえで、ガベージコレクションで破棄されたときである。

 これでめでたしめでたし、値型と参照型の相違が明らかになった……というのはC# 1.x時代の話である。

 実は、C# 3.0では、それほど単純な話にはなっていない。

 ラムダ式によってキャプチャされた変数が消えてなくなるのは、たとえ値型であっても、参照もすべて消滅したうえで、ガベージコレクションが実行された後になる。つまり、値型の変数の寿命が参照型と同等になってしまうのである。

 それがもたらすインパクトがピンとこない読者もいると思うので、リスト7.25として、簡単なサンプルコードを用意した。

using System;
using System.Diagnostics;

delegate void MyMethodInvoker();

struct TargetStruct
{
    public int[] HugeArray;
}

class Program
{
    static MyMethodInvoker GetMethod()
    {
        TargetStruct t;
        t.HugeArray = new int[100000000];
        return ()=>
        {
            // ↓の行をコメントアウトして比較
            Console.WriteLine(t.HugeArray.Length);
        };
    }

    static void Main(string[] args)
    {
        MyMethodInvoker doit = GetMethod();
        doit();
        GC.Collect();
        Console.WriteLine(
            Process.GetCurrentProcess().VirtualMemorySize64);
    }
}
リスト7.25 ラムダ式利用時の値型変数の寿命を見る

 このサンプルコードの「Console.WriteLine(t.HugeArray.Length);」という行をコメントアウトすると(つまり、ラムダ式で変数をキャプチャしないと)、プロセスに割り当てられた仮想メモリの量(VirtualMemorySize64プロパティで取得される)は190,791,680バイトとなった(Visual Studio 2008、Debugビルド、Windows Vista Ultimateの環境において)。

 一方、その行をコメントアウトしないで実行すると、プロセスに割り当てられた仮想メモリの量は603,279,360バイトとなった。

 つまり、劇的にプロセスが保持するメモリ量が異なっているのである。

 なぜその差が生じるのかといえば、変数doitが生きている限り、それによって参照されるラムダ式は生きていて、そこから参照される変数tも、また、生き続け、そこから参照される巨大配列HugeArrayも、また、生きているからである。その状況で、「GC.Collect();」を呼び出してガベージコレクションさせても、参照が生きているので回収されることはない。

 一方、変数tをキャプチャしない場合は、この変数の寿命はGetMethodメソッドを抜けた時点で尽きてしまう。したがって、GC.Collectメソッドを呼び出した時点ではすでに参照が存在しないので、そのためのメモリが残っていても回収されてしまうのである。

 このように、値型の変数をキャプチャすると、ラムダ式への参照が残る限りその変数の寿命も尽きず、解放されたと思ったメモリが解放されない問題も起こりうる。単純なサンプルコードではなかなか起こらないかもしれないが、込み入った実用プログラムのソースコードでは気づかないうちに起こりうるので、注意が必要だろう。

 余談だが、このようなソースコードの表面からはわかりにくい挙動を多数持っているのがC#という言語の特徴といえるだろう。この特徴は、よりシンプルな言語の信奉者から批判される点だろう。しかし、シンプルでわかりやすい言語のほうがコードを書きやすいか、あるいは読みやすいか……というと、そうともいえない。言語のわかりやすさと、その言語で書かれたソースコードの書きやすさ、わかりやすさは必ずしも比例しないのである。

 たとえ、表面的にわかりにくい挙動があったとしても、C#のほうがより良い結果を出すことも多い。これを理解するには、良い機能を集めれば良い言語ができ上がるという論理が、実は必ずしも成立しないことを学ばなければならない。これは、「合成の誤謬」と呼ばれる問題の一種といえる。言語のデザインとは、単純な理屈で割り切れない世界なのである。

【Exercise】練習問題

 次のプログラムは、ある時点で確定した条件により、後から実行する内容をラムダ式で記述しておくという遅延実行(巻末「C# 3.0デザインパターンミニカタログ」p.363参照)の実装を意図したものである。Actionで始まる行で実行すべき内容を確定させるが、実際に実行するのは「a();」の行まで遅延される。これにより、1行目に"Hello"、2行目に"Hello World!"という出力を期待している。しかし、実際は2行とも"Hello World!"が出力される。Actionで始まる行の正しい書き換えはどれか?

using System;

class Program
{
    static void Main(string[] args)
    {
        string s = "Hello";
        Action a = () => Console.WriteLine(s);
        s += " World!";
        a();
        Console.WriteLine(s);
    }
}
  1. Action a = () => Console.WriteLine("Hello!");
  2. Action<string> a = (s) => Console.WriteLine(s);
  3. Action a = () => Console.WriteLine(t - " World!");
  4. string t = s; Action a = () => Console.WriteLine(t);
  5. Action a = () => { string t = s; Console.WriteLine(t); };

 ◎解答:「 4 」(この行をマウスで選択してください)End of Article


 INDEX
  [完全版]究極のC#プログラミング
  Chapter7 ラムダ式(後編)
    1.7.1 ラムダ式は何をもたらすか?
    2.7.2 ラムダ式と匿名メソッドの違い
    3.7.3 ステートメント型のラムダ
    4.7.4 式形式のラムダの可能性
    5.7.5 型指定を省略できる場合、できない場合
    6.7.6 何もしないラムダ式
    7.7.7 ラムダ式の使用例/【C#olumn】「=>」は不等号?
    8.7.8 ラムダ式のさまざまなバリエーション
    9.7.9 ジェネリックメソッドと型推論
  10.7.10 オーバーロードの解決/値型と参照型の相違は何か?/練習問題
 
インデックス・ページヘ  「[完全版]究極のC#プログラミング」


Insider.NET フォーラム 新着記事
  • 第2回 簡潔なコーディングのために (2017/7/26)
     ラムダ式で記述できるメンバの増加、throw式、out変数、タプルなど、C# 7には以前よりもコードを簡潔に記述できるような機能が導入されている
  • 第1回 Visual Studio Codeデバッグの基礎知識 (2017/7/21)
     Node.jsプログラムをデバッグしながら、Visual Studio Codeに統合されているデバッグ機能の基本の「キ」をマスターしよう
  • 第1回 明瞭なコーディングのために (2017/7/19)
     C# 7で追加された新機能の中から、「数値リテラル構文の改善」と「ローカル関数」を紹介する。これらは分かりやすいコードを記述するのに使える
  • Presentation Translator (2017/7/18)
     Presentation TranslatorはPowerPoint用のアドイン。プレゼンテーション時の字幕の付加や、多言語での質疑応答、スライドの翻訳を行える
@ITメールマガジン 新着情報やスタッフのコラムがメールで届きます(無料)

注目のテーマ

Insider.NET 記事ランキング

本日 月間