软件设计杂谈——开闭原则
如何在老代码上添加新的功能?这是个对程序猿来说最常见不过的问题了,一般有两个思路来应对。
- 直接在老代码上一通魔改,实现新的功能。
- 老的不动,把原来的代码像三明治一样夹起来,在外层实现新功能,实在做不到再改老代码。
开闭原则
这两种思路都很简单粗暴,但第一种想法是不太正确的,第二种是更好的。为什么要保持原代码尽量少改呢?
软件工程中有一个非常重要的原则——开闭原则:对扩展开放,对修改关闭。
开闭原则也是OOP理论中5个原则之一(分别是S O L I D,单一职责原则、开闭原则、里氏替换、接口隔离、依赖反转),是前辈们总结出的软件开发工程化的经验。
简单类比一下:要建一堵白墙,不是应该想方设法把砖头做成白色的,而是应该在砖墙外刷一层腻子粉。不然下次想它变成蓝色的墙,还要把墙砸了换蓝色的砖头,如果用添加封装层的思维,只需要再刷一层蓝色的乳胶漆就可以了。
代码腐化之谜
曾有一篇文章写架构是如何腐化的,其实代码亦然,代码的腐化最终表现为架构的腐化。
上节说的第一种直接魔改老代码的思路,是有很多危害的,比如一通魔改让原来的函数产生了其他副作用的话,很容易引入BUG,修复BUG时又有概率引入新的BUG,而一般修BUG的代码都不会优雅。函数式编程推崇无状态无副作用的纯函数的组合来实现功能,其中一方面的原因在于,不可变(Immutable )和显式(Explicit)的数据和逻辑更加健壮,即使出现问题也更容易定位。
然而,很多时候第一种方式更省事,短期来看又不会有什么大问题,大部分程序猿都不会多费一些脑子想怎么用第二种思路做,于是日积月累,代码中的坏味道就越发明显,留下的技术债越来越多。“债务”这种东西时间越久威力越大。最终,软件项目变成了传说中没有人愿意维护的shi山。
千里之堤,溃于蚁穴。
减缓代码腐化之道
软件也有生命周期,像人一样会“生老病死”,热力学第二定律告诉我们一切事物都会往熵增的方向发展,只有引入“负熵”才能维持局部系统的持续稳定。而软件系统中可以引入的“负熵”,一方面是重构,持续重构现有的代码实现和整体架构;另一方面,在实现新的需求时,遵循开闭原则避免老代码的加速腐化。
具体来看,在修改代码时第二种符合开闭原则的思路(暂且称之为"夹三明治法"),在实现时有不同的表现形式:
- 修改参数时:利用函数重载或类似的机制,达到无需修改原代码调用方的目的;
- 增强功能时:利用多态性或合适的设计模式来设计编写新的代码,达到基本不用改原有代码的目的。
不少设计模式的目的之一就是为了优雅地隔离出可能变化的地方,防止我们在变化发生时改动原有代码的,比如:代理模式,装饰者模式,职责链模式,策略模式,适配器模式等等都属于这一类。
其中代理模式的变种——AOP,在实际应用中很广泛,本质上也是这种“夹三明治”的思维方式。
如果添加的功能是与老代码关系不大的功能,又不想每个调用的地方都改,比如监控统计方法执行时间,添加数据库事务控制等等。这时“三明治”的外层就可以起个名字叫“面向切面编程(AOP)”了。像Java提供了注解和反射等机制,搭配JDK或Cglib的动态代理能力,可以实现在类似三明治模式的切面编程。而其他的语言,只要函数是"一等公民",实现AOP是非常轻松的,比如下面这个最简化的JavaScript的例子,可以在不动原有的函数 a() 的情况下,给所有调用 a 的地方无感添加额外的逻辑,但这样用时最好不要在切面中引入影响原函数的副作用。
let a = function () { /* something */ }
const _a = a
a = function () {
// do other things
return _a.apply(this, arguments)
}
开闭原则的局限性
在单体系统中,即使一直遵循开闭原则,一直重构现有的实现,也难以保障在越来越复杂的需求、不断扩大的团队中仍然可以减缓代码的腐化。
大型单体软件就像大型动物,维持稳定状态需要的负熵远大于小型动物。康威定律告诉我们,软件的结构与团队组织结构应当是一致的。当团队随着软件的复杂化而扩大时,将大型单体软件拆分成独立的小型子系统,每个小团队维护一个小型子系统,维持每个子系统的稳定所需要的“负熵”相对较小,即使总体上需要的“负熵”更多了,但沟通成本更低,小团队协作起来更加容易。这与上一篇提到的Scale-Cube的Y轴是一个意思,而单体系统的拆分要怎么进行下去,又是一个很庞大的话题,以后再写吧。