编程精粹—— Microsoft 编写优质无错 C 程序秘诀 06:危险的行业

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


不记录,等于没读。本文记录书中第六章内容:危险的行业。

鉴于一个函数有多种实现可能性,不同实现的出错率有所不同也就不足为奇了。编写健壮函数的关键在于用已被证明同样高效但更安全的替代方案,来取代那些风险较高的算法和语言习惯。在极端情况下,这可能意味着使用明确的数据类型;在另一个极端情况下,这可能意味着抛弃整个设计,只因为它难以测试或无法测试。

当程序员有几种可能的实现方案时,他们却经常只考虑空间和速度,而完全忽视了风险性。假如你站在悬崖旁边,想要到达悬崖的底部,你会从悬崖直接跳下吗?毕竟这可是最快到达目的地的方法。

使用有严格定义的数据类型

ANSI在制定C语言标准时,有一些指导原则:

  1. 现存代码是非常重要的。不能定义过于严格的标准,这样会使大量现存代码无效。

    Existing code is important.

  2. 保持C语言的精神:即使不能保证可移植,也要使其快速。

    Keep the spirit of C: Make it fast,even if it is not guaranteed to be portable.

基于上面的准则,ANSI 标准没有定义固有数据类型(intrinsic data types,像 charintlong 这些数据类型),而是让编译器厂家定义详细的数据类型细节。

对于数据类型 char,有的编译器默认为无符号的,也有编译器默认为有符号的,这都符合ANSI标准。没有严格定义的数据类型会导致可移植问题。

ANSI虽然没有定义固有数据类型,但给出了数据类型必须遵循的最小要求。仔细分析 ANSI 标准,可以推导出可移植数据类型集的定义:

char0 ~ 127
signed char-127(并非-128) ~ 127
unsigned char0 ~255
short-32767(并非-32768) ~ 32768
signed short-32767 ~ 32767
unsigned short0 ~ 65535
int-32767(并非-32768) ~ 32768
signed int-32767 ~ 32767
unsigned int0 ~ 65535
long-2147483647(并非-2147483648) ~ 2147483647
signed long-2147483647 ~ 2147483647
unsigned long0 ~ 4294967295
int i : n0 ~ 2n-1 -1
signed int i : n-(2n-1 -1) ~ 2n-1 -1
unsigned int i : n0 ~ 2n -1

使用可移植的数据类型

int 类型不具备可移植性。它与硬件的位宽相同,16 位的机器上,int 变量占用 2 个字节,32 位的机器上占用 4 个字节。所以如果可以使用 long 类型,就不要使用 int 类型,即使将来的硬件设备使用 long 类型效率会低一些,也应该坚持使用可移植类型。

本书英文版发布于 1993 年,那时候还没有推出 C99 标准。现在,可移植的数据类型已经不再是问题,因为 C99 引入了新的标准头文件 stdint.h,它提供了明确的、固定宽度的整数类型。

总是问自己:这个变量或者表达式会溢出吗?

上溢:

unsigned char ch;

for(ch = 0; ch <= UCHAR_MAX; ch++)		//因为ch的上溢,ch <= UCHAR_MAX恒成立, 这里可能是死循环
{
    ...			
}

下溢:

size_t size;

while(--size >= 0)						//因为size的下溢,size >= 0恒成立,这里可能是死循环
{
    ...
}

为什么在上面的注释中写的是“可能是死循环”?

这是因为循环体可能有提前结束循环的条件,然后 returnbreak 离开循环体。这种情况会使得错误更难发现。

避免无关紧要的 if 语句

DOS时代也有图形化界面的。用户看到的图形界面是一系列窗口的集合。这些窗口是有层次的,它们有一个相同的根窗口。根窗口下面有一系列子窗口,其中每个子窗口下面可能还有一些列子窗口。这样在移动、删除、最小化一个窗口的时候,会将这个窗口以及它相关的窗口一起变动。

为了表示窗口层次结构,使用了二叉树结构。二叉树的一个分支指向子窗口,称为为 子节点(children),另一个分支指向具有相同父窗口的窗口,称为同级节点

typedef struct WINDOW 
{ 
 	struct WINDOW *pwndChild;  		/* 如果没有子节点,则为 NULL */ 
 	strcut WINDOW *pwindSibling;  	/* 如果没有同级节点,则为 NULL */ 
	char *strWndTitle;} window; 						   /* 命名:wnd, *pwnd */

向二叉树中插入子窗口,有以下三种代码,比较它们的不同。

第一种代码:

/* pwndRootChildren 是一个指针,指向顶层窗口的列表,例如象菜单框和主文件窗口 */ 
static window *pwndRootChildren = NULL; 

