编程精粹—— Microsoft 编写优质无错 C 程序秘诀 03:强化你的子系统

news2024/10/7 18:22:48

这是一本老书,作者 Steve Maguire 在微软工作期间写了这本书,英文版于 1993 年发布。2013 年推出了 20 周年纪念第二版。我们看到的标题是中译版名字,英文版的名字是《Writing Clean Code ─── Microsoft’s Techniques for Developing》,这本书主要讨论如何编写健壮、高质量的代码。作者在书中分享了许多实际编程的技巧和经验,旨在帮助开发人员避免常见的编程错误,提高代码的可靠性和可维护性。


不记录,等于没读。本文记录书中第三章内容:强化你的子系统。


上一章我们看到了 断言 的威力,相比编译器,它能够检查出更多的错误。断言的好处是:用户在错误发生时,可以自动地把它们检查出来。这同时揭示了断言的一个弱点:断言 静静地等待,直到错误出现。
断言无疑是强有力的工具,但只有断言还不够。更强大的是 子系统 完整性检查,它能 主动验证 子系统,在错误影响程序之前发现错误。针对标准 C 内存管理器的完整性检查能够检测空指针、内存泄漏以及非法使用未初始化或已释放的内存。完整性检查还可用于消除罕见行为,并迫使子系统重现错误,以便追踪和修复。

首先要了解的是,什么是 子系统 (subsystem) ?

子系统 指的是一个较大系统中的独立功能单元或组件。它具有独立的功能和接口,可以单独开发、测试和维护,但同时又与其他子系统协同工作,共同实现整个系统的功能。

操作系统 是一个大的系统,它包含许多子系统,比如:

  • 文件系统子系统:负责文件的存储、检索和管理。
  • 网络子系统:负责处理网络协议和数据传输。
  • 内存管理子系统:负责内存的分配和管理。

每个子系统都执行特定的任务,并通过系统调用接口与其他子系统和应用程序交互。在小型嵌入式系统中,模块化代码 也可以看做是一个子系统。

通常 子系统隐藏实现细节,只对外提供一些简单的接口,并且隐藏的实现细节可能相当复杂。比如文件系统一般只提供 5 个基本接口函数:打开、关闭、读、写和创建文件,但这些操作通常需要大量复杂的代码作支撑。

程序员在调用这些接口函数时,可以增加调试检查,这样就能毫不费力的进行许多错误检查。这正是本章的核心理念,即强化你的子系统。设想一下这个场景:

一场足球比赛可能有 5 万名球迷现场观赛,但只需要几个人就能完成检票。当然我们规定这些观众要从入口进入。程序也有这样的门,它们是进入子系统的入口。

要构建这个关键入口,我们可以将子系统提供的接口函数 再次封装。一方面可以在封装函数内部增加调试或断言,用来捕捉错误;另一方面在更换另一家供应商提供的子系统时,可以将 更改 限制到封装函数层面,而不必修改应用层代码

下面,我们以内存管理子系统为例,看看标准库给出的接口( mallocfreerealloc) 有哪些容易犯错的地方,然后我们再次封装这些标准接口,在其中添加断言和调试代码,然后再提供给上层应用使用。

要消除随机特性 ─── 使错误可再现

malloc 函数存在以下未定义行为:

  1. 根据 ANSI 标准,请求 malloc 分配长度为零的内存块时,其结果未定义;
  2. 如果 malloc 分配成功,那么它返回的内存块的内容未定义,可以是零,也可以是内容随机的无用信息。

malloc 函数封装时,要将上述的未定义行为消除,或者利用断言确保不会使用到:

#define bGarbage  0xCC

