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

多态

参考维基百科,多态 (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 子类型中直接实现抽取逻辑在实际的场景中可能反而实现的更多。软件工程没有银弹,我们必须在复杂度和扩展性之间进行平衡。

相关推荐
追逐时光者21 分钟前
C#/.NET/.NET Core拾遗补漏合集(25年4月更新)
后端·.net
FG.25 分钟前
GO语言入门
开发语言·后端·golang
转转技术团队1 小时前
加Log就卡?不加Log就瞎?”——这个插件治好了我的精神
java·后端
谦行2 小时前
前端视角 Java Web 入门手册 5.5:真实世界 Web 开发——控制反转与 @Autowired
java·后端
uhakadotcom2 小时前
PyTorch 2.0:最全入门指南,轻松理解新特性和实用案例
后端·面试·github
bnnnnnnnn2 小时前
前端实现多服务器文件 自动同步宝塔定时任务 + 同步工具 + 企业微信告警(实战详解)
前端·javascript·后端
DataFunTalk2 小时前
乐信集团副总经理周道钰亲述 :乐信“黎曼”异动归因系统的演进之路
前端·后端·算法
DataFunTalk2 小时前
开源一个MCP+数据库新玩法,网友直呼Text 2 SQL“有救了!”
前端·后端·算法
idMiFeng2 小时前
通过GO后端项目实践理解DDD架构
后端
LemonDu2 小时前
Cursor入门教程-JetBrains过度向
人工智能·后端