聊一聊函数式编程中的副作用概念

4,983

前言

为了清楚起见,请记住,副作用不是必需的坏事,有时副作用是有用的(尤其是在函数式编程范式之外)。

今天聊一聊函数式编程中的隔离思想,它所想隔离的就是“副作用”

我们先从其他角度来聊一聊副作用这个概念。

生活中的副作用

如果我听到副作用这个词后,第一反应是吃药💊 。

老话说是药三分毒,其中三分毒则为副作用。就比如你👨感冒了,吃了一些西方某些国家研制的专利药品,然后感冒好了,但是感冒好了之后发现自己秃顶👨‍🦲 了。

那么可以说秃顶就是这个感冒药的副作用。

我们来捋一下这个逻辑

  • 感冒好没好? 答: 好了
  • 这药算不算感冒药 ? 答: 算感冒药
  • 不吃这个药的话感冒就不会好,吃不吃 ? 答: 吃
  • 副作用可不可以忍受 ? 答: 至少本来就没头发可以忍

上面的副作用有些夸大其词了,但是药物一般来说都会有一些副作用。

那么话说回来,程序中呢?

程序的副作用是什么

在I/O模型中,我们希望在在I到O之间只有计算,如果中间包含且不仅包含触发了其他I/O、与此次I -> O计算并不相关的任何事情,都称为副作用。

为什么称之为副作用这样的词语呢,“副作用”这个单词给人第一感觉是糟糕的,从而想让你警惕起来。如果在I/O之间发生了一些我们不知道的副作用,那么我们将无法控制住这个过程,测试过程也会变得非常复杂

可以想像,如果在I/O之间如果要访问数据库,则必须确保数据库正在运行。如果要写入文件,则必须确保该文件存在并已打开。所以会导致执行过程和测试过程变得很复杂,并不是单一的点对点。

写到这里让我不禁想起了PromiseA+规范的测试用例,官方提供了872种测试用例,你所实现的Promise必须全部通过872种测试用例才符合官方规范。

无副作用的优势

如果一个I/O模型之间没有副作用的话会有什么样的优势呢?我们参照最开始生活角度的那个例子。

如果感冒药换成某东方国家生产的无副作用的药品,在我们感冒的时候,吃感冒药,感冒好了。过程中无任何副作用的产生,不会秃顶。

那么我们就可以放心的在感冒的情况下吃这种药品,而不用考虑其他情况。这就是一个纯的I/O模型。

在编程中,我们声明了一个函数double,如下

在每次输入x = 3的情况下,double返回恒等与6。它不依赖于我们传递它的参数之外的任何东西

可以想像,在我们调用double的时候,地球上发生着各种各样的事情,如果在调用的瞬间,天上出现了奥特曼,double依然输出6。在固定输入的情况下它是永恒的。

当你写下这个函数之后,你的余生都可以放心使用它,无论上下文如何,它将永久有效。

永恒的东西变化的频率较低,测试起来更加容易,调试起来更加容易。这就是为什么现在很多编程语言都倾向于无副作用。

函数式编程中的副作用概念

如果函数有副作用,我们将其称为过程

函数式编程是基于没有副作用的这样一个简单的前提。在这种范例中,副作用是被排斥的。

如果函数有副作用,我们将其称为过程,或者命令式。因此函数没有副作用。我们认为,如果函数修改了可变数据结构或变量,使用I/O,引发异常或中止错误,则将产生副作用。所有这些东西都被认为是副作用。

副作用之所以不好,是因为(如果有)副作用,取决于系统状态,功能可能是不可预测的。当一个函数没有副作用时,我们可以随时执行它,在给定相同的输入的情况下,它将始终返回相同的结果

但是要声明一点,函数式编程并不是不需要副作用,只是在需要时限制它们。

需要有副作用,因为没有它们,我们的程序将只能进行计算。

我们经常必须写数据库,与外部系统集成或写文件。与外界通过接口的形式交互才能将我们的计算展示出去。所以很多倾向无副作用的语言的中心细想是把“作用”与“副作用”分离开来处理。

下面我们通过一些特性来看一下。

参照透明

对于同一输入总是返回相同结果的函数称为纯函数。因此,纯函数是没有可观察到的副作用的函数,如果函数有任何副作用,即使我们使用相同的参数调用它,也可能返回不同的结果。所以我们可以将纯函数替换为其计算值,例如:

如果我们输入x = 2, y = 2,那么我们可以得到 4 = sum(2, 2)。

那么sum为纯函数吗?很显然是的,如果我们恒定传入x = 2, y = 2。那么sum将恒定输出4.

那么意味着 f(2, 2) 可以替换为4,比如 Math.floor(sum(2, 2)) 替换之后 Math.floor(4),是一致的。

它就像一个很大的查询表。我们可以这样做是因为它没有任何副作用。用其计算值替换表达式的能力称为参照透明性。

引用透明很重要,因为它允许我们用值替换表达式。此属性使我们能够使用替换模型来思考和推理程序评估。因此,可以说可以用值替换的表达式是确定性的,因为它们始终为给定的输入返回相同的值。

局部副作用

在讲局部副作用之前,我们先来举一个非局部副作用的典型例子🌰。

上述的factorial函数有副作用吗?

答:很显然是有的。函数内部与外界产生了可见的交互,外界result值在函数内部被修改了。而且第一次调用factorial(2) 返回值为 3,第二次调用返回值为6。对于统一输入不能总返回同一结果。这种副作用是被函数式编程思想所排斥的,与外界的交互使得factorial具有不确定性。

接下来我们看一下另一个例子🌰

那么问题来了,这次factorial有副作用吗?

答:有副作用,因为for每次执行的时候都会改变factorial的返回值,result在不断改变。

但是即使这样,factorial(2) 也可以用一个值代替,如果把factorial看作一个黑盒子,从外部我们是看不到副作用的。每次的输入x = 2,总会有固定的返回值3。

换句话说,该函数是具有确定性的,我们说的功能有局部副作用,但此功能的用户并不关心,因为它没有破坏我们的替代模式。因此,即使具有局部副作用,该函数也是纯净的。这也是上面为什么说“产生了可见的交互”,很显然这句话就是这么严谨,如果见不到,依然是纯的。

在函数式编程开发中,可以用一些技巧,比如利用容器,把一些副作用控制在局部以达到的目的。

举一些副作用的典型例子

想了想还是在这里立举一些典型有副作用的例子,通过例子可以更好的理解这种思想。

1、与外界交互的。

2、调用I/O的

3、从函数范围之外检索值

4、磁盘检索

5、抛出异常

END

函数式编程的副作用被隔离后,剩下的就是纯作用,也就是纯函数,通过pointfree思想执行。下篇见~

这篇文章终究阐述的是一种思想,虽然全篇举例都举了比较简单的例子,但是思想是一样的。我希望通过一些白话,而不是大篇的官方词语来阐述某种思想。这样的话对读者来说更加容易理解。

思想不一样的话,我们的代码整体都会变化,比如面向过程、面向对象、面向切片、面向结果...等等

项目都是一些代码片段搭建起来的,所以我们研究的都是比较小的代码片段。以小见大。

往期文章推荐