Coursera Programming Languages, Part C 华盛顿大学 Week 2

2023-03-08,,

week 2 我们采用一种新的视角来对比 FP 与 OOP,即将问题分解 (decompose) 与实现 (implement) 的方式

OOP Versus Functional Decomposition

对 FP 而言,常见的分解 (decomposition) 方式为:将程序拆分成一个个函数 (function),在对应的函数中执行一些操作 (perform some operation)

对 OOP 而言,我们则是将程序拆分为一个个类 (class),对某类型的数据采取行为

以我们很熟悉的算术语言 (就是之前在 ML 中编写的,又用 Racket 实现的算数小语言) 为例

这个程序中有几个要素:

不同种类的表达 (different variants of expressions),如 integer 类型的值,negate 类型的表达,add 类型的表达等
对这些表达 (expression) 的操作 (operation),例如 evaluate (即 eval),将其转化为字符串 (toString),表达中的值有没有零 (hasZero) 等

我们将这些要素抽象成一个二维网格 / 矩阵 (2D matrix),那么程序编写的过程实际上就是对矩阵的每一个空格进行实现

eval toString hasZero
Int
negate
add
mult

(注意,在我们的算术表达式语言中,Negate, Add, Mult 算作一种数据类型 (data variant),需要 eval 操作对其进行 evaluate)

The Functional Approach

Define a datatype for expressions, with one constructor for each variant
Define a function for each operation
In each function, has a branch (在 ML 中通过模式匹配 pattern-matching 实现) for each variant of data

可以发现 Functional Approach 是 对过程的分解 (procedural decomposition):将程序分解为若干个与操作相对应的过程 (procedure corresponding to each operation)

以下的 ML 代码 (见 section9sum) 展现了 functional approach 是怎样一列列地 (by column) 实现这个矩阵的:每个函数都能覆盖一列

The Object-Oriented Approach

Define a class for expressions, with one abstract method for each operation (定义抽象方法,再让子类进行 override)
Define a subclass for each variant of data
In each subclass, have a method definition for each operation. If there is a default for many variants, using a method definition in the superclass so that via inheritance we can avoid enumerating all the branches

可以发现 OOP Approach 是 面向数据的分解 (data-oriented decomposition) :将问题分解为与若干种类数据对应的类 (class corresponding to each data variant)

以下的 Ruby 代码展现了 OOP approach 是如何 一行行地 (by row) 实现这个矩阵地:每个类 (class) 都能覆盖一行

punch-line

FP 分解问题的方式 (基于过程,按列实现) 与 OOP 分解问题的方式 (基于数据的种类,按行实现) 对立而又统一 (so exactly opposite that they are the same)

理解这个对称性 (symmetry) 是看待问题如何分解的核心:对于不同的问题,有时基于过程的看法更加"自然",而有时基于数据(对象)的看法更加自然

Extending the Code with New operations or Variants

接下来我们研究以下当向代码中添加新的操作 (operation) 或者数据类型 (variants) 时 FP approach 与 OOP approach 的区别

首先考虑 FP approach:

添加一个新的操作十分简单:不需要修改任何已有的代码,只需要实现一个新的函数即可

而添加一个新的数据类型却比较复杂:我们需要修改所有之前定义过的函数,为其增加一个新的分支 (branch)

而 OOP approach 完全相反:添加一个新的数据类型很简单:只要实现一个新子类,不需要修改任何已有的代码

而添加一个新的操作较为复杂:对于所有的相关的子类,我们都需要添加一个新方法

总的来说,函数式分解 (functional decomposition) 能在不修改源代码的情况下添加新操作 (operations) 而面向对象分解 (object-oriented) 能在不修改源代码的情况下添加新数据类型 (variants)

但是主要我们为代码的拓展提前做计划 (planning for extensibility),那么使用一些编程技巧,也能做到相对的操作

通过双分配 (double dispatch) 实现的 "访问者模式" (visitor pattern),面向对象分解的方式也能做到添加新操作

