征服 JavaScript 面试:类继承和原型继承的区别

2017/01/30 · JavaScript
· 继承

原文出处: Eric
Elliott
   译文出处:众成翻译   

威尼斯人线上娱乐 1

图-电子吉他-Feliciano Guimarães(CC BY 2.0)

“征服JavaScript面试”是我所写的一个系列文章,旨在帮助那些应聘中、高级JavaScript开发职位的读者们准备一些常见的面试问题。我自己在实际面试当中也经常会问到这类问题。系列的第一篇文章请参见“什么是闭包”

注:本文均以ES6标准做代码举例。如果想了解ES6,可以参阅“ES6学习指南”

原文链接:https://medium.com/javascript-scene/master-the-javascript-interview-what-s-the-difference-between-class-prototypal-inheritance-e4cd0a7562e9\#.d84c324od

对象在JavaScript语言中使用十分广泛,学会如何有效地运用对象,有助于工作效率的提升。而不良的面向对象设计,可能会导致代码工程的失败,更严重的话还会引发整个公司悲剧

不同于其它大部分语言,JavaScript是基于原型的对象系统,而不是基于。遗憾的是,大多数JavaScript开发者对其对象系统理解不到位,或者难以良好地应用,总想按照类的方式使用,其结果将导致代码里的对象使用混乱不堪。所以JavaScript开发者最好对原型和类都能有所了解。

威尼斯人线上娱乐 2

类继承和原型继承有何区别?

这个问题比较复杂,大家有可能会在评论区各抒己见、莫衷一是。因此,列位看官需要打起十二分的精神学习个中差异,并将所学良好地运用到实践当中去。

类继承:可以把类比作一张蓝图,它描绘了被创建对象的属性及特征。

众所周知,使用new关键字调用构造函数可以创建类的实例。在ES6中,不用class关键字也可以实现类继承。像Java语言中类的概念,从技术上来说在JavaScript中并不存在。不过JavaScript借鉴了构造函数的思想。ES6中的class关键字,相当于是建立在构造函数之上的一种封装,其本质依旧是函数。

JavaScript

class Foo {} typeof Foo // ‘function’

1
2
class Foo {}
typeof Foo // ‘function’

虽然JavaScript中的类继承的实现建立在原型继承之上,但是并不意味二者具有相同的功能:

JavaScript的类继承使用原型链来连接子类和父类的
[[Prototype]],从而形成代理模式。通常情况下,super()_构造函数也会被调用。这种机制,形成了单一继承结构,以及面向对象设计中最紧密的耦合行为

“类之间的继承关系,导致了子类间的相互关联,从而形成了——基于层级的分类。”

原型继承: 原型是工作对象的实例。对象直接从其他对象继承属性。

原型继承模式下,对象实例可以由多个对象源所组成。这样就使得继承变得更加灵活且[[Prototype]]代理层级较浅。换言之,对于基于原型继承的面向对象设计,不会产生层级分类这样的副作用——这是区别于类继承的关键所在。

对象实例通常由工厂函数或者Object.create()来创建,也可以直接使用Object字面定义。

原型是工作对象的实例。对象直接从其他对象继承属性。”

JavaScript

为什么搞清楚类继承和原型继承很重要?

继承,本质上讲是一种代码重用机制——各种对象可以借此来共享代码。如果代码共享的方式选择不当,将会引发很多问题,如:

使用类继承,会产生父-子对象分类的副作用

这种类继承的层次划分体系,对于新用例将不可避免地出现问题。而且基类的过度派生,也会导致脆弱基类问题,其错误将难以修复。事实上,类继承会引发面向对象程序设计领域的诸多问题:

  • 紧耦合问题(在面向对象设计中,类继承是耦合最严重的一种设计),紧耦合还会引发另一个问题:
  • 脆弱基类问题
  • 层级僵化问题(新用例的出现,最终会使所有涉及到的继承层次上都出现问题)
  • 必然重复性问题(因为层级僵化,为了适应新用例,往往只能复制,而不能修改已有代码)
  • 大猩猩-香蕉问题(你想要的是一个香蕉,但是最终到的却是一个拿着香蕉的大猩猩,还有整个丛林)

