for WPF developers
Home Profile Tips 全記事一覧

遅延評価について

(2017/03/14 11:40:39 created.)

遅延評価について説明するために、まずは以下のサンプルコードを見てみましょう。

Program.cs
  1. namespace Tips_Linq
  2. {
  3.     using System;
  4.     using System.Linq;
  5.  
  6.     class Program
  7.     {
  8.         static void Main(string[] args)
  9.         {
  10.             var numbers = Enumerable.Range(1, 4).Select(x =>
  11.             {
  12.                 Console.WriteLine(x);
  13.                 return x;
  14.             });
  15.             Console.WriteLine("シーケンスの定義はここまで。");
  16.  
  17.             Console.ReadKey();
  18.         }
  19.     }
  20. }


Select 拡張メソッドによってシーケンスを別のシーケンスに射影できますが、ここでは要素をそのまま返しているため、シーケンスの内容は特に変更していません。ここで注目すべきは Select 拡張メソッドの中で使用している Console.WriteLine() メソッドの実行タイミングです。

上記のサンプルコードのままでは、実行してもコンソールには何も出力されません。つまり、12 行目の Console.WriteLine() メソッドは呼び出されていないということになります。これが Linq 最大の特徴のひとつである遅延評価というものです。

Linq が提供する拡張メソッドは様々な方法であるシーケンスから別のシーケンスや何かの数値へと変換をおこないますが、実際にその変換がおこなわれるタイミングは「そのインスタンスが使われるとき」となります。上記のサンプルコードでは、変数 numbers を定義していますが使用していません。したがって Select 拡張メソッドなどによるシーケンスの変換は実際にはおこなわれないため、12 行目の Console.WriteLine() メソッドも呼び出されないままとなっています。

それではこのサンプルコードを次のように修正してみましょう。

Program.cs
  1. namespace Tips_Linq
  2. {
  3.     using System;
  4.     using System.Linq;
  5.  
  6.     class Program
  7.     {
  8.         static void Main(string[] args)
  9.         {
  10.             var numbers = Enumerable.Range(1, 4).Select(x =>
  11.             {
  12.                 Console.WriteLine(x);
  13.                 return x;
  14.             });
  15.             Console.WriteLine("シーケンスの定義はここまで。");
  16.  
  17.             Console.WriteLine("要素数 = " + numbers.Count());
  18.  
  19.             Console.ReadKey();
  20.         }
  21.     }
  22. }


先ほどのサンプルコードと違い、17 行目で変数 numbers にアクセスしています。すると、出力結果に 17 行目の出力の前に 12 行目の出力結果が表示されている様子がわかります。このように、Linq によるシーケンスの変換の実行タイミングは変数を生成したときと異なることに注意が必要です。

それでは次のサンプルコードを見てみましょう。

Program.cs
  1. namespace Tips_Linq
  2. {
  3.     using System;
  4.     using System.Linq;
  5.  
  6.     class Program
  7.     {
  8.         static void Main(string[] args)
  9.         {
  10.             var numbers = Enumerable.Range(1, 4).Select(x =>
  11.             {
  12.                 var z = GetSomeValue();
  13.                 Console.WriteLine("Select " + z);
  14.                 return z;
  15.             });
  16.             Console.WriteLine("シーケンスの定義はここまで。");
  17.  
  18.             Console.WriteLine("1 回目 --------------");
  19.             Console.WriteLine(string.Join(", ", numbers));
  20.  
  21.             Console.WriteLine("2 回目 --------------");
  22.             Console.WriteLine(string.Join(", ", numbers));
  23.  
  24.             Console.ReadKey();
  25.         }
  26.  
  27.         private static int _count;
  28.  
  29.         private static int GetSomeValue()
  30.         {
  31.             return _count++;
  32.         }
  33.     }
  34. }


このサンプルコードでは 4 つの整数を要素とするシーケンスを変数 numbers として定義しています。Select 拡張メソッドから 4 回 GetSomeValue() メソッドが呼び出されるため、{0, 1, 2, 3} がシーケンスの中身になると期待します。

中の値を確認するために 17 行目でコンソールに表示しています。確かに 0 から 3 の整数が要素となっていることがわかります。ところが、20 行目でもう一度中の値を確認したとき、1 回目にアクセスしたときと異なる値が表示されています。これは、遅延評価によって Select 拡張メソッドに指定したデリゲートがもう一度呼び出されているため、 GetSomeValue() メソッドがもう一度 4 回呼び出されてしまうからです。

Linq によるシーケンス生成は、あくまでも生成方法をデリゲートで渡しているだけなので、実際にその方法でシーケンスを生成するタイミングは「そのインスタンスが使われるとき」となります。したがって、同じ変数でもその他の状態が異なればその変数の中身も変化することがあるので注意が必要です。

場合によってはこのままでも良いかもしれませんが、一度アクセスしたときの中身をそのまま維持したいこともあります。そのようなときは ToArray 拡張メソッドなどを使うと遅延評価ではなく即時評価されるようになります。ToArray 拡張メソッドを使ったサンプルコードを見てみましょう。