通过在定义 datatype 时留下"后台",并向函数中添加一个函数参数用来传入辅助函数,函数式分解的方式也能做到添加新数据类型

再谈拓展性

一般来说,设计既稳定 (robust) 又具有可拓展性 (extensible) 的软件有价值但是十分困难

可拓展性是一把双刃剑:它的源代码需要更多精力来开发,并且也很难在不打破可拓展性的情况下进行修改

实际上,各种语言经常提供一些 features 来防止 (prevent) 可拓展性:例如 ML 的模组 (module) 中可对 datatype 的实现进行隐藏,这样在模组外部就无法定义操作它们的函数 (operations over them)

Binary Method with Functional Decomposition

之前我们讨论的都是对于同一种数据类型 (data variants) 的操作 (operations),例如 eval, toString, hasZero 等等

若我们想实现对两个 (binary) 甚至以上 (n-ary) 种类的数据类型进行操作的话,则需要讨论更多情况

在 FP 中,这些情况仍然可以全部包括在一个函数中;而 OOP 中就会变得更加棘手 (cumbersome) (将在后面进行讨论)

datatype exp =
Int of int
| String of string
| Rational of rational
| Add of exp*exp
| Negate of exp
| Mult of exp*exp fun eval e =
case e of
Int i => i
| ...
| Add(e1, e2) => Int(eval e1 + eval e2)
| ...

举例来说,假设我们将 Add 的意义进行拓展:

当参数为 int 型或 rational 型时,进行算术运算
当参数中存在 string 型时,将另一个参数转为 string 并返回两个字符串的连接 (concatenation)

这样,对于 Add 这个操作,我们需要实现下列这个矩阵中的\(3\times 3 =9\)种情况

Int Rational String
Int
Rational
String
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)
| ... (complete 9 cases) fun eval e =
case e of
Int _ => e
| ...
| Add(e1, e2) => add_values (eval e1, eval e2) (* 在 add_values 中实现 9 种 cases *)
| ...

(完整代码见 section9)

当 case 的数量太多时,善用 wildcard 分支,定义辅助函数,并且利用重复的 case:有时候,参数的顺序不会对结果造成影响 (commutativity)

Binary Method in OOP: Double Dispatch

现在我们来研究下在 OOP 中如何添加一个对两个以上数据类型的操作

假设我们已经定义好了相关的类 Int, Negate, MyString,MyRational, Add, Mult 等等

为了扩展 Add 的语义,我们需要对 Add 类中的 eval 方法进行修改

这是原来的 eval 方法:只能处理整数的算数加法

class Add < Exp
attr_reader :e1, :e2
def initialize(e1, e2)
@e1 = e1
@e2 = e2
def toString
"(" + e1.toString + e2.toString + ")"
end
def hasZero
e1.hasZero || e2.hasZero
end
def eval # 需要修改的 eval method
Int.new(e1.eval.i + e2.eval.i) # i 是 Int 类中的方法
end
end

一个很自然的想法是,模仿在 FP 中我们定义的 add_values 函数来修改 eval:利用 is_a? Int, is_a? Mystring 等直接判断类型的函数进行分类讨论

但是这样简单粗暴的方法是十分不 OOP style 的:我们应该通过调用对象的方法使其无需明确的获取另一个对象的信息的情况下 (即另一个对象的类) 进行我们自定义的加法操作

首先我们将 Add 类中的 eval 改写成这样

def eval
e1.eval.add_values e2.eval
ene

修改之后,这就要求我们在 Int, MyString, MyRational 类中添加方法 add_values

这样做的好处是,通过动态分配 (dynamic dispatch),我们将自定义的加法运算分为了三种情况,分别交由 Int, MyStringMyRational 类进行进一步处理

我们这里以 Int 类中的 add_values 实现为例

由于当前可以获得的信息是 Self 的类为 Int,未知的是参数对象的信息

我们不如反其道而行之,在所有类中再定义三个接口方法,分别对应 Int, MyStringMyRational,并将 Self 的信息传入未知对象的接口:这样在未知对象中就相当于间接的获取了 Self 的信息