对于这些问题我曾做过深入探讨:“类继承已是明日黄花——探究基于原型的面向对象编程思想”

“优先选择对象组合而不是类继承。”
~先驱四人,《设计模式:可复用面向对象软件之道》

里面很好地总结了:

一. 重新认识面向对象

是否所有的继承方式都有问题?

人们说“优先选择对象组合而不是继承”的时候,其实是要表达“优先选择对象组合而不是类继承”(引用自《设计模式》的原文)。该思想在面向对象设计领域属于普遍共识,因为类继承方式的先天缺陷,会导致很多问题。人们在谈到继承的时候,总是习惯性地省略这个字,给人的感觉像是在针对所有的继承方式,而事实上并非如此。

因为大部分的继承方式还是很棒的。

1. JavaScript是一门面向对象的语言

在说明JavaScript是一个面向对象的语言之前,
我们来探讨一下面向对象的三大基本特征: 封装, 继承, 多态

封装

把抽象出来的属性和对方法组合在一起, 且属性值被保护在内部,
只有通过特定的方法进行改变和读取称为封装

我们以代码举例, 首先我们构造一个Person构造函数,
它有nameid两个属性, 并有一个sayHi方法用于打招呼:

//定义Person构造函数
function Person(name, id) {
  this.name = name;
  this.id = id;
}

//在Person.prototype中加入方法
Person.prototype.sayHi = function() {
  console.log('你好, 我是' +  this.name);
}

现在我们生成一个实例对象p1, 并调用sayHi()方法

//实例化对象
let p1 = new Person('阿辉', 1234);

//调用sayHi方法
p1.sayHi();

在上述的代码中, p1这个对象并不知道sayHi()这个方法是如何实现的,
但是仍然可以使用这个方法. 这其实就是封装.
你也可以实现对象属性的私有和公有,
我们在构造函数中声明一个salary作为私有属性,
有且只有通过getSalary()方法查询到薪资.

function Person(name, id) {
  this.name = name;
  this.id = id;
  let salary = 20000;
  this.getSalary = function (pwd) {
    pwd === 123456 ? console.log(salary) : console.log('对不起, 你没有权限查看密码');
  }
}

继承

可以让某个类型的对象获得另一个类型的对象的属性和方法称为继承

以刚才的Person作为父类构造器, 我们来新建一个子类构造器Student,
这里我们使用call()方法实现继承

function Student(name, id, subject) {
  //使用call实现父类继承
  Person.call(this, name, id);
  //添加子类的属性
  this.subject = subject;
}

let s1 = new Student('阿辉', 1234, '前端开发');

多态

同一操作作用于不同的对象产生不同的执行结果, 这称为多态

JavaScript中函数没有重载, 所以JavaScript中的多态是靠函数覆盖实现的。

同样以刚才的Person构造函数为例,
我们为Person构造函数添加一个study方法

function Person(name, id) {
  this.name = name;
  this.id = id;
  this.study = function() {
    console.log(name + '在学习');
  }
}

同样, 我们新建一个StudentTeacher构造函数, 该构造函数继承Person,
并也添加study方法

function Student(subject) {
  this.subject = subject;
  this.study = function() {
    console.log(this.name + '在学习' + this.subject);
  }
}
Student.prototype = new Person('阿辉', 1234);
Student.prototype.constructor = Student;

function Teacher(subject) {
  this.subject = subject;
  this.study = function() {
    console.log(this.name + '为了教学而学习' + this.subject);
  }
}
Teacher.prototype = new Person("老夫子", 4567);
Teacher.prototype.constructor = Teacher;

测试我们新建一个函数doStudy

function doStudy(role) {
  if(role instanceof Person) {
    role.study();
  }
}

此时我们分别实例化StudentTeacher, 并调用doStudy方法

let student = new Student('前端开发');
let teacher = new Teacher('前端开发');

doStudy(student); //阿辉在学习前端开发
doStudy(teacher); //老夫子为了教学在学习前端开发

对于同一函数doStudy, 由于参数的不同,
导致不同的调用结果,这就实现了多态.

