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

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






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

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

windows无法完成格式化

方法. 使用CMD格式化 请将U盘连接到电脑&#xff0c;并确保电脑能够正常识别。 1. 在搜索框中输入“命令提示符”。在左侧结果中的“命令提示符”上点击右键&#xff0c;选择“以管理员身份运行”。 2. 在新窗口中&#xff0c;键入“diskpart”并按“回车”&#xff0c;然后…

如何通过小猪APP分发轻松实现Web封装APP

你有没有想过将你的网站或者Web应用变成一个真正的APP&#xff1f;这听起来可能有点复杂&#xff0c;但其实在今天的技术环境下&#xff0c;这已经变得非常简单了。特别是有了像小猪APP分发这样的工具&#xff0c;你可以轻松地将你的Web应用封装成一个APP。 为什么要将Web应用封…

Golang | Leetcode Golang题解之第164题最大间距

题目&#xff1a; 题解&#xff1a; type pair struct{ min, max int }func maximumGap(nums []int) (ans int) {n : len(nums)if n < 2 {return}minVal : min(nums...)maxVal : max(nums...)d : max(1, (maxVal-minVal)/(n-1))bucketSize : (maxVal-minVal)/d 1// 存储 (…

如何在不丢失数据的情况下解锁安卓手机密码

手机是我们生活中必不可少的工具&#xff0c;可以帮助我们与朋友和家人保持联系&#xff0c;了解最新消息&#xff0c;甚至经营我们的业务。然而&#xff0c;当我们在 Android 手机或 iPhone 上设置密码时&#xff0c;我们经常会忘记密码&#xff0c;或者根本没有设置密码。当这…

安卓多媒体(音频录播、传统摄制、增强摄制)

本章介绍App开发常用的一些多媒体处理技术&#xff0c;主要包括&#xff1a;如何录制和播放音频&#xff0c;如何使用传统相机拍照和录像&#xff0c;如何截取视频画面&#xff0c;如何使用增强相机拍照和录像。 音频录播 本节介绍Android对音频的录播操作&#xff0c;内容包…

SpringBoot3整合SpringDoc实现在线接口文档

写在前面 在现目前项目开发中&#xff0c;一般都是前后端分离项目。前端小姐姐负责开发前端&#xff0c;苦逼的我们负责后端开发 事实是一个人全干&#xff0c;在这过程中编写接口文档就显得尤为重要了。然而作为一个程序员&#xff0c;最怕的莫过于自己写文档和别人不写文档…

c函数/2024/6/17

1.递归计算0--n的和 #include <stdio.h> int sum(int n);//递归求和函数 int main(int argc, const char *argv[]) {//(2)递归计算0--n的和int n0;printf("请输入n的值为:");scanf("%d",&n);printf("0--n的和为:%d",sum(n));return 0…

AI早班车

全球AI新闻速递 1.国内团队制作AI短片《凤鸣山海》亮相北京电影节 国内团队制作AI短片《凤鸣山海》亮相北京电影节“光影未来”电影科技单元。独特的中国玄幻题材&#xff0c;朱雀、玄武、白虎、青龙&#xff0c;四大神兽栩栩如生 2.字节跳动拒绝出售TikTok&#xff0c;如果败…

【数据结构初阶】--- 堆的应用:topk

堆的功能&#xff1a;topk 为什么使用topk 先举个例子&#xff0c;假如说全国有十万家奶茶店&#xff0c;我现在想找到评分前十的店铺&#xff0c;现在应该怎么实现&#xff1f; 第一想法当然是排序&#xff0c;由大到小排序好&#xff0c;前十就能拿到了。这是一种方法&…

三星(中国)投资公司线上入职测评笔试邀请数字推理语言逻辑真题题库

三星&#xff08;中国&#xff09;有限公司北京分公司 邀请您参加 SHL线上笔试 具体安排如下&#xff1a; 笔试时间&#xff1a;周三 9:00 笔试时长&#xff1a;1.5h ~ 2h 笔试内容及要求&#xff1a;数字推理限时30min&#xff1b;语言逻辑限时30min&#xff1b;性格测试不…

【机器学习】第5章 朴素贝叶斯分类器

一、概念 1.贝叶斯定理&#xff1a; &#xff08;1&#xff09;就是“某个特征”属于“某种东西”的概率&#xff0c;公式就是最下面那个公式。 2.朴素贝叶斯算法概述 &#xff08;1&#xff09;是为数不多的基于概率论的分类算法&#xff0c;即通过考虑特征概率来预测分类。 …

你对SSH协议了解吗

SSH&#xff08;Secure Shell&#xff09;协议&#xff0c;作为网络通信领域的一项核心技术&#xff0c;以其卓越的安全性能和广泛的应用范围&#xff0c;成为保障网络通信安全的重要工具。本文将深入剖析SSH协议的工作原理、核心特性以及在现代网络通信中的关键作用&#xff0…

HTML静态网页成品作业(HTML+CSS)——新媒体专业介绍介绍网页(1个页面)

&#x1f389;不定期分享源码&#xff0c;关注不丢失哦 文章目录 一、作品介绍二、作品演示三、代码目录四、网站代码HTML部分代码 五、源码获取 一、作品介绍 &#x1f3f7;️本套采用HTMLCSS&#xff0c;未使用Javacsript代码&#xff0c;共有1个页面。 二、作品演示 三、代…

经历的分享

我是三本计算机科学技术跨考上岸的学生&#xff0c;本科阶段技术能力并没有掌握多少&#xff0c;在选择导师时屡屡碰壁&#xff0c;我当时向许多计算机方向的导师&#xff0c;比如大数据方向,计算机视觉 迁移学习和图像处理方向的导师全都拒绝了我&#xff0c;最终学校给我分配…

SpringCloudStream原理和深入使用

简单概述 Spring Cloud Stream是一个用于构建与共享消息传递系统连接的高度可扩展的事件驱动型微服务的框架。 应用程序通过inputs或outputs来与Spring Cloud Stream中binder对象交互&#xff0c;binder对象负责与消息中间件交互。也就是说&#xff1a;Spring Cloud Stream能…

Sunny v1.3.0 官方版 (简洁且漂亮截图应用)

前言 Sunny是一款漂亮又实用的“截图&钉图”的软件&#xff0c;亦支持“屏幕识图”和“OCR”的软件。 一、下载地址 下载链接&#xff1a;http://dygod/source 点击搜索&#xff1a;Sunny 二、安装步骤 1、解压后将Sunny.exe发送到桌面快捷方式 2、启动桌面图标 3、正…

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

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

手写操作系统

对喜欢操作系统的伙伴强推一门课程 从0开始实现了支持文件系统、任务切换和网络协议栈的操作系统。 具体见 &#xff1a;http://www.ziyuanwang.online/977.html

012.指纹浏览器编译-修改canvas指纹(高级)

指纹浏览器编译-修改canvas指纹(高级) 一、canvas指纹是什么 之前介绍过canvas指纹和常见网站绕过canvas指纹&#xff0c;插眼&#xff1a; https://blog.csdn.net/w1101662433/article/details/137959179 二、为啥有更高级的canvas指纹 众所周知&#xff0c;creepjs和brow…

Java Lambda表达式:简洁代码的艺术与实战技巧

引言 Java Lambda表达式是Java SE8中引入的一项重要的语言特性&#xff0c;它允许我们以简洁的方式去编写代码&#xff0c;同时也能大大提高代码的可读性和编写的灵活性。结合Java8及以后版本中引入的Stream API&#xff0c;Lambda表达式使得集合操作变得更为直观和强大。本文将…