bool 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 分配长度为零的内存块时,其结果未定义。fNewMemory 函数使用 断言 对这种情况进行检查,如果请求分配长度为零的内存块,则会触发断言。
  • 如果 malloc 分配成功,那么它返回的内存块的内容未定义,可以是零,也可以是内容随机的无用信息。fNewMemory 函数通过额外的调试代码,对新申请的内存块填充已知的数据。注意,函数中填充的已知数据是 0xCC(由宏 bGarbage 定义),而不是 0 ,这样做的目的是增加暴露错误的可能性,你可以根据自己的系统特性选择一个数值,让这个数值尽可能看起来离奇而且无用,这样你的程序就不会错误的使用它,而是会崩溃或异常,让你不得不去处理。
  • 额外的调试代码调用了 fCreatBlockInfo 函数,这是 内存跟踪接口 中的一个函数,它记录申请到的内存地址和大小,用来辅助完整性检查。后面还会介绍更多内存跟踪接口。

冲掉无用的信息,以免被错误地使用

free 函数的问题是:

  1. 如果给 free 函数传递无效的指针,其结果未定义
  2. 已经被释放的内存仍包含着对软件而言有效的数据,如果因为软件错误,程序误用了已经释放的内存,可能不会立即出错。

为了解决上面的问题,我们重新封装 free 函数:

void FreeMemory(void *pv) {
    ASSERT(pv != NULL)
        
#ifdef DEBUG
        memset(pv, bGarbage, sizeofBlock(pv));
    	FreeBlockInfo(pv);
#endif
    
    free(pv);
}

让我来解释下这个函数:

  • 首先使用 断言 捕获参数为 NULL 的情况,应用程序将 NULL 传递给 free 函数是无意义的。
  • 将要释放的内存区域用特定的数值填充 (数值由宏 bGarbage 定义),这块区域的内容会变得无用。完成这一步,只需要调用 memset 函数,但问题是,这需要知道被释放的内存大小。为此我们调用 sizeofBlock 函数。这是第 2 个 内存跟踪接口 提供的函数,调用这个函数可以获取被释放内存的大小的原理是:当使用 fNewMemory 函数分配内存时,已经记录下申请到的内存地址和大小,sizeofBlock 函数利用内存地址(已知量) 来获取该块内存大小。另外, sizeofBlock 函数还顺便对 pv 指针进行了检查,确认它是由 fNewMemory 函数分配的。这当然是可以做到的,因为 内存跟踪接口 知道每个内存分配块的细节。
  • 函数 FreeBlockInfo 是第 3 个内存跟踪接口 提供的函数,用于释放跟踪数据。

realloc 函数的问题是:

  1. realloc 函数传递无效的指针,其结果未定义。
  2. realloc 函数调用失败,则返回 NULL 。如果程序员没有意识到这一点,可能会写类似 my_ptr = realloc(my_ptr, NEW_SIZE) 的错误代码。当 realloc 调用失败时,my_ptr 就将指向 NULL,之前申请的内存块再也无法访问。
  3. 若缩小内存,释放的内存中仍包含着对软件而言有效的数据;若扩大内存,新增的内存数据是随机的。
bool fResizeMemory(void **ppv, size_t sizeNew)
{
    byte **ppb = (byte **)ppv;
    byte *pbResize;
#ifdef DEBUG
    size_t sizeOld;
#endif
    
    ASSERT(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;
        }
    }
#endif

    pbRsize = (byte *)realloc(*ppb, sizeNew);
    if(pbResize == NULL)
    	return false;
