Programming Languages, UW, Part C - Ruby Basic

作为一门研究 programming languages 的课程,OOP 是一个避不开的话题。这一节通过介绍 Ruby —— a dynamically typed OOP language,来引出 functional v.s. OOP 这一历史悠久的命题。

Taken together, ML, Racket, and Ruby cover three of the four combinations of functional v.s. object-oriented and statically v.s. dynamically typed.

Part C 的内容非常多,因此也分为两节笔记;但这里将不会详细记录 Ruby 的 semantics。若想参考这部分的内容,详阅我在博客园上的笔记或课程提供的总结材料 section8sum.pdf。


  This article is a self-administered course note.

  It will NOT cover any exam or assignment related content.


The Rules of Class-Based OOP

Object-oriented programming, which we abbreviate OOP, is as follows:

  • All values (as usual, the result of evaluating expressions) are references to objects.
  • Given an object, code "communicate with it" by calling its methods. A synonym for calling a method is sending a message.
  • Each object has its own private state. Only an object's methods can directly access or update it.
  • Every object is an instance of a class.
  • An object's class determines the object's behavior.

这些规则在绝大多数 OOP 语言中成立,但仍有例外:例如在 Java 或 C# 中,某些值 (numbers) 并不被视为对象,这违反了规则 1;并且存在一些方法使得对象的私有域被公有访问,这违反了规则 3。

比起这些语言,Ruby 将以上的规则贯彻的更加彻底,因此有时其也被称为 pure OOP language.


Everything is an Object

Ruby 彻底贯彻了 Everything is an object 这个最基础的 OOP 思想,甚至连数,布尔值与空值 nil (as null in Java) 和类 (class) 本身都属于对象。

1
2
3
4
> -42.abs
42
> "hello".nil?
false

-42Fixnum 类的对象。在该类中定义了方法 abs,因此 -42 可以直接调用该方法。所有的对象都定义了 nil? 方法;对于 nil 类的对象,该方法返回 true;反之返回 false

注意到以上的代码都是在 REPL 中进行的:这是因为 Ruby 提供了很多方法支持 reflection。举例来说,methods 方法返回一个 list,其中存储某个对象所定义的全部方法;而 class 方法则返回某个对象的类。这些方法均可以在程序运行时进行调用。

reflection. Learning about objects and their definition during program execution.

Such reflection is occasionally useful in writing flexible code; it is also useful in the REPL or for debugging. Refer to this article for detailed explanation.


Dynamic Class Definition

A Ruby program (or a user of the REPL) can change class definition while a Ruby program is running.

很符合我对 dynamically typed language 的想象:Ruby 真是把这一点做到了极致。在运行时改变类的定义后,所有该类的实例,甚至包括修改之前创造的实例都将发生改变。

这是因为 Ruby 严格遵循了这一 OOP 规则:Every object has a class and the (current) class (definition) defines an object's behavior.

这一 feature 经常被人质疑,因为其破坏了封装 (breaks abstraction):如果我对方法 + (计算两数之和) 进行更改或直接删除,那么绝大多数程序都将无法正常运行。

但其确实使得 language definition 变得更加简单:Defining classes and changing their definitions is just a run-time operation like everything else.

对类的定义进行动态更改 (具体表现在修改已有的方法或添加新的方法) 的语法十分简单:Just give a class definition including method definitions for a class that is already defined. 若给出的方法已被定义 (与已有的方法重名),则其将替换类中原有的方法定义;反之,其将作为一个新方法加入类的定义中。


Duck Typing

If it walks like a duck and quacks like a duck, then it's a duck.

In Ruby, duck typing refers to the idea that the class of an object (e.g., "Duck") passed to a method is not important so long as the object can repond to all the messages it is expected to (e.g., "walk to x" or "quack now"). For example:

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

