主頁 > .NET開發 > .NET C#基礎(3):事件 - 不便處理的事就委托出去

.NET C#基礎(3):事件 - 不便處理的事就委托出去

2022-06-09 06:00:35 .NET開發

0. 文章目的

??本文面向有一定.NET C#基礎知識的學習者,介紹.NET中事件的相關概念、基本知識及其使用方法

 

1. 閱讀基礎

??理解C#基本語法(方法的宣告、方法的呼叫、類的定義)

 

2. 從委托說起,到底什么是事件

2.1 方法與委托

(1)從一個案例開始說起

??在討論本節主題之前,我們先來看一個實際問題,下面是一個方法,作用是把兩個值相加,然后將相加的結果通過控制臺程式列印出來,接著再回傳相加的值:

int Add(int a, int b)
{
    int n = a + b;
    Console.WriteLine(n);
    return n;
}

??這個方法很簡單,它在你的代碼中跑的很好,但需求總是會不斷變化的,現在新的需求來了:你希望可以把結果列印到一個檔案里,而不是在控制臺上列印,這對你來說也很簡單,你打開了定義此方法的檔案,然后做出了修改:

int Add(int a, int b)
{
    int n = a + b;
    Log.WriteToFile(n);
    return a + b;
}

(請不要在意Log.WriteToFile方法是否真的存在)

??這一次修改后,這個方法順利地跑了幾天,然而...是的,新的需求又來了,這你發現自己需要兩個Add方法,一個版本可以通過控制臺列印相加結果,另一個版本則可以將相加結果寫入檔案,這對你來說依然不難,你很快做出了以下修改:

int Add1(int a, int b)
{
    int n = a + b;
    Console.WriteLine(n);
    return a + b;
}
int Add2(int a, int b)
{
    int n = a + b;
    Log.WriteToFile(n);
    return a + b;
}

??方法名似乎有點隨意,但它們可以正確運行,但經歷了兩次修改后你意識到如果之后還有類似的需求,修改代碼的成本會越來越高,同時這時你發現了一個問題:Add1和Add2似乎有重復的代碼,遵循應當盡可能減少重復代碼,你決定將重復的代碼抽出來單獨成方法:

int Add1(int a, int b)
{
    int n = AddCore(a, b);
    Console.WriteLine(n);
    return a + b;
}
int Add2(int a, int b)
{
    int n = AddCore(a, b);
    Log.WriteToFile(n);
    return a + b;
}
int AddCore(int a, int b)
{
    return a + b;
}

??然而這似乎有點不太對勁:整個代碼不僅一行都沒有變少,反而還增加了復雜度,

(2)著手解決

??顯然,問題的根本不在于那一行簡單的a + b,現在回過來觀察一下兩個方法:

int Add1(int a, int b)
{
    int n = a + b;
    Console.WriteLine(n);
    return a + b;
}
int Add2(int a, int b)
{
    int n = a + b;
    Log.WriteToFile(n);
    return a + b;
}

??你發現兩個方法做的事基本相同,唯一的不同是它們對運算結果的輸出方式不同 - 一個通過控制臺顯示,一個將結果寫入檔案,這時你意識到:能否把這種輸出方式‘委托’出去,而不是在代碼中具體定義?或者說,把輸出方式像方法的引數一樣傳遞進去,在呼叫時自行決定使用什么方法輸出,這樣,到底要通過控制臺顯示還是寫入到檔案,就可以在呼叫時才決定,就像下面這樣:

int Add(int a, int b, 用來輸出用的方法)
{
    int n = a + b;
    呼叫用來輸出用的方法,并把n的值作為方法的引數,讓方法處理對n的值的輸出
    return a + b;
}

??要實作此目的,就需要使用.NET中的‘委托’機制,在C#中,委托的使用就類似于下面這樣:

int Add(int a, int b, OutputFunction of)
{
    int n = a + b;
    of(n);
    return a + b;
}

??這里我們假設OutputFunction是一個方法的委托,這樣,輸出的實際行為就可以由OutputFunction型別的of引數完成,你可以像下面這樣使用Add方法:

Add(1, 2, Console.WriteLine); // 相當于用Console.WriteLine替換of
Add(1, 2, Log.WriteToFile); // 相當于用Log.WriteToFile替換of

(從更廣泛的概念來說,這一行為被稱之為函式回呼, )

??可以認為,委托其實就是方法的代表,它用來表示了某個具體的方法,這并不奇怪,用委托表示具體方法就應該如同使用變數表示數字一樣自然:

int n = 1;
OutputFunction of = Console.WriteLine;

(3)定義委托??

??方法的呼叫只需要知道方法簽名,因此要代表方法,委托也只需要能表示方法簽名即可,實際上,委托只需要匹配方法的回傳型別和引數串列即可(因為方法名已經由委托型別的變數名所替代),因此,一個簡單的的委托定義如下:

