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
2
3
4
5
6
7
8
9
10
11
12
13
var person = { // 此处的person,也只是一个普通对象
name : "person",
age : 0,
greet : function(){
log('My name is ' + this.name + ' ,at the age of ' + this.age);
}
};

var john = Object.create(person); // 以 person 为原型,创建 john
john.greet(); // 输出 My name is person ,at the age of 0
john.name = "john";
john.age = 30;
john.greet(); // 输出 My name is john ,at the age of 30

表面上看,john 就像是 person 的一份拷贝

原型链(Prototype Chain)

上面提到,原型继承与 [object copy] 的表现非常相似,但其本质是完全不同的。在 oc 中,copy() 所做的事情是,创建一块与参照对象相同的新的内存区域,并把参照对象的各种属性值填到相应的内存块中。所以,当 copy() 执行完之后,新生成的对象就和原参照对象相互独立,再没有任何关系了。

但在 JavaScript 中,原型继承并不是按照参照对象的内存布局,重新创建一块相同的内存区域,相反,它只是简单的创建一个空对象,并以类似链表的形式,将新创建的对象与参照对象连接起来,所以,对 prototype 中属性的修改,将直接影响到所有其原型继承的对象,即便这些对象是在修改之前创建的。这也是 Prototype-based programming 的核心,以 Delegation 的方式,实现继承的效果。

加入两行打印,重新审视上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var person = { // 此处的person,也只是一个普通对象
name : "person",
age : 0,
greet : function(){
log('My name is ' + this.name + ' ,at the age of ' + this.age);
}
};

var john = Object.create(person); // 以 person 为原型,创建 john

log(person);//{ name: 'person', age: 0, greet: [Function: greet] }
log(john); // {}

person.name = 'human';
john.greet(); // My name is human ,at the age of 0
john.name = "john";
john.age = 30;
john.greet(); // My name is john ,at the age of 30

可以看到,john 此时仅仅是一个空对象。而我们在调用 john.greet() 的时候,由于 john 对象内没有对应方法,那么它就会沿着原型链往上查找,直到其找到对应方法为止(或是没找到,则报错)。而当我们首次调用 greet() 时,由于 john 对象没有 nameage 属性,它同样也会沿着原型链向上查找(事实上,在 JavaScript中,一切都是对象,所以 greet 同样是一个属性,所以此处“方法”和其它值属性表现一致)。而当我们为 john 添加 nameage 后,尽管 greet 依然需要沿原型链查找到 person 才执行,但已经可以正确访问到 john.namejohn.age 了(关于为何在 person.greet() 中的 this 可以正确访问到 john的属性,可以参见 我的上一篇文章 )。

类、对象、继承的本质

在 ES6 引入 class 关键字之前,我们通常会以如下的代码来实现一个“类”:

1
2
3
4
5
6
7
8
9
function Person(name){
this.name = name;
}
Person.prototype.greet = function(){ log("Hi ,my name is " + this.name)}; // 写在prototype中,才可以被“继承”

var harold = new Person('harold');
log(harold); // Person { name: 'harold' }
log(Object.getPrototypeOf(harold)); // Person { greet: [Function] }
harold.greet(); //Hi ,my name is harold

如何理解上述代码段的输出结果呢?前面提到过,JavaScript 中并没有类的概念,更没有“实例化”这样的概念,因此,其实根本不存在将类实例化成对象的一个过程。这里,new 仅仅是一个语法糖,上述代码段本质可以看作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//function Person(){}  
//Person.prototype.greet = ...

function createObjectForPerson(name){
var person = Object.create(Person.prototype);
Person.call(person,name);
return person;
}

var harold = createObjectForPerson('harold')
log(harold); // Person { name: 'harold' }
log(Object.getPrototypeOf(harold)); // Person { greet: [Function] }
harold.greet(); //Hi ,my name is harold

如此,也就很好理解为什么一定要在“构造函数”的 prototype 对象中定义可继承的属性和方法了,因为 new 关键字正是以 someFunc.prototype 对象作为参照对象,来创建新的对象的。
转化后的代码是不是有点熟悉?没错!其实对象“实例化”的过程,本质也是原型继承!转化为更通用的表达,我们可以得到如下代码:

1
2
3
4
5
6
7
8
9
10
function someFunc(param1,param2,...){...}

// var obj = new someFunc(p1,p2,...);
// new 展开(具体实现不一定就是闭包函数,此处只是为了示意):

var obj = (function createObjectForSomeFunc(){
var newObj = Object.create(someFunc.prototype); // 此处的 someFunc.prototype 也是一个对象,用作原型继承的参考对象。
someFunc.call(newObj,p1,p2,...); // 所谓的“构造函数”,其实就是指定 someFunc 方法在 newObj 上下文中执行,以达到初始化的效果。
return newObj;
})()

如果理解了上面这个概念,那么继承的概念也就非常好理解了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person(name){
this.name = name;
}
Person.prototype.greet = function(){ log("Hi ,my name is " + this.name)}; // 写在prototype中,才可以被“继承”

function Male(name){
Person.call(this,name); // [super init]
this.gender = 'male';
}
Male.prototype = Object.create(Person.prototype); //
Male.prototype.introduce = function (){log("I am a male !")};
Male.prototype.constructor = Male;

var fusco = new Male('Fusco');
fusco.greet(); // Hi ,my name is Fusco
fusco.introduce(); // I am a male !

也有写法采用的是 Male.prototype = new Person() ,但直接使用 Object.create() 会是更加合理高效的做法

所谓继承,不过是将原型链延伸的过程,在上述代码中,我们调用 fusco.greet() 其实经历了三级的寻找:

  1. fusco 对象:{ name: ‘Fusco’, gender: ‘male’ } ,不含 greet
  2. Male.prototype 对象:{ introduce: [Function], constructor: [Function: Male] } ,不含 greet
  3. Person.prototype 对象:{ greet: [Function] } ,找到,执行相应逻辑。
  4. //如果此处没有找到,会到 Object.prototype 中寻找,如果还没有找到,就会报错。

总结

之前一直无法理解,在 JavaScript 中的类、继承为什么表现如此不同,原型链指向为什么这么奇怪。再次强烈推荐 A Touch of Class: Inheritance in JavaScript (点过前面链接的不用再点了,同一篇文章),简单易懂,说清了 JavaScript 中“类”与“继承”的本质。

牢记,JavaScript 并不是一门面向对象的语言,往常我们学习新的知识,常常新的知识和已懂知识建立联系,甚至是对应关系,但请不要试着把面向对象中的概念对应到 JavaScript 中(事实上我觉得 ES6 引入一系列有关类和继承的关键字后,刚接触 JavaScript 的初学者将更难理解原型继承和原型链的本质,当然用起来或许更符合面向对象的编程思想)。