动贩卖机接口
微软公司提供给员工的一大福利就是免费的汽水、加味矿泉水、牛奶(也有巧克力调味乳!)跟小纸盒装的果汁,多少都没关系,随你取用。可是很该死的,如果你要甜点,就得自己付钱了。偶尔我想吃点零食,就得走到贩卖机前面去。投入硬币后,我按下 4 跟 5 ,然后讨厌的看着机器吐出墨西哥辣椒口味的口香糖,而不是我要的老祖母牌花生奶油饼干。在看了一下饼干的代号后,我知道我又弄错了:我要的饼干是该按 21 号,投 45 先令的硬币。
那台自动贩卖机总是让我生气,如果制造它的工程师肯多花半分钟来想想他们设计上的缺失,就不会让我跟其它人花钱却按到自己不想要的东西了。如果有个设计者想, " 嗯,人们应该会在投钱的时候想着' 45 先令'-我赌一定有人会跟着也在把价钱当成选择东西时的代号而弄错想要的东西。要避免这种事情发生,我们应该用字母键代替数字键来选择点心的种类。 "
改良过的机器制造起来不会花更多钱,也不会改变什么设计方式,可是如果机器是如我上头说的改良过的,当我想按下 45 时,我就会知道那不是我想按的,而会记得要去按字母键来选择我要的点心。这样的接口设计可以让人们不会弄错。
当你设计函式的接口时,你一定经历过类似的问题。不幸的,程序员们不常被训练成思考其它人会怎样使用他们写的函式,而且就像那不自动贩卖机一样,设计上的一点些微差异都可能制造出错误或者避免错误的发生。你写的函式没有错误还不够;你写出来的东西使用起来还要是安全的才行。
getchar 当然会传回整数值
许多标准 C 语言链接库函式跟数以千计用到这些链接库函式的其它函式,都跟我提到的贩卖机一样有着令人喷饭的设计方式。就拿 getchar 函式来说,它的接口设计就有好几个危险之处,而最严重的问题在于这样的设计容易让程序员们写出有问题的程序代码来。看看 Brian Kernighan 跟 Dennis Richie 在 The C Programming Language 一书中提到的:
考虑这段程序代码
char c;
c = getchar();
if (c == EOF)
...
在一部没有正负号扩展功能的机器上, c 一定是正的,因为它是个字符变量,而 EOF 是个负数。所以,这个测试的结果一定会不成立。要避免这种情形,我们得小心地使用整数变量来存放 getchar 传回来的结果,而不是用字符变量。
像 getchar 这样的名称当然会让人想把 c 定义成一个字符,而这就是为何程序写作者会在这里出错的原因。不过实际上,为何 getchar 会如此危险?它又没有什么大功能:只不过是试着从一个周边装置取得一个字符,并传回一个可能的错误状态而已。
底下的程序代码片段说明了另一个许多函式接口中常见的错误:
/*
strdup - 配置一块内存,并将
输入的字符串复制到新配置的内存中。
*/
char *strdup(char *str)
{
char *strNew;
strNew = (char a)malloc(strlen(str)+1);
strcpy(strNew, str);
return (strNew);
}
这段程序代码会运作得很正常,直到你用光了内存,碰到了 malloc 传回来配置内存失败,传回来一个 NULL 指针的错误情形。谁晓得在 strNew 是个 NULL 指针时, strcpy 会作出什么事来?也许是把程序当掉,或把内存填上垃圾,反正都不是写程序的人想要的结果。
程序员们在使用 malloc 跟 getchar 时会碰到问题,因为他们写的程序即使有缺陷,在大部分情形下也会正常运作着。只有在几星期或几个月后,就像铁达尼号的意外一样,一连串不可能发生的事情凑在一起酿成了灾难,程序才会非预期的当死。并不是说 malloc 跟 getchar 让程序员们写错了程序代码;只是程序员们忽略了这两个函式可能产生的错误状态。
getchar 跟 malloc 的问题在于它们传回来的值不完全是真正的结果。有时它们传回来你期望的有效资料,其它时候它们则传回来神奇的错误值。
如果 getchar 不会传回那个好笑的 EOF 值,那把 c 宣告成字符变量就没什么错误,也不会出现 Kernighan 跟 Ritchie 说的那个问题了。同理,如果 malloc 不会传回 NULL ,程序员就不用处理错误状态了。这些函式的问题并不在于它们会有错误状态,而在于它们把这些错误状态的反应经由传回一般结果的管道送出,让程序员容易因此忽略了错误情况的处理。
如果你重新设计 getchar 来让他同时传回两种输出结果,那会怎样呢?它会传回 TRUE 或 FALSE 让你判断是否正确读到了资料,而如果读到了数据,会把读到的字符放在一个经由指针参考到的字符变量内:
flag fGetChar(char *pch); /* 函式的雏形宣告 */
使用上面这样的接口宣告,程序很自然就会被写成这样:
char ch;
if (fGetChar(&ch))
ch存放着下一个字符;
else
碰到了档案结尾,ch放着是垃圾内容;
字符型态跟整数型态差异的问题消失了,而且对任何资历的程序员来说,都不会忘了检查是不是有错误发生。比较 getchar 跟 fGetChar 传回的值,你看到了 getchar 比较强调传回来的字符,而 fGetchar 比较强调传回来的错误状态了吗?如果你想写出零错误程序,你觉得该强调哪一种传回值的方式?
对,你失去了把程序写成底下这样的弹性
putchar(getchar());
可是你能多么确定 getchar 一定会正确读到字符?在几乎任何情形下,上头的程序代码都可能造成错误。
有些程序员可能会想, " 当然, fGetChar 有个比较安全的接口,可是你为了在呼叫它时传入额外的参数而浪费了些程序代码。如果程序员把 ch 当成了 &ch 送进去给它呢?毕竟,忘了写 & 是程序员在写用 scanf 函式时的老毛病了。 "
好问题。
编译器产生的执行码好不好完全看编译器的最佳化程度而定,不过大部分的编译器都会在呼叫上头的函式时产生多一点执行码。不过这么微小的程序大小增长并不值得忧虑,尤其当你考量磁盘空间与内存成本正快速下滑,而程序复杂性与随之而来的错误发生率正逐渐攀升时,这样的差距在未来只会拉得更大。
第二个重点-拿个字符变量当成 fGetChar 的字符指针参数用的情形-如果你在使用第一章中建议的函式雏形检查的话,就不用担心了。如果你把字符指针以外的东西传给 fGetChar ,编译器就会自动产生错误讯息,并告诉你哪边出错了。
把不同输出结果混在单一个返回值中送出的做法反映了汇编语言时代中一部微处理器只有有限缓存器能用来处理资料跟传递资料的现实限制。在那样的环境中,一个缓存器能同时传回两个输出结果不只是有效率而已,还经常是必要的。可是用 C 语言写程序就不同了-即使 C 语言让你 " 更接近低阶环境 " ,那并不表示你应该把程序当成高阶的汇编语言来写。
当你设计函式接口时,选择能让程序员把程序一次就写对的方式。不要用让人伤脑筋的双重用途返回值的设计-每个输出结果都应该只用来表达一种东西。设计接口时就明确限定各个输出结果的用法可以让使用者不会忽略掉重要的细节。
让错误状态难以忽略。
不要将错误代码跟别的输出结果一起放在返回值中。
考虑多一点
程序员们当然知道他们哪时会从一个返回值中获得多个输出结果,所以照着上面的建议作挺容易的-不要这么把一堆东西塞在一个返回值里头就好了。不然这样的使用接口就像特洛伊木马一样隐藏着危险。看看底下这个改变内存块大小的程序代码:
pbBuf = (byte *)realloc(pbBuf,sizeNew);
if (pbBuf != NULL)
初始化或使用更大的缓冲区
你看出哪里有问题了吗?如果你不晓得,不用担心-问题严重,但被隐藏起来了,如果没有提示,只有很少人找得出这个问题。所以,来个提示吧:如果 pbBuf 是唯一指向这块改变大小的内存的指针,那在 realloc 失败时会发生什么事?答案是 NULL 将会在 realloc 返回时盖过 pbBuf 本来的值,毁掉指向本来内存块的唯一指针。这段程序代码制造了遗失的内存块。
这就有个问题了:
你多常把要更动大小的内存块的新旧指针放在不同变量中?我会把这个问题想象成开一辆车进饭店中,而开另一辆车出来。当然,你会有想将新指针放到不同变量中的时候,但一般说来,如果你改变一块内存的大小,你会把变更过的结果放回本来的指针变量中。这就是为何程序员们常掉进 realloc 的陷阱中了, realloc 就像微软里头那个自动贩卖机的接口一样容易让人出错。
理想上, realloc 永远会将错误码跟指向内存块的指针一起传回来,无论内存块的大小有没被改变。那应该是两个不同的输出结果。让我们再看一下 fResizeMemory ,那个我在第三章中提到的 realloc 包装函式。底下是拿掉了除错码的 fResizeMemory 的样子:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte * )ppv;
byte *pbNew;
pbNew = (byte a)realloc(*ppb, sizeNew);
if (pbNew != NULL)
*ppb = pbNew;
return (pbNew != NULL);
}
注意一下上头那个 if 叙述-他确保本来的指针永远不会被销毁。如果你从头用 fResizeMemory 替代 realloc 来重写本节开头那段程序,你会写成
if (fResizeMemory(&pbBuf,sizeNew))
初始化或使用更大的缓冲区
在这里,如果改变内存块大小的动作失败了, pbBuf 会保留原样,继续指向本来的内存块; pbBuf 不会被改成 NULL 。这就是你想要的行为,不过又有个问题: " 一名程序员如果使用 fResizeMemory ,会不会遗失内存块? " 还有另一个问题: " 程序员会不会忘了处理 fResizeMemory 的错误状况? "
另一个要提出来的有趣问题是,如果程序员们习于照着本章稍早的建议- " 不要把错误码放在返回值中 " -就不会设计出像 realloc 这样的东西来了。他们最先构想出来的程序会像 fResizeMemory 的那样-而不会有 realloc 遗失内存的问题。本书中的建议各有相关,以你所意想不到的方式交互作用着,刚提到的不过是其中一个例子而已。
不过将你的输出结果分开来并不能永远保证你的设计接口不会出现陷阱。我希望我能提供更好的意见,不过唯一能捉出那些隐藏现影的办法就是停下来,好好思考你的设计方式。最好的做法就是检查每个可能有的合并式输出入参数,并考虑这么做会有哪些可能造成问题的副作用。我知道这有时令人讨厌,不过记住,多花掉的思考时间对日后所需的除错时间而言,可以说是相对廉价的。最糟糕的做法就是跳过这个步骤,让不知其数的程序员们追踪找寻并修正一堆由这个很糟糕的接口所带来的问题。
想象全世界的程序员们已经浪费了多少时间在追踪寻找由 getchar , malloc 跟 realloc 的陷阱所造成的问题-比较起来,利用这三者之中一个写出来的包装函式所需花的时间就显得微不足道了。前人们浪费在找寻不必要的错误上的时间难道不足以让你清醒过来吗?
永远找寻并消除接口中的缺失。
单功能内存管理器
虽然我在 第三章 中花了许多时间讨论 realloc 函式的问题,我并没有提到它更奇怪的许多地方。如果你找出你的 C 语言链接库参考手册,找到 realloc 的完整叙述,你会发现像下面的文字:
void *realloc(void *pv,size_t size);
realloc 改变先前配置出来的一块内存的大小,内存块的内容保持不变。
?
如果新内存块比本来的小, realloc 会释放内存块尾端不要了的内存,而 pv 值维持不变。
如果新内存块比本来的大,扩大了的内存块可能会被放在新地址,并将本来的内容复制过去。一个指向扩大后的内存块的指针值会传回来,而内存块新增部分的内容未经初始化。
如果你试图扩大内存块而 realloc 失败了,会传回一个 NULL 指针。 realloc 在缩小内存块时一定会成功。
如果 pv 值是 NULL , realloc 会呼叫 malloc(size) ,并传回新配置内存地址的指针,或者如果这个要求也失败了,就传回 NULL 指针。
如果新的大小是 0 ,而 pv 不是 NULL , realloc 就会如同呼叫了 free(pv) 般把内存释放掉,而会传回 NULL 。
如果 pv 值是 NULL 而新的内存块大小是 0 ,结果是未定义的。
哦! realloc 是个标准通杀型实作的范例-整个内存管理器的功能全塞在一个函式之中。那你还要 malloc 作什么?你还要 free 作什么? realloc 不是把它们的功能全通吃了吗?
你不应该把一个函式设计成这样子,有几个好理由。首先,你不能期望程序员安全的使用这个函式。这样的函式有许多细节连经验老到的程序员都不完全知晓。如果你怀疑这点,尽可以问看看有多少程序员知道把一个 NULL 指针传给 realloc 可以有 malloc 的效果,看看有多少人知道把 0 放入 size 参数中呼叫 realloc 会有如同呼叫 free 的效果。是的,这些都是相当神秘的行为,所以他们应该都知道怎样避免这些东西制造问题出来。再问问看,当他们使用 realloc 来扩大一块内存时,他们晓不晓得 realloc 可能会把内存块的地址搬动?
这里还有 realloc 的另一个问题:你可以把垃圾丢给 realloc ,可是由于它的定义是如此一般化,它根本很难检查参数到底对不对。如果你错传了一个 NULL 进去,那对 realloc 而言是合法的参数。如果你把 0 当成了 size 传进去,那对 realloc 也是合格的用法。如果你在要变更一块内存的大小时却不小心配置了一块新的内存,或是把本来的内存释放掉了,那真的太糟糕了。如果实作上每个用法都是对的,你要怎么检查 realloc 的参数有没有效?不管你丢什么参数给它, realloc 都能处理,即使是很极端的参数值。它一方面可以 free 掉内存块,另一方面又能配置内存块出来。这些是完全相反的行为,不是吗?
老实说,程序员们不常坐下来想想, " 我就是想把整个子系统都写在一个函式中。 "realloc 跟其它类似的函式出现的理由很简单,如果它们不是被慢慢写成多功能的函式,就是程序员们把一些本来实作外的功能加了进去,改写正式的函式功能叙述来容纳这些 " 侥幸 " 行为。
不管什么理由,如果你写了个多功能函式,把它打散开来。把 realloc 拆开来,就成为扩大内存块,缩小内存块,配置内存块跟释放内存块。藉由将 realloc 拆成四个分开的函式,你可以做出更好的错误检查。如果你想缩小内存块,你知道怎样的指针才是合格的参数,而且新的大小一定要小于(或等于)本来的大小,其它的情形都是错误状态。有了分开的 ShrinkMemory 函式后,你就可以用除错检查宏来核对呼叫参数了。
在某些状况中,你可能真的会想有个多功能的函式。举例来说,当你呼叫 realloc 时,你能经常确切掌握新内存块的大小吗?这全看程序而定,不过我经常不晓得一块内存可能会变多大(虽然我还是找得出信息来掌握这一点)。我发现有个同时能够缩小跟扩大内存块的函式会比较好些,这样我就不用在每次我需要改变内存块大小时写个 if 叙述了。是的,我放弃了一些额外的参数检查,不过好处是我不用在多写那些 if 了。我永远知道我要配置内存或释放内存,所以我把这些功能从 realloc 拿掉了,把它们放在不同的函式中。第三章中的 fNewMemory , FreeMemory 跟 fResizeMemory 就是三个定义完善的函式。
如果我在开发一个正常状态下我都晓得我该扩大或缩小一块内存的程序,我还是会把扩大跟缩小内存块的功能从 realloc 中分离出来,并创造两个额外的函式 :
flag fGrowMemory(void **ppv, size_t sizeLarger);
void ShrinkMemory(void *pv, size_t sizeSmaller);
这个分离工作让我不只能够彻底检查指针跟大小参数,还能以较低的风险呼叫 ShrinkMemory 函式,因为这样可以保证内存块一定会被缩小而不会被搬移到新的地址去。我不用再写成底下这样
ASSERT(sizeNew <= sizeofBlock(pb)); /* 核对pb跟sizeNew */
(void)realloc(pb,sizeNew); /* 缩小内存不会失败 */
我可以写成这样
ShrinkMemory(pb,sizeNew);
这样子就好了。使用 ShrinkMemory 而不用 realloc 的简单理由是,这样子程序更简洁了。用 ShrinkMemory ,你不需要解释它不会失败的注释,也不需要把无用的返回值再转换型态成 void ,也不用在检查 pb 跟 sizeNew 的值合不合格- ShrinkMemory 把这些工作全完成了。
不要写出多功能的函式。
把不同功能分开来写可以加强参数核对的检查。
定义不明确的输入
稍早我提过,你的输出结果应该分开来放,避免让使用函式的程序员造成困扰。如果你把同样的建议用在设计函式的输入参数上,你自然就不会写出像 realloc 这样多功能的函式。 realloc 用了个指向内存块的参数,可是这参数有时可以是 NULL ,让 realloc 模仿 malloc 的行为。 realloc 也有个大小参数,可是这参数可以为 0 ,这时 realloc 会模仿 free 的行为。这样奇怪的参数也许不够有杀伤力,可是毁了合理性。脑筋急转弯一下,底下的程序会改变内存块大小,配置内存块,还是释放内存块?
pbNew = realloc(pb, size);
你分不出来;可能是上述动作的任何一个-完全取决于 pb 跟 size 的内容而定。可是如果你晓得 pb 一定指向合格的内存块而 size 一定得是个合格的内存块大小,那你就可以立刻知道这一小段程序会改变内存块的大小。就像明确的输出结果会让错误检查更容易些,明确的输入参数也是如此。这样的明确性对于得阅读跟理解不是自己写的程序代码的维护程序员来说,真是无价之宝啊。
有时多用途的输入参数并不像 realloc 的那样容易找出来。举例来说,看看底下这个将前面 size 个字符从 strForm 复制成一个存放在 strTo 中的字符串的特制字符串复制子程序:
char *CopySubStr(char *strTo, char *strFrom, size_t size)
{
char *strStart = strTo;
while (size- > 0)
*strTo++ = *strFrom++;
*strTo = 0';
return (strStart);
}
CopySubStr 类似标准的 strncpy 函式,可是它保证 strTo 中的是个真正以零字符结尾的 C 语言字符串。一般你会用 CopySubStr 来把一个大字符串的一部份取出来-像是从一个装有一星期中每个日子名称的字符串里取出某一天的名称出来:
static char strDayNames[] = unMonTueWedThuFriSat";
.
.
.
ASSERT(day >= 0 && day <= 6);
CopySubStr(strDay, strDayNames + daya3, 3);
现在你晓得 CopySubStr 怎么用了,可是你看到那个有问题的输入方式了吗?如果你写除错维护检查来查核这些参数,你就容易找出问题何在。对 strTo 跟 strFrom 的检查会写成
ASSERT(strTo != NULL && strFrom != NULL);
可是你怎么检查 size 参数? 0 是合格的吗?如果 size 比 strFrom 的长度大呢?如果你检查程序代码,你会看到它同时处理两种状态。 while 循环在 size 一开始就是 0 时会脱离,所以 0 是合格的参数;如果 size 比 strFrom 的长度大, while 循环会将整个字符串都复制,包括结尾的零字符串。你只需要帮这函式写个注释说明一下,如后头的例子。
/* CopySubStr - 从字符串中取出子字符串。
* 将strFrom字符串前面size个字符存成放在strTo的另一个字符串。
* 如果strFrom的长度小于size,就会将整个字符串复制到strTo.
* 如果size为0,strTo会变成空字符串。
*/
char *CopySubStr(char *strTo, char *strFrom, size_t size)
{
.
.
.
这样子的说明眼熟吗?当然,因为这个函式的做法就跟灯泡上的灰尘一样常见。这样子仍然是处理 size 输入的最好方式吗?不尽然,至少在写作零错误程序时并不是这样子。
假设,举例来说,一名程序员在她呼叫 CopySubStr 时把 3 打成了 33 :
CopySubStr(strDay,strDayNames + day*3,33);
这真的是个错误,可是依照 CopySubStr 的定义而言,这样荒谬的值是完全合法的。喔,当然你大概会在发行程序前就抓出这个错误,不过你不会自动发现它;得要有人把它找出来。不要忘了,用一个在错误出现点附近的除错维护码找寻错误的原因要比从一个错误的输出来查看要快多了。
从零错误的观点来看,如果一个参数超出范围或无意义,就应该是非法的,因为默许使用这样奇怪的参数会隐藏错误,而非找出错误。一方面说来,允许松散的输入过关就是另一种防御性程序写作的做法。你可以为了程序的稳定留着这些防御性的程序代码,可是不要让这样有问题的输入参数过关:
/* CopySubStr - 从字符串中取出子字符串。
* 将strFrom字符串前面size个字符存成放在strTo的另一个字符串。
* 如果strFrom的长度小于size,就会将整个字符串复制到strTo.
* 如果size为0,strTo会变成空字符串。
*/
char *CopySubStr(char *strTo, char *strFrom, size_t size)
{
char *strStart = strTo;
ASSERT(strTo != NULL && strFrom != NULL);
ASSERT(size <= strlen(strFrom));
while (size- > 0)
*strTo++ = *strFrom++;
*strTo = 0';
return (strStart);
}
有时允许无意义的参数-如长度为 0 -是值得的,因为这让我们消除了呼叫端不必要的测试。举例来说,由于 memset 允许 size 参数为 0 ,所以在下面的程序片段中我们不需要那个 if 叙述:
if (strlen(str) != 0) /* 将str填成空格 */
memset(str,' ',strlen(str));
不过在你允许长度为 0 的时候,要小心。程序员们经常处理长度(或计数值为 0 )的情形,因为他们可以这么做,而不是因为他们应该这样做。如果你写了个需要长度参数的函式,你并不需要处理长度为 0 的状况。问问你自己, " 程序员们多常以长度 0 为参数来呼叫这个子程序? " 如果答案是永远不会,或者很少,就不要处理 0 的状况;改用除错检查来找出这些状态。记住,每当你放松一项限制,你就减少了一个捉住相关错误的机会。一开始就选择严格的输入定义来达到最大的除错检查效率是个好规矩。如果你稍后发现这样的限制太严苛,你可以拿掉这样的限制而不会影响到程序的其它部分。
我在第三章中把 NULL 指针的检查加到 FreeMemory 中时,便使用了这样的想法。由于我永远不会以 NULL 指针呼叫 FreeMemory ,对我来说,有这样强化的检查就更重要了。你的观点也许不同-这没什么对错之别。只要确定你所作的是个清醒的选择而不只是因为你习惯如此,就好了。
不要优柔寡断。
把函式参数明确定义好。
现在不要让我失望
微软公司有条在面试时询问求职者技术问题的政策,对程序员来说,那就是问些程序设计的问题。我习惯从写出标准的 tolower 函式问起。我会把一份 ASCII 字符表拿给我所面试的人,问说, " 你该怎样写出一个会将大写字符转换成小写字符的函式? " 我会谨慎的模糊处理符号跟小写字符的区别,主要是想看看程序员们会怎么处理这些情形。这些字符会保持不动吗?程序中怎样检查错误?符号跟小写字符会被忽略掉吗?超过一半的人会写出像这样的东西:
char tolower(char ch)
{
return (ch + 'a' - 'A');
}
这程序代码在 ch 是个大写字符时有效,可是如果 ch 是别的东西,它就毁了。当我告诉我所面谈的人这一点时,他们有时会说, " 我假设这字符一定是大写的。如果这字符不是大写的,我会原封不动的把它传回来。 " 这是个合理的做法;其它做法也差不多。少部分人会说, " 我没想到那点。我可以修改一下,在 ch 不是大写时,传回一个错误。 " 有时他们会让 tolower 传回 NULL ,或是零字符,不过清楚的胜选应该是 -1 :
char tolower(char ch)
{
if (ch >= 'A' && ch <= 'Z')
return (ch + 'a' - 'Z');
else
return (-1);
}
传回 -1 违反我稍早所建议的接口规范,因为它把错误值跟正常资料混在一起了。可是问题并不在于我所面谈的人没有做到一项他们可能没听过的建议,而在于他们犯下了一个不必要的错误。
这带来了另一种看法:如果函式本身传回错误状态,每个呼叫那函式的程序都得处理那个错误。如果 tolower 会传回 -1 ,就不能把程序写成底下这么简单了:
ch = tolower(ch);
你得写成像这样子:
int chNew; /* 这必须是个int整数型态,才装得下-1。 */
if ((chNew = tolower(ch)) != -1)
ch = chNew;
如果你想到你得怎么呼叫 tolower ,你会觉得会传回一个错误状态并不是定义这函式的最好方法。
如果你发现自己设计了一个会传回错误状态的函式,停下来问问你自己,是否有别的方式可以重新设计出不需要错误状态的函式。比起只把 tolower 定义成一个 " 把大写字母转成小写字母再传回来 " 的函式, " 让它在不能转换时传回原来的字符 " 会是个更好的设计方式。
如果你发现没办法拿掉传回错误状态的设计,考虑不要让产生错的状况出现。举例来说,你可以要求 tolower 的参数一定要是大写的字母,并说其它字符都是不合格的。你可以使用除错检查来核对这个参数:
char tolower(char ch)
{
ASSERT(ch >= ' && ch <= ');
return (ch + ' - ');
}
不管你重新定义函式,或是不让引起错误的情况出现,你解决了呼叫者必须进行执行期错误检查的问题,让程序代码更小而问题更少了些。
写函式时要让输入合格而不会出错。
跨行读取
我再三强调,从呼叫者的观点检查函式接口是很重要的一件事情。当你晓得一个定义出来的函式会从许多地方被呼叫时,不去检查它是怎么被使用的就显得很愚蠢了。 getchar , realloc 跟 tolower 的例子已经让我们了解到这一点-这些都是用起来很复杂的东西。不过混淆输出跟传回不需要的错误码并不是唯一让程序变得更复杂的方法。有时只需要在函式的输入上轻忽一下,就会让程序变得更复杂。
假设你想改善程序的磁盘存取部分,你碰到一个档案存取指针移动的呼叫是写成这样的:
if (fseek(fpDocument, offset, 1) == 0)
.
.
.
你晓得有个档案指针移动的动作出现了,你也看得出来错误是怎么处理的,可是这个呼叫的可读性怎样?哪一种档案指针移动在进行着-从档案头,档案指针现在的位置,还是从档案尾?如果传回来的值是 0 ,那表示成功还是失败?
假设程序员把这呼叫以预先定义的名称呼叫:
#include <stdio.h> /* 引入SEEK_CUR的定义 */
#define ERR_NONE 0
.
.
.
if (fseek(fpDocument, offset, SEEK_CUR) == ERR_NONE)
.
.
.
这样会清楚些吗?当然。不过这一点也不令人惊奇-程序员们数十年前就已经了解要避免在程序中使用奇怪的数字。我要指出的是, NULL , TRUE 跟 FALSE 并不是为了这种理由而存在的。它们常用,可是并不是用来当成神奇数字的文字替代式。举例来说,这底下的呼叫各作了什么?
UnsignedToStr(u, str, TRUE);
UnsignedToStr(u, str, FALSE);
你大概能猜出这些呼叫会把无号数转换成字符串,可是那个布尔型态的参数怎么影响整个转换?如果我把呼叫写得成这样,会不会更清楚些?
#define BASE10 1
#define BASE16 0
.
.
.
UnsignedToStr(u, str, BASE10);
UnsignedToStr(u, str, BASE16);
当一名程序员坐下来写这样的函式时,这些布尔( boolean )型态值的用法也许十分清楚。首先程序原先丢出一段叙述说明,然后再开始写程序:
/* UnsignedToStr
* 这个函式会将一个无号数转换成字符串。
* 如果fDecimal为TRUE,u就会被转换成十进制数字字符串;
* 不然会被转换成十六进制数字字符串。
*/
void UnsignedToStr(unsigned u, char犘trResult, flag fDecimal)
{
.
.
.
有比这样子更清楚的做法吗 ?
现实是布尔型态的参数往往表示程序员们没深思他们在作什么。函式本身作的或者是两件不同的工作,由布尔型态的参数决定要作哪一件,或者函式本身虽具弹性,程序员决定只用布尔型态的参数来选择两个他或她觉得有用到的状况;往往这两种情形都成立。
如果你把 UnsignedToStr 当成一个处理两件不同工作的函式,你可能会拿掉那个布尔型态的参数,而把 UnsignedToStr 拆成两个不同的函式:
void UnsignedToDecStr(unsigned u, char *str);
void UnsignedToHexStr(unsigned u, char *str);
不过有个更好的解决方案-把那个布尔参数改变一下型态,会让 UnsignedToStr 变成更有弹性的泛用函式。不要用 TRUE 或 FALSE 来决定输出结果的基底,而让使用函式的程序员决定要转换成哪个基底的结果:
void UnsignedToStr(unsigned u, char *str, unsigned base);
这个做法给了你一个清楚、弹性的设计,让呼叫这函式的程序代码具可读性,而同时又增加函式的功能性。
这意见也许跟我稍早提到的严格定义参数的建议相冲突-我们把本来固定的 TRUE 或 FALSE 输入,变成了一个大部分可能值都不太常用到的一般性输入。不过记住,虽然基底参数很有弹性,你永远可以加上一个除错检查来确认它的值是 10 或 16 。如果你之后决定你也需要二进制或八进制的转换,你可以把除错检查放宽,让程序员能够传入 2 或 8 的基底参数。
这远比我看过的一些有着 TRUE , FALSE , 2 跟 -1 参数的函式要好太多了!因为布尔参数不容易扩充,你可能得用这样无意义的做法,或是改变每个已经存在的呼叫叙述。
让程序在呼叫时可被理解。
避免使用布尔型态的参数。
标明危险性
作为对抗错误的最后一道防线,在你的文件中强调危险之处,并说明你希望人们怎样使用你的程序代码。不要这样说明 getchar,
/* getchar - 这跟getc(stdin)的作用一样。 */
int getchar(void)
.
.
.
那帮不了程序员们什么忙,你应该写成像这样:
/* getchar - 跟getc(stdin)有着相等效果。
*
* getchar传回stdin的下一个字符。
* 当错误发生时,它会传回一个整数的EOF值。
* 典型的用法是
*
* int ch; // ch必须是个整数型态的变量,以装下EOF值。
*
* if ((ch = getchar()) != EOF)
* // 成功 - ch就是下一个字符
* else
* // 失败 - ferror(stdin) 可以告诉你错误的型态
*/
int getchar(void)
.
.
.
如果你把两份说明都丢给一名刚学 C 语言链接库的程序员,你觉得哪一份比较能让人注意到使用 getchar 的危险性?你想她会自己写个新函式来读取字符,还式照抄你写的文件中的标准用法来满足需求?
帮函式加上说明文件的另一个正面副作用是,如此一来,它可以强迫一名吊儿郎当的程序员停下来想想其它人怎么使用他们的函式。如果一名程序员写了一个接口冗笨的函式,他应该会在试着写出 " 典型用法 " 的范例时,发现这样的接口实在不好。不过即使他没察觉这样的接口问题,只要他提供的例子完整而正确,那也就没关系了。如果 realloc 的文件说明提供了一个如后面这样的范例,你觉得怎样?
/* realloc(pv,size)
...
一个典型的用法是
void *pvNew; // 用来检查realloc失败的情形
pvNew = realloc( pv,sizeNew);
if (pvNew != NULL)
{
// 成功 - 更新pv
pv = pvNew;
}
else
// 失败 - 不要把原有的pv改成pvNew的NULL值
*/
void *realloc(void *pv, size_t size)
.
.
.
经由复制这样的范例,一个不太小心的程序员就更能避免本章稍早提到的内存遗失问题了。你的例子没办法保护所有程序员,可是这样的说明就像药罐子上头的警告,能够影响某些人,产生一些助益。
不过不要将使用范例当成设计完善接口的替代做法。 getchar 跟 realloc 都有着容易引人出错的接口-它们的危险性应该被消除掉,而不光只是把它们的危险性说出来就好了。
注释说明潜在危险。
恶魔躲在细节中
设计防弊接口不难,但需要多思考些,也需要有意愿放弃根深蒂固的程序写作习惯。本章的建议说明了接口上的简单改变能怎样让程序员们不用花太多脑筋就能写出正确的程序来。综观本章,关键的观念在于 " 让每件事都尽可能明白而清楚。 " 如果程序员了解而且计得每个细节,他们也许就不会出错了;不过程序员们还是会犯错,尤其在他们忘了或根本不知道重要细节时。让程序员们不容易无意间将细节遗漏;设计个防弊的接口吧。
快速回顾
建立易用而易懂的函式见面:确定输入跟输出各址代表一种资料。在输出入参数中混合错误跟其它特殊用途的值只会弄乱你的接口。
设计强迫程序员们思索如错误处理等重要细节的函式接口,不要让他们易于忽视或遗忘细节。
考虑程序员们必须如何呼叫函式的问题。在函式接口中找寻会让程序员们不经意的制造错误的缺失。特别重要的是:努力将函式写成一定会让呼叫者不用处理错误状况的形式。
增加清晰性,并经由确定程序员们可以了解对函式呼叫的用法来减少错误的发生。神奇数字的挑选跟布尔型态参数的使用背离了这个目标。
将多功能函式拆开来。拆开后的函式名称不只增进可读性(像是以 ShrinkMemory 取代 realloc ),还能以更严格的除错检查来自动找寻错误参数的问题。
说明你接口的用法,让程序员们知道如何正确呼叫函式。强调危险的地方。
该多想想的事
1. 本章开头的 strdup 函式配置了一块内存给复制的字符串,可是会在内存配置失败时传回 NULL 。该怎么帮 strdup 设计一个防弊接口?
2. 我提到布尔输入值的使用并不是最好的函式接口设计方式,那布尔输出值呢?例如,如果 fGetChar 失败了,它传回一个 FALSE ,要求程序员呼叫 ferror(stdin) 来找出错误原因。有没有一个更好的 getchar 接口?
3. 为何 ANSI 的 strncpy 函式会让粗心的程序员犯错?
4. 如果你熟悉 C++ 的 inline 含入函式限定字,描述一下它对于写作防弊接口的贡献。
5. C++ 提供了类似 Pascal 中 VAR 参数的 & 参考参数语法。不用再写成底下这样
6. flag fGetChar(char *pch); /* 函式雏形宣告 */
7. .
8. .
9. .
10. if (fGetChar(&ch))
ch存放了新的字符...
而可以写成
flag fGetChar(char &ch); /* 函式雏形宣告 */
.
.
.
if (fGetChar(ch)) /* 真正传过去的是&ch. */
ch存放了新的字符...
表面上,这似乎是个改进,因为程序员们不会 " 忘记 " 一般 C 语法中明确要求的 & 了。可是这样的用法为何不但不能防弊,反而容易产生错误?
11. 标准的 strcmp 函式取得两个字符串参数后,将两者的每个字符进行比较。如果两个字符串相等,就传回 0 ;如果第一个字符串小于第二个字符串,就传回负值;否则传回正值。那么,当你呼叫 strcmp 时,程序代码通常长这样子:
12. if (strcmp(str1, str2) rel_op 0)
13. .
14. .
.
这里的 rel_op 是 == , != , > , >= , < 或 <= 其中之一。可以这么用,可是除非你熟悉 strcmp 函式,不然这样的程序代码一点意义也没有。说明一下至少两个别种字符串比较的函式接口。这些接口除了应该要能防弊,还要比 strcmp 接口要有可读性。
学习计划:
复习一下标准 C 链接库,然后重新设计出更具防弊性的接口。将函式改为更能让人理解的名称的优缺点更为何?
学习计划:
找寻一大段程序中所有的 memset , memmove , memcpy 跟 strn 型的函式( strncpy 等)。这些呼叫中,有多少个要求函式要接受长度为 0 的参数?你的组织要求这样的便利性更甚于纠正这种用法容易产生的错误吗?