多态
参考维基百科,多态 (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 的行为,然后在不同的子类型中去实现具体的抽取方法。这个实现较为简单,逻辑清晰。但是可能面对以下问题:
- 如果后续要新增新的抽取逻辑,需要在每个子类型中新增实现
- 将行为和数据存放在一起,日志类型也负责了抽取这个行为的实现,违反了单一职能原则
- 并不是所有的类都是可控的,比如 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 无法在运行时决定调用哪个方法,那就只能苦一苦开发者,让开发者手动指定。将上述代码进行如下修改:
- 将 LogExtractor 修改为泛型,以便后续扩展抽取其他类型数据
- 在 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 子类型中直接实现抽取逻辑在实际的场景中可能反而实现的更多。软件工程没有银弹,我们必须在复杂度和扩展性之间进行平衡。