iOS 程序员如何理解 JS 中的类与继承
前言
新年第一篇,没想到到一月下半旬才有空写,实在是忙啊。
本文是 iOS 程序员的 JavaScript 之旅 系列的第二篇,这个系列主要用于记录我在学习 JavaScript 中的一点心得,文中内容由于参考文献版本不一定是最新 & 个人理解水平有限,可能出现一些错误,还请谅解。如果有任何讨论 / 建议 / 意见,欢迎评论留言或是邮件联系。
真相是…
关于 JavaScript 中的“类”与“继承”概念,大概两个月前看书看到这部分的时候,就看得云里雾里,和几个前端朋友讨教了好久 __proto__
怎么指, prototype
到底是什么意思,七七八八算是弄懂了这块。但经过两个月在 iOS 上的需求开发,今天再回头看之前做得笔记、写的代码,又一次陷入了云里雾里…直到我找到了 这篇文章 ,并看了一下 Wikipedia 的 Prototype-based programming。
JavaScript 中的“类”与“继承”
对于像我这样,以 C 语言为“母语”,接着就接触 C++ 并很快走上客户端开发的同僚来说,类的概念在我们日长开发中就像赖以生存的空气和水一样,你或许无法精准的对它进行描述和定义,但用起来却手到擒来,毫不含糊。但 JavaScript 并不是一门面向对象的语言,其设计并没有类、继承的概念。而它的复用,是采取 prototype(原型)的方式实现的。
什么是 Prototype
在 JavaScript 中,一切对象都可以被原型继承,被原型继承的对象往往被称作 Prototype,什么意思呢?结合 Prototype-based programming 中的第一句话:
Prototype-based programming is a style of object-oriented programming in which behaviour reuse (known as inheritance) is performed via a process of reusing existing objects via delegation that serve as prototypes.
结合我们熟悉的概念来进行理解:
在面向对象的世界里,如果把类比作绝对意义的抽象(仅定义数据格式、操作数据方式,而不定义数据具体的值),那么其对应实例就是抽象的具体表现形式(遵循数据类型的定义,为数据赋值)。
那么在面向原型的世界中,一切的对象都是一定意义上的抽象(每个对象都可以定义自己的数据格式、数据操作方式,并能被其它对象所“实例化”并修改),也是一定意义上的具体(每个对象同时也有具体的数据值)。
可以这么想, JavaScript 中的原型继承,其表现与 NSCopying 中的 [object copy]
非常相似(但其内存上的表现是不一样的),即拿着一个对象作为“参照(也就是原型)”,生成与其数据格式相同的另一个对象。但最大的不同在于,Objective-C 中的所有对象,其数据格式只能预先在类中定义好,而对象仅能修改具体值。但 JavaScript ,每个对象都可以随时为自己添加新的属性、方法。
在 oc 中,任何遵循 NSCopying 的对象都可以被拷贝。类似地,所有 JS 中的对象也都可以被当作原型,被其它对象“拷贝”。
简单来说,抛弃之前所有有关类和继承的相关知识,因为 JavaScript 中并没有这些概念,简单地理解为,任何对象都可以被“参考”并“拷贝”生成新的对象。
1 | var person = { // 此处的person,也只是一个普通对象 |
表面上看,john 就像是 person 的一份拷贝
原型链(Prototype Chain)
上面提到,原型继承与 [object copy]
的表现非常相似,但其本质是完全不同的。在 oc 中,copy()
所做的事情是,创建一块与参照对象相同的新的内存区域,并把参照对象的各种属性值填到相应的内存块中。所以,当 copy()
执行完之后,新生成的对象就和原参照对象相互独立,再没有任何关系了。
但在 JavaScript 中,原型继承并不是按照参照对象的内存布局,重新创建一块相同的内存区域,相反,它只是简单的创建一个空对象,并以类似链表的形式,将新创建的对象与参照对象连接起来,所以,对 prototype
中属性的修改,将直接影响到所有其原型继承的对象,即便这些对象是在修改之前创建的。这也是 Prototype-based programming 的核心,以 Delegation 的方式,实现继承的效果。
加入两行打印,重新审视上面的代码:
1 | var person = { // 此处的person,也只是一个普通对象 |
可以看到,john
此时仅仅是一个空对象。而我们在调用 john.greet()
的时候,由于 john
对象内没有对应方法,那么它就会沿着原型链往上查找,直到其找到对应方法为止(或是没找到,则报错)。而当我们首次调用 greet()
时,由于 john
对象没有 name
和 age
属性,它同样也会沿着原型链向上查找(事实上,在 JavaScript中,一切都是对象,所以 greet
同样是一个属性,所以此处“方法”和其它值属性表现一致)。而当我们为 john
添加 name
和 age
后,尽管 greet
依然需要沿原型链查找到 person
才执行,但已经可以正确访问到 john.name
和 john.age
了(关于为何在 person.greet()
中的 this
可以正确访问到 john
的属性,可以参见 我的上一篇文章 )。
类、对象、继承的本质
在 ES6 引入 class
关键字之前,我们通常会以如下的代码来实现一个“类”:
1 | function Person(name){ |
如何理解上述代码段的输出结果呢?前面提到过,JavaScript 中并没有类的概念,更没有“实例化”这样的概念,因此,其实根本不存在将类实例化成对象的一个过程。这里,new
仅仅是一个语法糖,上述代码段本质可以看作:
1 | //function Person(){} |
如此,也就很好理解为什么一定要在“构造函数”的 prototype
对象中定义可继承的属性和方法了,因为 new
关键字正是以 someFunc.prototype
对象作为参照对象,来创建新的对象的。
转化后的代码是不是有点熟悉?没错!其实对象“实例化”的过程,本质也是原型继承!转化为更通用的表达,我们可以得到如下代码:
1 | function someFunc(param1,param2,...){...} |
如果理解了上面这个概念,那么继承的概念也就非常好理解了。
1 | function Person(name){ |
也有写法采用的是
Male.prototype = new Person()
,但直接使用Object.create()
会是更加合理高效的做法。
所谓继承,不过是将原型链延伸的过程,在上述代码中,我们调用 fusco.greet()
其实经历了三级的寻找:
fusco
对象:{ name: ‘Fusco’, gender: ‘male’ } ,不含greet
。Male.prototype
对象:{ introduce: [Function], constructor: [Function: Male] } ,不含greet
。Person.prototype
对象:{ greet: [Function] } ,找到,执行相应逻辑。- //如果此处没有找到,会到 Object.prototype 中寻找,如果还没有找到,就会报错。
总结
之前一直无法理解,在 JavaScript 中的类、继承为什么表现如此不同,原型链指向为什么这么奇怪。再次强烈推荐 A Touch of Class: Inheritance in JavaScript (点过前面链接的不用再点了,同一篇文章),简单易懂,说清了 JavaScript 中“类”与“继承”的本质。
牢记,JavaScript 并不是一门面向对象的语言,往常我们学习新的知识,常常新的知识和已懂知识建立联系,甚至是对应关系,但请不要试着把面向对象中的概念对应到 JavaScript 中(事实上我觉得 ES6 引入一系列有关类和继承的关键字后,刚接触 JavaScript 的初学者将更难理解原型继承和原型链的本质,当然用起来或许更符合面向对象的编程思想)。