Published on

OOP vs FP:用 Visitor 模式克服 OOP 的局限

Authors
  • avatar
    Name
    Mu Xian Ming
    Twitter

OOP vs FP

在把一个业务领域的模型分解成各自的编程范式的最小单位的时候,过程/函数式的方式和面向对象的方式采用的是不同的甚至相反的途径。具体采用哪种,一是取决于个人喜好,二是得依据对程序未来扩展维度的预测。用一个简单的算术运算程序来举例:

  • 程序需要应对不同的表达式,如整型数值Int,负数Negate以及相加Add
  • 针对每种表达式有不同的操作,如对表达式求值eval,将表达式转为字符串toString和判断表达式是否含有零hasZero

这个问题可以用一个二维表格来表示,每个单元代表的是不同的表达式与操作之间的组合:

evaltoStringhasZero
Int
Add
Negate

无论采用哪种编程范式都需要考虑如何去实现每个单元中的逻辑。

采用函数式的途径

在所谓的函数式语言中,通常的解决方案如下:

  • 为不同的表达式定义相应的数据类型
  • 对应不同的操作类型定义所需的函数
  • 每个函数由多个分支组成,每个分支对应于不同的数据类型(通常用 pattern-matching 来实现)

下面是 OCaml 的实现。

exception BadResult of string

type exp =
    Int    of int
  | Negate of exp
  | Add    of exp * exp

let rec eval e =
  match e with
      Int _      -> e
    | Negate e1  -> (match eval e1 with
                        Int i -> Int (-i)
                      | _ -> raise (BadResult "non-int in negation"))
    | Add(e1,e2) -> (match (eval e1, eval e2) with
                        (Int i, Int j) -> Int (i+j)
                      | _ -> raise (BadResult "non-ints in addition"))

let rec toString = function
    Int i      -> string_of_int i
  | Negate e1  -> "-(" ^ (toString e1) ^ ")"
  | Add(e1,e2) -> "(" ^ (toString e1) ^ " + " ^ (toString e2) ^ ")"

let rec hasZero = function
    Int i      -> i = 0
  | Negate e1  -> hasZero e1
  | Add(e1,e2) -> (hasZero e1) || (hasZero e2)
;;
toString (eval (Add ((Negate (Int 5)), (Int 6))))
(* - : string = "1" *)

第 3 到 6 行集中定义了所有的数据类型,剩下的部分是所有的函数实现,最后是一个简单的测试。通过这段代码可以看出前述表格中的 9 个单元是按列来封装,每一列对应一个函数。

采用面向对象的途径

在面向对象的语言中,通常会采用如下模式:

  • 定义一个接口或抽象类来代表所有数据类型的父类型,其中每个抽象方法对应一个操作
  • 对应不同的数据类型定义不同的子类
  • 在子类中实现每个操作对应的方法,可以在多个类共用的方法放到抽象类或接口里实现

下面是 Java 的实现:

public final class SimpleExpressions {
    static class BadResultException extends RuntimeException {
        private static final long serialVersionUID = -7471855055854681068L;
        BadResultException(String s) { super(s); }
    }

    interface Exp {
        Exp eval();
        boolean hasZero();
        @Override
        String toString();
    }

    static class Int implements Exp {
        final int value;
        Int(int value) { this.value = value; }

        @Override
        public Exp eval() {
            return this;
        }
        @Override
        public boolean hasZero() {
            return value == 0;
        }
        @Override
        public String toString() {
            return String.valueOf(value);
        }
    }

    static class Negate implements Exp {
        final Exp exp;
        Negate(Int exp) { this.exp = exp; }

        @Override
        public Exp eval() {
            try {
                Int intExp = (Int) exp.eval();
                return new Int(-intExp.value);
            } catch (ClassCastException cce) {
                throw new BadResultException("non-int in negation");
            }
        }
        @Override
        public boolean hasZero() {
            return exp.hasZero();
        }
        @Override
        public String toString() {
            return "-(" + exp.toString() + ")";
        }
    }