这正符合 OOP 的精神:\(When\) \(we\) \(“need\) \(to\) \(know”\) \(the\) \(class\) \(of\) \(v\), \(we\) \(call\) \(a\) \(method\) \(on\) \(v\) \(instead\)

class Int < Exp
...
...
def add_values v # first dispatch
v.addInt self
end
def addInt v # double dispatch
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

事实上,我们还是实现了 \(3*3=9\) 种 cases,只不过我们通过两次动态分派 (dynamic dispatch) 将其分散给每一个类,并在运行时通过分配找到正确的代码运行

这一 technique 称为二次分派 (double dispatch)

Multimethods

认为所有 OOP 语言在实现 binary method 时都需要用到上面介绍的冗长的 (cumbersome) 二次分派方法是不对的

对于实现了多分派 (multimethods, mutiple dispatch)的语言,解决方法更加简单

在这些语言中,Int, MyString, MyRational 每个类型当中都可以定义三个均命名为 add_values 的方法 (所以整个程序中将会有 \(9\) 个名为 add_values 的方法)

每个 add_values 方法都应指明 (indicate) 它所期望的参数的类

这样的话,当实际调用时,例如代码 e1.eval.add_values e2.eval在运行时 (at run-time) 会从 \(9\) 个 add_values 方法中,依据 e1.eval 结果的类与 e2.eval 结果的类选择正确的一个进行调用

在我们之前对 Ruby 的学习中,method-lookup rules 的依据是运行时接收者的类 (the run-time class of the receiver, which is the object whose method we are calling, or self in Ruby),而不会牵扯到运行时参数的类型 (the run-time class of the arguments)。事实上,Java,C#,C++ 等语言也采取这样的分派方式

这种动态分派的方式称为动态单分派 (dymanic single dispatch)

动态多分派 (dynamic multiple dispatch) 通过运行时接收者的类型与运行时方法参数的类型共同选择符合要求的方法进行调用:某种意义上来说,它比单分派更加 "动态分派"

Ruby 之所以不支持多分派,一是因为规定了在类种不能存在同名方法,二是在定义方法时也无需声明期望参数的类

Java 与 C++ 同样不支持多分派,但是在这些语言中类中是可以定义多个同名的函数的:在选择函数时也的确用到了参数的类型 (type)作为依据;

但注意,这里用到的是参数的类型(type),是在编译时就已经确定的了 (determined at compile-time),而不是前面我们提到的运行时 evaluate 过后才得出的结果的类 (run-time class)

这个 semantic 被称为静态重载 (static overloading),也有人称为 静态多分派 (但当一般提到多分派时,我们指的都是动态多分派)

静态重载也十分有用,但是它并不能替代多分派:所以,该用二次分派时还得用

Multiple inheritance

我们已经见识过了,在 OOP 中,继承 (inheritance),重写 (overriding),动态分派 (dynamic dispatch)的重要性

但是,每个类都只有一个直接的父类 (all our examples have been classes with 1 immediate superclass)

既然继承的功能这么强大,为什么不能允许一个类同时继承自多个父类呢?

multiple inheritance (多继承): 即一个子类可以有多个父类,它继承了多个父类的特性。在单继承中,所有的类与继承关系形成了一棵树--即 class hierarchy (类的层次结构)。而多继承中,类与继承关系形成的的是一个 \(DAG\)。虽然功能很强大,但是会引出许多麻烦的 semantic 问题。Java 和 Ruby 都不支持多继承;C++ 支持

mixins:Ruby 只允许某一个类拥有一个直接的父类,但允许有多个 mixins。mixin 的本质上实际上是一系列方法的集合,所以可以避免标准多继承引发的许多问题。mixin 其实就是在语言不提供标准多重继承的情况下,变相实现多重继承的一个语法糖。 下一个章节将会详细进行介绍

