zhouweicsu

「译」什么是函数式编程?

本文讲的是我认为的函数式编程到底是什么,这应该对于搬砖写码给别人打工一心只想快点把活干完的码农来说,应该是很合适的。

首先我告诉你,你写的每个函数都有两组输入和两组输出。

两个?只有一个吧,你确定?

是的,两个。绝对是两个。我们来看看第一个例子:

1
2
3
4
5
public int square(int x) {
return x * x;
}
// 注意:其实什么语言无所谓,但为了强调,我选择了一个有明确的输入输出类型的语言。

上面的例子中,通常你认为输入就是int x,输出也是int

但这只是输入输出的第一个集合。如果你想叫它传统集合也可以。下面我们看看第二组输入输出集合的例子:

1
2
3
4
5
6
7
public void processNext() {
Message message = InboxQueue.popMessage();
if (message != null) {
process(message);
}
}

从语法上看,这个函数既没有输入也没有输出,然而很明显它依赖着什么,并且很显然也在做着什么。事实是,它有一个隐含的输入输出集合。这个隐含的输入是popMessage()函数调用之前IndexQueue的状态,隐含的输出是process产生的任何结果,再加上函数运行之后InboxQueue的状态。

毫无疑问 —— IndexQueue的状态是这个函数真正的输入。如果不知道这个值,那processNext的行为不可预测。并且它也是真正的输出 —— 如果不考虑IndexQueue的新状态,那调用 processNext之后的结果也不能被完全理解。

所以第二段代码有隐含的输入和输出。它依赖一些外部输入,也会产生一些结果,但是如果你只看 API,那你永远猜不对它在干什么。

这些隐含的输入和输出有一个正式的名字:“副作用[side-effects]”。副作用有很多类型,但它们本质都一样:“当我们调用函数时,它需要的什么东西不在参数列表中,它的返回值里体现不出它做过什么事情。”

(实际上我认为我们需要两个概念:隐含输出的“副作用[side-effects]”,和隐含输入的“副原因[side-causes]”。为了简便,接下来本文会使用“副作用”,但我绝对也在讨论副原因。我在讨论所有的隐含输入和输出。)

副作用是水下面的冰山

当函数有副作用(和副原因)时,函数看起来像这样:

1
public boolean processMessage(Channel channel) {...}

…如果你自以为知道它在干什么,那就大错特错了。如果不看内部代码,你根本就不知道它需要什么,它会产生什么结果。它会从 channel(通道)中取得一段消息然后处理它吗?可能。如果某个条件为真它会关闭 channel 吗?也许。它会在数据库的某处更新一个数值吗?或许会。如果找不到它期望的日志存储路径它会崩溃吗?可能会。

副作用是复杂的冰山。你看着函数签名与名字,认为知道这个函数在干嘛。但隐含在函数签名的表面之下的,有可能是其他任何操作。任何隐含的依赖,任何隐含的改变。不看具体的实现代码,你不会知道它到底涉及了哪些操作。API 的表面之下又是另一个潜在的巨大复杂体。如果想要掌控它,你只有三个选择:
选择一,深入了解函数定义,将里面的复杂体暴露出来;
选择二,忽略它,然后祈求出现最好的结果;
选择三,选择二通常是个严重的错误。

难道这不就是所谓的封装吗?

不是。

封装是隐藏实现细节。隐藏调用者不需要关心的内部代码。封装是个好的设计原则,但跟我们刚才说的隐含的东西不是一回事。

副作用并不是“隐藏实现细节”——而是隐藏了与函数外部环境相关的代码。一个有副原因的函数,会有许多找不到说明的外部依赖。一个有副作用的函数,不会告诉你它可能会改变哪些外部因素。

副作用不好吗?

当它们完全按照最初程序员的预期那样工作,就不是不好的,他们可能会正常工作。但这有个前提:我们必须相信最初程序员的隐含期望值是完全正确的,并且随着时间的推移依然正确。

我们有没有按照这个函数当初编写时希望的那样正确地初始化环境状态呢?又或者这个环境有没有在某个地方已经改变了吗?也许因为一小段看似无关的代码的改变。又或者我们把它安装在了一个新环境中。对于环境状态的隐含假设,意味着我们默认了使环境如此运行的希望。

我们能测试这些代码吗?答案是无法单独测试。这不像电路板,我们不能简单的给出输入然后检查输出。我们得打开代码,找出它隐含的依赖与影响,然后模拟它本该存在的外部环境。我已经见识过好几个TDD’ers(测试驱动开发者)在黑盒还是白盒测试的选择之间团团转了。正确的做法是,你应该做黑盒测试——忽略具体的实现细节——但是如果你允许副作用的存在,那就不能做黑盒测试了。副作用对黑盒彻底的关上了大门,因为如果不打开盒子看看里面的代码,你都不知道输入和输出是什么。

这个影响在调试的时候会更复杂。如果函数不允许副作用(或副原因),那你可以通过给出特定输入然后检查输出结果,轻松判断函数是否正常工作。但如果函数有副作用呢?那你必须考虑多少系统的其他部分就不好说了。尤其是当函数什么都可以依赖,什么结果都可能出现的情况下,那任何地方都可能有 bug。

让副作用浮出水面

