写在前面

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. ctfhub密码口令

    弱口令 进入环境 使用burpsuit抓包爆破 密码长度不一样应该密码就为他 即可找到 默认口令 进入环境一开始不懂百度借鉴原来是要看常见设备默认口令 很快就找到了 一个一个的试 即可获得答案

  2. (stm32f103学习总结)—RTC独立定时器—实时时钟实验

    一.STM32F1 RTC介绍 1.1 RTC简介 STM32 的实时时钟( RTC)是一个独立的定时器. STM32 的 RTC 模 块拥有一组连续计数的计数器,在相应软件配置下,可提供时钟日历的 ...

  3. MCU选型

    含义: MCU(Micro Controller Unit)中文名称为微控制单元,又称单片微型计算机(Single Chip Microcomputer),是指随着大规模集成电路的出现及其发展,将计算 ...

  4. webpack系列——webpack3导入jQuery的新方案

    本文的目的 拒绝全局导入jQuery!! 拒绝script导入jQuery!! 找到一种只在当前js组件中引入jQuery,并且使用webpack切割打包的方案! 测试环境 以下测试在webpack3 ...

  5. Vue-cli的打包初体验

    前言:我司是一个教育公司,最近要做一个入学诊断的项目,领导让我开始搭建一套基于vue的H5的开发环境.在网上搜集很多的适配方案,最终还是选定flexible方案.选择它的原因很简单: 它的github ...

  6. python-图的字典表示

    图的字典表示.输入多行字符串,每行表示一个顶点和该顶点相连的边及长度,输出顶点数,边数,边的总长度.比如上图0点表示:{'O':{'A':2,'B':5,'C':4}}.用eval函数处理输入,eva ...

  7. JavaWeb学习day2-web入门&随笔

    Tomcat详解: 1默认端口号: Tomcat:8080 Mysql:3306 http:80 https:443 2默认主机名:localhost 地址:127.0.0.1 3网站应用默认存放位置 ...

  8. 《手把手教你》系列基础篇(八十八)-java+ selenium自动化测试-框架设计基础-Log4j 2实现日志输出-下篇(详解教程)

    1.简介 上一篇宏哥讲解和分享了如何在控制台输出日志,但是你还需要复制粘贴才能发给相关人员,而且由于界面大小限制,你只能获取当前的日志,因此最好还是将日志适时地记录在文件中直接打包发给相关人员即可.因 ...

  9. python基础练习题(题目 斐波那契数列II)

    day16 --------------------------------------------------------------- 实例024:斐波那契数列II 题目 有一分数序列:2/1,3 ...

  10. CVPR 2022数据集汇总|包含目标检测、多模态等方向

    前言 本文收集汇总了目前CVPR 2022已放出的一些数据集资源. 转载自极市平台 欢迎关注公众号CV技术指南,专注于计算机视觉的技术总结.最新技术跟踪.经典论文解读.CV招聘信息. M5Produc ...