如何成为一位函数式编程爱好者(Part 1)
踏出理解函数式编程概念的第一步是最重要的一步,有时也是最难的一步。不过也不一定,取决于你们的思考方式。
学习开车
我们第一次学车的时候,是很痛苦的。看别人在学的时候真的很简单。但一到我们自己做的时候就变得比想象得要难得多。
我们曾在父母的车上练习,但是能在家周围的街道上熟练地驾驶之前都不敢去高速公路上冒险。
但经过反复练习和一些可能都已经忘记了的痛苦经历之后,我们也学会了开车并且最终拿到了驾照。
驾照到手后,我们可以开车去任何想去的地方。每次旅途让我们的车技越来越好,也对自己的车技越来越自信。直到有一天,要开别人的车,或者自己的车报废了又买了一辆新的。
第一次开一辆不同的车是一种什么感觉?和我们第一次开车时相同吗?差远了。第一次开车时,我们完全是个外行。之前我们也坐过车,但只是乘客位。这次是坐在驾驶座位上,控制车上的一切。
但在开第二辆车时,我们只需要知道一些非常简单的问题就可以了,钥匙在哪,灯光在哪,如何打转向灯,如何调整后视镜。
之后的一切则相当顺利。但与第一次开车相比,这次为什么这么容易呢?
因为新车和之前的旧车很像。他们都有一辆车所需要的基本结构,而且每处结构也非常像。
结构上可能有点小出入或者可能有一些新特性,不过前几次开车我们可以先不用新特性。最后,我们也能学会所有的新特性,至少包括我们常用的那些。
学编程语言和学车有点像。第一门是最难的。但一旦掌握了一门语言,其他的学起来都会更容易。
当开始学第二门语言时,也只需要问自己一些简单的问题,“如何创建一个模块? 如何从一个数组中查询值? substring
函数的参数是什么?”
你自信能驾驭这门新语言,因为旧语言的经验会帮助你,从中引发的新思考会让你学起来更容易。
第一次开宇宙飞船
先不管你这一生开过多少辆车了,想象一下,你就要去开宇宙飞船了。
如果你要开宇宙飞船,不用期望在路上的驾驶技术能帮你很多。全部都要从零开始。(毕竟我们是程序员,从0开始计数。)
你需要按照预期开始练习,在太空中一切都和之前不同,驾驶这个新装备也和在地上开车完全不同。
物理学没有变。只是你在宇宙中的穿行方式变了。
学习函数式编程也一样。你会发现很多东西都不一样。而且你曾经的很多编程经验都无法转化。
编程就像思考一样,函数式编程将教你完全不同地去思考。久而久之,你就可能再也不会用原来的方式思考了。
忘记你知道的全部
人们很喜欢说这句话,也挺对的。学函数式编程就像从零开始。不完全对,但很有用。函数式编程中有很多相似的概念,但将所有的东西重学一遍才是最好的。
有了正确的态度就能达到正确的期望,达到了正确的期望在遇到困难时就不会退缩。
你已经有了很多编程方面的习惯,但是在函数式编程中不再适用。
就像开车一样,过去你习惯倒车出库。但是在宇宙飞船上,并不能倒着开。你可能会想,“不能倒着开?那我到底该怎么开?!”
然而,事实证明宇宙飞船不需要倒着开,因为它可以在三维空间中飞行。一旦你明白了这点,你就不会再想倒着开了。而且总有一天,你会想起汽车的局限性多么强。
学习函数式编程需要一段时间。请保持耐心。
让我们离开指令式编程的冰冷世界,融入到函数式编程的温暖中吧。
在你深入第一门函数式编程语言之前,这篇文章后面关于函数式编程概念的的几节对你会有帮助。或者说如果你已经入坑了,他们能帮助你更好地理解。
不必心急。花一些时间看接下来的内容并且花些时间理解示例代码。你也可以在看完每章之后暂停一下,自己思考加深理解,然后再回来完成剩下的章节。
最重要的是理解。
纯粹的
函数式编程中说的纯粹,是指纯函数。
纯函数是非常简单的函数。他们只处理传入的参数。
这有一个纯函数的例子:
var z = 10;
function add(x, y) {
return x + y;
}
注意: add
函数没有碰变量 z
。没有读 z
也没有写 z
。只读了传入参数 x
和 y
,然后返回他们相加的结果。
这就是一个纯函数。如果 add
函数访问了 z
,它将不再纯粹。
再看下面这个函数:
function justTen() {
return 10;
}
这个justTen
只返回一个常量,也是纯函数。为什么?
因为我们没传入任何参数。而且,为了保证纯粹,它不能访问除传入参数外的任何东西,所以只能返回一个常量。
但是没有传参的纯函数什么都没做,没什么用处。justTen
定义为常量的话会更好。
大多数有用的纯函数必须传入至少一个参数。
看看这个函数:
function addNoReturn(x, y) {
var z = x + y
}
**注意:**这个函数没返回任何东西。把 x
和 y
相加后赋给了 z
变量,但并没有返回。
这也是个纯函数因为它只是处理了参数。做了加法,但是没返回结果,所以也没用。
有用的纯函数一定会返回一些东西。
再考虑一下第一次举例的 add
函数:
function add(x, y) {
return x + y;
}
console.log(add(1, 2)); // prints 3
console.log(add(1, 2)); // still prints 3
console.log(add(1, 2)); // WILL ALWAYS print 3
add(1, 2)
总会返回3
。不必惊讶因为这是个纯函数。如果这个函数使用了其他值,它的行为就再也无法预测了。
纯函数对于相同的输入总能产生相同的输出。
因为纯函数不能改变任何外部变量,所以下面的函数都是不纯的:
writeFile(fileName);
updateDatabaseTable(sqlCmd);
sendAjaxRequest(ajaxRequest);
openSocket(ipAddress);
这些函数都存在副作用。调用他们时,会改变文件和数据库表,向服务器发送数据或者调用系统API获取一个socket。他们除了处理入参合返回值之外还做了很多事情。因此你永远无法预测这些函数会返回什么。
纯函数没用副作用。
在指令式编程语言中,比如 JavaScript, Java 和 C#,到处都是副作用。程序中的变量会在任何地方改变会让调试非常困难。当你发现了一个因为变量被错误修改的bug时,你要从哪里看起?这太糟糕了。
这时,你可能会想了,“到底要怎样才能使用纯函数做任何事呢”
在函数式编程中,也不会只写纯函数。
函数式语言也不能避免副作用,只能限制他们。因为程序也不得不与现实世界打交道,每个程序肯定都有不纯粹的部分。我们的目标就是最小化不纯代码的数量,并与程序的其他部分代码保持隔离。
不可变
还记得自己第一次看到下面这些代码的时候吗:
var x = 1;
x = x + 1;
而且谁告诉过你让你忘了数学课上学的东西了?在数学中,x
永远不可能等于 x + 1
。
但是在指令式编程中,这表示,将 x
的当前值加1
在赋值回 x
。
但在函数式编程中,x = x + 1
是不合法的。所以你必须重新记起数学中的一些东西。
函数式编程中没有变量。
由于历史原因存储的值仍然叫变量,但他们是不可变的。比如说,一旦 x
中存了一个值,它的生命周期中就一直是这个值。
不用担心,x
通常是本地变量,所以生命周期很短。但是在其存在时,就永远不能改变。
这有一个 Elm
中常变量的例子,Elm
是一个为 Web 开发准备的纯函数式语言:
addOneToSum y z =
let
x = 1
in
x + y + z
如果你不熟悉ML风格的语法,我来解释一下。addOneToSum
是一个接收 y
和 z
两个参数的函数。
在 let
代码块中,x
绑定了1
这个值。因此,在他余下的生命周期中都等于1
。当函数退出时或更准确地说当 let
代码块计算结束时,它的生命周期就结束了。
在代码块内,计算过程可以使用 let
代码块中定义的变量,即 x
。x + y + z
,更准确地说,1 + y + z
的计算结果会被返回。
再说一次,我听到你问了“没有变量我到底能做什么?!”
考虑一下需要修改变量的场景。通常有两种:多个值改变(比如修改对象和列表中的值)和单一值改变(比如循环的计数器)。
函数式编程中通过对变化的值创建新副本来解决改变值的问题。不过巧妙地使用数据结构可以不必复制全部属性,十分高效。
处理单值变量也是创建副本。
哦对了函数式编程中没有循环。
“为什么变量和循环都没有?!我讨厌你!!!”
等等。这并不是说我们不能使用循环(没别的意思),只是函数式语言中没有像for,while,do,repeat这样的明确的循环结构而已。
函数式编程通过递归来做循环。
JavaScript中有两种方式创建循环:
// simple loop construct
var acc = 0;
for (var i = 1; i <= 10; ++i)
acc += i;
console.log(acc); // prints 55
// without loop construct or variables (recursion)
function sumRange(start, end, acc) {
if (start > end)
return acc;
return sumRange(start + 1, end, acc + start)
}
console.log(sumRange(1, 10, 0)); // prints 55
看看递归是如何工作的,这个函数通过新的起始值(start + 1)
和新的结果值(acc + start)
不断地调用自身达到了和循环一样的功能。它没有改变旧的值。而是使用由旧值计算出来的新值。
不幸的是,即使你学过一段时间JavaScript也很难看到这种写法,有两个原因。一是JavaScript语法很啰嗦,第二是你可能不习惯去用递归。
在Elm
中,这种写法更常见些,理解一下下面的代码:
sumRange start end acc =
if start > end then
acc
else
sumRange (start + 1) end (acc + start)
它是这样执行的:
sumRange 1 10 0 = -- sumRange (1 + 1) 10 (0 + 1)
sumRange 2 10 1 = -- sumRange (2 + 1) 10 (1 + 2)
sumRange 3 10 3 = -- sumRange (3 + 1) 10 (3 + 3)
sumRange 4 10 6 = -- sumRange (4 + 1) 10 (6 + 4)
sumRange 5 10 10 = -- sumRange (5 + 1) 10 (10 + 5)
sumRange 6 10 15 = -- sumRange (6 + 1) 10 (15 + 6)
sumRange 7 10 21 = -- sumRange (7 + 1) 10 (21 + 7)
sumRange 8 10 28 = -- sumRange (8 + 1) 10 (28 + 8)
sumRange 9 10 36 = -- sumRange (9 + 1) 10 (36 + 9)
sumRange 10 10 45 = -- sumRange (10 + 1) 10 (45 + 10)
sumRange 11 10 55 = -- 11 > 10 => 55
55
你可能认为循环更容易理解。但是我们熟悉的,非递归的循环更容易出问题,因为需要使用可变变量,这太糟糕了。
我还没用解释不可变数据的全部好处,更多内容请查看为什么程序员需要限制中全局可变状态章节。
一个明显的好处就是,如果你在程序中为一个变量赋值后,你就只有读的权限了,这意味着再也没人能改变这个值,就算是你自己。所以省去了很多意外的突变。
而且,如果你的程序是多线程的,其他的线程无法干扰到你当前线程。如果当前线程存在一个常量而且其它线程试图改变它,将会按照旧值创建一个新值。
90实际中期时,我写了一个生化危机的游戏引擎,很多bug的源头都是多线程的问题。我真希望自己那时候就知道不可变数据。不过回到那时候我更担心的应该是2x速和4x速CD-ROM驱动在游戏渲染上的差异。
不可变数据使代码更简单和安全。
记在脑子里!!!
到现在足够了。
在本文后续部分,我们将一起讨论高阶函数、函数组合等等,感兴趣的同学请继续关注后续相关更新。
本文根据@Charles Scalfani的《So You Want to be a Functional Programmer (Part 1)》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://medium.com/@cscalfani/so-you-want-to-be-a-functional-programmer-part-1-1f15e387e536#.5fwbp3yde。
如需转载,烦请注明出处:https://www.fedev.cn/javascript/so-you-want-to-be-a-functional-programmer-part-1.htmlAir Jordan V Low Supreme