当我们看到这个方法时,很自然的认为其应该作用于某个 Point 类的对象,并对该对象的 @x 域取相反数 (并且该类定义了 @x 域的 getter/setter)。但我们发现该方法并没有对参数的类型进行检查 (例如使用 Point? 方法),因此 duck typing is applicable。以下是不同 level 的 duck typing:

  • foo 类定义了实例变量 @x 与其对应的 getter/setter,那么 foo 类的对象也能作为参数传入方法。
  • bar 类未定义实例变量 @x,但其定义了返回值为数字的方法 xx=,那么 bar 类的对象也能作为参数传入方法。(甚至 x 方法不需要返回数字,它只需要回应以 -1 为参数的 * 信息即可)

因此, Point 类的对象即为 duck objects,而 foo 类对象或 bar 类对象为 duck-like objects。foobar 类对象虽然不是鸭子,但却能像鸭子一样走 (方法 x) 或叫 (方法 x=)。更经典的例子是某个类与其的子类 (subclass):一般来说,子类对象可以作为 duck-like objects 传入期待父类对象的方法。

Duck typing 能够提高代码的复用性 (reusability),使得 duck-like objects 也能作为 duck objects 作为参数传入方法当中。在 Ruby 中 duck typing 的实现非常简单:

In Ruby, duck typing basically comes for free as long you do not explicitly check that arguments are instances of particular classes using methods like instance_of? or is_a?

当然,duck typing 也有弊端。在该风格中,一个对象有效的语义,并不是继承自特定的类或实现特定的接口,而是由当前方法和属性的集合决定。也就是说,一个“走起来像鸭子”并且“叫起来像鸭子”的对象,可以是鸭子,也可以是类似鸭子的物种,但也可以是一只正在模仿鸭子的龙。但我们“并不总是想让龙进入池塘”。

因此在使用 duck typing 时,程序员必须很好的理解他正在编写的代码。


Passing Blocks

最常见,最简单,最富争议,最具有 Ruby 风格方式的闭包是 blocks。

接下来我们介绍 Ruby 中一个非常有特色的 feature —— blocks,它的存在使得 whilefor 这种传统的循环控制语句在 Ruby 中的使用率大大降低。例如,以下程序将重复输出三次 hi:

1
2
x = 3
x.times { puts "hi" }

These blocks are almost closures. 也就是说,blocks 是一种形式的 function value,其包含:

  • code of function body.
  • environment: variables in scope where the block is defined.

each 是一个常见的与 blocks 交互的方法。对于 list 中的每个元素,each 方法执行一次传入的 block。执行下述程序后变量 y 将变为 10:这是因为对于数组中的每个元素,each 都将执行一次 y += 1 语句。

1
2
y = 7
[4, 6, 8].each { y += 1 }

另外,block 也能 take arguments:与函数一样,block is executed under the environment (where it is defined) extended with the argument bindings. 在 block 中定义形参的语法是 { |i| e }do |i| e end。其中 i 为输入 block 的参数,e 为被执行的代码主体。

当某个 block { |i| e } 被传入 each 方法时,list 中的每个元素将会作为参数逐个传入 block,与 i 绑定后在扩展后的环境中执行 e。以下是一个经典的统计数组中数字之和的程序,执行它将逐行输出 4,10,18。

1
2
3
4
5
sum = 0
[4, 6, 8].each { |x|
sum += x
puts sum
}

Blocks, surprisingly, are NOT objects.

We cannot pass blocks as "regular" arguments to a method. Rather, any method can be passed either 0 or 1 blocks, after any other "regular" arguments.

inject 方法与 ML 中的 fold 类似,其维护一个参数 accumulator 并逐个处理数组中的元素。我们可以看到,inject 本身需要传入 accumulator 的初始值,此即为所谓 regular argument;而作为非对象的 block 在所有 regular argument 之后被传入。

1
sum = [4,6,8].inject(0) { |acc, elt| acc + elt }

inject 方法而言,传入其中的 block 可以有两个参数 acc, elt。其中 accinject 中的 accumulator 绑定,elt 逐个与数组中的元素绑定。

当调用允许传入 blocks 的方法时,我们需要了解该方法允许向 blocks 中传入参数的个数。对 inject 方法来说,允许的参数个数为 2;对 each 方法来说,允许的参数个数为 1;但当我们不需要向 blocks 中传入参数时,在定义中忽略 | ... | 部分即可。