void AddChild( window *pwndParent, window *pwndNewBorn ) 
{ 
	/* 新窗口不会有同级窗口 */ 
	ASSERT( pwndNewBorn->pwndSibling == NULL ); 
    
	if( pwndParent == NULL ) 
	{ 
		/* 将窗口加入到顶层根列表 */ 
		pwndNewBorn->pwndSibling = pwndRootChildren; 
		pwndRootChildren = pwndNewBorn; 
	} 
	else 
	{ 
		/* 如果是父节点的第一个子节点,则在子节点字段存储新窗口,
		   开启新的同级节点链表, 否则加到现存同级节点链的末尾处 */ 
		if( pwndParent -> pwndChild == NULL ) 
		{
			pwndParent -> pwndChild = pwndNewBorn; 
		}
		else 
		{ 
			window *pwnd = pwndParent -> pwndChild; 
			while( pwnd -> pwndSibling != NULL) 
				pwnd = pwnd -> pwndSibling; 
			pwnd -> pwndSibling = pwndNewBorn; 
		} 
	} 
}

这个函数的作用是插入新的窗口,但程序做了它所需工作的 3 倍:判断是否根窗口、判断是否是父节点的第一个子节点和插入新的窗口。引起这个情况的主要原因是设计上的不合理。

由于根窗口没有同级窗口也不会移动、最小化、删除等操作(以现在的眼光看,那时的DOS图形应用程序就是这样的弱,不支持多应用同开),在 window 结构中只有 pwndChild 字段才有意义。因此设计人员为了节省一点内存,没有什么完整的 window 类型对象,而是用指向顶层窗口的指针 pwndRootChildren 来代替。

这样做会给代码实现带来巨大的麻烦,很多地方不得不处理两种数据结构,虽然窗口结构设计为二叉树,但并不是按照二叉树结构实现的。

改进代码的第一步非常容易,砍掉内存“优化”,使用 pwndDisplay 代替 pwndRootChildren 指针。pwndDisplay 是一个指针,指向表示显示的窗口结构。在根窗口节点下面插入窗口时,再也不用传递特殊意义的 NULL 了,因为改进后只需要传递 pwndDisplay,这样可以省掉一种处理根窗口的专用代码判断。

第二种代码:

/* pwndDisplay 指向根窗口,根窗口在程序初始化过程中分配内存 */ 
window *pwndDisplay = NULL; 

void AddChild( window *pwndParent, window *pwndNewBorn ) 
{ 
	/* 新窗口不会有同级窗口 */ 
  	ASSERT( pwndNewBorn -> pwndSibling == NULL ); 
    
	/* 如果是父节点的第一个子节点,则在子节点字段存储新窗口,
	   开启新的同级节点链表, 否则加到现存同级节点链的末尾处 */
  	if( pwndParent -> pwndChild == NULL) 
  	{
      	pwndParent -> pwndChild = pwndNewBorn; 
  	}	
  	else 
	{ 
   		window *pwnd = pwndParent -> pwndChild; 
		while( pwnd -> pwndSibling != NULL ) 
			pwnd = pwnd -> pwndSibling; 
		pwnd -> pwndSibling = pwndNewBorn; 
	} 
} 

第二个版本要比第一个版本好些,但仍做了它所需工作的 2 倍。在你的头脑中应该有一个警戒线:一旦看到 if 语句就要引发报警,核查你是否在执行两次相同的工作,尽管执行方式不同。有些场合需要合法使用if执行一些条件操作,但大多数情况下,这是草率设计、粗心实现的结果:因为写出设计良好、实现也良好的代码非常困难,而且人们也往往喜欢走容易走的道路。

第三种代码:

void AddChild(window *pwndParent, window *pwndNewBorn ) 
{ 
	window **ppwindNext; 
    
	/* 新窗口不会有同级窗口 */  
	ASSERT( pwndNewBorn -> pwndSibling == NULL ); 
    
	/* 使用以指针为中心的算法 */
	ppwndNext = &pwndParent->pwndChild; 
	while( *ppwndNext != NULL ) 
		ppwndNext = &( *ppwndNext )->pwndSibling; 
	*ppwndNext = pwndNewBorn; 
} 

第三个版本消除了if语句。避免无关紧要的 if 语句

有意思的是,作者虽然一直强调避免无关紧要的 if 语句,但是在附录 B 内存日志例程给出的源码中,也存在无关紧要的 if 语句。

内存日志的作用在为子系统设防一节中详细讲解过。它用一个链表保存内存信息,包括内存地址、大小、是否引用等。当删除一个内存块时,也要把内存日志中保存的信息块删除掉,这个内容在函数 FreeBlockInfo 中实现。作者给出的实现方式要使用一个变量 pbiPrev 来保存前一个节点位置,并且要处理删除的是第一个节点 A 这种边界条件,这和他批评的代码实现方式很像:

void FreeBlockInfo(byte *pbToFree)
{
	blockinfo *pbi, *pbiPrev;

 pbiPrev = NULL;
	for(pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext)
	{
		if(fPtrEqual(pbi->pb, pbToFree)
		{
			if(pbiPrev == NULL)
				pbiHead = pbi->pbiHead;
			else
				pbiPrev->pbiNext = pbi->pbiNext;
			break;
		}
		pbiPrev = pbi;
	}

	/*如果pbi是NULL, 说明参数pbToFree非法*/
	ASSERT(pbi != NULL);

	/*在释放前破坏掉要释放内存中的内容*/
	memset(pbi, bGarbage, sizeof(blockinfo));
	free(pbi);
}

有一种惯用的用法可以巧妙的省掉变量pbiPrev以及边界判断,那就是使用二级指针:

void FreeBlockInfo(byte *pbToFree)
{
 blockinfo **ppbi, *pbiFind;

 pbiFind = NULL;
 for(ppbi = &pbiHead; *ppbi != NULL; ppbi = &(*ppbi)->pbiNext)
 {
     if(fPtrEqual((*ppbi)->pb, pbToFree)
	{
         pbiFind = *ppbi;
         *ppbi = (*ppbi)->pbiNext;
         break;
     }
 }

 /*如果pbiFind是NULL, 说明参数pbToFree非法*/
	ASSERT(pbiFind != NULL);

	/*在释放前破坏掉要释放内存中的内容*/
	memset(pbiFind, bGarbage, sizeof(blockinfo));
	free(pbiFind);
}

作者没有用最佳的实现方法编写 FreeBlockInfo 函数,恰恰证明了编写优质代码是多么的困难!

避免使用嵌套的 ?:

函数 uCycleCheckBox 用于返回对话框的下一个状态值,参数是当前状态值。状态值有两种循环,可能在 0->1->0->… 内循环变化,也可能是在 2->3->4->2->3->… 内循环变化。

第一种代码:

unsigned uCycleCheckBox(unsigned uCur) 
{ 
 	return( (uCur<=1)? (uCur? 0:1): (uCur==4)? 2:(uCur+1) ); 
} 

嵌套使用?:的代码不具有可读性.

第二种代码:

usigned uCycleCheckBox(unsigned uCur) 
{ 
	unsigned uRet; 
	if(uCur <= 1) 
	{ 
		if(uCur != 0) /* 处理 0,1,0 …… 循环 */ 
			uRet = 0; 
		else 
			uRet = 1; 
	} 
	else 
	{ 
		if(uCur == 4) /* 处理 2,3,4,2 …… 循环 */ 
			uRet = 2; 
		else 
			uRet = uCur + 1; 
	} 
	return(uRet) 
} 

?: 改为 if 语句.

第三种代码:

unsigned uCycleCheckBox( unsigned uCur ) 
{ 
	unsigned uRet; 
	if( uCur <= 1 ) 
	{ 
		uRet = 0;   /* 处理 0,1,0 …… 循环 */ 
		if( uCur == 0 ) 
			uRet = 1; 
	} 
	else 
	{ 
		cuRet = 2; /* 处理 2,3,4,2 …… 循环 */ 
		if( uCur != 4 ) 
			uRet = uCur + 1; 
	} 
	return( uRet ); 
} 

认真看下这三种版本的代码,他们都不具备可读性:一眼看过去,代码意图不够明显。虽然这些函数都能正确的维护两个循环,但实现方式就像用废机油清理发动机一样自欺欺人(没有实质上的改进)。它们的本质都是相同的,只不过是 3 种稍微不同的实现方式。完全可以写出更好的实现代码,只需要真正的思考,而不是只停留在表面。

第四种代码:

unsigned uCycleCheckBox( unsigned uCur ) 
{ 
 	ASSERT( uCur >= 0 && uCur <= 4 ); 
    
  	if( uCur == 1 )   		/* 重新开始第一个循环?*/ 
		return( 0 ); 
 	if( uCur == 4 )    		/* 重新开始第二个循环?*/ 
		return( 2 ); 
 	return( uCur + 1 );   	/* 这时没有任何特殊处理 */ 
} 

第五种代码:

unsigned uCycleCheckBox( unsigned uCur ) 
{ 
  	static const unsigned uNextState[] ={1,0,3,4,2 }; 
    
  	ASSERT( uCur >= 0 && uCur <= 4 ); 
    
	return ( uNextState[uCur] ); 
} 

虽然第五种代码没有第四种那么容易理解,但它是最简洁、执行速度最快的代码。第五种代码需要添加注释,告诉别人为什么要在数组中存储那些数字。

每种特殊情况只能处理一次

也就是编程界中最重要的基本原则之一:尽一切可能消除重复

不要过高的估计代价

Macintosh 操作系统进行版本升级后,Excel 不能正常工作了。Apple 请求 Microsoft 删除过时的工作区以保持与最新的操作系统一致。

但是,删除 Excel 的工作区就意味着要重写关键的手工优化的汇编函数。重写后的代码会增加 12 个指令周期。因为函数很关键,关于是否重写函数的争论持续了很久。一部分人认为要与 Apple 保持一致,另一部分人则要保持速度。

最后,一个程序员在这个函数中放入一个临时计数器,然后运行 Excel,进行了三个小时的高强度测试,考察这个函数被调用了多少次。这个数字很大,有 76000 次。虽然这个数字很大,但是重写函数并执行 12 个额外指令周期 76000 次,也只不过增加 0.1 秒,这还是在最慢的 Macintosh 电脑上得出的结果。有了这些发现,代码很快进行了更改。

这个例子说明了:关心局部效率是不值得的。如果你很注重效率的话,请集中于全局和算法的效率上,这样你才会看到努力的效果。这个例子还说明了一个问题,在《代码整洁之道-程序员的职业素养》一书中对此进行了描述:

凡是不能在 5 分钟内解决的争论,都不能靠辩论解决

争论之所以要花这么多时间,是因为各方都拿不出足够有力的证据。如果观点无法在短时间里达成一致,就永远无法达成一致。唯一的出路是,用数据说话。

消除不一致

很多场合需要用到字符流,比如两个设备间通讯。向对方传送一个 short 型数据,需要将这个数据拆分成两个字节,然后根据协议决定先发送低字节还是高字节。在接收方,需要将两个字节数据合并成一个 short 型数据,这里给出 3 种代码。

第一种代码:

word = high << 8 + low ; 

这个代码是错的。因为忽略了运算符 + 优先级大于 << 。即便写成 word = (high << 8) + low ;也不是理想代码,因为混用了位操作符和算术操作符。如果只使用位操作符或者算术操作符,出错的概率就要小一些,因为凭直觉,同一类操作符的优先级容易掌握。

第二种代码:

word = high * 256 + low;  	/* 算术解法 */ 

第三种代码:

word = high << 8 | low;  	/* 移位解法 */ 

小结

  • 在选择数据类型的时候要谨慎。虽然 ANSI 标准要求所有的执行程序都要支持 char、int、long 等类型,但是它并没有具体定义这类型。为了避免程序出错,应该只按照 ANSI 的标准选择数据类型。
  • 存在这种可能,你的算法正确,但是因为运行在指标不理想的硬件上,也会产生 BUG。所以要经常检查计算结果和测试结果,避免你的数据类型范围上溢或下溢。
  • 代码如实的反映你的设计。引入细微错误的最简单方法就是代码与设计不符。
  • 一个函数只做一件事。避免不必要的分支,用一条路径完成这件事。不管什么输入都执行相同的代码,就会降低出现遗留 BUG的概率。
  • if 语句是个特别好的警告信号,表明你可能正在做不必要的工作。问自己:为了去除这个特殊条件,我如何更改设计?然后努力消除每一个不必要的if语句。有时需要修改你的数据结构,有时又要改变自己看问题的方式。透镜是凸起的还是凹下的,取决于你在那一面观察。
  • 不要忘记,if 语句有时会隐藏在 whilefor 循环的控制表达式中。?: 操作符是 if 的另一种表达形式。
  • 警惕有风险的编程惯用语,比如用移位代替除法等。关注那些类似但更安全的惯用语。要特别注意那些可能会给你带来更好性能的代码微调。
  • 一个表达式中尽可能只用相同类型的运算符。如果必须混用运算符,使用小括号把它们分开。
  • 错误处理是特殊情况中的特殊情况。只要有可能,就不要调用会返回失败情况的函数。如果必须要调用会返回错误值的函数,尝试把错误处理本地化——这样能增加发现错误处理代码中的 BUG 的机会。
  • 有时候,通过确保你想做的事情不会失败,来消除一般的错误处理,这是有可能的。这可能需要在初始化期间处理一次错误,或者从根本上改变你的设计。






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

  • 17
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值