这是一本老书,作者 Steve Maguire 在微软工作期间写了这本书,英文版于 1993 年发布。2013 年推出了 20 周年纪念第二版。我们看到的标题是中译版名字,英文版的名字是《Writing Clean Code ─── Microsoft’s Techniques for Developing》,这本书主要讨论如何编写健壮、高质量的代码。作者在书中分享了许多实际编程的技巧和经验,旨在帮助开发人员避免常见的编程错误,提高代码的可靠性和可维护性。
不记录,等于没读。本文记录书中第三章内容:强化你的子系统。
上一章我们看到了
断言
的威力,相比编译器,它能够检查出更多的错误。断言的好处是:用户在错误发生时,可以自动地把它们检查出来。这同时揭示了断言的一个弱点:断言静静地等待
,直到错误出现。
断言无疑是强有力的工具,但只有断言还不够。更强大的是子系统
完整性检查,它能主动验证
子系统,在错误影响程序之前发现错误。针对标准 C 内存管理器的完整性检查能够检测空指针、内存泄漏以及非法使用未初始化或已释放的内存。完整性检查还可用于消除罕见行为,并迫使子系统重现错误,以便追踪和修复。
首先要了解的是,什么是 子系统
(subsystem) ?
子系统
指的是一个较大系统中的独立功能单元或组件。它具有独立的功能和接口,可以单独开发、测试和维护,但同时又与其他子系统协同工作,共同实现整个系统的功能。
操作系统
是一个大的系统,它包含许多子系统,比如:
- 文件系统子系统:负责文件的存储、检索和管理。
- 网络子系统:负责处理网络协议和数据传输。
- 内存管理子系统:负责内存的分配和管理。
每个子系统都执行特定的任务,并通过系统调用接口与其他子系统和应用程序交互。在小型嵌入式系统中,模块化代码
也可以看做是一个子系统。
通常 子系统
要隐藏实现细节,只对外提供一些简单的接口,并且隐藏的实现细节可能相当复杂。比如文件系统一般只提供 5 个基本接口函数:打开、关闭、读、写和创建文件,但这些操作通常需要大量复杂的代码作支撑。
程序员在调用这些接口函数时,可以增加调试检查,这样就能毫不费力的进行许多错误检查。这正是本章的核心理念,即强化你的子系统。设想一下这个场景:
一场足球比赛可能有 5 万名球迷现场观赛,但只需要几个人就能完成检票。当然我们规定这些观众要从入口进入。程序也有这样的门,它们是进入子系统的入口。
要构建这个关键入口,我们可以将子系统提供的接口函数 再次封装
。一方面可以在封装函数内部增加调试或断言,用来捕捉错误;另一方面在更换另一家供应商提供的子系统时,可以将 更改
限制到封装函数层面,而不必修改应用层代码 。
下面,我们以内存管理子系统为例,看看标准库给出的接口( malloc
、free
、realloc
) 有哪些容易犯错的地方,然后我们再次封装这些标准接口,在其中添加断言和调试代码,然后再提供给上层应用使用。
要消除随机特性 ─── 使错误可再现
malloc
函数存在以下未定义行为:
- 根据 ANSI 标准,请求
malloc
分配长度为零的内存块时,其结果未定义; - 如果
malloc
分配成功,那么它返回的内存块的内容未定义,可以是零,也可以是内容随机的无用信息。
对 malloc
函数封装时,要将上述的未定义行为消除,或者利用断言确保不会使用到:
#define bGarbage 0xCCbool fNewMemory(void **ppv, size_t size) { byte **ppb = (byte **)ppv; ASSERT(ppv != NULL && size != 0); *ppb = (byte*)malloc(size); if(*ppb == NULL)return false;#ifdef DEBUG memset(*ppb, bGarbage, size); //填充特定内容if(fCreatBlockInfo(*ppb, size) == false) {free(*ppb); //无法创建日志信息,模拟内存分配错误*ppb = NULL;return false;}
#endif return true;
}
这个函数比直接调用 malloc
函数要复杂多了,下面来解析这个函数:
- 多了一个
void **ppv
指针参数,返回值变成了bool
型。这样的改写有两个好处:- malloc 函数的返回值有两种含义:内存申请失败 (返回
NULL
) 或者指向已分配内存块的指针(返回非 NULL
)。现代的编程习惯不建议这样做,因为它违反了单一职责原则。fNewMemory
函数则不同,它的返回值表示内存申请是否成功,如果内存申请成功,已分配的内存块由参数*ppv
指向,如果内存申请失败,它负责将*ppv
设置为NULL
。 - 使用起来,
fNewMemory
函数更清晰。如果使用malloc
函数,形式如下:
而使用char *pbBlock; bpBlock = (char *)malloc(32); if(bpBlock != NULL)// 成功 else//失败
fNewMemory
函数,形式如下:char *pbBlock; if(fNewMemory(&pbBlock, 32) )//成功 else//失败
- malloc 函数的返回值有两种含义:内存申请失败 (返回
malloc
分配长度为零的内存块时,其结果未定义。fNewMemory
函数使用断言
对这种情况进行检查,如果请求分配长度为零的内存块,则会触发断言。- 如果
malloc
分配成功,那么它返回的内存块的内容未定义,可以是零,也可以是内容随机的无用信息。fNewMemory
函数通过额外的调试代码,对新申请的内存块填充已知的数据。注意,函数中填充的已知数据是0xCC
(由宏bGarbage
定义),而不是 0 ,这样做的目的是增加暴露错误的可能性,你可以根据自己的系统特性选择一个数值,让这个数值尽可能看起来离奇而且无用,这样你的程序就不会错误的使用它,而是会崩溃或异常,让你不得不去处理。 - 额外的调试代码调用了
fCreatBlockInfo
函数,这是内存跟踪接口
中的一个函数,它记录申请到的内存地址和大小,用来辅助完整性检查。后面还会介绍更多内存跟踪接口。
冲掉无用的信息,以免被错误地使用
free
函数的问题是:
- 如果给
free
函数传递无效的指针,其结果未定义 - 已经被释放的内存仍包含着对软件而言有效的数据,如果因为软件错误,程序误用了已经释放的内存,可能不会立即出错。
为了解决上面的问题,我们重新封装 free
函数:
void FreeMemory(void *pv) {ASSERT(pv != NULL)#ifdef DEBUGmemset(pv, bGarbage, sizeofBlock(pv));FreeBlockInfo(pv);
#endiffree(pv);
}
让我来解释下这个函数:
- 首先使用
断言
捕获参数为NULL
的情况,应用程序将NULL
传递给free
函数是无意义的。 - 将要释放的内存区域用特定的数值填充 (数值由宏
bGarbage
定义),这块区域的内容会变得无用。完成这一步,只需要调用memset
函数,但问题是,这需要知道被释放的内存大小。为此我们调用sizeofBlock
函数。这是第 2 个内存跟踪接口
提供的函数,调用这个函数可以获取被释放内存的大小的原理是:当使用fNewMemory
函数分配内存时,已经记录下申请到的内存地址和大小,sizeofBlock
函数利用内存地址(已知量) 来获取该块内存大小。另外,sizeofBlock
函数还顺便对pv
指针进行了检查,确认它是由fNewMemory
函数分配的。这当然是可以做到的,因为内存跟踪接口
知道每个内存分配块的细节。 - 函数
FreeBlockInfo
是第 3 个内存跟踪接口
提供的函数,用于释放跟踪数据。
realloc
函数的问题是:
- 给
realloc
函数传递无效的指针,其结果未定义。 realloc
函数调用失败,则返回NULL
。如果程序员没有意识到这一点,可能会写类似my_ptr = realloc(my_ptr, NEW_SIZE)
的错误代码。当realloc
调用失败时,my_ptr
就将指向NULL
,之前申请的内存块再也无法访问。- 若缩小内存,释放的内存中仍包含着对软件而言有效的数据;若扩大内存,新增的内存数据是随机的。
bool fResizeMemory(void **ppv, size_t sizeNew)
{byte **ppb = (byte **)ppv;byte *pbResize;
#ifdef DEBUGsize_t sizeOld;
#endifASSERT(ppb != NULL && sizeNew != 0);#ifdef DEBUG sizeOld = sizeofBlock(*ppb);if(sizeNew < sizeOld) { //内存缩小,冲掉块尾释放的内容*memset(*ppb + sizeNew, bGarbage, sizeOld - sizeNew);} else if(sizeNew > sizeOld) { //内存扩大,强迫realloc不能在原位置扩展空间byte *pbNew;if(fNewMemory(&pbNew, sizeNew)) {memcpy(pbNew, *ppb, sizeOld);FreeMemory(*ppb); //冲刷掉原来的内容*ppb = pbNew;}}
#endifpbRsize = (byte *)realloc(*ppb, sizeNew);if(pbResize == NULL)return false;
#ifdef DEBUGUpdateBlockInfo(*ppb, pbResize, sizeNew);/*如果扩大,对尾部增加的内容进行初始化*/if(sizeNew > sizeOld)memset(pbResize + sizeOld, bGarbage, sizeNew - sizeOld);
#endif*ppb = pbResize;return true;
}
让我来解释下这个函数:
- 使用断言捕获不应该发生的错误
- 如果缩小内存,用特定数据冲洗掉要释放的内存,如果扩大内存,对新增内存初始化为特定数据。
- 对于扩大内存,还有一层需要考虑。考虑一下,
realloc
在扩大内存时,可能有两种动作,第一种是紧随着当前内存块的后面扩充适当的内存,这种是最理想的情况;第二种情况是在另一个位置申请全新的、足够大的内存块,然后将扩充前的内存数据拷贝到新的内存块,再将扩充前的内存块释放掉。后一种情况可能带来问题,因为realloc
函数释放的内存块没有用特定数据冲洗。fResizeMemory
函数使用了一个小技巧来避免这个问题,即模拟realloc
函数的行为:用fNewMemory
申请新的内存块,然后把原来内容拷贝到新块中,最后释放掉原来内存块。 - 当内存扩大时,既然已经模拟了
realloc
函数的行为,是否可以在模拟完成后,即*ppb = pbNew
语句后面执行return true
返回?这样还可以提高运行速度。答案是绝不允许的!因为这会跳过正常代码的。要记住调试代码是多余的,最终是要从系统中去除的。因此调试代码决不能改变原有代码的执行顺序或跳过正常代码。 fResizeMemory
函数在操作失败的情况下并不返问NULL
。此时,新返回的指针仍然指向原有的内存分配块,并且块内的内容不变。
不必担心调试版本增加的额外代码。调试版本本来就不必短小精悍,不必有特别快的响应速度,只要能满足程序员和测试者的日常使用要求就够了。
有些错误的难点在于虽然它并不经常发生,但却总是发生:不要让事情很少发生。如果发现子系统中有极罕见的行为,要千方百计地设法使其重现。
你有过跟踪错误跟踪到了错误处理程序中,并且感到“这段错误处理程序中的错误太多了,我敢肯定它从来都没有被执行过”这种经历吗?肯定有!错误处理程序之所以往往容易出错,正是因为它很少被执行到。
保存调试信息,以便进行更强的错误检查
从调试的角度来看,内存管理程序是有问题的。创建的内存块大小只是在第一次创建时知道,随后就失去了这一信息。除了内存块大小,如果能够知道已经分配了多少次内存,每个内存块的位置在哪里,用处会更大。这就是编写 内存跟踪接口
的意义。通过编写内存跟踪接口,我们可以保存内存分配的信息,方便调试排错。内存跟踪接口源码见本书附录 B,这是一个很有价值的接口。
如果匪徒根本没打算出城,路障就没用了。不要等待错误发生。要“挨门挨户”的搜查错误:在程序中加上能够积极地寻找这种问题的调试代码。
如果你是售货员,那么当顾客到你那里准备购买毛衣和套装时,你应该先给顾客看套装,然后给顾客看毛衣。这样做可以增加销售额。因为顾客买了一件500美元的套装后,相比之下,一件80美元的毛衣就显得不那么贵了。但是如果你给顾客先看毛衣,那么80美元一件的价格可能顾客无法接受。
——Robert Cialdini博士《影响力》
任何人只要花30秒就能想明白这个道理。可是,又有多少人花时间想过这一问题呢?
一点就透,更要主动思考:仔细设计程序的测试代码,任何选择都应该经过考虑。
当测试代码将错误限制在一个局部范围之内后,就通过断言把错误抓住,打断正常的工作,明确告知程序员。努力做到测试代码对程序员是透明的,所有测试和检查自动执行。
小结:
- 考察所编写的子系统,问自己“在使用这些代码时,程序员可能会犯什么错误。”在子系统中加上相应的断言和确认检查代码,以捕捉难于发现的错误和常见的错误。
- 如果不能重现 BUG,就无法排除它们。找出程序中可能引起随机行为的因素,并将它们从程序的调试版本中清除。把“未定义”的内存单元设置成精心选择的常量值,是消除随机行为的一个例子。这样,如果某个代码引用了“未定义”内存,每次执行有问题的代码,每次都会得到相同的结果。
- 如果所编写的子系统释放内存(或者其它资源),并因此产生了“垃圾信息”,那么要用已知的数据把它冲刷掉。否则,这些被释放了的数据就有可能仍被使用,而又不会被注意到。
- 类似地,如果子系统中含有小概率行为,那么增加调试代码确保这些小概率行为一定发生。那些正常情况下不会执行的代码(通常是错误处理逻辑)最容易滋生BUG,这样做可以增加捕获这些BUG的概率。
- 确保所编写的测试代码能在程序员无感的情况下起作用,最好的测试代码是不用知道其存在也能起作用。
- 如果可能的话,把测试代码放到所编写的子系统中,而不要把它放到所编写子系统的外层。不要等到进行了系统编码后,才考虑其确认方法。在子系统设计的每一步,都要考虑如何对这一实现进行彻底地验证这一问题。如果发现这一设计难于测试或者不可能对其进行测试,那么要认真地考虑另一种不同的设计,即使这意味着用大小或速度作代价去换取系统的测试能力也要这么做。
- 如果一个验证测试程序太慢或占用太多内存,在弃用它之前要三思而后行。切记,交付版本中并不会有验证测试代码。如果发现自己正在想“这个测试程序太慢、太大了”,那么要马上停下来问自己:怎样才能保留这个测试程序,并使它即快又小?
每一份打赏,都是对创作者劳动的肯定与回报。!