在 Ruby 中,很多容器 (collections) 都定义了大量的 block-taking methods;这使得它在某种程度上实现了一部分 functional programming 的功能。

我们可以模仿 ML 中的定义方式,将 blocks 视作某个匿名函数的值,实现 mapfilter (select as in Ruby) 等类高阶函数。any?, all? 等遍历方法结合 blocks 更是在很多应用环境下替代了循环控制语句。


Using Blocks

We can define our own block-taking methods. We just pass a block to any method, and method body calls the block using the keyword yield.

1
2
3
4
5
6
7
8
9
10
def foo x
if x
yield
else
yield
yield
end
end
foo (true) { puts "true" }
foo (false) { puts "false" }

The above code prints "true" and then prints "false" 2 times. We could also pass arguments to a block by simply putting arguments after the yield, e.g., yield 7 or yield(8, "str").

The fact that a method may expect block is implicit; it is just that its body might use yield.

当方法的主体部分中使用了 yield 但调用方法时未向其传入 blocks,程序将会报错;In situations where a method may or may not expect a block, often other regular arguments determine whether a block should be present. If not, use block_given? method.

1
2
3
4
5
6
7
def count i
if yield i
1
else
1 + (count(i+1) { |x| yield x })
end
end

以上的方法将以递增的参数调用传入的 block,并统计最终在第几次调用时返回 true。注意到这个奇怪的 block:{ |x| yield x },it passes the caller's block as the callee's block argument.

在第 n + 1 层递归中,yield 将调用这个 "pipe" block。而其主体部分 { |x| yield x } 中又将执行一次 yield;而在参数绑定之后,该 yield 调用的是第 n 层递归中的 block。

这就是 lexical scope 的威力:它保证了 pipe block { |x| yield x } 被执行时的环境是其被定义处的环境 (即第n 层递归中的环境),以这种方式实现了 block 由 caller-side 到 callee-side 的传递。

Blocks are not first-class values: we cannot store them in a field, pass them as a regular method argument, assign them to a variable, put them in an array, etc.

再次强调,blocks 并不是对象:因此 (在 Ruby 中) 其并非“一等公民”。这是它与 closure 最大的区别。但是 Ruby 提供了将 blocks 升级为一等公民的方式。

Proc 类是真正的 closure,其实例化的对象可以被储存,作为普通的参数传入函数……我们只需要调用 lambda 方法,就能将 blocks 升级为 Proc 类的对象。

Ruby's design is an interesting contrast from ML and Racket, which just provide full closures as the natural choice. In Ruby, blocks are more convenient to use then Proc objects and suffice in most uses, but programmers still have Proc objects when needed. 这是一个很值得探讨的问题:

Is it better to distinguish blocks from closures and make the more common case easier with a less powerful construct, or is it better just to have one general fully powerful feature?


Dynamic Dispatch

考虑类 Point

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point
attr_accessor :x, :y # define getter/setter for fields x, y
def initialize(x, y)
@x = x
@y = y
end
def disFromOrigin
Math.sqrt(@x * @x + @y * @y)
end
def disFromOrigin2
Math.sqrt(x * x + y * y) # call getters of x, y instead of directly accessing them
end
end

接下来我们来考虑类 Point 的一个有趣的子类 PolarPoint:它内部采用弧度表示法而非坐标表示法来表示某个点,其余方法与父类等价。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 # avoid mutiple calls to method y
@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

注意,在 initialize 中没有使用 super 调用父类中的同名初始化方法,所以实际上类 PolarPoint 是没有定义实例变量 @x@y 的;但它 override 了方法 x, x=, yy=,使得用户无法将其与类 Point 进行区别。而在 Java/C++ 中,子类默认将继承父类中的实例变量,你可以选择是否使用它们。

The key point of this example is that the class does not override distFromOrigin2, but the inherited method works correctly.

这是为什么呢?我们来看 PolarPoint 类从父类中继承的 DistFromOrigin2 方法的定义:

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

