ProGuard 进阶系列(四)访问者模式

在进行后面的内容分析之前,不得不讲到访问者模式,这是 GOF 23 个设计模式中最难的几个模式之一。如果能够很好的理解访问者模式, 后续源码解读会相对容易一些。本文将结合 ProGuard 的部分源码,理解分析访问者模式的用途及使用场景。

一、什么是访问者模式

先来看定义,访问者模式是一种将对象操作算法与对象结构分离的设计模式。这句话很抽象,不是很好理解。用通俗的话来讲就是:我们在写代码的时候,一般情况下,会将对象的操作算法定义此对象内部,直接通过对象的某个方法去获取信息或进行对象操作,当有新需求变更时,就需要对此对象的代码进行修改,来满足新的需求。但在访问者模式中,会将对象结构固定下来,并且后续不在对其修改。面对新的需求时,不需要去修改对象结构,通过添加新的访问者去实现对象操作。访问者模式遵从「开闭原则」。当然,并不是所有场景都适合使用访问者模式,一般使用访问者模式的场景如下:

  1. 对象结构比较稳定,但经常需要在此对象结构上定义新的操作。 如 Class 文件,它的结构基本不会修改。
  2. 需要对一个对象结构中的对象进行很多不同且不相关的操作,为避免这些操作“污染”这些对象的类,也不希望在增加新操作时修改这些类。 如读取 Class 文件中的方法、属性等信息。

在来看一下访问者模式的类图:

访问者模式类图

介绍一下,类图中最重要的两个角色:

  • Visitor:接口或者抽象类,定义对 Element 的访问行为,visit 方法的参数为要访问的 Element。理论上,它的方法数与要访问的 Element 个数一致。因此,访问者模式要求的类型要稳定,如果经常添加、移除 Element,必然会导致频繁地修改 Visitor 接口,如果出现这种情况,则说明不适合使用访问者模式。

  • Element:元素接口或者抽象类,它定义了一个接受访问者(accept)的方法,其意义是指每一个元素都要可以被访问者访问。

看到这里,依然很抽象,但是我相信应该有一个大体的了解,为了更好的理解这个模式,下面将从 ProGuard中对 class 文件的部分操作,还原访问者模式的诞生过程。

二、访问者模式的诞生过程

要想更好的理解访问者模式,还是得从真实案例入手。在 ProGuard 中, 主要操作的是 Class 文件,而 Class 文件的格式非常固定,Oracle 文档中,对 Class 文件定义如下:

ClassFile 的定义

并且在 ProGuard 的执行过程中,可能会源源不断的增加对 ClassFile 的操作。 这就很好的对应上了使用访问者模式的两个条件。

先来思考一下,在 ProGuard 中,对 Class 文件的操作有哪些?

  1. 从文件中读取 Class 的内容,并填充到 ProgramClass 对象中
  2. ProgramClass 对象进行修改,如修改类名、变量名、方法名、方法参数类型等
  3. ProgramClass 对象的信息写入到 Class 文件

在这里,看一下从文件中读取 Class 的内容,根据定义,在 ClassFile 中,会有一个常量池,常量池中可能会有各种不同的常量, 如 IntegerConstantFloatConstantStringConstantUtf8Constant 等。现在要实现从 Class 文件中,读取这些信息出来。如果让你来实现,你会怎么做呢?

实现这个功能并不难,不同的人有不同的写法,我将其中一种实现方式代码贴在下面。其中,Constant 是一个接口,包含一个方法 readFromResource

abstract class Constant {
    abstract void readFromResource(DataInput dataInput) throws Exception;
}

class IntegerConstant extends Constant {
    public int u4value;

    @Override
    void readFromResource(DataInput dataInput) throws Exception {
        this.u4value = dataInput.readInt();
    }
}

class Utf8Constant extends Constant {
    public byte[] bytes;
    @Override
    void readFromResource(DataInput dataInput) throws Exception {
        int u2length = dataInput.readUnsignedShort();
        System.out.println(u2length);
        byte[] bytes = new byte[u2length];
        dataInput.readFully(bytes);
        this.bytes = bytes;
    }
}