#ifdef DEBUG
    UpdateBlockInfo(*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的概率。
  • 确保所编写的测试代码能在程序员无感的情况下起作用,最好的测试代码是不用知道其存在也能起作用。
  • 如果可能的话,把测试代码放到所编写的子系统中,而不要把它放到所编写子系统的外层。不要等到进行了系统编码后,才考虑其确认方法。在子系统设计的每一步,都要考虑如何对这一实现进行彻底地验证这一问题。如果发现这一设计难于测试或者不可能对其进行测试,那么要认真地考虑另一种不同的设计,即使这意味着用大小或速度作代价去换取系统的测试能力也要这么做。
  • 如果一个验证测试程序太慢或占用太多内存,在弃用它之前要三思而后行。切记,交付版本中并不会有验证测试代码。如果发现自己正在想“这个测试程序太慢、太大了”,那么要马上停下来问自己:怎样才能保留这个测试程序,并使它即快又小?






每一份打赏,都是对创作者劳动的肯定与回报。
千金难买知识,但可以买好多奶粉

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1834003.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【面试干货】常见的编译时异常(运行时异常)及其处理

【面试干货】常见的编译时异常&#xff08;运行时异常&#xff09;及其处理 1、SQLException2、IOException3、FileNotFoundException4、ClassNotFoundException5、EOFException6、总结 &#x1f496;The Begin&#x1f496;点点关注&#xff0c;收藏不迷路&#x1f496; 在Jav…

电能表厂家的研发能力是实力的体现

电能表厂家的研发能力无疑是其整体实力的核心体现。一个拥有强大研发能力的电能表厂家&#xff0c;不仅能够持续推出具有竞争力的新产品&#xff0c;满足市场需求&#xff0c;还能引领行业发展&#xff0c;塑造企业品牌形象。 一、研发能力对电能表厂家的重要性 研发能力是电…

图纸管理的方法、图纸管理软件

图纸管理是一个复杂且关键的过程&#xff0c;它涉及到图纸的创建、存储、共享、修改、审核、存档和检索等多个环节。以下是根据参考文章总结的图纸管理的具体内容和方法&#xff1a; 一、图纸管理的目的 1、确保图纸的准确性&#xff1a;通过规范的管理流程和质量控制措施&…

Failed to execute goal org.apache.maven.plugins:maven-antrun-plugin:1.8:

Mvan 点击执行 mvn install https://repo1.maven.org/maven2/org/apache/maven/plugins/maven-antrun-plugin/1.8/maven-antrun-plugin-1.8.pom

小米手机怎么用代理换ip:步骤详解与实用指南

在数字化时代&#xff0c;网络安全与隐私保护日益受到重视。对于小米手机用户而言&#xff0c;使用代理换IP已成为提升网络安全性、访问特定网站或绕过地域限制的有效手段。本文将详细介绍如何在小米手机上设置代理以更换IP地址&#xff0c;帮助用户更好地保护个人信息和享受更…

【NOI-题解】1448. 随机体能测试1469. 数的统计1511. 数字之和为13的整数1846. 阿尔法乘积

文章目录 一、前言二、问题问题&#xff1a;1448. 随机体能测试问题&#xff1a;1469. 数的统计问题&#xff1a;1511. 数字之和为13的整数问题&#xff1a;1846. 阿尔法乘积 三、感谢 一、前言 本章节主要对嵌套循环的题目进行讲解&#xff0c;包括《1448. 随机体能测试》《1…

Swift开发——存储属性与计算属性

Swift语言开发者建议程序设计者多用结构体开发应用程序。在Swift语言中,结构体具有了很多类的特性(除类的与继承相关的特性外),具有属性和方法,且为值类型。所谓的属性是指结构体中的变量或常量,所谓的方法是指结构体中的函数。在结构体中使用属性和方法是因为:①匹别于结…

泛微开发修炼之旅--19ecode获取用户人员信息方案汇总及代码示例(含pc端和移动端)

文章详情链接&#xff1a;19ecode获取用户人员信息方案汇总及代码示例&#xff08;含pc端和移动端&#xff09;

Android Basis - Google Keybox

什么是Keybox Keybox 又称为Gooogle attestation key&#xff0c;是Google用于管理、统计运行GMS套件设备的一种手段。 通常我们会向Google申请keybox&#xff0c;结合可能得出货量&#xff0c;提供如下信息给到的Google。 1. fingerprint 2. device id 列表 举个例子&am…

(done) AFL 都有哪些阶段? Stage progress

参考资料&#xff1a;https://afl-1.readthedocs.io/en/latest/user_guide.html 所有阶段如下&#xff0c;包括详细的解释

下载lombok.jar包,简化类的代码

Download (projectlombok.org) 去这个网站下载lombok.jar包 打开这个包文件的位置,拖到项目lib文件夹: 在这里右键添加为库(Add as library)。 添加这三个注解即可&#xff0c;类里面不需要其他东西了

基于Python的垃圾分类检测识别系统(Yolo4网络)【W8】

简介&#xff1a; 垃圾分类检测识别系统旨在利用深度学习和计算机视觉技术&#xff0c;实现对不同类别垃圾的自动识别和分类。应用环境包括Python编程语言、主流深度学习框架如TensorFlow或PyTorch&#xff0c;以及图像处理库OpenCV等&#xff0c;通过这些工具集成和优化模型&a…

物联网技术-第3章物联网感知技术-3.3传感技术

目录 1.1什么是传感器 1.1.1生活中的传感器 1.1.2人的五官与传感器 1.1.3传感器的定义 1.1.4传感器的组成 1.2传感器的特性 1.2.1传感器的静态特征 1、灵敏度&#xff08;静态灵敏度&#xff09; 2.精度 3.线性度&#xff08;非线性误差&#xff09; 4.最小检测量&a…

UITableView初识之分组显示数据Demo

基本介绍 继承自UIScrollView&#xff0c;因此可以滚动。 需要Datasource 遵循UITableViewDataSource协议的OC对象&#xff0c;都可以是UITableView的数据源&#xff0c;该协议中的方法告诉UITableView如何显示数据。 关于UITableView UITableView显示分组数据&#xff0c;对应…

【Android】使用SeekBar控制数据的滚动

项目需求 有一个文本数据比较长&#xff0c;需要在文本右侧加一个SeekBar&#xff0c;然后根据SeekBar的上下滚动来控制文本的滚动。 项目实现 我们使用TextView来显示文本&#xff0c;但是文本比较长的话&#xff0c;需要在TextView外面套一个ScrollView&#xff0c;但是我…

【 ARMv8/ARMv9 硬件加速系列 3.5.1 -- SVE 谓词寄存器有多少位?】

文章目录 SVE 谓词寄存器(predicate registers)简介SVE 谓词寄存器的位数SVE 谓词寄存器对向量寄存器的控制SVE 谓词寄存器位数计算SVE 谓词寄存器小结SVE 谓词寄存器(predicate registers)简介 ARMv9的Scalable Vector Extension (SVE) 引入了谓词寄存器(Predicate Register…

美团Meitu前端一面,期望27K

面经哥只做互联网社招面试经历分享&#xff0c;关注我&#xff0c;每日推送精选面经&#xff0c;面试前&#xff0c;先找面经哥 1、做的主要是什么项目&#xff0c;桌面端的吗&#xff1f; 2、用的主要是什么技术栈&#xff1f;vue有了解吗&#xff1f; 3、移动端开发一般怎么…

Tuxera NTFS与Paragon NTFS:两款NTFS驱动软件的深度对比 tuxera和paragon NTFS哪个好

在Mac上使用NTFS格式的磁盘&#xff0c;通常需要借助第三方的驱动软件。其中&#xff0c;Tuxera NTFS和Paragon NTFS是两款备受欢迎的选择。虽然它们的基本功能相似&#xff0c;但在细节和使用体验上却有所不同。本文将带你深入了解这两款软件的差异&#xff0c;帮助你做出更明…

三极管原理介绍

三极管 不同封装 基本定义 三极管&#xff0c;全称应为半导体三极管&#xff0c;也称双极型晶体管(BJT)、晶体三极管&#xff0c;是一种控制电流的半导体器件。其作用是把微弱信号放大成幅度值较大的电信号&#xff0c;也用作无触点开关。 三极管是半导体基本元器件之一&…

数据资产驱动的智能化决策:深度剖析数据资产在提升企业决策效率与准确性中的关键作用

在数字化、信息化日益普及的今天&#xff0c;数据已经成为企业发展的重要资产。数据资产不仅能够帮助企业更好地了解市场需求、优化业务流程&#xff0c;还能在决策过程中提供科学、精准的支持。本文将深入剖析数据资产在提升企业决策效率与准确性中的关键作用&#xff0c;探讨…