這個做法警告不要宣告 virtual event 因為在衍生類別覆寫 event 可能會導致意料外的問題。

在做法 16 有提到宣告事件時背後會產生 addremove 存取子以及一個 private backing field 供事件使用,這就代表當衍生類別覆寫事件的時候 因為 private 存取範圍導致 backing field 無法被存取,最後基底類別的事件無法正確觸發衍生類的事件。

例如下面這段程式碼在衍生類別 WorkerEngineDerived 覆寫了 OnProgress 事件,這會產生兩個 private backing field 一個在基底類別一個在衍生類別, 特別的是衍生類別的 backing field 會使用 new 來宣告,使用 new 來宣告的效果之前有提到過,就是透過隱藏的機制把基底類別的欄位隱藏起來, 所以當我們註冊訂閱者的時候實際是註冊在衍生類別的 backing field 而不是在基底類別的 backing field 上,由於在衍生類別沒有 Invoke 相關的程式碼 ,結果就是 SendConsoleLogger 不會被執行。

void Main()
{
	var w = new WorkerEngineDerived();
	w.OnProgress += SendConsoleLogger;
	((WorkerEngineBase)w).OnProgress += SendConsoleLogger;
	w.DoLotsOfStuff();
}

public class WorkerEngineDerived : WorkerEngineBase
{
	protected override void SomeWork()
	{
		Thread.Sleep(50);
	}

	public override event EventHandler<WorkerEventArgs> OnProgress;
}

public abstract class WorkerEngineBase
{
	public virtual event EventHandler<WorkerEventArgs> OnProgress;
	public void DoLotsOfStuff()
	{
		for (int i = 0; i < 10; i++)
		{
			SomeWork();
			WorkerEventArgs args = new WorkerEventArgs();
			args.Percent = i;
			OnProgress?.Invoke(this, args);
			if (args.Cancel)
				return;
		}
	}
	protected abstract void SomeWork();
}

public class WorkerEventArgs : EventArgs
{
	public int Percent { get; set; }
	public bool Cancel { get; set; }
}

public void SendConsoleLogger(object sender, WorkerEventArgs args)
{
	Console.Error.WriteLine("Work");
}

從結果來說所有事件都是註冊在衍生類別的 backing field 上,所以基底類別的 OnProgress?.Invoke 其實一直都是在喚醒基底類別的 backing field,才會導致沒有訂閱者執行的問題,有幾種解決辦法,一個是把基底類別的 DoLotsOfStuff 方法宣告成 virtual,並且在 衍生類別覆寫這樣就能確保衍生類別的事件被順利喚醒。

public class WorkerEngineDerived : WorkerEngineBase
{
	protected override void SomeWork()
	{
		Thread.Sleep(50);
	}

	public override event EventHandler<WorkerEventArgs> OnProgress;
	
	public override void DoLotsOfStuff()
	{
		for (int i = 0; i < 10; i++)
		{
			SomeWork();
			WorkerEventArgs args = new WorkerEventArgs();
			args.Percent = i;
			OnProgress?.Invoke(this, args);
			if (args.Cancel)
				return;
		}
	}
}

第二個是調整衍生類別並改用 addremove 存取子,這樣就不會在衍生類別生成 backing field,而是把所有存取事件都轉移到基底類別上。

public class WorkerEngineDerived : WorkerEngineBase
{
	protected override void SomeWork()
	{
		Thread.Sleep(50);
	}

	public override event EventHandler<WorkerEventArgs> OnProgress
	{
		add { base.OnProgress += value; }
		remove { base.OnProgress -= value; }
	}
}

還有一種是額外建立一個 virtual 方法 RaiseEvent,之後在衍生類別覆寫 RaiseEvent 就能喚醒正確的事件。

void Main()
{
	var w = new WorkerEngineDerived();
	w.OnProgress += SendConsoleLogger;
	w.DoLotsOfStuff();
}

public class WorkerEngineDerived : WorkerEngineBase
{
	protected override void SomeWork()
	{
		Thread.Sleep(50);
	}

	public override event EventHandler<WorkerEventArgs> OnProgress;
	protected override void RaiseEvent(WorkerEventArgs args)
	{
		OnProgress?.Invoke(this, args);
	}
}

public abstract class WorkerEngineBase
{
	public virtual event EventHandler<WorkerEventArgs> OnProgress;
	protected virtual void RaiseEvent(WorkerEventArgs args)
	{
		OnProgress?.Invoke(this, args);
	}
	public void DoLotsOfStuff()
	{
		for (int i = 0; i < 10; i++)
		{
			SomeWork();
			WorkerEventArgs args = new WorkerEventArgs();
			args.Percent = i;
			RaiseEvent(args);
			if (args.Cancel)
				return;
		}
	}
	protected abstract void SomeWork();
}

public class WorkerEventArgs : EventArgs
{
	public int Percent { get; set; }
	public bool Cancel { get; set; }
}

public void SendConsoleLogger(object sender, WorkerEventArgs args)
{
	Console.Error.WriteLine("Work");
}

Summary

這個做法解說了宣告 virtual event 會造成理解上的誤會,並且要正確執行還需要額外的處理邏輯,雖然有對應的處理方式不過從結果上來看 不要宣告成 virtual 其實才是最好的。