讲动人的故事,写懂人的代码
可能很多程序员和我一样,一直在一次次地重新入门函数式编程(和设计)。因为我们总是学了就忘。
鲍叔去年出版了他的大作《函数式设计》,里面有大量Clojure代码示例。如果不懂Clojure,读起来比较吃力。
在去年JetBrains全球程序员生态调查中,Clojure粉丝只占所有程序员中很小的一部分。大家总怕花了时间学一门小众语言,在时间投入上有些不值。
这些顾虑,成了我们自学Clojure的障碍。
该如何克服这些障碍?靠你自己的“损失厌恶”的心理特点。
人天生有避免损失的倾向。从进化心理学的角度来看,这是人类在漫长的进化过程中形成的生存本能。在远古时代,我们的祖先面临着严酷的生存环境,稍有不慎就可能丧命。所以人类进化出了对潜在损失高度警觉的心理机制。即使到了现代社会,这种本能仍然在我们的基因里。跟单纯被利益吸引相比,人们为了避免损失,往往更有行动力。那么我们能否利用这个心理来激励自己学习编程语言呢?答案是肯定的。
尽管现在包括Scala、Haskell、Clojure、Elixir、F#在内的所有常用的函数式编程语言,在JavaScript、Python和Java面前,都算小众语言,但这绝不代表未来不需要函数式设计。
正如我在《函数式设计》译者序里所言,程序员如果现在不学函数式设计,会有三大损失。
第一个损失是会丢掉现在的饭碗。在生成式AI来临的时代,程序员需要阅读大量AI所生成的代码。但未来很长的一段时间内,当代码出现错误时,责任还是会落在使用生成式AI的人类程序员身上。因此,作为AI的“监督者”,人类程序员需要能够理解AI所生成的代码,包括函数式风格的代码,并能指出其中关于多线程并发代码的缺陷。这样,当软件出现故障时,程序员就不会因为无法理解函数式代码而手足无措,而丢掉饭碗。
第二个损失是会失去未来的饭碗。新的现代编程需求正在呼唤懂函数式设计的程序员。随着实时数据处理和响应式用户界面变得越来越重要,函数式编程的概念如流(stream)和函数组合,为处理数据流提供了强大的模型。此外,在大数据和分布式系统等领域,需要进行大量数据的并行计算。函数式编程的无副作用特性和高度抽象,使其更适合在这些场景下表达和优化计算过程。程序员如果不会函数式设计,就会丢掉这些薪资诱人的新饭碗。
第三个损失是会伤害自己的生命。在许多公司,程序员常常需要加班修复软件缺陷。如果发现代码是由数据默认可变的面向对象的风格编写的,那么理解、测试和维护这些代码就会更困难。这将大大增加调试包括多线程并发问题在内的难以处理的缺陷的时间消耗和精神压力,从而大大增加因加班过度而导致过劳死的风险。
看到了这三大损失,相信你会更有动力学习函数式设计。而顺应“怕踩坑”的心理特点驱动自己入门Clojure,也会让你更有动力。
要想用"怕踩坑"的心理驱动自己学习新语言,就得先找到旧语言所埋下的坑。
Java并发编程两大坑
对于使用像Java这样的主流编程语言的程序员来说,并发编程是最容易踩坑的领域。具体来说,有两大坑。一是不得不使用易出现并发bug的默认可变的数据。二是不得不使用易出现失误的显式锁。为了避免这两大并发坑,专业Java程序员需要小心翼翼,稍有不慎,就会引入并发bug。
接下来我会用一个多线程并发的影院订票系统为例,展示这两大坑是如何埋伏在Java代码里的,如图1所示。
图1 用Java实现并发场景下的影院订票系统的类图
Java版的影院订票系统有三个类。
首先是BookingSystem
类。这个类的职责是协调整个订票系统,管理预订、取消、支付和查询可用座位的操作。这个类拥有3个数据,一个数据是类图下面左侧的MovieTheater
类的实例 theater
,另一个数据是类图下面右侧 List<Booking>
类型的预订列表bookings
,最后一个数据是 ReentrantLock
类型的重入锁bookingLock
。
第二个类MovieTheater
类的职责,是管理电影院座位,提供座位预订、取消和查询可用座位的功能。这个类拥有三个数据: 总座位数totalSeats
、List<Boolean>
类型的座位列表seats
和 ReentrantLock
类型的重入锁lock
。
最后一个类是Booking
类,它的职责是表示单个预订,并管理预订的支付状态。这个类拥有两个数据: 座位号seatNumber
和是否已支付isPaid
。
代码可以在图1中注明的github链接下载。
由于Java难以实现ArrayList
的原子操作,所以BookingSystem
类和MovieTheater
类都使用了锁。图1右边的图,展示了在BookingSystem
类的makeBooking()
方法里使用锁的方式。注意使用锁时,要用try-finally把存在并发冲突的代码包裹起来,并且在try
前面加锁,在finally
里解锁。
这些代码都是专业Java程序员用最常用的方法写出来的代码,都遵循了Java并发编程的良好实践。
但老虎也有打盹的时候,人也会有失误的时候。让我们看看程序员失误时会踩什么坑。
失误1:直接返回内部状态
一般情况下,代码经常由不同的程序员来维护。比如首先编写影院订票系统的的Java程序员,因工作调整,去做其他项目了。他之前写的代码转由另一位程序员来维护。或许因为需要方便调试代码,后来的程序员在MovieTheater
类里,增加了一个getSeats()
方法,如图2所示。
图2 失误1:直接返回内部状态
增加这个方法,虽然可以方便地获取MovieTheater
类的内部状态,便于调试。但后来其他程序员看到这里有一个getter方法,于是就在多线程代码里开始使用。在多个线程同时访问座位信息时,使用这个getter可能会出现并发安全的bug。比如,一个线程通过这个getter,获取了seats
列表的引用,并开始遍历它来查找可用座位。同时,另一个线程通过bookSeat
方法预订了一个座位。第一个线程看到的座位状态已经过时,可能会尝试预订一个实际上已被占用的座位。这有可能引发bug。
失误2:忘记加synchronized关键字
在有锁的这两个类里,有6个方法分别在6处加了锁。这6处有点多,但程序员幸好没有遗漏。可是在Booking
类的pay()
方法,程序员还真忘记加锁了,如图3所示。
图3 失误2:忘记加synchronized关键字
此处不加锁可能会导致可见性问题、重排序问题和原子性问题。为了解决这个问题,考虑到 Booking
对象可能被多个线程访问(例如,一个线程创建预订,另一个线程处理支付),可以在pay()
和isPaid()
方法前,分别添加synchronized
同步关键字,因为Booking
类的操作相对简单,不需要锁提供的高级特性,还能省掉锁所需要的样板代码,特别是try-finally
块。另外,使用synchronized
更不容易出错,因为JVM自动处理锁的释放。而在使用锁时,如果忘记在finally
块中解锁,可能导致死锁。
失误3:在锁内部调用可能长时间阻塞的操作(经常有副作用)
在实际工作场景中,维护这段Java代码的程序员换了好几拨人是很正常的事情。结果有一天,一位程序员在MovieTheater
类中,添加了一个riskyMethod()
方法,如图4所示。
图4 失误3:在锁内部调用可能长时间阻塞的操作(经常有副作用)
在这个方法里的锁内部,执行了往数据库里更新大数据集、写日志和处理复杂的交易这些很耗时且经常有副作用的任务。
副作用指一个函数或方法除了返回值之外,还对程序状态或外部世界产生了其他的影响。常见的副作用包括修改全局变量或静态变量、修改传入的参数、进行I/O操作(如文件读写、网络通信)、修改数据库和抛出异常。
在订票业务量小的时候,还没事。但到了某个电影首映日,大量用户同时访问系统。riskyMethod()
在持有锁的同时执行耗时的数据库操作,导致其他线程长时间等待,无法及时预订或取消座位,严重影响系统响应性和用户体验。
失误4:公开可变字段
又过了一段时间,一位程序员,或许是因为要方便写单元测试,结果把Booking
类的isPaid
成员变量的可变性,从private
改为public
,如图5所示。
图5 失误4:公开可变字段
这样一改,当在进行并发支付处理时,就会有并发安全的风险。比如两个客服人员同时处理同一个订单的支付。由于isPaid
字段是公开的,一个线程可能正在检查支付状态时,另一个线程直接修改了isPaid
的值。这可能导致重复支付或错误地将未支付的订单标记为已支付。
失误5:忘记加锁且在锁外部修改共享资源
假设因为某种原因,一位程序员忘记给BookingSystem
类的makeBooking()
方法加锁了,如图6所示。前面讲过,在Java里使用锁时,要用try-finally把存在并发冲突的代码包裹起来,并且在try前面加锁,在finally里解锁。但如果忘记在try前加锁,会导致线程安全性丧失、引发竞态条件并产生数据完整性问题。而如果忘记在finally里解锁,会导致死锁、资源耗尽和性能严重下降等问题。
图6 失误5:忘记加锁且在锁外部修改共享资源
Clojure避坑之法
从上面的5个失误能够看出,Java并发编程的两大坑,归根结底源于Java所默认的可变性,以及需要程序员手工显式加锁。为了编写并发安全的代码,Java程序员的认知负荷很重。必须小心翼翼。稍有不慎,就会引入难以调试的并发bug。那么有没有一种编程语言,能从根源上避免这两大坑呢?Clojure就是这样一门语言。
Clojure如何从根源上避免Java并发编程两大坑
Clojure默认数据是不可变的,这从根源上减少了大量并发bug。同时Clojure也无须像Java那样显式加锁,而是提供了无锁的原子操作,从而从根源上消除了程序员忘记加锁的失误。
什么是”默认数据是不可变的“?
在Clojure函数式编程中,“不可变”(immutable)指的是一旦创建,就不能被改变的数据。具体来说,首先创建后的数据不能被修改。如果需要"改变",实际上是创建了一个新的数据副本。其次函数不会修改输入参数,而是返回新的结果。最后在给定作用域内,符号一旦绑定数据,就不能再重新绑定数据。
这与传统的命令式编程形成对比,后者允许随意修改数据。
不可变性因为下面的原因,可以减少并发bug。首先是避免了竞态条件。多个线程不会同时修改同一数据,因为数据本身不可修改。其次是无需复杂的锁机制。因为数据不会被修改,所以不需要防止同时访问。第三是函数的行为更可预测。给定相同输入,总是产生相同输出,不受外部状态影响。最后是简化了程序推理。你可以确信一个对象的状态不会在不知情的情况下被改变。接下来让我们看看Clojure是如何实现这两点的。
要理解Clojure函数式代码,需要首先转变Java面向对象的思维模式。Java中,我们通常通过类操作其内部状态来完成任务。而在Clojure中,我们创建不可变的数据结构,并让这些数据流淌过一系列函数,每个函数基于输入,创建新的不可变数据结构,并返回,从而实现所需的功能,如图7所示。
图7 用Clojure实现的影院订票系统的数据流图
Clojure代码可以看作是数据转换的管道,其中高阶函数扮演着重要角色,增加了代码的灵活性和表达力。高阶函数是指可以接受其他函数作为参数,和/或返回函数作为结果的函数。这个特性使得函数可以被当作普通的值来传递和操作,大大增加了代码的灵活性和表达能力。比如图7中上面那几行代码中第9行map-indexed
就是一个高阶函数,它接受一个函数作为参数。这个函数应用到集合的每个元素上,同时提供元素的索引。
这种方法倾向于声明式编程,描述“做什么”而非“怎么做”。不可变性简化了并发编程,同时Clojure也提供了工具来安全地管理必要的状态变化。
理解函数式代码的关键在于把握数据的流动和转换过程,以及如何通过函数组合来构建复杂的行为。这种范式鼓励我们以数据和转换为中心来思考问题,而不是以对象和方法为中心。
图7中左侧的这张图,就是我们从影院订票系统的Clojure版的main
函数作为起点,绘制出的数据是如何在Clojure代码各个函数间流淌的图。右侧的两段代码,分别是没有副作用的纯函数get-available-seats()
和有副作用的函数make-booking!()
。注意,在Clojure中有个约定,当函数有副作用时,会在函数名末尾加个叹号。
对于失误1"直接返回内部状态",函数的封装能从根源上避免
现在我们看看Clojure是如何从根源上避免Java并发代码的5大坑的。
对于失误1"直接返回内部状态",Clojure函数的封装能从根源上避免。与影院订票系统Java版MovieTheater
类里增加getSeats()
方法直接返回内部状态最接近的Clojure的函数,是get-available-seats
函数,如图8所示。
图8 对于失误1“直接返回内部状态”,函数的封装能从根源上避免
从图8中右侧代码能够看出,Clojure 没有提供直接从函数外部访问其内部状态的机制。在 Clojure 中,函数通常被视为黑盒,它们接受输入并产生输出,而不直接暴露内部状态。get-available-seats
函数本身并不提供直接访问其内部计算过程的方法。
对于失误2"忘记加synchronized
关键字",不可变数据结构和无须显式加锁的atom能从根源上避免
对于失误2"忘记加synchronized关键字",不可变数据结构和无须显式加锁的atom能从根源上避免。在Clojure版影院订票系统中,create-movie-theater!
函数创建了一个atom来表示电影院的座位状态,create-booking-system!
函数创建了一个atom来存储预订信息,如图9所示。
图9 对于失误2“忘记加synchronized关键字”,不可变数据结构和无须显式加锁的atom能从根源上避免
这些atom的使用是线程安全的,不需要额外的锁机制。Clojure的atom提供了原子性操作,确保了在并发环境中的安全性。为何Clojure的atom不需要显式加锁?这是因为Clojure的atom实现了一种无锁的并发机制,称为"比较并交换"(Compare-and-Swap,CAS)。Clojure atom的工作原理是这样的:首先,atom存储了一个不可变的值。其次,当你想要更新atom的值时,你提供一个函数来计算新的值。第三,atom会尝试用这个新值替换旧值,但在替换之前,它会检查当前值是否还是你开始计算时的那个值。如果值没有改变,那么替换并更新成功。如果值已经被其他线程改变了,atom会重试整个过程。这种并发机制能用不加锁的方式实现原子操作。
对于失误3"在锁内部调用可能长时间阻塞的操作",不可变数据结构和无须显式加锁的atom能从根源上避免
对于失误3"在锁内部调用可能长时间阻塞的操作",不可变数据结构和无须显式加锁的atom能从根源上避免。原因也是因为不需要锁。另外,函数式编程鼓励将副作用分离,减少了在关键部分执行长时间操作的可能性,如图10所示。
图10 对于失误3"在锁内部调用可能长时间阻塞的操作",不可变数据结构和无须显式加锁的atom能从根源上避免
对于失误4"公开可变字段",函数的封装和不可变数据结构能从根源上避免
对于失误4"公开可变字段",函数的封装和不可变数据结构能从根源上避免。如前所述,Clojure 没有提供直接从函数外部访问其内部状态的机制,所以无法公开函数内部状态。此外,Clojure代码中Booking
这个record数据结构,是不可变的。即使要修改它的实例,也需要创建新的实例,而不是直接修改它,如图11所示。
图11 对于失误4"公开可变字段",函数的封装和不可变数据结构能从根源上避免
对于失误5"忘记加锁且在锁外部修改共享资源",不可变数据结构和无须显式加锁的atom能从根源上避免
对于失误5"忘记加锁且在锁外部修改共享资源",不可变数据结构和无须显式加锁的atom能从根源上避免。原因也是因为不需要锁。上面已经介绍了无须加锁进行原子操作的compare-and-set!
函数的工作原理。图右侧代码展示了与compare-and-set!
函数在工作原理上很相似的swap!
函数。两者除了在抽象级别上有差异外,其他都十分相似。swap!
是一个高级抽象,它自带了重试逻辑。而compare-and-set!
是一个低级操作,需要用户自己处理重试逻辑,如图12所示。
图12 对于失误5"忘记加锁且在锁外部修改共享资源",不可变数据结构和无须显式加锁的atom能从根源上避免
Java实现无锁原子性更新List面临的挑战
有人可能会问:“Clojure版本中第5行那个atom of boolean vector的:theater和第34行那个atom of Booking vector的:bookings,都使用了无锁的atom。既然Java提供的原子类(如AtomicReference
),能支持compare-and-set操作,为何Java不能像Clojure那样实现涉及ArrayList的无锁机制?我知道ArrayList
是线程不安全的,那Java能用线程安全的Vector
实现相似的无锁机制吗?”
Java如果想实现一个无锁的原子性更新ArrayList
,面临的最大挑战,是原子性操作局限。Java的AtomicReference
只能原子更新单个引用,无法直接原子更新复合对象如List<Boolean>
。虽然Vector
的单个方法是原子的,但多个操作的组合并不能保证原子性。
快速上手Clojure
看到这里,你是不是已经跃跃欲试,想快速上手Clojure了呢?
不要担心Clojure的语法,它其实极其精简。我把80%的语法浓缩成了下面短短的三行。只要记住这三条,你就掌握了大部分Clojure语法。
Clojure是一种运行在Java虚拟机(JVM)上的函数式编程语言。它强调不可变数据、高阶函数和惰性计算。
不可变数据和高阶函数前面都介绍过了。惰性计算是一种评估策略。在这种策略中,表达式的计算会被推迟到真正需要其结果的时候。这种方法可以避免不必要的计算,提高性能,并允许处理理论上无限大的数据结构。比如Clojure版的影院订票系统中的 get-available-seats
函数,使用了 map-indexed
和 remove
,这两个都是返回惰性序列的函数。这意味着直到实际需要结果时,这些操作才会被执行。
Clojure使用括号()来定义表达式和函数调用,且使用前缀表示法,例如,(+ 1 2)表示将1和2相加。函数总是放在第一位,后面跟着参数。例如,(println “Hello, world!”)。
def和defn分别用于定义变量和函数。let用于在局部范围内定义符号和数据结构的绑定。
那还有20%的语法怎么办?我的方法是用最喜欢的生成式AI,帮你逐行解释我在github上分享的那93行Clojure代码。遇到看不明白的地方,最权威的解释还得去查看Clojure官网的API参考文档中讲的语法。等你搞懂了这93行代码,再回过头来看Clojure是如何避开那5个并发编程失误的,我保证你一定会恍然大悟。学完之后,你就算入门了。
学编程语言,尤其是学一门新范式的编程语言,“怕踩坑"的心理能持续驱动你的学习动力。因为这种损失厌恶的心理,能让你难以忘记新语言所避免的旧语言的坑。而"单纯被利益吸引"的心理,则往往让你"学得快,忘得也快”。这就是我今天想跟大家分享的"揭秘:如何用’怕踩坑’的心理快速掌握Clojure成为函数式编程达人"的核心思想。希望大家都能尽快上手Clojure,掌握函数式编程,做一个不怕并发坑的Clojure达人。