聊聊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模式有一定了解,在实践中可以恰当的使用。
如果您对这篇文章有什么看法和见解,欢迎在评论区留言,大家一起进步!
聊聊C#中的Visitor模式的更多相关文章
- 聊聊C#中的composite模式
写在前面 Composite组合模式属于设计模式中比较热门的一个,相信大家对它一定不像对访问者模式那么陌生,毕竟谁又没有遇到过树形结构呢.不过所谓温故而知新,我们还是从一个例子出发,起底一下这个模式吧 ...
- Java 的双重分发与 Visitor 模式
双重分发(Double Dispatch) 什么是双重分发? 谈起面向对象的程序设计时,常说起的面向对象的「多态」,其中关于多态,经常有一个说法是「父类引用指向子类对象」. 这种父类的引用指向子类对象 ...
- 人多力量大vs.两个披萨原则,聊聊持续交付中的流水线模式
人多力量大vs.两个披萨原则,聊聊持续交付中的流水线模式 在前面5期文章中,我们分别详细介绍了持续交付体系基础层面的建设,主要是多环境和配置管理,这些是持续交付自动化体系的基础,是跟我们实际的业务场景 ...
- 聊聊OOP中的设计原则以及访问者模式
一 设计原则 (SOLID) 1. S - 单一职责原则(Single Responsibllity Principle) 1.1 定义 一个类或者模块只负责完成一个职责(或功能), 认为&qu ...
- 设计模式:基于线程池的并发Visitor模式
1.前言 第二篇设计模式的文章我们谈谈Visitor模式. 当然,不是简单的列个的demo,我们以电商网站中的购物车功能为背景,使用线程池实现并发的Visitor模式,并聊聊其中的几个关键点. 一,基 ...
- 完成C++不能做到的事 - Visitor模式
拿着刚磨好的热咖啡,我坐在了显示器前.“美好的一天又开始了”,我想. 昨晚做完了一个非常困难的任务并送给美国同事Review,因此今天只需要根据他们提出的意见适当修改代码并提交,一周的任务就完成了.剩 ...
- Visitor模式,Decorator模式,Extension Object模式
Modem结构 Visitor模式 对于被访问(Modem)层次结构中的每一个派生类,访问者(Visitor)层次中都有一个对应的方法. 从派生类到方法的90度旋转. 新增类似的Windows配置函数 ...
- 设计模式之visitor模式,人人能懂的有趣实例
设计模式,现在在网上随便搜都一大堆,为什么我还要写"设计模式"的章节呢? 两个原因: 1.本人觉得这是一个有趣的设计模式使用实例,所以记下来: 2.看着设计模式很牛逼,却不知道怎么 ...
- 【转载】完成C++不能做到的事 - Visitor模式
原文: 完成C++不能做到的事 - Visitor模式 拿着刚磨好的热咖啡,我坐在了显示器前.“美好的一天又开始了”,我想. 昨晚做完了一个非常困难的任务并送给美国同事Review,因此今天只需要根据 ...
随机推荐
- js技术之如何在JS中获取input的值
在JavaScript中获取input元素value的值: 方法一:var variations_number = $("#input的id名").val(); 1 <!DO ...
- 基于vue-cli搭了一个多页面应用的空脚手架
vue2.* (多页面跳转) @[vue2.3.3|webpack2.6.1|less|axios] 之前看过有相关朋友share了空的多页面脚手架. 不过down了几个都是webpack1.0或者v ...
- 小程序wx.createInnerAudioContext()获取不到时长问题
最近在开发小程序中,需要用到音频播放功能.但在初始化时,使用InnerAudioContext.duration获取不到音频的时长. Page({ /** * 生命周期函数--监听页面初次渲染完成 * ...
- Android开发小经验
1. TextView中的getTextSize返回值是以像素(px)为单位的, 而setTextSize()是以sp为单位的. 所以如果直接用返回的值来设置会出错,解决办法是 用setTextSiz ...
- 存储过程 psal emp.sal%type是什么意思
psal emp.sal%type 就是指psal这个变量是引用了表emp中的sal字段的类型.如果emp表中sal的类型变了,psal这个字段的类型也会跟着变化,总之,psal和表emp中sal字段 ...
- 解决vue-cli项目在运行时控制台出现 [WDS] Disconnected! 错误
在项目运行时 控制台输入 npm run dev 后浏览器出现该项目页面,但是出现了[WDS] Disconnected!错误 虽然有时并不影响,但是作为一名合格的程序员 我们还是尽量将其修复,以免将 ...
- css3,css的基础全局运用
浮动 1.浮动起来的盒子不占用位置,浮动了一个盒子下面的标准流的盒子会顶上来 可用清除浮动的方法来解决标准流会顶替位置的问题 清除浮动给父盒子加overflow: hidden; 鼠标经过事件 : ...
- Thread中,run方法和start方法的区别
1. 通过调用Thread类中的start()方法可以启动一个线程,但是线程并不是立刻运行,而是处于就绪态,一旦获取cpu时间片,则会立即运行run()方法 2. start()方法实现了多线程运行, ...
- SpringBoot2.x修改Redis序列化方式
添加一个配置类即可: /** * @Author FengZeng * @Date 2022-03-22 13:43 * @Description TODO */ @Configuration pub ...
- numpy教程01---ndarray的创建
欢迎关注公众号[Python开发实战], 获取更多内容! 工具-numpy numpy是使用Python进行数据科学的基础库.numpy以一个强大的N维数组对象为中心,它还包含有用的线性代数,傅里叶变 ...