重修设计模式-行为型-访问者模式

时间:2024-10-25 07:14:41

重修设计模式-行为型-访问者模式

Allows for one or more operation to be applied to a set of objects at runtime, decoupling the operations from the object structure.

允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。

访问者模式(Visitor Pattern)通过将操作分离到独立的访问者类中,从而可以在不修改现有类层次结构的情况下增加新的操作。访问者模式比较难理解,下面通过一个例子来了解访问者模式的原理。

假设现在需要处理一批资源文件,它们的格式有三种:PDF、PPT、Word。现在需要开发一个工具来处理这批资源文件,比如提取资源文件中的文本放到 txt 文件中。需求非常简单,稍微分析依稀就可以写出如下代码:

//资源文件抽象
abstract class ResourceFile(val filePath: String) {
    abstract fun extract2txt()
}

class PDFFile(filePath: String): ResourceFile(filePath) {
    override fun extract2txt() {
        println("提取PDF文件中文字信息...")
    }
}

class PPTFile(filePath: String): ResourceFile(filePath) {
    override fun extract2txt() {
        println("提取PPT文件中文字信息...")
    }
}

class WordFile(filePath: String): ResourceFile(filePath) {
    override fun extract2txt() {
        println("提取Word文件中文字信息...")
    }
}

//调用:
fun main() {
    val files = mutableListOf(PDFFile("a.pdf"), PPTFile("b.ppt"), WordFile("c.word"))
    files.forEach { file ->
        file.extract2txt()  //通过多态特性,调用运行时对象的具体方法
    }
}

若需求到此为止,这样写并没有太大问题,但随着需求的扩展,如还需要支持压缩、提取文件描述信息等,问题也就随之暴露:

  • 每添加一个新功能,都会改动到所有类的代码,违反开闭原则
  • 上层业务都耦合到具体的类(PDFFile、PPTFile、WordFile)中,导致类越来越膨胀,违反单一职责

针对上述问题,往往解决方式都是拆分解耦,把业务操作跟具体的数据结构解耦,设计成独立的类。那么上述代码,要如何拆分解耦呢?

首先 ResourceFile 只表示资源文件的数据结构,需要将业务操作(如 extract2txt 方法)拆分出去。且同一系列的操作最好放到同一个类中。这里可以创建 Extractor 类,专门负责不同资源文件的文字提取操作,代码重构完如下:

//数据结构
abstract class ResourceFile(val filePath: String) {
}

class PDFFile(filePath: String): ResourceFile(filePath) {
}

class PPTFile(filePath: String): ResourceFile(filePath) {
}

class WordFile(filePath: String): ResourceFile(filePath) {
}

//业务操作:提取文字
class Extractor() {
    fun extract2txt(file: PDFFile) {
        println("提取PDF文件中文字信息...")
    }

    fun extract2txt(file: PPTFile) {
        println("提取PPT文件中文字信息...")
    }

    fun extract2txt(file: WordFile) {
        println("提取Word文件中文字信息...")
    }
}

//调用:
fun main() {
    val files = mutableListOf(PDFFile("a.pdf"), PPTFile("b.ppt"), WordFile("c.word"))
    val extractor = Extractor()
    files.forEach { file ->
        //file.extract2txt()  //通过多态特性,调用具体运行时对象的具体方法
        extractor.extract2txt(file) //编译报错,并不能通过重载,调用到对象运行时的具体方法
    }
}

重构后数据结构和业务操作解耦了,如果添加新的业务操作也只需要增添新的业务操作类即可。想法很美好,但在 Java 中这样做是行不通的,在调用 extract2txt 方法时会报编译错误:找不到参数类型为 ResourceFileextract2txt 方法。
在这里插入图片描述
这是因为,Java 语言的语法,只支持 Single Dispatch(单分派)机制。

什么是 Single Dispatch 和 Double Dispatch?

Single Dispatch(单分派) 和 Double Dispatch(双分派) 跟多态函数重载直接相关。多态是一种动态绑定,可以在运行时获取对象的实际类型,来运行实际类型对应的方法。而函数重载是一种静态绑定,在编译时并不能获取对象的实际类型,而是根据声明类型执行声明类型对应的方法。多态表示执行哪个对象的方法,重载表示执行对象的哪个方法。

