附錄C 解答
本附录中包含所有本书中所有 " 该想一想的事情 " 小节中的问题的解答,不过那些开放思考性的 " 学习计划 " 并不包含在这附录中。
第一章
1. 由于编译器把那个运算顺序的错误解释成底下这样,而能找出问题来:
while (ch = (getchar() != EOF))
换句话说,编译器发现一个表达式的结果被指派给 ch 时,认为你把一个 == 打成了 =, 就发出一个可能指派错误的警告。
2a. 一个简单找出不小心打出来的 " 八进制数字 " 错误的办法是打开让编译器在碰到八进制数字常数十发出错误的编译器选项。变通绕过这错误的办法是使用十进制或十六进制的数字来替换八进制数字的值。
2b. 要找出程序员把 && 打成 & (或是把 || 打成 | )的错误,编译器只要打开找出 == 错打成 = 时用的那个编译器选项就好了。编译器会在发现你在任何 if 叙述中或是任何复合条件式中使用 & (或 | )而没将结果与 0 明确进行比较时发出错误讯息,所以底下这样应该会产生错误讯息:
if (u & 1) /* u是奇数? */
而这样子不会产生错误讯息:
if ((u & 1) != 0) /* u是奇数? */
2c. 一个简单找出不必要的注释的方法,就是让编译器在注释的第一个字符是个字母或是括号时发出警告讯息。这样的测试会逮到底下两种可疑的情形:
quot=numer/*pdenom;
quot=numer/*(pointer expression);
要避开这种警告,你可以用空格或括号隔开 / 跟 * :
quot = numer / *pdenom;
quot=numer/(*pdenom);
/*But note:这个注释会产生警告讯息。 */
/* 这样子不会发出警告讯息,因为开头隔了一个空格。 */
/*----------这样子也不会产生警告讯息。---------*/
2d. 你可以让编译器找出没括号起来的表达式中可能出问题的运算子对来找出运算顺序可能出错的地方。例如,程序员们有时会在混用 << 跟 + 运算子时出现运算顺序错误的问题,而编译器就会对如下这般的程序代码发出警告:
word = bHigh << 8 + bLow;
编译器不会对底下的叙述发出警告,因为它们用了括号:
word = (bHigh << 8) + bLow;
word = bHigh << (8 + bLow);
一种降低混淆的做法是采用如 " 如果两个运算子有不同的运算顺序而它们没被括号围起来,就发出警告 " 的测试法则。这样子判断其实过于简化,不过还是能有效的让你得到警示。开发一种好的测试法则需要在编译器中执行大量程序来检查法则中的各项条件,直到编译器产生出有用的结果。你当然不想在下面这样常见的表达式中碰到编译器发出的警告讯息:
word = bHigha256 + bLow;
if (ch == ' ' ?? ch == '\t' ?? ch == '\n')
3. 编译器会在碰到两个连续的 if 叙述接着一个 else 时发出可能出现孤悬 else 叙述的警告讯息:
if (expression1) / if (expression1)
if (expression2) if (expression2)
. .
. .
. .
else else
. .
. .
. .
要避开这种警告,你可以在里头那个 if 叙述周围加上中括号,让 else 的归属问题明朗化:
if (expression1) if (expression1)
{ {
if (expression2) if (expression2)
. .
. .
. .
} else
else .
.
.
. }
4. 将常数跟表达式放在比较式左边有帮助,是因为它可以提供另一种自动侦测错误的方法,不过这方法不幸的只有在有操作数是常数或表达式时才有用-如果两边的操作数都是变量时,这方法就毫无用处了。这方法的另一个问题是程序员们必须记得用这方法来写程序。
如果,另一方面,你打开编译器选项,编译器就会对每个可能有指派问题的地方发出警告。更棒的,这个选项对那些只上过程序设计课程而不知道要用调换比较式中操作数位置的方法的程序员们一样管用。
如果你有这种编译器选项,就把它打开来;如果你没有,在你获得一个更有帮助的编译器之前,就把比较式中的常数跟表达式放到式子的左边去吧。
5. 要避免未定义的前置处理器宏产生意料之外的结果,编译器(其实真正作这件事的是前置处理器)应该有个让程序员把未定义宏的使用当成错误状态的选项。现在的 ANSI C 编译器都支持原来的 #ifdef 前置处理指示跟在 #if 表达式中使用新的 defined 运算子的做法,就不太有必要再将未定义的宏当成 0 处理了。替代底下这种在 #if 表达式中使用未定义宏的做法,
/* 判断目的平台条件 */
#if INTEL8080
.
.
.
#elif INTEL80x86
.
.
.
#elif MC680x0
.
.
.
#endif
如果你打开禁止使用未定义宏的编译器选项,上头的写法就会出现错误,你应该改用 defined 运算子来写:
/* 判断目的平台条件 */
#if defined(INTEL8080)
.
.
.
#elif defined(INTEL80x86)
.
.
.
#elif defined(MC680x0)
.
.
.
#endif
这编译器选项在碰到在 #ifdef 叙述中使用未定义宏时,不应该发出警告讯息,因为这种宏用法应该是故意写出来的。
第二章
1. 一个 ASSERTMSG 宏的可能写法可以让这宏同时核对表达式跟显示检查错误讯息的字符串。例如要印出 memcpy 讯息时,你可以这样子呼叫 ASSERTMSG :
2. ASSERTMSG(pbTo >= pbFrom+size || pbFrom >=
pbTo+size,"memcpy: the blocks overlap");
在后头列出来的 ASSERTMSG 宏的实作中,你应该把宏写在一个表头档中,而把 _AsserMsg 例程写在另一个方便取用的原始码档案中。
#ifdef DEBUG
void _AssertMsg(char *strMessage); /* 函式雏形宣告
*/
#define ASSERTMSG(f,str) \
if (f) \
{} \
else \
_AssertMsg(str)
#else
#define ASSERTMSG(f,str)
#endif
底下是另一个档案中的例程实作:
#ifdef DEBUG
void _AssertMsg(char *strMessage)
{
fflush(NULL);
fprintf(stderr, "\n\nAssertion failure in
%s\n",strMessage);
fflush(stderr);
abort();
}
#endif
3. 简单的做法是-如果编译器支持的话-把编译器选项打开,让编译器将所有相同的字符串都存放在相同位置。如此一来,即使除错检查宣告了 73 份相同文件名的字符串,编译器也只会配置一个字符串的空间出来。这样子作的缺点是,它会将原始码档案中所有相同的字符串叠放在一起,不光是除错检查使用的那些字符串会被存放在一块而已,你可能不想碰到这种情形。
一个变通的办法是改变 ASSERT 宏的写法,让它故意在档案中参考相同的文件名字符串。唯一的困难在于建立文件名字符串,不过那并不是一件很难的事情-你可以将细节放在每个原始码档案开头都使用一遍的新 ASSERTFILE 宏中:
#include <stdio.h>
.
.
.
#include <debug.h>
ASSERTFILE(__FILE__)
.
.
.
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
byte *pbTo = (byte *)pvTo;
byte *pbFrom = (byte *)pvFrom;
ASSERT(pvTo != NULL && pvFrom != NULL);
.
.
.
看得出来 ASSERT 的用法还是没变吧?底下是实作 ASSERTFILE 宏跟 ASSERT 宏新版本的写法:
#ifdef DEBUG
#define ASSERTFILE(str) \
static char strAssertFile[] = str;
#define ASSERT(f) \
if (f) \
{} \
else \
_Assert(strAssertFile, __LINE__)
#else
#define ASSERTFILE(str)
#define ASSERT(f)
#endif
使用这个版本的 ASSERT ,你可以回收海量存储器 . 例如在我在书中用来测试这宏的小程序中,新的实作只用掉 3K 的资料空间。
4. 使用这种除错检查的问题是测试中包含了应该放在函式的非除错版中的程序代码,而且这个非除错版的程序代码的 do loop 无穷循环只有在碰到 ch 等于换行字符时才会结束。这函式应该写成这样子:
5. void getline(char *pch)
6. {
7. int ch; /* ch必须是个整数。 */
8.
9. do
10. {
11. ch = getchar();
12. ASSERT(ch != EOF);
13. }
14. while ((*pch++ = ch) != '\n');
}
15. 一个简单找出 switch 叙述中没有更新过的错误的办法,是把除错检查放在 default 状态中,对非预期的状态发出警告。有时 default 状态应该永远不会发生,因为所有可能状态都被明确处理掉了。当所有状态都明确处理完成后,加上这样子的检查:
16. .
17. .
18. .
19. default:
20. ASSERT(FALSE); /* 我们应该永远碰不到这个检查。*/
21. break;
}
22. 设计上,表格中的每个项目的位样式都应该是个对应屏蔽的子集合。例如说,在屏蔽为 0xFF00 时,位样式就不能有任何设立位出现在低字节中;不然就不可能有任何指令在屏蔽后吻合那样的位样式了。 CheckIdInst 子程序可以透过核对位样式是不是屏蔽的子集合的方式来加强:
23. void CheckIdInst(void)
24. {
25. identity *pid, *pidEarlier;
26. instruction inst;
27.
28. for (pid = &idInst[0]; pid->mask != 0; pid++)
29. {
30. /* 确定位样式是屏蔽的子集合。 */
31. ASSERT((pid->pat & pid->mask) == pid->pat);
32. .
33. .
.
34. 用除错检查来核对 inst 内部不存在有问题的设定:
35. instruction *pcDecodeEOR(instruction inst,
36. instruction *pc,opcode *popc)
37. {
38. /* 我们有将CMPM或CMPA.L指令弄错吗? */
39. ASSERT(eamode(inst) != 1 && mode(inst) != 3);
40.
41. /* 在非缓存器状态,只允许绝对字组跟长字组模式。 */
42. ASSERT(eamode(inst) != 7 ||
43. (eareg(inst) == 0 || eareg(inst) == 1));
44. .
45. .
.
46. 选择备用算法的重点在于跟本来的算法的不同性。要核对 qsort 是否正确运作,你可以检查排序后的资料顺序是否正确。检查顺序并不会更动资料的顺序,所以够格当成不同的算法来处理。要检查二元搜寻是否正确,可以用线性搜寻法来检查两种搜寻方式是否得到同样的结果。最后,要检查 itoa 函式,可以将它传回的字符串结果再转换成整数,跟原来传给 itoa 的整数值比较;这两个整数值应该是相同的。
当然,你大概不会在每一份你写的程序中使用备用的算法-除非你在替航天飞机、核子发电厂或任何出错就会造成生命威胁的机械装置写程序。不过你或许应该在程序所有重要的部分使用备用算法来检查错误。
第三章
1. 你可以使用不同的除错值来清理未初始化跟已释放内存的内容,让未初始化内存与已释放内存的使用更容易现形。例如 fNewMemory 会使用 bNewGarbage 清除新配置的未初始化内存内容,而 FreeMemory 会在释放内存时使用 bFreeGarbage 毁掉本来的内存内容:
2. #define bNewGarbage 0xA3
#define bFreeGarbage 0xA5
fResizeMemory 会产生这两种类型的垃圾值-你可以用上述两个值,也可以自己再设定两个不同值。
3. 要抓出 " 超出边界写入 " 的错误的一个办法是,定期检查每个已配置内存块后头的字节有没被更动过。不过这种测试看来虽然简单,却需要纪录每一块内存后头的字节内容,而且带来读取不属于已配置内存空间内容的问题。幸运的,要写出这样的测试方式有个简单的办法,只要你在配置内存时多配置一字节的空间就好了。
举例来说,当你以 size=36 的参数呼叫 fNewMemory 时,你可以一次配置 37 字节的内存,并将一个已知的 " 除错字节 " 放到多出来的那一个字节内。相似的,你也可以在 fResizeMemory 呼叫 realloc 时多配置并设定一个字节。要找出 " 超出边界写入 " 的问题,你可以把除错检查放到 sizeofBlock, fValidPointer, FreeBlockInfo, NoteMemoryRef 跟 CheckMemoryRefs 中,来检查除错字节有没有被动过。
下面是一种实作这个测试程序的一种方式。首先,你应该定义 bDebugByte 跟 sizeofDebugByte :
/* bDebugByte是个存放在除错版本程序配置的每一块内存末端
* 的神奇数字。sizeofDebugByte式传给malloc跟realloc
* 时真正增加的配置内存数量。
*/
#define bDebugByte 0xE1
#ifdef DEBUG
#define sizeofDebugByte 1
#else
#define sizeofDebugByte 0
#endif
接下来,你可以用 sizeofDebugByte 来调整 fNewMemory 跟 fResizeMemory 中对 malloc 跟 realloc 的呼叫参数,并用 bDebugByte 在内存配置成功时填入多余的字节:
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte **)ppv;
ASSERT(ppv != NULL && size != 0);
*ppb = (byte *)malloc(size + sizeofDebugByte);
#ifdef DEBUG
{
if (*ppb != NULL)
{
*(*ppb + size) = bDebugByte;
memset(*ppb, bGarbage, size);
.
.
.
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte **bNew;
.
.
.
pbNew = (byte *)realloc(*ppb, sizeNew +
sizeofDebugByte);if (pbNew != NULL)
{
#ifdef DEBUG
{
*(pbNew + sizeNew) = bDebugByte;
UpdateBlockInfo(*ppb, pbNew, sizeNew);
.
.
.
最后,在 sizeofBlock, fValidPointer, FreeblockInfo, NoteMemoryRef 跟 CheckMemoryRefs 这些附录 B 中的子程序中加上:
/* 核对内存块末端的内容有无被更动过。 */
ASSERT(a(pbi->pb + pbi->size) == bDebugByte);
修改了这些部分后,内存子系统就能够在程序写入超出已配置内存块末端时找出问题来了。
4. 你有许多种方法可以找出不完全孤悬指针的问题来。一种做法是将除错版的 FreeMemory 改成不会在收到指针时立刻释放内存块,而是将内存块的指针放到一个自由内存串行中。(串行中都是向系统配置好了,对程序却算是已释放了的内存块。)这样子修改 FreeMemory 可以避免一个 " 已释放 " 内存块在内存子系统能够经由 CheckMemoryRefs 核对前被重新配置出去。 CheckMemoryRefs 会检查内存系统,并将 FreeMemory 留下的自由内存串行中的内存块真正释放掉。
现在,虽然这种做法可以找到不完全孤悬指针的问题,你大概不会在程序碰到这样的问题之前使用这个方法。理由:这做法违反了除错码应该是多余-而非不同-的程序代码的原则。
5. 要核对指针参考到的内存块大小,你必须考虑两种情形:指向整块内存的指针跟指向内存块内部配置区域的指针。对于前者,你所能采用的最严格检查是核对指针是否指向内存块开头,而内存块大小是否吻合 sizeofBlock 函式所传回的结果。对于后者,则只能采取较松散的检查:指针必须指向一块内存,而且大小必须小于内存块的长度。
所以我们不使用现成的 NoteMemoryRef 例程来标示两种指针指向的内存,而用两个函式来标示两种类型的内存块。对于指向内存块内部配置区域的指针,你可以将现成的 NoteMemoryRef 函式加上一个 size 参数,在标示指向整块内存的指针时,你可以建立一个新的 NoteMemoryBlock 函式。
/* NoteMemoryRef(pv, size)
*
* NoteMemoryRef将pv指向的内存块标示成已参考状态。注
* 意:pv不用指向一块内存的开头;它只要指向一块已配置记忆
* 体内的地址就可以了,不过那块内存在被指到的地址之后至少
* 要有size个字节的空间才行。注意:用NoteMemoryBlock
* 来检查指向内存块开头的指针-那个函式可以提供更严格的检
* 查。
*/
void NoteMemoryRef(void *pv, size_t size);
/* NoteMemoryBlock(pv, size)
*
* NoteMemoryBlock将pv指向的内存块标示成已参考状态。注
* 注意:pv必须指向一块刚好有size个位组长度的内存块开
* 头。
*/
void NoteMemoryBlock(void *pv, size_t size);
这两个函式可以让你找出这个问题中提出的错误情形。
6. 要改善附录 B 中例程的完整度检查,你可以先把 blockinfo 结构中的参考旗标改成参考次数,再修改 ClearMemoryrefs 跟 NoteMemoryRef 来处理这个参考次数。那部分简单,问题在于你该如何修改 CheckMemoryRefs, 好让它能够对某些被多重参考的内存块发出错误讯息而让其它也被多重参考了的内存块过关?
这问题的一个做法是增强 NoteMemoryRef 函式,让它在指向内存块的指针之外再接受一个内存块 ID 的卷标输入。 NoteMemoryRef 可以将这卷标存放在 blockinfo 结构中,而 CheckMemoryRefs 可以在之后用这卷标来核对参考次数。底下就是这个修改版的写法。表头注释可以参考附录 B 中本来的函式。
/* blocktag是个程序中所有类型已配置内存块的串行。
* ClearmemoryRefs将所有内存块设定成tagNone.
* NoteMemoryRef将内存块的卷标设定成某个指定类型。
*/
typedef enum
{
tagNone,
tagSymName,
tagSymStruct,
tagListNode, /* 串行的节点必须被参考两次。*/
.
.
.
} blocktag;
void ClearMemoryRefs(void)
{
blockinfo *pbi;
for (pbi = pbiHead; pbi != NULL; pbi = pbi-
>pbiNext)
{
pbi->nReferenced = 0;
pbi->tag = tagNone;
}
}
void NoteMemoryRef(void *pv, blocktag tag)
{
blockinfo *pbi;
pbi = pbiGetBlockInfo((byte a)pv);
pbi->nReferenced++;
ASSERT(pbi->tag == tagNone || pbi->tag ==
tag);pbi->tag = tag;
}
void CheckMemoryRefs(void)
{
blockinfo *pbi;
for (pbi = pbiHead; pbi != NULL; pbi = pbi-
>pbiNext)
{
/* 一个内存块完整度的简单检查。如果除错检查失败
* 了,就表示管理blockinfo的除错码出问题了,或者
* 可能是有错误的内存写入毁了内存配置纪录的资
* 料结构。无论何者,都是错误的情形。
*/
ASSERT(pbi->pb != NULL && pbi->size != 0);
/* 对遗漏的内存块进行检查。如果除错检查失败了,
* 就表示程序遗失了一块内存的配置纪录,或是有整
* 体内存块的指针没被NoteMemoryRef纳入管理。
* 有些型态的内存块可能被参考多次。
*/
switch (pbi->tag)
{
default:
/* 大部分内存块都只会被参考一次而已。 */
ASSERT(pbi->nReferenced == 1);
break;
case tagListNode:
ASSERT(pbi->nReferenced == 2);
break;
.
.
.
}
}
}
7. MS-DOS, Windows 跟麦金塔程序开发人员一般会使用一种会快速吞噬内存空间的工具让程序的内存配置失败,以测试程序如何处理内存耗尽的情形。这做法可行,可是不精确-它没办法准确控制程序中内存配置失败处的位置。如果你要测试个别功能,这做法就不怎么管用了。一个更好的办法是直接在内存管理程序中实作一个内存耗尽状态的仿真程序。
不过要注意,内存配置失败祇是资源配置失败的一种而已-你可能会碰到磁盘空间耗尽,列表纸张用完,电话忙线中等等各种错误。你真正需要的,是一种能够伪造各种失误状态的泛用工具。
一种可行做法是建立一个 failureinfo 结构,其中包含告诉失误处理机制该如何处理各种状态的信息。这构想是让程序员跟测试圆能够从外部测试中考验特定功能的失误处理能力,微软的应用程序常有除错专用的对话窗口,在 Excel 这样有宏语言的程序中也有除错专用的宏,让测试人员能用这些除错工具来自动测试各种失误情形。
要在内存管理程序中宣告一个 failureinfo 结构,你可以加上
failureinfo fiMemory;
然后在 fNewMemory 跟 fResizeMemory 中仿真内存用完的失误状态,你可以在两个函式中加上一小段除错码:
flag fNewMemory(void **pv, size_t size)
{
byte **ppb = (byte **)ppv;
#ifdef DEBUG
if (fFakeFailure(&fiMemory))
{
*ppb = NULL;
return (FALSE);
}
#endif
.
.
.
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
#ifdef DEBUG
if (fFakeFailure(&fiMemory))
return (FALSE);
#endif
.
.
.
修改了这些东西之后,新的失误处理机制就等着上路了。要让它开始动作,你可以呼叫 SetFailures 函式来初始化 failureinfo 结构:
SetFailures(&fiMemory, 5, 7);
以 5 跟 7 的参数呼叫 SetFailures 是在让失误处理系统知道你在引起七次连续的错误之前会呼叫这个系统五次。 SetFailures 的两种常见呼叫方式是
/* 不伪造任何失误状态。*/
SetFailures(&fiMemory, UINT_MAX, 0);
/* 永远伪造失误状态。 */
SetFailures(&fiMemory, 0, UINT_MAX);
使用 SetFailures, 你可以设计出重复呼叫相同程序代码而每次呼叫时都设定不同 SetFailures 参数来仿真所有可能错误型态的单元测试。一种常见的测试方式是将第二个 " 失误 " 参数值设定在 UINT_MAX, 而让第一个 " 成功 " 次数逐一从 0 递增- " 一定失误 " -到某个够大到测试系统中所有对这系统的成功呼叫的次数。
最后,你有时会想呼叫内存系统,磁盘系统跟其它提供资源的子系统而不想碰到任何伪造出来失误状态;这通常是当你从其它除错程序代码中配置资源时。后面两个可巢状使用的函式让你能够暂时关闭这个失误处理机制。
DisableFailures(&fiMemory);
配置一些内存资源
EnableFailures(&fiMemory);
底下的程序代码实作了构成失误处理机制的四个函式:
typedef struct
{
unsigned nSucceed; /* 伪装失误之前的呼叫次数 */
unsigned nFail; /* 伪装失误的次数 */
unsigned nTries; /* 已经呼叫过的次数 */
int lock; /* 如果大于零,就会关闭失误伪装机制。*/
} failureinfo;
void SetFailures(failureinfo *pfi, unsigned
nSucceed,unsigned nFail)
{
/* 如果nFail为零,nSucceed就必须为UINT_MAX。 */
ASSERT(nFail != 0 || nSucceed == UINT_MAX);
pfi->nSucceed = nSucceed;
pfi->nFail = nFail;
pfi->nTries = 0;
pfi->lock = 0;
}
void EnableFailures(failureinfo *pfi)
{
ASSERT(pfi->lock > 0);
pfi->lock-;
}
void DisableFailures(failureinfo *pfi)
{
ASSERT(pfi->lock >= 0 && pfi->lock < INT_MAX);
pfi->lock++;
}
flag fFakeFailure(failureinfo *pfi)
{
ASSERT(pfi != NULL);
if (pfi->lock > 0)
return (FALSE);
/* nTries最大不超过UINT_MAX。 */
if (pfi->nTries != UINT_MAX)
pfi->nTries++;
if (pfi->nTries <= pfi->nSucceed)
return (FALSE);
if (pfi->nTries - pfi->nSucceed <= pfi->nFail)
return (TRUE);
return (FALSE);
}
第四章
第四章中没有任何问题,虽然给了几个学习计划。
第五章
1. strdup 的接口设计有危险性,因为它发生错误时会传回 NULL 指针,就像 malloc 的情形,这种错误可能会被忽略掉。一个降低错误性的接口会将错误状况与指针输出分开,让错误状态明显化。一个这样的接口可以写成底下那样
2. char *strDup; /* 指向复制好的字符串 */
3.
4. if (fStrDup(&strDup, strToCopy))
5. 成功 - strDup指向新的字符串
6. else
不成功 - strDup为NULL
7. getchar 一种比 fGetChar 更好的接口设计方式会传回一个错误代码,而不光只是传回一个 TRUE 或 FALSE 的 " 成功 " 与否的值-例如
8. /* These are the errors that errGetChar may return. */
9.
10. typedef enum
11. {
12. errNone = 0,
13. errEOF,
14. errBadRead,
15. .
16. .
17. .
18. } error;
19.
20. void ReadSomeStuff(void)
21. {
22. char ch;
23. error err;
24.
25. if ((err = errGetChar(&ch)) == errNone)
26. 成功 - ch含有下一个字符的内容
27. else
28. 失败 - err有错误原因的类型
29. .
30. .
.
这个接口比 fGetChar 好的原因在于它让 errGetChar 能够传回多种错误状态(也因此能传回多种成功状态)。如果你不在乎传回来的特定错误类型,你可以将区域变量 err 拿掉,而回头用 fGetChar 的接口:
if ((errGetChar(&ch)) == errNone)
成功 - ch含有下一个字符的内容
else
失败 - err有错误原因的类型
31. strncpy 的麻烦之处在于它的行为不一致:有时会将目的字符串以 nul 字符结尾,有时则不。 strncpy 与其它通用字符串处理函式摆在一起,让程序员们会误以为 strncpy 本身也是个通用函式,事实不然。有那种不寻常行为的 strncpy 应该不在 ANSI 标准中,它被放进了标准中只是因为它被广泛用在 ANSI 标准制定之前的 C 语言程序中。
32. C++ 的 inline 函式限定字的用处在于它让你可以把函式定义得根宏一般有效率-如果你的编译器够好-而不会有宏 " 函式 " 在评估参数值时的麻烦副作用。
33. C++ 新的 & 参考参数的严重问题在于隐藏了你实际上传递的是变量参考地址,而不是变量值,以致产生混淆。举例来说,假设你将 fResizeMemory 函式重新定义来使用参考参数,程序员们会写成
34. if (fResizeMemory(pb, sizeNew))
内存块大小改变成功
不过,要注意,不熟悉这函式的程序员们没理由相信 pb 在呼叫中会被改变。你觉得这对程序的维护会有什么影响?
一个相关的问题是,使用 C 语言的程序员们在知道传入的参数是变量值而不是变量参考时,常会改变形式参数的内容。可是如果维护程序员在修理不是他自己写的函式的错误,他没注意到函式宣告中的 &, 他可能会在函式里头改变参数的内容,而不知道这样的改变对影响到函式之外的情形。 & 参考参数的危险性在于会隐藏这样重要的实作细节。
35. strcmp 接口的问题在于传回值会让呼叫处产生不良程序代码。要改善 strcmp ,你会将接口设计成简单易懂的样子,即使对不了解这函式的人来说也是如此。
一种可行的接口设计方式是现有 strcmp 接口的一种轻微变形。对不相等的字符串,不再传回任意的正负值-那会让程序员们必须把所有结果都对 0 比较过-你可以把 strcmp 改成会传回三个定义完善的有名称常数:
if (strcmp(strLeft, strRight) == STR_LESS)
if (strcmp(strLeft, strRight) == STR_GREATER)
if (strcmp(strLeft, strRight) == STR_EQUAL)
另一种可行的接口设计方式是使用不同的函式来处理不同类型的比较:
if (fStrLess(strLeft, strRight))
if (fStrGreater(strLeft, strRight))
if (fStrEqual(strLeft, strRight))
第二种接口设计方式的优点在于让你能够使用宏透过现成的 strcmp 函式来实作这些函式:
#define fStrLess(strLeft, strRight) \
(strcmp(strLeft, strRight) < 0)
#define fStrGreater(strLeft, strRight) \
(strcmp(strLeft, strRight) > 0)
#define fStrEqual(strLeft, strRight) \
(strcmp(strLeft, strRight) == 0)
你可以再更进一步定义出使用 <= 跟 >= 进行比较的宏来增加可读性。这样子可以增进可读性,而不会造成程序大小或执行速度上的损失。
第六章
1. " 普通 " 单位元字段的可移植性值域只有 0 ,所以没什么用处。它还有个非零状态-可是你不知道那是 -1 还是 1 ,这完全看你的编译器内定位字段为有号数还是无号数而定。如果你将所有比较都限定成对 0 的比较,你就可以安全的使用单位元字段的两种状态。举例来说,如果你假设 psw.carry 是个 " 普通 " 的单位元字段,你就可以安全的使用底下四种测试写法:
2. if (psw.carry == 0) if (!psw.carry)
3.
if (psw.carry != 0) if (psw.carry)
不过底下的测试方式则因为它们依赖了你用的编译器设定而有危险性:
if (psw.carry == 1) if (psw.carry == -1)
if (psw.carry != 1) if (psw.carry != -1)
4. 传回布尔( Boolean )值的函式如同单位元字段般,你不能安全预测 "true" 传回来的是什么值。你可以信赖 FALSE 会为零的假设,可是程序员们经常会传回任何方便的非零值来表示 "true" ,使传回来的结果不等于常数 TRUE 。如果你假设 fNewMemory 会传回一个布尔值,你可以安全的使用底下的比较式
5. if (fNewMemory(...) == FALSE)
6.
if (fNewMemory(...) != FALSE)
或更好些,
if (!fNewMemory(...))
if (fNewMemory(...))
不过底下的写法则有危险,因为它假设 fNewMemory 永远不会传回一个 TRUE 以外的非零值:
if (fNewMemory(...) == TRUE) /* 危险! */
有条好规则要记得,永远不要将布尔值跟 TRUE 进行比较。
7. 如果你把 wndDisplay 宣告成一个 window 型态的整体数据结构,你就给了它一个其它窗口结构没有的性质:它是个整体数据结构。这看来只是微不足道的小地方,却可能带来不可预期的问题。例如当你要写一个窗口中所有子窗口跟它本身通通释放掉的函式时,你把函式写成这样:
8. void FreeWindowTree(window *pwndRoot)
9. {
10. if (pwndRoot != NULL)
11. {
12. window *pwnd, *pwndNext;
13.
14. ASSERT(fValidWindow(pwndRoot));
15.
16. for (pwnd = pwndRoot->pwndChild; pwnd !=
17. NULL;pwnd = pwndNext)
18. {
19. /* 在释放内存之前先取得"Next"指针的值。 */
20. pwndNext = pwnd->pwndSibling;
21. FreeWindowTree(pwnd);
22. }
23.
24. if (pwndRoot->strWndTitle != NULL)
25. FreeMemory(pwndRoot->strWndTitle);
26. FreeMemory(pwndRoot);
27. }
}
现在注意到,因为一般的窗口的指针 pwndDisplay 都指向一块已配置好的内存,如果你要释放每一个窗口,你可以安全把 pwndDisplay 传进去这个函式中。可是你不能把 &wndDisplay 传进去,因为这程序会试着释放 wndDisplay ,可是那是个整体数据结构而不可能被释放掉。要让这个程序代码能够处理 &wndDisplay, 你得加上
if (pwndRoot != &wndDisplay)
到 FreeMemory(pwndRoot) 的呼叫之前。如此一来,你就让程序与一个整体数据结构牵扯在一起了,有够讨厌。
要避免错误的一个最好办法就是避免写出随便设计出来的东西。
28. 第二个版本有好几个理由比第一个版本要危险。因为 A, D 跟运算是在第一个版本中是常见的程序代码 , 它们会被执行到-也因此会被测试到-不管 f 的值是什么。而在第二个版本中, A 跟 D 的程序代码会被分开测试,除非它们相同,不然你就冒着遗漏其中一种情形中的错误的风险。( A 跟 D 的程序代码在它们为 B 或 C 特别特别最佳化后不会相同的。)
在第二个版本中,你也会碰到让 A 跟 D 在程序员们修正跟增强程序时的同步问题,尤其当 A 跟 D 写得不一样时。所以,除非计算 f 的代价高到会让使用者注意到效率上的差异,不然就乖乖用第一个版本吧。记住这边的一条好规则:增加常见程序代码的量来降低程序代码的差异。
29. 使用相似的名称如 s1 跟 s2 是危险的,因为你容易在要打 s2 时误打成 s1. 更糟糕的,即使你打错字了,程序编译时也完全不会碰到问题。使用相似的名称也让你在用错变量名称时更难找出错误来:
30. int strcmp(const char *s1, const char *s2)
31. {
32. for ( ; *s1 == *s2; s1++, s2++)
33. {
34. if (*s1 == '\0') /* 到了字符串尾端了吗? */
35. return (0);
36. }
37.
38. return ((a(unsigned char a)s2 < a(unsigned char
39. a)s1) ?-1 : 1);
}
上头的程序错再返回叙述中的测试反了,可是因为变量命名得没什么意义,你很难看出里头有什么错误。如果你使用具描述性而且不同的名称,像是 sLeft 跟 sRight ,打错字或是用错变量的错误机会就大大消失了,而程序也可以更具可读性。
40. ANSI 标准保证你可以寻址一个宣告好了的数组后头一个字节,可是它不保证你可以参考这样的数组之前一个字节。标准中也不保证你可以参考用 malloc 配置得来的内存块之前一个字节。
举例来说,某些 80x86 内存模式中的指针是采用基底 : 位移的方式存放的,而只有位移值会被处理到。如果 pchStart 是这样子的指针,而且指向一块已配置内存的开头,它的位移量就会是 0 。如果你假设 pch 的值为 pchStart+size, pch 就永远不可以小于 pchStart, 因为它的位移值永远不可小于 pchStart 的 0 位移-位移量再降下去也只会回卷成 0xFFFF 。
41. a. 使用 printf(str); 而不用 printf("%s", str); 会在 str 包含任何 % 字符时发生错误; printf 会误将这些 % 字符当成格式限定字符使用。使用 printf("%s", str); 的麻烦则是它可能被明显最佳化成 printf(str); 粗新的程序员们有时会这样子更动程序而造成错误。
b. 用 f=1-f; 代替 f=!f; 的危险在于它假设 f 为 0 或 1, 而 !f 清楚说明你只是在翻转一个旗标状态,不管 f 的值为何都适用。使用 1-f 的唯一理由是这样子可以比 !f 产生出稍微有效率些的程序代码,可是记住一点,区域效率上的改善很少对程序的执行效率产生什么整体影响。使用 1-f 的写法只会增加多出一个错误的风险。
c. 在一个叙述中使用多个指派运算的危险在于它会带来预料之外的资料型态转换。以这里的例子来说,程序员们小心地把 ch 宣告成 int, 让它能够正确处理 getchar 会传回的 EOF 值,可是由于 getchar 的值先存放在一个字符串中,以致于这个结果先被转换成了字符型态,然后这个字符值-而不是本来传回的整数值-才指定给 ch 。这个非预期的型态转换重新带来了第五章中提到的 getchar 问题,尽管 ch 被小心的定义成了一个整数变量。
42. 一般说来,查表法可以让程序变得又小又快,也增进了程序的正确率。不过你可以从另一个角度来看待这个问题,当程序变小时,存放在表格中的资料也要占用内存,所以整体上,查表法可能会比非查表法的实作方式用掉更多内存。查表法的另一个问题是危险-你必须确定表格中的资料是正确的。有时检查表格资料的正确性很简单,像 tolower 跟 uCyclecheckBox 中的表格那样,可是在一个像第二章的反组译器中那样的大表格里头,就很容易潜藏问题。你要遵守的规则是,除非你能核对表格中的资料,不然不要用查表法。
43. 就算你的编译器不提供将乘法跟除法(在适当时)转换成位位移的最佳化,你也不用担心产生出来的程序代码执行效率不好;即使你把一个除法改用位位移来处理,程序效率也不会改善多少。不要为了一丁点编译器没最佳化好的效率问题,就去那样子改写程序。你该做的是把程序写好,换个好的编译器。
44. 要确保使用者的资料永远可以存盘,只要在使用者更动档案之前先配置好缓冲区就好了。如果每个档案都要有一个缓冲区,就将档案开启成只读状态,或是根本就不要开启档案。不果如果你处理所有开着的档案就只需要一个缓冲区,你可以在程序初始化时就将这缓冲区配置好。不要担心一直闲置这缓冲区会造成内存 " 浪费 " 的问题,至少这样的浪费保证你的使用者资料可以存盘,而不会让他或她工作了五个小时,却因为你的程序没办法配置缓冲区而存不了档。
第七章
1. 底下的程序代码改变了这函式的两个输入参数, pchTo 跟 pchFrom :
2. char *strcpy(char *pchTo, char *pchFrom)
3. {
4. char *pchStart = pchTo;
5.
6. while (*pchTo++ = *pchFrom++)
7. {}
8.
9. return (pchStart);
}
改变 pchTo 跟 pchFrom 不违反这些参数的写入权限,因为它们是传值参数, strcpy 收到的是输入值的内容复制品,所以 strcpy 可以改变这些参数内容。不过并不是所有的计算机程序语言- FORTRAN 就是个例子-都将参数以传值的方式传递。这做法在 C 语言中相当安全,不过在其它语言中可能是危险的。
10. strDigits 的麻烦在于它被宣告成一个静态指针,而不是静态缓冲区,这样潜藏的差异可能在你开启允许编译器将所有字符串内容当成常数看待的选项时造成问题。有些编译器支持 " 常数字符串内容 " 的选项,将所有字符串与其它常数存放在一起,由于常数不会被改变,编译器会检查所有常数字符串,将相同的叠放在一起。换句话说,如果 strFromUns 跟 strFromInt 都宣告了指向如 "?????" 这样的字符串的静态指针,编译器只会配置一份-而不是两份-那个字符串。有些编译器甚至会更彻底的将吻合其它字符串尾端内容的字符串合并,像是 "her" 吻合 "mother" 末尾的三个字母。改变这种字符串的一个,就会动到另一个的内容。
将所有字符串内容视为常数并限制程序读取是个安全多了的做法。如果你要改变一个字符串,宣告一个字符缓冲区,而非一个字符串指针:
char *strFromUns(unsigned u)
{
static char strDigits[] = "?????";
不过即使这样的程序都还有危险存在,因为它依赖程序员输入正确的问号数,而且假设末尾的 nul 字符一定不会被毁掉,使用问号字符来保留空间也不是个好点子。字符串内容是问号还是别的东西会有影响吗?如果你不确定,你就知道为何你应该用不同的字符来保留空间了。
一个较安全的写法是宣告缓冲区的大小,然后把除错检查换成一个写入确认:
char *strFromUns(unsigned u)
{
static char strDigits[6]; /* 5位数跟一个
. '\0' */
.
.
pch = &strDigits[5];
*pch = '\0'; /* 取代ASSERT。 */
11. 使用 memset 来处理化相邻的区域变量除了特别危险,还比直接初始化变量内容要没效率:
12. i = 0; /* 将i, j跟k设定为0。 */
13. j = 0;
k = 0;
或是这样子更简单
i = j = k = 0; /* 将i, j跟k设定为0。 */
这些程序代码既有效率又具可移植性,而且显然不需要批注,使用 memset 的版本就相反了。
我不确定本来的程序员试图用 memset 来改善什么,不过我确定他或她一定得不到好结果。对初学者来说,如果不是使用最好的编译器,呼叫 memset 的负担也比明确清除 i, j 跟 k 要重多了。不过即使程序员用的编译器聪明到将已知填入值跟长度的小型填入内存动作在编译时期变成内含指令处理,状况也不会改善多少:程序还是假设编译器会将 i, j 跟 k 在堆栈上相邻的配置在一起, k 在内存的最低地址上。程序代码也假设 i, j 跟 k 彼此相邻而没有任何 " 填充 " 字节将这些变量有效率的对齐特定内存地址。
不过谁说这些变量在堆栈框内有地方摆?好的编译器会常常作变量生命周期的分析,把区域变量在生命周期中用这些信息存放在缓存器内。 i, j 跟 k 这些区域变量在生命周期中可能都放在缓存器内, i 跟 j 可能就一生都在缓存器中,永远不会存放在堆栈框内。变量 k 另一方面必须在堆栈框内存放,因为它的地址传给了 memset -你没办法取得缓存器的地址。如此一来, i 跟 j 依然是未初始化状态,而接在 k 后头的 2*sizeof(int) 个字节会被错设成 0 。
14. 当你呼叫或跳到机器的 ROM 中一个特定地址时,你面对两种风险。首先,虽然你机器中的 ROM 永远不变,可是在你硬件的未来机种上, ROM 几乎是一定会被改过的。不过就算 ROM 中的长是永远不变,硬件厂商有时还是会用 RAM 中的常驻程序来弥补系统接口上的错误。如果你略过这些接口,你就连带的略过了这些修补错误的程序。
15. 把 val 当成不需要的参数而不传入的问题在于,呼叫者对 DoOperation 的内部运作方式作了跟 FILL 对 CMOVE 所作的一样的假设。假使程序员增强了 DoOperation ,并在过程中改写过程序,使 val 永远会被参考到:
16. void DoOperation(operation op, int val)
17. {
18. if (op < opPrimaryOps)
19. DoPrimaryOps(op, val);
20. else if (op < opFloatOps)
21. DoFloatOps(op, val);
22. else
23. .
24. .
.
那当 DoOperation 参考到不存在的 val 时,会发生什么事?视你的操作系统而定,不过大概会因为 val 处于堆栈框的只读区域内而使程序终止执行。
你可以强迫程序员们传入东西给没用到的变量,让他们难以在你的函式呼叫上省下这个动作。在文件中,你可以说, " 当你以 opNegAcc 的参数呼叫 DoOperation 时, val 参数就该为 0 。 " 一个放得好的除错检查可以让程序员忠实作到这点:
case opNegAcc:
ASSERT(val == 0); /* 传0给val。 */
accumulator = -accumulator;
break;
25. 那个除错检查核对 f 是不是 TRUE 或 FALSE 。这除错检查不光是不清楚,更重要的,除错码没必要这么挑剔;毕竟,这些程序代码在发行版本的程序中都会被去掉。那个除错检查最好改写成
ASSERT(f == TRUE ?? f == FALSE);
26. 不要把所有工作都挤在一行内完成。宣告个函式指针,把事情分成两行去作:
27. void *memmove(void *pvTo, void *pvFrom, size_t size)
28. {
29. void (*pfnMove)(byte *, byte *, size_t);
30. byte *pbTo = (byte *)pvTo;
31. byte *pbFrom = (byte *)pvFrom;
32.
33. pfnMove = (pbTo > pbFrom) ? tailmove : headmove;
34. (*pfnMove)(pbTo, pbFrom, size);
35.
36. return (pvTo);
}
37. 简单说,这程序代码依赖了 Print 程序代码的内部运作方式来呼叫 Print 。如果一名程序员改了 Print 的程序代码而不知道有其它程序代码以跳到 Print 进入点后四个字节的方式呼叫它,这名程序员就可能把程序改得让那样子呼叫 Print 的程序动弹不得。如果你发现你必须把程序写成那样子跳进一个例程中间去,那就让维护程序员们可以一眼看出这些进入点必须满足的条件:
38. move r0,#PRINTER
39. call PrintDevice
40. .
41. .
42. .
43. PrintDisplay: move r0,#DISPLAY
44. PrintDevice: ; r0 == 装置代号
45. .
46. .
.
47. 在微电脑的内存容量还很小,每个字节都还很珍贵的时候,跳进一个指令中间去执行是种常见的最佳化方式,使用这种技巧通常会省下一两个字节。不过这样子的做法不管是当时还是现在,都是不好的。如果你团队中有人还这样子写程序,礼貌的要求他们改变做法,不然就请他们离开你的小组。你不需要那样令人头痛的程序。
第八章
第八章中没有任何问题,只给了几个学习计划。