这篇文章中,咱们会探索一些高阶函数,去思考如何用这些函数来让咱们的程序更具表达性;同时,咱们也要在程序可感知复杂度(perceived complexity) 和表达性之间达到折中和平衡。html
编程中最基础的概念之一就是函数能够调用其它函数。前端
当一个函数能调用其它函数,且当一个函数能被多个其它函数调用时,咱们就能够干不少有意思的事情。而函数之间多对多的关系,比起一对多的关系,能让程序具备更强的表达能力。咱们能够给每一个函数单一的职责,而后命名这个职责;咱们还能确保有且仅有一个函数承担某项职责。程序员
多对多的函数关系使得函数与职责之间一对一的关系成为可能。面试
程序员常常说某个语言很是具备表达性,然而并无一个普世单一的标准来定义什么是表达性。大多数程序员赞成,决定某个语言具备“表达性”的一个重要特征是,该语言让程序员能很容易避免代码中不必的啰嗦。若是函数拥有多个职责,这会让函数自身变得庞大笨拙。若是同一个职责要被屡次实现,这就形成了程序的冗余。算法
若是程序中的函数都具备单一职责,且全部职责都仅被单一函数实现一次,这样的程序就避免了不必的啰嗦。编程
综上,函数之间多对多的关系,让编写高表达性程序成为可能。而那些无此特征的程序,在“表达性”上则是很是糟糕的。数据结构
然而,能力越大,责任越大。多对多函数关系的负面就是,随着程序体积增加,该程序能干的事情就急剧增多。“表达性”常常与“可感知复杂度”存在冲突。架构
为了更容易理解上面的论断,咱们画个关系图来类比来思考下。每一个函数是一个节点,函数之间的调用关系是连线。假设程序中没有死代码,那么每一个结构化程序都造成一个链接图。框架
给定已知数量的节点,在这些节点中能画的链接图数量造成 A001187 整数序列。(译者注:这个太硬核数学了,不懂)...(数学,不懂,省略翻译)... 总之,仅仅 10 个函数就能造成多于 34 兆个程序组合方式……ide
程序灵活性的爆炸性增加让程序员不得不克制一下。函数和职责之间一对一关系带来的好处,被由此而形成的无限复杂度而抵消。试想理解这种复杂度的程序多么困难。
JavaScript 能提供工具帮忙缓解这个问题。它的块建立了命名空间,ES Modules 也具备这个功能。它很快就会具备私有对象属性。(译者注:公有和私有类属性已经进入 State 3 草案了)
命名空间将本可能大的关系图限制到小的图里面,每个小图与其它小图(模块)链接的方式数量可控。用这种方式,你获得的依然是一张大图,可是你这张图的可组合可能性小了不少。这样,你就更容易弄清楚它能作什么,怎么作。
咱们刚刚以靠近直觉的方式来描述一种设计优秀软件系统的方式:给予程序员因实体间多对多关系带来的灵活性,同时让程序员能够主动限定实体间可链接的方式。
可是请注意咱们没有说有某种机制能同时干这两件事。不,咱们只是说有一个工具能帮咱们提高表达性,另外一个工具帮咱们限制程序中的可感知复杂度;而这二者之间存在冲突。
如今,咱们直觉上能明白这个问题了,那就让咱们来看一些高阶函数。从这些函数上,咱们试着能不能看出表达性和可感知复杂度的同时存在。
若是一个函数接受其它若干函数做为参数,且/或将函数做为值返回,咱们称这种函数为高阶函数,或 HOFs. 支持 HOFs 的语言同时也支持一等公民函数,并且几乎都会支持动态建立函数。
高阶函数给了程序员更多解构和组合程序的方式,由此,程序员有了更多编写职责 -- 函数一对一关系的方式。让咱们来看个例子。
传说好的公司总会要求毕业生应聘者进行 coding 面试。
好比,把两个已经排好序的列表合并到一块儿。这种问题不至于太难,同时也有现实应用场景。下面是一个天真的答案:
function merge({ list1, list2 }) {
if (list1.length === 0 || list2.length === 0) {
return list1.concat(list2);
} else {
let atom, remainder;
if (list1[0] < list2[0]) {
atom = list1[0];
remainder = {
list1: list1.slice(1),
list2,
};
} else {
(atom = list2[0]),
(remainder = {
list1,
list2: list2.slice(1),
});
}
const left = atom;
const right = merge(remainder);
return [left, ...right];
}
}
merge({
list1: [1, 2, 5, 8],
list2: [3, 4, 6, 7],
});
//=> [1, 2, 3, 4, 5, 6, 7, 8]
复制代码
下面是一个对数字组成列表求和的函数:
function sum(list) {
if (list.length === 0) {
return 0;
} else {
const [atom, ...remainder] = list;
const left = atom;
const right = sum(remainder);
return left + right;
}
}
sum([42, 3, -1]);
//=> 44
复制代码
咱们故意把这两个函数以同一种结构来写。这种结构叫线性递归。咱们能够把这种共有结构抽离出来吗?
线性递归形式很简单:
咱们刚刚展现的两个函数都有这个形式,那咱们就写个高阶函数来实现线性递归。咱们就以其中一个函数为例,来抽离出共有部分:
function sum(list) {
const indivisible = (list) => list.length === 0;
const value = () => 0;
const divide = (list) => {
const [atom, ...remainder] = list;
return { atom, remainder };
};
const combine = ({ left, right }) => left + right;
if (indivisible(list)) {
return value(list);
} else {
const { atom, remainder } = divide(list);
const left = atom;
const right = sum(remainder);
return combine({ left, right });
}
}
复制代码
还差一点就实现咱们想要的高阶函数了,最关键的一部是从新命名几个变量:
function myself(input) {
const indivisible = (list) => list.length === 0;
const value = () => 0;
const divide = (list) => {
const [atom, ...remainder] = list;
return { atom, remainder };
};
const combine = ({ left, right }) => left + right;
if (indivisible(input)) {
return value(input);
} else {
const { atom, remainder } = divide(input);
const left = atom;
const right = myself(remainder);
return combine({ left, right });
}
}
复制代码
最后一步是将这些常量函数改为一个最终返回 myself
的函数的形参:
function linrec({ indivisible, value, divide, combine }) {
return function myself(input) {
if (indivisible(input)) {
return value(input);
} else {
const { atom, remainder } = divide(input);
const left = atom;
const right = myself(remainder);
return combine({ left, right });
}
};
}
const sum = linrec({
indivisible: (list) => list.length === 0,
value: () => 0,
divide: (list) => {
const [atom, ...remainder] = list;
return { atom, remainder };
},
combine: ({ left, right }) => left + right,
});
复制代码
如今咱们就能利用 sum
和 merge
之间的相同属性了。让咱们用 linrec
来实现 merge
吧:
const merge = linrec({
indivisible: ({ list1, list2 }) => list1.length === 0 || list2.length === 0,
value: ({ list1, list2 }) => list1.concat(list2),
divide: ({ list1, list2 }) => {
if (list1[0] < list2[0]) {
return {
atom: list1[0],
remainder: {
list1: list1.slice(1),
list2,
},
};
} else {
return {
atom: list2[0],
remainder: {
list1,
list2: list2.slice(1),
},
};
}
},
combine: ({ left, right }) => [left, ...right],
});
复制代码
咱们还能够更进一步!
咱们来实现一个叫 binrec
的函数,这个函数实现了二元递归。咱们一开始举例子是合并两个已经排好序的列表,而 merge
函数常常被用在合并排序(merge sort)中。
binrec
实际上比 linrec
更简单。linrec
还要将输入值分为单个元素和剩余元素,binrec
将问题分红两部分,而后将同一个算法应用到这两个部分中:
function binrec({ indivisible, value, divide, combine }) {
return function myself(input) {
if (indivisible(input)) {
return value(input);
} else {
let { left, right } = divide(input);
left = myself(left);
right = myself(right);
return combine({ left, right });
}
};
}
const mergeSort = binrec({
indivisible: (list) => list.length <= 1,
value: (list) => list,
divide: (list) => ({
left: list.slice(0, list.length / 2),
right: list.slice(list.length / 2),
}),
combine: ({ left: list1, right: list2 }) => merge({ list1, list2 }),
});
mergeSort([1, 42, 4, 5]);
//=> [1, 4, 5, 42]
复制代码
脑洞再开大点,基于二元递归,咱们还能扩展出多元递归,即将问题分红随意数量的对称部分:
function mapWith(fn) {
return function*(iterable) {
for (const element of iterable) {
yield fn(element);
}
};
}
function multirec({ indivisible, value, divide, combine }) {
return function myself(input) {
if (indivisible(input)) {
return value(input);
} else {
const parts = divide(input);
const solutions = mapWith(myself)(parts);
return combine(solutions);
}
};
}
const mergeSort = multirec({
indivisible: (list) => list.length <= 1,
value: (list) => list,
divide: (list) => [
list.slice(0, list.length / 2),
list.slice(list.length / 2),
],
combine: ([list1, list2]) => merge({ list1, list2 }),
});
复制代码
咱们还能够继续探索无数多个高阶函数,不过我刚刚展现的这几个已经够了。让咱们回过头再来思考下表达性和可感知复杂度。
...(太啰嗦,重复以前的内容,不翻译了)…… 若是两个函数实现了同一项职责,那咱们的程序就不够 DRY (don't repeat yourself),表达性也差。
高阶函数和这个有什么关系?如咱们刚看到的,sum
和 merge
在解决域里面有不一样的职责,一个是合并列表,一个是列表求总。可是二者共享同一个实现结构,那就是线性递归。因此,他们都负责实现线性递归算法。
经过把线性递归算法抽离出来,咱们确保有且仅有一个实体 -- linrec
-- 负责实现线性递归。由此,咱们发现了,一等公民函数经过建立函数间的多对多关系,确实帮助了咱们实现更强大的表达性。
然而,咱们也知道,若是不利用某些语言特性或者架构设计来将函数进行分组管理,这种高阶函数的用法会增长程序的可感知复杂度。分组以后,组内函数依然存在丰富的相互关系,可是组之间的关系是限定的。
咱们来比较下分别用 binrec
和 multirec
来实现 mergeSort
:
const mergeSort1 = binrec({
indivisible: (list) => list.length <= 1,
value: (list) => list,
divide: (list) => ({
left: list.slice(0, list.length / 2),
right: list.slice(list.length / 2),
}),
combine: ({ left: list1, right: list2 }) => merge({ list1, list2 }),
});
const mergeSort2 = multirec({
indivisible: (list) => list.length <= 1,
value: (list) => list,
divide: (list) => [
list.slice(0, list.length / 2),
list.slice(list.length / 2),
],
combine: ([list1, list2]) => merge({ list1, list2 }),
});
复制代码
咱们传给 linrec 和 multirec 的函数挺有趣,来给他们命名下:
const hasAtMostOne = (list) => list.length <= 1;
const Identity = (list) => list;
const bisectLeftAndRight = (list) => ({
left: list.slice(0, list.length / 2),
right: list.slice(list.length / 2),
});
const bisect = (list) => [
list.slice(0, list.length / 2),
list.slice(list.length / 2),
];
const mergeLeftAndRight = ({ left: list1, right: list2 }) =>
merge({ list1, list2 });
const mergeBisected = ([list1, list2]) => merge({ list1, list2 });
复制代码
观察下函数名和函数的实际功能,你能发现某些函数,如 hasAtMostOne
, Identity
和 bisect
感受像是通用目的函数,咱们在写当前应用或其它应用时都会用到这种函数。事实上,这些函数确实能在一些通用目的函数工具库里找到。他们表达了在列表上的通用操做。(【译者注】:Ramda 里面的 identity
函数和这里同样。identity
函数,以及相似的 const always = x => y => x
一点都不无厘头,他们在特定上下文才有意义)
而 bisectLeftAndRight
和 mergeLiftAndRight
则显得目的更特殊。他们不大可能被用在其它地方。mergeBisected
则混合一点,咱们可能在其它地方能用到它,也可能用不到。
如本文一开始就一再强调的,这种多对多的函数关系,能帮助咱们提高代码表达性,以及在程序实体和职责之间建立一对一的关系。例如,bisect
的职责就是把列表分红两部分。咱们可让代码其它全部部分都调用 bisect
,而不是一直反复实现这个功能。
若是一个函数提供的接口或“行为协议”越通用,一个函数承担的职责越集中和简单,此函数建立多对多关系的能力就越强。所以,当咱们写像 multirec
这样的高阶函数时,咱们应当如此设计这些函数,使得它们接收通用目的函数为参数,而这些通用目的函数只承担简单职责。
咱们同时也能够写像 bisectLeftAndRight
和 mergeLeftAndRight
这种函数。当咱们这样写的时候,程序中就会存在一对多关系,由于除了在 merge
函数中有用外,它们没什么通用功能。这限制了咱们程序的表达性。
不幸的是,这种限制并没必要然意味着程序的可感知复杂度的随之下降。经过仔细阅读代码,咱们能看出 bisectLeftAndRight
这种函数在程序其它地方并无什么用。若是咱们没有另外使用模块做用域等机制去限制这些函数的范围,让其易于发现,咱们并不能下降程序的可感知复杂度。
由此,咱们能够观察到,某些编程技巧,好比那种为函数写高度专注的接口,或者让函数承担复杂的职责的编程技巧,会让程序的表达性下降,但并不能下降程序的可感知复杂度。
粗略来说,框架和库不过是一些类,函数和其它代码的集合。区别是,框架被设计成来调用咱们的代码,库被设计成被咱们的代码调用。
框架一般期待咱们写出带有很是具体而专注接口和行为协议的函数或者其它程序实体。例如,Ember 要求咱们去扩展它的基类去建立组件,而不是使用普通的 ES6 Class。如咱们上面已阐明的,当咱们写出专注的接口时,咱们就限制了程序的表达性,但并无所以而下降程序复杂度。
这意味着咱们是在为框架写代码,这样框架的做者就不用操心去在框架代码和用户代码之间建立多对多的关系。例如,咱们在写 Ember 类时,是无法使用 JavaScript mixins, subclass factories, 和 method advice 这些代码组合方式的。咱们不得不使用 Ember 提供的专注的元编程工具,或者使用专为 Ember 开发的插件。
面向框架的代码更具备一对多特性,而不是多对多,这就下降了其表达性。
相比之下,库是被设计成被咱们的代码调用的。最重要的是,库是被不少不少个编程风格迥异的团队调用的,这让库的做者们有动力去编写具备通用接口和简单职责的函数。
面向库的代码更具备多对多的特性,而不是一对多,这就使得它更有表达性。
那是否是面向框架的代码都是坏的?其实并不必定,只是取舍而已。框架提供了作事的标准方式。框架承诺帮咱们干更多事情,特别是帮咱们干很复杂的事。
理想状况下,虽然咱们的代码在框架之下会变得表达性很低,咱们的目的是写更少的代码。而咱们使用其它手段来下降程序的可感知复杂度。
从咱们对 linrec
, binrec
和 multirec
这些高阶函数的探索中,咱们发现专注接口和通用接口的对比,框架和库的取舍。
【原文】From Higher-Order Functions to Libraries And Frameworks
译后记
此文举例的高阶函数,是用递归实现的。大多数状况下,merge
和 sum
是用迭代实现的。那么,这些例子还有什么用吗?multirec
多元递归的使用场景是什么?敬请期待下一篇译文《递归数据结构与图像处理》
关于咱们
咱们是蚂蚁保险体验技术团队,来自蚂蚁金服保险事业群。咱们是一个年轻的团队(没有历史技术栈包袱),目前平均年龄92年(去除一个最高分8x年-团队leader,去除一个最低分97年-实习小老弟)。咱们支持了阿里集团几乎全部的保险业务。18年咱们产出的相互宝轰动保险界,19年咱们更有多个重量级项目筹备动员中。现伴随着事业群的高速发展,团队也在迅速扩张,欢迎各位前端高手加入咱们~
咱们但愿你是:技术上基础扎实、某领域深刻(Node/互动营销/数据可视化等);学习上善于沉淀、持续学习;性格上乐观开朗、活泼外向。
若有兴趣加入咱们,欢迎发送简历至邮箱:ray.hl@antfin.com
本文做者:蚂蚁保险-体验技术组-草津
掘金地址:serialcoder