基于原型的继承模式

继承(Inheritance)是 面向对象编程(Object Oriented Programming, OOP)的三大特性之一,其他两大特性是 封装(Encapsulation)和 多态(Polymorphism)。在编程语言中,继承的主流实现方式有两种,分别是:

  • 基于类的继承(Class-based Inheritance):绝大多数面向对象编程语言都使用了基于类的继承,比如:C++、Java、Swift、Kotlin 等。
  • 基于原型的继承(Prototype-based Inheritance):少数面向对象编程语言使用基于原型的继承,一般都是解释型编程语言,即脚本语言,比如:JavaScript、Lua、Io、Self、NewtonScript 等。

除此之外,有一些辅助继承的实现方式,比如:接口继承类型混入,一般用于实现多类型复用,可以达到类似多继承的效果。

本文,我们来简单介绍一下其中基于原型的继承模式。

基于类的继承 vs 基于原型的继承

在基于类继承的语言中,对象是类的实例,类可以从另一个类继承。从本质上而言,类相当于模板,对象则通过这些模板来进行创建。

下图所示,为基于类的继承实现示意图。每个类都有一个类似 superclass 的指针指向其父类。每个对象都有一个类似 isa 的指针指向其所属的类。

此外,每个类都存储了一系列方法,可用于其实例进行查找和共享。关于方法存储方式,不同语言的实现有所不同。 - 对于 C++ 等语言,每个类会保存所有祖先类的方法地址。因此,在方法查找时,无需沿着继承链进行查找 - 对于 Ruby、Objective-C 等语言,每个类只会保存其所定义的方法地址,而不保存祖先类的方法地址。因此,在方法查找时,会沿着继承链进行查找,这种模式也被称为 消息传递(Message Passing)。

在基于原型继承的语言中,没有类的概念,对象可以直接从另一对象继承。中间省略了通过模板创建对象的过程。

下图所示,为基于原型的继承实现示意图。每个对象都有一个类似 prototype 的指针指向其原型对象。

每个对象存储了一系列方法,基于原型链,对象之间可以实现方法共享,当然也可以共享属性。方法和属性的查找过程,类似于上述的消息传递,会沿着原型链进行查找。

原型继承的优缺点

前面,我们简单对比了两种继承模式的实现原理。下面,我们来讨论一下原型继承的优缺点。

对比而言,原型继承的优点主要有一下这些:

  • 避免大量的初始化工作。通过克隆一个现有对象来创建一个新对象,并具有相同的内部状态。
  • 具有非常强大的动态性。通过修改原型链,可以将原型指针指向任意对象,使得当前对象可以继承其他对象的能力。
  • 有效降低程序的代码量。由于原型继承没有类的概念,因此在代码实现中无需进行类的定义。

当然,凡事都具有两面性,以下罗列了一些原型继承的缺点:

  • 性能开销相对较大。当我们访问属性或方法时,运行时会通过原型链进行查找,中间存在一个遍历的过程。
  • 原型共享的副作用。由于多个对象可以共享同一个原型对象,一旦某个对象修改原型对象的状态,将会对其他对象产生副作用。
  • 面向对象异类设计。绝大多数面向对象语言及教程都是基于类的实现而设计的,这对于习惯于基于类的 OOM 的开发者很容易产生困惑。

不同语言的原型继承实现

下面,我们来看看不同编程语言中,基于原型的继承模式的实现细节。

JavaScript

JavaScript 原型实现存在着很多矛盾,它使用了一些复杂的语法,使其看上去类似于基于类的语言,这些语法掩盖了其内在的原型机制。JavaScript 不直接让对象继承其他对象,而是提供了一个中间层——构造函数,完成对象的创建和原型的串联,从而间接完成对象继承。由于构造函数的定义类似于类定义,但又不是真正意义的类,因此我们可以称之为 伪类(Pseudo Class)。

默认情况下,伪类包含一个 prototype 指针指向原型,对象包含一个 constructor 指针指向伪类(构造函数),两者之间的关系如下所示。

为了实现新的对象继承其他对象,一般会先修改伪类中 prototype 的指针,然后再调用伪类进行对象构造和原型绑定。如下所示,为一段代码实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function AType() {
this.property = true;
}

AType.propertytype.getSuperValue = function () {
return this.property;
}

function BType() {
this.subproperty = false;
}

// 继承 AType。即修改伪类 BType 的 prototype 指针,使其指向父对象。
BType.prototype = new AType();
BType.prototype.getSubValue = function () {
return this.subproperty;
}

let instance = new BType();
console.log(instance.getSuperValue()); // true

其中 BType.prototype = new AType() 修改了 BType 伪类的 prototype 指针,使其指向 AType 对象。当我们调用 BType 构造函数时,所构造的对象自动继承 AType 对象。如下所示,为基于原型的继承关系示意图,其中每个伪类的 prototype 指针都发生了变化,指向了其所继承的父对象。最终,生成的对象中会包含一个 __proto__ 指针指向父对象。根据 __proto__ 指针我们可以构建一个完整的原型链。

