《大前端三问》 - JavaScript中的面向对象与原型链1
JavaScript本身不提供一个 class 实现(在 ES2015/ES6 中引入了 class
关键字,但那只是语法糖,JavaScript 仍然是基于原型的), 而是一种基于原型的语言(prototype-based language) —— 同一类型的对象共有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链
(prototype chain)。
我看过许多文章,一上来就大讲prototype
、this
、原型链
等等“高深”的内容,把各种概念抛出来把人看得云里雾里、似懂非懂。而在这里,我用自己的思路和大家一起分析与学习下JS的面向对象技术。我将以面向对象的三大特性为切入点,尝试着深入了解JavaSctipr的面向对象设计思路与实现:
- 抽象与封装;
- 继承;
- 多态。
然后在这个探索中,我们再来着重弄清楚这几个“高级”问题:
- JS的对象、函数是怎样的关系;
- 什么是原型(prototype),什么是原型链;
3.执行上下文与this;
本章中,我们将主要讨论JavaScript是如何实现面向对象编程的封装
的特性。
1 抽象与封装
我们说,封装
主要做的是两件事:抽象
和复用
。
抽象
是一种思想,讲究的是如何把现实世界映射到计算机世界中。而封装
是计算机中的实现方式,通过设计一套组合方式把基础的数据结构装配成为抽象出来的整体。
要实现这两个概念,面向对象这套编程体系定义了类
和对象
这两种结构。其中,对象
是一个个具体的客观实体,而类
是这些实体共有特征的抽象。比如,我们每个人都是具体的实体、是一个个对象;而我们人又具有共同的特性 – 都有名字、性别、都会说话等。基本的抽象模型如下:
1 | Class People { |
像上面的例子虽然是伪代码,但是很好理解。这里我定义了一个抽象的类 – People
表示人; 然后使用类的构造函数又定义了具体的2个对象 – me
和peter
,该定义的方法是在构造函数前加new
关键字 – new People('Chauncey', 30)
。
- 通过
抽象
可以将客观世界映射到计算机世界中,这种思想为我们对程序设计提供了思想的基础。 - 而通过
封装
,我们把个体及他们的属性组合了起来。
比如说我现在要构建一个学生管理系统,抽象必然是我考虑的第一步。
1.1 抽象与封装在JavaScript中的实现
那么,在JavaScript中如何实现抽象的呢?具体来说,就是在JavaScript的世界中,对象
是怎样表现的,类
又是怎样表现的。
我们先从对象入手来看:
1.2 从对象入手
首先开门见山,JavaScript是一种基于原型的语言,并没有类的概念。所以在JS中,只有对象,而对象其实就是一个散列表结构!
JavaScript中的对象,
Object
,其实可以简单理解成“名称-值”对(而不是键值对:现在,ES 2015 的映射表(Map),比对象更接近键值对),不难联想 JavaScript 中的对象与下面这些概念类似:
- Python 中的字典(Dictionary)
- Perl 和 Ruby 中的散列/哈希(Hash)
- C/C++ 中的散列表(Hash table)
- Java 中的散列映射表(HashMap)
- PHP 中的关联数组(Associative array)
这样的设计让JavaScript的对象简单灵活,能应付各类复杂需求。正因为 JavaScript 中的一切(除了核心类型,core object)都是对象,所以 JavaScript 程序必然与大量的散列表查找操作有着千丝万缕的联系,因为散列表擅长的正是高速查找。
js中,可以直接使用字面的方式定义一个对象,就像定义一个散列表那么简单。比如下面的代码:
1 | let me = { |
像上面这样,我就定义了一个对象,而我们知道,JS中的对象其实是个散列表结构:
1 | ┌───────────────────────┐ ┌──────────────────────────┐ |
上面的代码在内存中就是这样的一个结构。这样的设计很好实现,也就是当我们定义一个对象时,JS会自动为我们开辟一个散列表空间,其中表的key
是对象成员的名字,而成员的值有两种:值类型
和引用类型
,如果成员是一个基本类型(比如是数值),则直接存其值,这是值类型
。如果成员变量是指向一个方法或对象,则为引用类型
,就像C中的指针一样,它存的是一个指向所指方法或对象的引用
。
然后我们看到me
这个对象的sayHi
成员函数引用,指向的是一个sayHi()
方法,在这里不得不提一个就是javaScript中的方法与其它语言中的方法并不相同。
1.3 初探javaScript中函数与对象的关系
在JavaScript中,函数与对象的关系是比较暧昧而复杂的,后面我们会详细介绍。但在这里,我们会大概讲一下JS中的函数,暂且不必太深入,我们先不求甚解地探索下。
1.3.1 函数也是一个对象
首先,在JavaScript中,函数也是一个对象。这从我上面画的图可以看出,函数实际上也是一个散列表的组成结构。这就和我们一般熟悉的编译型语言的方法很不同了。众所周知,在编译型语言中,方法是程序的重要组成结构,我们说进程就是运行着的程序。在编译型语言中,方法会被编译成一条条命令,然后放在只读的一块内存中,我们称之为代码段。
而在JS中,函数是一个对象,也是一个散列表结构,而其内一条条的语句,实际上就是当作一条字符串,就像上图一样,我用f
表示函数的代码部分,对应的值是具体的代码。为什么可以这样?因为JavaScript是解释性语言,要运行一个方法只需要将之放入解释器就行,这就使其非常的灵活。
我们可以做个实验,如下代码示例:
1 | function f1(x, y) { |
这里我们就看出,JS中声明一个方法其实就是定义了一个方法对象,而具体的方法体就是一段由JS表达式语句
组成的字符串。
还有一个就是 –在JavaScript中,函数也是一等公民。
1.3.2 函数是一等公民
“一等公民”这个称呼起源于哪里已不好追究了,但他的意思主要就是,在JavaScript中,函数也是一个对象,而且是重要的一种一等对象
。这样设计的好处在于,你可以将函数像普通对象一样传递(作为另一个函数的参数、作为函数的返回值,或者将之随便赋值给一个变量)。
比如在JS中你可以随便定义一个函数:
1 | function sayHi() { |
你可以像定义一个字面对象一样给他添加成员变量、成员方法(这里只是意思上的区分,其实我们知道对象的散列表本质,成员变量或成员方法也好都只是添加一条key-value记录)。
1 | sayHi.toWho = 'Peter'; |
这样一看,JS中function
的特性和类
的要求很像,都可以指定成员与方法。而正因为函数的这个特性,让它成为了JavaScript面向对象编程中用于实现封装
的主要工具。
1.3.3 使用函数实现类的封装
因为在JS中,函数被设计的无比强大,以至于设计者将面向对象的封装“重任”都交之于它(js语言的早期设计是一种极简风格,追求对关键字能省则省)。我们上小节说过,JS的function
和高级面向对象语言的class
很像,都可以指定成员与方法,于是可以用之来这样实现我们上面对“People”的封装:
1 | function People(name, age) { |
上面的代码其实很好懂,语义上就不作过多解释。有趣的是,在我们之前伪代码以及多数语言中,都是使用Class
关键词来定义一个类,而在JS中索性就用function
来定义了。这表现了JS极简的风格,当然,到ES6中还是引入了Class
关键字,但那只是语法糖,JS并没有为之增加一个Class类型。
虽然经过我们上面的学习,这个例子已很容易理解了 –这就是JS实现的抽象与封装,用函数
来实现类型的封装,定义对象实例们的通用属性与方法。基于构造函数生成具体的对象实例。
所以,至此为止,我们已经掌握了在JavsScript中是如何实现面向对象的抽象与封装的。
但是这里我们还有会产生好奇,在JS中,new function()
会发生什么事,对象是怎样基于函数生成的?这就涉及抽象与封装的另一个重要概念 – 复用
!
2 复用
我们现在抛开上面关于抽象与封装的概念想一想,为什么要有设计“类”与“对象”这两层数据结构? – 答案就是复用
。
因为在实际的操作中,我们操作的其实都是一个个具体的对象,而之所以抽象出“类”这个概念,就是为了复用。让具体的单个对象可以省去许多一样的模板代码。比如上面的People的例子,我可以每个对象都直接使用字面定义方式:
1 | let me = { |
但是这样明显会出现许多重复的定义,而且当我想去对它们的共同结构作修改时,我不得不一个个地去修改个体对象的代码。因此,我们说 – 类的存在主要是为了代码复用
。(注意这里复用有两层意义:一个是减少重复代码,一个是共用相同逻辑)
2.1 C++中类与对象的实现
我们来看看一般编译型语言中类与对象的实现。我这里用结构体举例而不是使用通用的高级语言的Class是因为一般大家都对struct的内存布局更加熟悉、其底层的理解更加简单。我们看一个例子:
1 | struct People { |
这里定义的People
与上面我们用JS作的定义效果是一样的。我们都知道,结构体的定义只是一种内存布局的描述,只有当结构体实例化时,才会真正地分配内存(正因为这个概念,所以在某些语言中,我们也常将对象称之为实例instance
)。所以我们发现在编译语言的实现中,类(比如上面示例中的struct),更像是一个模板。而根据这个模板,生成的对象实例(结构体实例)在内存中表示为:
1 | ┌──────────┐ |
我们看到,在C++中,实例在内存中的表示与在JS中大不相同。在JS中,变量是使用散列表
存储,根据key
来快速寻址成员变量value
。而C++中成员是使用偏移地址
来表示的(比如说你要访问name变量,编译器会根据name变量类型算出其大小与相对对象首地址的偏移),这样的好处是其对象的结构更简单了,寻址也会较散列的形式都快些。但是缺点也很明显,就是类结构与实例结构是定死的,因为使用偏移寻址
,后期想动态添加成员根本不可能。
在对比中学习可以让我们对一门学问了解的都深刻,现在我们知道,在C++中,复用是使用模板的形式来实现的,对象们共用了一份相同的内存布局,这样可以达到更高效率的操作,但是有失灵活性。
2.2 原型prototype
JavaScript中对复用的实现用了另外一种思路,它抽象出来了一个共用的底层对象 – prototype
(我们常翻译为“原型”)。由这个底层对象来存放对象共性的东西。这样做是为什么呢?
比如我们探研下之前的代码:
1 | function People(name, age) { |
这里多说一下,咱的示例代码都可以简单地使用chrome的调试工具来测试,在最后的《小结-使用chrome调试js代码》一节中我们将附操作方法。
从上面的代码我们发现,两个对象的内部函数居然不是同一个。这当然啦,因为new People
时, function内的代码都会执行一把,在这里我们发现this.sayHi相当于都重新赋值了一遍,也就是每创建一个对象都生成了一个sayHi()
方法。
这里我们不得不又先抛出一个概念,我们new People(...)
时究竟是在干嘛?
2.2.1 所有函数都可以是构造函数
我们现在细看一个People
这个函数。有没有发现它和java或其它高级面向对象语言的中构造函数
很像!没错,JavaScript就是这么精简!在JS中,只要你对函数使用new
语句,就可以将此函数变成构造函数(注意,ES6中新增的键头函数
不可作为构造函数,只能作为普通函数
)。
为什么我要强调这个特性呢?因为JavaScript是这么精简,以至于我们要为之自己脑补许多代码。。。我们将上面People
的代码脑补一下:
1 | Class People { |
这下对于new People()
我们就好理解了,当我们对函数作new
时,js会将之待作构造函数
,并返回为我们生成好的对象。这点和高级面向对象语言比如Java是一样的,在Java中,new ClassName(param)
也是调用该类的构造函数生成对象,并完成成员的初始化。
而在JS中并没有class
的概念,但你可以更直接地将function
“升级”为构造函数。使用new
操作符和构造器函数结合来创建对象。我们现在翻回去看看People
这个构造函数的定义,在里面this
就代指你生成的对象,我们通过给this
也就是生成的对象赋于同一的属性与方法,来完成对象的初始化。这样生成出来的对象都有相同的属性与方法,所以JS创造者大概可以说,“你看,我们没有Class关键字,面向对象的抽象与封装不是也实现的好好的吗”。
这样一来你就可以发现什么了吧,在构造函数function People(name, age)
中我们对每个对象的sayhi方法都作了这样的赋值 – this.sayHi = function()
,这就是每个对象的sayHi方法都不相同的原因(他们都创建了一份新的实例)。那我们怎样解决这个问题呢?JS的解决方案就是用所有对象共享一个公共的对象结构,把公用的属性与方法放在此公共结构上 – 这个结构就是prototype(原型)
。
现在我们把话题转回来。说回prototype
。
2.2.2 prototype
、__proto__
与原型链
首先要注意下,经过上一节我们的脑补,我们知道早期JavaScript是一种极为精简的语言,其能省就省的风格造就了它连class
关键字都懒得定义。但我们还是要清楚,这后面我们所说的function
,其实大多数指的是面向对象中说的class
的概念。
我们现在的疑问是,所有对象共享的这个公共对象结构 – prototype
– 是在何时生成的 – 答案是在函数定义的时候。每当我们使用function
定义一个函数时(注意,新增的键头函数不会生成prototype),解释器会自动地为函数生成一个prototype
对象(注意,prototype也是一个对象),并为该function增加一个成员属性prototype
指向这个对象。
现在,我们就可以将公共的属性或方法定义在它的prototype
对象上,像这样:
1 | function People(name, age) { |
现在,整个对象与函数的关系如下图:
1 | ┌───────────────────────┐ |
为什么调用me.sayHi()
可以找到正确的方法呢?这里又有一个重要概念,就是当使用new
生成JS对象时,解释器会自动为之添加一个__proto__
成员,其指向People
的原型People.prototype
!!
因此,当你执行:
1 | var me = new People('Chauncey', 31); |
JavaScript 实际上执行的是:
1 | var me = new Object(); |
- 原型链
而当我们访问对象的成员时,解释器会自动帮我们作向上查找,当在本对象的散列表结构中没有找到相应的引用时,会沿着其__proto__
属性找到我们所说的公共对象prototype
,到其原型对象中去找!这有点像Objective-C中的消息转发
机制。
这种机制实现了JS中的复用
特性,现在你可以想想看,无论我用People
定义多少个对象,他们都可以共用一个prototype
对象。prototype
是一个可以被所有实例对象共享的对象,称之为原型
。它是一个名叫原型链(prototype chain)
的查询链的一部分。当访问一个对象(比如People的实例me)的属性或方法时,会先在此对象已定义的成员中找,如果没找到,就会到其prototype中去找!没错,这看起来有点像继承关系的到父类中去找一样(比如sayHi
这个函数,我们先在me
这个对象的散列表中找。结果发现没找到,就顺着其__prop__
属性找到它的原型People.prototype
,最终找到sayHi()
方法并调用之)。
然后我们还要注意一点的是,me.hello();
失败了,也就是在对象中直接调用模板函数的方法是行不通的,因为变量的查找只会沿着__proto__
指向往上查找。模板函数上直接定义的方法只能通过模板函数调用,这也是很多高级语言中实现的所谓实例变量(在构造器内使用this定义的变量成员)与静态变量(在函数上直接定义的变量成员)。
2.2.3 关于prototype的oneMoreThing
我们注意到People.prototype
中有个constructor
指回了People
函数。这种设计固然给从原型出发找到其相应的模板函数提供了路径,另一方面,这种类似“循环引用”的设计也给人以原型与模板函数一一绑定的感觉。
还有一点是我们注意到我把People.prototype
的__proto__
属性也画了出来。那当然咯,因为原型也是对象,是对象就有__proto__
属性。而People.prototype
的__proto__
指向的是Object.prototype
。这是什么意思呢?
这说明所有原型都是解释器自动地使用new Object()
生成出来的~也说明了当对象的属性在原型上也找不到时,还会继承向上查找,一直找到Object原型上去!!这不符合面向对象的思想吗?在面向对象的世界中,一切对象皆是Object。我们不妨作如下验证:
1 | console.log(People.prototype.__proto__ === Object.prototype); // ture |
这里给出了验证,而且me.toString()
的调用成功也说明了一点,就是JS的成员查找可以顺着__proto__
原型链一直往上找,使得Object
的方法也可以为子类们享用。
好了,大家看到这里,想必已经对JS的面向对象编程有了个大概的了解。因为抽象和封装是面向对象的基础,我们今天了解了其抽象和封装的实现,也就是掌握了基oop的基础。这为我们接下来的学习也奠定了根基,下章我们将一起学习另一个特性,也就是继承
在JS中是如何实现的。
这里咱们来小结一下:
3 小结
3.1 内容小结
- JavaScript是基于原型的面向对象语言,并没有Class的概念。面向对象的抽象与封装的实现主要是由
object
、function
、prototype
三种主要结构来构建。 - 其中
object
指的是具体的对象实例,本质上是一个散列表结构;function
本质上是构造函数,但在其内部实现了为所生成的对象定义相同属性与方法的抽象作用;而prototype
原型的出现是为了解决面向对象的复用问题,我们说这里复用有两层意义:一个是减少重复代码,一个是共用相同逻辑。 - JS中函数是一个对象,也是一等公民,可以自由赋值、自由传递(作为方法的参数或返回值等)。
- JS中没有
class
的概念,你可以更直接地将function
“升级”为构造函数。使用new
操作符和构造器函数结合来创建对象 –new People()
。 prototype
原型是伴随着function
构造函数的定义而自动产生的,使用func.prototype
和obj.__prop__
可以直接访问到。- 而正因为
__prop__
的存在,产生了所谓的原型链
。当我们访问对象的成员时,如果在本对象的散列表结构中没有找到相应的引用时,解释器会自动帮我们作向上查找,沿着其__proto__
属性找到原型对象prototype
。如果还是没有,会一直沿着原型的原型一路向上找,直到找到Object.prototype
, 其__proto__
为null。
3.2 使用chrome调试js代码
随便打开chrome,按下alt + cmd + j
可以打开调试模式。
选中console
tab,这个就是调试工具的调试终端。比如我们在里面输入如下代码(使用):
1 | function test() { |
然后你就可以输入test
, test.prototype
去查看对象的组成。比如我们查看test.prototype
,就可以看到输出:
1 | test.prototype |
3.3 本章相关命令与方法
js中,可以直接使用字面的方式定义一个对象,就像定义一个散列表那么简单。下面罗列了javaScript中创建对象的几个方法:
- 使用new关键字;
- 创建字面值对象;
- 使用Object.create(obj)基于现有对象创建对象
1 | // 创建 |
3.4 JS面向对象的抽象与封装的实现
1 | function People(name, age) { |
4 引用
- 《重新介绍JavaScript》- 介绍JS很好的入门材料
- 《JavaScript高级程序设计》- (美)(Nicholas C.Zakas)扎卡斯 学习JS挺好的入门教材
- 《继承与原型链》- MDN web docs 继承与原型链, 很好,讲了性能,和原型实际
- [《Function函数与Object对象的关系》- 网文](https://www.cnblogs.com/nature-tao/p/9504712.html Function函数与Object对象的关系的一篇挺好的探索文章