delegate void MyDelegate(int n);

??你可以委托的宣告很像方法宣告,唯一不同的是使用關鍵字deleagete指明了它是一個委托,這個委托可以代表的方法應該是這樣的:

  1. 方法沒有回傳值
  2. 接受一個int型別的引數

??回到上面的例子,如果你希望通過OutputFunction來作為代表輸出方法的委托,那么OutputFunction的定義應該如下:

delegate void OutputFunction(int n);

(4)封裝委托

??你找到了Add方法的修改方式,你決定通過委托機制對其進行封裝,現在,你將其封裝到一個Math類中,并用一個OutputFunction型別的委托欄位Printer來代表輸出行為,結合上述,Math類定義如下:

delegate void OutputFunction(int n);

class Math
{
    public OutputFunction Printer;

    public int Add(int a, int b)
    {
        int n = a + b;
        Printer(n);
        return a + b;
    }
}

??這樣,便可以像下面這樣使用Math類:

Math math = new Math();

math.Printer = Console.WriteLine; // Printer現在代表Console.WriteLine
int n = math.Add(1, 2);

math.Printer = Log.WriteToFile; // Printer現在代表Log.WriteToFile
int m = math.Add(1, 2);

??現在,你不用再擔心因為需求的變動而反復修改Add方法了,輸出行為已經被‘委托’出去,具體要如何輸出可以在呼叫時輕松決定,

2.2 多播委托

??通過上面的例子你應該對委托有了一定的基本認識,下面再來考慮一個新的需求:如何把相加結果輸出到控制臺的同時還要列印到檔案里呢?一種方法是,使用一個方法包裝一下兩種輸出方法,就像下面這樣:

void PrintAndSave(int n)
{
    Console.WriteLine(n);
    Log.WriteToFile(n);
}

math.Printer = PrintAndSave; // Printer現在代表PrintAndSave了
int n = math.Add(1, 2);

??這是可以的,但這會帶來許多不便,其中一點就是,如果你的委托已經在某個地方被賦值了并且進行了封裝,那么其他人在使用你的類的時候就難以正確地修改被委托的方法,為了解決這個矛盾,考慮另一種解決思路:不是宣告一個委托,而是宣告一個委托串列,并依次呼叫串列中被委托的方法,如下:

class Math
{
    public List<OutputFunction> Printers = new List<OutputFunction>();

    public int Add(int a, int b)
    {
        int n = a + b;
        // 依次呼叫串列中被委托的方法
        for (int i = 0; i < Printers.Count; i++)
        {
            Printers[i](n);
        }
        return a + b;
    }
}

??這樣就可以像下面這樣使用:

Math math = new Math();
math.Printers.Add(Console.WriteLine); 
math.Printers.Add(Log.WriteToFile);

int m = math.Add(1, 2);

??上面這種實作實際就類似于所謂‘多播委托’的作業方式,多播委托的表現類似于委托串列,但是它的優點在于可以用更簡潔的語法完成類似作業,將上面的例子改為使用多播委托,則多播委托的宣告可以簡化為如下:

class Math
{
    public OutputFunction Printers;

    public int Add(int a, int b)
    {
        int n = a + b;
        Printers(n);
        return a + b;
    }
}

??其呼叫方法如下:

Math math = new Math();
math.Printers += Console.WriteLine;
math.Printers += Log.WriteToFile;
int n = math.Add(1, 2);

??你可能會注意到類中的定義多播委托和定義普通的委托的委托型別完全一樣,唯一的區別似乎只在于在使用時需要使用+=符號來為多播委托添加方法(也就是+=的表現類似于對串列使用Add方法),而非使用=符號進行直接賦值,這不是書寫錯誤,而是由于歷史原因,C#中所有通過delegate宣告出來的委托都是多播委托,+=與-=做的事就是將委托加入或移出委托串列,

(4)委托就僅此而已嗎?

??上面的例子的目的僅僅是為了從一個更抽象的概念上理解委托與多播委托,實際上C#中的委托還有很多可探究的地方,例如委托本質其實是一個類(Delegate),而多播委托(MulticastDelegate)是Delegate的子類,并且多播委托的實作也并非只是簡單使用一個委托串列,它的實作依賴于一種更為復雜的被稱為委托鏈的機制(在概念上更像是鏈表),如果希望更進一步理解委托,可以參考.NET的原始碼實作,

2.3 事件

(1)本質:對委托的封裝

??現在會過來看之前的Math類:

class Math
{
    public OutputFunction Printers;

    public int Add(int a, int b)
    {
        int n = a + b;
        Printers(n);
        return a + b;
    }
}

??上面例子中使用一個Printers欄位作為(多播)委托,這樣做存在許多問題,其中一個最明顯的問題在于這個欄位可以被賦值,被賦值后不僅原有的委托鏈將會丟失,還可能導致null例外,也就是說,下面的情況是有可能會發生的:

Math math = new Math();
math.Printers += Console.WriteLine;
math.Printers += Log.WriteToFile;
int n = math.Add(1, 2);

math.Printers = null;
int m = math.Add(1, 2); // 報錯

??在上面的例子中,Printers被賦值為null后,之后的代碼將會在運行時報錯,原因在于此時Printers已經為null,此時Add方法中對其進行呼叫將會引發null例外,一個解決辦法是在呼叫前進行null檢查:

class Math
{
    public OutputFunction Printers;

    public int Add(int a, int b)
    {
        int n = a + b;
        if (Printers != null)
        {
           Printers(n);
        }
        return a + b;
    }
}

??然而這依然無法解決委托鏈丟失的問題:在實際情況中,委托鏈的修改可能會在多個地方進行,不了解委托鏈的修改情況而隨意丟失委托鏈很可能導致程式的作業不符合預期,因此,有必要阻止外部對委托進行直接賦值,對于這類‘避免外部直接修改欄位’的問題,通常可以先考慮使用屬性:

class Math
{
    public OutputFunction Printers { get; private set; }

    public int Add(int a, int b)
    {
        int n = a + b;
        if (Printers != null)
        {
           Printers(n);
        }
        return a + b;
    }
}

??你可能會認為上面這樣就可以避免Printers被直接賦值,事實上也確實如此,然而這會導致一個更為嚴重的問題:無法修改委托鏈,也就是說,+=與-=符也將無法使用,因為兩者實際執行的操作是將當前委托與目標委托使用Delegate類的Combine或Remove靜態方法進行組合后重新賦值,如下:

// math.Printers += Console.WriteLine
math.Printers = (OutputFunction)Delegate.Combine(math.Printers, new OutputFunction(Console.WriteLine));

// math.Printers -= Console.WriteLine
math.Printers = (OutputFunction)Delegate.Remove(math.Printers, new OutputFunction(Console.WriteLine));

(如果你覺得上面的例子難以理解,沒有關系,只需要注意到上面的操作中存在賦值符號=即可)

??顯然,無法簡單地通過將委托欄位使用屬性包裝來解決問題,實際上,即便可以,也還有很多問題需要解決問題,例如,如何避免多播委托的委托鏈被外部意外修改?或者,我們可能需要控制委托的呼叫時機,不能讓委托被隨意呼叫,因此,有必要通過其他手段對委托進行封裝,一種封裝思路是,將委托設定為私有欄位,然后只暴露兩個方法用于將目標委托添加和移出委托鏈,就像下面這樣:

class Math
{
    private OutputFunction? _printers;

    public void AddPrinter(OutputFunction of)
    {
        _printers += of;
    }
    public void RemovePrinter(OutputFunction of)
    {
        _printers -= of;
    }
    
    // ... 省略其他代碼
}

??這樣,外部對于委托欄位的控制權就大大減小了,顯然,C#的設計者也想到了這種方法,并提供了更標準的封裝方式,這種使用了類似于上述封裝方式的委托便被稱之為‘事件’,利用C#提供的定義事件的語法,可以將上面的委托封裝修改為如下所示:

class Math
{
    public event OutputFunction Printers
    {
        add
        {
            _printers += value;
        }
        remove
        {
            _printers -= value;
        }
    }

    private OutputFunction? _printers;
}

??同樣,就如同屬性有自動屬性這樣的簡化宣告語法一樣,事件也有簡化宣告語法,其簡化宣告語法如下:

class Math
{
    public event OutputFunction Printers;
}

??是的,宣告事件和宣告委托欄位的區別僅僅在于簡單地添加了一個event關鍵字,但請記住這只是簡化語法,其本質行為依然依賴于事件的完整宣告語法以及其封裝邏輯,

(2)使用:就像使用委托一樣簡單

??事件的使用和多播委托完全一致,唯一的區別在于在事件的宣告類之外,只允許添加與移出特定委托(即便是子類也是如此):

class Math
{
    public event OutputFunction Printers;

    public int Add(int a, int b)
    {
        int n = a + b;
        if (Printers != null)
        {
           // 在事件的宣告類中,可以引發事件
           // 實際上對于類內部來說,可以像對待多播委托一樣對待事件
           Printers(n); 
        }
        return a + b;
    }
}


Math math = new Math();
math.Printers += Console.WriteLine;
math.Printers += Log.WriteToFile;
int n = math.Add(1, 2); 

math.Printers = null; // 直接給事件賦值,是不允許的操作
math.Printers(); // 嘗試從外部引發事件,同樣是不允許的操作

??你可能注意到上述例子中使用了‘引發事件’這一說法,實際上它就是指讓事件背后的多播委托呼叫委托鏈;而+=操作就是將委托添加到委托鏈中,這一行為被稱為‘訂閱事件’,與之相對的與-操作就是將委托從委托鏈中移出,這一行為被稱為“取消訂閱事件”;而+=后的方法也被稱之為‘事件處理程式’,事件處理程式會被委托包裝后添加到事件背后的委托鏈中,

