多态、双分派与访问者模式

多态

参考维基百科,多态 (polymorphism)指为不同数据类型的实体提供统一的接口[1],或使用一个单一的符号来表示多个不同的类型[2]。例如,方法重载就是多态的一种(也称为特设多态, ad hoc polymorphism),支持这种多态的语言可以定义同名,但不同参数列表的方法。调用时根据参数的类型来选择对应的方法进行调用,例如:

java 复制代码
public static int add(Integer a, Integer b){
    return a + b;
}

public static String add(String a, String b){
    return a.concat(b);
}

public static void main(String[] args) {
    System.out.println(add(1, 2)); // 选择第一个 add 方法
    System.out.println(add("hello", "world")); // 选择第二个 add 方法
}

这也达成了为不同的数据类型提供统一的接口这一目的。

还有一种常见的多态是子类型多态,即是通过继承/实现一个父类或接口的方式,构成多个子类型,每个子类型有不同的行为,但同名的方法,例如:

java 复制代码
interface Animal{
    void say();
}

class Dog implements Animal{

    @Override
    public void say() {
        System.out.println("旺旺");
    }
}

class Cat implements Animal{

    @Override
    public void say() {
        System.out.println("喵喵");
    }
}

public static void saySomething(Animal animal){
    animal.say();
}

Animal cat = new Cat();
Animal dog = new Dog();
saySomething(cat); // 喵喵
saySomething(dog); // 旺旺

可以看出,虽然 saySomething 的方法签名类型为Animal,但最终是根据入参的实际类型(Dog或Cat)来调用对应的 say 方法。

静态多态与动态多态

在上面提到的方法重载子类型都是实现多态的一种方式。但这里必须强调一下这两种方法的不同之处,方法重载是在编译时生效,因此又被称之为静态多态。而子类型则是在运行时生效,称之为动态多态。对于参数重载,我们无法写入下面的代码:

java 复制代码
Object s1 = "123";
Object s2 = "234";
add(s1, s2);

这段代码会提示我们找不到 add(Object, Object) 这样的方法,因为方法重载必须在编译时寻找到参数签名完全一致的方法 ,否则就无法选择到底调用哪个方法。很多人可能会说,s1, s2 这么明显都是 String,明明可以从编译期推断出来,编译器为什么不能智能一点帮我们匹配上 add(String, String) 这个方法呢?因为上面的例子只是一个特例,而编译器完全可能面对下面的代码:

java 复制代码
Object s1 = randInt(10) > 5 ? 1: "123";
Object s2 = randInt(10) > 5 ? 2: "456";
add(s1, s2);

可以看出,s1 与 s2 的类型需要运行时才能决定,因此方法重载无法处理这种情况。

而与之相对的,子类型多态则是在运行时才调用哪个对象的法,例如下面的代码是可以正常运行的:

java 复制代码
// 必须要到运行时才能确定 Animal 的真正类型是 Dog 还是 Cat
Animal animal = randInt(10) > 5 ? new Cat(): new Dog();
saySomething(animal);

单分派与双分派

首先,分派可以理解为实现多态的过程。例如对于方法重载,其根据参数选择正确的方法这一过程就可以称之为分派。 像 java, c++ 原生只支持单分派,即要么在编译时通过方法重载来实现多态 ,要么在运行时通过子类型来实现多态。而在一些场景下,单分派并不能完全解决问题。考虑这样的一个场景:有不同格式的日志,比如 xml, json 或者是 plain text 格式,现在希望能从这日志中抽取一个统一格式的日志。一个简单的实现为:

java 复制代码
// 统一 log 类
class UniversalLog{

}
// log 接口
interface ILog{
    UniversalLog extractUniversalLog();
}

class XmlLog implements ILog{
    
    @Override
    public UniversalLog extractUniversalLog() {
        return null;
    }
}

class JsonLog implements ILog{

    @Override
    public UniversalLog extractUniversalLog() {
        return null;
    }
}

class PlainTextLog implements ILog{

    @Override
    public UniversalLog extractUniversalLog() {
        return null;
    }
}

通过定义一个 interface 来定义抽取 log 的行为,然后在不同的子类型中去实现具体的抽取方法。这个实现较为简单,逻辑清晰。但是可能面对以下问题:

  1. 如果后续要新增新的抽取逻辑,需要在每个子类型中新增实现
  2. 将行为和数据存放在一起,日志类型也负责了抽取这个行为的实现,违反了单一职能原则
  3. 并不是所有的类都是可控的,比如 XmlLog 完全有可能是第三方库中的类型。

另一个方案是定义一个 LogExtractor 类来完成抽取工作,例如:

java 复制代码
class LogExtractor{
    public UniversalLog extract(XmlLog log){
        return null;
    }
    public UniversalLog extract(JsonLog log){
        return null;
    }
    public UniversalLog extract(PlainTextLog log){
        return null;
    }
}
LogExtractor logExtractor = new LogExtractor();
logExtractor.extract(new XmlLog());
logExtractor.extract(new JsonLog());
logExtractor.extract(new PlainTextLog());

可以看出,这里使用了方法重载来实现多态来完成抽取逻辑。但是现实场景中,log 的类型往往不能在编译时就决定,因为其往往是从文本解析出来的,因此代码可能是这样:

java 复制代码
        LogExtractor logExtractor = new LogExtractor();
        LogParser logParser = new LogParser();
        List<File> fileList = new ArrayList<>();
        
        // some code to visit file system and generate fileList
        // 从文本中解析日志
        List<ILog> logs = fileList.stream().map(logParser::parse).collect(Collectors.toList());
        for(ILog log: logs){
            logExtractor.extract(logs); // Cannot resolve method 'extract(List<ILog>)'
        }

此时,由于 log 的类型是运行时决定的,因此这种写法在 java 中无法正常工作。其本质是 java 无法支持双分派,无法在运行时通过类型决定调用哪个方法

访问者模式

为了克服这种缺点,访问者模式就诞生了。既然 java 无法在运行时决定调用哪个方法,那就只能苦一苦开发者,让开发者手动指定。将上述代码进行如下修改:

  1. 将 LogExtractor 修改为泛型,以便后续扩展抽取其他类型数据
  2. 在 Log 类中新增了 accept 方法,用于 显式调用相对应的 extract 方法
java 复制代码
interface ILog{
    <T> T accept(LogExtractor<T> logExtractor);
}

class XmlLog implements ILog{

    @Override
    public <T> T accept(LogExtractor<T> logExtractor)  {
        return logExtractor.extractFromXml(this); // XmlLog 就手动调用 extractFromXml
    }
}

class JsonLog implements ILog{

    @Override
    public <T> T accept(LogExtractor<T> logExtractor)  {
        return logExtractor.extractFromJson(this);
    }
}

class PlainTextLog implements ILog{

    @Override
    public <T> T accept(LogExtractor<T> logExtractor)  {
        return logExtractor.extractFromPlainText(this);
    }
}

class LogExtractor<T>{
    public T extractFromXml(XmlLog log){
        return null;
    }
    public T extractFromJson(JsonLog log){
        return null;
    }
    public T extractFromPlainText(PlainTextLog log){
        return null;
    }
}

LogExtractor<UniversalLog> logExtractor = new LogExtractor<>();
LogParser logParser = new LogParser();
List<File> fileList = new ArrayList<>();

// some code to visit file system and generate fileList

List<ILog> logs = fileList.stream().map(logParser::parse).collect(Collectors.toList());
for(ILog log: logs){
    log.accept(logExtractor);
}

没错,这就是一个访问者模式的实例。上面提到,访问者模型模拟了双分派,那么这个 到底体现在哪里。首先,在代码 log.accept(logExtractor) 会根据 log 实际类型来调用对应子类型的 accept 方法,这里是一次分派。

java 复制代码
for(ILog log: logs){
    log.accept(logExtractor);
}

而在子类的 accept 代码中,手动的调用了 logExtractor 的 extractFromXml。这里是第二次分派,即在运行时根据参数类型来调用对应的方法(虽然是手动的)。

java 复制代码
    @Override
    public <T> T accept(LogExtractor<T> logExtractor)  {
        return logExtractor.extractFromXml(this); // XmlLog 就手动调用 extractFromXml
    }

当然,我们还可以稍微模拟的再像一点,既然 java 支持重载,那么 LogExtractor 完全可以改为:

java 复制代码
class LogExtractor<T>{
    public T extract(XmlLog log){
        return null;
    }
    public T extract(JsonLog log){
        return null;
    }
    public T extract(PlainTextLog log){
        return null;
    }
}

而在对应的 accept 方法里面,也可以改为:

java 复制代码
class XmlLog implements ILog{

    @Override
    public <T> T accept(LogExtractor<T> logExtractor)  {
        return logExtractor.extract(this);
    }
}

class JsonLog implements ILog{

    @Override
    public <T> T accept(LogExtractor<T> logExtractor)  {
        return logExtractor.extract(this);
    }
}

class PlainTextLog implements ILog{

    @Override
    public <T> T accept(LogExtractor<T> logExtractor)  {
        return logExtractor.extract(this);
    }
}

这样三个方法都可以直接调用 extract 方法,而不是 extractXXX

总结

由于 java 不支持双分派,因此通过访问者模式来模拟。只不过并不是所有的场景都需要用到双分派。一开始提到的在 Log 子类型中直接实现抽取逻辑在实际的场景中可能反而实现的更多。软件工程没有银弹,我们必须在复杂度和扩展性之间进行平衡。

相关推荐
二闹5 分钟前
三个注解,到底该用哪一个?别再傻傻分不清了!
后端
用户490558160812516 分钟前
当控制面更新一条 ACL 规则时,如何更新给数据面
后端
林太白18 分钟前
Nuxt.js搭建一个官网如何简单
前端·javascript·后端
码事漫谈20 分钟前
VS Code 终端完全指南
后端
该用户已不存在1 小时前
OpenJDK、Temurin、GraalVM...到底该装哪个?
java·后端
怀刃1 小时前
内存监控对应解决方案
后端
码事漫谈1 小时前
VS Code Copilot 内联聊天与提示词技巧指南
后端
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞 (上):编译前端实现
后端·算法·编程语言
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞(下):llvm IR 代码生成
后端·程序员·代码规范
Moonbit2 小时前
MoonBit Pearls Vol.05: 函数式里的依赖注入:Reader Monad
后端·rust·编程语言