    static class Add implements Exp {
        final Exp e1;
        final Exp e2;
        Add(Exp e1, Exp e2) { this.e1 = e1; this.e2 = e2; }

        @Override
        public Exp eval() {
            try {
                Int i1 = (Int) e1.eval();
                Int i2 = (Int) e2.eval();
                return new Int(i1.value + i2.value);
            } catch (ClassCastException e) {
                throw new BadResultException("non-ints in addition");
            }
        }
        @Override
        public boolean hasZero() {
            return e1.hasZero() || e2.hasZero();
        }
        @Override
        public String toString() {
            return "(" + e1.toString() + ") + (" + e2.toString() + ")";
        }
    }

    public static void main(String[] args) {
        System.out.println(new Add(new Negate(new Int(5)), new Int(6)).eval());
    }
}

这一次 9 个单元变成按行实现,每个类就是表格中的一行。

扩展现有程序

从这个例子可以看出来,针对由数据类型和操作组成的表格,函数式和面向对象的编程模式分别围绕列和行来实现程序中的基本单位(函数和类)。这也造成了两种模式在程序扩展性上的差异。假设需要在现有程序中增加一个用Negate替代常量中负号的操作noNegConstants,在函数式模式中不需要更改现有的代码,只需简单的增加一个函数:

let rec noNegConstants = function
      Int i      -> if i < 0 then Negate(Int(-i)) else e
    | Negate e1  -> Negate(noNegConstants e1)
    | Add(e1,e2) -> Add(noNegConstants e1, noNegConstants e2)

如果要增加的是一个新的乘法数据类型Mult,那么在面向对象的模式中也无需更改旧有程序,只增加一个新的类就可以:

static class Mult implements Exp {
    final Exp e1;
    final Exp e2;
    Mult(Exp e1, Exp e2) { this.e1 = e1; this.e2 = e2; }

    @Override
    public Exp eval() {
        try {
            Int i1 = (Int) e1.eval();
            Int i2 = (Int) e2.eval();
            return new Int(i1.value * i2.value);
        } catch (ClassCastException e) {
            throw new BadResultException("non-ints in addition");
        }
    }
    @Override
    public boolean hasZero() {
        return e1.hasZero() || e2.hasZero();
    }
    @Override
    public String toString() {
        return "(" + e1.toString() + ") * (" + e2.toString() + ")";
    }
}

而在相反的情况下,向函数式模式的程序中增加新的数据类型或面向对象模式中增加新的操作都是一件痛苦的事。前者需要修改所有的函数,后者则是修改所有的类。所以一些“设计模式“就应运而生来克服这些扩展上的局限性。对于函数式模式来说,可以在类型定义中增加一个“其他”的类型,然后所有的函数都接受一个额外的函数类型的参数来处理“其他”的数据类型:

type ’a ext_exp =
    Int    of int
  | Negate of ’a ext_exp
  | Add    of ’a ext_exp * ’a ext_exp
  | OtherExtExp  of ’a

let rec eval_ext (f, e) =
  match e with
      Int i -> i
    | Negate e1 -> 0 - (eval_ext (f, e1))
    | Add (e1, e2) -> (eval_ext (f, e1)) + (eval_ext (f, e2))
    | OtherExtExp e -> f e

而对于 OOP 来说就可以使用本文要讨论的 Visitor 模式。

Visitor 模式

Visitor 模式可以看作是在通常的面向对象模式的基础上进一步加工,把各个数据类型类里的代表相同操作的方法封装到一个 Visitor 类,这些 Visitor 本质上就是函数式模式里的函数,使得在面向对象模式中也可以按“列”来分解,这样也就可以让程序更容易的在操作维度上扩展。前文的例子如果使用 Visitor 模式,可以这样来实现:

public final class SimpleExpression {
    static class BadResultException extends RuntimeException {
        private static final long serialVersionUID = -7471855055854681068L;
        BadResultException(String s) { super(s); }
    }