Java/C#-style interfaces (接口): Java 或 C# 中的类仅允许唯一的直接父类,但是可以 "实现" 多个接口 ("implement" any number of interfaces)。接口并不提供真正的方法,它只保证特定方法的存在:这样的话大多数 semantic 问题就可以得到解决。接口的实现依靠静态检查 (type-checking),所以在类似 Ruby 这样的动态类型语言中实现的意义不大。C++ 同样也没有实现接口,因为继承一个抽象类 (abstract class) (定义的方法均为抽象方法 abstract methods,也即纯虚方法 pure virtual methods)就可以起到接口的作用

接下来,我们将通过例子展示多继承的优势与问题

考虑类 Point2D, 其子类 Point3D (添加了一维,以 \(z\) 存储) 与 ColorPoint (添加颜色,以 \(color\) 存储)。实现一个有颜色的三维点时,很自然的就会想到定义 ColorPoint3D 类同时继承 Point3D类与 ColorPoint
考虑类 Person 的两个子类 CowboyArtist。当实现一个艺术家牛仔时,很自然的想到 CowboyArtist 类同时继承自 Cowboy 类与 Artist类。但是这会引出一个问题:Cowboy类中定义了一个 draw 方法(draw the gun, 指拔枪),Artist 中同样定义了一个 draw方法 (指画画),我们可以发现,这两个同名方法的行为完全不同,那 CowboyArtist 类应该如何继承?若两个方法都继承,那应该如何区分?这些都是多继承带来的麻烦的 semantic 问题

有了多继承,ColorPoint3D 类与 CowboyArtist 类的实现可以很灵活方便

但如果我们对多继承的定义是,子类继承多个父类的所有方法(对于成员变量属于类定义一部分的语言,子类同样继承父类的所有成员变量),那当两个直接父类拥有同名的方法或者成员变量时继承时的规则时什么?(上面提到的 draw 方法)当继承的方法或者成员变量来源于 (但可能被改写) common ancestor (共同祖先)应该怎么办?(例:Person 类中的 pocket 成员变量在 Cowboy 类中可能用来放枪,在 Artist 类中可能用来放画笔;那么在 CowbotArtist 类中应该如何处理?)

这些问题正是那些实现了多继承的语言(例如C++)有着十分繁复的继承,分配,成员变量访问规则的原因。例如,C++ 有着至少两种方法来定义子类;一种拷贝所有来自父类的方法与成员变量;另一种,对于继承自共同祖先的成员变量,仅拷贝一份 (Ruby 没有类似的特性因为 Ruby 中实例变量不属于类定义的一部分)

mixins

mixins 大概是位于标准多继承与接口之间的一个 feature

mixins 提供方法的代码给各种类来 include,但它本身并不是类,不能被实例化为对象

在 Ruby 中,我们使用 module 关键字来定义 mixins,使用 include 关键字来使类 "继承" mixins

(定义 mixins 与定义类的格式几乎一致,除了使用的关键字是 module 而非 class)

这是一个用于给类添加颜色方法的 mixins

module Color
attr_accessor :color
def darken
self.color = "dark " + self.color
end
end

这个 mixins 中定义了三个方法,color, color=darken,在类定义中使用 include 添加这个 mixins

class ColorPt < Pt
include Color
end

这一段程序中存在两个问题:

    Pt 类中继承来的 initialize 方法并没有被重写,所以并不会初始化实例变量color。所以,在调用 color= 方法之前,所有尝试访问 color 的操作都会返回 nil
    在 mixins 中使用实例变量可能会引发一些问题。类中原有的实例变量 (或从另一个 mixins 中继承的实例变量) 可能会与该 mixin 中继承的实例变量重名。所以一般来说,我们尽量避免在 mixins 中定义实例变量

既然介绍了 mixins,我们现在重新考虑下 Ruby 中的 method-lookup rules:若对象 obj 是类 C 的一个实例,且我们现在向 obj 发送信息 m

