如何跟老代码友好相处
旅游了一天,睡觉前刚好有点时间,就上来写点东西。后面还有将近一个星期的路程。在Office工作也已经超过两年了。尽管Office是微软C++新标准最有力的推手之一,我在这里面学到的东西其实跟语言倒没什么关系,主要还是跟老代码(legacy code)相关的事情。@Hush 曾经安利过我一本《Working Efficiency with Legacy Code》,不过我还没看,你们有兴趣可以去读一读。
面对legacy code的情况有很多,不仅仅在工作中会遇到一些198x年写的代码跟2016年写的代码混在一起的情况,哪怕是开发自己的GacUI也会有。尽管GacUI公开立项是在C++11发布之前不久,但是实际上整份代码是在我读大学的时候造各种轮子的时候,慢慢组合起来的。现在在注释里面还能看到类似Vczh Library ++ 3.0的字样,那1.0是什么呢?
Borland是在我上大一的时候把Delphi卖掉的,我也是差不多在那个时候第一次感觉到了自己学到的东西好像突然就没有价值了,于是趁这个机会全方面转向C++。第一个任务自然就是要把我以前写Delphi的时候积累下来的轮子用C++重写一遍,那就是Vczh Library++ 1.0。++的意思就是这是C++写的,怀念Delphi。后来慢慢的一边修改一遍重构一边删除各种东西,因此我自己所有的C++个人项目都是围绕着这个库来开发的。从这个角度来看,GacUI的一部分代码算起来也有十年之久了,这个年龄其实已经超过了大家工作的时候能遇到的大部分项目的年龄了。所以在这里介绍的经验,对大部分的人都应该是合适的。
Legacy code造成的最大的问题是什么?其实就是最新的best practice和标准,与过去的开发经验的矛盾。这是面对legacy code开发的时候,遇到的的主要矛盾。这个问题在Office尤为明显。GacUI嘛,也只有十几万行。只要我哪天中了彩票,我可以辞职在家里从头优化,花个一年还是能够把所有的东西都改成最好的。至于Office,哪怕你让全球的办公软件开发商停下来等你,好让你把所有的代码都翻新一遍改成最好的,也是一件不可能的事情。
Office客户端的一个版本的代码(不包括分支也不包括历史),拉下来所有的文件就有300多G。这里面有差不多20-30G其实是所有平台的编译器和SDK,还有一些全球语言的字符串和配置,还有一些图标和测试,剩下的占了大部分内容的都是代码。Office现在有很多千人在做,30多年通过不断的收购以及打字,最终创造了这么多代码。平均每个人要负责的代码就有超过30M那么多(是GacUI的十几倍)。要全部翻新一遍,量子计算机应该也普及了。所以首先我们要明白的事情就是,用最新的标准来要求程序员产出的代码是不可能的。哪怕是新的代码,只要这些东西跟古老的部分有一点关系,你做起来就会更加困难。那落实到具体应该怎么做呢?实际上最合适的办法就是,当你在修改哪一个年代的代码的时候,就按照那个时候的要求,也就是整份代码的风格来写。
其次就是重构。前辈们的经验告诉我们,重构最大的好处就是,通过现在多花一点时间,来节省未来无穷多的时间。那节省的时间到底是什么呢?其实就是新的需求跟就的架构的矛盾带来的开发效率的降低。你为了现在的需求做了一个设计,很好的满足了需要,架构弄好了之后业务逻辑写出来特别的快。但是需求总是会变更的,总有一天你的架构就会成为落后的架构,在上面实现新的需求就会变得很困难,开发效率就降低了。在我们总是希望软件的生命无限延续的前提下,我们需要适当地做一些重构,来满足现在或者短期的快速开发业务逻辑的需要。
举个很简单的例子,如果我们在命令行里面打印一个菜单,按下数字键就可以做一些不同的事情,那当软件刚刚诞生,里面的东西还不多的时候,我们会直接的使用if(input == 1) { … } else … 的方法来写。后来你加进去的东西越来越多,你会发现if的那些代码就总是重复,所以有一天你改成了switch(input) { case 1: … break; …}。再后来,你发现由于业务逻辑的变化,这个菜单开始有增删改的要求,那你总不能每次拿掉一个东西就把所有的case重新修正一遍吧?这个时候就会开始使用函数指针数组,在main函数里面初始化之后,input就可以直接当下标。后来这个软件中遇到了更加复杂的需求,菜单开始不是线性的了,你可能也就即将开始感觉到一个UI库的重要性,慢慢的就引进了各种设计模式。软件的迭代从宏观上来看,道理也是一样的。
但是面对legacy code的重构有其独特的难点。一个持续进化软件的legacy code很legacy,通常也就意味着这个软件也不小,那你重构的时候需要处理的地方就非常多。你这项工作可能要持续半年,在这半年里面你又不能push,因为重构了一半的代码多半是跑不起来的。别人也不可能停下来等你重构,所以会在旧的架构的基础上不断地添加新东西,那么你需要处理的事情就会越来越多,直到爆炸。这也是很多古老的软件无法进行任何重构的重要原因之一。
但是这个问题并不是无法解决的。在Office里面有三种风格的重构。
第一种就是靠一个牛逼的人,就是可以迅速结束战斗,同时一个change上去感染了几千个C++代码文件,上去还能跑。遇到这样的人只能每天路过办公室门口的时候进行膜拜。我就有幸目睹了一个principal的毛子干了这样的事情,因为之前一直都有合作,觉得真是太伟大了。
第二种就是让大家一起来。你开一个branch,把基础的东西弄好,然后让每一个人都在自己的工作之余加入到你的重构工作来,其实也就是把他们自己的组件的代码改成兼容你的新架构的。等到所有的组件都翻新过后,最后让大家一起再解决一遍pull request里面的conflict。
第三种就是,在旧的库的旁边写一个新的库,然后只要你库的对象不是在整个系统里到处传播的,那么你总是可以一个一个文件慢慢地把#include换掉,把代码改成兼容新库的形式。这样在后面修改这个文件的人自然也就被迫使用你的新库了。一直到所有对老库的#include都消失了,把老的删掉,重构工作就完成了。这样做的好处是你的新代码是不断地push给大家的,不会有merge的噩梦出现。
但是重构也不是万能的。因为毕竟一个架构如果没有影响到你的开发效率的话,为什么要去重构他呢?做这个很容易就变成过度设计了。这在GacUI的身上就很明显。大家可能会发现,我在知乎上说C++新标准下面应该如何如何做的时候,GacUI出现的却总是那些过时的方法。其实一个很重要的原因就是,事情还没发展到我非翻新旧代码不可的时候。
举个例子,C++11说你们可以用shared_ptr、weak_ptr和unique_ptr来表达不同对象的生命周期,从而最大程度的避免对裸指针的使用(避免粗心用错)。但是GacUI仍然大量使用Ptr<T>和裸指针。其实原因有三个。
第一个就是,代码是旧的,在这套东西还没反映在标准里面的时候,Ptr<T>已经被广泛使用了。那现在我要不要再添加新功能的时候,使用shared_ptr,让系统里面同时出现两套智能指针呢?这当然是不行的,因为智能指针有自己管理引用计数的方法,不同的智能指针几乎是不可能混用的。
第二个就是,既然如此,为什么我不把Ptr<T>删掉直接全部换成shared_ptr?这就反映了上面粗体的内容。Ptr<T>换成shared_ptr真的就能提高开发效率嘛?如果GacUI是由很多个人写的,那这个很难说,毕竟不是所有人都知道Ptr<T>的各方面细节。但是我作为GacUI的“几乎”唯一的作者,我对代码的所有方面都有无限的了解,我用裸指针也很少犯错误,所以shared_ptr的好处仍然不值得我做一次全文替换。
第三个就是,其实Ptr<T>还有shared_ptr所没有的功能。GacUI的脚本引擎支持脚本创建新的类,这个新类可以继承自若干个C++里面的类,新类还能被反射出来使用。这就在实际上造成了,在内存布局上面,新类其实就是N个对象组成的,N-1个C++里面的类,还有一个脚本创建的模拟的类。那么这几个类的实例其实应该共享同一个引用计数的指针。不然父类实例用了一半,子类实例被早早析构了,这就尴尬了。
大家可以发现,用新的智能指针实现这个东西的方法就是,脚本创建的类去unique_ptr所有的父类,然后dynamic_cast其实就是去获得一个内部用shared_ptr装着子类的、父类接口的shared_ptr或unique_ptr,实现就是把虚函数redirect到父类那里去。当然这样你的父类就要求全部都写成接口(COM就是这么干的)。看起来很别扭是不是?因为C++这一套东西对这个场景其实不能很好的表达。
但是反过来,我可以很轻易地通过修改Ptr<T>、可以被脚本继承的类的基类DescriptableObject,加上通过SFINAE来让Ptr<T>面对普通类型的时候使用普通的实现,来轻松的做到这一点。要换成shared_ptr就要变成另一套做法了,重构难度还是挺大,超过了对以后开发效率的改善。COM的Aggregation是对相同的问题的另一套做法,我不小心重新发明了一次。
类似的内容还有很多,C++11出了for(auto x : xs){ … },然而我所有的地方还是用FOREACH(X, x, xs){ … }宏。C++17即将就要有range了,而我却有老早就为了弥补range不存在,#include <algorithm>里面的东西组合起来又太难而山寨的Linq。C++11有了T&&之后,容器就变得非常好用。但是我自己需要的容器不多,加上这个T&&的支持也不难,所以我最终也没有把自己的类换成STL。给定新标准下一个支持X&&够早的X类,一个返回X类型的属性的setter最好的写法是SetX(X x),不是X&&x,也不是const X& x,也不是两个都有。但是其实很多地方我也没换。因为这些东西的替换实际上都无法带来什么显著的好处,所以干脆就留着了。
剩下的还有很多琐碎的地方我也就不一一提到了,10年前的一些次要组件现在看起来可能会发现代码里有各种问题,但是反正已经写好了,改得更好用起来也不会更方便,干脆就放着不管了。
GacUI就几乎只有我一个人在写,添加新feature赶紧做完,好腾出手来造新的轮子,才是第一目标。代码是否符合最新的C++规范,那是其次。这放在很多商业软件上也是成立的。我的理由是由于我想造新轮子所以要赶紧把GacUI做完,商业软件为了生存下去给自己员工涨工资要迅速发布和迭代产品,这两个理由其实是等价的。
回到Office里面,其实也会经常遇到类似的问题。三个不同的古老的组件,使用了收购回来的时候内部就已经有的三个字符串类。有一天我要把他们整合到一起,怎么办?其实最经济的方法就是,把他们都严严实实地封装进自己的接口里面,别人不要去碰他们,只有我来碰。那我内部最多也就是多写几个恶心的字符串转换的代码而已。隔绝的好处就是,落后的组件的实现,通过我改头换面之后,对别人的伤害降到了最低。因此我也没有必要去重构他们了。这样就使用最短的时间,在保证质量的前提下,写出了不会降低别人开发效率的代码。
重构是要看成本的。当然反过来,哪怕是一个重构很难,规模很大,但是他创造的效益更大的话,就值得你去说服大老板让你去做。
在讲完了重构之后,剩下的一个大问题就是,那添加新的代码怎么办?其实这也是要按照相同的标准去做的。你添加新的代码,是否会因为旧的架构的影响,让使用你的新代码进行二次开发的人的开发效率被降低?如果你认为答案是“会”,这可能就是一个重构的信号,你要先重构,然后再加新代码。否则,你就按照跟原有的部分兼容的方法去写就好了。当然了,如果情况允许的话,你也可以通过上面讲到的第三种重构的方法,先从你的新代码开始,推广新的架构。等整个系统的每一个角落都被你翻新了之后,旧的架构就删掉了,你就在完成了一次重构的同时,要加的新feature早就上线了,不会影响到release的日期。
总的来说,为什么会有这些准则?其实根本的原因是,修改代码会造成regression,如果你测试的覆盖不够,重构也会引发大量的问题(这就是为什么重构跟测试是相辅相成的,少了一个都不行)。老的代码没有重构的必要就不要重构,你的整体工作量也就大大降低了,同时保证了软件可以被release。
一个刚刚加入工作的程序员,或者是一个学生,可能在遇到类似的问题的时候都比较激进,然后就会被事实教做人。其实这都是因为年轻人眼界不够高,没办法在全局观上看到很多事情背后的cost。所以如果你恰好加入了一个古老的软件的项目组,不要对旧的东西产生抵触,就是一个良好的开始。
本文文字及图片出自 zhuanlan.zhihu.com
你也许感兴趣的:
- 如何处理前任程序员留下的代码
- 十年的老代码,你敢动?
- 如何看待自己写的烂代码
- 【外评】电脑从哪里获取时间?
- 【外评】为什么 Stack Overflow 正在消失?
- Android 全力押注 Rust,Linux 却在原地踏步?谷歌:用 Rust 重写固件太简单了!
- 【外评】哪些开源项目被广泛使用,但仅由少数人维护?
- 【外评】好的重构与不好的重构
- C 语言老将从中作梗,Rust for Linux 项目内讧升级!核心维护者愤然离职:不受尊重、热情被消耗光
- 【外评】代码审查反模式
你对本文的反应是: