写点什么

编程经典案例之函数

用户头像
顿晓
关注
发布于: 2020 年 08 月 02 日
编程经典案例之函数

《Composing Software》一书有一章简单回顾了函数式编程的历史。



当然,这避不开要提到 1936 年阿伦佐·丘奇和阿兰·图灵各自独立地创造了第一批通用计算机的抽象设计。



图灵的计算模型以及他对于求解的问题本质的概念是最靠近物理的。所以,我们目前建造的计算机都是基于图灵的计算模型。以及,同样以靠近物理为哲学的汇编语言,C 语言都是这样发展出来的。



而丘奇的 Lambda 运算,则是以函数为基础的另一种计算模型,也是函数式编程语言共同的祖先。



虽然在数学上能证明 2 者是等价的,但并不代表,也没必要非得要在其中进行 2 选 1。



比如,Lambda 运算的核心是 Lambda 表达式,以此形成函数定义、函数应用和递归的形式系统。这很自然,因为函数本来就是抽象的,如果用靠近物理的表达,反而不直观。



但是,当使用 Lambda 表达式定义出布尔值、数值和各种基本操作符等语言元素后,虽然能够形成纯粹的函数式编程语言,却反而没有靠近物理的表达来的直接。






纯函数本身很好理解,就像数学的函数一样,固定的输入返回固定的输出,好处就是:永远不会改变,很确定。



带有副作用的函数也容易理解,坏处就是不确定。



究其原因,Scala的作者总结的很精辟:「非确定性=并行处理+可变状态」



然而,副作用不可避免,要做的就是把副作用函数限定在一个小范围内,能改为纯函数的地方都改造为纯函数。



如这个常见的例子,往数组中添加一项:



const addToCart = (cart, item, quantity) => {
cart.items.push({
item,
quantity
});
return cart;
};
const originalCart = {
items: []
};
const newCart = addToCart(
originalCart,
{
name: "Digital SLR Camera",
price: '1495'
},
1
);



靠近物理的写法是,就地修改。数据只有一份,被多个操作逻辑共享。



接下来介绍一个纯函数改造方法:由就地修改改为创建新的副本。修改后返回新的副本,不同的逻辑处理各自的副本。



const addToCart = (cart, item, quantity) => {
return {
...cart,
items: cart.items.concat([{
item,
quantity
}]),
};
};



这样,由纯函数组合而成的逻辑方便测试,不易产生bug。



用这个方法足以应付一般的搬砖。






提到函数式编程,大家应该都听过函数式更具声明式(相比命令式而言),意思是函数式更注重表达的是做什么而不是怎么做。



如果你学编程是从 C、Java 这些不支持函数式的语言入门,在刚接触函数式时,需要做一些思想转换。



因为使用 2 者时,有些选择是刚好相反的,但归结起来,主要还是函数式更偏爱声明性。



其他的差别,如避免共享状态、避免可变状态、避免副作用都可以包含在声明性里面。



const doubleMap = numbers => {
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
return doubled;
};



如上面这个例子,就是命令式的,里面自然用到了可变状态,如果将内部的临时变量放到函数外面,则就用到了共享状态和副作用。



下面的例子,则是声明式的,几乎全部的操作细节都被隐藏在 map 函数里。



const doubleMap = numbers => numbers.map(n => n * 2);



由于 map 是标准操作,可以最大限度地复用,熟悉之后,理解起来几乎不费力。



最后,就只剩和 map 组合的匿名函数了,因为这个是必须要理解的,所以声明式可以将理解难度降到最低。



2 者的区别,也可以类比语音聊天和文字聊天的区别,一个感性,凭直觉,效率高;一个理性,要思考,效率低。






另一个常用的高阶函数是 filter,也就是过滤,可以用来消除判断语句,如 if 。



看下面两个用到 if 的常用例子,在数据处理流中添加各种判断逻辑。一个是过滤数据长度,另一个过滤以 s 开头的数据。



const censor = words => {
const filtered = [];
for(let i = 0, { length } = words; i < length; i++) {
const word = words[i];
if (word.length !== 4) filtered.push(word)
}
return filtered;
}
const startsWithS = words => {
const filtered = [];
for(let i = 0, { length } = words; i < length; i++) {
const word = words[i];
if (word.startsWith('s')) filtered.push(word);
}
return filtered;
}