??你可能會好奇到底是‘委托’訂閱事件還是‘事件處理程式’訂閱事件,答案是委托,盡管語法上看起來是事件處理程式訂閱了委托,但在編譯時編譯器會將其使用委托包裝起來,也就是說,類似于如下:

math.Printers += Console.WriteLine;
// 上述代碼實際含義如下
math.Printers += new OutputFunction(Console.WriteLine);

??基于上述,可以認為訂閱事件的本質就是將委托添加到事件背后的多播委托的委托鏈中,取消訂閱事件則是將委托從委托鏈中移出,而事件的引發的本質就是對委托鏈中的委托進行逐一呼叫

(3)基于委托,但比委托更嚴格

??盡管事件基于委托,并且可以只使用委托與方法的封裝來模擬事件,但是事件應當遵循以下規則:

  1. 使用沒有回傳值的委托,事件的本質是多播委托,也就是引發事件實際就是逐一呼叫委托鏈中的方法,這意味著從多播委托中獲取的回傳值無法確定到底來自于哪個方法(如果真的有類似需求,應考慮其他實作方式),使用這種回傳值可能帶來不確定的后果,
  2. 不依賴委托鏈的執行順序,也就是說,不應該假定事件背后的多播委托的委托鏈以何種順序呼叫委托,并根據此假設來執行某種操作,

 

3. 符合.NET準則的事件

3.1 定義

??要更好地使用事件,應當定義符合.NET準則的事件,這并不難,要求只有一點:使用基于EventHandler的委托型別,EventHandler委托宣告如下:

public delegate void EventHandler(object sender, EventArgs e);

??sender表示事件的發送方,通常情況下就是指類的實體(也就是說,this),EventArgs表示事件引發時的附加引數,下面是一個符合.NET準則的事件宣告:

public event EventHandler MyEvent;

??然而這是遠遠不夠的,因為EventArgs是一個非常簡單的類,它不提供任何有意義的附加資訊,這意味著你需要定義自己的EventArgs來傳遞所需要的引數,并定義使用自定義EventArgs的EventHandler委托,

(1)定義EventArgs事件引數

??作為規范,自定義的EventArgs應滿足下面兩個要求:

  1. 派生自EventArgs
  2. 以事件名+EventArgs作為類名

??現在假定有一個NewMessageArrived事件,則下面是一個用于該事件的自定義EventArgs的示例,此EventArgs擁有一個string型別的Message屬性:

public class NewMessageArrivedEventArgs : EventArgs
{
    public string Message { get; }

    public NewMessageArrivedEventArgs(string message)
    {
        Message = message;
    }
}

(2)定義EventHandler委托

??定義好EventArgs后,還需要定義相應的委托,作為規范,委托的定義滿足以下要求:

  1. 回傳值于引數串列形如EventHandler
  2. 以事件名+EventHandler為委托名

??下面是一個用于NewMessageArrived事件的自定義的EventHandler,該委托使用NewMessageArrivedEventArgs代替了原來的EventArgs:

public delegate void NewMessageArrivedEventHandler(object sender, NewMessageArrivedEventArgs e);

??這樣,結合上述的自定義EventArgs與EventHandler,可以宣告一個符合.NET準則的事件:

class Messenger
{
    public event NewMessageArrivedEventHandler NewMessageArrived;
}

??此外,除了手動宣告委托外,還有一種做法是使用泛型EventHandler<>,其接受一個泛型引數作為事件附加引數的引數型別,泛型委托EventHandler<>的定義如下:

public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

??因此可以像下面這樣來宣告委托型別:

class Messenger
{
    public event EventHandler<NewMessageArrivedEventArgs> NewMessageArrived;
}

??通過泛型委托,可以簡化委托的宣告,

(3)定義事件引發方法

??所謂事件的引發方法就是用于間接引發事件的方法封裝,這一定義不是必須的,但定義事件的引發方法有助于事件的使用,通常,事件的引發方法應該符合以下規則:

    1. 以On+事件名為方法名

    2. 只引發對應的事件

??例如,對于NewMessageArrived事件,可以定義如下的事件引發方法:

void OnNewMessageArrived(string message)
{
    if (NewMessageArrived != null)
    {
        NewMessageArrived(this, new NewMessageArrivedEventArgs(message));
    }
}

??同時,可使用空值傳播運算子與Invoke方法簡化判空操作:

void OnNewMessageArrived(string message)
{
    NewMessageArrived?.Invoke(this, new NewMessageArrivedEventArgs(message));
}

??在需要引發事件時,便可以通過呼叫此方法來引發事件,

class Messenger
{
    public event EventHandler<NewMessageArrivedEventArgs> NewMessageArrived;
    
    public void FetchMessage()
    {
        string message = ...
        OnNewMessageArrived(message);
    }
}

