一起详细聊聊C#中的Visitor模式

目录
  • 写在前面
  • 模式演进
    • 举个例子
    • 使用了Tpye-Switch的版本
    • 尝试使用重载的版本
  • 单分派与双分派
  • 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# Visitor模式内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • C#设计模式之Visitor访问者模式解决长隆欢乐世界问题实例

    本文实例讲述了C#设计模式之Visitor访问者模式解决长隆欢乐世界问题.分享给大家供大家参考,具体如下: 一.理论定义 访问者模式 提供了 一组 集合 对象 统一的 访问接口,适合对 一个集合中的对象,进行逻辑操作,使 数据结构  和 逻辑结构分离. 二.应用举例 需求描述:暑假来啦!三个小伙子组团,开车来 长隆欢乐世界玩. 每个人想玩的项目都不一样, 旅游者 1   想玩:十环过山车,龙卷风暴,梦幻旋马 旅游者 2   想玩:空中警察,欢乐摩天轮,超级水战 旅游者 3   想玩:四维影院,垂

  • 一起详细聊聊C#中的Visitor模式

    目录 写在前面 模式演进 举个例子 使用了Tpye-Switch的版本 尝试使用重载的版本 单分派与双分派 Visitor模式 总结 写在前面 Visitor模式在日常工作中出场比较少,如果统计大家不熟悉的模式,那么它榜上有名的可能性非常大.使用频率少,再加上很多文章提到Visitor模式都着重于它克服语言单分派的特点上面,而对何时应该使用这个模式及这个模式是怎么一点点演讲出来的提之甚少,造成很多人对这个模式有种雾里看花的感觉,今天跟着老胡,我们一起来一点点揭开它的面纱吧. 模式演进 举个例子

  • 详细聊聊JDK中的反模式接口常量

    目录 前言 常量接口 类接口 枚举类型 结束语 前言 在实际开发过程中,经常会需要定义一个文件,用于存储一些常量,这些常量设计为静态公共常量(使用 public static final 修饰).这个时候就出现两种选择: 在接口中定义常量,比如 JDK 1.1 中的 java.io.ObjectStreamConstans 接口: 在类中定义常量,比如 JDK 1.7 中的 java.nio.charset.StandardCharsets: 这两种方式都能够达到要求:存储常量.无需实例化.下面

  • Java超详细讲解设计模式中的命令模式

    目录 介绍 实现 个人理解:把一个类里的多个命令分离出来,每个类里放一个命令,实现解耦合,一个类只对应一个功能,在使用命令时由另一个类来统一管理所有命令. 缺点:如果功能多了就会导致创建的类的数量过多 命令模式(Command Pattern)是⼀种数据驱动的设计模式,它属于行为型模式.请求以命令的形式包裹在对象中,并传给调⽤对象.调⽤对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执⾏命令. 介绍 意图:将⼀个请求封装成⼀个对象,从⽽使您可以⽤不同的请求对客户进⾏参数化.

  • 详细聊聊SpringBoot中动态切换数据源的方法

    其实这个表示有点不太对,应该是 Druid 动态切换数据源的方法,只是应用在了 springboot 框架中,准备代码准备了半天,之前在一次数据库迁移中使用了,发现 Druid 还是很强大的,用来做动态数据源切换很方便. 首先这里的场景跟我原来用的有点点区别,在项目中使用的是通过配置中心控制数据源切换,统一切换,而这里的例子多加了个可以根据接口注解配置 第一部分是最核心的,如何基于 Spring JDBC 和 Druid 来实现数据源切换,是继承了org.springframework.jdbc

  • 详细聊聊sql中exists和not exists用法

    目录 exists: exists 和in 的区别 not exists详细介绍: 附案例分析 总结 之所以要说这个问题,是因为项目中用到了not exists,但两者写的语句只有一点差别,结果一个有问题了,一个没问题.具体问题下面详细说明,先来看看exists如何应用. exists: 强调的是是否有返回集,不需知道具体返回的是什么,比如: SELECT * FROM customer WHERE not EXISTS ( SELECT 0 FROM customer_goods WHERE

  • 详细聊聊TypeScript中unknown与any的区别

    目录 前言 1. unknown vs any 2. unknown 和 any 的心智模式 3.总结 总结 前言 我们知道 any 类型的变量可以被赋给任何值. let myVar: any = 0; myVar = '1'; myVar = false; TypeScript 指南并不鼓励使用 any,因为使用它就会丢掉类型限制--而需要类型限制也是我们选择 TypeScript 的一个原因,所以就是有点背道而驰. TypeScript(3.0 及以上版本)还提供了一种类似于 any 的特殊

  • 详细聊聊MySQL中慢SQL优化的方向

    目录 前言 SQL语句优化 记录慢查询SQL 如何修改配置 查看慢查询日志 查看SQL执行计划 如何使用 SQL编写优化 为何要对慢SQL进行治理 总结 前言 影响一个系统的运行速度的原因有很多,是多方面的,甚至可能是偶然性的,或前端,或后端,或数据库,或中间件,或服务器,或网络等等等等,真正的去定位一个问题需要对系统有一定的认知,可以根据自身的判断去缩小问题范围. 今天不说其他的优化,单独把数据库的优化拿出来说几个优化方向. 跟系统的优化方向一样,数据库的优化,同样也是多方面的,其中涵盖着SQ

  • 详细聊聊vue中组件的props属性

    目录 问题一:那props具体是怎么使用呢?原理又是什么呢?往下看 问题二:那如果我们想给年龄加1岁,怎么实现? 问题三:对于年龄这一类型,我们最希望拿到的是什么数据类型? 问题四:可以限制类型,那是不是也可以限制是否必传呢? 问题五:props接收的属性值可以修改吗? 问题六:必须要修改props属性值,怎么办? 总结:配置项props 总结 今天这篇文章,让你彻底学会props属性-- props主要用于组件的传值,他的工作就是为了接收外面传过来的数据,与data.el.ref是一个级别的配

  • 详细聊聊MySQL中的LIMIT语句

    目录 问题 server层和存储引擎层 那LIMIT是什么鬼? 怎么办? 吐个槽 最近有多个小伙伴在答疑群里问了小孩子关于LIMIT的一个问题,下边我来大致描述一下这个问题. 问题 为了故事的顺利发展,我们得先有个表: CREATE TABLE t ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1

  • 详细聊聊Mybatis中万能的Map

    目录 万能的Map demo map 实现add user map 实现通过id查询 多个参数可以使用Map进行传参 总结 万能的Map 假设,我们的实体类,或者数据库中的表,字段或者参数过多,我们需要考虑使用Map 简单来说,map你用什么参数就写什么参数,而实体类需要写所有参数. map不需要名称完全对应,通过键的映射取值,实体类必须要求和实体类中属性名字一样 map传递参数,直接在sql中取出key即可 [parameterType="map"] 对象传递参数,直接在sql中取对

随机推荐