public class VisitorV1 {
    public static void main(String[] args) throws Exception {
        File clazzFile = new File("visit.class");
        DataInput dataInput = new DataInputStream(new FileInputStream(clazzFile));
        for (Constant constant : listAllConstant()) {
            constant.readFromResource(dataInput);
            System.out.println(constant);
        }
    }
    public static List<Constant> listAllConstant() {
        List<Constant> list = new ArrayList<>();
        list.add(new IntegerConstant());
        list.add(new Utf8Constant());
        return list;
    }
}

针对这些 Constant ,我们不仅需要读取,还需要修改、写文件等逻辑。如果继续按照这个思路写下去,我们需要在添加 modifysave 等方法,这将带来一些新的问题:

  • 违背开闭原则,添加新功能,所有类的代码都需要进行更改。
  • 随着功能增多,每个类的代码也会不断膨胀,可读性和可维护性都会变差
  • 上层的业务逻辑与具体的类耦合在一起,会导致类的职责不单一

要解决这个问题,常用的解决方法就是拆分解藕,把业务操作与数据结构进行解藕,设计成独立的类。这里我们按照访问者模式的演进思路对代码进行重构,重构后代码如下:

abstract class Constant {
}

class IntegerConstant extends Constant {
    public int u4value;
}

class Utf8Constant extends Constant {
    public byte[] bytes;
}


class Reader {
    public DataInput dataInput;

    public Reader(DataInput dataInput) {
        this.dataInput = dataInput;
    }

    public void readFromResource(IntegerConstant constant) throws Exception {
        constant.u4value = dataInput.readInt();
    }

    public void readFromResource(Utf8Constant constant) throws Exception {
        int u2length = dataInput.readUnsignedShort();
        byte[] bytes = new byte[u2length];
        dataInput.readFully(bytes);
        constant.bytes = bytes;
    }
}

public class VisitorV2 {
    public static void main(String[] args) throws Exception {
        File clazzFile = new File("visit.class");
        DataInput dataInput = new DataInputStream(new FileInputStream(clazzFile));
        Reader reader = new Reader(dataInput);
        for (Constant constant : listAllConstant()) {
            reader.readFromResource(constant);
            System.out.println(constant);
        }
    }

    public static List<Constant> listAllConstant() {
        List<Constant> list = new ArrayList<>();
        list.add(new IntegerConstant());
        list.add(new Utf8Constant());
        return list;
    }
}

这其中最关键的一点设计是, 我们将读取不同类型常量的操作,设计成了三个重载函数。众所周知,重载函数是指在同一个类中函数名相同、参数不同的一组函数。

针对重构后的代码,如果你足够细心,就会发现上面的代码,其实是无法编译通过。

reader.readFromResource(constant);

此行代码编译会报错,报错如下:

编译报错信息

这是为什么呢?

要解释这个问题,先说一下 Java 中的多态,它是一种动态绑定,在运行时可以获取对象的具体类型,然后在运行其实际类型对应的方法。我们就是希望代码在运行的时候,能够像多态那样,在运行时能够根据参数类型去调不同的 readFromResource 方法。然而在代码实现中使用的是函数重载,它是一种静态绑定。代码在编译时并不能识别对象的具体类型,因此上面代码编译时就会出现「找不到合适的方法」这个错误。

解决办法就是访问者模式的精髓,先来看一下代码:

abstract class Constant {
    public abstract void accept(Reader reader) throws Exception;
}

class IntegerConstant extends Constant {
    public int u4value;

    @Override
    public void accept(Reader reader) throws Exception {
        reader.readFromResource(this);
    }
}

class Utf8Constant extends Constant {
    public byte[] bytes;

    @Override
    public void accept(Reader reader) throws Exception {
        reader.readFromResource(this);
    }
}

// 与前面 Reader 一样

public class VisitorV3 {
    public static void main(String[] args) throws Exception {
        File clazzFile = new File("visit.class");
        DataInput dataInput = new DataInputStream(new FileInputStream(clazzFile));
        Reader reader = new Reader(dataInput);
        for (Constant constant : listAllConstant()) {
            constant.accept(reader);
            System.out.println(constant);
        }
    }
}