在类 \(C\) 的定义中查找 \(m\) 的定义
若没找到,在类 \(C\) include 的 mixins 中从后往前查找 \(m\) 的定义 (later includes shadow earlier ones)
若没找到,在 \(C\) 父类的定义中查找
若没找到。在 \(C\) 父类中的 mixins 中查找
以此类推

mixins 的比较优雅的用法是:They define methods that call other method on self that are not defined bt mixins

例如下面这个例子,定义的 double 方法中调用了并未在 mixins 中定义的 + 方法,也就是说,mixins 期望 include 它的类中,定义了相应的方法

module Doubler
def double
self + self # use self's + message, not defined in Doubler
end
end

如果类 \(C\) include 了 Doubler,我们将会对其的实例调用 + 方法;如果类 \(C\) 中并未定义该方法,那么将会出现错误

但是只要类 \(C\) 定义了 + 方法,一切都顺理成章了

class AnotherPt
attr_accessor :x, :y
include Doubler
def + other # add two points
ans = AnotherPt.new
ans.x = self.x + self.x
ans.y = self.y + self.y
ans
end
end

现在 AnotherPt 类的实例可以直接调用 double 方法了

我们甚至可以在一个标准库中提供的,定义了 + 方法的类中 include Doubler

class String
include Doubler
end

现在对某个字符串调用 double方法将使其变为原来的两倍

基于这个原理,Ruby 中实现了两个很方便很优美很强大的 mixins:EnumerableComparable

Comparable 提供方法 =, !=, >, >=, <<=,要求 include 它的类中提供 <=> 方法的定义

<=> 方法,有时又被称为 spaceship 方法,需要传入两个参数 \(x\), \(y\),并且

当 \(x<y\) 时,返回一个负数
当 \(x=y\) 时,返回 \(0\)
当 \(x>y\) 时,返回一个正数

(有没有联想到向 \(C\) 中 qsort 函数中传入的 cmp 自定义比较函数?)

所以我们只需要在相关的类中实现 <=> 函数,再 include Comparable mixins 就可以直接获得整套比较函数

下面是一个例子,通过字典序来定义两个名字的大小

class Name
attr_accessor :first, :middle, :last
include Comparable
def initialize(first, last, middle="")
@first = first
@last = last
@middle = middle
end
def <=> other
l = @last <=> other.last # <=> has been defined on string
return l if l != 0
f = @first <=> other.first
return f if f != 0
@middle <=> other.middle
end
end

Enumerable 则提供一些有用的需要传入 block 的方法,并且一般会遍历一些已定义的数据结构。如 any?, map, count, inject 方法 (都特别好用!)

include Enumerable 需要类实现 each 方法

下面是一个例子

class MyRange
include Enumerable
define initialize(low, high)
@low = low
@high = high
end
def each
i = @low
while i <= @high
yield i
i = i + 1
end
end
end

现在我们可以写下诸如 MyRange.new(4, 8).inject {|x, y| x + y} 或者 MyRange.new(5, 12).count {|i| i.odd?}

注意 map 返回的始终是一个 Array 类的实例。在 Enumerablemap 是这样定义的

def map
arr = []
each {|x| arr.push (yield x)} # missing "yield" in pdf
arr
end

(如果有原 pdf 的同学可能会发现给的代码和我这里的不一样,我感觉是 pdf 里有问题,缺了一个 yield 的环节。已经尝试向助教反馈了)

mixins 虽然强大,但是还是不能代替多继承。对于给定的 Cowboy 类与 Artist 类,使用 mixins 我们仍然没有办法自然的创造出 CowboyArtist 类。

Java/C#-Style interfaces

在 Java 或 C# 中,一个类只能有一个直接父类,但是却可以 "实现" (implement) 任意数量的接口 (interface)

一个接口声明了若干方法,以及这些方法的参数与返回值的类型 (type)。注意,接口中声明的方法并未被实现,即方法的主体 (function body) 部分都是空置的

一个类若要通过 type check,它需要提供所有它声称实现的接口中声明的方法 (无论是直接实现还是通过继承实现)