??定義事件引發方法的一個明顯的優點是,如果引發方法的訪問修飾符是protected或者public,那么便可以讓子類甚至外部引發相應的事件,這在某些時候可能有助于解決某些問題,此外,在某些時候可能有助于減小生成的IL碼的體積,

 

4. 事件雜談

4.1 虛事件

??現在回過來看下面的委托封裝:

class Math
{
    private OutputFunction? _printers;

    public void AddPrinter(OutputFunction of)
    {
        _printers += of;
    }
    public void RemovePrinter(OutputFunction of)
    {
        _printers -= of;
    }
    
    // ... 省略其他代碼
}

??AddPrinter與RemovePrinter本質上都是普通方法,這意味著可以使用virtual修飾符將兩個方法標記為虛事件從而讓其被其子類重寫,如下:

class Math
{
    private OutputFunction Printers;

    public virtual void AddPrinter(OutputFunction of){ ... }
    public virtual void RemovePrinter(OutputFunction of) { ... }
}

class XMath : Math
{
    private OutputFunction Printers;

    public override void AddPrinter(OutputFunction of){ ... }
    public override void RemovePrinter(OutputFunction of) { ... }
}

??同時我們知道事件的本質就是類似于上述對委托的封裝,因此,將事件宣告為virtual是可行的:

class Math
{
    public virtual event OutputFunction Printers;
}

??被宣告為虛事件后,其子類可以重寫事件的實作:

class XMath : Math
{
    public virtual event OutputFunction Printers;
}

??顯然上述代碼沒有太大的意義,要讓虛事件有意義,需要使用完整的事件宣告語法來重寫事件:

class XMath : Math
{
    public override event OutputFunction Printers
    {
        add
        {
            ...
        }
        remove
        {
            ...
        }
    }
}

??盡管如此,虛事件依然幾乎沒有什么使用場合,但是,這可以幫助理解為何在介面中可以定義事件,

4.2 事件使用誤區

4.2.1 重復訂閱事件

??事件并不檢查與保證委托鏈中各個委托的唯一性,換句話說,同一個委托可以重復訂閱一個事件,

??在開始說明這一問題前,先定義一個可以發布事件的簡單類:

delegate void MeowedHandler(string message);

class Cat
{
    public event MeowedHandler Meowed;

    public void Meow()
    {
        Meowed?.Invoke("meow meow meow");
    } 
}

??接下來像下面這樣使用這個類:

Cat cat = new Cat();

cat.Meowed += Console.WriteLine;
cat.Meowed += Console.WriteLine;
cat.Meowed += Console.WriteLine;

cat.Meow();

(此時你應該能理解上述代碼的作業原理)

??此時若運行上述代碼,你會發現控制臺輸出了三次‘meow meow meow’,原因是上述代碼中的Console.WriteLine方法向Meowed事件訂閱了三次,因此Meowed事件背后的多播委托的委托鏈中存在三次對Console.WriteLine的委托呼叫,

??上述例子說明了同一個委托可以重復訂閱一個事件,但很多情況下我們只需要將一個委托對一個事件訂閱一次,此外同一個委托被重復呼叫可能會帶來各種問題,因此除非確實需要重復訂閱事件,否則應當避免不必要的重復訂閱,如果你無法確定事件是否被訂閱,可以考慮在每次訂閱之前先取消待訂閱,然后再訂閱,這樣做的目的在于取消上一次可能忘記取消的訂閱,如下:

Cat cat = new Cat();

cat.Meowed -= Console.WriteLine;  // 如果之前忘了取消相同的委托對該事件的訂閱,這里就順便取消了
cat.Meowed += Console.WriteLine; 

cat.Meowed -= Console.WriteLine;  // 如果之前忘了取消相同的委托對該事件的訂閱,這里就順便取消了
cat.Meowed += Console.WriteLine; 

cat.Meowed -= Console.WriteLine;  // 如果之前忘了取消相同的委托對該事件的訂閱,這里就順便取消了
cat.Meowed += Console.WriteLine; 

cat.Meow();

??上述代碼運行后只會輸出一次‘meow meow meow’,嘗試取消訂閱沒有訂閱的委托不會出現任何錯誤,因此即便事件背后的多播委托的委托鏈中沒有任何委托,進行取消訂閱的操作也不會發生錯誤,不過這樣顯然依然難以避免意外的重復注冊,因為必須保證所有進行訂閱的地方都采用了上述的訂閱方法,也就是說,必須清楚地知道每一個訂閱點,顯然這樣的負擔過于巨大,一個解決方法是顯式定義委托訂閱事件的邏輯,讓任何委托在訂閱事件前都先嘗試將自己從委托鏈中移除,然后再加入:

class Cat
{
    private MeowedHandler _meowed;

    public event MeowedHandler Meowed
    {
        add
        {
            _meowed -= value;
            _meowed += value;
        }
        remove
        {
            _meowed -= value;
        }
    }

