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,结合今天的内容,谈谈你对它的理解。欢迎在留言区写下你的想法。

感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。

精选留言

  • Medusa

    2020-07-21 19:14:07

    如果每个函数都使用局部变量,那有些函数入参就会很多,这又与代码整洁之道里谈到函数入参尽可能少的原则对立,怎么去做一个平衡?
    作者回复

    首先,参数的多少与是否使用局部变量无关。其次,参数如果多,可以封装成类。

    2020-07-22 07:16:53

  • sam

    2020-07-08 08:03:07

    初始化后不会改变的“值”就是常量吗?
    作者回复

    常量一般是预先确定的,而值是在运行过程中生成的。

    2020-07-08 10:26:52

  • 阳仔

    2020-07-08 09:45:30

    变化是软件开发的永恒主题,所以在编码实践上尽量的编写不变的纯函数和类,将变化的粒度控制到最小
    作者回复

    变化是需求层面的不得已,不变是代码层面的努力控制。

    2020-07-08 11:50:32

  • 阿姆斯壮

    2021-08-27 08:35:11

    学习函数式编程后,昨天发生了一件事。令我很有成就感。如果按照之前工作的方法,其实就是把JavaScript当成C语言来使用。需要引入大数组的方式来记录相应关键信息。而且中间还需要考虑各种复杂的记录和查找的代码。我用来闭包的方法,引入一个变量。调整了一下代码的结构。改动不到3行。就成功解决了这个新需求。重点是获得了很大的正向反馈。现在每天下班前回看自己今天写的代码。有种隐约发现可以从函数中反应出业务的流程。不过,由于校长介绍的知识太吸引人了。这几天老是想学习DDD。但以终为始的方式实践下来却让我感觉能在工作中应用起来的知识才是目前自己最为急需的。而且能让自己进步更快。所以我重新调整了一下自己目标。先继续打磨函数式编程。争取未来半年能在公司教其他同事。另外就是引入测试的实践。这个过程中可以不断学习设计原则。在代码中思考原则如何应用。打通这些之后,才把重点引入DDD。
    作者回复

    恭喜你通过实战有所收获

    2021-08-28 22:01:02

  • Feng

    2020-11-04 16:42:49

    Event Sourcing,把对象的状态每次变化抽象成独立的事件进行保存,对象的状态变化可由一系列事件的调用重现。
    编写不变类,无可变化的状态;Event Sourcing记录每一次状态变化。
    作者回复

    非常好的补充。

    2021-01-31 21:20:28

  • zcc

    2020-07-08 08:31:27

    尤其是大量负责业务逻辑处理的代码,完全可以写成不变的。这句话不太理解,老师可以举个例子吗?
    事件溯源,对比一般的CRUD,就是没有修改,只有不断的插入值不同的同一条记录,下次修改时,在最新一条基础上修改值后再插入一条最新的。有点类似Java String 的处理方式,修改是生成另一个对象。
  • podric

    2021-05-20 09:58:33

    您好,郑老师!较多地使用“不变类”是否会
    + 增加内存的占用 (改变后的值和旧值同时在内存中) -> 可能由GC机制管理,因此可以解决。
    + 导致运算速度的降低 (较多的复制操作)
    作者回复

    这取决于所用的语言内存管理机制是什么样的,Rust就不用考虑这样的问题。

    设计首先要考虑的是模型关系,实现是下一步的考量。如果你的设计里面,这个不变的设计要经常变,那你考虑的是这个设计是不是有问题。

    2021-05-22 22:14:00

  • 2020-08-31 03:14:26

    从effect java中学到了builder模式;之前实验ddd的时候就考虑了不变性;比如修改用户信息;业务逻辑提取入参数据,返回值是通过builder构造一个新的对象;builder中有完整性校验;这样我可以保证经过业务逻辑处理后返回的对象一定是一个新的并且是符合业务完整性的领域对象。
    作者回复

    设计模式是值得专门学一下的。

    2021-01-27 14:25:01

  • 独孤九剑

    2021-07-14 09:41:38

    函数式编程的“不变性”也是OCP原则的一种体现吧?
    作者回复

    殊途同归

    2021-07-24 17:33:23

  • 2020-09-12 08:55:07

    老师,函数式语言,应用领域主要在哪里啊,适合做业务系统吗
    作者回复

    现在纯函数式的语言使用范围并不广,但越来越多的语言支持了函数式编程,比如,Java。

    显然,我这里给出的例子都是在真实项目中如何使用的例子,所以,用函数式编程做业务系统不是问题。

    2020-09-13 20:10:11

  • 6点无痛早起学习的和尚

    2023-11-21 16:37:55

    通过代码之丑的 09 节回来看这节内容,有一个新的问题,我在工作代码有这样一个场景,我定义了 3 个全局变量,在构造方法中 init,这样是否又有问题呢?
    public class XXRpc {

    private final XXConfigApolloData xxConfigApolloData;
    private final String url;
    private final String apiKey;

    @Autowired
    public XXRpc(XXConfigApolloData XXConfigApolloData) {
    this.xxConfigApolloData = xxConfigApolloData;
    url = xxConfigApolloData.getUrl();
    apiKey = xxConfigApolloData.getApiKey();
    }
    }
  • 路过

    2023-10-15 11:08:41

    对于纯函数里面的第二点,没有副作用,这里的副作用是指什么
  • 6点无痛早起学习的和尚

    2023-09-20 09:20:49

    这里有个困惑:尽量编写纯函数那应用在面向对象里,方法void hander(xxDto xx),hander方法逻辑会对 Dto 做一些 set 赋值,如果应用纯函数,那我理解就是优化为xxDto hander(xxDto xx),在 hander 里新 new 一个 xxDto 做赋值然后 return 这样吗?
  • ifelse

    2022-05-16 12:59:38

    尽量编写不变类和纯函数。--记下来
  • 呆呆狗的兽

    2021-06-25 16:00:53

    粗略了解了下这个Event Sourcing,看了下其官方还是开发者,提供的代码样例,其思想就是有个全局的事件处理器,类似于消息总线?掌控着系统所有的消息的发送与接受(DomainEventPublisher负责对方法发送,DomainEventDispatcher负责对向消息的分发,然后各自服务进行处理),这样整个系统各服务就依赖事件的发送与消费,完成了不变性?因为各系统不会直接修改其他系统的“值”了,比如order的status,都由其domain中代码控制,而其他domain或者service等,都完全没有控制其变更的方式。我是这样理解的
  • 猪肉怂

    2021-06-13 10:33:37

    事件源:之前看过网上 MOPCON 的一个分享(https://www.youtube.com/watch?v=mGR0A5Jyolg)提到过,不要轻易添加「状态」,取而代之的是通过事件源(通过事件的发生时间,去重建历史的对象及对应关系),我觉得这本质上是给实体模型赋予不变性,从而消除因为状态变化而引发的副作用。

    不变性,也是诸多编程原则背后的原则。例如,基于「不变性」这样一个目标,领悟驱动设计中的「值对象」 做法(定义一个不变的对对象,用于标识实体之外的其他业务模型),以及马丁.福勒提出的「无副作用方法」(side-effect-free function,指代方法不会对对象状态产生任何改变) 等,就都显得非常恰如其分了。

    更极端的如 Rust ,直接让不变性成为语法语汇,有人评价这是一种把道德规范引入法律的做法,觉得这种类比有一定道理。然而在语言层面,至少倒逼程序员产出不那么坏的代码。
  • Janenesome

    2020-10-22 21:39:57

    想请教一下老师。
    我用的是 Java,以前会经常设计非线程安全的对象,因为不用考虑并发问题,他的成员变量可以在方法内被修改。业务流程中执行到中间某个方法的时候需要顺便赋值某个变量,然后在接下来的另外一些方法里用到该变量。

    如果按文中的不变性来定义,可变的变量是不是应该改成由函数来返回,然后作为入参传给其他函数?
    或者这个对象最好只提供纯函数,对象里的成员变量拆到另外一个类去呢?
  • Janenesome

    2020-10-22 21:28:25

    实际生产中也有遇到过 static 全局变量带来的并发问题,而且还挺隐晦的,我们是 tomcat 容器多线程下引起的。因为代码里没有显式使用多线程,导致一开始还没意识到是什么问题。

    static 用起来倒是挺方便的,因为很多时候都不会意识到会有什么问题,还是见得少了。看完《10X程序员》之后就已经下意识地少用 static 了。
    作者回复

    学到一点,用起来都是好的。

    2021-02-02 14:31:21

  • 2020-08-27 08:42:30

    那如果把全局变量都改成ThreadLocal,老师觉得怎么样,有什么不好的地方,我除了觉得这类变量方便是方便,但很难跟踪修改,导致代码可读性不好。