有一类Bug是很让人头疼的,就是你的代码怎么看都没问题,可是运行起来就是出问题了。
某程序库是其他人封装的,我只是拿来用。按理我调用这个函数逻辑也不复杂,不应该有问题。
为快定位问题,还是打开了这个程序库源码。发现底层实现中,出现全局变量。
在我的代码执行过程中,有别的程序会调用另外函数,修改这个全局变量,导致程序执行失败。
表面看,我调用的这个函数和另外那个函数没关系,但它们却通过一个底层全局变量,相互影响。
有人认为这是全局变量使用不当,所以在Java设计中,甚至取消了全局变量,但类似问题并未减少,只是以不同面貌展现,比如,static 变量。
这类问题真正原因还是在于变量可变。
变量的危害
变量不就应该是变的吗?
先上代码:
但并发环境下有 bug。正确写法修正如下:
区别在于,SimpleDateFormat在哪里构建:
-
被当作一个字段
-
在方法内部
不同做法根本差异在于SimpleDateFormat 对象是否共享。
为什么对象共享会有问题?
查看源码:
calendar是SimpleDateFormat类的一个字段
在执行format过程中修改了calendar,导致 bug。
-
A线程把变量值修改成自己需要的值
-
此时线程切换,B线程开始执行,将变量值修改成它需要值
-
线程切换回来,A继续执行,但此时变量已不是原来A自己所设值,所以,执行出错
对于SimpleDateFormat,calendar就是那个共享变量:一个线程刚设置的值,可能会被另一个线程修改,导致意外惊喜。
而Test2中,每次创建一个新SDF对象,避免了线程共享,bug解决。
若就爱Test1写法,SDF该如何改写呢?
SDF作者水平太次,换我写,就给它加synchronized或Lock锁,但你轻易地引入多线程的复杂性。多线程是另外一个关注点,能不用就不用。
推荐方案:将calendar变成局部变量,从根本解决线程共享变量问题。
这类问题在函数式编程中却几乎不可能存在,因函数式编程的不变性。
不变性
函数式编程的不变性主要体现在:
- 值
可理解为一个初始化之后就不再改变的量,即当你使用一个值时,值不会变
很多人开始无界:初始化后不会改变的“值”,这不就是常量吗?注意,常量一般是预先确定的,而值是在运行过程中生成的。
- 纯函数
对于相同的输入,给出相同的输出;没有副作用
二者结合:
-
值保证不会显式改变一个量
-
纯函数保证不会隐式改变一个量
函数式编程中的函数源自数学的函数。当前语境下,函数就是纯函数,一个函数计算后不会产生额外改变,而函数中用到的一个个量就是值,它们是不会随着计算改变的。
所以,在函数式编程中,计算天然不变。
因为不变性,所以前面的问题也就不复存在:
-
若你拿到一个量,这次的值是1,下一次它还是1,无需担心它会改变
-
调用一个函数,传进去同样的参数,它保证给出同样的结果,行为是完全可以预期的,不会碰触到其他部分。即便是在多线程下,也无需担心同步问题
传统方式的基础是面向内存单元,任性的改来改去已成为大多程序员的本能。所以习惯如下代码
counter = counter + 1
传统的编程方式占优的地方是执行效率,而如今,这优点则越来越不明显,反而因为到处都可变,带来更多问题。
我们更该在设计中,借鉴函数式编程,把不变性更多应用在业务代码中。
怎么应用呢?
值
可以编写不变类,即对象一旦构造出来就不能改变,最常见的不变类就是String类了,
如何编写一个不变类呢?
-
所有的字段只在构造器初始化
-
所有的方法都是纯函数
-
若需要改变,返回一个新对象,而非修改已有字段
如String#replace方法,会用一个新字符(newChar)替换掉这个字符串中原字符(oldChar),但并非直接修改已有字符串,而是创建一个新字符串对象返回。好处是,使用原来这个字符串的类无需担心自己引用的内容会随之变化。
理解这个,你就更理解 DDD 里的值对象(Value Object)是啥了。
纯函数
写纯函数的关键:
-
不修改任何字段
-
不调用修改字段内容的方法
Java并非严格函数式编程语言,不是所有量都是值。
所以在实用性角度,可以实践:
-
若要使用变量,就使用局部变量
-
使用语法中不变的修饰符
多用final。无论是修饰变量、方法,都是让编译器提醒你,要多在不变性上设计
拥有不变性编程思维后,你会发现很多习惯都让你的代码陷入水深火热,比如你最爱的setter就是提供了一个接口,专门修改一个对象内部的值。
但要承认现实,纯粹函数式编程很难,只能把编程原则设定为尽可能编写不变类和纯函数。但你依然能套在大量现存业务代码上。
大多数涉及可变或副作用的代码,应该都是与外部系统交互。能够把大多数代码写成不变的,的确能减少许多后期维护成本。
正由于不变性,有些新语言默认不再是变量,而是值。比如,Rust声明的是个值,一旦初始化,就无法修改:
let result = 1;
而若你想声明一个变量,必须显式告诉编译器:
let mut result = 1;
Java的Valhalla 项目也在尝试将值类型引入语言。所以,不变性,真的是减少程序问题的发展趋势。
事件溯源
事件源:不要轻易添加「状态」,取而代之的是通过事件源(通过事件的发生时间,去重建历史的对象及对应关系),我觉得这本质上是给实体模型赋予不变性,从而消除因为状态变化而引发的副作用。
不变性,也是诸多编程原则背后的原则。例如,基于「不变性」这样一个目标,领悟驱动设计中的「值对象」 做法(定义一个不变的对对象,用于标识实体之外的其他业务模型),以及马丁.福勒提出的「无副作用方法」(side-effect-free function,指代方法不会对对象状态产生任何改变) 等,就都显得非常恰如其分了。
更极端的如 Rust ,直接让不变性成为语法语汇,有人评价这是一种把道德规范引入法律的做法,觉得这种类比有一定道理。然而在语言层面,至少倒逼程序员产出不那么坏的代码。
对比一般的CRUD,就是没有修改,只有不断的插入值不同的同一条记录,下次修改时,在最新一条基础上修改值后再插入一条最新的。有点类似Java String 的处理方式,修改是生成另一个对象。
总结
函数式编程,限制使用赋值语句,它是对程序中的赋值施加了约束。一旦初始化好一个量,就不要随便给它赋值了。
函数式编程相关的各种说法:无副作用、无状态、引用透明等,都是在说不变性。
从Effect Java中学到了builder模式,实践DDD,也应多考虑不变性:
比如修改用户信息,业务逻辑提取入参数据,返回值是通过builder构造一个新对象;builder中有完整性校验;这样可保证经过业务逻辑处理后返回的对象一定是一个新的并且是符合业务完整性的领域对象。
变化是需求层面的不得已,不变是代码层面的努力控制。
最后,请尽量编写不变类和纯函数。