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

2023-03-14,,

来进入这一系列课程的最后一 Part 啦!

\(P1\) 我们介绍了 \(ML\),一种 static typing 的 functional language

\(P2\) 我们介绍了 \(Racket\),一种 dynamic typing 的 functional language

回顾一下 函数式编程 的特点

这一 Part 我们介绍 \(Ruby\) ,它是一种 dynamic typingobject-oriented language

Ruby features most interesting for a PL course

    \(Ruby\) 是一个 纯粹的面向对象语言 (pure object-oriented language),这意味着,所有的值都是对象 (all values in the language are objects)
    \(Ruby\) 是基于类 (class-based) 的语言,也就是说,每一个对象都是类的实例 (Every object is an instance of class),一个对象的类的定义决定了它有哪些方法 (method)
    \(Ruby\) 有 mixins: 是 C++ 中多重继承和 Java 中接口的一个折中:一个类可以 include 任意多的 mixins,且 mixins 中可以对方法做出完整的定义
    \(Ruby\) 是一种动态类型 (dynamically typed)语言:就像 Racket 中在调用函数时对传入的参数没有任何限制一样 (Racket 也是动态类型语言)。Ruby 同样不对调用对象的方法时传入的参数做任何限制。如果接收者 (receiver,即被调用方法的对象) 没有定义相关方法,则弹出动态错误 (dynamic errors)
    \(Ruby\) 有许多动态特征 (dynamic features): 除了其类型是动态类型之外,Ruby 甚至允许在程序执行时动态的向对象中添加或者移除方法与实例变量(instance variable,即其它语言中的域或是成员变量)
    \(Ruby\) 有方便的反射 (reflection):Ruby 提供了许多内置方法,支持我们在运行时对对象进行调试分析
    \(Ruby\) 有 blocksclosuresblocks 可以完成 closures 的大部分功能(除了不能作为值进行存储)。由于有太多好用的迭代器与 blocks,Ruby 中的数组很少用循环语句进行操作
    \(Ruby\) 是一种脚本语言 (scripting language):脚本语言的定义比较模糊,但是 \(Ruby\) 确实是一种被广泛认可的脚本语言:这意味着这个语言在写较短程序时很有优势,且能够很方便的处理文件和字符串。就像很多其他的脚本语言一样,Ruby 不要求某个变量在使用前必须被定义
    \(Ruby\) 被广泛用于网页开发 (web applications)

The rules of class-based OOP

基于类的面向对象语言有如下特点:

    所有 值 (value, the result of evaluating expressions) 都是对象的引用 (reference to objects)
    代码与对象交互的方式是 调用其的方法 (calling its method)
    每个对象都有其私有域 (private state)
    每个对象都是类的实例 (instance of a class)
    对象的类决定其行为 (behavior)

对大多数 OOP 语言,这些规则是成立的,但也有例外

比如,在 \(Java\) 与 C# 中,有些值 (例如数) 并不是对象,并且存在方法使对象的私有域暴露

Objects, classes, methods, variables, etc.

定义类

class Foo
def m1
...
end
def m2 (x, y)
...
end
...
def mn z
...
end
end

以上代码定义了一个具有方法 m1...mn 的类 Foo

类名必须以大写字母开头,类中含有方法的定义

方法的最后一个表达默认为其返回值,也可以使用 return 关键字 (将 return 放在末尾是 bad style,因为默认最后一个表达就是返回值)

方法的参数可以有 default,同样只能集中在左侧

调用方法

e0.m(e1, e2, ... en)

\(e0, e1, e2, ... en\) 被 evaluate 为对象 (object)

然后调用 \(e0\) evaluate 出的对象的方法 \(m\),并将 \(e1, e2, ... en\) 分析出的对象作为参数传入 \(m\)

格式上,括号可省略,不过建议将括号写出,对于一个 \(0\) 参数的方法这样调用 e0.me0.m()

在 OOP 语言中,调用方法 (method call) 的另一个常见名是 传送消息 (message send)

这一种说法显得更加面向对象:作为用户 (client),我们不在乎收信人 (receiver of the message) 是如何实现的,只要它能处理传送过去的信息就行

实例变量 (instance variable)

为了描述对象的各种性质,我们可以为其添加实例变量 (instance variables),有些语言用 fields (场) 这个名词来表示同样的概念

与 \(C++/Java\) 不同,类的定义中并不会指明 (indicate) 对象中的实例变量

如果我们想为某个 对象 (注意,是对对象而言的) 添加一个实例变量,我们只需要进行赋值 (assign,注意,这里用的是 assign 而不是 binding)

注意:实例变量是对象私有的 (private to object),且是 mutable 的。所有实例变量名都需要以 @ 开头以与方法中的临时变量相区分

class A
def m1
@foo = 0 # 初次调用:为 A 实例化的对象添加实例变量 @foo; 后续调用:将 @foo 置为 0
end
def m2 x # 实例变量是可变(mutable)的
@foo += x
end
def foo # 若在 @foo 未被添加的情况下调用该函数,返回 nil
@foo
end
end

作个比较:在 \(C++\) 中,实例变量在类的定义中就已经指明,也就是说,由类实例化出的对象一诞生就拥有完整的一套实例变量

而在 \(Ruby\) 中,实例变量是可以通过调用方法向对象中动态添加的,这意味着同属一类的两个对象可能拥有不同的实例变量组

类变量 (class variable)

类变量被所有类的实例共享,因此它并不是某个对象的私有域成员

所有类变量名都需要以 @@ 开头,例:@@foo

\(Ruby\) 的类变量类似于 \(C++\) 中的静态成员变量:本质上是封装在类中的全局变量,见 我的 C++ 程序设计笔记

构造一个对象 (construct an object)

为类 Foo 创建一个实例:Foo.new(...)

这里,括号里传入的是若干个参数:在 Foo.new 创建的对象返回之前,调用其 initialize 方法并将 Foo.new(...) 中的参数传入

initialize 方法是一个特殊的方法,它在一个对象刚刚被创建的时候就被调用

initialize 方法类似于 C++ 中类的构造函数 (constructor)

一般而言,initialize 方法的行为是 创建并初始化若干实例变量 (create and initialize instance variables)

一个 good style 是不在 initialize 之外的方法创建新的 instance variable,但在有些情形下为了方便也不必遵守

表达与本地变量 (Expression and local variables)

\(Ruby\) 中的绝大部分表达都是方法的调用 (回忆一下:\(Ruby\) 中所有值都是对象)

例如 e1+e2 其实是 e1.+(e2) 调用了 e1 evaluate 出对象的 + 方法

但是也有些表达并不是方法的调用,比如条件语句,循环语句等

关于本地变量:同样也是第一次 assign 会创建这个变量,变量的生命周期 (scope) 持续整个方法的主体部分

使用一个未被定义的本地变量是 run-time error,而使用一个未被定义的实例变量只会返回 nil

类常量与类方法 (class constants and class methods)

类常量是公共的(publicly visible),不可修改的 (immutable) 的类变量,并且类常量以大写字母开头而不是以 @@ 开头

在实例的外部访问类 \(C\) 类常量 \(Foo\) 的方法:C::Foo

类方法是公共的(publicly callable),且其不能调用实例的任何实例变量与实例方法

在实例的外部调用 类 \(C\) 类方法的方法 C.method_name args

类常量与类方法类似于 \(C++\) 中的静态成员常量 (static constant)静态成员函数 (static method)

定义的方法是:

def self.method_name args
...
end

Visibility and Getters/Setters

之前提到,实例变量是对象私有的,外界之可以通过调用方法与对象进行互动

方法可以有不同的可视性 (visibility),默认是公共的 (public),同样可以是私有的 (private),在此之间还有受保护的 (protected):一个受保护的方法可以被同类型/该类型的子类型的实例调用

声明类中方法可视性的方式同 \(C++\) 差不多,在类定义的开头有一个隐式的 private

为了可以使外界可以访问/调用,我们可以定义相对的 Getters/Setters (访问器/修改器)

def foo    # getter
@foo
end
def foo= x # setter
@foo = x
end

如果这些方法是公共的,我们就可以通过调用这些方法来访问/修改实例变量:有时候,将 Getters/Setters 设为 protected 是必要的

这里有一个 syntactic sugar:将 setter 的方法名定义为 var_name=:在调用以 = 结尾的方法时可以这样写c.var_name = x而不是 c.var_name=.(x),使调用方法更像是一个 assignment 语句

由于定义 getter/setter 过于常见,Ruby 提供了一个更简单的语法:例如,对实例变量 @x, @y, 与 @z

attr_reader :y, :z # defines getters
attr_accessor :x # defines getters and setters

最后,关于一个语法细节:

若方法 \(m\) 是一个私有方法, 我们只能这样调用:mm(args)

一个类似这样的调用 x.m 或者 x.m(arg) 是不合法的,破坏了可视性原则

就算这样调用 self.m 或者 self.m(arg) 是合法的,但仍然被 Ruby 所禁止

Everything is an Object

再次强调,在 Ruby 中,所有值都是对象,包括数,布尔值 (booleens) 与 nil

举个例子,-42.abs 将会 evaluate 为 42,因为 -42Fixnum 类的的一个实例且该类中定义了 abs 方法计算绝对值

所有的对象都有 nil? 这个方法,除了 nil 所在的类定义的方法返回 true 之外的类都返回 false