对比两个例子,能明显看出除了判断逻辑不同外,其他代码都是一样的。



如何重用这部分代码呢?可以把这部分代码当做模版,封装成高阶函数。



例子中的代码是处理数组的,当然已经有封装好的高阶函数可以使用,就是 filter。



const censor = words => filter(
word => word.length !== 4,
words
);
const startsWithS = words => filter(
word => word.startsWith('s'),
words
);



如果要自己封装,可以看下面的例子。



const reduce = (reducer, initial, arr) => {
let acc = initial;
for(let i = 0, { length } = arr; i < length; i++) {
acc = reducer(acc, arr[i]);
}
return acc;
}
const filter=(
fn, arr
) => reduce((acc,curr)=>fn(curr)?
acc.concat([curr]) :
acc, [], arr
);



遍历一个数组,然后将处理的结果再收集起来,这种模版叫做 reducer。filter 就是一种 reducer,将过滤和 reducer 模版组合在一起。



再看另外一个例子,过滤大于某个数的数据。



const highpass = cutoff => n => n >= cutoff;
const gte3 = highpass(3);
[1, 2 , 3, 4].filter(gte3); //[3,4];



可以看出,表达简洁,没有一丝多余。



也可以这样类比,方便理解:高阶函数像个领导者,只负责安排任务;一阶函数,也就是普通函数则是干活的,需要执行具体的步骤。






接下来要介绍一个特殊的高阶函数用法。它的名字就不在这里说了,因为这不是重点。



这个特殊的用法,其实就是一种套路,如果你喜欢上它,很有可能就退不回去了。



我们先看下普通的函数定义,不管各种语法糖如何变化,本质上是一样的,都是由参数列表和返回值组成的签名二元组。



function foo (/*parameters are declared here*/) {
//...
}
const foo = (/*parameters are declared here*/) => //...
const foo = function(/*parameters are declared here*/) {
//...
}



再看下这个套路,如果你对函数的定义理解不够的话,看到这样的写法,会引起不适反应。



const add = a => b => a + b;



但再看下它的用法,也感觉怪怪的。但这不是它常用的用法。



const result = add(2)(3); //=>5



再看下这两个例子,感觉就会好多了。



const inc = add(1);
inc(3);//=>4
const inc10 = add(10);
const inc20 = add(20);
inc10(3);//=>13
inc20(3);//=>23



从用法来看,它们像是遵从了一些约定的函数组合方法。



特点是每个函数都只有一个参数。有了这个约束,虽然自由度上少了一些,但更加强调对函数组合的设计,能把一个函数只做一件事这个原则执行得更彻底。






之前我们遇到的函数组合,一般情况是,只有两三个函数,然后自己写个函数把它们组合起来,这样很方便,简单易懂。



const g = n => n + 1;
const f = n => n * 2;
const h = x => f(g(x));



但是当函数较多时,尤其不同的函数组合,会让新产生的函数数量爆增,达到难以维护的地步。



const compose1 = (f,g) => x => f(g(x));
const compose2 = (f,g) => x => g(f(x));
...



如果刚才的情况你能忍受,那压倒骆驼的最后一根稻草就是:多个函数的组合,其中还包含了逻辑,比如增加一个判断逻辑,情况A选用函数F,情况B选G,这些逻辑是依据运行时的输入来判断的。



这时,就需要能动态组合函数的工具出现了。



const compose = (...fns) => x => fns.reduceRight((y, f)=>f(y), x);



然后,刚才的手工组合就可以写成这样。



const h = compose(f, g);



如果要增加更多函数进来,比如打印一些调试信息,也很方便。



const trace = label => value => {
console.log(`${ label }: ${ value }`);
return value;
}



函数的主体保持不变,只需调整组合函数的参数即可。



const h = compose(
trace('after f'),
f,
trace('after g'),
g
);



可以看出,这个用来组合函数的函数就很通用,减少了没必要临时函数。结构上也达到了最精简。



发布于: 2020 年 08 月 02 日阅读数: 54
用户头像

顿晓

关注

因观黑白愕然悟,顿晓三百六十路。 2017.10.17 加入

视频号「编程日课」 一个不爱编程的程序员, 一个用软件来解决问题的工程师, 一个有匠心的手艺人。

评论

发布
暂无评论
编程经典案例之函数