    public void Meow()
    {
        _meowed?.Invoke("meow meow meow");
    }
}

??但這一方法不適用于Lambda運算式定義的匿名方法,也就是說,對于下面的情況,依然會輸出三次‘meow meow meow’:

Cat cat = new Cat();
cat.Meowed += c => Console.WriteLine(c);
cat.Meowed += c => Console.WriteLine(c);
cat.Meowed += c => Console.WriteLine(c);
cat.Meow();

??原因在于上述的三個Lambda運算式實際上生成了三個不同的匿名方法,此外,如果你的程式可能運行在多執行緒環境下,可能還需要進行加鎖以保證執行緒安全,并且然而無論如何,這不是完美的解決方法,甚至可以說是一種糟糕的解決辦法,首先這樣做意味著事件將無法重復訂閱,然而有時候確實有重復訂閱事件的需求;此外,如果程式運行時出現了意外的重復注冊,通常說明有更嚴重的邏輯問題,使用上述方法會隱藏這些錯誤,不應該隱藏錯誤,而是要讓錯誤盡可能早地被發現從而避免更大的錯誤,

??因此,如果要避免重復訂閱事件,最恰當的方式應當是從程式撰寫的邏輯上避免,

4.2.2 不及時取消訂閱

??當委托訂閱事件后,委托就被加入到事件背后的多播委托的委托鏈中,除非手動取消訂閱,否則委托將一直留在委鏈條,因此如果沒有及時取消訂閱,則可能會因為委托呼叫時出錯而導致程式終止,例如:

class Repeater
{
    public string Name { get; set; }
    
    public void Say(string message)
    {
        Console.WriteLine(Name.ToUpper() + " Repeat: " + message);
    }
}

Cat cat = new Cat();
Repeater repeater = new Repeater();
repeater.Name = "aaa";

cat.Meowed += repeater.Say;
cat.Meow();

repeater.Name = null; 
cat.Meow(); // 空參考報錯

??上述代碼中中,在第二次呼叫Cat的Meow方法引發Meowd事件前,repeater的Name屬性被設定null,此時其Say方法中的的有關Name.ToUpper()的呼叫時就會出現空參考錯誤,為了避免這一問題,應當及時進行取消訂閱,

??另外一點需要說明的是,如果不及時取消訂閱,那么由于委托鏈中會一直保有一個對該委托的所屬物件的持有,這會導致其無法被GC回收,直到取消訂閱或者事件發布方被回收,也就是說,對于下面的情況:

Cat cat = new Cat();
Repeater repeater = new Repeater();
cat.Meowed += repeater.Say;
repeater = null;

??看起來在repeater被設定為null后,所參考的Repeater物件的參考計數應該歸0并準備被GC回收了,然而實際上在其訂閱cat的Meowed事件后,Meowed事件背后的多播委托還會對repeater所參考的物件有一個持有,這種情況下只有等到cat被GC回收才可以回收其所持有的Repeater實體,

??綜上所述,為了減少潛在的呼叫錯誤與記憶體泄露,應當在委托不需要關注事件后及時取消對事件的訂閱,

4.2.3 事件處理程式存在耗時操作

??請記住,訂閱事件的本質就是將委托添加到事件背后多播委托的委托鏈,取消訂閱事件則是將委托從中移出,而事件的引發的本質上就是對委托鏈中的委托進行逐一呼叫,也就是說,如果委托鏈中存在耗時的操作,會阻塞后續委托的呼叫,例如下述情況:

Cat cat = new Cat();

cat.Meowed += (_) => {
    Thread.Sleep(255);
};
cat.Meowed += Console.WriteLine;

cat.Meow();

??在Console.WriteLine訂閱Meowed事件前,一個暫停當前執行緒255毫秒的匿名方法先訂閱了Meowed事件,這就導致在引發事件時,Console.WriteLine必須等待前面的匿名方法完成后才會被呼叫,也就是說要等255毫秒后才會輸出‘meow meow meow’,除非有特殊需要,否則不應當使用耗時的方法訂閱事件,

4.2.4 依賴事件的執行順序

??不要依賴事件的執行順序來達成某種操作盡管通過對事件背后委托鏈的控制可以對委托執行順序進行精細控制,但是更好的辦法應該是對需要順序執行的委托進行封裝,例如,對于如下代碼:

cat.Meowed += Console.WriteLine;
cat.Meowed += Log.WriteToFile;
cat.Meowed += (_) => Console.WriteLine("Completed");

??從代碼邏輯來看,代碼的意圖很明顯:先在控制臺輸出,然后再將其寫入到檔案,接著在控制臺輸出‘Completed’表示已完成,然而這依賴了委托鏈的呼叫順序,正確的做法是應當假設訂閱的各個委托之間沒有任何關系,更好的辦法是將存在依賴關系的方法包裝起來后再訂閱事件,如下:

cat.Meowed += (message) => 
{
    Console.WriteLine(message);
    Log.WriteToFile(message);
    Console.WriteLine("Completed");
};

??

5. 參考代碼

??下面是基于上面示例的完整的可運行代碼,你可以嘗試運行與分析這些代碼來加強對事件機制的理解:

5.1 簡單的事件示例

using System;

namespace DelegateAndEventSample
{
    delegate void OutputFunction(int n);

    class Math
    {
        public event OutputFunction Printers;

        public int Add(int a, int b)
        {
            int n = a + b;
            Printers?.Invoke(n);
            return 0;
        }

    }

    class Program
    {
        static void Main(string[] args)
        {
            Math math = new Math();

            math.Printers += Console.WriteLine;
            int n = math.Add(1, 2);
            
            math.Printers -= Console.WriteLine;
            int m = math.Add(1, 2); 
        }
    }
}

5.2 符合.NET準則的事件示例

using System;

namespace DelegateAndEventSampleX
{
    // 定義事件所用的EventArgs
    public class NewMessageArrivedEventArgs : EventArgs
    {
        public string Message { get; }
        
        public NewMessageArrivedEventArgs(string message)
        {
            Message = message;
        }
    }

    // 用于演示的類
    class Messenger
    {
        public event EventHandler<NewMessageArrivedEventArgs> NewMessageArrived;

        public string Name { get; set; }

        public void FetchMessage()
        {
            Thread.Sleep(1000); // 等待一秒
            int message = new Random().Next(0, 10); // 隨機生成一個數字
            OnNewMessageArrived(message.ToString());
        }

        private void OnNewMessageArrived(string message)
        {
            NewMessageArrived?.Invoke(this, new NewMessageArrivedEventArgs(message));
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Messenger m = new Messenger();
            m.Name = "New messenger";
            m.NewMessageArrived += Print;

            m.FetchMessage();
        }

        static void Print(object sender, NewMessageArrivedEventArgs e)
        {
            Console.WriteLine("Value " + e.Message + " From " + (sender as Messenger).Name);
        }
    }
}

 

轉載請註明出處,本文鏈接:https://www.uj5u.com/net/487791.html

標籤:C#

上一篇:.NET C#基礎(2):方法修飾符 - 給方法疊buff

