Effective C# 48.偏好強例外保證 Effective C# 48.偏好強例外保證 (Prefer the Strong Exception Guarantee)

Published on Tuesday, October 29, 2024

在做法 39 有提到 Strong exception guarantee 這個概念,這個做法會對三種保證進行詳細說明。

以下是三種保證與它們各自的定義。

  1. No-throw guarantee: 保證你寫的方法不會拋出例外錯誤,也是約束最強的一種設計,一但方法內部有錯誤產生需要馬上進行內部處理,不能讓外部的使用者發覺。
  2. Strong exception guarantee: 可以拋出錯誤但需要保證發生錯誤時數據要返回原始狀態,要保證錯誤產生時不能有副作用。
  3. Basic exception guarantee: 最寬鬆的一種設計,錯誤產生時數據可能是錯亂的狀態但還是可以操作數據。

從定義中可以知道 Strong exception guarantee 是比較折中的處理方式,使用者開發起來也比較簡單並且又能保證程序能夠從錯誤中恢復也不會有副作用。

做法 39 提到使用 ToList() 方法先備份數據,成功之後才會賦值給 allEmployees,如果運作途中拋出錯誤也不會運行到賦值的那段程式碼 避免副作用的產生。

var updates = (from e in allEmployees
               select new Employee
               {
                   EmployeeID = e.EmployeeID,
                   Classification = e.Classification,
                   YearsOfService = e.YearsOfService,
                   MonthlySalary = e.MonthlySalary *= 1.05M
               }).ToList();
allEmployees = updates;

Strong exception guarantee 也可以理解成當錯誤產生時,程序的狀態必須跟執行前的狀態相同,代表要麼完全成功要麼完全失敗, 如果失敗就必須保證狀態要回到跟一開始一模一樣,絕對不能產生部分成功部分失敗的結果,也就是說這個操作沒有生效或者從來不存在過。

採用上面提到的備份方法會稍微降低程式碼的運行效率,原因也很容易理解,建立備份就代表需要在 heap 上分配記憶體,不過由於有 GC 背後會進行管控 所以開銷並沒有想像的那麼大,犧牲了一點運行效率可以換來更加安全的強異常保證是更加划算的。

但要注意參考型別的複製問題,下面這段程式碼看起來是使用了正確的備份機制,保證 UnreliableOperation 執行成功後才會覆蓋原本資料, 但因為 MyCollection 所讀取的是 data 的參考,所以如果有人在你修改 data 同時在讀取 MyCollection 那他就可能會看到舊的數據, 也就是說值型別才適合直接用備份的這個技巧。

private List<PayrollData> data;
public IList<PayrollData> MyCollection
{
   get { return data; }
}
public void UpdateData()
{
   var temp = UnreliableOperation();

   data = temp;
}

在參考型別還要額外進行處理,從上一段的結論知道我們不能直接替換因為可能會獲取到舊的數據,因此關鍵是要把參考指向的數據替換掉並且 必須保證替換的過程中不能有任何錯誤產生,而不是替換參考本身。

最容易想到的做法就是把 List 裡面的資料都刪光,之後把新的數據添加到 List 裡面,例如下面這段程式碼可以應付一般遇到的情況。

private List<PayrollData> data;
public IList<PayrollData> MyCollection
{
	get
	{
		return data;
	}
}
public void UpdateData()
{
	var temp = UnreliableOperation();
	
	data.Clear();
	foreach (var item in temp)
		data.Add(item);
}

如果要更嚴謹一點的寫法可以採用 envelope-letter pattern 這個設計模式,這模式也很好理解就是建立一個新的類別(envelope) 來包裹 資料(letter) 也就是說我們需要把上面的 data 欄位從原本的 List<PayrollData> 類型改成一個新的信封類型 Envelope, 並且透過公開的方法 SafeUpdate 給使用者呼叫以更新資料,這種寫法就能保持線程安全避免替換資料的時候被打斷。

private Envelope data;
public IList<PayrollData> MyCollection
{
	get
	{
		return data;
	}
}
public void UpdateData()
{
	data.SafeUpdate(UnreliableOperation());
}

public class Envelope : IList<PayrollData>
{
	private List<PayrollData> data = new List<PayrollData>();
	public void SafeUpdate(IEnumerable<PayrollData> sourceList)
	{
		List<PayrollData> updates = new List<PayrollData>(sourceList.ToList());
		data = updates;
	}
	public PayrollData this[int index]
	{
		get { return data[index]; }
		set { data[index] = value; }
	}
	public int Count => data.Count;
	public bool IsReadOnly => ((IList<PayrollData>)data).IsReadOnly;
	public void Add(PayrollData item) => data.Add(item);
	public void Clear() => data.Clear();
	public bool Contains(PayrollData item) => data.Contains(item);
	public void CopyTo(PayrollData[] array, int arrayIndex) => data.CopyTo(array, arrayIndex);
	public IEnumerator<PayrollData> GetEnumerator() => data.GetEnumerator();
	public int IndexOf(PayrollData item) => data.IndexOf(item);
	public void Insert(int index, PayrollData item) => data.Insert(index, item);
	public bool Remove(PayrollData item)
	{
		return ((IList<PayrollData>)data).Remove(item);
	}
	public void RemoveAt(int index)
	{
		((IList<PayrollData>)data).RemoveAt(index);
	}

	IEnumerator IEnumerable.GetEnumerator() => data.GetEnumerator();
}

另外要提到最嚴格的 No-throw guarantee,畢竟真的有些地方需要保證不能拋出錯誤,例如 Finalizer 與 Dispose 就是如此,如果在這兩個方法 拋出錯誤反而會造成更大的問題,可以把較複雜的方法包在 try/catch 裡面把錯誤吞掉保證不會發生異常。

還有 exception filter 的 when 子句也絕對不能拋出錯誤,如果拋出的話反而會導致舊的異常無法被訪問。

還有一個是 delegate 還有 event 對於 multicast delegate 來說如果其中一個委派發生錯誤,其他的委派就不會在執行所以要極力避免委派拋出錯誤。


Summary

Exception 可能會導致程式產生意料之外的結果,所以要避免這個問題最好是讓你的程式碼有 Strong exception guarantee,也就是只允許成功 不然就是維持原樣。

Finalizer、Dispose、Exception Filter、Delegate 這幾個是特例要避免拋出錯誤。

最後就是使用備份來替換原始數據的方式要注意參考型別所會導致的問題。