如何成为一名函数式程序员( I)

2016-11-18 10:11:59 +08:00
 lfj

理解函数式编程概念是最重要,有时也是最难的一步。

**学开车

在第一次学开车的时候,挣扎是难免的。看着别人开起来很简单,但实际上比我们想象的要难一些。在爸妈的车里练习,直到对小区的路都熟悉了才敢开到路上去。在经过反复的练习以及一些恐慌的时刻之后,我们终于学会了开车,拿到了驾照。

拿到驾照之后,我们会抓住所有的机会开车出去。一次一次,越来越娴熟,我们也越来越自信。有一天我们不得不借别人的车出去,或者我们的车彻底坏了,不得不买一辆新的。这时候问题就来了,开一辆新车是怎样的感觉?会和第一次开车的感觉一样吗?

*这两种感觉差老远了

第一次开车的时候,完全是陌生的感觉。虽然在此之前,我们坐过车,但都是以乘客的身份。开车就不一样了,我们是坐在驾驶位子上,控制车里的各种东西。

在开第二辆车的时候,我们只是会问一些简单的问题,比如钥匙去哪了?灯在哪里?你怎么使用转向灯,怎么调侧视镜?

之后就非常顺利了。但是为什么这次就这么简单呢?

这是因为新车和旧车很相像,基本的东西都一样,几乎在同样的位置。

有些东西安装的不一样,可能增添了额外的特性( feature ),在第一次甚至第二次开的时候都不会用到。最后我们也都了解了这些新特性,至少了解了我们关心的那些。

学习编程语言就有点像学车。第一次最难,但是一旦你学会了一种,之后的就简单了。

当你开始学习第二种语言的时候,你会问一些这样问题,比如“我如何创建一个模块?你怎么搜索数组? substring 函数的参数是多少?”

在学习新语言的时候,你会变得自信,因为你会想到之前的语言,加了一些新的东西,学起来也容易些。

**你的第一艘宇宙飞船


不管你这辈子是只开过一辆车还是开了几十辆车,现在想象一下你要驾驶一艘宇宙飞船的情景。

如果你要驾驶飞船的话,你肯定不会期望曾经开车的能力能够帮到你。你得从零开始。(作为程序员,我们都是从 0 开始计数。)

开始训练的时候,你就已预想到在太空中是完全不一样的,虽然物理空间不变,还是在同一宇宙中,但是驾驶飞船和开车完全是两码事。

这和学习函数式编程是类似的。编程就是在思考,函数式编程会教你如何以不同的方式思考。以至于,你之后再也没法回到以前的思考方式。

**将自己归零

人们很喜欢说这句话,将自己归零,这么说是有些道理的。学习函数式编程就像是从头开始。不完全,但很贴切。如果你是希望一切都从头学起的话,那是最好的。

看问题的角度正确了,才会有正确的预期;预期正确了,才不会在遇到难题的时候轻易放弃。

作为一名程序员,有各种各样的事情,你已成习惯了,但是在函数式编程的时候是没法做的。

就像在车里,你习惯从私人车道倒出来。但是在飞船里,根本就没有倒挡。现在你可能会想,“什么?没倒挡?!没倒挡,我 TM 怎么开?!”

没倒挡就说明在飞船里根本不需要倒挡,因为飞船可以在三维空间里任人操作。一旦你理解了这点之后,你就不会再想着倒挡了。事实上,某一天,你会觉得车这东西真的是限制太多了。

*学习函数式编程需要一段时间,要有耐心。

走出命令式编程的冰冷世界,温柔地沉浸到函数式编程的暖春中。

在开始研究函数语言之前,下面的函数式编程概念对你会有所帮助。如果你已经开始尝试了,下面的编程概念会让你有更全面的理解。

请花点时间向下读,理解一下编码示例。 最重要的就是你要理解它。

**纯洁性

当函数型程序员说起纯度 纯洁性的时候,他们指的是纯函数。纯函数是非常简单的函数,只运行输入的参数。

举一个用 Javascript 写的纯函数例子:

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

要注意的是add函数不会触发z变量,不会从z开始读取,也不会写到z,只会读取xy(输入值),返回加和结果。

这就是纯函数。如果add函数访问到z,那么它就不是纯函数了。

下面是另一个函数:

function justTen() {
    return 10;
}

如果函数just Ten是纯函数的话,那它只能返回一个常数。为什么?

因为我们还没输入任何东西。如果要变成一个纯函数的话,是没法访问到输入值以外的任何值的,所以唯一能返回的值就是个常数。

没有参数的纯函数无法运行,所以没太大用处。如果just Ten定义为一个常数,那就更好了。

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

看看这个函数:

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 。这并不奇怪,因为这个函数是纯函数。如果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是一个有两个参数(yz)的函数。

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 驱动对游戏性能有啥区别。

不变性创建了更简单安全的代码。

这篇文章来自我的个人知乎专栏, 翻译起来感觉有些难,所以以上不一定准确。有什么地方不对的,麻烦各位大神在评论中指出来,请多多指教!

5029 次点击
所在节点    程序员
27 条回复
jackisnotspirate
2016-11-18 13:56:31 +08:00
@lfj 我只看了标题和图
reus
2016-11-18 15:59:25 +08:00
好好学 Haskell ,然后就会发现 js 这些都是小儿科,根本不需要刻意去学。更不用说“函数式程序员”。为什么 js 社区都这么喜欢造一些看似高大上然而实际并没有什么值得称道的名词?
klion26
2016-11-18 21:10:48 +08:00
个人感觉,翻译还是在最后加上原文链接比较好
SoloCompany
2016-11-19 04:06:31 +08:00
如果无参数函数是纯函数的话,那它只能返回一个常数。
Math.random()
andyL
2016-11-19 08:37:13 +08:00
我看完了,老外写的文章,直译出来都行,很容易跟随作者的人思路进行互动式的学习。知识性的问题的话没法评价。
文章不错👍
lfj
2016-11-19 14:50:28 +08:00
@klion26 好的 我加上
cromwell
2016-11-20 00:43:05 +08:00
函数式编程的变量是 variable ,命令式编程的变量其实是 assignable ,“函数式编程中不存在变量”的说法不准确,混淆了两个概念

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/321389

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX