编程精粹—— Microsoft 编写优质无错 C 程序秘诀 07:编码中的假象

news2025/1/12 12:24:12

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


不记录,等于没读。本文记录书中第七章内容:编码中的假象。


有些编程实践非常危险,永远不应使用。它们中的大多数明显具有风险,但也有些看似相当安全,甚至令人向往,因为它们满足需求而没有明显的危险。这些危险的编码实践其实是披着羊皮的狼。为什么不应该引用刚刚释放的内存?为什么在全局或静态存储中传递数据是有风险的?为什么应该避免寄生函数?为什么依赖 ANSI 标准中列出的每一个细枝末节是不明智的?

这里先解释 寄生函数。在编程领域,“parasitic functions”(寄生函数)通常指那些依赖外部状态或副作用而工作的函数。这些函数不具备独立性,因为它们的行为依赖于外部环境,而不是纯粹的输入参数。这样的函数往往难以预测、测试和维护。以下是寄生函数的一些典型特征:

  • 依赖全局变量:函数依赖于全局变量的状态,这使得它们在不同的上下文中表现不一致。
  • 修改外部状态:函数在运行时改变了外部变量或状态,而不仅仅是返回一个结果。
  • 副作用:函数除了返回值外,还对程序的其他部分产生影响,如打印输出、修改文件等。
  • 依赖环境:函数的行为依赖于外部环境或系统状态,如系统时间、配置文件等。

寄生函数的存在会增加代码的复杂性和错误率,因为它们不遵循“单一职责原则”(SRP)和“函数纯度”(pure function)的理念。为了提高代码的可维护性和可靠性,编程时应尽量避免使用寄生函数,而应设计独立、可预测和易于测试的函数。

注意到底引用了什么

memchr 函数的作用是在内存块中查找第一次出现的特定字符。如果在内存块中找到了该字符,则返回指向该字符的指针,否则,返回 NULL 。一个正确的实现代码如下所示:

void *memchr(void *pv, unsigned char ch, size_t size) {
	unsigned char *pch = (unsigned char *)pv;
	while(size-- > 0)
	{
		if(*pch == ch)
			return(pch);
			
		pch++;
	}
	
	return(NULL);
}

如果有程序员想要追求更快的速度,那么他可以使用一些奇技淫巧来去除范围检查:只要在内存块之后的第一个位置存放 ch 字符,这样总是可以找到 ch 字符。只要能保证总是可以找打指定字符,那么就可以不用检查内存范围(这个内存范围内一定有待查字符)。或许你会有疑问,在内存块之后放置一个字符,不是破坏其它内存数据了吗?是的,但是有办法补救,我们会先将这个位置数据存储下来,在函数返回前,再将数据放回原位置,堪称完美。代码如下:

void *memchr(void *pv, unsigned char ch, size_t size) {
	unsigned char *pch = (unsigned char *)pv;
	unsigned char *pchPlant;
	unsigned char chSave;			

	pchPlant = pch + size;	//pchPlant 指向要被查寻的内存块后面的第一个字节
	chSave = *pchPlant;		//保存这个区域的数据
	*pchPlant = ch;			//设置数据位 ch ,确保函数一定能找到 ch
	
	while(*pch != ch)
		pch++;
		
	*pchPlant = chSave;		//恢复数据
	
	return((pch == pchPlant)? NULL : pch);
}

巧妙吗?通过保证 memchr 总能找到 ch,这样就可以删除范围检查,使循环速度加倍。但是这样可靠吗?

并不可靠。这少有以下问题:

  • 如果 pv 指向的是只读存储器,那么在 pchPlant 处存放字符 ch 就不起作用。
  • 如果 pchPlant 指向映射到 I/O 的存储器,那么向该位置写操作的后果就不可预测。
  • 如果待查找的内存块恰好位于合法内存的最后位置,那么 pchPlant 指向非法区域,向这个位置写操作会引起存储故障,可能会终止整个程序。
  • 如果 pchPlant 指向并发进程共享的数据区域,则可能造成其它进程数据错误。

不要引用不属于你自己的存储空间

再看一个有些微妙的错误的例子:释放链表的子窗口。代码简化如下:

void FreeWindowsTree(windows *pwndRoot) {
	if(pwndRoot != NULL) {
		window *pwnd;
		
		for(pwnd = pwndRoot->pwndChild; pwnd != NULL; pwnd = pwnd->pwndSibling)
			FreeWindowTree(pwnd);	//释放子窗口
		...
	}
}

让我们看一下 for 循环体,它按照以下步骤执行:

  1. 初始化 pwnd :pwnd = pwndRoot->pwndChild;
  2. 判断条件: pwnd 是否为 NULL 。如果是,执行步骤 3;否则循环结束。
  3. 执行函数 FreeWindowTree(pwnd) :释放 pwnd 指向的存储块。
  4. 更新 pwnd :pwnd = pwnd->pwndSibling,然后执行步骤 2。

