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 | > -42.abs |
数 -42
是 Fixnum
类的对象。在该类中定义了方法 abs
,因此 -42
可以直接调用该方法。所有的对象都定义了 nil?
方法;对于
nil
类的对象,该方法返回 true
;反之返回
false
。
注意到以上的代码都是在 REPL 中进行的:这是因为 Ruby
提供了很多方法支持 reflection。举例来说,methods
方法返回一个 list,其中存储某个对象所定义的全部方法;而
class
方法则返回某个对象的类。这些方法均可以在程序运行时进行调用。
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
很符合我对 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 | def mirror_update pt |
当我们看到这个方法时,很自然的认为其应该作用于某个 Point
类的对象,并对该对象的 @x
域取相反数 (并且该类定义了
@x
域的
getter/setter)。但我们发现该方法并没有对参数的类型进行检查 (例如使用
Point?
方法),因此 duck typing is applicable。以下是不同
level 的 duck typing:
- 若
foo
类定义了实例变量@x
与其对应的 getter/setter,那么foo
类的对象也能作为参数传入方法。 - 若
bar
类未定义实例变量@x
,但其定义了返回值为数字的方法x
与x=
,那么bar
类的对象也能作为参数传入方法。(甚至x
方法不需要返回数字,它只需要回应以-1
为参数的*
信息即可)
因此, Point
类的对象即为 duck objects,而
foo
类对象或 bar
类对象为 duck-like
objects。foo
与 bar
类对象虽然不是鸭子,但却能像鸭子一样走 (方法 x
) 或叫 (方法
x=
)。更经典的例子是某个类与其的子类
(subclass):一般来说,子类对象可以作为 duck-like objects
传入期待父类对象的方法。
Duck typing 能够提高代码的复用性 (reusability),使得 duck-like objects 也能作为 duck objects 作为参数传入方法当中。在 Ruby 中 duck typing 的实现非常简单:
当然,duck typing 也有弊端。在该风格中,一个对象有效的语义,并不是继承自特定的类或实现特定的接口,而是由当前方法和属性的集合决定。也就是说,一个“走起来像鸭子”并且“叫起来像鸭子”的对象,可以是鸭子,也可以是类似鸭子的物种,但也可以是一只正在模仿鸭子的龙。但我们“并不总是想让龙进入池塘”。
因此在使用 duck typing 时,程序员必须很好的理解他正在编写的代码。
Passing Blocks
最常见,最简单,最富争议,最具有 Ruby 风格方式的闭包是 blocks。
接下来我们介绍 Ruby 中一个非常有特色的 feature ——
blocks,它的存在使得 while
或 for
这种传统的循环控制语句在 Ruby
中的使用率大大降低。例如,以下程序将重复输出三次 hi:
1 | x = 3 |
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 | y = 7 |
另外,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 | sum = 0 |
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
。其中 acc
与
inject
中的 accumulator 绑定,elt
逐个与数组中的元素绑定。
当调用允许传入 blocks 的方法时,我们需要了解该方法允许向 blocks
中传入参数的个数。对 inject
方法来说,允许的参数个数为
2;对 each
方法来说,允许的参数个数为 1;但当我们不需要向
blocks 中传入参数时,在定义中忽略 | ... |
部分即可。
在 Ruby 中,很多容器 (collections) 都定义了大量的 block-taking methods;这使得它在某种程度上实现了一部分 functional programming 的功能。
我们可以模仿 ML 中的定义方式,将 blocks 视作某个匿名函数的值,实现
map
,filter
(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 | def foo x |
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")
.
当方法的主体部分中使用了 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 | def count i |
以上的方法将以递增的参数调用传入的
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 并不是对象:因此 (在 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. 这是一个很值得探讨的问题:
Dynamic Dispatch
考虑类 Point
:
1 | class Point |
接下来我们来考虑类 Point
的一个有趣的子类
PolarPoint
:它内部采用弧度表示法而非坐标表示法来表示某个点,其余方法与父类等价。
1 | class PolarPoint < Point |
注意,在 initialize
中没有使用 super
调用父类中的同名初始化方法,所以实际上类 PolarPoint
是没有定义实例变量 @x
与 @y
的;但它
override 了方法 x
, x=
, y
与 y=
,使得用户无法将其与类 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 | def distFromOrigin2 |
与 distFromOrigin
方法不同,distFromOrigin2
方法使用调用其他方法 (self.x()
与 self.y()
)
的结果来计算距离而不是直接使用实例变量 @x
与
@y
中储存的值。
然而,PolarPoint
类 override 了方法 x
与
y
;这使得继承自 Point
类的方法
DistFromOrigin2
的表现 (behavior)
发生了改变。虽然是相同的代码,但在子类与父类中却分别调用了不同的方法。
这一 semantic 被称之为 dynamic dispatch。又名 late
binding 或 virtual method call。这是一个十分 OOP 的
semantic —— 它涉及到环境中 self
的特殊处理。
我们看到一个熟悉的名字 virtual method:事实上,C++ 中的虚函数采用的也是该 semantics。虚函数的定义是:通过基类指针调用虚函数时,若指针指向基类对象,则被调用的是基类的虚函数;若指向的是派生类对象,则被调用的是派生类的虚函数。
1 |
|
以上这个代码的运行结果为 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
这一特殊的指针。
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., objectsobj0
,obj1
, ...,objn
.Get the class of
obj0
. Every object knows its class at run-time; think of the class as part of the state ofobj0
.Suppose
obj0
has classA
. 从类A
开始沿着 hierarchy 不断往上攀升:Ifm
is defined inA
, call that method. Otherwise recur with the superclass ofA
to see if it definesm
.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 mapx1
toobj1
,x2
toobj2
, 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 toobj0
, the object that is the receiver of the message.
self
与 obj0
进行绑定,这一规则就是所谓的
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:
Course info:
Programming Languages, Part C, University of Washington, Lecturer: Professor Dan Grossman.