写在前面

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. Spring系列28:@Transactional事务源码分析

    本文内容 @Transactional事务使用 @EnableTransactionManagement 详解 @Transactional事务属性的解析 TransactionInterceptor ...

  2. 遇到MyBatis-Plus的错误之“Table 'mybatis_plus.user' doesn't exist”

    一.问题 Table 'mybatis_plus.user' doesn't exist 二.原因 表中没有user表 三.解决方案 生成user表既可 四.结果图 运行后显示查询出来的数据 五.总结 ...

  3. C++中sort()函数使用介绍

    sort()简介 为什么选择使用sort()  在刷题的时候我们经常会碰到排序的问题,如果我们不使用一些排序的方法那我们只能手撕排序,这样就会浪费一些时间.而且我们还需要根据需要去选择相关的排序方法: ...

  4. Hadoop搭建高可用的HA集群

    一.工具准备 1.7台虚拟机(至少需要3台),本次搭建以7台为例,配好ip,关闭防火墙,修改主机名和IP的映射关系(/etc/hosts),关闭防火墙 2.安装JDK,配置环境变量 二.集群规划: 集 ...

  5. PAT A1035 Password

    题目描述: To prepare for PAT, the judge sometimes has to generate random passwords for the users. The pr ...

  6. CCF201509-2日期计算

    问题描述 给定一个年份y和一个整数d,问这一年的第d天是几月几日? 注意闰年的2月有29天.满足下面条件之一的是闰年: 1) 年份是4的整数倍,而且不是100的整数倍: 2) 年份是400的整数倍. ...

  7. Java---变量和基本数据类型

    变量 在Java中,变量分为两种:基本类型的变量和引用类型的变量. 在Java中变量必须先定义后使用,在定义变量的时候可以给它一个初始值.如果不写初始值,默认为0或空. 变量的一个重要特点是可以重新赋 ...

  8. Linux---必备命令(2)

    进程相关命令 # 查看系统所有的进程 ps -ef ps -ef | grep vim # 过滤出vim有关的进程 ps -ef | grep vim # 过滤出22端口的信息 ps -tunlp | ...

  9. 7.Docker容器使用辅助工具汇总

    原文地址: 点击直达 more information: https://docs.docker.com/engine/security/security/#docker-daemon-attack- ...

  10. Go xmas2020 学习笔记 07、Formatted & File I/O

    07-Formatted & File I/O. I/O steams. formatted I/O. fmt functions. file I/O. Practice ① I/O. Alw ...