Design-Pattern-Visitor
访问者模式(Visitor)是一种行为型设计模式。其核心思想在于:分离数据结构与数据操作,支持在不修改现有数据结构的前提下,为其扩展新的操作逻辑。
场景
假设需开发一款办公文档处理程序,该文档由多种核心元素构成,具体包括标题、段落、文本、图片等。基于上述各类元素的共性特征,设计并定义了元素接口Element,各类具体元素均为该接口的实现类。
当前需对这些元素执行一系列操作,例如元素导出、格式转换、内容字数统计等。由于不同元素对应的操作实现逻辑存在差异,无法在抽象基类中统一定义通用实现,因此需为各类元素分别实现专属的操作逻辑。
一种直观的思路是将这些操作实现在对应的数据结构中,例如将标题的导出功能实现在标题类内部。
1 | |
但该实现方式存在明显缺陷,即数据与操作存在耦合。关于该实现方式的具体利弊,本文后续将展开详细探讨,此处暂不赘述。若需遵循数据与操作分离的设计原则,则需单独设计数据操作类,暂将其命名为访问者(Visitor)类。后续可将同类型操作(如标题导出、文本导出等)统一封装于该访问者类中。
在Visitor类中实现各元素的操作方法时,需关注以下要点:操作方法需接收一个入参。若所有数据共用同一个操作方法,该入参通常以Element接口类型传入,此时需在操作方法内部判断入参的实际类型(例如通过instanceof关键字或反射机制实现)。
1 | |
该实现方式不仅繁琐,且当新增数据类型时,需在所有访问者类中新增else分支,直接违反了设计模式中的开闭原则。
是否可以利用语言特性解决这个问题?例如通过函数重载机制,定义多个同名函数,利用入参类型差异实现区分,从而自动匹配并执行对应的操作实现方法。
1 | |
但该方案并不可行。函数重载属于编译期行为,仅在编译阶段对入参类型进行校验。若调用方法时传入的变量类型为Element接口类型,则仅会匹配并调用入参为Element类型的方法,无法根据变量运行时的实际类型,自动匹配对应的具体导出方法。
另一种思路是定义不同名称的方法,但本质上只是将类型判断的责任从操作类转移到了客户端,并未从根本上解决问题。
1 | |
归根结底,该问题的核心成因在于:访问者类无法知晓数据的实际类型,却需依据数据类型匹配并执行对应的代码逻辑。
访问者模式
对于客户端与访问者类而言,数据的实际类型具有不确定性;但在每个具体数据类内部,其自身类型是明确的。因此,可将根据类型匹配操作的逻辑委派给数据类承担。
由此,方法调用方向发生反转:不再是访问者接收数据对象,而是数据对象接收访问者。数据对象明确自身类型,因此能够精准调用访问者中对应的操作方法。
为确定最终执行的操作方法,需经过两次分派过程,即双分派:第一次分派针对数据对象——通常通过接口引用,根据数据对象运行时的实际类型,调用其对应的方法;第二次分派发生在数据对象的方法内部,数据对象根据自身类型,调用访问者类中匹配的操作方法。
数据对象中用于接收访问者的方法,为保证通用性,其入参通常定义为访问者接口类型。方法体内可通过两种方式实现匹配:一是调用访问者中特定名称的操作方法;二是将自身对象传入访问者中存在多个重载版本的操作方法。双分派设计让编译期静态的重载特性,具备了运行期动态匹配的效果。
classDiagram
direction TB
class Visitor {
<<interface>>
+visitConcreteElementA(element: ConcreteElementA): void
+visitConcreteElementB(element: ConcreteElementB): void
}
class ConcreteVisitorA {
+visitConcreteElementA(element: ConcreteElementA): void
+visitConcreteElementB(element: ConcreteElementB): void
}
class ConcreteVisitorB {
+visitConcreteElementA(element: ConcreteElementA): void
+visitConcreteElementB(element: ConcreteElementB): void
}
class Element {
<<interface>>
+accept(visitor: Visitor): void
}
class ConcreteElementA {
+accept(visitor: Visitor): void
}
class ConcreteElementB {
+accept(visitor: Visitor): void
}
class Client {
-elements: List~Element~
+addElement(element: Element): void
+removeElement(element: Element): void
+accept(visitor: Visitor): void // 遍历所有元素并调用accept
}
Visitor <|-- ConcreteVisitorA
Visitor <|-- ConcreteVisitorB
Element <|-- ConcreteElementA
Element <|-- ConcreteElementB
Client *-- Element
Element --> Visitor : accept(Visitor)
ConcreteElementA --> Visitor : visitConcreteElementA(this)
ConcreteElementB --> Visitor : visitConcreteElementB(this)
Client --> Visitor
至此,访问者模式的核心内容已介绍完毕。在学习过程中,笔者发现访问者模式与策略模式Design-Pattern-Strategy存在较高相似度:从角色对应关系来看,访问者对应策略模式中的策略角色,元素对应上下文角色;二者的核心目标一致,均为在运行时动态替换不同的算法或操作
二者的核心区别在于维度差异:策略模式属于一维设计,即具体算法的选择仅由策略对象决定,不受上下文影响;访问者模式则属于二维设计,具体操作的执行由访问者与元素共同决定。正是这种维度差异,使得访问者模式需要采用看似复杂冗余的结构设计。
数据和操作分离
访问者模式复杂的结构设计与调用逻辑,导致笔者对该设计模式的接受度较低,认为其缺乏统一、简洁的代码美感。而这种复杂结构的产生,核心初衷是实现数据与操作的分离。
若无需严格区分数据与操作,整体实现结构可大幅简化。例如,可将需支持的操作定义在元素接口中,再由各具体元素类分别实现这些操作。调用阶段仅通过多态特性,即可实现具体操作的动态匹配——本质上是将设计维度降低一维,即由客户端直接确定操作的具体类型。
诸多讲解访问者模式的教材与博客,均强调此类实现方式存在明显缺陷,具体可归纳为三点:一是违反开闭原则,即若需为元素新增操作,需修改元素接口及所有具体元素类的实现;二是违反单一职责原则,元素类既承担数据存储职责,又包含业务操作逻辑,易导致代码臃肿;三是操作逻辑分散,相同业务场景的操作逻辑被拆分至多个元素类的方法中,不利于维护。
但需客观看待这一问题:若新增操作时,仅在各元素类中新增方法而非修改已有方法,则对现有代码的改动相对可控,并非完全违反开闭原则。此外,代码组织存在多元方案,对于“操作与元素”的二维对应关系,无论按任一维度汇总组织,在语义上均具备合理性。例如,将同类操作聚合管理,或把同一元素的相关操作集中封装,两种方式均符合逻辑。具体选择何种维度,通常可参考三类因素:其一,比较元素与操作的种类数量,按数量更少的维度组织,可减少类的创建成本;其二,考量变化频率,将变动更频繁的维度作为组织依据,能降低长期维护成本;其三,评估不同元素操作间的相似性,按相似性更高的维度组织,可提升代码可读性。
因此,笔者认为,数据与操作的分离并非必然选择,实际开发中亦存在大量不分离的合理范例。
在小型项目、操作固定、需求简单的场景下,数据与操作耦合具备显著优势:代码逻辑直观、调用方式简便、无需理解额外中间层,甚至可提升开发效率。
分离设计的本质是牺牲元素扩展的灵活性,换取操作扩展的灵活性。具体选型需结合实际场景权衡:当数据结构稳定、操作数量少且无变化需求时,不分离是最高效的方案;当操作需频繁新增或修改时,分离设计可避免改动数据类代码。是否采用分离设计,需结合实际需求对各类影响因素综合取舍。
总结
访问者模式的核心是实现数据与操作的分离:为每类操作单独定义访问者类,并在该类中统一实现所有元素对应此类操作的逻辑。
数据侧需提供接收访问者的接口,并在接口实现中根据自身类型,主动调用访问者类中对应的操作方法。这种调用机制即双分派,其核心价值在于让静态的函数重载具备动态匹配的特性。
访问者模式的结构设计相对复杂,其优势仅在特定场景下凸显:即当数据结构相对稳定、操作需求频繁变化时,采用该模式才能显著降低维护成本、提升扩展效率。