与 ML 和 Racket 一样,每个表达都会产生结果 (Every expression produces a result),但当结果无意义时,就将返回 nil

在定义方法时,返回本身 self 是一个好的选择,这是因为这使对同一对象连续的方法调用更加方便

例如以下代码

x.foo(14)
x.bar("hi")

可以直接写成 x.foo(14).bar("hi")

Reflection

在 Ruby 中有许多方法 (method) 支持反射 (reflection):在程序执行期间获取对象及其定义的信息 (learning about objects and their definition during program execution)

也就是在程序执行的过程中仍然可以获取程序相关的信息

例如,方法 methods 返回一个数组,其中储存了该对象类中所定义的所有方法

方法 class 返回当前对象所属的类 (注意,这个返回的"类"也是一个对象, the class is itself another object)

反射 在编写灵活的 (flexible) 代码中经常很有用,并且也可用于在 REPL 中 debug

文章参考

The Top-level

在类定义之外 (outside of an explicit class definition) 定义的方法,会隐式的加入到 Object 类中 (implicitly added to class Object),这使它们能被任何对象调用

这是因为我们定义的任何类都是 Object 类的子类 (subclass),所以会继承 (inherit) Object 类中的所有方法,自然也包括 Top-level methods

顶层表达在程序运行时是按顺序 evaluate 的

Class Definitions are Dynamic

一个 Ruby 程序 (或正在使用 REPL 的 user) 可以在运行时改变类定义 (change class definitions while running)

改变类定义后,所有该类的实例 (甚至包括修改前创造的实例) 都会发生改变

毕竟每个对象都有类,且类的(当前 current) 定义决定了对象的行为

动态的类定义经常被人质疑 (dubious) 因为这样会破坏类的抽象性 (abstraction),但是这使得语言的定义更加简单:定义和改变类的定义可以是一个运行时的行为 (run-time operation)

但是这样做当然也会破坏程序:如果我在运行时更改 + 的定义,大多数程序都会产生运行错误

动态加入或者更改类的方法的语法很简单:仅仅只需要给出一个包含你想要加入/更改的方法的类定义即可 (注意,这个类应该是源代码中已经被定义过的)

那么,若源代码中的类定义没有该方法,则该方法添加近类中;若存在同名的方法,则将源代码中的同名方法进行覆盖 (replace)

Duck Typing

