- 原文地址:Why Composition is Harder with Classes
- 原文做者:
Eric Elliott- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:yoyoyohamapi
- 校对者:sunui IridescentMia
注意:这是 “软件编写” 系列文章的第十部分,该系列主要阐述如何在 JavaScript ES6+ 中从零开始学习函数式编程和组合化软件(compositional software)技术(译注:关于软件可组合性的概念,参见维基百科 Composability)。后续还有更多精彩内容,敬请期待!
< 上一篇 | << 返回第一篇javascript
前文中,咱们仔细审视了工厂函数,而且也看到了在使用了函数式 mixins 以后,它们能很好地服务于函数组合。如今,咱们还将更加仔细地看看类,验证 class
的机制是如何妨碍了组合式软件编写。前端
但咱们并不彻底否认类,一些优秀的类使用案例和如何更加安全地使用类也是本文将会探讨的。java
ES6 拥有了一个便捷的 class
语法,这也让你难免怀疑为何咱们还须要工厂函数。两者最显著的区别是构造函数以及 class
要使用 new
关键字。但 new
究竟作了什么?react
this
绑定到了该对象。this
。[[Prototype]]
(一个内部引用) 属性设置为 Constructor.prototype
,从而有 Object.getPrototypeOf(instance) === Constructor.prototype
。instance.constructor === Constructor
。全部的这些都意味着,与工厂函数不一样,类并非完成组合式函数 mixin 的好手段。虽然你仍可使用 class
来完成组合,但在后文中你将看到,这是一个很是复杂的过程,你的煞费苦心并不值当。android
最终,你可能须要将类重构为工厂函数,可是若是你要求调用者使用 new
关键字,那么重构将会以各类你没法预见到的方式打破原有的客户端代码。首先,不一样于类和构造函数,工厂函数不会自动地构造一条委托原型链。ios
[[Prototype]]
连接是服务于原型委托的,若是你有数以百万计的对象,它将能帮你节约内存,亦或当你须要在程序中在 16 毫秒内的渲染循环中访问一个对象成千上万的属性时,它可以带来一些微小的性能提高。git
若是你并不须要内存或者性能上的微型优化,[[Prototype]]
连接就弊大于利了。在 JavaScript 中,原型链增强了 instanceof
运算符,但不幸的是,因为如下两个缘由,instanceof
并不可靠:github
在 ES5 中,Constructor.prototype
连接是动态可重配的,这一特性在你须要建立抽象工厂时显得尤其方便,可是若是你使用了该特性,当 Constructor.prototype
引用的对象和 [[Prototype]]
属性指向的不是同一对象时,instanceof
会引发伪阴性(false negative),即丢失了对象和所属类的关系:编程
class User {
constructor ({userName, avatar}) {
this.userName = userName;
this.avatar = avatar;
}
}
const currentUser = new User({
userName: 'Foo',
avatar: 'foo.png'
});
User.prototype = {}; // 重配了 User 原型
console.log(
currentUser instanceof User, // <-- false -- 糟糕!
// 可是该对象的形态确实知足 User 类型
// { avatar: "foo.png", userName: "Foo" }
currentUser
);复制代码
Chrome 意识到了这个问题,因此在属性描述之中,将 Constructor.prototype
的 configurable
属性设置为了 false
。然而,Babel 就没有实现相似的行为,因此 Babel 编译后的代码将表现得和 ES5 的构造函数同样。而当你试图从新配置 Constructor.prototype
属性时,V8 将静默失败。不管是哪一种方式,你都得不到你想要的结果。更加糟糕的是,从新设置 Constructor.prototype
会是先后矛盾的,所以我不推荐这样作。后端
更常见的问题是,JavaScript 会拥有多个执行上下文 -- 相同代码所在的内存沙盒会访问不一样的物理内存地址。例如,若是在父 frame 中有一个构造函数,且在 iframe
中有相同的构造函数,那么父 frame 中的 Constructor.prototype
和 iframe
中的 Constructor.prototype
将不会引用相同的内存位置。这是由于 JavaScript 中的对象值在底层是内存引用的,而不一样的 frame 指向内存的不一样内存位置,因此 ===
将会检查失败。
instanceof
的另外一个问题是,它是一个名义上的类型检查而非结构类型检查,这意味着若是你开始使用了 class
并在以后切换到了抽象工厂,全部调用了 instanceof
的代码将再也不能明白新的实现,即使这些代码都知足了接口约束。例如,你已经构建了一个音乐播放器接口,以后产品团队要求你为视频播放也提供支持,以后的以后,又叫你支持全景视频。视频播放器对象和音乐播放器对象是使用一致的控制策略:播放,中止,倒回,快进。
可是若是你使用了 instanceof
做为对象类型检查,全部实现了你的视频接口类的对象不会知足代码中已经存在的 foo instanceof AudioInterface
检查。
这些检查本应当成功的,然而如今却失败了。在其余语言中,经过容许一个类声明其所实现的接口,实现了可共享接口,从而也就解决了上面的问题。但在 JavaScript 中,这一点尚不能作到。
在 JavaScript 中,若是你不须要委托原型连接([[Prototype]]
)的话,就打断委托原型链,让每次对象的类型判断检查都失败,错就错个完全,这才是使用 instanceof
的最好方式。这样的处理方式你也不会对对象类型判断的可靠性产生误解。这实际上是让你不要相信 instanceof
,它也就没法对你撒谎了。
.constructor
在 JavaScript 中已经鲜有使用了,它本该颇有用,将它放入你的对象实例中也会是个好主意。但大多数状况下,若是你不尝试使用它来进行类型检测的话,它会是毛病重重的,而且,它也是不安全的,缘由和 instanceof
不安全的缘由同样。
理论上来讲,.constructor
对于建立通用函数颇有用,这些通用函数可以返回你传入对象的新实例。
实践中,在 JavaScript 中,有许多不一样的方式来建立新的实例。即便是一些微不足道的目的,让对象保持一个其构造函数的引用,和知道如何使用构造函数够实例化新的对象也并非一件事儿,咱们能够看到下面这个例子,如何建立一个与指定对象同类型的空实例,首先,咱们借助于 new
及对象的 .constructor
属性:
// 返回任何传入对象类型的空实例?
const empty = ({ constructor } = {}) => constructor ?
new constructor() :
undefined
;
const foo = [10];
console.log(
empty(foo) // []
);复制代码
对于数组类型来讲,这段代码工做良好。那么咱们试试返回 Promise 类型的空对象:
// 返回任何传入对象类型的空实例?
const empty = ({ constructor } = {}) => constructor ?
new constructor() :
undefined
;
const foo = Promise.resolve(10);
console.log(
empty(foo) // [TypeError: Promise resolver undefined is
// not a function]
);复制代码
注意到代码中的 new
关键字,这是问题的来源。能够认为,在任何工厂函数中使用 new
关键字是不安全的,有时它会形成错误。
要使上述代码正确工做,咱们须要有一个标准的方式来传入一个新的值到新的实例中,这个方式将使用一个不须要 new
的标准工厂函数。对此,这里有个规范:任何构造函数或者工厂方法都须要一个 .of()
的静态方法。.of()
是一个工厂函数,它能根据你传入的对象,返回对应类型的新实例。
如今,咱们可使用 .of()
来建立一个更好的通用 empty()
函数:
// 返回任何传入对象类型的空实例?
const empty = ({ constructor } = {}) => constructor.of ?
constructor.of() :
undefined
;
const foo = [23];
console.log(
empty(foo) // []
);复制代码
不幸的是,.of()
静态方法才开始在 JavaScript 中获得支持。Promise
对象没有 .of()
静态方法,但有一个与之行为一致的静态方法 .resolve()
,所以,咱们的通用工厂函数没法工做在 Promise
对象上:
// 返回任意对象类型的空实例?
const empty = ({ constructor } = {}) => constructor.of ?
constructor.of() :
undefined
;
const foo = Promise.resolve(10);
console.log(
empty(foo) // undefined
);复制代码
一样地,若是字符串、数字、object、map、weak map、set 等类型也提供了 .of()
静态方法,那么 .constructor
属性将成为 JavaScript 中更加有用的特性。咱们可以使用它来构建一个富工具函数库,这个库可以工做在 functor,monad 以及其余任何代数类型上。
对于一个工厂函数来讲,添加 .constructor
和 .of()
是很是容易的:
const createUser = ({
userName = 'Anonymous',
avatar = 'anon.png'
} = {}) => ({
userName,
avatar,
constructor: createUser
});
createUser.of = createUser;
// 测试 .of 和 .constructor:
const empty = ({ constructor } = {}) => constructor.of ?
constructor.of() :
undefined
;
const foo = createUser({ userName: 'Empty', avatar: 'me.png' });
console.log(
empty(foo), // { avatar: "anon.png", userName: "Anonymous" }
foo.constructor === createUser.of, // true
createUser.of === createUser // true
);复制代码
你甚至能够经过 Object.create()
方法来让 .constructor
不可枚举(译注:这样 Object.keys()
等方法就没法拿到 .constructor
属性):
const createUser = ({
userName = 'Anonymous',
avatar = 'anon.png'
} = {}) => Object.assign(
Object.create({
constructor: createUser
}), {
userName,
avatar
}
);复制代码
工厂函数经过下面这些方式提升了代码的灵活性:
instanceof
或者其余不可靠的类型检测手段,这些手段每每会在跨执行上下文调用或是当你切换到一个抽象工厂时破坏了原有的代码。.play()
方法来知足不一样的媒体类型。尽管多数目标可以经过类完成,可是使用工厂函数,将会让一切变得更加轻松。使用工厂函数,将更少地遇到 bug,更少地陷入复杂性的泥潭,以及更少的代码。
基于以上缘由,更加推崇将 class
重构为工厂函数,但也要注意,重构会是个复杂而且有可能产生错误的过程。在每个面向对象语言中,从类到工厂函数的重构都是一个广泛的需求。关于此,你能够在 Martin Fowler、Kent Beck、John Brant、William Opdyke 和 Don Roberts 的这篇文章中知道更多:Refactoring: Improving the Design of Existing Code
因为 new
改变了一个函数调用的行为,从类到工厂函数进行的重构将是一个潜在的巨大改变。换言之,强制调用者使用 new
将不可避免地将调用者限制到构造函数的实现中,所以,new
将潜在地引发巨大的调用相关的 API 的实现改变。
咱们已经见识过了,下面这些隐式行为会让从类到工厂的转变成为一个巨大的改变:
[[Prototype]]
连接,那么该实例全部调用 instanceof
进行类型检测的代码都须要修改。.constructor
属性,全部用到该实例 .constructor
属性的代码都须要修改。这两个问题能够经过在工厂函数建立对象的过程当中绑定这两个属性来补救。
你也要留心 this
可能会绑定到工厂函数的调用环境,这在使用 new
时是不须要考虑的(译注:new
会将 this
默认绑定到新建立的对象上)。若是你想要将抽象工厂原型存储为工厂函数的静态属性,这会让问题变得更加棘手。
这是也是另外一个须要留意的问题。全部的 class
调用都必须使用 new
。省略了 new
的话,将会抛出以下错误:
class Foo {};
// TypeError: Class constructor Foo cannot be invoked without 'new'
const Bar = Foo();复制代码
在 ES6 及以上的版本,更常使用箭头函数来建立工厂,可是在 JavaScript 中,因为箭头函数不会拥有本身的 this
绑定,用 new
来调用一个箭头函数将会抛出错误:
const foo = () => ({});
// TypeError: foo is not a constructor
const bar = new foo();复制代码
因此,你没法在 ES6 环境下去将类重构为一个箭头函数工厂。但这可有可无,彻头彻尾的失败是件好事儿,这会让你断了使用 new
的念想。
可是,若是你将箭头函数编译为标准函数来容许对标准函数使用 neW
,就会错上加错。在构建应用程序时,代码工做良好,可是应用切到生产环境时,也许会致使错误,从而影响了用户体验,甚至让整个应用崩溃。
一个编辑器默认配置的变化就能破坏你的应用,甚至是你都没有改变任何你本身撰写的代码。再唠叨一句:
警告:从
class
到箭头函数的工厂的重构可能能在某一编译器下工做,可是若是工厂被编译为了一个原生箭头函数,你的应用将由于不能对该箭头函数使用new
而崩溃。
开闭原则指的是,咱们的 API 应当对扩展开放,而对修改封闭。因为对某个类常见的扩展是将它变为一个灵活性更高的工厂函数,可是这个重构如上文所说是一个巨大的改变,所以 new
关键字是对扩展封闭而对修改开放的,这与开闭原则相悖。
若是你的 class
API 是公开的,或者若是你和一个大型团队一块儿服务于一个大型项目,重构极可能破坏一些你没法意识到的代码。更好的作法是淘汰掉整个类(译注:也要淘汰类的相关操做,如 new
,instanceof
等),并将其替代为工厂函数。
该过程将一个小的,兴许可以静默解决的技术问题变为了极大的人的问题,新的重构将要求开发者对此具备足够的意识,受教育程度,以及愿意入伙重构,所以,这样的重构会是一个十分繁重的任务。
我已经见到过了 new
屡次引发了很是使人头痛的问题,但这很容易避免:
使用工厂函数替代类。
class
关键字被认为是为 JavaScript 中的对象模式建立提供了更棒的语法,但在某些方面,它仍有不足:
class
的初衷是要提供一个友好的语法来在 JavaScript 中模拟其余语言中的 class
。但咱们须要问问本身,究竟在 JavaScript 中是否真的须要来模拟其余语言中的 class
?
JavaScript 的工厂函数提供了一个更加友好的语法,开箱即用,很是简单。一般,一个对象字面量就足够完成对象建立了。若是你须要建立多个实例,工厂函数会是接下来的选择。
在 Java 和 C++ 中,相较于类,工厂函数更加复杂,但因为其提供的高度灵活性,工厂仍然值得建立。在 JavaScript 中,相较于类,工厂则更加简单,可是却更增强大。
下面的代码使用类来建立对象:
class User {
constructor ({userName, avatar}) {
this.userName = userName;
this.avatar = avatar;
}
}
const currentUser = new User({
userName: 'Foo',
avatar: 'foo.png'
});复制代码
一样的功能,咱们替换为工厂函数试试:
const createUser = ({ userName, avatar }) => ({
userName,
avatar
});
const currentUser = createUser({
userName: 'Foo',
avatar: 'foo.png'
});复制代码
若是熟悉 JavaScript 以及箭头函数,那么可以感觉到工厂函数更简洁的语法及所以带来的代码可读性的提升。或许你还倾向于 new
,但下面这篇文章阐述了应当避免使用的 new
的缘由:Familiarity bias may be holding you back。
还有别的工厂优于类的论证吗?
委托原型好处寥寥。
class
语法稍优于 ES5 的构造函数,其主要目的在于为对象创建委托原型链,可是委托原型实在是好处寥寥。缘由主要归结于性能。
class
提供了两个性能优化方式:属性检索优化以及存在委托原型上的属性会共享内存。
大多数现代设备的 RAM 都不小,任何类型的闭包做用域或者属性检索都能达到成百上千的 ops。因此是否使用 class
形成的性能差别在现代设备中几乎能够忽略不计了。
固然,也有例外。RxJS 使用了 class
实例,是由于它们确实比闭包性能好些,可是 RxJS 做为一个工具库,有可能工做在操做频繁的上下文中,所以它须要限制其渲染循环在 16 毫秒内完成,这无可厚非。
ThreeJS 也使用了类,但你知道的,ThreeJS 是一个 3d 渲染库,经常使用于开发游戏引擎,对性能极度苛求,每 16 毫秒的渲染循环就要操做上千个对象。
上面两个例子想说明的是,做为对性能有要求的库,它们使用 class
是合情合理的。
在通常的应用开发中,咱们应当避免提早优化,只有在性能须要提高或者遭遇瓶颈时才考虑去优化它。对于大多数应用来讲,性能优化的点在于网络的请求和响应,过渡动画,静态资源的缓存策略等等。
诸如使用 class
这样的微型优化对性能的优化是有限的,除非你真正发现了性能问题,并找准了瓶颈发生的位置。
取而代之的,你更应当关注和优化代码的可维护性和灵活性。
JavaScript 中的类是动态的,instanceof
的类型检测不会真正地跨执行上下文工做,因此基于 class
的类型检测不值得考虑。类型检测可能致使 bug,你的应用程序也不须要那么严格,形成复杂性的提升。
extends
进行类继承类继承会形成的这些问题想必你已经听过屡次了:
extends
的惟一目的是建立一个单一祖先的 class 分类法。一些机智的 hacker 读了本文会说:“我不认同你的见解,类也是可组合的 ”。对此,个人回答是 “可是你脱离了 extend
,使用对象组合来替代类继承,在 JavaScript 中是更加简单,安全的方式”
我说了不少工厂替代掉类的好处,但你仍坚持使用类的话,不妨再看看我下面的一些建议,它们帮助你更安全地使用类:
instanceof
。因为 JavaScript 是动态语言而且拥有多个执行上下文,instanceof
老是难以反映指望的类型检测结果。若是以后你要切换到抽象工厂,这也会形成问题。extends
。不要屡次继承一个单一层级。“应当优先考虑对象组合而不是类继承” 这句话源自 Design Patterns: Elements of Reusable Object-Oriented Softwareclass
会让应用得到必定程度的性能提高,可是导出一个工厂来建立实例是为了避免鼓励用户来继承你撰写好的类,也避免他们使用 new
来实例化对象。new
。尽可能不直接使用 new
,也不要强制你的调用者使用它,取而代之的是,你能够导出一个工厂供调用者使用。下面这些状况你可使用类:
new
。new
和 extend
。在大多数状况下,工厂函数将更好地服务于你。
在 JavaScript 中,工厂比类或者构造函数更加简单。咱们在撰写应用时,应当先从简单的模式开始,直到须要时,才渐进到更复杂的模式。
想学习更多 JavaScript 函数式编程吗?
跟着 Eric Elliott 学 Javacript,机不可失时再也不来!
Eric Elliott 是 “编写 JavaScript 应用” (O’Reilly) 以及 “跟着 Eric Elliott 学 Javascript” 两书的做者。他为许多公司和组织做过贡献,例如 Adobe Systems、Zumba Fitness、The Wall Street Journal、ESPN 和 BBC 等 , 也是不少机构的顶级艺术家,包括但不限于 Usher、Frank Ocean 以及 Metallica。
大多数时间,他都在 San Francisco Bay Area,同这世上最美丽的女子在一块儿。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、React、前端、后端、产品、设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。