JavaScript世界中的合成价格

在开发或多或少复杂的业务逻辑时,应该优先考虑对象的组成,而不是继承的观点在各种类型的软件开发人员中很普遍。 在ReactJS的成功引发的功能编程范例的下一波流行中,谈论组成解决方案的好处来到了前端。 在这篇文章中,有一些关于Java对象构成理论的书架,一个特定的例子,其分析以及对用户语义优雅的代价有多大的问题的解答(破坏者:很多)。

V.康定斯基-构图X
瓦西里·康定斯基-“ Composition X”

多年来,主要是在学术领域,成功开发了一种面向对象的开发方法,这导致了普通开发人员的头脑中明显的不平衡。 因此,在大多数情况下,必要时首先针对多个不同实体概括行为的想法是创建父类并继承此行为。 这种滥用方法会导致使设计复杂化并阻碍开发的若干问题。

首先,逻辑重载的基类变得脆弱 -其方法的微小更改会严重影响派生类。 解决这种情况的一种方法是将逻辑分布在多个类中,从而创建更复杂的继承层次结构。 在这种情况下,开发人员会遇到另一个问题-如果父类的功能之间存在交集,但是必要时,在继承人中可以复制父类的逻辑,但重要的是,还不完整。

图片
mail.mozilla.org/pipermail/es-discuss/2013-June/031614.html

最后,创建一个相当深的层次结构,当用户使用任何实体时,无论他是否要使用其功能,都必须将其所有祖先及其所有依存关系拖在一起。 埃尔兰(Erlang)的创造者乔·阿姆斯特朗(Joe Armstrong)的轻巧双手过度依赖环境的问题被称为大猩猩和香蕉问题:

我认为缺乏可重用性的是面向对象的语言,而不是功能语言。 因为面向对象语言的问题是它们拥有了它们所伴随的所有隐式环境。 您想要香蕉,但是得到的是一只大猩猩,拿着香蕉和整个丛林。

调用对象的组合来解决所有这些问题,作为类继承的替代方法。 这个想法根本不是什么新鲜事物,但是并没有在开发人员中找到完整的理解。 前端世界的情况要好一些,在这种情况下,软件项目的结构通常非常简单,并且不会刺激创建复杂的面向对象的关系方案。 但是,盲目地遵循“四人帮”的盟约,建议优先选择组成而不是继承,这也可能会骗大开发者的灵感。

将定义从“设计模式”转移到动态的Java脚本世界中,我们可以总结三种类型的对象组合: 聚合串联委托 。 值得一提的是,这种分离和对象组成的概念通常具有纯粹的技术性质,而这些术语的含义却有交叉之处,这会引起混乱。 因此,例如,Javascript中的类继承是基于委托(原型继承)实现的。 因此,每种情况下最好都带有实时代码示例。

聚合是一个可枚举的对象并集,可以使用唯一的访问标识符获得每个对象。 示例包括数组,树,图。 Web开发领域的一个很好的例子是DOM树。 此类合成的主要质量及其创建原因是能够方便地将某些处理程序应用于合成的每个子级的能力。

一个合成的例子是一系列对象,这些对象轮流设置任意视觉元素的样式。

const styles = [  { fontSize: '12px', fontFamily: 'Arial' },  { fontFamily: 'Verdana', fontStyle: 'italic', fontWeight: 'bold' },  { fontFamily: 'Tahoma', fontStyle: 'normal'} ]; 

可以通过其索引提取每个样式对象而不会丢失信息。 另外,使用Array.prototype.map(),可以以给定的方式处理所有存储的值。

 const getFontFamily = s => s.fontFamily; styles.map(getFontFamily) //["Arial","Verdana","Tahoma"] 

串联涉及通过向现有对象添加新属性来扩展其功能。 因此,例如,Redux中的状态缩减器起作用。 接收到的用于更新的数据被写入状态对象,并对其进行扩展。 如果不保存,与聚合不同的是,有关对象当前状态的数据将丢失。

返回该示例,将上述设置交替应用到可视元素,您可以通过串联对象的参数来生成最终结果。

 const concatenate = (a, s) => ({…a, …s}); styles.reduce(concatenate, {}) //{fontSize:"12px",fontFamily:"Tahoma",fontStyle:"normal",fontWeight:"bold"} 

更具体的样式的值最终将覆盖以前的状态。

委托时 ,您可能会猜到,一个对象委托给另一对象。 代表,例如,是Javascript中的原型。 派生对象的实例将调用重定向到父方法。 如果数组实例中没有必需的属性或方法,它将将该调用重定向到Array.prototype,并且在必要时将其重定向到Object.prototype。 因此,JavaScript中的继承机制基于原型委托链,从技术上讲,它是该组合的(惊奇)版本。

可以通过委托组合样式对象数组,如下所示。

 const delegate = (a, b) => Object.assign(Object.create(a), b); styles.reduceRight(delegate, {}) //{"fontSize":"12px","fontFamily":"Arial"} styles.reduceRight(delegate, {}).fontWeight //bold 

如您所见,委托属性不能通过枚举访问(例如,使用Object.keys()),而只能通过显式访问来访问。 这给了我们一个事实,这是帖子的结尾。