Single Dispatch 之所以称为“Single”,是因为执行哪个对象的哪个方法,只跟**“对象”的运行时类型有关。Double Dispatch 之所以称为“Double”,是因为执行哪个对象的哪个方法,跟“对象”“方法参数”**两者的运行时类型有关。

从多态角度来看,Single DispatchDouble Dispatch 是相同的:

  • 执行哪个对象的方法,都根据对象的运行时类型来决定。

从函数重载来看,Single DispatchDouble Dispatch 则不同:

  • Single Dispatch:执行对象的哪个方法,根据方法参数的编译时类型来决定。
  • Double Dispatch:执行对象的哪个方法,根据方法参数的运行时类型来决定。

举个例子:

open class Parent() {
    open fun f() {
        println("Parent's f()")
    }
}

class Child(): Parent() {
    override fun f() {
        println("Child's f()")
    }
}

class SingleDispatch() {
    fun overloadFunction(p: Parent) {
        println("SingleDispatchDemo's Parent type function.")
    }

    fun overloadFunction(p: Child) {
        println("SingleDispatchDemo's Child type function.")
    }
}


fun main() {
    //多态
    val p: Parent = Child()
    p.f()   //执行哪个对象的方法,由对象的实际类型决定(运行时决定)

    //重载
    val s = SingleDispatch()
    s.overloadFunction(p)   //执行对象的哪个方法,由参数对象的声明类型决定(编译时决定)
}

//执行结果:
Child's f()
SingleDispatch's Parent type function.

由于 Kotlin 是单分派机制,所以重载代码那里匹配的是 SingleDispatch 的 overloadFunction(p: Parent) 函数,也就是根据 p 的声明类型来决定匹配哪个重载函数,而不是运行时真实类型。

