聊聊 C# 中的 Visitor 模式(访问者模式)

.NET
355
0
0
2023-01-08
标签   C#

前言

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();
    }
}

运行结果如下,非常简单

img

但是细看这版代码,会发现有以下问题:

•所有的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

img

在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模式有一定了解,在实践中可以恰当的使用。