在执行 constant.accept(reader) 的时候,根据 Java 的多态性,在运行时,就会调用实际类型的 accept 函数, 比如 Utf8Constantaccept 函数,而此时在 Utf8Constant 中会调用 ReaderreadFromResource 方法,此处的参数 this 类型就是 Utf8Constant ,在编译时就确定了,所以会调用 readFromResource(Utf8Constant constant) 这个重载函数。

如果,你看懂了这个,就基本已经理解了访问者模式。现在,我们继续添加新的功能, 例如我需要对 Constant 进行修改或重新写入新的文件。此时,实现可以参考前面的 Reader ,实现新的 ModifierWriter,并且给这两个类分别定义二个重载方法,以实现对不同 Constant 进行操作。当然,还需要在 Constant 中添加新的 accept 方法,具体实现如下:

abstract class Constant {
    public abstract void accept(Reader reader) throws Exception;
    public void accept(Writer writer) throws Exception;
}

class IntegerConstant extends Constant {
    public int u4value;

    @Override
    public void accept(Reader reader) throws Exception {
        reader.readFromResource(this);
    }
    @Override
    public void accept(Writer writer) throws Exception {
        writer.writeToResource(this);
    }
}
// ... 省略部分代码 

class Writer {
    public DataOutput dataOutput;

    public Writer(DataOutput dataOutput) {
        this.dataOutput = dataOutput;
    }

    public void writeToResource(IntegerConstant constant) throws Exception {
                dataOutput.writeInt(constant.u4value);
    }

    public void readFromResource(Utf8Constant constant) throws Exception {
          dataOutput.writeShort(constant.bytes.length);
                dataOutput.write(constant.bytes);
    }
}
// ... 省略测试入口

不同的操作已经抽象到不同的实现类中去了,但是每一次添加新的功能,都需要去修改对应的资源文件,不仅工作量巨大,而且违反了开闭原则。针对这个问题,可以回到文件的那个类图,我们可以设计一个 Visitor 的接口,并在接口中定义访问这些不同常量的 visit 方法,按照这个思路,重构后的代码示例如下:

abstract class Constant {
    public abstract void accept(Visitor visitor) throws Exception;
}

class IntegerConstant extends Constant {
    public int u4value;

    @Override
    public void accept(Visitor visitor) throws Exception {
        visitor.visit(this);
    }
}

class Utf8Constant extends Constant {
    public byte[] bytes;

    @Override
    public void accept(Visitor visitor) throws Exception {
        visitor.visit(this);
    }
}

interface Visitor {
    void visit(IntegerConstant constant) throws Exception;

    void visit(Utf8Constant constant) throws Exception;
}

class Reader implements Visitor {
    public DataInput dataInput;

    public Reader(DataInput dataInput) {
        this.dataInput = dataInput;
    }

    public void visit(IntegerConstant constant) throws Exception {
        constant.u4value = dataInput.readInt();
    }

    public void visit(Utf8Constant constant) throws Exception {
        int u2length = dataInput.readUnsignedShort();
        byte[] bytes = new byte[u2length];
        dataInput.readFully(bytes);
        constant.bytes = bytes;
    }
}

class Writer implements Visitor {

    public DataOutput dataOutput;

    public Writer(DataOutput dataOutput) {
        this.dataOutput = dataOutput;
    }

    @Override
    public void visit(IntegerConstant constant) throws Exception {
        dataOutput.writeInt(constant.u4value);
    }

    @Override
    public void visit(Utf8Constant constant) throws Exception {
        dataOutput.writeShort(constant.bytes.length);
        dataOutput.write(constant.bytes);
    }
}

三、总结

从上面例子中代码迭代中,应该能够很好地理解访问者模式了。访问者模式在日常开发工作中,很少会用到。访问者模式实现了一个或多个操作应用到一组对象上,设计意图是解耦操作与对象本身,保持类职责单一,满足开闭原则以应对直接修改代码带来的复杂性。

因为访问者模式实现非常的不好理解,在项目中使用此模式会导致代码的可读性降低。所以在项目中是否需要使用此模式,需要谨慎评估。

最后,ASM 以及 ProGuard 中对 Class 文件操作都很好地应用了访问者模式,有兴趣可以深度理解一下其源码实现,相信有不一样的收获。