第一步先来理解函数式编程的概念,这是最重要,也往往是最难的一步。但是完全不必如此。因为你的视角不对。
学习开车
当我们第一次学习开车的时候,我们也挣扎过。没错,看着别人开车是如此的简单。但事实证明比我们想象的要难。
我们在自己父母的车里练习,在熟悉我们自己街区的街道之前也不敢冒险上高速路。
但是通过反复练习,经历一些我们父母愿意忘记的惊恐时刻后,我们学会了驾驶并最终获得了驾照。
驾照在手,我们可以在任意可能的时刻开车出去。一次次的旅行,我们的技术越来越好,信心越来越高。然后某一天,我们需要驾驶别人的汽车或者是已有的汽车报废了我们需要买一辆新的。
第一次驾驶另外一辆车是什么感觉?和第一次驾驶汽车的感觉一样吗?差的还不是一点的远。第一次是如此陌生。在那之前我们也坐过汽车,但是仅仅是作为一名乘客。这一次我们是坐在驾驶座。拥有绝对的掌控权。
但是当我们驾驶我们的第二辆汽车时,我们通常会问自己几个简单的问题比如:钥匙孔在哪里,灯光开关在哪里,怎样使用转向灯和怎样调节后视镜。
然后就可以平稳的行使。但是为什么这一次和第一次相比如此容易?
那是因为新的汽车和以前的汽车非常相似。它拥有所有和其他汽车一样的必备基础设施,并且几乎都在同一个位置。
它有一小部分东西可能通过不同的方式实现,也有可能拥有一些附加功能。但是我们在第一次驾驶时不会使用这些功能,甚至第二次驾驶时也不会使用。最终,我们会学会使用这些新功能。至少学会我们关心的那些。
没错,学习编程语言和这个过程有点相似。第一个是最难的。一旦搞定了一个,后面的就很简单。
当你开始学习第二个语言的时候,你会问一些问题比如,“怎么创建一个模块?怎么搜索一个数组?substring函数的参数是什么?”
你很自信能够学会驾驭这门新语言,因为它提醒你,已有的语言或许加上一些新的特性以后有望让你的生活变的更简单。
你的第一艘宇宙飞船
不管你一生中是否驾驶过一辆汽车或者几十辆汽车,想象一下你即将要驾驶一艘宇宙飞船。
如果你准备去驾驶一艘宇宙飞船,你不要指望在马路上的驾驶能力能够帮上什么忙。你必须从0开始。(我们都是程序员。我们计数从0开始。)
你会预期太空中的一切都会不同,驾驶这个新奇装置和在陆地上驾驶也会不同,并按照这个预期来培训自己。
物理特性并没有改变。都只是你在同一个宇宙中导航的方式。
学习函数式编程也是如此。你应该预想事情将会非常不同。并且很多你所了解的编程知识都不能转换。
编程是需要思考的事情,函数式编程会教会你用完全不同的方式思考。所以,你可能永远不会回到以前的思维方式。
忘记你所知道的一切
人们总是喜欢说这句谚语,但是这是真的。学习函数式编程就像从0开始。不完全是,但是印象深刻。有许多相似的概念,但是你最好预设你必须重学所有的东西。
有了正确的视角,你就会有正确的预期。有了正确的预期才不会在事情变得艰难时退出。
有各种各样作为程序员已经习惯做的事情在函数式编程中都不能再做。
就像驾驶汽车一样,你已经习惯倒出私人车道。但是在宇宙飞船里没有倒挡。现在你也许会想:“什么?没有倒挡?!没有倒挡我还怎么驾驶?!”
好吧,事实证明宇宙飞船不需要倒挡,因为太空中可以在三个维度运动。一旦你理解了这一点,你就不会再想念倒挡。事实上某一天你会觉得汽车太过于限制。
学习函数式编程需要一点时间。所以请有点耐心。
让我们一起走出冰冷的命令式编程世界,纵身一跃,跳进到函数编程温泉中吧。
接下来的一系列文章是一些函数式编程概念,这些概念在你学习你的第一个函数式语言之前非常有用。或许你已经投身学习函数式编程,本文将帮助你理解。
请不要着急。从现在开始花时间阅读并花时间去理解示例代码。你也许想在阅读完一节后停止阅读并消化这些概念。稍后再回来完成剩余部分。
最重要的事情是你要理解。
纯净
当函数式程序员在谈到纯净时,他们是指纯函数。
纯函数是非常简单的函数。他们仅仅针对它们的输入参数进行操作。
这里有一个JavaScript版本的纯函数示例:
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
。不是多么惊奇的事情因为这是一个纯函数。如果add函数使用了外部的值,你根本不可能预测它的行为。
纯函数对于给定相同的输入,总是产生相同的输出。
由于纯函数不能修改任何外部变量,以下所有的函数都是不纯的:
writeFile(fileName);
updateDatabaseTable(sqlCmd);
sendAjaxRequest(ajaxRequest);
openSocket(ipAddress);
所有这些函数都有所谓的函数副作用。 当你调用它们的时候,它们会修改文件和数据库表,发送数据到服务器或者调用操作系统接口获取一个socket。它们所做的事情远远多于仅仅操作输入参数并返回其输出。所以你不可能预测这些函数会返回什么。
纯函数没有函数副作用。
在命令式编程语言中,比如JavaScript,Java和C#,函数副作用到处都是。这使得调试非常困难,因为一个变量在你程序中的任意一个地方都可能被修改。所以当因为一个变量在错误的时间被修改成一个错误值而引发bug时,你在哪里查找这个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-Style句法不熟悉,我来解释一下:addOnetoSum
是一个函数,它有两个输入参数y
和z
。
在let
代码块中,x
的值被绑定为1
,即它剩下的生命周期中,它的值总是1
。它的生命周期在这个函数退出时就结束了,或者更准确的说当let
代码块被评估完时就结束了。
在 in
代码块中,加法运算可以包含 let
块中定义好的值,即x
。x + y + z
的运算结果被返回,更准确的说是1 + y + z
的运算结果被返回因为x = 1
。
再一次我听见你在问:“没有变量我还怎么做事情?!”
我们来想一下什么时候需要修改变量。有两种常用的情况:多值修改(例如:修改一个对象或者记录的一个值)和单值修改(例如:循环计数器)。
函数式编程通过记录处理变量修改,拷贝一份被修改的值的记录。它通过一定的数据结构使得高效处理变得可能,通过一定的数据接口不需要拷贝记录中的所有数据。
函数式编程在处理单值修改时使用完全相同的方式,拷贝。
噢,是的,没有循环。
“什么,没有变量现在没有循环?!!我恨你!!!”
等等,并不是说我们不能做循环(没有别的意思),仅仅是没有特殊的循环结构,比如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)
达到和for
循环相同的效果的。它没有修改旧的值。取而代之的是他使用了旧值的计算结果。
不幸的是,即使你花点时间调查一下也很难在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
也许你觉得for
循环更容易理解。这是一个有争议的问题,并且更像是一个熟悉性的问题,没有递归的循环需要可修改的能力(Mutability),这点不太好。
我还没有完整的解释不可修改性(Immutability)的好处, 可以阅读Why Programmers Need Limits这篇文章中Global Mutable State这一节了更多信息。
一个明显的好处就是如果你有权限访问程序中某一个值,你只有读取权限,也就意味着其他任何人都不能修改那个值。即使你也不可以。这样就不会有偶发的修改。
此外,即使你的程序是多线程的,其他线程也不可能给你制造麻烦。由于该值是一个常量,其他线程如果想要修改那么它需要拷贝一份旧值。
回到90年代中期,我编写了一个游戏引擎Creature Crunch,最大的bug来源就是多线程问题。我真希望那个时候就知道不变性(immutability)。但是那个时候我所担心的游戏性能在2X或者4X的光驱上的差异。
不变性创建了更简单更安全的代码
我的脑袋!!!!
下一篇:成为一名函数式码农(2)