有趣的是,在 Java 与 C# 中,接口是一种类型 (type),同样类也是一种类型 (type)

若类 \(C\) 实现了接口 \(I\),那么我们可以将类 \(C\) 实例化的对象作为参数传入一个形参类型 (formal argument type) 为 \(I\) 的方法 (与 duck typing 的思想很相似)

因为接口并不实际的实现方法,只是声明这些方法与其参数和返回值的类型 (type),所以多继承的问题都可以被避免

就算两个接口中有方法出现了重名冲突,一个类也可以同时对其实现;若接口中的方法重名且参数或返回值类型不一致,则任何类都无法同时实现这两个接口:type checker 会在运行前就找出错误

同样,由于接口并不实际上实现方法,它也不能像 mixins 那样使用

然而,在类似 \(Ruby\) 这样的动态类型语言 (dynamic) 中,接口的存在没有太大意义。因为在这些语言中,由于没有 type system 的限制,我们已经可以将任意类型的对象传入任意方法了

接口并不能继承代码 (Implementing interfaces does not inherit code),它的存在与静态类型语言中的类型系统息息相关--它使得这些语言的 type system 更加灵活,允许了某种程度上的鸭子类型。所以,对于根本不存在 type system 的动态语言,接口的存在意义不大

Abstract Methods

我们经常能见到在一些类定义中,某些方法中调用了类中未定义的方法 (与 mixins 有点类似)

class Doubler
define double
self + self # the method '+' isn't defined in class Doubler
end
end

如果我们想要实例化这个类生成对象,并且调用该对象相应的方法,那么会出现 method missing 的错误

实际上,定义这种类的目的不是为了被实例化,它存在的目的就是被继承 (to be subclassed),这样其不同的子类就可以通过采取不同的方式定义 the missing method

依托动态分派 (dynamic dispatch),多个不同的子类都可以调用父类中的代码,但由于自己定义的 the missing method 不同而展现出不同的行为

Ruby 允许这种不能实例化的类存在,但仔细想想,这种类起到的作用实际上和 mixins 是一样的 (同样的,mixins 虽然定义方式与类几乎一致,但也不能实例化为对象)

在静态类型语言如 C++ 中,情况更加有趣

我们都知道,拥有 type system 的语言,会在编译期间就对程序进行类型检查以避免错误。而在众多需要避免的错误中,method missing 也是其中一个

所以我们需要采取措施使得这种专用于被继承的类不能被实例化。在 C#/Java 中这种类被称为抽象类(abstract class)

同样的,在抽象类的定义中,我们也需要声明子类 (非抽象子类) 需要提供的方法的类型 (参数类型+返回值类型),这样的方法称为抽象方法 (abstract methods)。在 C++ 中则被称为纯虚函数 (pure virtual function)

一般来说,在这些语言中,由于子类型 (subtyping) 的存在,we can have expressions with the type of the superclass and know that at run-time the object will actually be one of the subclasses (这句话很精髓,就不翻译了)

有了抽象类与抽象方法的概念后,type checker 将确保某个对象所属的类实现了所有的抽象方法,这样调用这些方法将是安全的

本质上,OOP 语言中的抽象方法 (abstract methods) 与 FP 语言中的高阶函数 (higher-order function) 有着微妙的相似之处

本质上,它们的目的都是将一段代码以灵活的(flexible),可复用(reusable)的方式传到另一段代码中

在 OOP 中,不同的子类以不同的方式实现抽象方法;而父类中的代码,通过动态分派,可以访问并使用各个子类中不同的代码实现

在 FP 中,以其他函数作为参数的高阶函数,不同的 callers 可以提供不同的实现给函数主体部分

有抽象方法与多继承的语言不需要接口:一个全部方法都是抽象方法(纯虚函数)的抽象类起到的作用与接口一致

Coursera Programming Languages, Part C 华盛顿大学 Week 2的相关教程结束。

《Coursera Programming Languages, Part C 华盛顿大学 Week 2.doc》

下载本文的Word格式文档,以方便收藏与打印。