对于这个复杂体我们能做些什么吗?当然可以。实际上步骤相当简单:把函数的依赖变成输入,声明函数的返回并将它当做输出。就是这么简单。

我们写个例子试一下。下面有个隐含输入的函数。快速找出它你就能得到额外奖励:

1
2
3
4
5
public Program getCurrentProgram(TVGuide guide, int channel) {
Schedule schedule = guide.getSchedule(channel);
Program current = schedule.programAt(new Date());
return current;
}

这个函数的隐含输入就是当前时间(new Date())。我们只需将这个时间当做一个参数输入就能将这个复杂的问题解决:

1
2
3
4
5
public Program getProgramAt(TVGuide guide, int channel, Date when) {
Schedule schedule = guide.getSchedule(channel);
Program program = schedule.programAt(when);
return program;
}

函数现在没有隐含的输入(和输出)。

我们来对比一下这个新版本的优劣:

缺点

看起来似乎更复杂了。本来是2个参数,现在变成了3个。

优点

函数并没有变复杂。隐含一个依赖并没有使之变得更简单,实事求是地写出来也并没有使之变得更复杂。

它让测试变得无比简单。测试一天的不同时间,时钟的变换,闰年等等,都很明确,因为可以传入任何想要的时间值。我在产品中见过加参数之前的代码版本,还有为了测试方便对系统时钟的各种欺骗。想象一下,多写一参数可以省多大事啊!

很容易得到一个结论:现在函数仅仅是描述输入与输出之间的一种关系。如果你知道输入是什么,你就应该知道结果是什么,你对函数产生的结果了如指掌。这个意义重大。现在可以独立测试代码了。我们只需测试输入与输出之间的关系,就可完成整个函数的测试了。

(另一方面,它也很有用。我们利用这个函数,还可以知道“一个小时之后会播放什么节目”。)

什么是“纯函数”?

此处应该有掌声。

终于,在清楚地知道隐含输入与输出之后,我们现在可以给出“一个搬砖码农对纯函数的定义”:

一个函数能称之为“纯”函数,则该函数所有的输入都是显示的(无隐含输入),同样它的所有输出都是显示输出。

相反,若函数有隐含输入或输出,那它就是“不纯”的,而函数给我们的协议并不完整。复杂的冰山隐隐可见。我们绝不能“单独”使用不纯的代码。我们也绝不能单独测试这些代码。因为它总是依赖别的东西,无论什么时候测试或调试,都要考虑这些依赖。

什么是“函数式编程”?

在了解纯函数与不纯函数之后,我们可以给出“一个搬砖码农对函数式编程的定义”:

函数式编程就是编写纯函数,尽可能移除隐含的输入与输出,这样我们的代码就是描述输入与输出之间关系的代码。

我们接受有些副作用是不可避免的——因为许多程序就是为了执行某些操作而不是获取返回值,但在我们的程序里,我们将严格控制它。我们会尽我们所能消除副作用(和副原因),当不能消除时,我们会严格控制。

或者换一种说法:不要隐式地包含任何一段代码需要的任何东西,也不要隐藏它将会产生的任何结果。如果代码需要某些东西才能正确执行,就让它成为输入参数。如果函数做了什么有用的事情,声明它让它成为输出。当我们这么做了之后,我们的代码将会非常清晰。复杂体将会浮出水面,那时我们就能消除或者处理它。

什么是“函数式编程语言”?

每一种语言都支持纯函数——很难使得add(x,y)不纯(对 Java 来说,还是很难)。许多情况下,我们只需要将所有的输入和输出提升到函数签名中,就能将一个不纯的函数转换为纯函数,这样签名就能完整描述该函数的行为。那所有的编程语言都是“函数式”吗?

当然不是。如果是那个词不就毫无意义了。

那我们能给出的“搬砖码农对函数式编程语言的定义”是什么呢?

函数式编程语言就是支持和鼓励不使用副作用来编程的语言。

更确切的说法:函数式语言会尽可能帮你消除副作用,当不能消除时就严格控制。

更生动的说法:函数式语言非常仇视副作用。副作用是复杂体,复杂体是 bug,bug 就是魔鬼。函数式语言也会帮助你仇视副作用。你们一起就能把副作用整得服服帖帖。

就这样?

对,就这样。有些细微之处——你之前也许永远都不会认为这是一个隐藏输入的事情,但这个是关键。构建软件之前,带上“副作用是头等敌人”的看法,它会改变你对编程的一切认知。戳我查看第二篇文章,在对副作用,函数式编程有初步了解之后,拿出机关枪向编程领域开一梭子,看看能打到谁。

致谢

本文是对函数式编程本质的一系列讨论的总结。特别是与 Sleepyfox 关于“如果使用正确的库,JavaScript 是否可以被认为是函数式编程语言”的讨论。我的直觉当然不是,但通过思考“为什么”,我走向了一条非常有价值的思考链。

James Henderson 打个招呼,他今年跟我灌输了许多很有价值的函数式思想。

感谢 Malcolm Sparks, Ivan Uemlianin, Joel Clermont, Katy Moe 和我的影分身 Chris Jenkins 对本文的校对与指导。

原文:What Is Functional Programming?

译者:zhouweicsu