当然,在原型继承模式中,原型链中的父对象可能会被多个子对象所共享,因此子对象之间的状态同步问题需要格外注意。一旦,某个子对象修改了父对象的状态,那么会同时影响其他子对象。关于如何解决这个问题,JavaScript 中有很多解决方法,具体细节可以阅读相关书籍和博客,这里不作详细赘述。

Lua

Lua 中的 表(table) 是一种非常强大且常用的数据结构,它类似于其他编程语言中的字典或哈希表,可以以键值对的方式存储数据,包括方法定义。通常会使用 table 来解决模块(module)、包(package)、对象(object)等相关实现。

与此同时,Lua 还提供了 元表(metatable) 的概念,其本质上仍然是一个表结构。但是元表可以对表进行关联和扩展,允许我们改变表的行为。

元表中最常用的键是 __index 元方法。当我们通过键来访问表时,如果对应的键没有定义值,那么 Lua 会查找表的元表中的 __index 键。如果 __index 指向一个表,那么 Lua 会在这个表中查找对应的键。

如下所示,我们为表 a 设置一个元表,其中定义元表的。 __index 键为表 b。当查找表 a 时,对应的键没有定义,那么会去查找元表。判断元表是否定义了 __index 键,这里定义为另一个表 b。于是,会在表 b 中查找对应的键。

1
setmetatable(a, { __index = b })

Lua 中的继承模式正是基于元表和 __index 元方法而实现的。如下所示,分别是 Lua 中继承模式的实现示意图,以及对应的代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
RootType = { rootproperty = 0 }

function RootType:new (o)
o = o or {}
setmetatable(o, self)
self.__index = self
return o
end

SuperType = RootType:new({ superproperty = 0 })

SubType = SuperType:new({ subproperty = 0 })

RootType 是一个对象,其实现了一个 new 方法用于完成几项工作:

  • 构造对象,其本质上是一个表。
  • RootType 对象设置为新对象的元表。
  • RootType 对象的 __index 指向 RootType 对象自身。

最终形成图中所示的对象继承关系。由于 Lua 中的继承实现没有类的概念,而只有对象的概念。因此也被归类成基于原型的继承模式。当 SubType 对象中没有找到对应的键时,会根据 metatable 指针找到对应的元表,并根据元表的 __index 指针找到进一步查找的表对象 SuperType。如果 SuperType 中仍然没有,那么继续根据 metatable__index 指针进行查找。

Io

Io 的继承模式也是基于原型实现的,它的实现相对而言更加简单、直观。

在 Io 中,一切都是对象(包括闭包、命名空间等),所有行为都是消息(包括赋值操作)。这种消息传递机制其实与 Objective-C、Ruby 是一样的机制。在 Io 中,对象的组成非常关键,其主要包含两个部分:

  • (slots):一系列键值对,可以存储方法或属性。
  • 原型(protos):一个内部的对象数组,记录该对象所继承的原型。

Io 使用克隆的方式创建对象,对应提供了一个 clone 方法。当对父对象进行克隆时,新对象的 protos 数组中会加入对父对象的引用,从而建立继承关系。如下所示,为 Io 中继承模式的实现示意图,以及对应的代码实现。

1
2
3
4
5
6
7
8
RootType := Object clone
RootType rootproperty := 0

SuperType := RootType clone
SuperType superproperty := 0

SubType := SuperType clone
SubType subproperty := 0

相比于 JavaScript 和 Lua 的链表式单继承模式,Io 是支持多继承的,其采用了多叉树的模式来实现的,其中最关键的就是 protos 数组。很显然,protos 数组可以存储多个原型对象。因此,可以实现多继承。如下所示,是 Io 中多继承模式的实现示意图。

因此,Io 中方法和属性的查找方式也有所不同,其基于 protos 数组,使用深度优先搜索的方式来进行查找。在这种模式下,如果一个对象继承的对象越多,那么方法和属性的查找效率也会越低。

总结

本文,我们首先简单对比了基于类的继承模式与基于原型的继承模式,其核心区别在于是否基于类来进行构建继承关系。对于后者,没有类的概念,即使有,那也是一种语法糖,为了与基于类的语言靠拢降低开发者的学习成本和理解成本。

其次,我们简单介绍了基于原型继承的优缺点。当我们对编程语言进行技术选型时,也可以从这方面进行考虑和权衡,判断是否适用于特定的场景。

最后,我们介绍了三种编程语言中基于原型的继承实现,分别是:JavaScript、Lua、Io。三种语言各有其实现特点,但核心思想基本是一致的,即直接在对象之间建立引用关系,从而便于进行方法和属性的查找。

参考

  1. 《深入设计模式》
  2. 《JavaScript 高级程序设计》
  3. 《JavaScript 语言精粹》
  4. 《七周七语言:理解多种编程范式》
  5. prototype —— Prototype Based OO Programming For Lua
  6. Javascript继承机制的设计思想
  7. Prototype-based programming
  8. Difference from class-based inheritance
  9. What are advantanges and disadvantages of prototypal OOP
  10. What is prototype-based OOP?
  11. Prototype chains and classes
  12. lua-object
  13. 01.原型(prototype)和原型链(prototype chain)
  14. 对象原型
  15. JavaScript's Pseudo Classical Inheritance diagram
  16. Programming in Lua