Chapter 4 - Item 44 : Avoid Modifying Bound Variables

Effective C# (Covers C# 6.0), (includes Content Update Program): 50 Specific Ways to Improve Your C#, 3rd Edition By Bill Wagner 讀後心得

LINQ 使用非常方便且彈性,而能讓他有如此的彈性需歸功於利用大量的委派;讓用戶端客制化自己的 Query 方式。而因為委派方法內有儲存“變數參考”時,當不經意的去修改該參考時,可能會出現讓人出乎意料的結果;本節將介紹其中原理。

考慮以下程式碼:

public IEnumerable<int> Generate(int max, Func<int> func)
{
	var counter = 0;
	while (counter < max)
	{
		yield return func();
		counter++;
	}
}

測試程式碼:

public void Test()
{
	var index = 0;
	Func<IEnumerable<int>> sequence = () => Generate(5,() => index++);
	
	index = 20;
	foreach (var n in sequence.Invoke())
		Debug.WriteLine(n.ToString());
	
	Debug.WriteLine("Done");
	index = 100;
	foreach (var n in sequence.Invoke())
		Debug.WriteLine(n.ToString());
}

輸出會是:
20
21
22
23
24
Done
100
101
102
103
104

設定 index = 100 時,也同時修改到了委派內 index 的值;原因就是先前所提到,委派內儲存的是 index 的參考位置。

編譯器其實在背後幫我們做了一些事情,來看下面這個簡單例子。

int[] someNumbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var answers = from n in someNumbers
  select n * n;

編譯器會產生類似以下程式碼:

private static int HiddenFunc(int n) => n * n;
private static Func<int, int> HiddenDelegateDefinition;

int[] someNumbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
if (HiddenDelegateDefinition == null)
	HiddenDelegateDefinition = new Func<int, int>(HiddenFunc);

var answers = someNumbers.Select<int, int>(HiddenDelegateDefinition);

編譯器幫我們在背景產生了一組供 Select Query 使用的方法委派與方法 pair。

接著我們加入一個 Query 表達式。

class ModFilter
{
	private readonly int _modulus;

	public ModFilter(int mod)
	{
		_modulus = mod;
	}

	public IEnumerable<int> FindValues(IEnumerable<int> sequence)
	{
		return from n in sequence
		       where n % _modulus == 0 // 新的條件表達式
		       select n * n; // 原先的選取表達式
	}
}

編譯器產生的程式碼:

class ModFilterCompilie
{
	private readonly int _modulus;

	public ModFilterCompilie(int mod)
	{
		_modulus = mod;
	}

	// 新增的方法
	private bool WhereClause(int n)
		=> n % _modulus == 0;

	// 原先的方法
	private static int SelectClause(int n)
		=> n * n;

	// 原先的委派
	private static Func<int, int> SelectDelegate;

	public IEnumerable<int> FindValues(IEnumerable<int> sequence)
	{
		if (SelectDelegate == null)
			SelectDelegate = new Func<int, int>(SelectClause);

		return sequence.Where(new Func<int, bool>(WhereClause))
		               .Select<int, int>(SelectClause);
	}
}

我們可以看到,在 LINQ 語法糖的背後,都只是委派與方法的宣告;只是外部不需要關心這些就可以簡單使用。

那麼,加入變數的情況會是如何呢?
再次修改程式碼:

class ModFilter2
{
	private readonly int _modulus;

	public ModFilter2(int mod)
	{
		_modulus = mod;
	}

	public IEnumerable<int> FindValues(IEnumerable<int> sequence)
	{
		int numValues = 0;
		return from n in sequence
		       where n % _modulus == 0
		       select n * n / ++numValues; // 新增了 numValues
	}
}

編譯器產生的程式碼:

class ModFilter2Compile
{
	private sealed class Closure
	{
		public ModFilter2Compile _outer;
		public int _numValues;

		public int SelectClause(int n)
			=> (n * n) / ++_numValues;
	}

	private readonly int _modulus;

	public ModFilter2Compile(int mod)
	{
		_modulus = mod;
	}

	private bool WhereClause(int n)
		=> (n % _modulus) == 0;

	public IEnumerable<int> FindValues(IEnumerable<int> sequence)
	{
		var c = new Closure
		{
			_outer = this,
			_numValues = 0
		};
		return sequence.Where<int>(new Func<int, bool>(WhereClause))
		               .Select<int, int>(new Func<int, int>(c.SelectClause));
	}
}

可以看到,編譯器幫我們自動產生了 Closure 類別,用以儲存 _numberValue 與 ModFilter2Compile 本身,還記得 Item41 提過的 IEnumerable 延遲執行的特性,會造成物件生命週期延長嗎?只有在外部不再參考 FindValues 回傳結果時,Closure 與 ModFilter2Compile 資源才會被釋放。聽起來很抽象,先跑個測試來看看就知道了。

測試程式碼:

var list = new List<int>{6,3};
var x = new ModFilter2Compile(3);
var y = x.FindValues(list);

foreach (var e in y)
    Debug.WriteLine(e.ToString());
	
Debug.WriteLine("Done");

foreach (var e in y)
    Debug.WriteLine(e.ToString());

經過兩次繞行的結果將不會是:
36
4
Done
36
4

而是:
36
4
Done
12
2

在一開始的例子中,此處的 _numValues 就相當於 index,當外部或內部去修改該值時,修改的不會是副本,而是值本身。

結論:
1. 使用 LINQ 時,需注意外部是否有去修改到委派方法內參考到的值;否則容易發生未預期的行為。