第 50 講:事件:委托字段的封裝
還記得屬性嗎?屬性是字段的封裝,它的目的就是為了包裝一些賦值邏輯,防止外來(lái)隨意對(duì)底層字段亂賦值的情況,起到了數(shù)據(jù)校驗(yàn)和數(shù)據(jù)防修改的工作。
本講內(nèi)容將給大家介紹一個(gè)新的面向?qū)ο蟮某蓡T類(lèi)型:事件(Event)。事件是對(duì)委托字段的封裝。下面來(lái)說(shuō)一下引例,來(lái)給大家介紹一下它的使用場(chǎng)合。
Part 1 委托字段的引入
假設(shè)我們現(xiàn)在有一個(gè)列表數(shù)據(jù)類(lèi)型,專(zhuān)門(mén)用來(lái)存儲(chǔ)一系列的數(shù)據(jù)。假設(shè)我現(xiàn)在大體實(shí)現(xiàn)了基本的處理代碼,但這個(gè)列表我想在每次增加一個(gè)元素的時(shí)候都執(zhí)行一個(gè)固定的行為,比如輸出“增加一個(gè)元素,現(xiàn)在有多少元素”的信息;或者是驗(yàn)證增加的元素到底是不是超過(guò)了總?cè)萘?。如果超過(guò)容量范疇,我們就拋出異常來(lái)提示用戶(hù)無(wú)法繼續(xù)添加元素。
先給出數(shù)據(jù)結(jié)構(gòu)的基本實(shí)現(xiàn)邏輯。
我想,應(yīng)該不是特別難理解。主要是部分方法的使用可能之前沒(méi)有介紹過(guò),比如這里的
string.Format
靜態(tài)方法。
string.Format
方法和Console.WriteLine
的傳參過(guò)程差不多,第一個(gè)字符串給出模式字符串,后面的參數(shù)都直接把各種替代結(jié)果傳入進(jìn)去。只是之前稍微沒(méi)有講到這一點(diǎn):如果模式字符串必須要使用大括號(hào),而又不是{0}
、{1}
這樣的占位符的大括號(hào)的話(huà),就需要雙寫(xiě)大括號(hào),比如{{
或者}}
。然后
GetHashCode
方法是我隨便寫(xiě)的公式,它并不一定必須要這么寫(xiě),這里只是給你看一下,做個(gè)參考;再加上這個(gè)也不是本講的主要內(nèi)容,所以可以先忽略掉。
那么,使用這個(gè)數(shù)據(jù)類(lèi)型也很容易:
就像這樣。
代碼我們看明白之后,接下來(lái)我們來(lái)擴(kuò)展一下代碼。假設(shè),我們每次增加一個(gè)元素,我們就判斷一下總?cè)萘渴遣皇浅^(guò) 10 了。因?yàn)槲覀冞@個(gè)基本數(shù)據(jù)類(lèi)型的設(shè)計(jì)里,我們是規(guī)定了 _elements
是最多只能放 10 個(gè)元素的。那么如果超出元素容量,我們就不讓用戶(hù)繼續(xù)添加元素了。于是,我們就需要改變 AddElement
的邏輯,改成這樣:
大概這種感覺(jué)??蓡?wèn)題是,這樣的代碼不夠靈活,因?yàn)槲覀儾灰欢ǚ且惨褣伄惓5倪@個(gè)邏輯直接寫(xiě)到代碼里當(dāng)成代碼的一部分,而是我們完全可以由調(diào)用方來(lái)指定,即用戶(hù)來(lái)指定處理邏輯,比如說(shuō)更改這里拋異常的邏輯。那么,我們?nèi)绾尾拍茏層脩?hù)自定義一個(gè)行為,然后嵌入到已經(jīng)實(shí)現(xiàn)好的數(shù)據(jù)類(lèi)型里呢?
沒(méi)錯(cuò),委托。委托可以用來(lái)自定義一個(gè)執(zhí)行的行為,這剛好可以派上用場(chǎng)。我們改變一下這里的 List
數(shù)據(jù)類(lèi)型,給它加一個(gè)委托類(lèi)型,并附上一個(gè)此委托類(lèi)型的字段:
是的,這個(gè) Checker
是無(wú)參無(wú)返回值類(lèi)型的委托類(lèi)型。
因?yàn)槲蓄?lèi)型底層會(huì)被翻譯成一個(gè)類(lèi),所以我們盡量建議你把委托類(lèi)型放在類(lèi)的外面來(lái)書(shū)寫(xiě),而不是里面(當(dāng)然,寫(xiě)里面也可以,只是嵌套起來(lái)有點(diǎn)不太好看)。
然后,我們繼續(xù)更改 AddElement
方法:
請(qǐng)看第 4 行到第 7 行代碼。代碼從拋異常換成了 _checker.Invoke();
。因?yàn)?_checker.Invoke();
好比是一個(gè)方法的調(diào)用,所以最終也不一定能夠直接退出 AddElement
方法。如果我們不寫(xiě)這個(gè) return;
的話(huà),有可能在執(zhí)行完 _checker
的回調(diào)函數(shù)后,就會(huì)繼續(xù)往下走到第 9 行代碼里,仍然要添加元素。此時(shí)整個(gè)數(shù)組 _elements
元素已滿(mǎn),再添加元素肯定是會(huì)觸發(fā)索引越界的錯(cuò)誤的。所以我們這里要加一個(gè) return;
強(qiáng)制退出方法。
稍微注意下的是,這里的
_checker
字段可能為null
。如果你不給它賦值,那么我們說(shuō)過(guò),類(lèi)的數(shù)據(jù)成員在初始化的時(shí)候,默認(rèn)是它這個(gè)類(lèi)型的默認(rèn)數(shù)值作為初始化值的,引用類(lèi)型的默認(rèn)數(shù)值是null
,因此在使用之前一定要先判斷是不是null
。
而這里的 _checker.Invoke();
起到了一個(gè)回調(diào)的作用。雖然我們目前還不知道到底要做什么東西,但是我們可以先寫(xiě)在這里,然后我們更改實(shí)例化 List
的過(guò)程:
比如這樣的感覺(jué)。我們?cè)诘?2 行追加了一個(gè)語(yǔ)句,對(duì)這個(gè)委托類(lèi)型的字段賦了值。這里賦值給的是 new Checker(PrintErrorMessage)
,這也是對(duì)得上的:因?yàn)?_checker
字段是 Checker
這個(gè)類(lèi)型的,而你右側(cè)賦值也應(yīng)該是這個(gè)類(lèi)型,對(duì)吧。然后,Checker
是委托類(lèi)型,因此實(shí)例化的時(shí)候,構(gòu)造器的參數(shù)里寫(xiě)的是方法名,且要和 Checker
委托類(lèi)型給的簽名(參數(shù)類(lèi)型和返回值類(lèi)型)匹配。
現(xiàn)在,我們?nèi)绻磸?fù)追加元素的話(huà):
你猜,會(huì)出現(xiàn)什么執(zhí)行結(jié)果?