问题出现在步骤 4 上。更新 pwnd 时,表达式 pwnd->pwndSibling 引用了已经释放的内存数据。有些程序员并不认为这样有什么问题,刚刚存储区还好好的,也没做什么影响它的事,而且在机器上运行这个程序,并有任何的异常。

关键的是,一旦释放 pwnd 指向的内存块,那么 pwnd->pwndSibling 的值是什么呢?是一堆垃圾。引用已经释放的存储区是非法的,在释放过程中,存储管理程序可能以任何方式使用这些内存,而你并不能控制存储管理程序,因为它是操作系统提供的。如果上述程序能正常运行,也只是凑巧而已。

仅取所需

编写一个无符号数转字符串的函数,一般步骤是:

  1. 获取数字的个位数,转换成字符
  2. 将数字缩小 10 倍
  3. 判断数字是否大于 0 ,如果是,执行步骤1,否则转换完成。

唯一的问题是,这样转换出来的字符串是倒置的,比如数字 123,通过上述算法得到的字符串是 “321”。所以,为了获取正确的顺序,转换结束后要进行字符串反转操作。有些程序员觉得这样做效率低下,他们给出了新的算法,反向生成字符串,以便正确表示数字,代码如下:

char *UnsToStr(unsigned u, char *str)  { 
   char *pch; 

   ASSERT(u <= 65536); 

   pch = &str[5]; 		//这里假设 str 指向的内存足够大,能存储 u 的最大值
   *pch = '\0'; 

   do 
   	*--pch = u % 10 + '0'; 
   while((u /= 10) > 0); 

   return pch; 
}

这个函数的问题是,str 指向的存储区有多大,你并不知道。但是,函数却假设它足够大。调用者并不一定知道这个函数基于的假设。比如调用者确定自己的数据在 0-255 以内,就可能只申请 4 个字节的内存空间:

char strScore[4]; 
UnsToStr(UserScore, strScore); 	

这样 UnsToStr 函数会破坏 strScore 数组后面的内存数据。一个编程经验是:尽一切可能避免依赖。你的每一个依赖都可能是将来问题的原因。

不要在全局或静态存储中传递数据

还是以编写一个无符号数转字符串的函数为例。在上一节中,我们说不能假设 str 指向的存储区足够大。所以这次,我们在函数内部定义一个足够大的静态数组:

char *strFromUns(unsigned u) {
	static char strDigits[] = "?????" ; 	//5个字符 + '\0'
	char *pch;
	ASSERT(u <= 65535);
	
	pch = &strDigits[5];
	ASSERT(*pch == '\0');
	do
		*--pch = u % 10 + '0' ;
	while((u /= 10) > 0);
	
	return(pch);
}

一旦使用全局或静态存储区传递数据,就意味着这个函数不具备可重入性。比如连续将两个无符号数转换成在字符串:

strHighScore = strFromUns(HighScore);
strThisScore = strFromUns(Score);

第二次调用会将第一次转换的结果覆盖掉!

一些观点

  • 任何时候,只要不编写直观代码,就是自找麻烦!

  • 用一把螺丝刀撬开油漆罐的盖子,然后又用这把螺丝刀搅拌油漆,这是家庭维护中最熟悉的举动之一。人们知道这样做会损坏螺丝刀,不应该如此,为什么还要用螺丝刀来搅拌油漆呢?原因在于,这样做在当时很方便,而且能够解决问题。

  • 使用过某个工具后,你有把它物归原位的习惯吗?据我观察,基本上没有人这么做。所以等到再次用到这个工具的时候,他们会费时间到处找。为什么不放回原位呢,因为用完一扔最方便。
    我很警惕那些怎么方便就怎么来的人。他们常常会以牺牲他人的方式方便自己。

  • 在 Microsoft,那些理解产品内部工作原理的人,会更多的编写新代码。对项目了解很少的人则把时间花在阅读别人的代码、修改别人的BUG以及对已有功能做少量的局部性增补。这种安排很有意义。如果你不理解系统,就不能给系统增加主要功能。

  • 如果你发现自己编写的代码用了较多技巧,那么停止编写代码并寻找别的解决方法。如果一个算法不直观,却产生了正确的结果,那么这个算法的错误同样也会不明显。因此,编写直观代码才是真正的聪明人。

