纯函数

如果说编程中函数这个术语借自数学,那么纯函数就是编程到数学的回归。本文将会介绍纯函数的相关概念,并且解释把一个函数写成纯函数的形式究竟有什么好处,以及编译器是怎么标记一个函数纯与不纯的性质。

(本文主要面向类 C 语言的读者,因此会默认使用类 C 编程语言的术语。)

编程的函数 vs 数学的函数

首先,我们凭直觉写下“函数”这个概念,在数学和编程相同——也就是可以类比的地方:

  • 都有输入和输出

除此之外似乎就没有别的共性了。有人可能会说,函数必须是一个输出值,实则不然。就如计算机编程中总是可以用复合的数据结构打包输出多个值,数学上也可以定义函数的值域为多元数对比如 (i, j, k) 构成的集合。

接着,我们再凭直觉写下“函数”在编程里能做到而数学里不能做到的东西:

  1. 控制台的键盘输入和显示输出,包括日志。
  2. 调用操作系统的 API,进行存储、网络、任务、时钟等控制。
  3. 访问和改变全局变量、修改静态局部变量准备给下次使用。
  4. 修改指针所指向的值,也就是修改进程内统一的“内存”里的内容。

无法一一列举。对上面的内容进行归并,仔细思考过后,不难发现:

  • 1 和 2 实际上最终都是调用系统提供的 API,1 所描述的 I/O 也是 2 所描述的资源控制的一种,都是对这个函数外的各种资源进行控制;
  • 3 和 4 也有明显的共性,都是对于一个概念上的“内存”进行存取,load 和 store。这个“内存”并不专属于函数,对它来说也是一种外部世界。

进一步归纳后者对“内存”进行的访问和对“磁盘”进行的 I/O,似乎在本质上也是相似的。这么看来可以归结为:编程意义上的函数,相比数学意义上的函数,存在对“外部”的存取。

这个所谓的“外部”在术语中称为“环境”,或者一个同义词“上下文”。

纯函数和它的削弱形式

不依赖环境的函数,叫做纯函数。换句话说,纯函数就是仅仅使用输入的参数便能得出输出结果,且不影响或者说改变环境的函数。

这句话对环境有读和写两个侧面,因此也有两个约束更弱的版本:

  • 一方面是,纯函数不能“读取”环境,比如获取当前时间,读键盘输入,满足这个条件的称为确定性函数;
  • 一方面是,纯函数不能“改写”环境,比如写文件,网络操作,杀死其他进程或者退出自身进程,满足这个条件的称为无副作用函数。

纯函数既满足确定性也满足无副作用

这里举四个例子,说明这两个侧面的所有可能选择:

确定性无副作用

int foo(int x) {
    return x * x;
}
这个函数仅根据输入,就能完全地计算出结果,对外部没有任何影响

确定性,有副作用:

int foo(int sig) {
    kill(0, sig);
    return 0;
}
这个函数仅根据输入值做出相应动作,给全系统的进程发送编号为 sig 的信号

非确定性,无副作用

extern int g_seed;

int foo(int x) {
    return g_seed + x;
}
这个函数不会改变外部,但是需要从外部额外获得信息;类似的还可以是 time(NULL) 获取时间等

非确定性,有副作用:

int foo(const char *path) {
    int fd = open(path, O_CREAT, O_RDWR);
    if (fd == -1) {
        return -1;
    }
    if (write(fd, "hello world\n", 12) != 12) {
        return -1;
    }
    close(fd);
    return 0;
}
这个函数不仅会改变了外部环境,而且外部环境也会影响它的行为和输出:例如文件无权打开

注:术语方面,一些指令式编程语言背景的程序员会将仅符合无副作用的函数也称之为纯函数,这对于普及纯函数是有利的,毕竟对副作用的定位和隔离本就是是迈向纯函数的必备的一步,但因为还缺失了“确定性”这一要素,没法完全利用纯函数的所有优秀性质。

纯函数的性质应用

因为纯函数与环境无关,它的表现非常类似或者等同于一个数学上的函数——对于给定的输入,总是有一个固定的输出。对它反复多次求值,每次的结果并不会变,也不会突然让电脑发生爆炸(想象一下,正弦函数 sin 就是一个纯函数,电脑并不会因为你算了多少次 sin(1) 的值而突然返回一个不一样的数值,或者后台建立了奇怪的网络连接、多了很多磁盘上的垃圾文件)。

这个性质使得它求值(或者说运行、计算)策略和其他的代码都可以有所不同:计算一次和计算N次是相同的,早点计算和晚点计算也是相同的。典型的好处有:

  1. 函数可以提前计算然后多次使用。也可以结合缓存,计算一次之后存下来,下次对于同样的输入,跳过计算步骤,查表返回同样的结果即可;
  2. 或者函数也需要的时候再计算(即延迟求值),这可以自动消除某些永远不会被用到的表达式片段;
  3. 既然与环境没有交互,那么它必然是线程安全(没有全局内存存取)且可重入(没有线程局部存取)的,它的存储访问最多不超出这次调用自身栈内的信息。

最关注这种性质的,是编译器的优化器。想象一下,对于多次的 sin(1) 求值,它可以让 CPU 求值一次(甚至在编译期间就计算完了!),然后存在某个寄存器或者常量里,下次要用到的时候跳过笨重的浮点数计算。

编译器视角中的纯函数

(未完待续)