现在到细节。 迈克尔·里斯(Michael Rise)的《 Java中对象组合 》( Object Composition in Javascript)就是一个很好的例子,它鼓励开发人员使用组合而不是继承。 在这里,作者考虑创建角色扮演角色层次结构的过程。 最初,需要两种类型的角色-战士和魔术师,每个角色都有一定的健康储备并有名字。 这些属性是通用的,可以移到父类Character。

 class Character { constructor(name) { this.name = name; this.health = 100; } } 

战士的特点是他知道如何在消耗耐力的同时进行打击,而魔术师则可以施展咒语,减少法力值。

 class Fighter extends Character { constructor(name) { super(name); this.stamina = 100; } fight() { console.log(`${this.name} takes a mighty swing!`); this.stamina -  ; } } class Mage extends Character { constructor(name) { super(name); this.mana = 100; } cast() { console.log(`${this.name} casts a fireball!`); this.mana -  ; } } 

创建了Fighter和Mage类(Character的后代)后,在需要创建Paladin类时,开发人员面临着意外的问题。 新角色以令人羡慕的战斗和召唤能力而著称。 随便可以看到一些解决方案,它们的不同之处在于缺乏宽限期。

  1. 您可以使Paladin成为Character的后代,并从头开始实现fight()和cast()。 在这种情况下,将严重违反DRY原理,因为每种方法在创建过程中都会被复制,并且随后将需要与Mage和Fighter类的方法进行持续同步以跟踪更改。
  2. fight()和cast()方法可以在Charater类的级别上实现,以便所有三种类型的字符都具有它们。 这是一个稍微令人满意的解决方案,但是,在这种情况下,开发人员必须重新定义魔术师的fight()方法和战士的cast()方法,并用空的存根替换它们。

在任何一种选择中,迟早都要面对职位开头提出的继承问题。 可以使用功能实现字符的方法来解决它们。 从它们的类型而不是功能上推销就足够了。 最重要的是,我们有两个关键特征来决定角色的能力-战斗能力和召唤能力。 可以使用工厂功能来设置这些功能,这些功能会扩展定义字符的状态(组合的一个示例是串联)。

 const canCast = (state) => ({ cast: (spell) => { console.log(`${state.name} casts ${spell}!`); state.mana -  ; } }) const canFight = (state) => ({ fight: () => { console.log(`${state.name} slashes at the foe!`); state.stamina -  ; } }) 

因此,角色由这些特征的集合以及初始属性(常规(名称和健康)和私有(耐力和法力))决定。

 const fighter = (name) => { let state = { name, health: 100, stamina: 100 } return Object.assign(state, canFight(state)); } const mage = (name) => { let state = { name, health: 100, mana: 100 } return Object.assign(state, canCast(state)); } const paladin = (name) => { let state = { name, health: 100, mana: 100, stamina: 100 } return Object.assign(state, canCast(state), canFight(state)); } 

一切都是美好的-动作代码被重用,您可以轻松添加任何新角色,而无需触及以前的角色,也不会增加任何一个对象的功能。 要在提出的解决方案中找到美中不足的地方,只需将基于继承(读取委派)的解决方案与基于串联的解决方案的性能进行比较即可。 创建百万个创建角色实例的军队。

 var inheritanceArmy = []; for (var i = 0; i < 1000000; i++) { inheritanceArmy.push(new Fighter('Fighter' + i)); inheritanceArmy.push(new Mage('Mage' + i)); } var compositionArmy = []; for (var i = 0; i < 1000000; i++) { compositionArmy.push(fighter('Fighter' + i)); compositionArmy.push(mage('Mage' + i)); } 

并比较创建对象所需的继承和合成之间的内存和计算成本。

图片

平均而言,通过串联使用组合的解决方案需要多100–150%的资源。 呈现的结果是在NodeJS环境中获得的,您可以通过运行此测试来查看浏览器引擎的结果。

基于继承委托的解决方案的优势可以通过以下方式来解释:由于缺少对委托属性的隐式访问,从而节省了内存,并且为动态委托禁用了一些引擎优化。 反过来,基于串联的解决方案使用了非常昂贵的Object.assign()方法,这极大地影响了其性能。 有趣的是,Firefox Quantum显示的Chromium结果截然相反-第二种解决方案在Gecko中的运行速度更快。

当然,只有在解决与创建复杂基础结构的大量对象相关的繁琐任务时,例如在使用虚拟元素树或开发图形库时,才值得依赖性能测试的结果。 在大多数情况下,解决方案的结构美,可靠性和简单性变得更加重要,并且性能的微小差异不会发挥很大的作用(使用DOM元素进行操作会占用更多的资源)。

总之,值得注意的是,所考虑的构图类型不是唯一的且互斥的。 可以使用聚合来实现委派,使用委派来实现类继承(就像在JavaScript中一样)。 从本质上讲,对象的任何组合都是构成的一种形式或另一种形式,最终,最终解决方案的简单性和灵活性才是关键。

Source: https://habr.com/ru/post/zh-CN438404/


All Articles