当前主流的面向对象编程语言(比如,Java、C++、C#)都只支持 Single Dispatch,不支持 Double Dispatch。

中介者模式实现

再回到最初的资源文件处理例子,如果语言支持 Double Dispatch,那么重构后的代码直接可以跑通,就无需中介者模式了。但主流编程语言大多还是 Single Dispatch 机制,这就需要中介者模式来处理运行时的函数重载问题。

运行时重载在语言层面时行不通的,那么能否将函数重载问题转换为多态问题呢?当然是可以的,首先在调用处我们无法直接使用声明类型 ResourceFile 来调用 Extractor 中对应子类型的参数方法(单分派语言的限制),但 ResourceFile 子类的内部是知道自己具体类型的,再根据通过多态特性,运行时是知道 ResourceFile 具体类型的,那么只要将访问 Extractor 的位置由调用处移动到 PDFFile、PPTFile 和 WordFile 内部不就可以了,下面来验证一下这个想法是否可行:

//数据结构
abstract class ResourceFile(val filePath: String) {
    abstract fun accept(extractor: Extractor)
}

class PDFFile(filePath: String): ResourceFile(filePath) {
    override fun accept(extractor: Extractor) {
        extractor.extract2txt(this)
    }
}

class PPTFile(filePath: String): ResourceFile(filePath) {
    override fun accept(extractor: Extractor) {
        extractor.extract2txt(this)
    }
}

class WordFile(filePath: String): ResourceFile(filePath) {
    override fun accept(extractor: Extractor) {
        extractor.extract2txt(this)
    }
}

//业务操作:提取文字
class Extractor() {
    fun extract2txt(file: PDFFile) {
        println("提取PDF文件中文字信息...")
    }

    fun extract2txt(file: PPTFile) {
        println("提取PPT文件中文字信息...")
    }

    fun extract2txt(file: WordFile) {
        println("提取Word文件中文字信息...")
    }
}

fun main() {
    val files = mutableListOf(PDFFile("a.pdf"), PPTFile("b.ppt"), WordFile("c.word"))
    val extractor = Extractor()
    files.forEach { file ->
        //file.extract2txt()  //通过多态特性,调用具体运行时对象的具体方法
        //extractor.extract2txt(file) //编译报错,并不能通过重载,调用到对象运行时的具体方法
        file.accept(extractor)  //将访问操作放到数据结构内部,根据多态特性得到真实类型再去调用
    }
}

编译通过,可以看到通过将“运行时执行对象的哪个方法”问题,转换为了 “运行时执行哪个对象的方法”,从而绕过了单分派机制语言的限制,这虽然不是完整的访问者模式,但已包含访问者模式的核心原理了。

下面再根据面向抽象的编程原则,将所有业务操作进一步抽象成一个接口,从而方便业务的灵活扩张,最终代码实现如下:

//数据结构
abstract class ResourceFile(val filePath: String) {
    abstract fun accept(visitor: Visitor)
}

class PDFFile(filePath: String): ResourceFile(filePath) {
    override fun accept(visitor: Visitor) {
        visitor.visit(this)
    }
}

class PPTFile(filePath: String): ResourceFile(filePath) {
    override fun accept(visitor: Visitor) {
        visitor.visit(this)
    }
}

class WordFile(filePath: String): ResourceFile(filePath) {
    override fun accept(visitor: Visitor) {
        visitor.visit(this)
    }
}

//抽象业务操作:访问者
interface Visitor {
    fun visit(file: PDFFile)
    fun visit(file: PPTFile)
    fun visit(file: WordFile)
}


//具体业务操作1:提取文字
class ExtractorVisitor(): Visitor {
    override fun visit(file: PDFFile) {
        println("提取PDF文件中文字信息...")
    }

    override fun visit(file: PPTFile) {
        println("提取PPT文件中文字信息...")
    }

    override fun visit(file: WordFile) {
        println("提取Word文件中文字信息...")
    }
}

//具体业务操作2:压缩文件
class CompressorVisitor(): Visitor {
    override fun visit(file: PDFFile) {
        println("压缩PDF文件内容...")
    }

    override fun visit(file: PPTFile) {
        println("压缩PPT文件内容...")
    }

    override fun visit(file: WordFile) {
        println("压缩Word文件内容...")
    }
}

fun main() {
    val files: MutableList<ResourceFile> = mutableListOf(PDFFile("a.pdf"), PPTFile("b.ppt"), WordFile("c.word"))
    val extractor = ExtractorVisitor()
    val compressor = CompressorVisitor()
    files.forEach { file: ResourceFile ->
        file.accept(extractor)  //业务操作1
        file.accept(compressor) //业务操作2
    }
}

这就是完整的访问者模式,角色定义如下:

  1. Visitor(抽象访问者):声明访问者可以访问哪些元素,具体到程序中就是 visit 方法的参数类型,定义哪些对象是可以被访问的。
  2. ConcreteVisitor(具体访问者):实现Visitor接口中的各个访问操作,使每个操作可以访问并处理被访问对象中的具体类。
  3. Element(抽象元素):声明可以接受哪些类型的访问者方法,通常为accept
  4. ConcreteElement(具体元素)类:实现 Element 接口,并具体实现accept方法,以便在调用该方法时能够接受访问者的访问。
  5. ObjectStructure(对象结构)类:元素产生者,一般容纳在多个不同类、不同接口的容器,如List、Set、Map等,一般很少抽象出这个角色。

访问者通用类图如下:
在这里插入图片描述

访问者应用场景

访问者模式较难理解,使用时可能会导致代码的可读性、可维护性变差,所以,访问者模式在实际的软件开发中很少被用到。如果有以下场景,可以考虑访问者模式:

  • 对复杂对象结构(如多类型的对象集合)中的所有元素执行某些操作,通过访问者为多个目标类提供相同操作的变体,从而在属于不同类的一组对象上执行同一操作。
  • 对象结构相对稳定,但经常需要在此对象结构上定义新的操作。
  • 需要对对象结构中的对象进行复杂的操作时,而这些操作又不适合定义在对象的类中。

总结

总的来说,访问者模式是一种强大的设计模式,它允许在不修改对象结构的情况下增加新的操作,但在使用时需要权衡其带来的复杂性和性能开销。