JavaScript的面向对象
从上面的分析可以论证出, JavaScript是一门面向对象的语言,
因为它实现了面向对象的所有特性. 其实,
面向对象仅仅是一个概念或者一个编程思想而已, 它不应该依赖于某个语言存在,
比如Java采用面向对象思想构造其语言, 它实现了类, 继承, 派生, 多态,
接口等机制. 但是这些机制,只是实现面向对象的一种手段,
而非必须。换言之,
一门语言可以根据自身特性选择合适的方式来实现面向对象。
由于大多数程序员首先学习的是Java, C++等高级编程语言,
因而先入为主的接受了“类”这个面向对象实际方式,所以习惯性的用类式面向对象语言中的概念来判断该语言是否是面向对象的语言。这也是很多有其他编程语言经验的人在学习JavaScript对象时,感觉到很困难的地方。

实际上,
JavaScript是通过一种叫原型(prototype)的方式来实现面向对象编程的。下面我们就来讨论一下基于类(class-basesd)的面向对象基于原型(protoype-based)的面向对象这两者的差别。

三种不同的原型继承方式

在深入探讨其他继承类型之前,还需要先仔细分析下我所说的类继承

你可以在Codepen上找到并测试下这段示例程序

BassAmp 继承自 GuitarAmp, ChannelStrip 继承自 BassAmp
GuitarAmp。从这个例子我们可以看到面向对象设计发生问题的过程。ChannelStrip实际上并不是GuitarAmp的一种,而且它根本不需要一个cabinet的属性。一个比较好的解决办法是创建一个新的基类,供amps和strip来继承,但是这种方法依然有所局限。

到最后,采用新建基类的策略也会失效。

更好的办法就是通过类组合的方式,来继承那些真正需要的属性:

修改后的代码

认真看这段代码,你就会发现:通过对象组合,我们可以确切地保证对象可以按需继承。这一点是类继承模式不可能做到的。因为使用类继承的时候,子类会把需要的和不需要的属性统统继承过来。

这时候你可能会问:“唔,是那么回事。可是这里头怎么没提到原型啊?”

客官莫急,且听我一步步道来~首先你要知道,基于原型的面向对象设计方法总共有三种。

  1. 拼接继承:
    是直接从一个对象拷贝属性到另一个对象的模式。被拷贝的原型通常被称为mixins。ES6为这个模式提供了一个方便的工具Object.assign()。在ES6之前,一般使用Underscore/Lodash提供的.extend(),或者
    jQuery 中的$.extend(),
    来实现。上面那个对象组合的例子,采用的就是拼接继承的方式。
  2. 原型代理:JavaScript中,一个对象可能包含一个指向原型的引用,该原型被称为代理。如果某个属性不存在于当前对象中,就会查找其代理原型。代理原型本身也会有自己的代理原型。这样就形成了一条原型链,沿着代理链向上查找,直到找到该属性,或者找到根代理Object.prototype为止。原型就是这样,通过使用new关键字来创建实例以及Constructor.prototype前后勾连成一条继承链。当然,也可以使用Object.create()来达到同样的目的,或者把它和拼接继承混用,从而可以把多个原型精简为单一代理,也可以做到在对象实例创建后继续扩展。
  3. 函数继承:在JavaScript中,任何函数都可以用来创建对象。如果一个函数既不是构造函数,也不是
    class,它就被称为工厂函数。函数继承的工作原理是:由工厂函数创建对象,并向该对象直接添加属性,借此来扩展对象(使用拼接继承)。函数继承的概念最先由道格拉斯·克罗克福德提出,不过这种继承方式在JavaScript中却早已有之。

这时候你会发现,拼接继承是JavaScript能够实现对象组合的秘诀,也使得原型代理和函数继承更加丰富多彩。

多数人谈起JavaScript面向对象设计时,首先想到的都是原型代理。不过你看,可不仅仅只有原型代理。要取代类继承,原型代理还是得靠边站,对象组合才是主角

2. 基于类的面向对象和基于原型的面向对象的比较

基于类的面向对象

