C#委托淺析與漫談
1. 概述
委托是C#區別于其他語言的一個特色,用它我們能寫出簡潔優雅的代碼、能很方便的實現對象間的交互。
初學者可能會覺得委托體系很復雜:lambda表達式、語句lambda、匿名方法、委托、事件,光名詞就一堆。其實 這些只是C#編譯器為我們提供的語法糖 ,在編譯后它們都是MulticastDelegate類型的對象。而且從用途上講主要也就兩方面: 將“方法對象化”和實現“觀察者模式” ,本文圍繞這兩方面,分享本人對委托中相關概念的理解,順便介紹一些相關的其它東西。
2. 閉包
閉包似乎在javascript里談得比較多,其實只要支持定義”局部函數”的語言都會涉及到”閉包”的概念,像C++11的lambda、java的匿名內部類、Smalltalk的Block等。
在C#中也有閉包,比如下面這個簡單的例子:
例1
int[] scores = { 100, 80, 60, 40, 20 };
var min = 60;
var passed = scores.Where((int i) => { return i >= min; });
這里向Count這個擴展方法傳入了一個匿名方法,注意這里變量min對于這個匿名方法很特殊,它對匿名方法來說叫”自由變量(free variable)”(與之相對的叫”bound variable”),因為它不是該匿名方法的參數,也不是它的局部變量,這段代碼能成功編譯是因為從 詞法作用域 的角度看,min這個變量對于匿名方法內部是可見的。
2.1 詞法作用域(lexical scoping)
首先我們搞清一些概念:
- scope:英語中有“視野”之義,表示 符號名的可見范圍 。
- extent:或叫lifetime,表示 變量的一生 。scope有時會影響extent。
JavaScript中的變量提升
關于這兩者區別的一個例子就是javascript中的“變量提升”:
例2
text = 'global variable';
function test(){
alert(text);//猜猜這里會彈出什么?
var text = "local variable";
};
test();
答案是彈出”undefined”。為什么?因為首先javascript是函數作用域,第二javascript中的 局部變量名的scope貫穿整個函數 ,即函數中變量名在函數起始到結束范圍內都是有效的,所以調用alert時text這個變量名被解析為局部變量,而這時還未執行到賦值語句,也就是局部text的extent還未開始,text只是個有效的變量名,并沒有指向一個有效的對象,因此會彈出”undefined”。
好,我們繼續
什么是詞法作用域?
“詞法作用域”也叫 靜態作用域(static scoping) ,”詞法”表示源代碼級別,”靜態”指發生在解釋時/編譯時(與之相對是運行時),根據字面可理解為“變量在源代碼中的可見范圍”。
因為是基于源代碼的,所以看上去很符合我們的直觀感受,大多數的編程語言,包括許多動態語言,都使用詞法作用域的規則來進行標識符解析的。C#也不例外。
我們通常所稱的“閉包”全稱叫“詞法閉包”,它是指 存儲了一個函數和創建該函數時所處詞法環境的對象 。在閉包中,它訪問的外部變量叫做“ 被捕捉的變量(captured variable) ”。比如上面 例1 中,第3行,匿名方法內部使用了min這個符號,但min既不是匿名方法的參數,也不是它的局部變量,所以編譯器開始從定義這個匿名方法的詞法環境開始向上不斷搜索min這個符號,成功找到后,將它“捕捉”進匿名方法里,形成“閉包”,傳遞給Count方法的其實是這個“閉包”。
“捕捉”到底是什么意思?把它復制一份?還是保存它的引用?帶著疑問猜猜下面C#代碼的執行結果:
例3
class Program
{
static void Main()
{
foreach(var i in MakeClosures(3))
{
i();//每次執行的結果是什么
}
Console.ReadKey();
}
static IEnumerableMakeClosures(int count)
{
var closures = new List();
for (var i = 0; i Add(() => Console.WriteLine(++i));
return closures;
}
}
我們來分析一下。首先,C#中的捕捉是“ 按引用捕捉 ”,所以:
- 在循環中創建閉包時捕捉的并 不是i那個時刻的值,而是i的引用,是i這個對象本身 ,所以這些閉包共享同一個i
- i在循環中不斷更新,當第一個閉包被調用時i == count
所以這段代碼的運行結果是”4,5,6″。
為什么循環結束了i還能被訪問?因為它被捕捉到了閉包里,它的extent被延長到至少和閉包對象一樣長。
大多數語言的閉包都是按引用捕捉(更符合直觀感受),java比較非主流,它是按值捕捉(即復制,注意引用類型是復制引用)。所以請看下面的java代碼:
public class Main{
public static void main(String[] args){
int i = 1;
Runnable runnable = new Runnable(){
@Override
public void run(){
i = 2;
}
};
runnable.run();
}
}
編譯這段代碼會報錯:local variables referenced from an inner class must be final or effectively final,意思說”被內部類引用的局部變量必須由final修飾或實際上就是final的(就是自始至終都沒有被重新賦值)”。
為什會這樣呢?因為從直覺上看,這段代碼執行runnable.run()后i應該變成2,但java是按值捕捉,i并不會變,為避免誤解,所以Java語言規定被內部類訪問的外部變量要是不可變。
要突破這一限制,我們可以“手動實現按引用捕捉”,即創建一個類,把需要修改的變量放到那個類里。
C++的lambda很靈活,它允許我們指定捕捉哪些變量、按值還是按引用捕捉。在構建閉包時,編譯器生成一個重載了”()”操作符的類,把被捕捉的變量定義為它的成員。
C#編譯器是如何實現閉包的?
CLR的類型系統中并沒有“匿名方法”、“閉包”這些概念,其實C#編譯器為我們生成了一些代碼,比如生成一個類,把被捕捉的變量和匿名方法打包進去,變成實例變量和實例方法。但具體怎么實現并沒有具體的標準,只要能符合語言規范就行,所以這個不必深究,有興趣的自己可以用反編譯工具查看一下。
3. 多播委托、事件與觀察者模式
觀察者模式也叫”發布者/訂閱者模式”是GoF設計模式中比較常用的一個,它是用來解決“一個對象需要在特定時刻通知n個其它對象”的問題的。
比如:mvc中model在自己屬性發生改變時發布廣播,事先關注了的view會收到并更新自已的狀態,使界面與程序內部狀態保持同步,同時又保持了內部邏輯與界面的良好分離。
這里我演示一個簡單的例子:
實現一個下載程序,下載時能顯示進度。為實現邏輯與界面的分離,我們設計兩個類:
- Downloader :下載器,用于執行下載任務
- ProgressView:進度界面,顯示進度條和百分比
那么問題來了,下載器如何通知界面更新?直接告訴它(調用它的一個方法)?如果這樣么做的話下載器和界面的耦合度就會過高,這里我們可以運用“依賴倒置”的思想,加入一個IProgressMonitor接口。于是初步設計是這樣的: 代碼:
- IProgressMonitor.cs
//進度監視接口 interface IProgressMonitor { void OnProgress(int done, int total); }
- ProgressVeiw.cs
class ProgressVeiw : IProgressMonitor { const int LENGTH = 50; string last = String.Empty; public void OnProgress(int done, int total) { var builder = new StringBuilder(); builder.Append('['); var filled = (int)(done / (total + 0.0) * LENGTH); for (var i = 0; i Append(i ' : ''); } builder.Append(']'); if (done != total) builder.AppendFormat(" {0:p0}", done / (total + 0.0)); else builder.Append(" 完成!");
//回退之前打印的字符 for (var i = 0; i Length; ++i) Console.Write('b'); var state = builder.ToString(); _last = state; Console.Write(state); }
}</code></pre> </li>
- Downloader.cs
class Downloader { public void Download(string resource) { for (int i = 1, size = 10; i this.OnProrgess(i, size); Thread.Sleep(500); } } public void AddMonitor(IProgressMonitor monitor) { this._monitors.Add(monitor); } public void RemoveMonitor(IProgressMonitor monitor) { this._monitors.Remove(monitor); } private void OnProrgess(int done, int total) { foreach (var i in this._monitors) { i.OnProgress(done, total); } } private ICollection _monitors = new List();//進度監視者集合 }
- Program.cs
class Program { static void Main(string[] args) { var resouce = "visual studio.iso"; var downloader = new Downloader(); downloader.AddMonitor(new ProgressVeiw()); Console.WriteLine("正在下載 " + resouce); downloader.Download(resouce);
Console.ReadKey(); }
}</code></pre> </li> </ul>
整個邏輯是這樣的:
- 創建下載器
- ProgressView對下載進度“ 感興趣 ”,就到下載器那“ 登記一下 ”
- 下載器開始下載
- 下載器下載過程進度有變化時就通知已登記了的對象,具體這些對象要干嘛,它不管,它只負責通知
- ProgressView收到通知,更新界面
從代碼中可以看到,實現“登記”、“通知”是通過手動操作一個集合實現的,其實這種功能.Net已經幫我們實現,那就是多播委托。
3.1 多播委托(MulticastDelegate)
委托類層次圖
.Net中所有具體委托類型其實都繼承自MulticastDelegate,多播委托內部有一個數組,用來保存其它委托,這就是所謂的“委托鏈”,有了它就可以對多個委托進行組合,讓一個委托可以一次執行多個操作。比如:
Action act = ()=>Console.WriteLine("動作1"); act += ()=>Console.WriteLine("動作2"); act += ()=>Console.WriteLine("動作3"); act();//將打印出三行文字
這里編譯器其實把 += 操作符替換成了Delegate.Combine(Delegate a, Delegate b)方法,該方法內部又是直接調用第一個參數的CombineImpl方法。有興趣的可以閱讀其 源碼 。
因此多播委托的作用在于,現實觀察者模式就不用手動維護一個訂閱者集合了。于是上面的例子可以重構成這樣:
- Downloader.cs
class Downloader { public void Download(string resource) { for (int i = 1, size = 10; i this.OnProrgess(i, size); Thread.Sleep(500); } }
//因為有多播委托,所以這里不需要“登記”、“注銷”方法了 private void OnProrgess(int done, int total) { if (this.DownloadProgress != null) this.DownloadProgress(done, total); } //NOTE: 這里為了用 +=,-=操作符替代“登記”、“注銷”方法,不得以將訂閱者集合暴露出去 public Actionint, int> DownloadProgress;//進度回調鏈
}</code></pre> </li>
- 刪除IProgressMonitor接口,因為只需要委托簽名匹配即可,不需要用接口來約束
- Program.cs
class Program { static void Main(string[] args) { var resouce = "visual studio.iso"; var downloader = new Downloader(); downloader.DownloadProgress += new ProgressVeiw().OnProgress; Console.WriteLine("正在下載 " + resouce); downloader.Download(resouce);
Console.ReadKey(); }
}</code></pre> </li> </ul>
注意上面Downloader.cs注釋中的 NOTE 標記,在觀察者模式中,發布者應該是它自己發布消息,而此處是public修飾,可以直接在外部調用,違背了觀察者模式,因此我們需要使用 事件 來進行訪問控制。
3.2 事件
方法相當簡單,只需要添加一個 event 關鍵字
//用event修飾,這樣外部只能進行-=、+=操作,調用只能在本類內部進行 public event Actionint, int> DownloadProgress;//進度事件
那事件和委托有什么區別?其實事件和屬性一樣,屬語法糖,編譯器會為我們生成訪問器方法和實際的實例變量。
比如上面,編譯器生成了Action成員,把事件的+=,-=轉換為一對public的addXxx、removeXxx方法。
如果不想讓編譯器為我們自動生成委托成員,我們也可以手動實現,如:
public event Actionint, int> DownloadProgress { add { _downloadProgress += value; } remove { _downloadProgress -= value; } } private Actionint, int> _downloadProgress;
所以事件的作用在于,實現觀察者模式只需要用event關鍵字定義一個實例變量即可
其它語言相關概念
C++
C/C++中的 函數指針 與.Net中委托的區別在于
- 函數指針很“赤裸”,就是一個unsigned int類型的值,表示函數第一條CPU指令的內存地址
- 委托是比較復雜的CLR對象,內部封裝了目標對象和方法指針,多播委托內部還維護了一個委托鏈
C++雖然沒有內置觀察者模式的實現,但許多第三方庫如qt、boost都提供了“信號(signal)-槽(slot)”的功能。信號相當于.Net中的事件,槽相當于事件處理方法。使用boost的signals2時需要注意的是,它實現的線程安全是指一個線程中添加、移除slot,不會影響另一個線程遍歷slot集合。我們仍要注意對象被銷毀的問題,比如一個slot在某線程中正在執行,而同時signal所屬的那個對象在另一個線程被銷毀了,這時訪問那個對象會不會導致程序crash就要看運氣了。
Java
java沒有匿名方法的語法,但可以用”匿名內部類 + 單方法接口”代替,java8對語法和api進行了擴展,引入了lambda,其實只是語法糖,本質還是接口。有一些第三方類庫提供了觀察者模式的實現,比如:谷歌guava中的EventBus。
Ruby
ruby語法非常豐富,這里介紹下它的block(代碼塊,一種匿名方法的語法: do |param| ...end 和 { |param| do_something param } ,這個概念來自Smalltalk)。
我們在寫程序時會經常需要實現“回調”的功能,即接收一個函數,然后在適當的時刻調用它,ruby直接原生支持了,任何方法都可以接收一個block作為隱式的參數,不用在參數列表中顯式聲明,在方法里可以用yield關鍵字調用傳進來的block。如:
def process_data data do_work(data) # 假裝在處理數據 yield data if block_given? # 如果有block則調用它 end
process_data([1, 2, 3]) do |data| puts '處理完成!' end</code></pre>
再比如核心庫中的File::open方法
# 調用該方法時如果
傳入block則將打開的文件傳給block,本方法結束后文件對象被銷毀,這樣就不用麻煩調用者關閉文件了
否則
返回文件對象
File.open('file.txt') do |f| puts f.readlines # 打印出所有行 end</code></pre>
再看ruby的一個web框架 Sinatra 的API
get '/home/index' do '我是對get請求的響應' end post '/home/index' do '我是對post請求的響應' end
用這種語法來寫HTTP Web應用是不是很爽?因為這種API設計可以算是實現了一種HTTP的DSL(領域專用語言)了。
如果你喜歡這種風格,可以試試.Net的開源web框架 Nancy ,我已用她寫了兩個項目,API很實用,比ASP.NET MVC靈活。
這里簡單展示一下她的特色:content negotiation(內容協商,根據客戶端請求返回指定類型內容)
Get["/"] = parameters => { //這里返回的對象會被傳遞到nancy的content negotiation管道 //然后會檢查請求頭的accept類型,比如 如果是xml則該對象被序列化為xml,如果是json則被序列為json return new MyModel(); };
來自: http://blog.jobbole.com/99738/
- Downloader.cs