函数式和面向对象编程有什么区别?

函数式编程 (Functional Programming) 和 面向对象编程 (Object Oriented Programming) 是两个主流的编程范式,他们有各自独特的闪光点,比如函数式编程的数据不可变惰性求值,面向对象编程的继承多态等。这些语言特性上的区别,可以参考之前的文章,这篇文章主要从实现相同功能的角度,来对比这两种编程范式,他们在实现上的逻辑是截然相反的

初步实现

在函数式编程中,代码逻辑通常是按照要做什么。而在面向对象编程中,通常是把代码逻辑抽象成 class,然后给这些 class 一些操作。这么说起来很抽象,用下面这个例子来详细说明。

假设我们要用 函数式编程 和 面向对象编程 来分别实现下面这些功能:

eval toString hasZero
Int
Add
Negate

表格左列 Int, Add, Negate 是三个变式 (Variant),eval, toString, hasZero 是三种操作,这里要做的是填满这个表格,分别实现三个变式的三种操作。

函数式编程实现

这里用 ML 来做函数式编程的实现,即使没用过这门语言,应该也能读懂大概意思。

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

exception BadResult of string

fun add_values (v1,v2) =
case (v1,v2) of
(Int i, Int j) => Int (i+j)
| _ => raise BadResult "non-values passed to add_values"

fun eval e =
case e of
Int _ => e
| Negate e1 => (case eval e1 of
Int i => Int (~i)
| _ => raise BadResult "non-int in negation")
| Add(e1,e2) => add_values (eval e1, eval e2)

fun toString e =
case e of
Int i => Int.toString i
| Negate e1 => "-(" ^ (toString e1) ^ ")"
| Add(e1,e2) => "(" ^ (toString e1) ^ " + " ^ (toString e2) ^ ")"

fun hasZero e =
case e of
Int i => i=0
| Negate e1 => hasZero e1
| Add(e1,e2) => (hasZero e1) orelse (hasZero e2)

在函数式编程中,先定义了一个数据类型 (datatype) 来表示 Int, Negate, Add,这样定义的目的是什么呢?举个表达式的例子:

  • Int 代表一个 int 的数据,比如 Int(2)
  • Negate 代表 Int 的负数,比如 Negate(Int(2)))
  • Add 代表两个 Int 相加,比如 Add((Int(2), Int(3))

然后再分别实现三个操作 eval, toString, hasZero:

  • eval 是给一个表达式求值,比如给 Negate 求值,eval(Negate(Int(2))) = Int(-2) ,给 Add 求值,eval(Add(Int(2), Int(3))) = Int(5)
  • toString 是把这个表达式输出成字符串,比如 toString(Add(Int(2), Int(3))) = "2 + 3"
  • hasZero 是判断表达式有没有 0。

再看刚刚这句话函数式编程的代码逻辑通常是按照要做什么,这里的主体是三个操作,eval, toString 和 hasZero,所以三个分别是一个函数,在函数里去实现三种变式怎么操作。

可以说,函数式编程式纵向的填满了上面的表格。

面向对象编程

这里用 Ruby 来实现。

class Exp
end

class Value < Exp
end

class Int < Value
attr_reader :i
def initialize i
@i = i
end
def eval # no argument because no environment
self
end
def toString
@i.to_s
end
def hasZero
i==0
end
end

class Negate < Exp
attr_reader :e
def initialize e
@e = e
end
def eval
Int.new(-e.eval.i) # error if e.eval has no i method
end
def toString
"-(" + e.toString + ")"
end
def hasZero
e.hasZero
end
end

class Add < Exp
attr_reader :e1, :e2
def initialize(e1,e2)
@e1 = e1
@e2 = e2
end
def eval
Int.new(e1.eval.i + e2.eval.i) # error if e1.eval or e2.eval has no i method
end
def toString
"(" + e1.toString + " + " + e2.toString + ")"
end
def hasZero
e1.hasZero || e2.hasZero
end
end

< 在 Ruby 里是继承的意思,class Int < Value 表示 Int 继承了 Value,Int 是 Value 的 Subclass。

可以看到面向对象编程组织代码的方式和之前的完全不一样。这里把 Int, Negate, Add 抽象成了三个 class,然后分别给每个 class 加上 eval, toString, hasZero 三个方法。这也是刚刚那句话的说法 面向对象编程把代码逻辑抽象成 class,然后给这些 class 一些操作,这里的主体是 Int, Negate, Add 这三个 class。

可以说,面向对象编程是横向的填满了上的表格。

通过这个对比,可以知道 函数式编程 和 面向对象编程 是两种相反的思维模式和实现方式。这两种方式对代码的扩展性有什么影响呢?

扩展实现

eval toString hasZero absolute
Int
Negate
Add
Multi

在上面那个例子的基础上,我们再加一行一列,增加 Multi 这个变式,表示乘法,增加 absolute 这个操作,作用是求绝对值。这会怎么影响我们的代码呢?

函数式编程

在函数式编程中,要增加一个操作 absolute 很简单,只要添加一个新的函数,不用修改之前的代码。但是要增加 Multi 比较麻烦,要修改之前的所有函数。

面向对象编程

和函数式编程相反的,在这里增加一个 Multi 简单,只要添加一个新的 class,但是增加 absolute 这个操作就要在之前的每一个 class 做更改。

选择用 函数式编程 还是 面向对象编程 的一个考量因素是以后将会如何扩展代码,对之前代码的更改越少,出错的概率越小。

Binary Methods

前面的对比,操作都是在一个数据类型上进行的,这里进行最后一个对比,一个函数对多个数据类型进行操作时,函数式和面向对象分别怎么实现。

Int String Rational
Int
String
Rational

这里要实现的是一个 add_values(x, y) 的操作,把两个数据相加,但是 x, y 可能是不同的类型的。

函数式编程

函数式编程的实现相对简单:

datatype exp =
Int of int
| String of string
| Rational of real

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)
| (Int i, Rational(j,k)) => Rational(i*k+j,k)
| (String s, Int i) => String(s ^ Int.toString i) (* not commutative *)
| (String s1, String s2) => String(s1 ^ s2)
| (String s, Rational(i,j)) => String(s ^ Int.toString i ^ "/" ^ Int.toString j)
| (Rational _, Int _) => add_values(v2,v1)
| (Rational(i,j), String s) => String(Int.toString i ^ "/" ^ Int.toString j ^ s)
| (Rational(a,b), Rational(c,d)) => Rational(a*d+b*c,b*d)
| _ => raise BadResult "non-values passed to add_values"

