https://www.zcfy.cc/article/composing-software-an-introduction-javascript-scene-medium原文链接: medium.com
注意:这是从头开始在 JavaScript ES6+ 中学习函数式编程和组合软件技术的“组合软件”系列的介绍。敬请关注。还有更多!
目录:
-
组合软件:0. 简介(本文)
组合:“组合部分或元素组成整体的行为”〜“Dictionary.com”
在我第一节高中程序设计课上,我被告知软件开发是“将复杂问题转化为较小问题,并组合简单的解决方案以形成复杂问题的完整解决方案的行为”。
我生命中最大的遗憾之一就是未能早日理解这一节课的意义。我太晚才学到软件设计的本质。
我采访过不少开发人员。从这些谈话中我了解到我并非个例。很少有现职软件开发人员很好地掌握了软件开发的本质。他们不知道手头现有的最重要的工具,或者如何善加利用它们。所有人一直在努力回答软件开发领域中最重要的一个或两个问题:
-
什么是函数组合?
-
什么是对象组合?
问题是,就算你不知道组合也没法避开它。你仍然在这样做 - 只不过做的很糟糕。你编写的代码有更多的缺陷,也让其他开发人员更难理解。这是个大问题。代价非常昂贵。维护软件所花的时间比从头开始创建它们还要多,并且缺陷会影响到全球数十亿人。
今天,整个世界都运行在软件之上。每辆新车都是车轮上的一台迷你超级计算机,而软件设计的问题会导致真实的事故,以人类的生命为代价。2013年,在一次事故调查后,陪审团发现丰田的软件开发团队的意大利面条式代码里有10000个全局变量,犯有不计后果的罪过。
黑客和政府囤积软件漏洞,以窥探人们,窃取信用卡,利用计算资源启动分布式拒绝服务(DDoS)攻击,破解密码,甚至操纵选举。
我们必须做得更好。
每天都在组合软件
如果你是软件开发人员,无论你知道与否,其实你每天都在组合函数和数据结构。你要么有意识地(及更好)地做,要么漫不经心地到处修修补补。
软件开发过程就是将大问题分解成较小的问题,创建解决这些较小问题的组件,然后将这些组件组合在一起,形成一个完整的应用程序。
组合函数
函数组合是将一个函数应用到另一函数的输出的过程。在代数中,假设两个函数:f
和 g
,(f ∘ g)(x) = f(g(x))
。这个圆点是组合运算符。它通常被念为“...与...组合”或“...组合...之后”。你可以大声说出来 “f与g组合等于x的g的f”,或“f组合g之后等于x的g的f”。我们在g
之后说f
,是因为g
先被求值,然后其输出作为一个参数被传递给f
。
每次像这样编写代码时,就是在组合函数:
const g = n => n + 1;
const f = n => n * 2;
const doStuff = x => {
const afterG = g(x);
const afterF = f(afterG);
return afterF;
};
doStuff(20); // 42
每次写 promise 链时,就是在组合函数:
const g = n => n + 1;
const f = n => n * 2;
const wait = time => new Promise(
(resolve, reject) => setTimeout(
() => resolve(20),
time
)
);
wait(300)
.then(() => 20)
.then(g)
.then(f)
.then(value => console.log(value)) // 42
;
同样,每次链接数组方法调用、lodash方法、observable(RxJS等)时,都是在组合函数。如果你正在用方法链,就是在组合函数。如果将返回值传递给其他函数,那么就是在组合。如果在一个序列中调用两个方法,就是用 this
为输入数据来组合。
如果你正在链接函数,就是在组合。
如果是下意识地组合函数,会做得更好。
下意识去组合函数的话,我们可以将我们的 doStuff()
函数改进为一行搞定:
const g = n => n + 1;
const f = n => n * 2;
const doStuffBetter = x => f(g(x));
doStuffBetter(20); // 42
对这种形式的一个常见的异议是,它更难调试。例如,我们如何使用函数组合来写这个?
const doStuff = x => {
const afterG = g(x);
console.log(`after g: ${ afterG }`);
const afterF = f(afterG);
console.log(`after f: ${ afterF }`);
return afterF;
};
doStuff(20); // =>
/*
"after g: 21"
"after f: 42"
*/
首先,我们来把“after f”、“after g” 抽出去记录到称为一个trace()
:的小工具程序中:
const trace = label => value => {
console.log(`${ label }: ${ value }`);
return value;
};
现在我们可以这样用它:
const doStuff = x => {
const afterG = g(x);
trace('after g')(afterG);
const afterF = f(afterG);
trace('after f')(afterF);
return afterF;
};
doStuff(20); // =>
/*
"after g: 21"
"after f: 42"
*/
像Lodash和Ramda这样的热门函数式编程库包括了让函数组合更容易的实用程序。你可以像这样重写上述函数:
import pipe from 'lodash/fp/flow';
const doStuffBetter = pipe(
g,
trace('after g'),
f,
trace('after f')
);
doStuffBetter(20); // =>
/*
"after g: 21"
"after f: 42"
*/
如果想尝试不导入东西来实现这段代码,你可以这样定义 pipe:
// pipe(...fns: [...Function]) => x => y
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
如果你还没有搞清楚这是怎么回事,请不要担心。稍后我们会详细探讨函数组合。事实上,它是非常重要的,你会看到在本文中会多次定义和演示了它。关键是帮助你逐步对它了如指掌,让其定义和用法变成习惯性的,成为一个用组合的人。
pipe()
创建一个函数的管道,将一个函数的输出传递给另一个函数的输入。当使用pipe()
(及其孪生兄弟compose()
)时,不需要中间变量。编写不涉及参数的函数称为 piont-free 风格。要做到这一点,你将调用一个返回新函数的函数,而不是显式声明函数。这意味着你不需要 function
关键字或箭头语法(=>
)。(译者按:关于point-free 风格,可以参考阮一峰的博文《Pointfree 编程风格指南》)
Point-free 风格可能太过了,但这里有一点很不错,因为这些中间变量给你的函数增加了不必要的复杂性。
减少复杂性有几个好处:
工作记忆
人脑平均只有少量共享资源用于工作记忆(Working Memeory)中的离散量子,而每个变量潜在地消耗这些量子之一。随着更多变量的添加,我们精确回忆每个变量含义的能力就会降低。工作记忆模型通常涉及4-7个离散量子。超过这些数字的话,错误率就会显着增加。
使用管道的形式,我们消除了3个变量 - 腾出几乎一半的可用工作记忆去做其他事情。这显著降低了我们的认知负担。在将数据分割成工作记忆方面,软件开发人员趋向于比普通人做得更好一些,不过也不是好很多,因为分割会削弱贮存的重要性。
信噪比
简洁的代码也提高了代码的信噪比。就像收听收音机一样 - 当收音机没有正确调到电台时,会产生很多干扰噪音,更难听到音乐。当将其调到正确的电台时,噪点就消失,会得到更强的音乐信号。
代码是一样的。更简洁的代码表达可以提高理解能力。有些代码给了我们有用的信息,有些代码只占用空间。如果你可以减少所用代码量,而不会减少它传输的含义,那么可以使代码更容易被其它需要读它的人解析和理解。
缺陷表面积
看看函数前后。看起来好像函数在节食减肥一样。这很重要,因为额外的代码意味着额外的缺陷表面积可以隐藏,这意味着更多的缺陷会隐藏在其中。
较少的代码=较少的缺陷表面积=更少的缺陷。
组合对象
四人帮《设计模式:可重用面向对象软件的要素》:“对象组合优于类继承”
“在计算机科学中,组合数据类型或者复合数据类型是可以用编程语言的基础数据类型和其他复合类型在程序中构建的任何数据类型。[...]构建复合类型的行为被称为组合。“〜维基百科
如下这些都是基础数据类型:
const firstName = 'Claude';
const lastName = 'Debussy';
而如下是一个复合数据类型:
const fullName = {
firstName,
lastName
};
同样,所有Array、Set、Map、WeakMap、TypedArray等都是复合数据类型。任何时候,只要你构建任何非基础类型数据结构,就是在执行某种对象组合。
请注意,“四人帮”定义了一种称为组合模式的模式,该模式是组合的一种,使得每个组件成为容器组件的一个自包含属性。一些开发人员搞混了,认为组合模式是对象组合的唯一形式。不要搞混了。
类继承可以用于构造组合对象,但它是一种限制性和脆弱的方法。“四人帮”说“对象组合优于类继承”时,是建议使用灵活的方式来组合对象构建,而不是用死板的、紧耦合的类继承方式。
“四人帮”还定义了其他组合设计模式,包括 flyweight模式、委托模式、聚合模式等。
我们将使用来自《计算机科学中的分类方法:从拓扑学角度》(1989)一书中对象组合的更通用的定义:
“组合对象是通过将对象放在一起,使得后者是前者的一部分而形成的。"
另一个很好的参考是Glenford J Myers 1975年出版的《Reliable Software Through Composite Design》。这两本书都已印刷很久了,不过如果你想以更深入的技术深度探索对象组合的主题,仍然可以在Amazon或eBay上找到卖家。
类继承只是组合对象构造的一种类型。所有类都生成组合对象,但并非所有组合对象都是由类或类继承生成的。“对象组合优于类继承”意味着你应该从小组件部分形成组合对象,而不是从类层次结构中的祖先继承所有属性。后者会导致面向对象设计中众多众所周知的问题:
-
紧耦合问题:由于子类依赖于父类的实现,所以类继承是面向对象设计中可用的最紧密的耦合。
-
脆弱的基类问题:由于紧耦合,对基类的更改会潜在破坏大量后代类 - 可能在第三方管理的代码中。作者可能会没有意识到会破坏代码。
-
层级不灵活的问题:对于单祖先分类法,如果有足够的时间和演化,所有类别分类法最终对新的用例都是错的。
-
重复的必要性问题:由于层级不灵活,新的用例通常是通过重复而不是扩展来实现,导致出乎意料发散的相似类别。一旦重复设置,不清楚哪些类新类应该从哪里派生,或为什么。
-
大猩猩/香蕉问题:“...面向对象语言的问题是它们总是得到语言运行环境的所有隐含信息。你想要一个香蕉,但是你所得到的是一只拿着香蕉的大猩猩和整个丛林。“〜Joe Armstrong《编程人生》
最常见的对象组合形式称为 mixin 组合。它的作用如冰淇淋。你从一个对象(如香草冰淇淋)开始,然后混合你想要的功能。加入一些坚果、焦糖、巧克力漩涡,你搭配坚果焦糖巧克力漩涡冰淇淋。
用类继承创建组合:
class Foo {
constructor () {
this.a = 'a'
}
}
class Bar extends Foo {
constructor (options) {
super(options);
this.b = 'b'
}
}
const myBar = new Bar(); // {a: 'a', b: 'b'}
用 mixin 组合创建组合:
const a = {
a: 'a'
};
const b = {
b: 'b'
};
const c = {...a, ...b}; // {a: 'a', b: 'b'}
稍后我们将深入探讨其他风格的对象组合。现在,你的理解应该是:
-
能做到这一点的方法不止一种。
-
有些方法比其它方法更好。
-
你想为手头的任务选择最简单、最灵活的解决方案。
总结
本文并非讨论函数式编程(FP)对面向对象编程(OOP)或一种语言对另一种语言。组件可以采取函数、数据结构、类等形式...不同的编程语言倾向于为组件提供不同的原子元素。Java提供对象,Haskell提供函数等...但无论你喜欢什么语言和什么样的范式,你都不能摆脱组合函数和数据结构。最后,这就是最终的结果。
我们将多讨论函数式编程,因为函数是JavaScript中用于组合的最简单的事情,函数式编程社区已经投入了大量的时间和精力来规范化函数组合技术。
我们不会说函数式编程比面向对象编程更好,或者你必须选择一个。OOP 与 FP是一种假对立。我近年来看到的每一个真正的Javascript应用程序都广泛地混合了FP和OOP。
我们将使用对象组合来生成函数式编程的数据类型,而用函数式编程来生成 OOP 的对象。
_无论你如何编写软件,都应该很好地组合。
软件开发的本质是组合。
不了解组合的软件开发人员就像一个不了解螺栓或钉子的室内建筑师。创建软件而不知道组合就像室内建筑师把墙壁用胶带和疯狂的胶水粘在一起一样。
现在是时候简化了,而简化的最简单的方法就是触及本质。麻烦的是,业内几乎没有人对本质有很好的处理能力。我们作为一个行业已经让你,软件开发者失望了。作为一个行业,我们有责任更好地培训开发人员。我们必须改善我们需要承担责任。一切都在软件上运行,从经济到医疗设备。人类社会的方方面面都受到我们软件质量的影响。我们需要知道我们在做什么。
是时候学习如何组合软件了。
网友评论