鸭子类型 (Duck Typing) 来自于一句俗语:\(If\) \(it\) \(walks\) \(like\) \(a\) \(duck\) \(and\) \(quacks\) \(like\) \(a\) \(duck,\) \(then\) \(it's\) \(a\) \(duck\)

在 Ruby 中,鸭子类型指一种风格,也就是我们并不在乎作为参数传入某方法的对象的类 (即所谓的"鸭子"Duck),只要该对象的行为能够满足该方法内的交互 (即行为 "walk" 或 "quack")

换句话说,就是一个对象有效的语义,不是由继承自特定的类或实现特定的接口 (Duck),而是由 当前方法和属性的集合 (Duck's behavior) 决定

举个例子:

def mirror_update pt
pt.x = pt.x * -1
end

当我们看到这个方法时,很自然的就会认为这个方法应该 take 一个 Point 的对象,并将该对象中的实例变量 @x 设为负 (且这个 @x 定义了 Getter/Setter)

    但由于这个方法并没有检查对象的类,若一个 foo 类的对象,且这个对象有实例变量 @x 且定义了 @x 对应的 Getter/Setter 也能够作为参数传入这个方法
    甚至某个 bar 类的对象没有实例变量 @x,只要其定义了能够返回某个数字的方法 xx= 即可
    甚至 x 方法不需要返回数字,它只需要能够回应以 -1 为参数的 * 信息即可 (respond to the * message with argument -1)

这就是我们所说的 Duck Typing:Poing 类的对象就是鸭子,而剩下的都是走路,叫声都与鸭子一样的 "类鸭子"

鸭子类型使得代码的重复利用性 (reusable) 更高,使得一些 "类鸭子" (Fake ducks) 也能使用原本鸭子用的代码

在 Ruby 中,只要我们不对方法的参数进行检查(检查是否属于期待传入的类),自然而然 (comes for free) 就采用了鸭子类型

当然,鸭子类型也有很多缺点

例如,当本当是给 Point 类对象使用的方法同时被 FooBar 等多个类型一起使用

当有必要对该方法进行修改时,修改后的方法仍适用于 Point 类,但也许不再适用于 Foo Bar 等类型:这样原本可以正常运行的程序就会出现问题

贴一个百度百科

Arrays

Array 类型的实例具有所有其他编程语言中数组的功能,甚至还丰富得多

同 \(Java/C++\) 中的数组相比,\(Ruby\) 中的 Array 更为灵活,动态化且功能全面

这样的代价是 \(Ruby\) 中的 Array 效率更低

总的来讲,在 Ruby 中,数组 (Array) 是数字 (即下标,Indices) 对对象的映射 (map)

有以下两种创建新数组的方式:

[e1, e2, e3, e4]

以上表达创建了一个下标从 \(0\) 开始,长度为 \(4\) 的数组
Array.new(x)

该表达创建了一个长度为 \(x\),初始化为 nil 的数组

Array.new(x) { 0 }

Array.new(x) { |i| -i }

通过传入额外 block 来对数组进行初始化 (关于 block 下一节进行介绍)

访问数组中下标为 \(i\) 的元素:a[i]

修改数组中下标为 \(i\) 的元素: a[i] = e

在 Ruby 中,这样的访问方式当然也是以某种形式调用了 Array 的方法

Ruby 数组的几个特点:

作为动态类型语言,Ruby 中的数组可以存储不同类型的值 (即,属于不同 class 的实例),例 [14, "hi", false, 34]
负数下标被解释成从数组的末端开始的下标,例 a[-1] 指数组的倒数第一位元素,以此类推
没有数组越界错误 (No array-bounds errors),这一点很有趣

设数组长度为 \(l+1\) (即下标从 \(0..l\)),表达为 a[i]a[i] = e (\(i>l\),产生数组越界)

访问下标为 \(i\) 的元素 a[i],返回 nil
修改下标为 \(i\) 的元素 a[i] = e,此时数组的长度将会动态增长至 \(i+1\):最后一个位置存放 \(e\),与旧数组结尾之间的一串位置均为 nil
Slicing: a[x, y]:\(a\) 数组截取由 \(a[x]\) 开始 \(y\) 长度的数组
拥有大量的方法可供调用:例如 + 表示连接两个数组,| 表示合并并去重两个数组

另外 Ruby 数组可以直接用于构建 stack,queue,tuple,triple 等数据结构

这得益于其的大量可供调用的方法,动态语言带来的灵活性和不过多追求效率

栈 Stack:

push 方法,使数组长度 \(+1\) 并将传入的参数压入末尾
pop 方法,返回数组末尾的元素后使数组长度 \(-1\)

队列 Queue:

push 方法
pop 方法
shift 方法:返回下标为 \(0\) 的元素并将其弹出,在其之后的元素均向前移动一位,数组长度 \(-1\)
unshift 方法:数组长度 \(+1\),将下标为 \(1\) 至末尾的元素全部向后移动一位,将传入的参数压入数组首端

关于 alias:

a = [[1, 2, 3], [4, 5, 6]]
b = a + []
a[0][0] = 0

执行这段代码过后,\(b[0][0]\) 也将变为 \(0\)

虽然 \(+\) 号使得 \(b\) 并不是 \(a\) 的 alias,但是 a 与 b 的地址中存储的引用相同,所以会一起改变

Passing blocks

最常见、最简单、最富争议、最有 Ruby 风格的方式是 blocks

在 Ruby 中,许多方法都 takes blocks,强大的 Block 使得 whilefor 语句的存在几乎都被忽略了

blocks 与 closure 几乎 是一致的

给出几个例子

3.times { puts "hi" }

以上这个程序将连续输出三行 hi

y = 7
[4, 6, 8].each { y += 1 } # 传入 block 的参数为 0

这个语句执行过后 \(y\) 变为 \(10\)

each 方法:对 array 内的每个元素执行一次 block

这里可以看出 Block 与 Closure 的相似之处:同样也可以 refer to block 定义时环境中的变量

sum = 0
[4, 6, 8].each { |x| # 传入 block 的参数为 1
sum += x
puts sum
}

以上这个程序每行输出 \(4\), \(10\), \(18\)

block 的定义采取这样的形式 { |i| e },\(i\) 是 block 的所需的参数,当传入 each 方法中时,array 中的每个元素将会作为参数逐个传入 block,与变量 \(i\) 绑定后执行 \(e\)

要强调,Block 并不是对象,它不能像普通的参数一样传入方法

每个方法都能被传入 \(0\) 或 \(1\) 个 block,与其他 "普通" 的参数分开,放在最右边

sum = [4, 6, 8].inject(0) { |acc, elt| acc + elt } # 传入 block 的参数为 2

如上,传入 inject 方法的 block 与 "普通参数" \(0\) 分开

且这里的 block 中有两个参数:其中 \(acc\) 初始化 \(0\) 作为 accumulator,\(elt\) 则遍历绑定整个数组的元素

除了上例所示的用大括号 {} 包住 block,另外一个被视为 good style 的方法时用 do 替代 {end 替代 }

当调用一个需要传入 block 的方法时,需要了解有多少参数将会传入 block (注意,是传入 block!):例如,对 each 方法来讲,传入 block 的参数为 \(1\),但第二个例子并不需要这个参数,此时,我们可以省略 block 中的 |i|

以下再介绍一些需要传入 block 执行的数组方法:

a = Array.new(5) { |i| 4*(i+1) }

执行该程序后 \(a\) 的结果是 \([4, 8, 12, 16, 20]\)

new 方法中传入 block 可以用于新数组的初始化

a.map { |x| 2*x }

经典的高阶函数 map,block 承担了提供匿名函数的作用

该程序返回数组 \([8, 16, 24, 32, 40]\)

a.any? { |x| x > 3 }   # return true
a.all? { |x| x > 10 } # return false

询问数组有无任何 (any?) 或是所有 (all?) 元素都符合某个条件

当不向这两个方法中传入 block 时,默认判断数组中有无任何或是所有元素是除了 nilfalse 之外的元素 (只有这两个对象在 ruby 中 evaluate to false)、

a.select { |x| x > 7 && x < 18 }

经典的高阶函数 filter,筛选数组中符合条件的元素

该程序返回数组 \([8, 12, 16]\)

Using blocks

我们可以定义自己的使用 blocks 的方法 (虽然 ruby 丰富的标准库使得这样做的机会很少)

我们可以将 block 传入任意 (any) 一个方法,在方法的主体 (method body) 中使用关键词 yield 来调用 block

def foo x
if x
yield
else
yield
yield
end
end
foo (true) { puts "hi" }
foo (false) { puts "hi" }

上面这段代码执行后输出三行 hi

若在调用 block 时需要向 block 中传入参数,我们在 yield 后写入参数,如 yield 7yield(8, "str")

当未向某个方法中传入 block 却执行了 yield 语句时,程序会会出现错误

def count i
if yield i
1
else
1 + (count (i + 1) { |x| yield x } )
end
end

以上这个递归方法统计以逐渐增加的参数调用多少次 block 才能返回 true

我们可以注意到一个奇怪的 block:{ |x| yield x }

这是由于 block 是匿名的,我们无法将当前的 block (caller's block) 直接传入下一层调用 (callee's block)

因此我们创建一个新 block,这个 block 通过 lexical scope 相当于把这一层的 block 传到下一层

The Proc Class

首先,我们再次强调 Block 是 次等的 (second-class):它无法被对象储存,无法作为正常的参数或者返回值

它虽然功能与 closure 相似,但始终不是真正的 closure

但是我们可以将其转变为真正的,头等的 (first-class) closure

在 ruby 中,closure 是 Proc 类的实例,我们用关键字 call 来对其进行调用

将 block 转化为 Proc 实例很简单,只需要在前面加上 lambda 即可

有趣的是,lambda 并不是关键字,它只是 Object 类中的一个方法

在大多数情况下,我们使用 Block 就足够了

但当我们想要对 Block 进行储存时,就需要转化成头等的 Proc

a = [3, 5, 7, 9]
c = a.map { |x| { |y| x >= y } } # syntax error
c = a.map { |x| lambda { |y| x >= y } }
c[2].call 17 # use call to call Proc

Hashes and Ranges

接下来我们介绍标准库类:HashRange (其实就是 C++/Python 中的 map 与 Python 中的 range)

hash 可以说是一种特殊的 array,它是任意对象到对象的映射,如果 \(a\) 映射 \(b\),我们说 \(a\) 是键值 (key) \(b\) 是值 (value)

所以 hash 是一系列键值到值映射的集合,其中键值与值都可以是任意的对象

创建一个 hash

h1 = {"SML" => 1, "Racket" => 2, "Ruby" => 3}
h2 = {:sml => 1, :racket => 2, ruby => 3} # use Ruby symbol as key value

访问/修改 hash 中元素

h1["sml"] = false
h1[false]
h1[false] = true
h1[nil] = "nil"

当 hash 中未查询到对应键值时返回 nil

一些方法

h1.delete false # given a key, remove it and its value from the hash
h1.keys # return an array of all keys
h1.values

还有 each, inject 等方法,与 array 中的同名方法功能一致

range 代表的则是一段连续的数字,例如 \(1..100\) (两个点) 就代表数字 \(1, 2, 3, ..., 100\)

虽然我们也可以用 array 来表示,Array.new(100) { |i| i },但用 range 表示更加简单且效率更高

range 可以一定程度上代替循环语句 loop,如 (0...n).each {|i| e}

array, hash 与 range 共享了许多同名函方法:这使得 duck typing 可以得到很好的体现

例如以下这个方法:

def foo a
a.count { |x| x*x < 50 }
end

我们可能很自然的认为 \(a\) 应该是一个 array,但实际上,hash,range 都可以传入并进行 count 统计

Subclassing and Inheritance

基本思想与术语 (Idea & terminology)

子类 (subclass) 是基于类的 OOP 的重要特征

如果类 \(C\) 是类 \(D\) 的子类,那么每个 \(C\) 类的实例都同时是其父类 (superclass) \(D\) 的实例

类 \(C\) 的定义将会 继承 (inherit) 其父类 \(D\) 的所有方法 (也就是说,类 \(D\) 的定义直接成为类 \(C\) 定义的一部分)

同样,子类 \(C\) 也可以通过直接改变定义的方式 覆盖 (override) 继承自父类 \(D\) 的方法

这里提一个很有趣的差异:在 \(Java\), \(C++\) 中,除了方法,子类也会继承父类的所有 成员变量/属性 (field, i.e. instance varaible in Ruby) 的定义,而 Ruby 不会

这是因为在 Ruby 中,实例变量并不是类定义的一部分,每个类的实例将会创建自己的实例变量 (each object instance just creates its own instance variables)

在 Ruby 中除了 BasicObject 类 (Object 类的父类) 以外的所有类都是有自己的父类的

这些类形成了一棵树:它们的父节点是自己的父类,而这棵树的根节点是 BasicObject。在基于类的 OOP 语言中,这叫 \(class hierarchy\) (类层次结构)

根据子类的定义,一个类的定义中包含它所有祖先的所有方法 (允许覆盖)

Some Ruby Specifics

    class C < D ... end 代表类 \(C\) 是类 \(D\) 的子类。当忽略 < D 时将解读为 < object

    也就是说,我们自定义的任何类都是 object 类的子类

    每个对象都有 class 方法,调用某个对象的 class 方法将返回该对象所属的 class

    这里我们需要强调,返回的class 本身也是Ruby 中的一个对象 (毕竟,在 Ruby 中,Every value is an object)。这个对象是 Class 类的实例

    另外,Class 类中还定义了 superclass 方法,返回某个对象的父类

    每个对象都有 is_a? 方法与 instance_of? 方法。

    is_a? 方法读入一个类 (例,x.is_a? Integer),若调用该方法的对象是该类或该类的后代类(transitive subclasses)的实例返回真

    instance_of? 方法相似,但是当且仅当调用该方法的对象是该类的实例才返回真

    注意,调用 is_a?instance_of? 方法并 不是很 "面向对象" (less object-oriented),因为调用它们经常与鸭子类型 (duck typing) 相冲突

A first example: Point and colorpoint

以下两个类分别代表了一个二维平面上的点,以及包含了颜色属性的二维平面点

class Point
attr_accessor :x, :y
def initialize(x, y)
@x = x
@y = y
end
def distFromOrigin
Math.sqrt(@x * @x + @y * @y)
end
def distFromOrigin2
Math.sqrt(x * x + y * y) ; essentially the same as above
end
end class ColorPoint < Point
attr_accessor :color
def initialize(x, y, c = "clear")
super(x, y) ; keyword "super"
@color = c
end
end

通过 getter/setter 方法使得实例变量 x, y, color 可修改 (mutable)
ColorPoint 的实例变量 color 初始化为 "clear"
在类ColorPoint中,我们将继承的 initialize 方法覆盖 (override) 了:这里我们用到了 super 关键字,可以令我们调用父类中的同名方法(覆盖之前的方法)

Why use subclassing?

    继承 (inheritance) 机制使得我们能够重复利用父类的定义
    完美的契合了鸭子定义 (duck typing),一般来说,子类作为 "类鸭子" 可以使用参数为父类的方法

    另外,我们知道子类的实例也可以当作父类的实例来对待
    OOP 语言编程者往往会过度使用 (overuse) 子类。

    注意,当 类 \(A\) 是类 \(B\) 独立的一部分 (as a seperate sub-part of it) 时,我们应该在类 \(B\) 的实例中创造属于类 \(A\) 的实例变量而不是使用子类和继承。

Overriding and Dynamic Dispatch

接下来我们来考虑 Point 的另外一个子类 ThreeDPoint

class ThreeDPoint < Point
attr_accessor :z
def initialize(x, y, z)
super(x, y)
@z = z
end
def distFromOrigin
d = super # call superclass's same-name method
Math.sqrt(d * d + @z * @z)
end
def distFromOrigin2
d = super
Math.sqrt(d * d + z * z)
end
end

我们提到,使用子类的一大好处是代码的复用性:在这个例子中,我们复用的代码局限于 x, y, x=y= 以及 super 所调用的父类方法

计算机科学家们争论了很长时间以上这段代码究竟是不是 good style

虽然 ThreeDPoint 确实复用了一部分代码

但是三维点在概念上 (conceptually) 并不是二维点的延申:所以,如果将三维点实例传入某些期待 (expect) 二维点参数的方法会带来理解上的问题

也有人说可以将二维点看作是三维点在平面上的投影,其 \(z=0\)

从这个例子我们可以看出:subclassing 有时是 bad/confusing style 虽然的确可以复用代码

接下来我们看一个更有趣的例子:Point 的另外一个子类 PolarPoint

这个类的实例除了向 initialize 方法中传入的参数以外Point 类实例的表现等价 (behave equivalently),也就是说,从外部看来,Point 类实例与 PolarPoint 类的实例完全等价,甚至可以互相替换 (interchange)

但是,在它的定义中,与父类 Point 采取的坐标表示法不同,PolarPoint 采取的是角度表示法

class PolarPoint < Point
def initialize(r, theta)
@r = r
@theta = theta
end
def x
@r * Math.cos(@theta)
end
def y
@r * Math.sin(@theta)
end
def x= a
b = y # temporary variable, avoid mutiple calls to y method
@theta = Math.atan(b / a)
@r = Math.sqrt(a*a + b*b)
self
end
def y= b
a = y
@theta = Math.atan(b / a)
@r = Math.sqrt(a*a + b*b)
self
end
def distFromOrigin
@r
end
# No need to override distFromOrigin2, it already works!!
end

注意 PolarPoint 类型的实例并没有实例变量 @x@y (在 PolarPoint 定义中的 initialize 方法并没有使用 super 关键字)。在 Java/C++ 中,我们可以视作子类继承了父类的成员变量,却没有进行使用

但是类定义中却覆盖 (override) 了 x, y, x=y= 方法,这样用户 (client) 就无法分辨 Point, PolarPoint 在具体实现上的分别

在这个例子中我们想要强调的重点是:即使子类 PolarPoint 并没有覆盖 (override) distFromOrigin2,继承的方法仍然能正常运行

我们来看看原因:

在父类 (superclass) 中 distFromOrigin2 的定义如下:

def distFromOrigin2
Math.sqrt(x * x + y * y)
end

distFromOrigin 直接使用实例变量 @x@y 不同,distFromOrigin 将调用的方法返回值作为参数进行乘法运算。在父类 Point 类中,这里调用的方法是实例变量 @x@y 的 Getter

将这个语法糖拆开,定义原本其实是这样的

def distFromOrigin2
Math.sqrt(self.x() * self.x() + self.y() * self.y())
end

在子类 PolarPoint 中,由于 initialize 方法的不同,是没有实例变量 @x@y

然而,对在父类中担任 Getter 作用的方法 x y 的覆盖使得 distFromOrigin2 在子类实例中的表现不同

可以这么说,当父类调用 distFromOrigin2 时,在其中调用的方法是父类定义的 xy;而当子类调用同样的,继承自父类的 distFromOrigin2 时,在其中调用的是子类的 x, y;

同样的方法,在父类与子类中调用的方法却不同,这就是所谓"表现不同"的含义

这一 semantic 有许多名字,\(dynamic\) \(dispatch\) (动态调度),\(late\) \(binding\) (晚绑定),\(virtual\) \(method\) \(call\) (虚方法调用)

看到有一个熟悉的名字:这个是不是和 \(C++\) 中的虚函数有点像???

对比一下 \(C++\) 中的虚函数定义:通过基类指针调用虚函数时,若指针指向基类对象,则被调用的是基类的虚函数;若指向的是派生类对象,则被调用的是派生类的虚函数

是不是在本质上很相似?不同的是,在 \(C++\) 中,为了实现这个功能,需要在相关函数定义之前加上 virtual 关键字

我写了以下两段 \(C++\) 代码以便理解与测试:

#include <iostream>

using namespace std;

class Base {
public:
int x() { return 1; }
int y() { return 1; }
void put_ans() {
cout << x() + y() << endl;
}
}; class Derived : public Base {
public:
int x() { return 2; }
int y() { return 2; }
}; int main() { Base a;
a.put_ans();
Derived b;
b.put_ans(); return 0;
}

以上这段代码的运行结果是

2
2

可以发现,在没加 virtual 关键字时,put_ans() 函数仍然只能调用父类中的 x()y() 函数

接下来我们加上 virtual 关键字

#include <iostream>

using namespace std;

class Base {
public:
virtual int x() { return 1; }
virtual int y() { return 1; }
void put_ans() {
cout << x() + y() << endl;
}
}; class Derived : public Base {
public:
int x() { return 2; }
int y() { return 2; }
}; int main() { Base a;
a.put_ans();
Derived b;
b.put_ans(); return 0;
}

运行结果为

2
4

成功的调用了子类中被覆盖过的 x()y() 函数,实现了调用同样的方法 (put_ans()) 在父类与子类中的不同表现

与 Ruby 中的结果一样:这下我们就能更好的理解为什么 Ruby 的这个 semantic 又叫 \(virtual\) \(method\) \(call\) (虚方法调用) 了

The Precise Definition of Method Lookup *

这个章节十分重要:无论在什么语言中,variable-lookup 以及 function-lookup 的规则都是核心的 semantic,值得我们仔细探讨

在 ML 与 Racket 中,variable-lookup 与 function-lookup 的规则依靠于 词法作用域 lexical scope函数闭包 function closure

并且在 Racket 中,我们有三种不同的 let 表达式:这三种表达式分别有着三种不同 lookup variable 的 semantic

这些我们在前面的章节已经详细介绍过

在 Ruby 中,variable-lookup 与 method-lookup 的规则离不开 self 这个特殊的对象

首先,对于方法与 \(block\) 中的本地变量 (local variable) ,variable-lookup 的规则与 ML,Racket 差别不大 (即,使用 \(lexical\) \(scope\) 与 \(closure\))

但是对于 方法 (method),实例变量 (instance variable) 与 类变量 (class variable),答案就决定于 self 绑定的对象了

并且这些与对象有关的变量/方法的储存方式并不是添加进某个环境中,而更类似于以该对象为 \(record\) 的若干线性排列的 \(field\)

在任意环境 (any environment) 中,self 都与 当前对象 (current object) (即正在执行方法的对象) 进行绑定

lookup 实例变量 @x

直接使用 self 绑定的对象,在其状态 (state) 中查找 @x:若未查找到,返回 nil
lookup 类对象 @@x

self.class 绑定的对象的状态中查找 @@x:若未查找到,返回 nil
lookup 某个方法

调用某个对象的方法语句:e0.m(e1, e2, ... en)

evaluate \(e0\), \(e1\), ...\(en\) to values (即 \(obj1\), \(obj2\), ... \(objn\), 在 Ruby 中所有值都是对象)
获取 \(obj0\) 的类 (class)。每个对象的类可以说是其状态 (state) 的一部分,而对象的状态可以在程序运行期间 (knows its class at run-time) 获取
\(obj0\) 的类为 \(A\)

若 \(A\) 中定义了 \(m\),则调用该方法;否则追溯至 \(A\) 的父类 check 下是否定义了 \(m\),以此类推

如果未在 \(A\) 或 \(A\) 的任意父类中找到 \(m\) 的定义,则弹出 \(method\) \(missing\) 错误

(想象以下这个过程,在类层次结构树中不断向根寻找 \(m\) 的定义,若找到立刻在对应的类中进行调用)
现在我们已经找到了需要被调用的方法 \(m\)。

若该方法有形参 (formal parametre) \(x1\), \(x2\), ... \(xn\),我们将之前 evaluate 的 \(obj1\), ... \(objn\) 进行逐个绑定并在对应环境中 evaluate method body

与 functional programming 最核心的区别是:环境中永远存在 self。当 evaluate method body 时,self 与 \(obj0\) 绑定,也就是作为收信人的对象 (the object that is the "receiver" of the message)

先明确一下 caller 与 callee 的概念:在以上例子中,caller 是 \(obj0\),callee 是 \(m\)

而 \(m\) 的定义可能并不在 \(obj0\) 所属类 \(A\) 的定义中,而在其某个祖先类 \(A_f\) 的定义中

在调用 \(A_f\) 类中定义的 callee: \(m\) 方法时,self 仍然和 caller:\(obj0\) 相绑定,这就是我们所说的 late-binding, 或者说 dynamic dispatch 或者 virtual method calls

作为 Ruby 与其他 OOP 语言的核心 semantic 之一,这意味着当 \(m\) 的主体部分对 self 调用方法时 (例,self.someMethod 34 或者直接 someMethod 34),我们用 \(obj0\) 所属的类来执行此 someMethod,而 \(obj0\) 所属的类不一定是当前执行 \(m\) 的类

关于:

Ruby 的 \(mixins\) 会使得 lookup rules 更加复杂,后面再做介绍*
Ruby 的 semantic 确实比 ML/Racket 要复杂。与 ML/Racket 仅需维护一个环境 (environment) 相比,Ruby 还需要特殊处理 self 这一概念 (We have to treat the notion of self differently from everything else in the language)
Java 与 C# 的 lookup rules 更为复杂:这是因为它们有 static overload (静态重载),也就是说,参数表不同的方法可以共用一个名字,根据调用时提供的参数决定调用的方法:这都需要依靠 static type system,而 Ruby 并没有。C++ 提供了更多可能性,可以选择支不支持 dynamic dispatch

Dynamic Dispatch Manually in Racket

具体见 section8sum.pdf

用 Racket 的 struct 模拟了 Dynamic dispatch,很值得一看!

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

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

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