基于原型的继承模式
继承(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 | function AType() { |
其中 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 | RootType = { rootproperty = 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 | RootType := Object clone |
相比于 JavaScript 和 Lua 的链表式单继承模式,Io
是支持多继承的,其采用了多叉树的模式来实现的,其中最关键的就是
protos
数组。很显然,protos
数组可以存储多个原型对象。因此,可以实现多继承。如下所示,是 Io
中多继承模式的实现示意图。
因此,Io 中方法和属性的查找方式也有所不同,其基于 protos
数组,使用深度优先搜索的方式来进行查找。在这种模式下,如果一个对象继承的对象越多,那么方法和属性的查找效率也会越低。
总结
本文,我们首先简单对比了基于类的继承模式与基于原型的继承模式,其核心区别在于是否基于类来进行构建继承关系。对于后者,没有类的概念,即使有,那也是一种语法糖,为了与基于类的语言靠拢降低开发者的学习成本和理解成本。
其次,我们简单介绍了基于原型继承的优缺点。当我们对编程语言进行技术选型时,也可以从这方面进行考虑和权衡,判断是否适用于特定的场景。
最后,我们介绍了三种编程语言中基于原型的继承实现,分别是:JavaScript、Lua、Io。三种语言各有其实现特点,但核心思想基本是一致的,即直接在对象之间建立引用关系,从而便于进行方法和属性的查找。
参考
- 《深入设计模式》
- 《JavaScript 高级程序设计》
- 《JavaScript 语言精粹》
- 《七周七语言:理解多种编程范式》
- prototype —— Prototype Based OO Programming For Lua
- Javascript继承机制的设计思想
- Prototype-based programming
- Difference from class-based inheritance
- What are advantanges and disadvantages of prototypal OOP
- What is prototype-based OOP?
- Prototype chains and classes
- lua-object
- 01.原型(prototype)和原型链(prototype chain)
- 对象原型
- JavaScript's Pseudo Classical Inheritance diagram
- Programming in Lua