Program.cs
  1. namespace Tips_Linq
  2. {
  3.     using System;
  4.     using System.Linq;
  5.  
  6.     class Program
  7.     {
  8.         static void Main(string[] args)
  9.         {
  10.             var numbers = Enumerable.Range(1, 4).Select(x =>
  11.             {
  12.                 var z = GetSomeValue();
  13.                 Console.WriteLine("Select " + z);
  14.                 return z;
  15.             }).ToArray();
  16.             Console.WriteLine("シーケンスの定義はここまで。");
  17.  
  18.             Console.WriteLine("1 回目 --------------");
  19.             Console.WriteLine(string.Join(", ", numbers));
  20.  
  21.             Console.WriteLine("2 回目 --------------");
  22.             Console.WriteLine(string.Join(", ", numbers));
  23.  
  24.             Console.ReadKey();
  25.         }
  26.  
  27.         private static int _count;
  28.  
  29.         private static int GetSomeValue()
  30.         {
  31.             return _count++;
  32.         }
  33.     }
  34. }


先ほどのサンプルコードとの違いは 15 行目で ToArray 拡張メソッドを使うようにしたことだけです。すると、変数 numbers を宣言したときに Select 拡張メソッドに指定したデリゲートが実行されている様子がわかります。また、2 回目にアクセスしても 1 回目と同じ要素が維持されている様子がわかります。このように、ToArray 拡張メソッドはその瞬間に切り取ったシーケンスのキャッシュを保持するときなどに利用されます。

ちなみに ToArray 拡張メソッドの中では次のように Array.Copy メソッドを使っています。値型ならその値、参照型ならその参照先アドレスをコピーした要素を持つ配列を用意することで遅延評価されることを回避しているようです。

ILSpy による逆コンパイル結果
  1. internal TElement[] ToArray()
  2. {
  3.     TElement[] array = new TElement[this.count];
  4.     Array.Copy(this.items, 0, array, 0, this.count);
  5.     return array;
  6. }

それでは、ToArray 拡張メソッドを使った次のサンプルコードを見てみましょう。

Program.cs
  1. namespace Tips_Linq
  2. {
  3.     using System;
  4.     using System.Linq;
  5.  
  6.     class Program
  7.     {
  8.         static void Main(string[] args)
  9.         {
  10.             var numbers = Enumerable.Range(1, 4).Select(x =>
  11.             {
  12.                 var z = GetSomeValue();
  13.                 Console.WriteLine("Select " + z);
  14.                 return z;
  15.             }).ToArray();
  16.             Console.WriteLine("シーケンスの定義はここまで。");
  17.  
  18.             foreach (var number in numbers)
  19.             {
  20.                 _count++;
  21.                 Console.WriteLine(number);
  22.             }
  23.  
  24.             Console.ReadKey();
  25.         }
  26.  
  27.         private static int _count;
  28.  
  29.         private static int GetSomeValue()
  30.         {
  31.             return _count++;
  32.         }
  33.     }
  34. }


18 行目以降で実行されている foreach 文の中で、変数 _count の値を変更しているため、GetSomeValue() メソッドの戻り値に影響を与えています。ただし、15 行目で ToArray 拡張メソッドを使っているため、変数 numbers の中身はここで確定しています。したがって、変数 numbers の各要素は {0, 1, 2, 3} になっていることが確認できています。

それでは、ToArray 拡張メソッドを使わないようにするとどうなるでしょうか。次の出力結果は上記のサンプルコードで、 15 行目の ToArray 拡張メソッドを使わないようにした場合の結果です。


遅延評価される Select 拡張メソッドは、各要素にアクセスする度に評価がおこなわれるため、20 行目のインクリメントがおこなわれてから要素が取り出されています。したがって、{0, 1, 2, 3} というシーケンスを期待していた場合、異なるシーケンスとなってしまいます。

また、OrderByDescending 拡張メソッドを使うと状態変化があっても影響されないように見えますが、複数回アクセスするとやはり影響されるようです。

Program.cs
  1. namespace Tips_Linq
  2. {
  3.     using System;
  4.     using System.Linq;
  5.  
  6.     class Program
  7.     {
  8.         static void Main(string[] args)
  9.         {
  10.             var numbers = Enumerable.Range(1, 4).Select(x =>
  11.             {
  12.                 var z = GetSomeValue();
  13.                 Console.WriteLine("Select " + z);
  14.                 return z;
  15.             }).OrderByDescending(x => x);
  16.             Console.WriteLine("シーケンスの定義はここまで。");
  17.  
  18.             Console.WriteLine("1 回目 --------------");
  19.             foreach (var number in numbers)
  20.             {
  21.                 _count++;
  22.                 Console.WriteLine(number);
  23.             }
  24.  
  25.             Console.WriteLine("2 回目 --------------");
  26.             foreach (var number in numbers)
  27.             {
  28.                 _count++;
  29.                 Console.WriteLine(number);
  30.             }
  31.  
  32.             Console.ReadKey();
  33.         }
  34.  
  35.         private static int _count;
  36.  
  37.         private static int GetSomeValue()
  38.         {
  39.             return _count++;
  40.         }
  41.     }
  42. }


遅延評価という特徴は生成後のシーケンスの実体を「インスタンスが使われるまで」実体化しないのでメモリ節約ができるというメリットがありますが、シーケンスの生成に何度も同じ処理がおこなわれたり、悪い意味で状態変化に追従してしまったりという性質があります。遅延評価でおこなうべきか、即時評価でキャッシュを保持すべきかを常に意識しておくことがとても重要です。