写在前面

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. Linux ftp服务器部署(最简单的ftp教程)

    之前在阿里云领了一个ECS服务器(顺便说一句,白嫖的,真香~),就想着做点什么,然后试着做个 ftp 站点,因为第一次尝试,结果走了不少弯路.最后终于完成了,研究了两天(哎~,脑壳笨没办法)就想着记录 ...

  2. 接口combine

    需求描述 进行复杂项目开发时,服务端(数据接口实现端)会把接口拆分的比较细粒度,以方便在更多地方复用.而拆分后的接口在前端进行组合请求时,通常会出现一个区块要请求5.6个接口甚至更多接口请求才能拿到想 ...

  3. 用原生js写一个"多动症"的简历

    用原生js写一个"多动症"的简历 预览地址源码地址 最近在知乎上看到@方应杭用vue写了一个会动的简历,觉得挺好玩的,研究一下其实现思路,决定试试用原生js来实现. 会动的简历实现 ...

  4. python-输入输出-计算字符串中的数

    将字符串中的每个数都抽取出来,然后统计所有数的个数并求和. 输入格式: 一行字符串,字符串中的数之间用1个空格或者多个空格分隔. 输出格式: 第1行:输出数的个数.第2行:求和的结果,保留3位小数. ...

  5. Spring Boot-切换嵌入式Servlet容器

    首先我们先查看Spring Boot中支持几种嵌入式容器 选中ConfigurableWebServerFactory类,点击ctrl+h键,查看 切换到jetty容器步骤如下 1.排除掉tomcat ...

  6. QT类使用记录

    QT类使用记录 1.QSharedMemory 提供了对一段共享内存的访问.既提供了被多进程和多线程共享的一段内存的访问.也为单线程或单进程锁定内存以实现互斥访问提供了方法. QSharedMemor ...

  7. Water 2.5.9 发布,一站式服务治理平台

    Water(水孕育万物...) Water 为项目开发.服务治理,提供一站式解决方案(可以理解为微服务架构支持套件).基于 Solon 框架开发,并支持完整的 Solon Cloud 规范:已在生产环 ...

  8. css 让一行文字 字与字的隔开一点

     效果: css: letter-spacing: 5rpx; 我这是小程序. 单位自带.px  rem em这些,随自己需要带.

  9. SVG 和 CSS3 实现一个超酷爱心 Like 按钮

    在现代网页中,我们经常可以在一些文章.视频和图片页面上找到"Like"按钮,并且通过点击该按钮来表示自己对该内容的喜欢或者不喜欢.大部分"Like"按钮是纯文本 ...

  10. Java 字符串Split方法的一个坑

    java字符串的split,只传一个参数,后面空白的字符串会被忽略: public static void main(String[] args) { String str = "ab|c| ...