C#委托淺析與漫談

ndn4open 8年前發布 | 40K 次閱讀 C# 設計模式 .NET開發

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/

       

 本文由用戶 ndn4open 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!