1. 程式人生 > 其它 >聊聊C#中的Visitor模式 聊聊C#中的Visitor模式

聊聊C#中的Visitor模式 聊聊C#中的Visitor模式

聊聊C#中的Visitor模式

寫在前面

Visitor模式在日常工作中出場比較少,如果統計大家不熟悉的模式,那麼它榜上有名的可能性非常大。使用頻率少,再加上很多文章提到Visitor模式都著重於它克服語言單分派的特點上面,而對何時應該使用這個模式及這個模式是怎麼一點點演講出來的提之甚少,造成很多人對這個模式有種霧裡看花的感覺,今天跟著老胡,我們一起來一點點揭開它的面紗吧。
 

模式演進

舉個例子

現在假設我們有一個簡單的需求,需要統計出一篇文件中的字數、詞數和圖片數量。其中字數和詞數存在於段落中,圖片數量單獨統計。於是乎,我們可以很快的寫出第一版程式碼

使用了基本抽象的版本

    abstract class DocumentElement
    {
        public abstract void UpdateStatus(DocumentStatus status);
    }

    public class DocumentStatus
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }
        public int ImageNum { get; set; }
        public void ShowStatus()
        {
            Console.WriteLine("I have {0} char, {1} word and {2} image", CharNum, WordNum, ImageNum);
        }
    }

    class ImageElement : DocumentElement
    {
        public override void UpdateStatus(DocumentStatus status)
        {
            status.ImageNum++;
        }
    }

    class ParagraphElement : DocumentElement
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }

        public ParagraphElement(int charNum, int wordNum)
        {
            CharNum = charNum;
            WordNum = wordNum;
        }

        public override void UpdateStatus(DocumentStatus status)
        {
            status.CharNum += CharNum;
            status.WordNum += WordNum;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            DocumentStatus docStatus = new DocumentStatus();
            List<DocumentElement> list = new List<DocumentElement>();
            DocumentElement e1 = new ImageElement();
            DocumentElement e2 = new ParagraphElement(10, 20);
            list.Add(e1);
            list.Add(e2);
            list.ForEach(e => e.UpdateStatus(docStatus));
            docStatus.ShowStatus();
        }
    }

執行結果如下,非常簡單

但是細看這版程式碼,會發現有以下問題:

  • 所有的DocumentElement派生類必須訪問DocumentStatus,根據迪米特法則,這不是個好現象,如果在未來對DocumentStatus有修改,這些派生類被波及的可能性極大
  • 統計程式碼散落在不同的派生類裡面,維護不方便

有鑑於此,我們推出了第二版程式碼
 

使用了Tpye-Switch的版本

這一版程式碼中,我們摒棄了之前在具體的DocumentElement派生類中進行統計的做法,直接在統計類中統一處理

    public abstract class DocumentElement
    {
        //nothing to do now
    }

    public class DocumentStatus
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }
        public int ImageNum { get; set; }
        public void ShowStatus()
        {
            Console.WriteLine("I have {0} char, {1} word and {2} image", CharNum, WordNum, ImageNum);
        }

        public void Update(DocumentElement documentElement)
        {
            switch(documentElement)
            {
                case ImageElement imageElement:
                    ImageNum++;
                    break;

                case ParagraphElement paragraphElement:
                    WordNum += paragraphElement.WordNum;
                    CharNum += paragraphElement.CharNum;
                    break;
            }
        }
    }

    public class ImageElement : DocumentElement
    {

    }

    public class ParagraphElement : DocumentElement
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }

        public ParagraphElement(int charNum, int wordNum)
        {
            CharNum = charNum;
            WordNum = wordNum;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            DocumentStatus docStatus = new DocumentStatus();
            List<DocumentElement> list = new List<DocumentElement>();
            DocumentElement e1 = new ImageElement();
            DocumentElement e2 = new ParagraphElement(10, 20);
            list.Add(e1);
            list.Add(e2);
            docStatus.ShowStatus();
        }
    }

