Binary Methods.

Binary methods are operations that take two data variants as arguments. Similarly, we have n-ary methods that take more data variants as arguments.

在此之前,我们研究的 operations 大多都是针对同一种数据类型的:eval, toStringhasZero 皆属此类。若我们想对两个 (binary) 或以上 (n-ary) 种类的数据类型进行操作,则需要讨论更多的情况。

FP: Simply Adding Cases

在 functional decomposition 中,binary method 的实现比较简单,我们只需要在函数体中讨论对应的 cases 即可。当数据种类较多时,讨论的 cases 也将增加,但也存在许多简化的方法 (使用 wildcard _,定义辅助函数,利用重复的 cases 等等)。

举例来说,add_values 方法原本只能计算两个 Int 类型数之和;若我们想将其扩展为 binary method,支持对 IntStringRational 三种数据类型进行操作 (语义由我们自定义),我们只需要在其函数体中增加对应的 3 * 3 = 9 种 cases 即可。

1
2
3
4
5
fun add_values(v1, v2) =
case (v1, v2) of
(Int i, Int j) => Int (i + j)
| (Int i, String s) => String (Int.toString i ^ s)
| ... (* 3 data variants, 9 cases in total *)

OOP: Double Dispatch

对于同样的例子,OOP 需要使用一个特殊的 technique - double dispatch 来实现 binary method。

假设我们已经定义了 Int, Negate, MyString, MyRational, Add, Mult 等相关的类,为了扩展 Add 的语义,我们需要对 Add 类中的 eval 方法进行修改。原来的 eval 方法:只能处理两个 Int 类型数之和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Add < Exp
attr_reader :e1, :e2
def initialize(e1, e2)
@e1 = e1
@e2 = e2
end
def toString
"(" + e1.toString + e2.toString + ")"
end
def hasZero
e1.hasZero || e2.hasZero
end
def eval # eval method that needs to be modified
Int.new(e1.eval.i + e2.eval.i) # i is a method in class Int
end
end

一个很自然的想法是,模仿在 FP 中我们定义的 add_values 函数对来修改 eval:利用 is_a? 等类型判断方法来分类讨论不同的 cases。例如使用 is_a? Int,或 is_a? MyString 来获取 e1e2 的类。

但是请注意,使用类型判断函数是非常不符合 OOP style 的。我们应该调用某个对象的方法,在无需明确的获取另一个对象所属类的情况下执行我们自定义的加法操作。也就是说,An Int, MyRational or MyString should know “how to add itself to another value.” 于是我们将类 Add 中的 eval 改写成这样。

1
2
3
def eval
e1.eval.add_values e2.eval # one (first) dispatch
end

这样修改要求我们分别在 Int, MyStringMyRational 类中实现方法 add_values。通过 dynamic dispatch,我们成功将该自定义的加法运算分为了三种情况,分别交由 Int, MyStringMyRational 类进行进一步处理。

为什么这里利用了 dynamic dispatch?因为调用同样的方法 evale1.eval 返回的类不同 (Int, MyStringMyRational),所调用的 add_values 方法也不同,从而实现了相同方法的不同表现 (behaviors)。

Int 类中的 add_values 方法的实现为例;当前已知的信息是 Self 的类为 Int,未知的是参数对象的信息;我们不如再进行一次 dynamic dispatch,即,在所有类中再定义三个接口方法,分别对应 Int, MyStringMyRational 类,并将 Self 的信息传入该未知对象的接口。这样未知对象就间接获取了关于 Self 的信息。

这样的 technique 称为 double dispatch,即利用两次 dynamic dispatch 以实现 binary method。这正符合 OOP 的精神:When we “need to know” the class of v, we call a method on v instead.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Int < Exp
attr_reader :i
...
def add_values v
v.addInt self # double (second) dispatch (based on following interfaces)
end
def addInt v
Int.new(i + v.i)
end
def addString v
MyString.new(v.s + i.to_s)
end
def addRational v
MyRational.new(v.i+v.j*i, v.j)
end
end

虽然形式上差异很大,Double dispatch 本质上与 FP approach 是相同的;最终我们还是实现了 3 * 3 = 9 种 cases。只不过,通过 add_values 方法的一次分配与 addInt, addStringaddRational 接口方法的二次分配,dynamic dispatch could pick the correct code among all 9 cases in the end.

在 Java 这样的静态类型 OOP 语言中,我们需要 (1) 声明参数与返回值的类型,并且 (2) 在父类中声明子类所继承的方法与其类型,这使得 double dispatch 的实现更为直观。