Chapter 4 - Item 41 : Avoid Capturing Expensive Resources # 2

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

前一篇提到了物件會因為延遲執行與委派,進而延長生命週期的情況。這篇繼續用一些實際的例子來說明。

假設有以下方法:

public static class Extensions
{
	public static IEnumerable<string> ReadLines(this TextReader reader)
	{
		var txt = reader.ReadLine();
		while (txt != null)
		{
			yield return txt;
			txt = reader.ReadLine();
		}
	}

	public static int DefaultParse(this string input, int defaultValue)
	{
		return int.TryParse(input, out var answer) ? answer : defaultValue;
	}
}

// 讀取目標檔案,逐行讀取字串後,用 ',' 分隔各個子字串;接著將每一個陣列元素轉換成整數。
public static IEnumerable<IEnumerable<int>> ReadNumbersFromStream(TextReader t)
{
	var allLines = from line in t.ReadLines()
	               select line.Split(',');
	var matrixOfValues = from line in allLines
                             select from item in line
	                     select item.DefaultParse(0);
	return matrixOfValues;
}

客戶端程式碼:

var reader = new StreamReader(File.OpenRead("TestFile.txt"));
var rowOfNumbers = ReadNumbersFromStream(reader);

接著,在下一次 Code Review 會被說:嘿~你忘記釋放資源了喔。
於是很自然的改成以下程式碼:

var rowOfNumbers = default(IEnumerable<IEnumerable<int>>);
using (var reader = new StreamReader(File.OpenRead("TestFile.txt")))
	rowOfNumbers = ReadNumbersFromStream(reader);

嗯,看起來很不錯;有用 using 釋放資源。但跑測試後會擲出 ObjectDisposedException 例外。

var rowOfNumbers = default(IEnumerable<IEnumerable<int>>);
using (var reader = new StreamReader(File.OpenRead("TestFile.txt")))
	rowOfNumbers = ReadNumbersFromStream(reader);

// 擲出例外
foreach (var line in rowOfNumbers)
{
	foreach (var num in line)
		Debug.WriteLine($"Number is {num.ToString()}");
}

原因還是先前提過的,延遲執行。IEnumerable 只有在呼叫 foreach 或 ToList 時,才會真正執行。於是又修改了程式:

using (var reader = new StreamReader(File.OpenRead("TestFile.txt")))
{
	var rowOfNumbers = ReadNumbersFromStream(reader);

	foreach (var line in rowOfNumbers)
		foreach (var num in line)
			Debug.WriteLine($"Number is {num.ToString()}");
}

那就把資源釋放跟用戶端程式碼包在一起就好啦,但這樣有個缺點;重複的程式碼多了(客戶端每次呼叫都要記得 using)。
那要不然把 using 區塊封裝到底層如何呢?

public static IEnumerable<IEnumerable<int>> ReadNumbersFromStream(string path)
{
	using (var reader = new StreamReader(File.OpenRead(path)))
	{
		var allLines = from line in reader.ReadLines()
		               select line.Split(',');
		var matrixOfValues = from line in allLines
		                      select from item in line
		                      select item.DefaultParse(0);
		return matrixOfValues;
	}
}

再進一步,把客戶端對集合的操作寫成委派,讓 using 區塊真正的留在底層方法;去除外部需要多去管裡 StreamReader 的情況。

// 定義客戶端方法委派
public delegate TResult ProcessElementsFromFile<TResult>(IEnumerable<IEnumerable<int>> values);

// 定義包含讀取與依照外部傳入委派執行的方法
public static TResult ProcessFile<TResult>(string filePath, ProcessElementsFromFile<TResult> func)
{
	using (var reader = new StreamReader(File.OpenRead(path)))
	{
		var allLines = from line in reader.ReadLines()
		               select line.Split(',');
		var matrixOfValues = from line in allLines
		                     select from item in line
		                     select item.DefaultParse(0);
		return func(matrixOfValues);
	}
}

客戶端程式碼:

var maximum = ProcessFile("Test.txt", arrayOfNums=>
                                      (from line in arrayOfNums
                                       select line.Max()).Max());

如此一來,我們已經將檔案開啟讀取與釋放完全和外部程式碼解耦;不需再去擔心物件的生命週期。

結論:
1. 物件的生命週期,即便在記憶體託管的環境下;仍然需要開發者去留意。
2. 延遲執行造成物件生命週期延展;可能的話,盡早呼叫 ToList 把結果存入快取中也是一個方式。在此例的方式為:
// 呼叫 ToList 立即執行,不再需要 StreamReader。
var allLines = (from line in reader.ReadLines()
                select line.Split(',')).ToList();