小结

  • 如果你在处理不属于你的数据,哪怕是临时的,也不要写入它。虽然你可能认为读取数据总是安全的,但请记住,读取内存映射的 I/O 可能会对硬件造成危害。
  • 一旦释放了内存,不要再引用它。引用已释放的内存会导致许多种错误。
  • 为了效率,你可能会想在全局或静态缓冲区中传递数据,但这是一个充满危险的捷径。如果你编写了一个函数,它所创建的数据只对调用者有用,请将数据返回给调用者,或者保证你不会意外地更改这些数据。
  • 不要编写依赖于其他函数具体实现的函数。
  • 编程时,按照程序设计语言原来的本意,编写清晰、准确的代码。避免使用可疑的编程习惯,即使语言标准保证它们能工作。记住,标准是会改变的。
  • 从逻辑上看,用 C 语言高效地表达一个概念似乎也会生成同样高效的机器代码,但事实并非如此。在将一段清晰的多行 C 代码压缩成单行代码之前,请确保你因此得到了更好的机器代码。即便如此,请记住局部效率的提升通常难以察觉,而且通常不值得破坏代码的可读性。
  • 最后,不要像律师写合同那样编写代码。如果一个普通程序员不能阅读和理解你的代码,那它就太复杂了;请使用更简单的语言。






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

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

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

相关文章

C语言入门课程学习笔记8:变量的作用域递归函数宏定义交换变量

C语言入门课程学习笔记8 第36课 - 变量的作用域与生命期&#xff08;上&#xff09;第37课 - 变量的作用域与生命期&#xff08;下&#xff09;实验—局部变量的作用域实验-变量的生命期 第38课 - 函数专题练习第39课 - 递归函数简介实验小结 第40课 - C 语言中的宏定义实验小结…

基于STM32的智能农业灌溉系统

目录 引言环境准备智能农业灌溉系统基础代码实现&#xff1a;实现智能农业灌溉系统 4.1 数据采集模块4.2 数据处理与分析4.3 控制系统实现4.4 用户界面与数据可视化应用场景&#xff1a;智能农业管理与优化问题解决方案与优化收尾与总结 1. 引言 智能农业灌溉系统通过使用ST…

lvgl_micropython development for esp32

​​​​​​上一篇博客已经编译源码生成了ESP32C3的固件lvgl_micropy_ESP32_GENERIC_C3-4.bin&#xff0c;这篇博客开发一个界面。 一、开发环境 1、安装开发工具 Windows安装Thonny工具&#xff0c;官网链接&#xff1a;Thonny, Python IDE for beginners。 参考博客:用M…

AIGC时代算法工程师的面试秘籍(2024.5.27-6.9第十五式) |【三年面试五年模拟】

写在前面 【三年面试五年模拟】旨在整理&挖掘AI算法工程师在实习/校招/社招时所需的干货知识点与面试方法&#xff0c;力求让读者在获得心仪offer的同时&#xff0c;增强技术基本面。也欢迎大家提出宝贵的优化建议&#xff0c;一起交流学习&#x1f4aa; 欢迎大家关注Rocky…

解析 flink sql 转化成flink job

文章目录 背景流程flink实例实现细节定义的规则定义的物理算子定义的flink exec node 背景 在很多计算引擎里&#xff0c;都会把sql 这种标准语言&#xff0c;转成计算引擎下底层实际的算子&#xff0c;因此理解此转换的流程对于理解整个过程非常重要 流程 flink实例 public…

绘制口罩maskTheFace数据源是300w_lp

官网下载mask the face 代码&#xff0c;增加代码draw_face.py import argparse import cv2 import scipy.io from tqdm import tqdm from utils.aux_functions_2 import *# 设置命令行输入参数 parser argparse.ArgumentParser(description"MaskTheFace - Python code…

C++的特殊类设计 饥饿汉模式

目录 特殊类设计 设计一个不能被拷贝的类 设计一个只能在堆上创建对象的类 设计一个只能在栈上创建对象的类 设计一个不能继承的类 设计模式 单例模式 饿汉模式 饥汉模式 特殊类设计 设计一个不能被拷贝的类 C98的设计方式&#xff1a;将该类的拷贝构造和赋值运算符…

OpenGL3.3_C++_Windows(17)

Demo演示 demo演示 绘制不同的图元&#xff08;点&#xff0c;线…&#xff09;&#xff1a; 理解 glDrawArrays 和 glDrawElements的区别 glDrawArrays &#xff1a;渲染的图元模式mode&#xff08;可以参考&#xff09;&#xff0c;起始位置&#xff0c;顶点数量glDrawElem…

昇思25天学习打卡营第2天|张量Tensor

一、张量的定义&#xff1a; 张量是一种特殊的数据结构&#xff0c;与数组和矩阵非常相似。张量&#xff08;Tensor&#xff09;是MindSpore网络运算中的基本数据结构&#xff08;也是所有深度学习模型的基础数据结构&#xff09;&#xff0c;下面将主要介绍张量和稀疏张量的属…

