如何成为一名函数式程序员(I)
理解函数式编程概念是最重要,有时也是最难的一步。
学开车
在第一次学开车的时候,挣扎是难免的。看着别人开起来很简单,但实际上比我们想象的要难一些。在爸妈的车里练习,直到对小区的路都熟悉了才敢开到路上去。在经过反复的练习以及一些恐慌的时刻之后,我们终于学会了开车,拿到了驾照。
拿到驾照之后,我们会抓住所有的机会开车出去。一次一次,越来越娴熟,我们也越来越自信。有一天我们不得不借别人的车出去,或者我们的车彻底坏了,不得不买一辆新的。这时候问题就来了,开一辆新车是怎样的感觉?会和第一次开车的感觉一样吗?
这两种感觉差老远了。
第一次开车的时候,完全是陌生的感觉。虽然在此之前,我们坐过车,但都是以乘客的身份。开车就不一样了,我们是坐在驾驶位子上,控制车里的各种东西。
在开第二辆车的时候,我们只是会问一些简单的问题,比如钥匙去哪了?灯在哪里?你怎么使用转向灯,怎么调侧视镜?
之后就非常顺利了。但是为什么这次就这么简单呢?
这是因为新车和旧车很相像,基本的东西都一样,几乎在同样的位置。
有些东西安装的不一样,可能增添了额外的特性(feature),在第一次甚至第二次开的时候都不会用到。最后我们也都了解了这些新特性,至少了解了我们关心的那些。
学习编程语言就有点像学车。第一次最难,但是一旦你学会了一种,之后的就简单了。
当你开始学习第二种语言的时候,你会问一些这样问题,比如“我如何创建一个模块?你怎么搜索数组?substring 函数的参数是多少?”
在学习新语言的时候,你会变得自信,因为你会想到之前的语言,加了一些新的东西,学起来也容易些。
你的第一艘宇宙飞船
不管你这辈子是只开过一辆车还是开了几十辆车,现在想象一下你要驾驶一艘宇宙飞船的情景。
如果你要驾驶飞船的话,你肯定不会期望曾经开车的能力能够帮到你。你得从零开始。(作为程序员,我们都是从0开始计数。)
开始训练的时候,你就已预想到在太空中是完全不一样的,虽然物理空间不变,还是在同一宇宙中,但是驾驶飞船和开车完全是两码事。
这和学习函数式编程是类似的。编程就是在思考,函数式编程会教你如何以不同的方式思考。以至于,你之后再也没法回到以前的思考方式。
将自己归零
人们很喜欢说这句话,将自己归零,这么说是有些道理的。学习函数式编程就像是从头开始。不完全,但很贴切。如果你是希望一切都从头学起的话,那是最好的。
看问题的角度正确了,才会有正确的预期;预期正确了,才不会在遇到难题的时候轻易放弃。
作为一名程序员,有各种各样的事情,你已成习惯了,但是在函数式编程的时候是没法做的。
就像在车里,你习惯从私人车道倒出来。但是在飞船里,根本就没有倒挡。现在你可能会想,“什么?没倒挡?!没倒挡,我TM怎么开?!”
没倒挡就说明在飞船里根本不需要倒挡,因为飞船可以在三维空间里任人操作。一旦你理解了这点之后,你就不会再想着倒挡了。事实上,某一天,你会觉得车这东西真的是限制太多了。
学习函数式编程需要一段时间,要有耐心。
走出命令式编程的冰冷世界,温柔地沉浸到函数式编程的暖春中。
在开始研究函数语言之前,下面的函数式编程概念对你会有所帮助。如果你已经开始尝试了,下面的编程概念会让你有更全面的理解。
请花点时间向下读,理解一下编码示例。 最重要的就是你要理解它。
纯洁性
当函数型程序员说起纯度 纯洁性的时候,他们指的是纯函数。纯函数是非常简单的函数,只运行输入的参数。
举一个用Javascript写的纯函数例子:
var z = 10;
function add(x, y) {
return x + y;
}
要注意的是add函数不会触发z变量,不会从z开始读取,也不会写到z,只会读取x和y(输入值),返回加和结果。
这就是纯函数。如果add函数访问到z,那么它就不是纯函数了。
下面是另一个函数:
function justTen() {
return 10;
}
如果函数just Ten是纯函数的话,那它只能返回一个常数。为什么?
因为我们还没输入任何东西。如果要变成一个纯函数的话,是没法访问到输入值以外的任何值的,所以唯一能返回的值就是个常数。
没有参数的纯函数无法运行,所以没太大用处。如果just Ten定义为一个常数,那就更好了。
大多数有用的纯函数都必须至少有一个参数。
看看这个函数:
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。这并不奇怪,因为这个函数是纯函数。如果add函数使用外部的值,那么就没法预知结果了。
同样的输入,纯函数得出的结果都是一样的。
因为纯函数是没法改变任何外部变量的,所以下面这些函数都不是纯函数:
writeFile(fileName);
updateDatabaseTable(sqlCmd);
sendAjaxRequest(ajaxRequest);
openSocket(ipAddress);
这些函数都有副作用,当你调用的时候,文件和数据表都会改变,数据发送到服务器,或者调用OS套接,不仅仅是运行输入值返回输出值。所以永远也没法预判这些函数会返回什么东西。
纯函数没有副作用。
在命令式编程语言中,比如Javascript、Java和C#,副作用无处不在。这使得调试非常困难,因为在项目中一个变量随处都可改变。所以当发现一个bug,这bug是因为一个变量在错误的时间被改成了错误的数值导致的,怎么找出来?到处找吗?根本不行!
这时候,你可能会在想 “只有个纯函数,我TM怎么办?”
在函数式编程中,你不仅仅是写纯函数。
函数式编程无法彻底消除副作用,只能限定。因为各个项目要和真实的世界交互,每一个项目中某一部分必须是非纯函数的。目标是最小化非纯码的数量,将其与其它的项目隔离开。
不变性
你还记得你第一次看到下面这些代码的时候吗?
var x = 1;
x = x + 1;
是不是每个教你的人都告诫你忘记你在数学课上学的东西?在数学上,x是不可能等于x+1的。
但是在命令式编程中,x+1的意思是将x现在的值加上1,然后将值返回给x。
在函数式编程中,x = x + 1是非法的。所以你必须得记住你忘记的那一点点数学知识。
函数式编程中不存在变量。
存储的值也叫作变量,但是他们都是常数,比如一旦x取了一个值,那它永远就是这个值。
不用担心,x通常是个局部变量,所以它的生命通常很短暂。但是在生命期中,它的值是无法改变的。
下面是EIm中的一个固定变量的例子,EIm是一种用于网页开发的纯函数式编程语言。
addOneToSum y z =
let
x = 1
in
x + y + z
如果你对ML-Style语法不熟悉的话,我来解释一下,addOneToSum是一个有两个参数(y和z)的函数。
在let模块内,x绑定值为1,也就是它的值等于1。在函数退出或者更精确的说是当let模块求值之后,x的生命就结束了。
在in模块,计算包括let模块定义的数值,也就是x。返回x + y + z的计算结果,更精确地说是,因为x = 1,返回1 + y + z这样一个结果。
你肯定会又问“没有变量,我TM到底该怎么做?!”
我们来想想什么时候要调整变量。一般有两种情况:多值变化(比如:改变某个对象或记录的一个值)和单值变化(比如:循环计数器)。
函数式编程通过利用数据结构复制值变化后的记录(不用复制全部记录)的方式高效地处理记录中数值的变化。
函数式编程也是通过同样地方式(即复制)解决单值变化。
是的,但是没有循环。
你肯定又抓狂了:“什么?没变量,现在又没循环?!”
冷静,不是不能进行循环,只是没有特定的循环结构,像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
我们看看递归(一种函数方法)是怎样通过自我调用一个new开始(start + 1) 和一个new累加器(acc + start)实现和 for循环一样的结果的。递归不需要改变原先的值,而是使用旧值算出的新值。
不幸的是,即使你花了一点时间学习,在Javascript中也很难看到这个。有两个原因:一是Javascript语法比较杂乱;二是你可能不习惯用递归的方式思考。
在EIm中,读取更容易,理解也相应的更容易:
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
你可能会觉得for循环更容易理解。然而,这是有争议的,更可能是熟悉度的问题,非递归循环需要可变性,可变性这就糟糕了。
这里我没有完全解释不变性的好处,你可以到另一篇文章《为什么程序员需要限定》(Why Programmers Need Limits)中的Global Mutable State查看一下,会学到更多。
不变性一个明显的好处就是如果你访问了项目中的一个值,你只能读取访问,这意味着其他没有人能改变这个值,即使是你。所以不会发生意外的变性。
另外,如果你的项目是多线程的,那么其它线程没法让你挂掉的。值是不变的,如果另一个线程要改变它,必须从旧的值里创建一个新值。
在20世纪90年代中期,我为生物危机(Creature Crunch)写了个游戏引擎,最大的bug来源就是多线程问题。我很希望能够重新了解不变性。但是我更在意是2x或4x速率CD-ROM驱动对游戏性能有啥区别。
不变性创建了更简单安全的代码。