    interface Exp {
        <T> T accept(ExpVisitor<T> ask);
    }

    interface ExpVisitor<T> {
        T forInt(int value);
        T forNegate(Int intExp);
        T forAdd(Exp e1, Exp e2);
    }

    static class Int implements Exp {
        final int value;
        Int(int value) { this.value = value; }

        @Override
        public <T> T accept(ExpVisitor<T> ask) {
            return ask.forInt(value);
        }
    }

    static class Negate implements Exp {
        final Exp e;
        Negate(Exp e) { this.e = e; }

        @Override
        public <T> T accept(ExpVisitor<T> ask) {
            return ask.forNegate(e);
        }
    }

    static class Add implements Exp {
        final Exp e1;
        final Exp e2;
        Add(Exp e1, Exp e2) {
            this.e1 = e1;
            this.e2 = e2;
        }

        @Override
        public <T> T accept(ExpVisitor<T> ask) {
            return ask.forAdd(e1, e2);
        }
    }

    static class EvalVisitor implements ExpVisitor<Exp> {
        @Override
        public Exp forInt(int value) {
            return new Int(value);
        }

        @Override
        public Exp forNegate(Exp exp) {
            Int intExp = (Int) exp.accept(this);
            try {
                return new Int(-intExp.value);
            } catch (ClassCastException cce) {
                throw new BadResultException("non-int in negation");
            }
        }

        @Override
        public Exp forAdd(Exp e1, Exp e2) {
            try {
                Int i1 = (Int) e1.accept(this);
                Int i2 = (Int) e2.accept(this);
                return new Int(i1.value + i2.value);
            } catch (ClassCastException e) {
                throw new BadResultException("non-ints in addition");
            }
        }
    }

    static class HasZeroVisitor implements ExpVisitor<Boolean> {
        @Override
        public Boolean forInt(int value) {
            return value == 0;
        }

        @Override
        public Boolean forNegate(Exp e) {
            return e.accept(this);
        }

        @Override
        public Boolean forAdd(Exp e1, Exp e2) {
            return e1.accept(this) || e2.accept(this);
        }
    }

    static class ToStringVisitor implements ExpVisitor<String> {
        @Override
        public String forInt(int value) {
            return String.valueOf(value);
        }

        @Override
        public String forNegate(Exp e) {
            return "-(" + e.accept(this) + ")";
        }

        @Override
        public String forAdd(Exp e1, Exp e2) {
            return "(" + e1.accept(this) + ") + (" + e2.accept(this) + ")";
        }
    }

    public static void main(String[] args) {
        System.out.println(new Add(new Negate(new Int(5)), new Int(6))
                                   .accept(new EvalVisitor())
                                   .accept(new ToStringVisitor()));
    }
}

如果参照开始的 OCaml 程序来看上面这段程序,可以看出 Visitor 类和函数的明显的对应关系:Visitor 中的每个方法对应于函数中 pattern-matching 的分支,而方法的参数则对应于 pattern-matching 中自动解析的变量。这样当我们需要增加一个操作的时候只需要写一个新的 Visitor 类就足够:

static class NoNegConstantsVisitor implements ExpVisitor<Exp> {
    @Override
    public Exp forInt(int value) {
        if (value < 0) {
            return new Negate(new Int(-value));
        } else {
            return new Int(value);
        }
    }
    @Override
    public Exp forNegate(Exp e) {
        return new Negate(e.accept(this));
    }
    @Override
    public Exp forAdd(Exp e1, Exp e2) {
        return new Add(e1.accept(this), e2.accept(this));
    }
}

所以归根结底,Visitor 模式只是让面向对象语言写出的程序在设计的层面更函数式一些,是否采用它取决于要解决的问题是否需要在操作的纬度有更大的灵活性。如果对 Visitor 模式在真实项目中的应用感兴趣可以参考一下 Java 的一个 bytecode instrumentation 库 ASM

参考