如何成为一位函数式编程爱好者(Part 1)

发布于 Heng温

踏出理解函数式编程概念的第一步是最重要的一步,有时也是最难的一步。不过也不一定,取决于你们的思考方式。

学习开车

我们第一次学车的时候,是很痛苦的。看别人在学的时候真的很简单。但一到我们自己做的时候就变得比想象得要难得多。

我们曾在父母的车上练习,但是能在家周围的街道上熟练地驾驶之前都不敢去高速公路上冒险。

但经过反复练习和一些可能都已经忘记了的痛苦经历之后,我们也学会了开车并且最终拿到了驾照。

驾照到手后,我们可以开车去任何想去的地方。每次旅途让我们的车技越来越好,也对自己的车技越来越自信。直到有一天,要开别人的车,或者自己的车报废了又买了一辆新的。

第一次开一辆不同的车是一种什么感觉?和我们第一次开车时相同吗?差远了。第一次开车时,我们完全是个外行。之前我们也坐过车,但只是乘客位。这次是坐在驾驶座位上,控制车上的一切。

但在开第二辆车时,我们只需要知道一些非常简单的问题就可以了,钥匙在哪,灯光在哪,如何打转向灯,如何调整后视镜。

之后的一切则相当顺利。但与第一次开车相比,这次为什么这么容易呢?

因为新车和之前的旧车很像。他们都有一辆车所需要的基本结构,而且每处结构也非常像。

结构上可能有点小出入或者可能有一些新特性,不过前几次开车我们可以先不用新特性。最后,我们也能学会所有的新特性,至少包括我们常用的那些。

学编程语言和学车有点像。第一门是最难的。但一旦掌握了一门语言,其他的学起来都会更容易。

当开始学第二门语言时,也只需要问自己一些简单的问题,“如何创建一个模块? 如何从一个数组中查询值? substring函数的参数是什么?”

你自信能驾驭这门新语言,因为旧语言的经验会帮助你,从中引发的新思考会让你学起来更容易。

第一次开宇宙飞船

先不管你这一生开过多少辆车了,想象一下,你就要去开宇宙飞船了。

如果你要开宇宙飞船,不用期望在路上的驾驶技术能帮你很多。全部都要从零开始。(毕竟我们是程序员,从0开始计数。)

你需要按照预期开始练习,在太空中一切都和之前不同,驾驶这个新装备也和在地上开车完全不同。

物理学没有变。只是你在宇宙中的穿行方式变了。

学习函数式编程也一样。你会发现很多东西都不一样。而且你曾经的很多编程经验都无法转化。

编程就像思考一样,函数式编程将教你完全不同地去思考。久而久之,你就可能再也不会用原来的方式思考了。

忘记你知道的全部

人们很喜欢说这句话,也挺对的。学函数式编程就像从零开始。不完全对,但很有用。函数式编程中有很多相似的概念,但将所有的东西重学一遍才是最好的。

有了正确的态度就能达到正确的期望,达到了正确的期望在遇到困难时就不会退缩。

你已经有了很多编程方面的习惯,但是在函数式编程中不再适用。

就像开车一样,过去你习惯倒车出库。但是在宇宙飞船上,并不能倒着开。你可能会想,“不能倒着开?那我到底该怎么开?!”

然而,事实证明宇宙飞船不需要倒着开,因为它可以在三维空间中飞行。一旦你明白了这点,你就不会再想倒着开了。而且总有一天,你会想起汽车的局限性多么强。

学习函数式编程需要一段时间。请保持耐心。

让我们离开指令式编程的冰冷世界,融入到函数式编程的温暖中吧。

在你深入第一门函数式编程语言之前,这篇文章后面关于函数式编程概念的的几节对你会有帮助。或者说如果你已经入坑了,他们能帮助你更好地理解。

不必心急。花一些时间看接下来的内容并且花些时间理解示例代码。你也可以在看完每章之后暂停一下,自己思考加深理解,然后再回来完成剩下的章节。

最重要的是理解。

纯粹的

函数式编程中说的纯粹,是指纯函数。

纯函数是非常简单的函数。他们只处理传入的参数。

这有一个纯函数的例子:

var z = 10;
function add(x, y) {
    return x + y;
}

注意: add 函数没有碰变量 z。没有读 z 也没有写 z 。只读了传入参数 xy,然后返回他们相加的结果。

这就是一个纯函数。如果 add 函数访问了 z,它将不再纯粹。

再看下面这个函数:

function justTen() {
    return 10;
}

这个justTen只返回一个常量,也是纯函数。为什么?

因为我们没传入任何参数。而且,为了保证纯粹,它不能访问除传入参数外的任何东西,所以只能返回一个常量。

但是没有传参的纯函数什么都没做,没什么用处。justTen定义为常量的话会更好。

大多数有用的纯函数必须传入至少一个参数。

看看这个函数:

function addNoReturn(x, y) {
    var z = x + y
}

**注意:**这个函数没返回任何东西。把 xy 相加后赋给了 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 是一个接收 yz 两个参数的函数。

let 代码块中,x 绑定了1这个值。因此,在他余下的生命周期中都等于1。当函数退出时或更准确地说当 let 代码块计算结束时,它的生命周期就结束了。

在代码块内,计算过程可以使用 let 代码块中定义的变量,即 xx + 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

Heng温

前端开发,音乐,动漫,技术控,喜欢折腾新鲜事物,以不断学习的态度,在前端的路上走下去。

如需转载,烦请注明出处:https://www.fedev.cn/javascript/so-you-want-to-be-a-functional-programmer-part-1.htmlAir Jordan V Low Supreme