(点击上方公众号,可快速关注)
从事计算机软件行业,或多或少都经历过一些难以言表的脏乱差工程,这篇文章说的是这些脏乱差工程的直接表现,以及简单的原因分析和解决方案。由于这是一个非常大的话题,本篇文章侧重于我经历过的项目,力求篇幅不要太大。
背景
“脏乱差”是一个主观色彩比较浓重的词,每个人的标准都不太一样。下文是以我的视角所展开的一些阐述,但应该大部分是共识,谁让我们都是喜爱穿格子衫的人呢。
我想每个人都接手过前人留下的遗产工程,如果你运气好的话,这些工程可读性好、扩展性好,你能快速地上手并加以迭代,最后能顺利得过渡到自己的框架体系下。但从我有限的经历看,这是颇具运气的事,因为我所接手的工程基本上清一色的脏乱差的代码,维护成本超高。这样的代码所造成的最大的影响是:时间成本巨大,一个简单的三五分钟的修改,需要反反复复的验证,这样的验证不止于代码阅读,大多数情况下还要写各种不同的测试用例验证不同情况下的输出。这套流程下来,有时候甚至几天,这真不是危言耸听。这既给公司造成了浪费,也让个人的成长缺乏成就感和明显的绩效。
如果你遇到这种情况,能怎么处理呢,要么放弃这个烂摊子(这往往不是你能决定的),要么开始长达数月的重构之旅。你不重构,这个问题会反反复复地骚扰你。本文不阐述重构的技巧,具体重构的技术、技巧可以阅读《重构:改善既有代码的设计》这本书。
再多说一句,为什么我们要重构代码呢?因为不管这些代码长得啥样,但至少是身经百战能稳定运行的,所以要尽可能复用现有代码,并在现有代码上迭代,这种迭代方式是软件工程实践用时间证明比较可行的方案。而且,近些年的IT环境跟传统的开发环境不太一样,传统的产品开发,如果产品死了,代码就死了,基本没有重构的必要。现在的行业环境比前些年前进了不少,代码能复用就复用,甚至有些很小的团队很短时间就能出一款产品,这都是因代码复用的功劳。所以,我们不得不重构。
“脏乱差”的代码都长什么样呢,以及能从中得到什么启示呢?
表现及分析
具体的软件项目涉及方方面面,但项目立足于工程代码,而脏乱差的工程代码直接让开发者望而却步,进而影响各方面工作的进度。我接触到脏乱差的工程代码,主要涉及:
封装差
第一眼看到代码的感觉,形象点,就是洋洋洒洒一大篇,跟医生的开的处方似的,看的云里雾里。
现实是复杂的,处理现实需求的代码也应该是复杂的。这没什么问题,但代码的主逻辑应该是清晰的,举一个好搞笑的例子,宋丹丹的小品:怎么把大象放冰箱?总共分三步:
把冰箱门打开
把大象放进去
把冰箱门带上
这三步构成了需求的主逻辑,非常清晰可见,任谁都能看懂并吸收。其他的,如,怎么让大象听你的话,用多少人打开和关闭冰箱门等等,都是细节,应该封装起来。如果把这些步骤混到主逻辑里去,任谁都看得不明白。我平时接触的代码之所以难读,很大一部分原因属于这类,各种较低层次的细节遍布各处,绕来绕去,实在不堪其烦。
这类问题的解决方案就两个字,封装。把细节封装,使用抽象数据类型把数据结构的细节隐藏起来。
风格各异
每个人的风格肯定是不一样的,所以必须要有规范约束每个人的自由发挥。网上已经有一些不少的公司贴出了自家的规范,比如阿里的Java编码规范,google 的C++规范等,都值得参考。当然,如果你们公司有自己的规范,尽量遵循自己的标准。在这里我提几个我认为比较常见且要规范的地方:
标识符的命名规则
实际上,现在我这边已经混乱了。早先的代码风格是匈牙利命名法(我之前写文章讨论过其缺点,可以参考这里),随后又有Linux C的命名规则(单词之间使用短横线分隔),后来又有驼峰的加入,现在真是百花齐放。
命名规则还是尽量要统一,否则读代码是不畅快的。
函数声明的风格(Unix vs Windows)
对比一下创建文件的系统api,Unix和Windows下函数声明的样子:
// Unix int creat(const char *filename, mode_t mode); // Win32 HANDLE CreateFileA( LPCSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile );
看到没,Unix下函数声明短小简明,最直接的收益就是可读和可理解性较高,使用起来很方便。而Win32的函数又臭又长,想把所有参数搞清楚得费很大的劲。上面的例子反映的是一种常态,大家可以随便找几例对比一下。
我接触的代码中,也有这种又臭又长的函数声明,10几个参数的函数也很常见, 很难搞明白每个参数的用途。很多参数是指针类型,不同的调用情况,会将相应的参数置为
NULL
,真是够复杂的。从心理学上讲,人的短期记忆的点的上限是[7-2,7+2]个,10几个参数肯定是不利于记忆的,所以不要把函数参数整那么多,真的不好记、不好理解,要采用Unix下的风格。
注释的风格
这也很重要。很多公司并没有就这块做出具体规定,导致群魔乱舞。先讲几个坏的例子:
代码几乎每行都要注释(这是做翻译吗?)
代码的每次更改的地方都加上时间、作者信息,跟盖楼似的,快超过珠穆朗玛峰了(这是搞注释竞赛吗?)
代码跟注释不一致(这是典型的心口不一,很常见)
我要表达的核心意思是:尽可能不要注释,用清晰的代码诉说你的意图。这个论点的支撑有以下几点:
注释是额外的纯体力劳动,而且没有工具进行验证,很难保证与代码的一致,这块的维护是很难的,所以越少越好。
过多依赖注释,会降低代码的质量,比如命名的准确性,反而不利于代码的维护。
之所以强调“尽可能”,也得具体情况具体分析,有些事项的确需要做注解,比如紧急项目的凑活代码,但很多都是需要后期处理的,这不与我的观点违背。再说一下上面谈到的第二个例子,这种盖楼我真的觉得没必要,当功能已稳定,这些注解基本没有存在的必要,反而存在本身会带来困扰。而且,现在很多版本控制工具,比如,git都已经能跟踪代码的变化的时间和作者这些信息了,就不要做这种人力维护的事了。
代码冗余严重
冗余的代码给维护设置了一道很高的门槛。比较常见的冗余有两类:
代码copy
甚至出现过同一个项目存在多份完全一样的代码,而且都在调用。要修改这些函数的行为,需要所有地都要改,无疑中增加了工作量,最要命的是容易忘。
所以,代码要力求一份。如果同一个工程多份,要删掉多余的拷贝;多个工程共用,最好建一个共享的项目。
相似功能的函数存在多份
利用函数重载的情况下,同一个名字的函数很多,而且都很长。不利用函数重载的情况下,各种后缀形式的函数争奇斗艳,比如
fun_1
,这类以数字编号的,fun_ex
这类以英文缩写标记的,还有不少是以日期后缀的,很多很多。函数重载是多数情况下是必要的,但不要每个函数都是一份完整的实现。采取的原则应该是:核心代码只有一份,其他代码只是对于核心代码调用形式的不同。这个核心函数,可以称之为代理函数。C++11 以后引入的代理构造函数是一个道理。
不使用标准库
很多公司都有自造的轮子来替代标准库。为什么这么做呢,我们来探讨一下。我觉得主要原因是早些年C++演进的速度非常之慢,从C++98到C++11经历了13年,那时候标准库非常薄弱,而C++标准委员会又无所作为,很多公司的轮子差不多是这时开始造的。
当然,这些造轮子的公司也都有自己的说辞,比如,可控性强,因为是自家实现的嘛;性能高,毕竟是自家实现的嘛。。。但现如今这些理由都已经站不住脚了,目前基本上三大编译器的标准库都已经开源了,跟自家的没两样;而且标准库采用了很多优化,真的性能很高;而且,很多编译器对标准库的调试过程中内部结构的查看等支持真的很好,自家的轮子是没有这个待遇的。标准库是同一编程语言的开发者之间的共同语言,可以有效复用以前学习的知识和经验,这是标准库最大的贡献。
所以,要尽可能使用标准库。这些年C++已经改变的很快了,每个版本标准库也在扩充,所以条件允许的话,把编译器升级到最新,把标准库用好。说句题外话,用最新的编译器的好处是新版本的二进制优化更好,曾经我测试一个项目,将vs 2012的编译升级到vs 2017,效率整整提高了40%。
关于RAII
RAII (Resource Acquisition Is Initialization),也称为“资源获取就是初始化”,是C++语言的一种管理资源的惯用法,常用于内存资源、网络连接、文件、数据库等资源的管理。
如果你见过每个if分支里都有关于所申请资源的释放代码,你一定要用RAII技术改造一下,这能大大简化你的代码逻辑,并降低内存泄露的可能性。RAII本质上是通过一个类型自动地管理资源,这是C++相比于C的最大优势之一。
当然在C中也是可以使用goto语句达到类似的效果,但与RAII对比,RAII更安全,更可读。
关于状态
关于状态,我更倾向于无状态的函数,这仅仅是个人倾向,这里简单阐述我的一些看法,不同意也没关系,因为一般情况下不同风格不会带来太大的问题。
无状态的函数基本继承了纯函数的一些优秀的特性,比如,利于并发等等,这里不细讨论。我所关注的点,在于函数的可读性,你能从函数的输入和输出清晰得看出数据的流向。
class Test
{
public:
Test(int a, int b, int c, int d):a(a),b(b),c(c),d(d){}
//使用a、b、c、d数据生成一个token
std::string generateToken();
private:
int a;
int b;
int c;
int d;
};
namespace Test
{
std::string generateToken(int a, int b, int c, int d);
}
上面两个例子中的generateToken
实现的效果是一样的,但我更倾向于第二种。因为能更清楚地看到函数契约,在未深入读代码地情况下就已经有所认识,而Test
类的函数完全看不出这些信息。
今天就写到这吧,希望能给你所有启示。喜欢我的文章,请关注我的公众号。
封面图片使用Abujar Ansari的作品。