測試結果和第一個版本的程式碼一樣,這一版程式碼克服了第一個版本中,統計程式碼散落,具體類依賴統計類的問題,轉而我們在統計類中集中處理了統計任務。但同時它引入了type-switch, 這也是一個不好的訊號,具體表現在:

  • 程式碼冗長且難以維護
  • 如果派生層次加多,需要很小心的選擇case順序以防出現繼承層次較低的類出現在繼承層次更遠的類前面,從而造成後面的case永遠無法被訪問的情況,這造成了額外的精力成本
     

嘗試使用過載的版本

有鑑於上面type-switch版本的問題,作為敏銳的程式設計師,可能馬上有人就會提出過載方案:“如果我們針對每個具體的DocumentElement寫出相應的Update方法,不就可以了嗎?”就像下面這樣

    public class DocumentStatus
    {
        //省略相同程式碼
        public void Update(ImageElement imageElement)
        {
           ImageNum++;
        }

        public void Update(ParagraphElement paragraphElement)
        {
           WordNum += paragraphElement.WordNum;
           CharNum += paragraphElement.CharNum;
        }
    }

    //省略相同程式碼
    class Program
    {
        static void Main(string[] args)
        {
            DocumentStatus docStatus = new DocumentStatus();
            List<DocumentElement> list = new List<DocumentElement>();
            list.Add(new ImageElement());
            list.Add(new ParagraphElement(10, 20));
            list.ForEach(e => docStatus.Update(e));
            docStatus.ShowStatus();
        }
    }

看起來很好,不過可惜,這段程式碼編譯失敗,編譯器會抱怨說,不能將DocumentElement轉為它的子類,這是為什麼呢?講到這裡,就不能不提一下程式語言中的單分派和雙分派
 

單分派與雙分派

大家都知道,多型是OOP的三個基本特徵之一,即形如以下的程式碼

    public class Father
    {
	public virtual void DoSomething(string str){}
    }

    public class Son : Father
    {
	public override void DoSomething(string str){}
    }

    Father son = new Son();
    son.DoSomething();

son 雖然被宣告為Father型別,但在執行時會被動態繫結到其實際型別Son並呼叫到正確的被重寫後的函式,這是多型,通過呼叫函式的物件執行動態繫結。在主流語言,比如C#, C++ 和 JAVA中,編譯器在編譯類函式的時候會進行擴充,把this指標隱含的傳遞到方法裡面,上面的方法會擴充為

    void DoSomething(this, string);
    void DoSomething(this, string);

在多型中實現的this指標動態繫結,其實是針對函式的第一個引數進行執行時動態繫結,這個也是單分派的定義。
至於雙分派,顧名思義,就是可以針對兩個引數進行執行時繫結的分派方法,不過可惜,C#等都不支援,所以大家現在應該能理解為什麼上面的程式碼不能通過編譯了吧,上面的程式碼通過編譯器的擴充,變成了

    public void Update(DocumentStatus status, ImageElement imageElement)
    public void Update(DocumentStatus status, ParagraphElement imageElement)

因為C#不支援雙分派,第二引數無法動態解析,所以就算實際型別是ImageElement,但是宣告型別是其基類DocumentElement,也會被編譯器拒絕。
所以,為了在本不支援雙分派的C#中實現雙分派,我們需要新增一個跳板函式,通過這個函式,我們讓第二引數充當被呼叫物件,實現動態繫結,從而找到正確的過載函式,我們需要引出今天的主角,Visitor模式。

Visitor模式

Visitor is a behavioral design pattern that lets you separate algorithms from the objects on which they operate.

翻譯的更直白一點,Visitor模式允許針對不同的具體型別定製不同的訪問方法,而這個訪問者本身,也可以是不同的型別,看一下UML

在Visitor模式中,我們需要把訪問者抽象出來,以方便之後定製更多的不同型別的訪問者

  • 抽象出DocumentElementVisitor,含有兩個版本的Visit方法,在其子類中具體定製針對不同型別的訪問方法
    public abstract class DocumentElementVisitor
    {
        public abstract void Visit(ImageElement imageElement);
        public abstract void Visit(ParagraphElement imageElement);
    }

    public class DocumentStatus : DocumentElementVisitor
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }
        public int ImageNum { get; set; }
        public void ShowStatus()
        {
            Console.WriteLine("I have {0} char, {1} word and {2} image", CharNum, WordNum, ImageNum);
        }

        public void Update(DocumentElement documentElement)
        {
            documentElement.Accept(this);
        }

        public override void Visit(ImageElement imageElement)
        {
            ImageNum++;
        }

        public override void Visit(ParagraphElement paragraphElement)
        {
            WordNum += paragraphElement.WordNum;
            CharNum += paragraphElement.CharNum;
        }
    }
  • 在被訪問類的基類中新增一個Accept方法,這個方法用來實現雙分派,這個方法就是我們前文提到的跳板函式,它的作用就是讓第二引數充當被呼叫物件,第二次利用多型(第一次多型發生在呼叫Accept方法的時候)
    public abstract class DocumentElement
    {
        public abstract void Accept(DocumentElementVisitor visitor);
    }
    
    public class ImageElement : DocumentElement
    {
        public override void Accept(DocumentElementVisitor visitor)
        {
            visitor.Visit(this);
        }
    }

    public class ParagraphElement : DocumentElement
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }

        public ParagraphElement(int charNum, int wordNum)
        {
            CharNum = charNum;
            WordNum = wordNum;
        }

        public override void Accept(DocumentElementVisitor visitor)
        {
            visitor.Visit(this);
        }
    }

這裡,Accept方法就是Visitor模式的精髓,通過呼叫被訪問基類的Accept方法,被訪問基類通過語言的單分派,動態綁定了正確的被訪問子類,接著在子類方法中,將第一引數當做執行物件再呼叫一次它的方法,根據語言的單分派機制,第一引數也能被正確的動態繫結型別,這樣就實現了雙分派

這就是Visitor模式的簡單介紹,這個模式的好處在於:

  • 克服語言沒有雙分派功能的缺陷,能夠正確的解析引數的型別,尤其當想要對一個繼承族群類的不同子類定製訪問方法時,這個模式可以派上用場
  • 非常便於新增訪問者,試想,如果我們未來想要新增一個DocumentPriceCount,需要對段落和圖片計費,我們只需要新建一個類,繼承自DocumentVisitor,同時實現相應的Visit方法就行

希望大家通過這篇文章,能對Visitor模式有一定了解,在實踐中可以恰當的使用。
如果您對這篇文章有什麼看法和見解,歡迎在評論區留言,大家一起進步!

寫在前面

Visitor模式在日常工作中出場比較少,如果統計大家不熟悉的模式,那麼它榜上有名的可能性非常大。使用頻率少,再加上很多文章提到Visitor模式都著重於它克服語言單分派的特點上面,而對何時應該使用這個模式及這個模式是怎麼一點點演講出來的提之甚少,造成很多人對這個模式有種霧裡看花的感覺,今天跟著老胡,我們一起來一點點揭開它的面紗吧。
 

模式演進

舉個例子

現在假設我們有一個簡單的需求,需要統計出一篇文件中的字數、詞數和圖片數量。其中字數和詞數存在於段落中,圖片數量單獨統計。於是乎,我們可以很快的寫出第一版程式碼

使用了基本抽象的版本

    abstract class DocumentElement
    {
        public abstract void UpdateStatus(DocumentStatus status);
    }

    public class DocumentStatus
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }
        public int ImageNum { get; set; }
        public void ShowStatus()
        {
            Console.WriteLine("I have {0} char, {1} word and {2} image", CharNum, WordNum, ImageNum);
        }
    }

    class ImageElement : DocumentElement
    {
        public override void UpdateStatus(DocumentStatus status)
        {
            status.ImageNum++;
        }
    }

    class ParagraphElement : DocumentElement
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }

        public ParagraphElement(int charNum, int wordNum)
        {
            CharNum = charNum;
            WordNum = wordNum;
        }

        public override void UpdateStatus(DocumentStatus status)
        {
            status.CharNum += CharNum;
            status.WordNum += WordNum;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            DocumentStatus docStatus = new DocumentStatus();
            List<DocumentElement> list = new List<DocumentElement>();
            DocumentElement e1 = new ImageElement();
            DocumentElement e2 = new ParagraphElement(10, 20);
            list.Add(e1);
            list.Add(e2);
            list.ForEach(e => e.UpdateStatus(docStatus));
            docStatus.ShowStatus();
        }
    }

執行結果如下,非常簡單

但是細看這版程式碼,會發現有以下問題:

  • 所有的DocumentElement派生類必須訪問DocumentStatus,根據迪米特法則,這不是個好現象,如果在未來對DocumentStatus有修改,這些派生類被波及的可能性極大
  • 統計程式碼散落在不同的派生類裡面,維護不方便

有鑑於此,我們推出了第二版程式碼
 

使用了Tpye-Switch的版本

這一版程式碼中,我們摒棄了之前在具體的DocumentElement派生類中進行統計的做法,直接在統計類中統一處理

    public abstract class DocumentElement
    {
        //nothing to do now
    }

    public class DocumentStatus
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }
        public int ImageNum { get; set; }
        public void ShowStatus()
        {
            Console.WriteLine("I have {0} char, {1} word and {2} image", CharNum, WordNum, ImageNum);
        }

        public void Update(DocumentElement documentElement)
        {
            switch(documentElement)
            {
                case ImageElement imageElement:
                    ImageNum++;
                    break;

                case ParagraphElement paragraphElement:
                    WordNum += paragraphElement.WordNum;
                    CharNum += paragraphElement.CharNum;
                    break;
            }
        }
    }

    public class ImageElement : DocumentElement
    {

    }

    public class ParagraphElement : DocumentElement
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }

        public ParagraphElement(int charNum, int wordNum)
        {
            CharNum = charNum;
            WordNum = wordNum;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            DocumentStatus docStatus = new DocumentStatus();
            List<DocumentElement> list = new List<DocumentElement>();
            DocumentElement e1 = new ImageElement();
            DocumentElement e2 = new ParagraphElement(10, 20);
            list.Add(e1);
            list.Add(e2);
            docStatus.ShowStatus();
        }
    }

測試結果和第一個版本的程式碼一樣,這一版程式碼克服了第一個版本中,統計程式碼散落,具體類依賴統計類的問題,轉而我們在統計類中集中處理了統計任務。但同時它引入了type-switch, 這也是一個不好的訊號,具體表現在:

  • 程式碼冗長且難以維護
  • 如果派生層次加多,需要很小心的選擇case順序以防出現繼承層次較低的類出現在繼承層次更遠的類前面,從而造成後面的case永遠無法被訪問的情況,這造成了額外的精力成本
     

嘗試使用過載的版本

有鑑於上面type-switch版本的問題,作為敏銳的程式設計師,可能馬上有人就會提出過載方案:“如果我們針對每個具體的DocumentElement寫出相應的Update方法,不就可以了嗎?”就像下面這樣

    public class DocumentStatus
    {
        //省略相同程式碼
        public void Update(ImageElement imageElement)
        {
           ImageNum++;
        }

        public void Update(ParagraphElement paragraphElement)
        {
           WordNum += paragraphElement.WordNum;
           CharNum += paragraphElement.CharNum;
        }
    }

    //省略相同程式碼
    class Program
    {
        static void Main(string[] args)
        {
            DocumentStatus docStatus = new DocumentStatus();
            List<DocumentElement> list = new List<DocumentElement>();
            list.Add(new ImageElement());
            list.Add(new ParagraphElement(10, 20));
            list.ForEach(e => docStatus.Update(e));
            docStatus.ShowStatus();
        }
    }

看起來很好,不過可惜,這段程式碼編譯失敗,編譯器會抱怨說,不能將DocumentElement轉為它的子類,這是為什麼呢?講到這裡,就不能不提一下程式語言中的單分派和雙分派
 

單分派與雙分派

大家都知道,多型是OOP的三個基本特徵之一,即形如以下的程式碼

    public class Father
    {
	public virtual void DoSomething(string str){}
    }

    public class Son : Father
    {
	public override void DoSomething(string str){}
    }

    Father son = new Son();
    son.DoSomething();

son 雖然被宣告為Father型別,但在執行時會被動態繫結到其實際型別Son並呼叫到正確的被重寫後的函式,這是多型,通過呼叫函式的物件執行動態繫結。在主流語言,比如C#, C++ 和 JAVA中,編譯器在編譯類函式的時候會進行擴充,把this指標隱含的傳遞到方法裡面,上面的方法會擴充為

    void DoSomething(this, string);
    void DoSomething(this, string);