这里的操作是 add_values,所以只要把所有可能的数据类型(总共9种)都列出来,就可以了。

面向对象编程:二次分派

按照上面面向对象编程的例子,我们可以这么做:

class Int < Value
...
def add_values v
if v.is_a? Int
i + v.i
elsif v.is_a? MyString
i.to_s + v.i
else
...
end
end
end

class MyString < Value
...
end

在 add_values 这个方法里面去做判断,看传入参数的类型,去做相应的操作。这种做法不是那么的 面向对象,可以有另外一种写法:

class Int < Value
...
# double-dispatch for adding values
def add_values v # first dispatch
v.addInt self
end
def addInt v # second dispatch: other is Int
Int.new(v.i + i)
end
def addString v # second dispatch: other is MyString (notice order flipped)
MyString.new(v.s + i.to_s)
end
def addRational v # second dispatch: other is MyRational
MyRational.new(v.i+v.j*i,v.j)
end
end

class MyString < Value
...
# double-dispatch for adding values
def add_values v # first dispatch
v.addString self
end
def addInt v # second dispatch: other is Int (notice order is flipped)
MyString.new(v.i.to_s + s)
end
def addString v # second dispatch: other is MyString (notice order flipped)
MyString.new(v.s + s)
end
def addRational v # second dispatch: other is MyRational (notice order flipped)
MyString.new(v.i.to_s + "/" + v.j.to_s + s)
end
end
...

这里涉及到了一个概念 二次分派 (Double Dispatch),在一次方法的调用过程中,做了两次 动态分派 (Dynamic Dispatch) 。用例子来说明

i = Int.new(1)
s = MyString.new("string")
i.add_values(s)

i.add_values(s)在调用这个方法时,实现了一次 dispatch,到 add_values 这个方法里后,做的其实是 s.addInt i,也就是去调用了 MyString 里的 addInt 这个方法,这是第二次 dispatch,所以叫做 double dispatch。

总结

函数式编程 和 面向对象编程 对比下来,我们并不能说哪一种模式更好。但是可以看出它们在思维上是截然不同的。函数式编程中侧重要做什么,面向对象编程侧重对象的抽象化,在有些编程语言里,比如 Java,是都可以实现的,但是要用哪种还要根据需求具体考虑。如果要了解更多 函数式编程 和 面向对象编程 的基础概念的话,可以看看之前的这三篇文章。

推荐阅读:
编程语言的一些基础概念(一):静态函数式编程
编程语言的一些基础概念(二):动态函数式编程
编程语言的一些基础概念(三):面向对象