19|函数式编程之不变性:怎样保证我的代码不会被别人破坏?
文章目录
你好!我是郑晔。
经过前两讲的介绍,你已经认识到了函数式编程的能力,函数以及函数之间的组合很好地体现出了函数式编程的巧妙之处。不过,我们在讲编程范式时说过,学习编程范式不仅要看它提供了什么,还要看它约束了什么。这一讲,我们就来看看函数式编程对我们施加的约束。
在软件开发中,有一类 Bug 是很让人头疼的,就是你的代码怎么看都没问题,可是运行起来就是出问题了。我曾经就遇到过这样的麻烦,有一次我用 C 写了一个程序,怎么运行都不对。我翻来覆去地看自己的代码,看了很多遍都没发现问题,不得已,只能一步一步跟踪代码。最后,我发现我的代码调用到一个程序库时,出现了与预期不符的结果。
这个程序库是其他人封装的,我只是拿过来用。按理说,我调用的这个函数逻辑也不是特别复杂,不应该出现什么问题。不过,为了更快定位问题,我还是打开了这个程序库的源代码。经过一番挖掘,我发现在这个函数底层实现中,出现了一个全局变量。
分析之后,我发现正是这个全局变量引起了这场麻烦,因为在我的代码执行过程中,有别的程序会调用另外的函数,修改这个全局变量的值,最终,导致了我的程序执行失败。从表面上看,我调用的这个函数和另外那个函数八竿子都打不到,但是,它们却通过一个底层的全局变量,产生了相互的影响。
这就是一类非常让人头疼的 Bug。有人认为这是全局变量使用不当造成的,在 Java 设计中,甚至取消了全局变量,但类似的问题并没有因此减少,只是以不同面貌展现出来而已,比如,static 变量。
那么造成这类问题的真正原因是什么呢?真正原因就在于变量是可变的。
变之殇
你可能会好奇,难道变量不就应该是变的吗?为了更好地理解这一类问题,我们来看一段代码:
class Sample1 {
private static final DateFormat format =
new SimpleDateFormat(“yyyy.MM.dd”);
public String getCurrentDateText() {
return format.format(new Date());
}
}
如果你不熟悉 JDK 的 SimpleDateFormat,你可能会觉得这段代码看上去还不错。然而,这段代码在多线程环境下就会出问题。正确的用法应该是这样:
public class Sample2 {
public String getCurrentDateText() {
DateFormat format = new SimpleDateFormat(“yyyy.MM.dd”);
return format.format(new Date());
}
}
两段代码最大的区别就在于,SimpleDateFormat 在哪里构建。一个是被当作了一个字段,另一个则是在函数内部构建出来。这两种不同做法的根本差别就在于,SimpleDateFormat 对象是否共享。
为什么这个对象共享会有问题呢?翻看 format 方法的源码,你会发现这样一句:
calendar.setTime(date);
这里的 calendar 是 SimpleDateFormat 这个类的一个字段,正是因为在 format 的过程中修改了 calendar 字段,所以,它才会出问题。
我们来看看这种问题是怎么出现的,就像下面这张图看到的:
- A 线程把变量的值修改成自己需要的值;
- 这时发生线程切换,B 线程开始执行,将变量的值修改成它所需要的值;
- 线程切换回来,A 线程继续执行,但此时变量已经不是自己设置的值了,所以,执行会出错。
回到 SimpleDateFormat 上,问题是一样的,calendar 就是那个共享的变量。一个线程刚刚设置的值,可能会被另外一个线程修改掉,因此会造成结果的不正确。而在 Sample2 的写法中,通过每次创建一个新的 SimpleDateFormat 对象,我们将二者之间的共享解开,规避了这个问题。
那如果我还是想按照 Sample1 的写法写,SimpleDateFormat 这个库应该怎么改写呢?可能你会想,SimpleDateFormat 的作者没写好,如果换我写,我就会给它加上一个同步(synchronized)或者加上锁(Lock)。你甚至都没有注意,你轻易地将多线程的复杂性引入了进来。还记得我在分离关注点那节讨论的问题吗,多线程是另外一个关注点,能少用,尽量少用。
一个更好的办法是将 calendar 变成局部变量,这样一来,不同线程之间共享变量的问题就得到了根本的解决。但是,这类非常头疼的问题在函数式编程中却几乎不存在,这就依赖于函数式编程的不变性。
不变性
函数式编程的不变性主要体现在值和纯函数上。值,你可以将它理解为一个初始化之后就不再改变的量,换句话说,当你使用一个值的时候,值是不会变的。纯函数,是符合下面两点的函数:
- 对于相同的输入,给出相同的输出;
- 没有副作用。
把值和纯函数合起来看,值保证不会显式改变一个量,而纯函数保证的是,不会隐式改变一个量。
我们说过,函数式编程中的函数源自数学中的函数。在这个语境里,函数就是纯函数,一个函数计算之后是不会产生额外的改变的,而函数中用到的一个一个量就是值,它们是不会随着计算改变的。所以,在函数式编程中,计算天然就是不变的。
正是由于不变性的存在,我们在前面遇到的那些问题也就不再是问题了。一方面,如果你拿到一个量,这次的值是 1,下一次它还是 1,我们完全不用担心它会改变。另一方面,我们调用一个函数,传进去同样的参数,它保证给出同样的结果,行为是完全可以预期的,不会碰触到其他部分。即便是在多线程的情况下,我们也不必考虑同步的问题,后续一系列的问题也就不存在了。
这与我们习惯的方式有着非常大的区别,因为传统方式的基础是面向内存单元的,改来改去甚至已经成为了程序员的本能。所以,我们对 counter = counter + 1 这种代码习以为常,而初学编程的人总会觉得这在数学上是不成立的。
在之前的讨论中,我们说过,传统的编程方式占优的地方是执行效率,而现如今,这个优点则越来越不明显,反而是因为到处可变而带来了更多的问题。相较之下,我们更应该在现在的设计中,考虑借鉴函数式编程的思路,把不变性更多地应用在我们的代码之中。
那怎么应用呢?首先是值。我们可以编写不变类,就是对象一旦构造出来就不能改变,Java 程序员最熟悉的不变类应该就是 String 类,怎样编写不变类呢?
- 所有的字段只在构造函数中初始化;
- 所有的方法都是纯函数;
- 如果需要有改变,返回一个新的对象,而不是修改已有字段。
前面两点可能还好理解,最后一点,我们可以看一下 Java String 类的 replace 方法签名:
String replace(char oldChar, char newChar);
在这里,我们会用一个新的字符(newChar)替换掉这个字符串中原有的字符(oldChar),但我们并不是直接修改已有的这个字符串,而是创建一个新的字符串对象返回。这样一来,使用原来这个字符串的类并不用担心自己引用的内容会随之变化。
有了这个基础,等我们后面学习领域驱动设计的时候,你就很容易理解值对象(Value Object)是怎么回事了。
我们再来看纯函数。编写纯函数的重点是,不修改任何字段,也不调用修改字段内容的方法。因为在实际的工作中,我们使用的大多数都是传统的程序设计语言,而不是严格的函数式编程语言,不是所有用到的量都是值。所以,站在实用性的角度,如果要使用变量,就使用局部变量。
还有一个实用性的编程建议,就是使用语法中不变的修饰符,比如,Java 就尽可能多使用 final,C/C++ 就多写 const。无论是修饰变量还是方法,它们的主要作用就是让编译器提醒你,要多从不变的角度思考问题。
当你有了用不变性思考问题的角度,你会发现之前的很多编程习惯是极其糟糕的,比如,Java 程序员最喜欢写的 setter,它就是提供了一个接口,修改一个对象内部的值。
不过,纯粹的函数式编程是很困难的,我们只能把编程原则设定为尽可能编写不变类和纯函数。但仅仅是这么来看,你也会发现,自己从前写的很多代码,尤其是大量负责业务逻辑处理的代码,完全可以写成不变的。
绝大多数涉及到可变或者副作用的代码,应该都是与外部系统打交道的。能够把大多数代码写成不变的,这已经是一个巨大的进步,也会减少许多后期维护的成本。
而正是不变性的优势,有些新的程序设计语言默认选项不再是变量,而是值。比如,在 Rust 里,你这么声明的是一个值,因为一旦初始化了,你将无法修改它:
let result = 1;
而如果你想声明一个变量,必须显式地告诉编译器:
let mut result = 1;
Java 也在尝试将值类型引入语言,有一个专门的Valhalla 项目就是做这个的。你也看到了,不变性,是减少程序问题的一个重要努力方向。
现在回过头来看编程范式那一讲里说的约束:
函数式编程,限制使用赋值语句,它是对程序中的赋值施加了约束。
理解了不变性,你应该知道这句话的含义了,一旦初始化好一个量,就不要随便给它赋值了。
总结时刻
今天,我们讲了无论是全局变量、还是多线程,变化给程序设计带来了很多麻烦,然后我们还分析了这类问题的成因。
然而,这类问题在函数式编程中并不存在。其中,重要的原因就是函数式编程的不变性。函数式编程的不变性主要体现在它的值和纯函数上。深入学习函数式编程时,你会遇到的与之相关的各种说法:无副作用、无状态、引用透明等等,其实都是在讨论不变性。
即便使用传统的程序设计语言,我们也可以从中借鉴一些编程的方法。比如,编写不变类、编写纯函数、尽量使用不变的修饰符等等。
经过了这三讲的介绍,相信你已经对函数式编程有了很多认识,不过,我只是把设计中最常用的部分给你做了一个介绍,这远远不是函数式编程的全部。就算 Java 这种后期增补的函数式编程的语言,其中也包含了惰性求值、Optional 等诸多内容,值得你去深入了解。不过我相信有了前面知识的铺垫,你再去学习函数式编程其他相关内容,难度系数就会降低一些。
关于编程范式的介绍,我们就告一段落,下一讲,我们开始介绍设计原则。
如果今天的内容你只能记住一件事,那请记住:尽量编写不变类和纯函数。
思考题
最后,我想请你去了解一下Event Sourcing,结合今天的内容,谈谈你对它的理解。欢迎在留言区写下你的想法。
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
文章作者 anonymous
上次更新 2024-03-30