从来没有深入了解ECMA,网上找了一下,发现早在2010年就有大佬 Dmitry Soshnikov 总结了ECMA中的核心内容,我这里只是翻译记录,加深自己的印象。文章原文来自 ECMA-262-3 in detail. Chapter 7.1. OOP: The general theory.
介绍
在本文中,我们介绍ECMAScript中面向对象编程的主要方面。本文没有转向另一个(正如这个主题在许多文章里都被讨论过),除了从理论方面来从内部观察这些过程之外,还将给予更多的关注。特别是我们将考虑对象创建算法,了解对象之间的关系(包括基础的关系 — 继承)是如何建立的,并给出可以在讨论中使用的准确定义(我希望可以消除一些关于JavaScript文章中经常出现的术语和意识形态上的疑问和混乱)。
一般规定,范式和意识形态
在分析ECMAScript中OOP的技术部分之前,有必要规定大量的一般特征,并且阐明一般理论的关键概念。
ECMAScript支持多样的编程范例,它们是:结构化编程,面向对象编程,函数式编程,命令式编程,以及在某些情况下是面向方面编程。但是,正如关于OOP文章的描述,让我们给出有关于此本质的ECMAScript定义:
ECMAScript是基于原型实现的面向对象编程。
基于原型的OOP模型与基于静态类的范式有许多不同。我们来详细看看。
基于类模型与基于原型模型的特征
注意,之前的语句中有很重要的一点被提及了 — 基于静态类。使用了“静态”一词,我们了解静态对象与类,一般来说,都是强类型(虽然最后一个不是必须的)。
注意这种情况,因为经常在各种文章以及讨论中将JavaScript叫做“另一个”,“不同”。主要原因可以用作反对:“class vs. prototype”,虽然只有这一不同点在一些实现中(例如基于类的Python与Ruby中)是不太重要(接受一些条件,JavaScript就不再是“另一个”,尽管在某些意识形态特征上的差异是)。但是更重要的是“statics + classes vs. aynamics + prototypes” 的对立。确切地说是静态和类(例如C ++,Java)以及相关的属性/方法解析机制,使您可以看到与基于原型的实现方式的准确区别。
但让我们一个接一个。让我们考虑以下这些范式的一般理论与关键概念。
基于静态类的模型
在基于类模型中有类的概念与属于这个类的实例。类的实例通常也被称为对象或者是示例。
类与对象
该类表示实例的广义特征的形式化抽象集(关于对象的知识)。
在这方面术语集更接近数学,但是,也可以称它为类型或者类。
🌰(这里和下面的代码将会使用伪代码给出):
1 | C = Class {a, b, c} // class C, 具有特征 a, b, c |
示例的特征是:属性(对象描述)和方法(对象活动)。
特征自身也可以被当作对象:即属性是否可写,是否可配置,活动(获取 getter/设置 setter)等。
因此,对象存储状态(类中所描述的所有属性的具体值),类定义其实例的严格不变的结构(即那些或者其他属性的存在)和严格不变的行为(即那些或其他方法的存在)。
1 | C = Class {a, b, c, method1, method2} |
层次继承
为了改善代码的重用,类可以继承其他类,带来必要的补充。这种机制叫做(层次)继承。
1 | D = Class extends C = {d, e} // {a, b, c, d, e} |
当从实例调用方法的时候,方法的解析是通过对类进行的严格,不变和连续的检查来处理的。如果方法在当前类没有找到,那么在父类寻找,在父类的父类寻找,即在严格的层次链中进行。如果到达层次链的基本环节方法依旧没有解析,结论是:这个对象没有相似的行为,要得到可取的结果是不可能的。
1 | d1.method1() // D.method1 (no) -> C.method1 (yes) |
与继承时方法没有复制到后代类相比,表单层次复制的方法属性总是被拷贝了的。我们可以在父类是 C
的子类 D
的这个例子中看到这个行为:属性 a
, b
, c
被复制,即 D 的结构是: {a, b, c, d, e}
。但是,方法 {method1, method2}
没有被复制,但是继承了。因此,这方面的内存使用量与层次结构的深度成正比。这里的基本不足是,在层次更深的结构中,即使一些属性是不需要的,但是他们也会拥有。
基于类模型的关键概念
有以下关键概念:
- 首先创建一个对象,还必须定义它的类;
- 以自己的分类“图像和相似性”(结构和行为)创建对象;
- 方法的解析由严格,直接,不变的继承链处理;
- 后代类(以及他们创建的对象)包含所有继承链中的属性(即使是这些属性中的一些对具体的继承类不必要);
- 一旦被创建,这个类就不能(因为静态模型)更改其实例的特征(无论是属性还是方法);
- 实例(也是因为静态模型)既不能拥有附加的自身(唯一的)行为也不能拥有与类结构和行为不同的附加属性。
让我们看看基于原型替代OOP的模型。
基于原型模型
基本概念是动态可变对象。
变化(完全可变,不只是值,也包括所有特征)与语言的动性直接相关。
此类对象可以独立存储他们所有的特征(属性,方法)而不需要类。
1 | object = {a: 10, b: 20, c: 30, method: fn}; |
而且,因为是动态的,所以他们可以轻易的改变(添加,删除,修改)他们的特征:
1 | object.method5 = function () {...}; // 添加新方法 |
因此,在分配时,如果特征在对象中不存在,那么就创建新的并且使用传递的值初始化,如果存在了,仅仅只是更新。
这种情况下,代码重用不是通过类的继承而是通过所谓的引用原型。
原型是一个对象,不仅可被用于其他对象的原型副本,或者是作为辅助对象,如果其他对象没有必要的特征,其他对象可以将其委托给这些对象。
基于委托的模型
任何一个对象都可以作为另一个对象的属性使用,并且同时,由于是可变的,对象可以在运行时轻松的动态更改它的原型。
注意,现在我们考虑一般理论而不触及具体的实现。当我们将要讨论具体的实现(尤其是ECMAScript)的时候,我们将看到许多自己的特征。
🌰(伪代码):
1 | x = {a: 10, b: 20}; |
这个例子展示了当原型作为属性的辅助对象时与原型相关的重要特点与机制。在自己相似属性没有的情况下,可以委托其他对象。
这种机制称为委托,基于它的原型模型是一个委托原型(或基于委托的原型)。这种情况下引用特性被称为将消息发送到对象。当对象自身不能对消息作出回应,那就会委托原型(尝试让它回答)。
这种情况下,代码重用被称为基于委托的继承或者基于原型的继承。
因为任何对象都可以作为属性使用,这就意味着原型也能够有他们自己的原型。原型的这种连接组合形成了原型链。这个链也是像静态类一样是分层级的,然而由于可变性,它可以轻松的被重新排列,更改层次与结构。
1 | x = {a: 10}; |
如果对象以及它的原型链都不能对发送的消息做出回应,那么对象可以激活相应的系统信号,并进行处理,从而可以像另一个链进行调度和委派。
这个系统信号在许多的实现中都可以访问,包括基于动态类系统:SmallTalk中的#doesNotUnderstand
,Ruby中的_missing
方法,Python中的__getattr__
,PHP中的__call
,ECMAScript实现之一中的__noSuchMethod__
等等。
🌰(SpiderMonkey中ECMAScript的实现,现在没有了):
1 | var object = { |
因此,与基于静态类的实现相反,在不能相应消息的情况下:结论是:此时对象没有被需求的特征,但是为了得到结果,如果尝试分析替代原型链也是可能的,或者在一系列变化之后对象拥有了这个特征。
关于ECMAScript,这里正是这样的实现 — 使用基于委托的原型。然而,正如我们将看到的,根据规范与实现,还有一些自己的功能。
串联模型
但为了公平起见,当原型代表要复制其他对象的原始对象时,有必要从定义说一下关于其他情况的话(即使没有的ECMAScript中使用)。
在这里,代码重用不是在对象创建时的使用委托而是原型的精确拷贝(一个克隆)。
这种原型称为串联原型。
自己复制了原型的所有特征后,对象可以进一步完全修改自己的属性与方法,正如原型可以修改(并且这种修改不会影响已经存在的对象然而在基于委托模型中随着修订原型的属性会影响已经存在的对象)。这种方法的优点是可以减少调度和委派的时间,而基本的不足是更高的内存利用率。
鸭子类型
回到动态,对象的弱类型与突变,与基于静态类模型相比,这里执行某些操作的能力测试的通过与否与对象的类型(类)无关,而是对象是否能够回应信息(通过测试后是否需要这样做)
🌰:
1 | // in static class based model |
用行话来说,这就是鸭子类型。也就是说,可以通过检查时设置的特征来识别对象,而不是对象在层次结构中的位置或他们属于任何具体类型的位置。
基于原型模型的关键概念
我们来看看这种方法的主要特点:
- 基本概念是对象
- 对象完全动态与可变(理论上可以从一种类型转变成另一种类型)
- 对象没有描述特征和行为的静态类;对象不需要类
- 虽然没有类,但是如果对象自身不能回应信息,他们有可以委托的原型
- 对象的原型可以在任何运行的时候被修改
- 在基于委托模型中修改原型的特征会影响与原型有关的所有对象
- 在串联原型模型中原型是从原始副本中克隆其他对象并进行进一步独立的原始副本;修改原型的特征不影响从它克隆的对象
- 如果它不能回应消息,则可以向呼叫者发送信号,通知他可以采取其他措施(例如,更改调度)
- 对象的识别不能通过其层次结构和具体类型来确定,而可以通过当前的一组特征来进行。
然而,我还还应该考虑另一种模型。
基于动态类模型
我们认为这个模型可以在示例中展示开始提到的内容 — “类与原型”之前的区别不是那么重要(尤其是原型链是不变的;为了更准确地进行区分,有必要同时考虑类中的静态变量)。作为例子,使用Python或者Ruby(又或是是其他相似的语言)也是可以的。两种语言都是使用基于动态类的范例。但是在某些方面,可以看到一些基于原型实现的功能。
下面的例子中,我们可以看到,就像在基于委托的原型中一样,我们可以增加一个类(原型),并且他会影响与这个类相关得所有对象,我们也可以在运行时动态的修改对象得类(给委托提供一个新的对象)。
1 | # Python |
在Ruby中也是相似:那里也使用了完全动态的类,我们可以完全修改对象与类的特征(在类中添加属性或者方法,并且这些方法会影响已经存在的对象);但是,不能够动态的修改对象的类。
但是,本文的重点不是Python和Ruby,因此我们结束这个比较,开始讨论ECMAScript自身。
但是在此之前,我们依然要看看在一些OOP实现中是可用的附加的“语法糖”,因为这些问题经常出现在一些关于Javascript的文章中。
并且本章仅考虑到诸如“Javascript是另类,它具有原型而不是类”之类的语句的不正确性。理解不是所有基于类的实现都在实现上完全不同是有必要的。即使我们可能说“Javascript是不用的”,也必须考虑(除了“类”的概念以外)其他所有相关特性。
各种OOP实现的附加功能
这一节中我们将简单看一下在各种OOP中的附加功能以及代码的重用,并与ECMAScript的OOP实现并行进行。原因是在出现关于Javascript的文章中,OOP概念仅限于一些惯用的实现,而不管这里有多种实现,唯一的(主要的)要求就是他们应该在技术上与思想上被证实。没有从一个(习惯的)OOP实现中找到与一些“语法糖”的相似点的情况下,Javascript可以被草率的命名为“不纯的OOP语言”,这是不正确的陈述。
多态的
ECMAScript中的对象在多种含义中都是多态的。
例如,一个函数可以应用到不同的对象,就像他是对象的原生特性一样(因为this值在进入上下文的时候就决定了)
1 | function test() { |
但是,也有例外:例如, Date.prototype.getTime()
方法,根据标准, this
值总是有一个date对象,否则,抛出异常:
1 | alert(Date.prototype.getTime.call(new Date())); // 当前的时间戳 |
当为所有的数据类型均等的定义函数但接收多态函数参数时,即所谓的参数多态(例子就是数组的 .sort
方法以及它的参数,多态的排序函数)。顺便一提,上面的例子也可以被当成一种参数多态。
或者是在原型中,方法定义为空,所有的被创建的对象都要重新定义这个方法(例如一个接口或者多个实现)。
这里的多态也可以与我们上面提到的鸭子类型有关:例如,对象的类型与位置在层级中不是那么重要,但是如果他有所有需要的特征,那么它可以被轻松的接受(又或者通用接口很重要,实现可以是多样的)
封装
有了这个想法,往往会在意识上产生混乱与错误。这种情况下,我们讨论一个有关一些OOP实现的一个很方便的“糖” — 总所周知的装饰器:私有,受保护以及公众的,也成为对象特征的访问级别(或访问修饰符)。
我想提醒一下封装本质的主要目的:封装是对抽象的一种增加,但不是对“恶意黑客”的妄想的隐藏,“恶意黑客”希望将某些东西直接写入类的字段中。
为了隐藏而使用隐藏是一个很大(且普遍存在)的错误。
首先,访问级别(私有,受保护与公共)已经被几种OOP实现,为了程序员抽象描述与构建系统更方便。
这一点可以在一些实现中看到(比如已经提到的Python与Ruby中)。一方面(在Python中),他们是 __private
与 _protected
属性(命名约定是通过前划线来指定的)并且从外部可能访问。另一方面,Python只通过特殊规则(_ClassName__field_name)重命名了此类字段,并且通过此名称,他们已经可以从外部访问。
1 | class A(object): |
或者是在Ruby中:一方面,可以定义私有的、受保护的特征;另一方面,还有一些特殊的方法(例如 instance_variable_get
, instance_variable_set
, send
等),可以访问封装的数据。
1 | class A |
最主要的原因是程序员自己想要封装的数据。如果这些数据被莫名其妙的错误的修改或者是有任何错误,所有的责任都完全在程序员身上,而不是“键入错误”或者“某些人随意的修改了某字段”。但是如果这种情况变得很频繁,我们仍可能注意到不良的编程习惯与风格,因为通常只有通过公共API与对象“对话”才是最好的。
封装的复用,最基本的目的就是从用户那里获取辅助助手数据而不是“从黑客那里保护对象的方法”。为了软件的安全性,许多更严格的措施被使用,而不是“私有”修饰符。
封装辅助助手(本地)对象,我们以最小的费用为公共接口的进一步行为修改提供了可能性,可以定位和预测这些更改的位置。而这正是封装的主要目的。
setter方法的重要的目的是困难计算抽象化。例如, element.innerHTML
setter — 我们只是简单的声明 — “现在这个元素的html如下”。对于InnerHTML属性的setter函数中,将难以计算与检查。这种情况下,问题主要与抽象有关,但是随着封装的增加,封装也会产生。
封装的概念不仅仅可以与OOP有关。例如,可以是封装了大量计算的简单函数,并且是抽象的(例如对用户而言, Math.round(...)
方法如何实现不重要,它只需要知道如何使用)。这是一个封装,并且注意,我没有提及关于“私有,受保护以及公共”任何一词。
在当前版本(原文写于2010年)中的ECMAScript中没有定义私有,受保护和私有修饰符。
然而,在实践中,可能会看到名为“模仿JS中的封装”的内容。通常,为了此目的使用周围上下文(通常,构造函数本身)。不幸的是,经常实施这种“模仿”,程序员可以为绝对非抽象的实体生成“getters/setters”(重申一遍,错误地对待封装本身)。
1 | function A() { |
因此,都知道对于每个创建的对象,同时也创建了这对 getA/setA
,导致内存问题与创建对象数量成正比(与原型中定义的方法相反)。虽然,在第一种情况下,理论上可以使用链接的对象进行优化。
在大量的关于JavaScript中这些方法的文章中,都有一个名字,“特权方法”。注意:ECMA-262-3中并没有定义任何“特权方法”的概念。
然而,在构造函数中创建方法是很常见的,就像在语言意识形态中一样 — 对象是完全可变的,并且有唯一的特征(在构造器中,根据条件,一些对象可以获得附加的方法,其他的不行)。
而且,对于JavaScript,这样的“hidden”,”private”变量不会那么隐藏(如果封装依旧被误解为是针对“恶意黑客”的保护,该黑客想直接在某个字段中写入值而不是使用setter方法)。在一些实现中,通过个eval函数传递一个调用上下文(这个可以在升级到1.2.7版本的SpiderMonkey中测试,译者在chrome最新版中测试是无效的),可以访问必要的作用域链(以及其中所有的变量对象)。
1 | eval('_a = 100', a.getA); // or a.setA, as "_a" is in [[Scope]] of both methods |
或者,允许直接访问变量对象的实现(例如,Rhino);通过访问该对象的相应属性可以修改内部变量的值:
1 | // Rhino |
有时,作为组织措施(也可以看作是一种封装),JavaScript中的“私有”和“受保护”的数据以前导下划线标记(与Python相比,这里只是命名约定)。
1 | var _myPrivateData = "testString"; |
关于周围执行上下文的影响,经常被使用,但是对于封装,真正的辅助(帮手)数据与对象没有直接关系,并且从外部的API提取他们很方便。
1 | (function() { |
多重继承(Multiple inheritance)
多重继承是一个对于提高代码重用很方便的“糖”(如果我们可以继承一个类,为什么不能一一次继承10个呢?)。但是,他有很多不足,这就是为什么在实现中不受欢迎的原因。
ECMAScript不支持多重继承(即:只能将一个对象用作直接原型),尽管其祖先Self编程语言有这个能力。但是在一些实现中,比如SpiderMonkey,使用 __noSuchMethod__
,可以管理调度和委派给其他原型链。
混合(Mixins)
Mixins也是代码重用很方便的方法。Mixins已经被建议作为多重继承的替代方法。这些是可以与任何对象混合的独立元素,从而扩展他们的功能(因此这个对象可以缓和多个mixin)。ECMA-262-3规范中没有定义“mixin”概念,然而根据mixins的定义,并且由于ECMAScript具有动态可变对象,因此没有什么可以阻止将任何一个对象与另一个对象混合,而只是简单的增强其特性。
经典例子:
1 | // 增强助手 |
注意,我使用引号包裹这些定义(“mixins”, “mix”),因为我们提到过ECMA-262-3中没有定义这个概念,而且这不是一个混合而是一般的通过新特性扩展对象。
特性(Traits)
Traits与mixins相似,但是它有大量的特征。通过定义,traits的基本是traits不应该具有像mixin那样可能导致命名冲突的状态。对于ECMAScript,traits是通过与mixins相同的原理来模仿的;标准中没有定义“traits”概念。
接口(Interfaces)
在某些OOP实现中可用的接口类似于mixin和traits。但是,与mixin和traits相比,接口强制类完全实现其方法签名的行为。
接口可以被看作完全抽象类。但是,与抽象类相比,单独继承一个类可以实现多个接口;由于这种原因,接口(以及mixins)可以被当作多重继承的替代。
ECMA-262-3标准既没有定义“接口”的概念也没有定义“抽象类”的概念。但是,作为模仿的,可以使用具有“空”方法的对象来增强对象(或在方法中引发异常,以信号表示应实施此方法)。
对象组合(Object composition)
对象组合也是动态代码重用的技术之一。对象组成不同于继承,它具有高灵活性,并实现了对动态可变委托的委托。而这又是基于原型委托的基础。除了动态可变原型之外,对象可以聚合(创建一个合成,聚合)对象以进行委托,并进一步向对象发送特定消息,以委托给该委托。这里可能会有超过一个的委托,并且由于动态性质,可能在运行时修改他们。
作为一个已经提及到的示例, __noSuchMethod__
是可以的,但是,还是让我们来展示一下如何显示使用委托:
🌰:
1 | var _delegate = { |
对象之间的这种关系成为“has-a(具有)”。“contains inside” in contrast with inheritance what “is-a” i.e. “is a descendant”.(这句话不知道怎么翻译好)
因为明确组成的缺陷(),中间代码可能会越来越多。
AOP特性
作为面向切面编程的特征,可以使用函数装饰器(function decorators)。ECMA-262-3标准没有明确定义”函数装饰器”。但是,具有功能参数的函数可以在某些方面进行修饰和激活:
最简单的装饰器示例:
1 | function checkDecorator(originalFunction) { |