在基于的面向对象语言中(比如Java和C++),
是构建在类(class)实例(instance)上的。其中定义了所有用于具有某一特征对象的属性。是抽象的事物,
而不是其所描述的全部对象中的任何特定的个体。另一方面,
一个实例是一个的实例化,是其中的一个成员。

基于原型的面向对象
在基于原型的语言中(如JavaScript)并不存在这种区别:它只有对象!不论是构造函数(constructor),实例(instance),原型(prototype)本身都是对象。基于原型的语言具有所谓的原型对象的概念,新对象可以从中获得原始的属性。

所以,在JavaScript中有一个很有意思的__proto__属性(ES6以下是非标准属性)用于访问其原型对象,
你会发现,上面提到的构造函数,实例,原型本身都有__proto__指向原型对象。其最后顺着原型链都会指向Object这个构造函数,然而Object的原型对象的原型是null,不信,
你可以尝试一下Object.prototype.__proto__ === nulltrue。然而typeof null === 'object'true。到这里,
我相信你应该就能明白为什么JavaScript这类基于原型的语言中没有类和实例的区别,
而是万物皆对象!

差异总结

基于类的(Java) 基于原型的(JavaScript)
类和实例是不同的事物。 所有对象均为实例。
通过类定义来定义类;通过构造器方法来实例化类。 通过构造器函数来定义和创建一组对象。
通过 new 操作符创建单个对象。 相同
通过类定义来定义现存类的子类, 从而构建对象的层级结构 指定一个对象作为原型并且与构造函数一起构建对象的层级结构
遵循类链接继承属性 遵循原型链继承属性
类定义指定类的所有实例的所有属性。无法在运行时动态添加属性 构造器函数或原型指定初始的属性集。允许动态地向单个的对象或者整个对象集中添加或移除属性。

*为什么说对象组合能够避免脆弱基类问题

要搞清楚这个问题,首先要知道脆弱基类是如何形成的:

  1. 假设有基类A
  2. B继承自基类A
  3. C继承自B
  4. D也继承自B

C中调用super方法,该方法将执行类B中的代码。同样,B也调用super方法,该方法会执行A中的代码。

CD需要从AB中继承一些无关联的特性。此时,D作为一个新用例,需要从A的初始化代码继承一些特性,这些特性与C的略有不同。为了应对以上需求,菜鸟开发人员会去调整A的初始化代码。于是乎,尽管D可以正常工作,但是C原本的特性被破坏了。

上面这个例子中,ABCD提供各种特性。可是,CD不需要来自AB的所有特性,它们只是需要继承某些属性。但是,通过继承和调用super方法,你无法选择性地继承,只能全部继承:

“面向对象语言的问题在于,子类会携带有父类所隐含的环境信息。你想要的是一个香蕉,但是最终到的却是一个拿着香蕉的大猩猩,以及整个丛林”——乔·阿姆斯特朗《编程人生》

如果是使用对象组合的方式 设想有如下几个特性:

JavaScript

feat1, feat2, feat3, feat4

1
feat1, feat2, feat3, feat4

C需要特性feat1feat3,而D 需要特性feat1, feat2,
feat4

JavaScript

const C = compose(feat1, feat3); const D = compose(feat1, feat2, feat4);

1
2
const C = compose(feat1, feat3);
const D = compose(feat1, feat2, feat4);

假如你发现D需要的特性与feat1**略有出入。这时候无需改动feat1只要创建一个feat1的定制化版本*,就可以做到保持feat2feat4特性的同时,也不会影响到C*,如下:

JavaScript

const D = compose(custom1, feat2, feat4);

1
const D = compose(custom1, feat2, feat4);

像这样灵活的优点,是类继承方式所不具备的。因为子类在继承的时候,会连带着整个类继承结构

这种情况下,要适应新的用例,要么复制现有类层划分(必然重复性问题),要么在现有类层结构的基础上进行重构,就又会导致脆弱基类问题

而采用对象组合的话,这两个问题都将迎刃而解。

二. ES5中的面向对象

*这里的ES5并不特指ECMAScript 5, 而是代表ECMAScript 6
之前的ECMAScript!

你真的了解原型了吗?