你會(huì)看到,一共我們追加了 12 個(gè)元素,但因?yàn)樽詈髢蓚€(gè)元素超出輸出追加的范圍(只能追加 10 個(gè)元素進(jìn)去),于是產(chǎn)生了錯(cuò)誤信息的輸出。最后的 "Elements: ..."
這個(gè)字符串是 Console.WriteLine
的功勞。
順帶一提。
Console.WriteLine
方法的參數(shù)可以不自己追加.ToString
也會(huì)自動(dòng)調(diào)用.ToString
的;但是這是因?yàn)閰?shù)是object
類(lèi)型的關(guān)系,任何數(shù)據(jù)類(lèi)型都可以接收。但值類(lèi)型傳進(jìn)去會(huì)導(dǎo)致裝箱行為,因此我們建議是對(duì)引用類(lèi)型省去.ToString()
部分,但值類(lèi)型的對(duì)象就不要省略.ToString()
部分了。
那么,至此我們就把委托字段的概念介紹了。委托字段是委托類(lèi)型的字段,它代表一個(gè)回調(diào)函數(shù)列表,用于一些固定的時(shí)候執(zhí)行一些“外部不方便更改內(nèi)部代碼”的操作。
Part 2 事件成員的引入
為了介紹事件,我們不得不單獨(dú)開(kāi)一個(gè) Part 1 的內(nèi)容介紹委托字段,因?yàn)槲凶侄问怯斜锥说模旅嫖覀冃枰脑焖?/span>
我們?cè)俅位氐皆瓉?lái)的代碼上:
請(qǐng)注意這里的 _checker
委托字段。這個(gè)委托字段有一個(gè)致命問(wèn)題是,它是 public
那么,我們就需要有一個(gè)機(jī)制來(lái)避免任何人隨便寫(xiě)入委托字段的內(nèi)容。這個(gè)時(shí)候,事件就誕生了。
現(xiàn)在,我們將 _checker
從 public
改成 private
:
然后,在代碼里追加一個(gè)事件的成員,寫(xiě)法是這樣的:
請(qǐng)注意第 5 到第 9 行的代碼。是的,事件成員的書(shū)寫(xiě)代碼跟屬性長(zhǎng)相特別像,只是把這里面的 get
和 set
改成了 add
和 remove
。我們說(shuō)一下這個(gè)事件的書(shū)寫(xiě),以及它到底是拿來(lái)干嘛用的。
首先,我們剛才寫(xiě)了委托字段,是吧。那么我們基于這個(gè)委托字段,作為封裝,寫(xiě)成事件的格式,就需要使用同一個(gè)委托類(lèi)型,作為這個(gè)事件成員的類(lèi)型,寫(xiě)在 event
關(guān)鍵字之后。接著,在 Checker
這個(gè)委托類(lèi)型名后,加上事件成員的名字。前面我們說(shuō)過(guò)屬性的名字是自己隨便取的,但建議取名和底層封裝的字段要配套,這樣才表示是一個(gè)東西。那么這里的事件成員也是一樣:我們給底層的 _checker
字段封裝了一下,給事件取了個(gè)“現(xiàn)在進(jìn)行時(shí)”的名字:Checking
。
有人會(huì)說(shuō),這取名也不配套啊,畢竟你也沒(méi)叫它
Checker
,對(duì)吧。實(shí)際上原因是這樣的。屬性是用來(lái)封裝字段的。字段一般存儲(chǔ)的都是一些數(shù)據(jù)信息,這樣屬性才可以取值存值和字段進(jìn)行交互,對(duì)吧。那么既然是這樣的話(huà),字段的取名往往都是一個(gè)名詞,比如年齡(age)、生日(birthday)之類(lèi)的??蓡?wèn)題就在于,事件是委托字段的封裝,委托是干嘛的?委托是用來(lái)間接調(diào)用和處理一個(gè)行為用的。那么既然是一個(gè)行為,那么就是一個(gè)動(dòng)作,所以自然就應(yīng)該用動(dòng)詞表示。不過(guò)還有一點(diǎn)需要注意的是,既然是動(dòng)作,那肯定動(dòng)詞是有變化形式的。你看英語(yǔ)它有各種各樣的變形;而 C# 使用事件的時(shí)候也會(huì)采用這樣的動(dòng)詞變形來(lái)表達(dá)事件。比如前文的追加元素的過(guò)程。顯然追加元素提示已滿(mǎn),剛好是放在
AddElement
這個(gè)方法里的,說(shuō)明這個(gè)動(dòng)作是正在做的,因此這里的 check 用的是進(jìn)行時(shí)Checking
。往往事件成員使用的動(dòng)詞要么是動(dòng)詞的進(jìn)行時(shí)(現(xiàn)在分詞),要么就是完成時(shí)(過(guò)去分詞),這一點(diǎn)一定要記住。
事件成員的聲明我們就說(shuō)完了,主要就是 event
關(guān)鍵字,搭配 add
和 remove
來(lái)完成。接著來(lái)說(shuō)一下里面的 add
和 remove
代替的內(nèi)容到底是什么。
事件是一種委托字段的封裝,對(duì)吧。那么封裝委托字段是為了干嘛呢?目的是什么呢?是不是不讓外界隨便使用委托?。渴堑?,就是為了避免直接操作委托字段。那么操作委托字段,有兩種行為:
賦值一個(gè)新的委托字段過(guò)去;
為委托字段增加或刪除一個(gè)新的回調(diào)函數(shù)。
事件已經(jīng)避免了你直接使用委托字段,所以這就意味著第一點(diǎn)已經(jīng)完成了。那么第二點(diǎn):增刪回調(diào)函數(shù)是不是就應(yīng)該派上用場(chǎng)了?委托字段既然不能直接操作,那么我們就只能對(duì)底層的回調(diào)函數(shù)列表進(jìn)行增加或刪除操作了。
委托字段的增刪是不是采用了 +
運(yùn)算符和 -
運(yùn)算符?是的,所以為了配合這個(gè)語(yǔ)法,C# 的事件也使用的是加減運(yùn)算符。比如,我們對(duì)現(xiàn)在已經(jīng)封裝完成的數(shù)據(jù)類(lèi)型進(jìn)行實(shí)例化,并追加 Checking
事件的回調(diào)函數(shù):
請(qǐng)注意第 2 行代碼。因?yàn)槲覀兏牧藬?shù)據(jù)類(lèi)型,所以現(xiàn)在我們只能通過(guò)事件來(lái)使用這個(gè)數(shù)據(jù)類(lèi)型了。于是乎,這里的 _checker
Checking
。然后,事件采用了和委托相同的語(yǔ)法,所以我們可以使用 +
來(lái)給事件進(jìn)行賦值和內(nèi)容追加。后面的代碼也不用變動(dòng),整體就是這樣使用的,大概就這種感覺(jué)。
你可能會(huì)這么想:我從外面使用這個(gè)類(lèi)型的話(huà),畢竟我自己也不知道回調(diào)函數(shù)列表里有沒(méi)有這個(gè)方法,那么刪除操作(減法運(yùn)算)應(yīng)該多半都不好用,對(duì)吧?是的,正是因?yàn)槿绱耍詼p法運(yùn)算很少被用到。但是為了提供配套的邏輯,總不可能只有加法沒(méi)有減法吧?而且我們也不一定非得只用加法。在極少數(shù)時(shí)候,我們還是可能使用減法運(yùn)算,所以有這個(gè)機(jī)制還是好的。
在事件內(nèi)部,委托 +
運(yùn)算對(duì)應(yīng)了事件的 add
這一塊代碼;而 -
運(yùn)算符則對(duì)應(yīng)了事件的 remove
這一塊代碼??梢钥吹剑?/span>add
里寫(xiě)的代碼其實(shí)很簡(jiǎn)單:_checker += value;
,左側(cè)的 _checker
實(shí)際上就是現(xiàn)在封裝起來(lái)的底層的字段,而右側(cè)的 value
其實(shí)是和屬性里的 value
完全一樣的存在:它是從外界傳入的東西,指代的就是這個(gè)東西。而這里,上方的第 2 行代碼,+=
右側(cè)寫(xiě)的是 new Checker(PrintErrorMessage)
,這在底層執(zhí)行事件的 add
塊的時(shí)候,value
參數(shù)就對(duì)應(yīng)了 new Checker(PrintErrorMessage)
這個(gè)東西。同理,remove
里的 _checker -= value;
我就不用介紹了吧,和前面 add
塊的內(nèi)容是一樣的處理機(jī)制。
那么整體我就把事件成員的聲明,以及使用就給大家介紹了一下。
Part 3 事件和委托字段初始情況的空合賦值
這里說(shuō)一個(gè)補(bǔ)充的內(nèi)容。委托是類(lèi)型,委托字段是委托類(lèi)型的字段成員,而事件則是委托字段的封裝機(jī)制。那么,如果委托字段最初為空的話(huà),代碼不會(huì)有 bug 嗎?我說(shuō)明白一點(diǎn),讓你明白我的意思。請(qǐng)看這段代碼。
代碼的 _checker
最開(kāi)始初始化是為 null
的。本來(lái)我們就沒(méi)有打算給它賦值的時(shí)候,它默認(rèn)就會(huì)初始化為 null
;而現(xiàn)在被封裝成事件機(jī)制了之后,這個(gè)委托字段就更不可能被初始化為別的東西了。那么實(shí)例化的時(shí)候,字段為 null
;但這里的 add
和 remove
塊卻直接對(duì)這個(gè) _checker
這個(gè)本就為 null
的字段在追加或移除回調(diào)函數(shù)。
問(wèn)題來(lái)了。它是 null
,直接操作不會(huì)產(chǎn)生 NullReferenceException
異常嗎?實(shí)際上,不論是直接操作委托字段本身,還是操作事件,你都不會(huì)出現(xiàn)這個(gè)異常,即使它本來(lái)為 null
。這是因?yàn)榈讓?+
、-
運(yùn)算符被翻譯成了 Delegate.Combine
和 Delegate.Remove
null.方法名()
之類(lèi)的調(diào)用。大家都知道 null.成員
是會(huì)產(chǎn)生異常的,但靜態(tài)類(lèi)型的成員是不會(huì)直接產(chǎn)生異常的,因?yàn)殪o態(tài)成員是不存在實(shí)例的概念的。
換句話(huà)說(shuō),即使我們傳入了 null
,也只是 Delegate.Combine(null, 委托)
或者 Delegate.Remove(委托, null)
的調(diào)用的翻譯。由于這個(gè)方法在底層是會(huì)處理 null
的特殊情況的緣故,這樣的運(yùn)算符永遠(yuǎn)不會(huì)產(chǎn)生 NullReferenceException
異常,所以,即使在初始情況下,它也不會(huì)有 bug。
那么至此,我們就把事件、委托的內(nèi)容給大家介紹了一遍了。那么整個(gè)委托和事件的內(nèi)容就全部介紹完了。下面我們會(huì)進(jìn)入新的板塊:集合的基本概念,以及實(shí)現(xiàn)、實(shí)現(xiàn)集合的基本接口等內(nèi)容。