访问者模式
访问者模式被称为是最复杂的设计模式,比较难理解并且使用频率不高。
在 GoF 的《设计模式》⼀书中,访问者者模式(Visitor Design Pattern)是这么定义的:
Allows for one or more operation to be applied to a set of objects at runtime, decoupling the operations from the object structure.
允许⼀个或者多个操作应⽤到⼀组对象上,解耦操作和对象本⾝。
访问者模式是一种将数据结构和数据操作分离的设计模式,属于行为型模式。
访问者模式的基本思想是: 假设系统中有一个由许多对象构成的对象结构(元素),这些对象的类都提供一个accept()
方法用来接受访问者对象的访问,不同的访问者访问同一对象可以产生不同的数据结果(访问者其实就是一个拥有visit()方法的接口) 。accept()
方法可以接收不同的访问者对象,然后在其内部将自己(元素)转发到访问者对象的visit()
方法内。
访问者模式的核心是解耦数据结构和数据操作,使得对元素的操作具备良好的扩展性。可以通过扩展不同的访问者来实现对同一元素集的不同操作。
如果你的系统中只是对单个对象(类)进行操作或者对多个类进行一种操作,那么就没必要使用访问者模式了。运用访问者模式是为了方便后续扩展操作类型,在对对象集(多个类对象)扩展操作的时候可以不需要修改所有类的代码。
当系统中存在类型数目稳定(固定)的一类数据结构时,可以通过访问者模式方便地实现对该类型所有数据结构的不同操作。
访问者模式类图
主要包含四个角色 :
Visitor 抽象访问者:接口或者抽象类,它定义了对每一个可访问元素(Visitable Element)访问的行为,它的参数就是可以访问的元素,它的方法个数理论上来讲与元素个数(Element的实现类个数)是一样的,从这点不难看出,访问者模式要求元素类的个数不能改变(不能改变的意思是说,如果元素类的个数经常改变,则说明不适合使用访问者模式)。
ConcreteVisitor:具体的访问者,实现对每一个元素的具体操作。
Element 抽象元素:元素接口,它定义了一个接受访问者(accept)的方法,其意义是指,每一个元素都要可以被访问者访问。
ConcreteElement:具体的元素类,它提供接受访问者的具体实现,而这个具体的实现,通常情况下是调用访问者提供的访问该元素类的方法。
代码示例
我们使用访问者模式模拟一个处理不同类型文件的场景
假设我们有三类文件,PDF,EXCEL,WORD,我们需要从三种文件中提取信息存到到自己的系统里(假设需要导入到自己的一个txt文件),然后还需要对三类文件都进行压缩等一系列功能
将不同文件类型定义为抽象元素(这是一个稳定的数据结构), 对文件的操作定义为访问者,代码如下 :
public abstract class ResourceFile {
private String name;
protected ResourceFile(String name) {
this.name = name;
}
abstract void accept(Vistor vistor);
}
public class PDFFile extends ResourceFile {
protected PDFFile(String name) {
super(name);
}
@Override
void accept(Vistor vistor) {
vistor.visit(this);
}
}
public class ExcelFile extends ResourceFile{
protected ExcelFile(String name) {
super(name);
}
@Override
void accept(Vistor vistor) {
vistor.visit(this);
}
}
public class WordFile extends ResourceFile{
public WordFile(String name) {
super(name);
}
@Override
void accept(Vistor vistor) {
vistor.visit(this);
}
}
public interface Vistor {
void visit(PDFFile file);
void visit(ExcelFile file);
void visit(WordFile file);
}
public class CompressionVistor implements Vistor{
@Override
public void visit(PDFFile file) {
System.out.println("压缩pdf文件");
}
@Override
public void visit(ExcelFile file) {
System.out.println("压缩excel文件");
}
@Override
public void visit(WordFile file) {
System.out.println("压缩word文件");
}
}
public class ExtractVistor implements Vistor{
@Override
public void visit(PDFFile file) {
System.out.println("提取pdf文字内容");
}
@Override
public void visit(ExcelFile file) {
System.out.println("提取excel文字内容");
}
@Override
public void visit(WordFile file) {
System.out.println("提取word文字内容");
}
}
public class Test {
private static final List<ResourceFile> resourceFileList = new ArrayList<>();
static {
resourceFileList.add(new PDFFile("设计模式.pdf"));
resourceFileList.add(new ExcelFile("Data.excel"));
resourceFileList.add(new WordFile("笔记.doc"));
}
public static void main(String[] args) {
for (ResourceFile resourceFile: resourceFileList) {
resourceFile.accept(new CompressionVistor());
}
for (ResourceFile resourceFile: resourceFileList) {
resourceFile.accept(new ExtractVistor());
}
}
}
Double Dispatch
静态分派
静态分派(Static Dispatch)就是按照变量的静态类型(变量被声明时的类型)进行分派,从而确定方法的执行版本,静态分派在编译时就可以确定方法的版本,典型例子就是java的方法重载
java在静态分派的时候,我们可以根据多个判断依据(即参数个数和参数类型)判断使用哪个方法,所以java是静态多分派的语言
动态分派
动态分派,不是在编译期确定方法版本,而是在运行时才能确定
Single Dispatch,指的是我们仅仅需要根据对象运行时的类型来决定执行哪个对象的方法
Double Dispatch,指的是我们需要根据对象的运行时类型和参数的运行时类型来决定执行哪个对象的哪个方法 (二者区别主要在于是否可以根据方法参数运行时的类型来判断执行对象的哪个方法)
当前主流的⾯向对象编程语⾔(⽐如,Java、C++、C#)都只⽀持 Single Dispatch,不⽀持 Double Dispatch。
以Java为例,Java⽀持多态,代码可以在运⾏时获得对象的实际类型,然后根据实际类型决定调⽤哪个对象的方法。 Java 也⽀持方法重载,但 Java 设计的方法重载的语法规则是在编译时,根据传递进函数的参数的声明类型,来决定调⽤哪个重载方法。也就是说,具体执⾏哪个对象的哪个⽅法,只跟对象的运⾏时类型有关,跟参数的运⾏时类型⽆关。所以,Java 语⾔是 动态单分派的语言。
我们可以看下具体的例子 :
public class ParentClass {
public void method() {
System.out.println("ParentClass 执行method方法");
}
}
public class SonClass extends ParentClass{
@Override
public void method() {
System.out.println("SonClass 执行method方法");
}
}
public class SingleDispatch {
public void method(ParentClass parentClass) {
parentClass.method();
}
public void print(ParentClass parentClass) {
System.out.println("打印parentClass");
}
public void print(SonClass sonClass) {
System.out.println("打印sonClass");
}
}
public class Test {
public static void main(String[] args) {
ParentClass s = new SonClass();
SingleDispatch singleDispatch = new SingleDispatch();
singleDispatch.method(s);//执⾏哪个对象的⽅法,由对象的实际类型决定(多态)
singleDispatch.print(s);//执⾏对象的哪个⽅法,由参数对象的声明类型决定,这里声明的时ParentClass类型
}
}
动态双分派的语言不需要访问者模式
假设 Java 语⾔⽀持 动态双分派,那么下面的代码就可以编译通过,正常执行了。
public class ExtractExecutor {
public void extract(PDFFile file) {
System.out.println("提取pdf文字内容");
}
public void extract(WordFile file) {
System.out.println("提取word文字内容");
}
public void extract(ExcelFile file) {
System.out.println("提取excel文字内容");
}
}
public static void main(String[] args) {
ExtractExecutor extractExecutor = new ExtractExecutor();
for (ResourceFile resourceFile: resourceFileList) {
//这里会编译报错: Cannot resolve method 'extract(ResourceFile)'
extractExecutor.extract(resourceFile);
}
}
代码会在运⾏时,根据参数(resourceFile)的实际类型(PDFFile、ExcelFile、WordFile),来决定调用extract()
的三个重载方法中的哪⼀个,也就不需要访问者模式了。
访问者模式中的伪动态双分派
所谓的动态双分派就是在运行时根据对象和参数的运行时类型去判断调用哪个一个对象的哪个方法。访问者模式通过进行两次动态单分派来达到这个效果。
for (ResourceFile resourceFile: resourceFileList) {
resourceFile.accept(new ExtractVistor());
}
@Override
void accept(Vistor vistor) {
vistor.visit(this);
}
当调用accept()
方法的时候, 根据resourceFile的实际类型决定调用哪个文件的accept()
方法;
在执行accept()
方法的时候,根据vistor的示例类型来决定调用哪个Vistor的visist方法,此时的this的类型就是这个类的静态类型,这是在编译期就确定的,所以也可以确定是调用的哪个重载方法
通过工厂模式实现上述功能
上述的例子,如果对文件的操作也比较固定,也可以使用工厂模式来实现,定义⼀个包含 extract()
接⼝的Executor接⼝。PdfExtractExecutor、ExcelExtractExecutor、WordExtractExecutor 类实现 Executor接⼝,完成对各自⽂件的⽂本内容抽取。然后再提供一个ExtractExecutorFactory ⼯⼚类根据不同的⽂件类型,返回不同的 Executor。
public abstract class ResourceFile {
private String name;
protected ResourceFile(String name) {
this.name = name;
}
abstract String getType();
}
public class PDFFile extends ResourceFile {
protected PDFFile(String name) {
super(name);
}
@Override
String getType() {
return "PDF";
}
}
public interface Executor {
void extract(ResourceFile file);
}
//省略了WordExtractExecutor,ExcelExtractExecutor的代码
public class PDFExtractExecutor implements Executor{
@Override
public void extract(ResourceFile file) {
System.out.println("提取pdf的内容");
}
}
public class ExtractExecutorFactory {
private static Map<String, Executor> map = new HashMap<>();
static {
map.put("PDF", new PDFExtractExecutor());
// map.put("EXCEL", new ExeclExtractExecutor());
// map.put("WORD", new WordExtractExecutor());
}
public static Executor getExecutor(ResourceFile file) {
return map.get(file.getType());
}
}
访问者模式在源码中的应用
Java 7 版本后,Files 类提供了 walkFileTree() 方法,该方法可以很容易的对目录下的所有文件进行遍历,需要 Path、FileVisitor 两个参数。其中,Path 是要遍历文件的路径,FileVisitor 则可以看成一个文件访问器。源码如下。
package java.nio.file;
public final class Files {
...
public static Path walkFileTree(Path start, FileVisitor<? super Path> visitor)
throws IOException
{
return walkFileTree(start,
EnumSet.noneOf(FileVisitOption.class),
Integer.MAX_VALUE,
visitor);
}
...
}
FileVisitor 提供了递归遍历文件树的支持,这个接口的方法表示了遍历过程中的关键过程,允许在文件被访问、目录将被访问、目录已被访问、发生错误等过程中进行控制。换句话说,这个接口在文件被访问前、访问中和访问后,以及产生错误的时候都有相应的钩子程序进行处理。
FileVisitor 主要提供了 4 个方法,且返回结果的都是 FileVisitResult 对象值,用于决定当前操作完成后接下来该如何处理。FileVisitResult 是一个枚举类,代表返回之后的一些后续操作。
package java.nio.file;
import java.nio.file.attribute.BasicFileAttributes;
import java.io.IOException;
public interface FileVisitor<T> {
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
throws IOException;
FileVisitResult visitFile(T file, BasicFileAttributes attrs)
throws IOException;
FileVisitResult visitFileFailed(T file, IOException exc)
throws IOException;
FileVisitResult postVisitDirectory(T dir, IOException exc)
throws IOException;
}
package java.nio.file;
public enum FileVisitResult {
CONTINUE,
TERMINATE,
SKIP_SUBTREE,
SKIP_SIBLINGS;
}
FileVisitResult 主要包含 4 个常见的操作。
- FileVisitResult.CONTINUE:这个访问结果表示当前的遍历过程将会继续。
- FileVisitResult.SKIP_SIBLINGS:这个访问结果表示当前的遍历过程将会继续,但是要忽略当前文件/目录的兄弟节点。
- FileVisitResult.SKIP_SUBTREE:这个访问结果表示当前的遍历过程将会继续,但是要忽略当前目录下的所有节点。
- FileVisitResult.TERMINATE:这个访问结果表示当前的遍历过程将会停止。
通过访问者去遍历文件树会比较方便,比如查找文件夹内符合某个条件的文件或者某一天内所创建的文件,这个类中都提供了相对应的方法。它的实现也非常简单,代码如下
public class SimpleFileVisitor<T> implements FileVisitor<T> {
protected SimpleFileVisitor() {
}
@Override
public FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
throws IOException
{
Objects.requireNonNull(dir);
Objects.requireNonNull(attrs);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(T file, BasicFileAttributes attrs)
throws IOException
{
Objects.requireNonNull(file);
Objects.requireNonNull(attrs);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(T file, IOException exc)
throws IOException
{
Objects.requireNonNull(file);
throw exc;
}
@Override
public FileVisitResult postVisitDirectory(T dir, IOException exc)
throws IOException
{
Objects.requireNonNull(dir);
if (exc != null)
throw exc;
return FileVisitResult.CONTINUE;
}
}
一开始觉得这里的设计比较多余,后来仔细想了下,在不同场景下我们对文件树的遍历要求是不一样的,通过访问者模式,用户可以方便的定义自己的遍历操作
比方说在JavacPathFileManager里就重写了preVisitDirectory
和visitFile
方法
Files.walkFileTree(packageDir, opts, maxDepth,
new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
Path name = dir.getFileName();
if (name == null || SourceVersion.isIdentifier(name.toString())) // JSR 292?
return FileVisitResult.CONTINUE;
else
return FileVisitResult.SKIP_SUBTREE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.isRegularFile() && kinds.contains(getKind(file.getFileName().toString()))) {
JavaFileObject fe =
PathFileObject.createDirectoryPathFileObject(
JavacPathFileManager.this, file, pathDir);
results.append(fe);
}
return FileVisitResult.CONTINUE;
}
});
总结
简单来说,访问者模式就是封装一些作用于某种数据结构中的各元素的操作,它可以在不改变这个数据结构的前提下定义作用于这些元素的新操作。
访问者模式适用场景 :
- 数据结构稳定,但是作用于数据结构的操作经常变化
- 需要数据结构与数据操作分离
- 需要对不同数据类型(元素)进行操作,但是有不使用
if.. else ..
判断具体类型
优点:
- 使得数据结构和作用于结构上的操作解耦,使得操作集合可以独立变化。
- 扩展性好,添加新的操作或者说访问者会非常容易。
缺点:
- 增加新的元素类型会非常困难,每次新增元素类型,则访问者类必须增加对应元素类型的操作
- 违反了依赖倒置原则,访问者依赖的是具体元素类型,而不是抽象
- 变更元素的属性可能会导致对应的访问者类也需要修改