在多型中實現的this指標動態繫結,其實是針對函式的第一個引數進行執行時動態繫結,這個也是單分派的定義。
至於雙分派,顧名思義,就是可以針對兩個引數進行執行時繫結的分派方法,不過可惜,C#等都不支援,所以大家現在應該能理解為什麼上面的程式碼不能通過編譯了吧,上面的程式碼通過編譯器的擴充,變成了

    public void Update(DocumentStatus status, ImageElement imageElement)
    public void Update(DocumentStatus status, ParagraphElement imageElement)

因為C#不支援雙分派,第二引數無法動態解析,所以就算實際型別是ImageElement,但是宣告型別是其基類DocumentElement,也會被編譯器拒絕。
所以,為了在本不支援雙分派的C#中實現雙分派,我們需要新增一個跳板函式,通過這個函式,我們讓第二引數充當被呼叫物件,實現動態繫結,從而找到正確的過載函式,我們需要引出今天的主角,Visitor模式。

Visitor模式

Visitor is a behavioral design pattern that lets you separate algorithms from the objects on which they operate.

翻譯的更直白一點,Visitor模式允許針對不同的具體型別定製不同的訪問方法,而這個訪問者本身,也可以是不同的型別,看一下UML

在Visitor模式中,我們需要把訪問者抽象出來,以方便之後定製更多的不同型別的訪問者

  • 抽象出DocumentElementVisitor,含有兩個版本的Visit方法,在其子類中具體定製針對不同型別的訪問方法
    public abstract class DocumentElementVisitor
    {
        public abstract void Visit(ImageElement imageElement);
        public abstract void Visit(ParagraphElement imageElement);
    }

    public class DocumentStatus : DocumentElementVisitor
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }
        public int ImageNum { get; set; }
        public void ShowStatus()
        {
            Console.WriteLine("I have {0} char, {1} word and {2} image", CharNum, WordNum, ImageNum);
        }

        public void Update(DocumentElement documentElement)
        {
            documentElement.Accept(this);
        }

        public override void Visit(ImageElement imageElement)
        {
            ImageNum++;
        }

        public override void Visit(ParagraphElement paragraphElement)
        {
            WordNum += paragraphElement.WordNum;
            CharNum += paragraphElement.CharNum;
        }
    }
  • 在被訪問類的基類中新增一個Accept方法,這個方法用來實現雙分派,這個方法就是我們前文提到的跳板函式,它的作用就是讓第二引數充當被呼叫物件,第二次利用多型(第一次多型發生在呼叫Accept方法的時候)
    public abstract class DocumentElement
    {
        public abstract void Accept(DocumentElementVisitor visitor);
    }
    
    public class ImageElement : DocumentElement
    {
        public override void Accept(DocumentElementVisitor visitor)
        {
            visitor.Visit(this);
        }
    }

    public class ParagraphElement : DocumentElement
    {
        public int CharNum { get; set; }
        public int WordNum { get; set; }

        public ParagraphElement(int charNum, int wordNum)
        {
            CharNum = charNum;
            WordNum = wordNum;
        }

        public override void Accept(DocumentElementVisitor visitor)
        {
            visitor.Visit(this);
        }
    }

這裡,Accept方法就是Visitor模式的精髓,通過呼叫被訪問基類的Accept方法,被訪問基類通過語言的單分派,動態綁定了正確的被訪問子類,接著在子類方法中,將第一引數當做執行物件再呼叫一次它的方法,根據語言的單分派機制,第一引數也能被正確的動態繫結型別,這樣就實現了雙分派

這就是Visitor模式的簡單介紹,這個模式的好處在於:

  • 克服語言沒有雙分派功能的缺陷,能夠正確的解析引數的型別,尤其當想要對一個繼承族群類的不同子類定製訪問方法時,這個模式可以派上用場
  • 非常便於新增訪問者,試想,如果我們未來想要新增一個DocumentPriceCount,需要對段落和圖片計費,我們只需要新建一個類,繼承自DocumentVisitor,同時實現相應的Visit方法就行

希望大家通過這篇文章,能對Visitor模式有一定了解,在實踐中可以恰當的使用。
如果您對這篇文章有什麼看法和見解,歡迎在評論區留言,大家一起進步!