distFromOrigin 方法不同,distFromOrigin2 方法使用调用其他方法 (self.x()self.y()) 的结果来计算距离而不是直接使用实例变量 @x@y 中储存的值。

然而,PolarPoint 类 override 了方法 xy;这使得继承自 Point 类的方法 DistFromOrigin2 的表现 (behavior) 发生了改变。虽然是相同的代码,但在子类与父类中却分别调用了不同的方法。

这一 semantic 被称之为 dynamic dispatch。又名 late bindingvirtual method call。这是一个十分 OOP 的 semantic —— 它涉及到环境中 self 的特殊处理。

我们看到一个熟悉的名字 virtual method:事实上,C++ 中的虚函数采用的也是该 semantics。虚函数的定义是:通过基类指针调用虚函数时,若指针指向基类对象,则被调用的是基类的虚函数;若指向的是派生类对象,则被调用的是派生类的虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <string>

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\n4;也就是说,派生类 Derived 类由基类中继承的函数 put_ans() 调用的是派生类中被 override 的函数 x()y(),实现了调用同样的方法 put_ans() 在基类与派生类中的不同表现。

注意,在 C++ 中 dynamic dispatch 所依赖的函数需要在基类中添加关键字 virtual 声明其为虚函数;在上例中,若 x()y() 函数未声明为虚函数,代码的运行结果将变为 2\n2


Method Lookup Rules

无论对于什么语言,如何 lookup 这一规则都是核心的 semantic,值得我们仔细探讨。

  • variable-lookup: 如何“找到”定义的某个变量?
  • function/method-lookup: 如何“找到”某个需要调用的函数/方法?

在 Ruby 中,对于 local variables,blocks 的 lookup 与 ML 和 Racket 中没有区别;它们都遵循 lexical scope 定义的规则。但对于 instance variables,class variables 与 methods 的 lookup 牵扯到 self 这一特殊的指针。

In any environment. self maps to some object, which we think of as the "current object" — the object currently executing a method.

To look up an instance variable @x, use the object bound to self - each object has its own state and we use self's state. To look up a class variable @@x, use the state of the object bound to self.class.

methods 的 lookup 规则是最为复杂的:In class-based OOP languages like Ruby, the rule for evaluating a method call like e0.m(e1, ..., en) is:

  • Evaluate e0, e1, ..., en to values, i.e., objects obj0, obj1, ..., objn.

  • Get the class of obj0. Every object knows its class at run-time; think of the class as part of the state of obj0.

  • Suppose obj0 has class A. 从类 A 开始沿着 hierarchy 不断往上攀升:If m is defined in A, call that method. Otherwise recur with the superclass of A to see if it defines m.

  • We have now found the method to call. If the method has formal arguments x1, x2, ..., xn, then the environment for evaluating the body will map x1 to obj1, x2 to obj2, etc.

    But there is one more thing that is the essence of OOP and has no real analogue in functional programming: We always have self in the environment. While evaluating the method body, self is bound to obj0, the object that is the receiver of the message.

selfobj0 进行绑定,这一规则就是所谓的 late-binding,或 dynamic dispatch 与 virtual method calls。这意味着,when the body of m calls a method on self, we use the class of obj0 to resolve someMethod, not necessarily the class of the method we are executing.

注意,我们仅仅使用最简洁的方式描述了 method lookup 的规则框架,Ruby 中存在许多其他的 features (如 mixins) 令我们在该框架下需要考虑更多的情况。此外,在 Java 与 C# 中,method lookup 的规则大致与 Ruby 相同,但更加复杂,我们还需要考虑 static overloading 所带来的影响。


Reference

  This article is a self-administered course note.

  References in the article are from corresponding course materials if not specified.

Supplementry notes:

My notes in CnBlogs.

知乎 - 为什么语言要提供 “反射” 功能?.

Course info:

Programming Languages, Part C, University of Washington, Lecturer: Professor Dan Grossman.

-----------------------------------そして、次の曲が始まるのです。-----------------------------------