在前面两篇中,主要讲了函数式编程语言的一些基础概念。这篇是 Coursera Programming Languages, Part C 的总结,通过 Ruby 介绍面向对象编程里的一些概念。了解这些概念能让你在上手任何一门新的面向对象语言时,都更加得心应手。
虽然用的是 Ruby,但是不会涉及很深的 Ruby,即使不懂 Ruby,读下来应该没问题。对于已经了解面向对象编程的朋友,可以考虑直接跳到子类和继承那部分,或许你会有一些新的启发。
面向对象编程 & Ruby
面向对象编程(Object Oriented Programming)简称 OOP,像 Java,C++ 等语言的主要编程模式都是面向对象的。OOP 主要是通过 对象(Object) 来抽象表示,比如说平面上的一个点,可以抽象表示成 Point(x, y)
,x,y 表示这个点的横纵坐标。在对象中可以有一些方法(methods) 来具体表示这个对象能做些什么,比如对于一个点,可定义一个 method distFromOrigin
,去求这个点的到原点的距离。
Everything is object in Ruby
Ruby 是动态类型的面向对象语言。Ruby 中所有的表达式都是一个对象,比如数字 1, 2, 3, 4 等是对象,+ 加法是对象的一个方法。Ruby 最出名的是在网页应用的开发,但是因为 Everything is object in Ruby
,被选做为这个课程的教学语言。
Class & Methods
在 OOP 中,最基本的就是怎么定义和使用对象以及对象中的方法。
定义 Class 和 Methods
Ruby 中,对象的定义通过 Class 实现。
class Foo |
这里定义了一个对象 Foo,以及两个方法 m1 和 m2。
调用方法
foo = Foo.new |
上例是最简单的方法调用。
e0.m(e1, ..., en) |
这是调用方法的抽象表达,可以有另一种理解: 发送消息,e0 是消息的接受者,可以说将 e1 的结果作为参数放在消息 m 里,发送给 e0。
在 Ruby 中,所有表达式都是对象,e1 + e2
实际上是 e1.+ e2
的简写法,e1 调用了 方法 +,e2 是这个方法调用的参数。
定义 实例变量、类变量、类常量、类方法
class Foo |
上例中,@f
为实例变量 (instance variable) ,@@f
为类变量 (class variable) ,Const
是类常量 (Class constant),classMethod
是类方法。
怎么使用类常量和类方法?
Foo::Const |
别名 Aliasing
foo = Foo.new |
在这个例子中,bar 是 foo 的别名,他们对应是同一个 object,如果 bar 更改了,foo 也会相对应的更改。在 OOP 中,不像函数式编程里数据不可更改,需要特别注意什么时候用别名,什么时候要新建一个 object。
可见性 Visibility
对象内的变量和方法根据不同的定义方式,对象外不一定可见,可以理解为知不知道它的存在。
class Foo |
实例变量 @foo
在 class Foo 外是不知道它的存在的,foo.@foo
会报错,实例变量只有对象内的方法 m1, m2, m3 可以使用。
这样的设计其实很符合面向对象的概念,Foo 是一个对象,要跟这个对象交流,只能通过发送消息,也就是调用方法的方式。
对象内的方法也可以定义可见性。
- public,表示对外可见,Ruby 对象默认的模式,在类之外可以调用。
- protected 和 private 只能在 类和子类都能调用。
Getter & Setter
对象的实例变量对外不可见,但是很多情况下可能会需要实例变量,这个时候可以通过定义 Getter 和 Setter 两个方法来实现实例变量的读写。
# getter |
反射 Reflections
反射指的是在程序运行的过程中,能访问、检测和修改它本身的这个对象。比如说可以在运行的过程中去访问这个对象里都有哪些方法,然后动态的去调用这些方法。
鸭子类型 Duck Typing
当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。
def mirror_update point |
这个方法本意是将一个点 (point) 的 x 值变为 -x,但是所有”像点一样有 x 这个方法”的对象都可以作为参数传入这个函数,这就是所谓的鸭子类型。通常只有动态类型的语言才会支持鸭子类型,但是 Golang 也是有办法实现,这里不展开了。
优缺点
鸭子类型的最大的优点在于代码的重用,同样一段代码,传进的参数只要有那些方法,就能够复用这段代码。但是也带来了缺点,重用使得代码表意不清,我们在用这个方法时,考虑的不是这个方法怎么调用,而是要清楚的了解这个方法的具体实现。举个例子:
def double x |
看到 double 这个函数名,可能最直观的理解就是把数字翻倍,具体的实现 x + x
,也可以把两个字符串连在一起。但假如 Ruby 的字符串没有 *
这个方法,具体实现是 2*x
的话,这个函数就不适用于字符串。所以知道一个函数名不够,还得看具体的实现,也挺麻烦的。
Blocks and Using Blocks
这是 Ruby 特有的一个特性。
def silly a |
{|b| b*2}
类似于这种的结构,在 Ruby 中是一个 Block,Block 可以放在任何方法调用后,通过方法里的 yeild
来调用 Block 里的内容。上例中,yield a
变成了 a * 2
,也就是 5*2
。如果一个方法里有 yield,就一定要传入 Block,不然会报错。
子类化和继承
子类化 (SubClassing) 和 继承 (Inheritence) 是 基于类的 OOP 里最重要也是最基础的概念。如果一个类是另一个的子类,那么这个类的实例也是另一个的实例。
class Point |
在 Ruby 中,子类化用 <
来表示,ColorPoint 是 Point 的子类,它继承了 Point 里所有的方法和变量,所以 ColorPoint 也有 distFromOrigin
的方法,也有 x, y 坐标。可以看出,通过这种继承关系,ColorPoint 不用将 Point 里关于 x, y 及计算到顶点的距离的代码再复制一遍,实现了代码的重用。
覆写和动态调度
覆写 (Overriding) 指的是在子类里和父类一样名字的方法会覆写父类的方法。
动态调度 (Dynamic Patching) 也叫 late binding 或者 virtual methods,指的是从子类去调用父类方法,父类方法里可以动态的调度子类的方法。
class A |
上例中,子类 B 中的方法 m3 覆写了 A 中的 m3,b.dynamicDispatch
调用了 A 中的 m1, m1 里 self.m2
的应该调用 A 还是 B 的 方法 m2 呢?因为发起调用的是 b
,所以虽然调用的是 A 中 m1 的 self.m2,但是会被动态的调用到 B 中的 m2。
动态调度 VS Closure
对比下吗 ML 和 Ruby 的两个例子:
fun even x = if x=0 then true else odd (x-1) |
在 ML 中,有 closure,所以后面定义的两个 even 函数,对第一个函数没有任何影响。
class A |
因为动态调度 A 中的 even 不会被调用,而是调用子类 B 和子类 C 的 even。对于程序员来说,可以像 B 一样,写出更好的 even 的代码,也可能像 C 一样写 bug。
可以说动态调度这个特性使得代码更加灵活,重用率可能更高,给了程序员更大的自由度,但是需要程序员去注意不会一不小心写了 bug。
方法查询 Method Lookup
在覆写和动态调度中,涉及到了 OOP 编程语言中怎么去查询方法的设计。在 Ruby 中,对于一个 class C 的实例调用方法 m,方法查询经过下面这些过程:
- 在 class C 中找方法 m
- 在 class C 中的 mixin (后面介绍) 中找方法 m
- 在 class C 的父类中找方法 m
- 在 class C 的父类的 mixin 中找方法 m
- 以此类推向上查找,直到找到方法 m 的定义,或者找不到返回 method missing error。
多方法 和 静态重载
多方法 (Multimethods) ** 指的是在一个类里,有多个方法有一样的名字,一样数量的参数,但是参数的类型不一样,在调用这个名字的方法时,会在动态的找到最合适类型的那个方法,进行调用。Ruby **不支持 Multimethods,下面这个例子,借用 Ruby 的语法:
class A |
在调用时,A.new.addValue ?
会根据参数类型,动态的找到最合适的方法。
Clojure 支持 Multimethods。
静态重载 (Static Overload) 和多方法一样,定义的多个方法名字是一样的,但是可以是不同数量的参数和类型。和多方法最大的不同在于,重载不是动态调用时完成的,而是静态的,感觉有点像类型检查,在程序编译时就完成了(不是很确定)。看一个 Java 的例子:
public class Sum { |
多重继承 Multiple Inheritence
多重继承指的是继承多个父类。看一个 C++ 的例子:
struct Base1 { |
多重继承比只能有一个父类的继承要复杂的多,比如多个父类的优先顺序问题,多个父类中,都有同一个方法时,method lookup 要怎么行?类型检查,class 的结构也要相对的复杂的多。所以不是所有的 OOP 都支持多重继承,C++ 是支持的,但 Java 和 Ruby 就没有。
Mixin
因为多继承的复杂性,还有单继承代码复用的局限性,有了一个新的概念可以解决这个问题,mixin。Mixin 是一些方法的合集,可以被包含在 class 里,在这个 class 里可以直接用 mixin 里的方法。
module Doubler |
上例中,Doubler 是一个 Mixin 被用在了 String 和 Point 两个类里,使得两个类都有了 double
这个方法,然后在 class 里可以像 Point 里一样去实现 double 里的 +
方法,来相加两个 Point。再看一个例子:
# you define <=> and you get ==, >, <, >=, <= from the mixin |
include Comparable
把 ==, >, <, >=, <=
这些方法都包含进去了,在 Comparable 里用 <=>
这个方法返回的结果 -1, 0, 1 来决定 ``==, >, <, >=, <=` 这些返回 true 或者 false。
感觉是一个非常巧妙的做法,让代码的重用蹭蹭蹭往上涨。也很好的解决了在只有一个继承的情况下,怎么去扩展一个 class 的实现。
接口 Interface
接口 (Interface) 是另外一个 OOP 中非常重要的概念,在接口里可以定义一些方法,但是这些方法只有名称、参数类型还有返回类型,没有方法的实现。接口的实现由别的类来完成。
interface Example { |
在这个例子中,class A 和 class B 分别实现了 接口 Example,可以看出接口的一个大的优势在于将实现和接口分离,可以很容易的更换实现,而不变接口,从客户端来说,只要知道接口是什么样的,不用管具体的实现。
像 Java 这类静态类型的语言,因为有类型检查,所以不像 Ruby 这种动态类型的语言那样灵活,Interface 的另一个优点是让这个语言有了更大的灵活性。这也是动态类型语言中没有 Interface 的原因之一,因为那些语言本身已经非常灵活了。
抽象方法 Abstract Methods
抽象方法 (Abstract Methods) 是由父类定义了一些没有实现的方法,具体实现由子类 (subclass) 去实现。
abstract class A { |
上例中,父类 A 里定义了抽象方法 m2,只有参数类型和返回类型,所有继承了 A 的子类,都需要实现 m2,比如 class B。
抽象方法、接口还有多重继承,通常一门语言里不会都包括这三项。比如 C++ 中,只有抽象方法和多重继承,所以可以定义抽象类,让子类多重继承,抽象类的作用就有点像 interface。
子类型 SubTyping
先看两个类型定义:
1. (int, int, string) |
在这例子中,1是2的子类型 (SubTyping)。如果一列类型通过省略掉其中零个或者几个类型和另一列类型一模一样,可以说这列类型是另一列类型的子类型。
子类型影响到的是静态类型中的类型检查,如果一个函数传入的参数是要求参数的子类型,类型检查该不该通过?
fun distToOrigin (p:{x:real,y:real}) = ... |
这是一个捏造的例子,distToOrigin(c)
和makePurple(c)
该不该报错呢?一门语言该不该接受子类型呢?要在什么样的程度接受子类型?
在 OOP 中,SubClass 的类型其实就是父类的子类型。在 Java 中,是支持子类型的传递,也支持一个 list 子类型的传递,但是会产生一些问题:
class Point { ... } |
在这个例子中,m1(cpt_arr)
是能通过类型检查的,但是在 m1里 ColorPoint 被设置成了 Point,没了 color 这个 attribute,在 cpt_arr[0].color
中就会出错。Java 的处理方式是在虽然通过了类型检查,但是 run-time 自在pt_arr[0] = new Point(3,4)
报错 ArrayStoreException。
更加灵活的类型系统,需要更多更灵活的程序,但是同时能避免的错误也减少了。
另一个很有一些的是 null,在 Java 中本该不是对象,也没有任何的方法,但是却像是所有类型的”子类型”,任何对象类型都可以以 null 传入。
泛型 和 子类型
泛型 (Generics) 指的是能够接受任何类型。
class Pair<T1,T2> { |
这里 T1 和 T2 可以是任何类型。在一些情况下,我们可能会要求一个类型是任何子类型的泛型。比如下面这个例子:
List<Point> inCircle(List<Point> pts, Point center, double r) { ... } |
inCircle
参数和返回的类型是 Point 的 List,如果我们有 Point 的子类 ColorPoint
,就得把这个代码复制一遍。为了代码复用,也不能把 List 设置成泛型,因为如果 List 不是 Point 的子类会出问题。有没有办法能实现一个只允许子类型的泛型?这叫做 限定多态 (bounded polymorphism)
<T extends Pt> List<T> inCircle(List<T> pts, Pt center, double r) { |
这里,通过 <T extends Pt>
实现了限定多态。
总结
在这篇文章中,介绍了很多面向对象类语言的基础概念,比如方法/变量的可见性、子类、继承、接口、抽象方法等等。不是每一门语言都包括了所有的这些概念,这里应该也只介绍了多数,不能说所有的概念。但是如果你好好读了这篇文章,以后你在碰到任何一门面向对象的编程语言时,敢说一定有很多相似处,可以让你更快的上手那门语言。通过 子类,多重继承,Mixin,接口,抽象方法等的讨论,也能让你更好的理解那门语言的特性。
这篇文章是这个系列文章的最后一篇。三篇文章分别是对 Coursera Programming Languages 三个部分的总结,介绍了函数式编程和面向对象编程的一些基础概念。这个应该是目前学到的 Coursera 上最棒的课,帮助我深入了解了 函数式编程 和 面向对象编程,虽然平时一直是在面向对象编程,但是从编程语言这个角度去解读现在和以前用过的编程语言,还是学到了许多新内容,认识到了一门编程语言在设计的过程中,都有哪些取舍,能够进一步去思考为什么这么取舍,这么取舍导致了这门语言的什么特性?