采用先创建类和构造函数,然后再继承的方式,并不是正宗的原型继承,不过是使用原型来模拟类继承的方法罢了。这里有一些关于JavaScript中关于继承的常见误解,供君参考。

JavaScript中,类继承模式历史悠久,而且建立在灵活丰富的原型继承特性之上(ES6以上的版本亦然)。可是一旦使用了类继承,就再也享受不到原型灵活强大的特性了。类继承的所有问题都将始终如影随形无法摆脱

在JavaScript中使用类继承,是一种舍本逐末的行为。

(一) ES5中对象的创建

在ES5中创建对象有两种方式, 第一种是使用对象字面量的方式,
第二种是使用构造函数的方式。该两种方法在特定的使用场景分别有其优点和缺点,
下面我们来分别介绍这两种创建对象的方式。

Stamps:可组合式工厂函数

多数情况下,对象组合是通过使用工厂函数来实现:工厂函数负责创建对象实例。如果工厂函数也可以组合呢?快查看Stamp文档找出答案吧。

(译者注:感觉原文表达有些不尽兴。于是我自作主张地画了2个图便于读者理解。不足之处还请谅解和指正)
威尼斯人线上娱乐 3图:类继承

说明:从图上可以直接看出单一继承关系、紧耦合以及层级分类的问题;其中,类8,只想继承五边形的属性,却得到了继承链上其它并不需要的属性——大猩猩/香蕉问题;类9只需要把五角星属性修改成四角形,导致需要修改基类1,从而影响整个继承树——脆弱基类/层级僵化问题;否则就需要为9新建基类——必然重复性问题。
威尼斯人线上娱乐 4图:原型继承/对象组合

说明:采用原型继承/对象组合,可以避免复杂纵深的层级关系。当1需要四角星特性的时候,只需要组合新的特性即可,不会影响到其他实例。

1 赞 8 收藏
评论

威尼斯人线上娱乐 5

1. 使用对象字面量的方式

我们通过对象字面量的方式创建两个student对象,分别是student1student2

var student1 = {
  name: '阿辉',
  age: 22,
  subject: '前端开发'
};

var student2 = {
  name: '阿傻',
  age: 22,
  subject: '大数据开发'
};

上面的代码就是使用对象字面量的方式创建实例对象,
使用对象字面量的方式在创建单一简单对象的时候是非常方便的。但是,它也有其缺点:

  • 在生成多个实例对象时,
    我们需要每次重复写name,age,subject属性,写起来特别的麻烦
  • 虽然都是学生的对象,
    但是看不出student1student2之间有什么联系。

为了解决以上两个问题, JavaScript提供了构造函数创建对象的方式。

2. 使用构造函数的方式

构造函数就其实就是一个普通的函数,当对构造函数使用new进行实例化时,会将其内部this的指向绑定实例对象上,下面我们来创建一个Student构造函数(构造函数约定使用大写开头,和普通函数做区分)。

function Student (name, age, subject) {
  this.name = name;
  this.age = age; 
  this.subject = subject;
  console.log(this);
}

我特意在构造函数中打印出this的指向。上面我们提到,构造函数其实就是一个普通的函数,
那么我们使用普通函数的调用方式尝试调用Student

Student('阿辉', 22, '前端开发'); //window{}

采用普通方式调用Student时,
this的指向是window。下面使用new来实例化该构造函数,
生成一个实例对象student1

let student1 = new Student('阿辉', 22, '前端开发'); //Student {name: "阿辉", age: 22, subject: "前端开发"}

当我们采用new生成实例化对象student1时, this不再指向window,
而是指向的实例对象本身。这些,
都是new帮我们做的。上面的就是采用构造函数的方式生成实例对象的方式,
并且当我们生成其他实例对象时,由于都是采用Student这个构造函数实例化而来的,
我们能够清楚的知道各实例对象之间的联系。

let student1 = new Student('阿辉', 22, '前端开发');
let student2 = new Student('阿傻', 22, '大数据开发');
let student3 = new Student('阿呆', 22, 'Python');
let student4 = new Student('阿笨', 22, 'Java');

(二) ES5中对象的继承