Maven的依赖传递、依赖管理、依赖作用域

在Maven项目中通常会引入大量依赖&#xff0c;但依赖管理不当&#xff0c;会造成版本混乱冲突或者目标包臃肿。因此&#xff0c;我们以SpringBoot为例&#xff0c;从三方面探索依赖的使用规则。 1、 依赖传递 依赖是会传递的&#xff0c;依赖的依赖也会连带引入。例如在项目中…

大型企业网络DHCP服务器配置安装实践@FreeBSD

企业需求 需要为企业里的机器配置一台DHCP服务器。因为光猫提供DHCP服务的能力很差&#xff0c;多机器dhcp多机器NAT拓扑方式机器一多就卡顿。使用一台路由器来进行子网络的dhcp和NAT服务&#xff0c;分担光猫负载&#xff0c;但是还有一部分机器需要放到光猫网络&#xff0c;…

一、企业级架构设计-archimate基础概念

目录 一、标准 二、实现工具 1、Archimate 1、Archimate 基本概念 1、通用元模型 2、结构关系 3、依赖关系 1、服务关系 2、访问关系 3、影响关系 1、影响方式 2、概念 3、关系线 4、案例 4、关联关系 4、动态、节点和其他关系 1、时间或因果关系 2、信息流 …

ubuntu18.04 编译HBA 并实例运行

HBA是一个激光点云层级式的全局优化的程序&#xff0c;他的论文题目是&#xff1a;HBA: A Globally Consistent and Efficient Large-Scale LiDAR Mapping Module&#xff0c;对应的github地址是&#xff1a;HKU-Mars-Lab GitHub 学习本博客&#xff0c;可以学到gtsam安装&am…

6.S081的Lab学习——Lab8: locks

文章目录 前言一、Memory allocator(moderate)提示&#xff1a;解析 二、Buffer cache(hard)解析&#xff1a; 三、Barrier (moderate)解析&#xff1a; 总结 前言 一个本硕双非的小菜鸡&#xff0c;备战24年秋招。打算尝试6.S081&#xff0c;将它的Lab逐一实现&#xff0c;并…

[数据集][目标检测]药片药丸检测数据集VOC+YOLO格式152张1类别

数据集格式&#xff1a;Pascal VOC格式YOLO格式(不包含分割路径的txt文件&#xff0c;仅仅包含jpg图片以及对应的VOC格式xml文件和yolo格式txt文件) 图片数量(jpg文件个数)&#xff1a;152 标注数量(xml文件个数)&#xff1a;152 标注数量(txt文件个数)&#xff1a;152 标注类别…

Django 模版过滤器

Django模版过滤器是一个非常有用的功能&#xff0c;它允许我们在模版中处理数据。过滤器看起来像这样&#xff1a;{{ name|lower }}&#xff0c;这将把变量name的值转换为小写。 1&#xff0c;创建应用 python manage.py startapp app5 2&#xff0c;注册应用 Test/Test/sett…

ic基础|功耗篇03:ic设计人员如何在代码中降低功耗?一文带你了解行为级以及RTL级低功耗技术

大家好&#xff0c;我是数字小熊饼干&#xff0c;一个练习时长两年半的ic打工人。我在两年前通过自学跨行社招加入了IC行业。现在我打算将这两年的工作经验和当初面试时最常问的一些问题进行总结&#xff0c;并通过汇总成文章的形式进行输出&#xff0c;相信无论你是在职的还是…

【计算机网络篇】数据链路层(13)共享式以太网与交换式以太网的对比

文章目录 &#x1f354;共享式以太网与交换式以太网的对比&#x1f50e;主机发送单播帧的情况&#x1f50e;主机发送广播帧的情况&#x1f50e;多对主机同时通信 &#x1f6f8;使用集线器和交换机扩展共享式以太网的区别 &#x1f354;共享式以太网与交换式以太网的对比 下图是…

基于STM32的智能家居安防系统

目录 引言环境准备智能家居安防系统基础代码实现&#xff1a;实现智能家居安防系统 4.1 数据采集模块4.2 数据处理与分析4.3 控制系统实现4.4 用户界面与数据可视化应用场景&#xff1a;智能家居安防管理与优化问题解决方案与优化收尾与总结 1. 引言 智能家居安防系统通过使…

使用J-Link Commander查找STM32死机问题

接口:PA13,PA14&#xff0c;请勿连接复位引脚。 输入usb命令这里我已经连接过了STM32F407VET6了。 再输入connect命令这里我已经默认选择了SWD接口&#xff0c;4000K速率。 可以输入speed 4000命令选择4000K速率: 写一段崩溃代码进行测试: void CashCode(void){*((volatil…