写在前面

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

如果您对这篇文章有什么看法和见解,欢迎在评论区留言,大家一起进步!

聊聊C#中的Visitor模式的更多相关文章

  1. 聊聊C#中的composite模式

    写在前面 Composite组合模式属于设计模式中比较热门的一个,相信大家对它一定不像对访问者模式那么陌生,毕竟谁又没有遇到过树形结构呢.不过所谓温故而知新,我们还是从一个例子出发,起底一下这个模式吧 ...

  2. Java 的双重分发与 Visitor 模式

    双重分发(Double Dispatch) 什么是双重分发? 谈起面向对象的程序设计时,常说起的面向对象的「多态」,其中关于多态,经常有一个说法是「父类引用指向子类对象」. 这种父类的引用指向子类对象 ...

  3. 人多力量大vs.两个披萨原则,聊聊持续交付中的流水线模式

    人多力量大vs.两个披萨原则,聊聊持续交付中的流水线模式 在前面5期文章中,我们分别详细介绍了持续交付体系基础层面的建设,主要是多环境和配置管理,这些是持续交付自动化体系的基础,是跟我们实际的业务场景 ...

  4. 聊聊OOP中的设计原则以及访问者模式

    一  设计原则 (SOLID) 1.  S - 单一职责原则(Single Responsibllity Principle) 1.1  定义 一个类或者模块只负责完成一个职责(或功能), 认为&qu ...

  5. 设计模式:基于线程池的并发Visitor模式

    1.前言 第二篇设计模式的文章我们谈谈Visitor模式. 当然,不是简单的列个的demo,我们以电商网站中的购物车功能为背景,使用线程池实现并发的Visitor模式,并聊聊其中的几个关键点. 一,基 ...

  6. 完成C++不能做到的事 - Visitor模式

    拿着刚磨好的热咖啡,我坐在了显示器前.“美好的一天又开始了”,我想. 昨晚做完了一个非常困难的任务并送给美国同事Review,因此今天只需要根据他们提出的意见适当修改代码并提交,一周的任务就完成了.剩 ...

  7. Visitor模式,Decorator模式,Extension Object模式

    Modem结构 Visitor模式 对于被访问(Modem)层次结构中的每一个派生类,访问者(Visitor)层次中都有一个对应的方法. 从派生类到方法的90度旋转. 新增类似的Windows配置函数 ...

  8. 设计模式之visitor模式,人人能懂的有趣实例

    设计模式,现在在网上随便搜都一大堆,为什么我还要写"设计模式"的章节呢? 两个原因: 1.本人觉得这是一个有趣的设计模式使用实例,所以记下来: 2.看着设计模式很牛逼,却不知道怎么 ...

  9. 【转载】完成C++不能做到的事 - Visitor模式

    原文: 完成C++不能做到的事 - Visitor模式 拿着刚磨好的热咖啡,我坐在了显示器前.“美好的一天又开始了”,我想. 昨晚做完了一个非常困难的任务并送给美国同事Review,因此今天只需要根据 ...

随机推荐

  1. SCTF 2018_Simple PHP Web

    SCTF 2018_Simple PHP Web 进入环境注意观察url http://www.bmzclub.cn:23627/?f=login.php 有点像是文件读取我们尝试读一下/etc/pa ...

  2. Matplotlib is currently using agg, which is a non-GUI backend 和 ImportError: No module named 'Tkinter' [closed]

    跑maskrcnn报错:UserWarning: Matplotlib is currently using agg, which is a non-GUI backend, so cannot sh ...

  3. 浅析CSS定位

    position 属性指定了html元素的定位类型. position 属性有 4 种值:1. static(default)表示没有定位,元素出现在正常的文档流中.为静态定位的元素设置 top|bo ...

  4. 详解 CSS 属性 - position

    postion 属性定义了一个元素在页面布局中的位置以及对周围元素的影响.该属性共有5个值: position: absolute position: relative position: fixed ...

  5. 设计模式之:工厂方法模式FactoryMethodPattern的实现

    本例用到了配置文件.接口.反射.多态: 满足的设计原则: 通过工厂,实现创建对象和使用对象的分离,实现松耦合,满足迪米特法则: 通过配置文件指定创建对象类型,而不需更改源代码,满足开闭原则: 容易实现 ...

  6. ubantu系统之 Ubuntu14.04安装Samba实现文件共享

    1 安装    sudo apt-get install samba  2 配置    打开Samba配置文件:    vim /etc/samba/smb.conf      在其最后添加:    ...

  7. Javascript中数组的判断方法

    摘要: 1.数组检测的方法: 1) typeof . 2) instanceof . 3) constructor . 4) Object.prototype.toString. 5) Array.i ...

  8. 拼写检查-c++

    [问题描述] 作为一个新的拼写检查程序开发团队的成员,您将编写一个模块,用已知的所有形式正确的词典来检查给定单词的正确性.        如果字典中没有这个词,那么可以用下列操作中的一个来替换正确的单 ...

  9. python---导入模块和包

    导入模块和包 导入模块 import的过程中发生了哪些事情? 寻找模块 如果找到,开辟一块空间,执行这个模块 把这个模块中用到的名字都收录到开辟的空间中 创建一个变量来引用这个模块的空间 注意: 模块 ...

  10. 使用基于Roslyn的编译时AOP框架来解决.NET项目的代码复用问题

    理想的代码优化方式 团队日常协作中,自然而然的会出现很多重复代码,根据这些代码的种类,之前可能会以以下方式处理 方式 描述 应用时可能产生的问题 硬编码 多数新手,或逐渐腐坏的项目会这么干,会直接复制 ...