下一篇:.NET C#基礎(4):屬性 - 本質是方法

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • WebAPI簡介

    Web體系結構: 有三個核心:資源(resource),URL(統一資源識別符號)和表示 他們的關系是這樣的:一個資源由一個URL進行標識,HTTP客戶端使用URL定位資源,表示是從資源回傳資料,媒體型別是資源回傳的資料格式。 接下來我們說下HTTP. HTTP協議的系統是一種無狀態的方式,使用請求/ ......

    uj5u.com 2020-09-09 22:07:47 more
  • asp.net core 3.1 入口:Program.cs中的Main函式

    本文分析Program.cs 中Main()函式中代碼的運行順序分析asp.net core程式的啟動,重點不是剖析原始碼,而是理清程式開始時執行的順序。到呼叫了哪些實體,哪些法方。asp.net core 3.1 的程式入口在專案Program.cs檔案里,如下。ususing System; us ......

    uj5u.com 2020-09-09 22:07:49 more
  • asp.net網站作為websocket服務端的應用該如何寫

    最近被websocket的一個問題困擾了很久,有一個需求是在web網站中搭建websocket服務。客戶端通過網頁與服務器建立連接,然后服務器根據ip給客戶端網頁發送資訊。 其實,這個需求并不難,只是剛開始對websocket的內容不太了解。上網搜索了一下,有通過asp.net core 實作的、有 ......

    uj5u.com 2020-09-09 22:08:02 more
  • ASP.NET 開源匯入匯出庫Magicodes.IE Docker中使用

    Magicodes.IE在Docker中使用 更新歷史 2019.02.13 【Nuget】版本更新到2.0.2 【匯入】修復單列匯入的Bug,單元測驗“OneColumnImporter_Test”。問題見(https://github.com/dotnetcore/Magicodes.IE/is ......

    uj5u.com 2020-09-09 22:08:05 more
  • 在webform中使用ajax

    如果你用過Asp.net webform, 說明你也算是.NET 開發的老兵了。WEBform應該是2011 2013左右,當時還用visual studio 2005、 visual studio 2008。后來基本都用的是MVC。 如果是新開發的專案,估計沒人會用webform技術。但是有些舊版 ......

    uj5u.com 2020-09-09 22:08:50 more
  • iis添加asp.net網站,訪問提示:由于擴展配置問題而無法提供您請求的

    今天在iis服務器配置asp.net網站,遇到一個問題,記錄一下: 問題:由于擴展配置問題而無法提供您請求的頁面。如果該頁面是腳本,請添加處理程式。如果應下載檔案,請添加 MIME 映射。 WindowServer2012服務器,添加角色安裝完.netframework和iis之后,運行aspx頁面 ......

    uj5u.com 2020-09-09 22:10:00 more
  • WebAPI-處理架構

    帶著問題去思考,大家好! 問題1:HTTP請求和回傳相應的HTTP回應資訊之間發生了什么? 1:首先是最底層,托管層,位于WebAPI和底層HTTP堆疊之間 2:其次是 訊息處理程式管道層,這里比如日志和快取。OWIN的參考是將訊息處理程式管道的一些功能下移到堆疊下端的OWIN中間件了。 3:控制器處理 ......

    uj5u.com 2020-09-09 22:11:13 more
  • 微信門戶開發框架-使用指導說明書

    微信門戶應用管理系統,采用基于 MVC + Bootstrap + Ajax + Enterprise Library的技術路線,界面層采用Boostrap + Metronic組合的前端框架,資料訪問層支持Oracle、SQLServer、MySQL、PostgreSQL等資料庫。框架以MVC5,... ......

    uj5u.com 2020-09-09 22:15:18 more
  • WebAPI-HTTP編程模型

    帶著問題去思考,大家好!它是什么?它包含什么?它能干什么? 訊息 HTTP編程模型的核心就是訊息抽象,表示為:HttPRequestMessage,HttpResponseMessage.用于客戶端和服務端之間交換請求和回應訊息。 HttpMethod類包含了一組靜態屬性: private stat ......

    uj5u.com 2020-09-09 22:15:23 more
  • 部署WebApi隨筆

    一、跨域 NuGet參考Microsoft.AspNet.WebApi.Cors WebApiConfig.cs中配置: // Web API 配置和服務 config.EnableCors(new EnableCorsAttribute("*", "*", "*")); 二、清除默認回傳XML格式 ......

    uj5u.com 2020-09-09 22:15:48 more
最新发布
  • C#多執行緒學習(二) 如何操縱一個執行緒

    <a href="https://www.cnblogs.com/x-zhi/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/2943582/20220801082530.png" alt="" /></...

    uj5u.com 2023-04-19 09:17:20 more
  • C#多執行緒學習(二) 如何操縱一個執行緒

    C#多執行緒學習(二) 如何操縱一個執行緒 執行緒學習第一篇:C#多執行緒學習(一) 多執行緒的相關概念 下面我們就動手來創建一個執行緒,使用Thread類創建執行緒時,只需提供執行緒入口即可。(執行緒入口使程式知道該讓這個執行緒干什么事) 在C#中,執行緒入口是通過ThreadStart代理(delegate)來提供的 ......

    uj5u.com 2023-04-19 09:16:49 more
  • 記一次 .NET某醫療器械清洗系統 卡死分析

    <a href="https://www.cnblogs.com/huangxincheng/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/214741/20200614104537.png" alt="" /&g...

    uj5u.com 2023-04-18 08:39:04 more
  • 記一次 .NET某醫療器械清洗系統 卡死分析

    一:背景 1. 講故事 前段時間協助訓練營里的一位朋友分析了一個程式卡死的問題,回過頭來看這個案例比較經典,這篇稍微整理一下供后來者少踩坑吧。 二:WinDbg 分析 1. 為什么會卡死 因為是表單程式,理所當然就是看主執行緒此時正在做什么? 可以用 ~0s ; k 看一下便知。 0:000> k # ......

    uj5u.com 2023-04-18 08:33:10 more
  • SignalR, No Connection with that ID,IIS

    <a href="https://www.cnblogs.com/smartstar/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/u36196.jpg" alt="" /></a>...

    uj5u.com 2023-03-30 17:21:52 more
  • 一次對pool的誤用導致的.net頻繁gc的診斷分析

    <a href="https://www.cnblogs.com/dotnet-diagnostic/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/3115652/20230225090434.png" alt=""...

    uj5u.com 2023-03-28 10:15:33 more
  • 一次對pool的誤用導致的.net頻繁gc的診斷分析

    <a href="https://www.cnblogs.com/dotnet-diagnostic/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/3115652/20230225090434.png" alt=""...

    uj5u.com 2023-03-28 10:13:31 more
  • C#遍歷指定檔案夾中所有檔案的3種方法

    <a href="https://www.cnblogs.com/xbhp/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/957602/20230310105611.png" alt="" /></a&...

    uj5u.com 2023-03-27 14:46:55 more
  • C#/VB.NET:如何將PDF轉為PDF/A

    <a href="https://www.cnblogs.com/Carina-baby/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/2859233/20220427162558.png" alt="" />...

    uj5u.com 2023-03-27 14:46:35 more
  • 武裝你的WEBAPI-OData聚合查詢

    <a href="https://www.cnblogs.com/podolski/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/616093/20140323000327.png" alt="" /><...

    uj5u.com 2023-03-27 14:46:16 more