编写无错C程序系例(5)
第5章 糖果机界面
Microsoft雇员从公司得到的一个好处是可以随便享用免费的软饮料,如香味塞尔查矿泉 水、牛奶加巧克力和软包装果汁等,管够。但讨厌的是,如果你想吃糖果,就得自己掏 腰包。所以有时馋了,我就溜到自动售货机那儿。一次,我塞进几个25美分的硬币,然 后按下选择键4和5。但当售货机吐出茉莉香味的泡泡糖,而不是我想买的老奶奶牌花生 黄油饼干时我愣住了。自然,售货机没错,是我错了,45号是代表泡泡糖。看一眼售货 机上花生黄油饼干的小标记,进一步证实了我的错误。标记上写着花生黄油饼干,21 号,45美分。 这件事一直使我耿耿于怀,因为假如自动售货机的设计者多花30秒钟考虑一下他们的设 计,就不会使我以及无数其他人遇到这种事情:买了不想买的东西。如果他们想过: “嗯,人们在向键盘塞钱时常常会想着45美分 ─── 我敢打赌,人们在向键盘塞钱时 常常会把价钱错当选择号输入给售货机。因此,我们应该选用字母键,不应该使用数字 键,以避免这种情况。 这样设计自动售货机并不会增加他的造价,也不会明显改变它的原有设计,但每当在键 盘上敲入45美分时就会发现机器拒绝接受这种输入,提醒你敲入相应的字母代码。这种 设计会引导人们去做正确的事情。 当我们设计函数的界面时,所面临的是相同的问题。不幸的是,程序员不常考虑其他程 序员会怎样使用他所设计的函数。就像上面的糖果机界面一样,设计上的细微差别有可 能非常容易引起错误,也可能非常容易避免错误。光使设计出的函数没有错误并不够, 还必须使它们使用起来很安全。
很自然,getchar()会得到一个int 标准的C库函数以及按照该模式编写的数以千计的其它函数,都有着上述糖果机式界面, 容易使用户犯错误。就说getchar函数吧,我们有充足的理由说这个函数的界面是有风险 的,其中最严重的问题是该函数的设计名鼓励程序员编写有错的代码。关于这一点,还 是让我们看看Brian Kernighan和Dennis Ritchie在其“C 程序设计语言”一书中是怎么 说的吧:
考虑以下的代码: char c; c = getchar(); if( c == EOF ) …… 在不进行符号扩展的机器上,c总是正数因为它是char类型而EOF却是负数,结果上面的 测试条件总会失败。为了避免这一点,必须用int而不用char来保存getchar返回值的变 量。 这不是说明即使有经验的程序员也必须小心谨慎地使用函数吗?按照getchar这样的函数 名,将c定义成字符类型是很自然的事情,这就是程序员会遇到这个错误的原因。但 getchar非得如此有害不可吗?该函数要做的工作并不复杂,不过是从某个设备上读入一 个字符并返回可能的错误情况。 以下代码给出了另一个常见的问题: /* strdup ─── 为一个字符串建立副本 */ char* strdup( char* str ) { char* strNew; strNew = (char*)malloc( strlen(str)+1 ); strcpy( strNew, str ); return( strNew ); } 这个函数在一般的情况下都会工作得很好,除非内存空间耗尽引起了malloc的失败。这 时,它返回一个不指向任何内存单元的NULL指针。但当目的指针strNew为NULL时,鬼才 知道strcpy会做些什么。strcpy不论是失败,还是悄悄地冲掉内存中的信息都不是程序 员所期望的。 程序员之所以在使用getchar和malloc时会遇到麻烦,是因为他们能够写出即使有缺陷但 表面上仍能工作的代码。直到几个星期甚至几个月后,才会碰到一连串不易发生的事件 而导致这些代码的失败,就像泰坦尼克号邮船沉没的灾难一样。getchar和malloc都不能 引导程序员写出正确的代码,都极易使程序员忽视错误情况。 getchar和malloc的问题在于他们返回的值不精确。有时他们返回所要的有效数据,但另 一些时候他们却返回不可思议的错误值。 假如getchar不返回奇怪的EOF值,把c声明为字符类型就是正确的,程序员也就不会遇到 Kernighan和Ritchie所说的错误。同样,假如malloc不返回好象是内存指针的NULL,程 序员就不会忘记对相应的错误进行处理。问题在于不怕这些函数返回错误,而怕他们把 错误隐藏在程序员极易忽视的正常返回值中。 如果我们重新设计getchar,使他们分别返回两个不同输出怎么样?它可以根据是否成功 读入一个新的字符而返回TRUE或FALSE,并把读入的字符返回到一个通过引用传递给他的 变量中: flag fGetChar(char* pch); 通过这一界面我们可以很自然地写出 chat ch; if( fGetChar( &ch ) ) ch中是下一个字符 else 碰到了EOF,ch中是无用信息 这样一来,“char还是int”的问题就解决了。任何程序员,不管多么幼稚都不太可能偶 然忘记测试它的错误返回值,比较一下getchar和fgetchar的返回值,你看出getchar强 调的是所返回的字符而fGetChar强调的是错误情况吗?如果你的目标是编写出无错的代 码,那么你认为应该强调哪一方面? 确实,这样一来在编写代码时就失去了下面的灵活性: putchar( getchar() ); 但你知道getchar的失败频度有多高吗?而几乎在所有的情况下,上面的代码都会产生错 误。 一些程序员可能会想:“确实fGetChar的界面很安全,但却浪费了代码。因为在调用它 时,必须多传一个参数。另外如果程序员没有传递 &ch 而传递了ch怎么办?当程序员使 用scanf函数时,忘记相应的 & ,长期以来一直是一个出错的根源。” 问得好。 编译程序生成代码的好坏其实取决于具体的编译程序,有的编译程序生成稍多的代码, 有的稍少,因为我们不必在每次调用该函数之后对函数的返回值和EOF进行比较。不管稍 多也好稍少也罢,考虑到磁盘和存储器价格的暴跌,同时程序的复杂性及相应的错误率 骤增,代码大小上的细微差别也许并不值得顾虑。 在于第二个问题 ─── 比如为fGetChar传递了字符而不是字符指针,在采用了第1章建 议的函数原型之后也用不着担心。如果给fGetChar传递了非字符指针的其它参数,编译 程序会自动地产生一条错误信息向你指明所犯的错误。 事实上把相互排斥的输出组合到单一返回值中的做法是从汇编语言继承下来的。对于汇 编语言来说,只有有限的机器寄存器可以用来处理和传递数据。因此,在汇编语言环境 中使用一个寄存器返回两个相互排斥的值既有效率常常又是必需的。然而用C编程是另一 回事,尽管C可以使我们“更接近于机器”,但这并不是说我们应该把它当作高级的汇编 语言来使用。 当设计函数的界面时,要选择使程序员第一次就能够写出正确代码的设计。不要使用引 起混淆的双重意义的返回值 ─── 每个输出应该只代表一种数据类型,要在设计中显 式地体现出这些要点,使用户很难忽视这些重要的细节。
只再多考虑一下 程序员总知道在什么时候把多个输出组合到单一的返回值中,所以实施上述的建议很容 易 ─── 只要不那么做就行了。然而在其它的情况下,程序员设计的界面可能很好, 但却象特洛伊木马一样会含有潜在的危险。观察一下改变内存块大小的以下代码: pbBuf = (byte*)realloc( pbBuf, sizeNew ); if( pbBuf != NULL ) 使用初始化这个更大的缓冲区 你看出这段程序的错误了吗?如果没看出,也没什么关系 ─── 这个错误虽然很严 重,但却很微妙,如果不给出一点暗示很少人会发现它。所以我们给出一个提示:如果 pbBuf是指向将要改变其大小的内存块的唯一指针,那么当realloc的调用失败时会怎 样?回答是当realloc返回时会把NULL填入pbBuf,冲掉这个指向原有内存块的唯一指 针。简而言之,上面的代码会产生内存块丢失的现象。 我们有多少次在要改变一个内存块的大小时,想到要把指向新内存块的指针存储到另一 个不同的变量中?我想就象在大街上捡到25美分硬币一样,把新指针存储到不同的变量 中肯定也很少见。通常人们在改变一个内存块的大小时,会希望仍用原来的变量指向新 的内存块,这就是程序员常常掉进陷阱,写出上面代码的原因。 请注意,那些经常把错误值和有效数据混杂在一起返回的程序员,会习惯性地设计出象 realloc这样的界面。理想情况下,realloc应该返回一个错误代码,同时不管内存块扩 大与否都要再返回一个指向相应内存块的指针。这是两个独立的输出。让我们再看看 fResizeMemory,它是我们在第3章中介绍过的realloc的外壳函数。去掉了其中的调试代 码之后,它的形式如下: flag fResizeMemory( void** ppv, size _t sizeNew ) { byte** ppb = (byte**)ppv; byte* pbResize; pbResize = (byte*)realloc(*ppb, sizeNew); if( pbResize != NULL ) *ppb = pbResize; return( pbResize != NULL ); } 上面代码中的if语句保证了原有指针绝不会被破坏。如果利用fResizeMemory重写本节开 始例子中的realloc代码,就会得到: if( fResizeMemory(&pbBuf, sizeNew) ) 使用初始化这个更大的缓冲区 如果fResizeMemory失败,pbBuf并不会被置为NULL。它仍会指向原来的内存块,正如我 们所期待的那样。所以我们可以问:“使用fResizeMemory,程序员有可能丢失内存块 吗?”我们还可以问:“程序员有可能会忘记处理fResizeMemory的错误情况吗?” 需要说明的另一个有趣问题是:自觉遵循本章给出的第一个建议(“不要在返回值中隐 藏错误”)的程序员。永远不会设计出象realloc这样的界面。他们一开始就会做出更象 fResizeMemory这样的设计,因而不会有realloc的丢失内存块问题。本书的全部论点都 建筑在相互作用的基础上,它们会起到意想不到的效果。这就是一个例证。 然而,将函数的输出分开不总能使我们避免设计出隐藏陷阱的界面,我真希望对此给出 一点更好的忠告,但我认为找出这些暗藏陷阱的唯一办法是停下来思考所做的设计。这 样做的最佳途径是检查输入和输出的各种可能组合,寻找可能引起问题的副作用。我知 道这样做有时非常乏味,但要记住:这比以后再花时间回过来考虑这一问题要划算得 多。最坏的情况是略过这一步骤,那么天晓得会有多少个其他的程序员要对设计的不好 的界面所引起的错误进行跟踪追击了。只要想一想为了查出由getchar,malloc和 realloc这类界面暗藏陷阱的函数所引起的错误,全世界的程序员要浪费掉多少时间,我 们对所有按此模式编写出其他函数简直无话可说。这真是太可怕了!其实只要在设计时 多多考虑一点,就可以完全避免这种现象。
单一功能的内存管理程序 虽然在第3章我们花了许多时间去讨论realloc函数,但并没有涉及到它许多更令人奇怪 的方面。如果你抽出C运行库手册,查出realloc的完整描述你就会发现一些类似于下面 的叙述: void* realloc( void* pv, size_t size ); realloc改变先前已分配的内存块的大小,该内存块的原有内容从该块的开始位置到新块 和老块长度的最小长度之间得到保留。 l 如果该内存块的新长度小于老长度,realloc释放该块尾部不再想要的内存空间,返回 的pv不变。 l 如果该内存块的新长度大于老长度,扩大后的内存块有可能被分配到新的地址处,该 块的原有内容被拷贝到新的位置。返回的指针指向扩大后的内存块,并且该块扩大部分 的内容未经初始化。 l 如果满足不了扩大内存块的请求,realloc返回NULL,当缩小内存块时,realloc总会 成功。 l 如果pv为NULL,那么realloc的作用相当于调用malloc(size),并返回指向新分配内存 块的指针,或者在该请求无法满足时返回NULL。 l 如果pv不是NULL,但新的块长为零,那么realloc的作用相当于调用free(pv)并且总是 返回NULL。 l 如果pv为NULL且当前的内存块长为零,结果无定义 哎呀!realloc真是一个实现得“面面俱到”的最好例子,它在一个函数中完成了所有的 内存管理工作。既然如此还要malloc干什么?还要free干什么?realloc全包了。 有几个很好的理由说明我们不应该这样设计函数。首先,这样的函数怎么能指望程序员 可以安全地使用呢?它包括了如此之多的细节,甚至有经验的程序员都不全知道。如果 你对此有疑问,不妨调查一下,算算有多少程序员知道给realloc传递一个NULL指针相当 于调用了malloc;又有多少程序员知道给realloc传递一个为零的块长效果与调用free相 同。确实,这些功能都相当隐秘,所以我们可以问他们要避免错误就必须知道的一些问 题,如当调用realloc扩大一个内存块时会发生什么事情,或者他们是否知道此时相应的 内存块可能会被移动? realloc的另一个问题是:我们知道传递给realloc的可能是无用信息,但是因为其定义 如此通用使它很难防范无效的参数。如果错误地给它传递了NULL 指针,合法;如果错误 地给它传递了为零的块长也合法。更糟的是本想改变内存块的大小,却malloc了一个新 块或free掉了当前的内存块。如果实际上任何参数都合法,那么我们怎样用断言检查 realloc参数的有效性呢?不管你提供了什么样的参数,realloc全能处理,甚至在极端 的情况下也是如此。一个极端是它free内存块,另一个极端是它malloc内存块。这是截 然相反的两种功能。 公平地说,程序员通常不会坐下来思考:“我打算在一个函数中设计一个完整的子系 统。”象realloc这样的函数几乎总是产生于两个原因:一个是其多种功能是逐步演变而 来的;另一个是具体的实现为其增加了多余的功能(如free和malloc),为了包括这些 所谓的“幸运”功能,实现该函数的程序员扩展了相应的形式描述。 不管出于什么样的理由编写了多功能的函数,都要把它分解为不同的功能。对于realloc 来说,就是要分解出扩大内存块、缩小内存块、分配内存块和释放内存块。把realloc分 解为四个不同的函数,我们就能使错误检查的效果更好。例如,如果要缩小内存块,我 们知道相应的指针必须指向一个有效的内存块,而且新的块长必须小于(也可以等于) 当前的块长。除此之外任何东西都是错误的。利用单独的ShrinkMemory函数我们可以通 过断言来验证这些参数。 在某些情况下我们实际也许希望一个函数做多个事情。例如当调用realloc时,通常我们 知道新的块长是大于还是小于当前的块长?这要取决于具体的程序,但我通常不知道 (尽管我常常能够推算出这一信息)。对我来说,最好是有一个函数既能扩大内存块, 又能缩小内存块。这样可以避免在每次需要改变内存块大小时,必须写出if语句。这样 虽说放弃了对某些多余参数的检查,但可以得到不再需要写多个if语句(可能会搞乱程 序)的补偿。既然我们总是知道什么时候要分配内存,什么时候要释放内存,所以应该 把这些功能从realloc中割裂出来,使它们构成单独的函数。第3章介绍的fNewMemory, FreeMemory和fResizeMemroy就是这样三个定义良好的函数。 但是假如我正在编一个通常确实知道是要扩大还是缩小内存块的程序,那我一定会把 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会为我们做这一切。但是如果使用reallo,我甚至认为还应该使 用断言检查他返回的指针是否与pb完全相同。
模棱两可的输入 前面我们谈过为了避免使程序员产生混淆,应该把函数的各种输出明确地分别列出。如 果把这一建议也应用于函数的输入,自然就可以避免写出象realloc这样包罗万象的函 数。realloc输入一个内存块指针参数,但有时却可以取不可思议的NULL值,结果使它成 了malloc的仿造物。realloc还有一个块长参数,但却可以取不可思议的零值,结果使它 成了free的仿造物。这些不可思议的参数值看起来好象没有什么害处,其实损害了程序 的可理解性。我们可以看一下,下面的代码究竟是改变内存块的大小,还是分配或者释 放内存块呢? pbNew = realloc( pb, size ); 我们对此一无所知,它们都有可能,这完全取决于pb和size的取值。但是假如我们知道 pb的指向的是一个有效的内存块,size是个合法的块长,立刻就知道它是改变内存块的 大小。正象明确的输出使人容易搞清函数的结果一样,明确的输入亦使人容易理解函数 要做的事情,它对必须阅读和理解别人程序的维护人员极有价值。 有时模棱两可的输入并不象在realloc情况下那么容易发现。让我们来看看下面的专用字 符串拷贝例程。它从strFrom开始取size个字符,并把它们存储到从strTo开始的字符串 中: char* CopySubStr( char* strTo, char* strFrom, size_t size ) { char* strStart = strTo; while(size-- > 0) strTo++ = strFrom++; *strTo=‘\0’; return(strStart); } CopySubStr类似于标准的函数strcpy,所不同的是它保证起始于strTo的字符串确定是个 以零结尾的C字符串。该函数的典型用法是从大字符串中抽取子串。例如从一个组合串中 抽出星期几: static char* strDayNames = “SunMonTueWedThuFriSat”; …… ASSERT(day>=0 && day<=6); CopySubStr(strDay, strDayNames+day*3, 3); 现在我们明白了CopySubStr的工作方式,但你看得出该函数的输入有问题吗?只要你试 着为该函数写断言去确认它的参数,就很容易发现这一问题。参数strTo和strFrom的断 言可以是: ASSERT( strTo != NULL && strFrom != NULL ); 但我们怎样确认size参数呢?size为零合法吗?size大于strFrom的长度怎么办?如果查 看该函数的实现,我们就会看到这两种情况都可以得到处理。如果在进入该函数时size 等于零,while循环就不会执行;如果size大于strFrom,while循环将把strFrom整个连 同其终止符一道拷贝到strTo中。为了说明这点,必需在函数的注解中加以说明: /* CopySubStr ─── 从字符串中抽取子串 * 把strFrom的前size个字符转储到从strTo * 开始的字符串中。如果strFtom中的字符数小 * 于“size”,那么strFrom中的所有字符都被拷 * 贝到strTo。如果size等于零,strTo被设 * 置成空字符串. */ char* CopySubStr(char* strTo, char* strFrom, size_t size) { …… 听起来好象很熟悉,不是吗?确实如此,类似的函数就象灯泡上的灰尘一样司空见惯。 但这是处理其size输入参数的最好方式吗?回答是“不”,至少从编写无错代码的观点 来看是“不”。 例如,假定程序员在调用CopySubStr时错把“3”输成了“33”: CopySubStr( strDay, strDayNames+day*3, 33 ); 这确实是个错误,但根据CopySubStr的定义用33调用它却完全合法。是的,在交出相应 的代码之前或许也可能抓住这个错误,但却没法自动地发现它,必须由人查出它。不要 忘了从靠近错误的断言开始查错,要比从错误的输出开始查错速度更快。 从“无错”的观点,如果函数的参数越界或者无意义,那么即使能被智能地处理,仍然 应该被视为非法的输入。因为悄悄地接受奇怪的输入值,会隐藏而不是暴露错误。在某 种意义上,防错性程序设计应该允许“无拘无束”的输入。为了提高程序的健壮性,要 在代码中包括相应的防错代码,而不是禁止有问题的输入: /* CopySubStr ─── 从字符串中抽取子串 * 把strFrom的前“size”个字符转储到从strTo * 开始的字符串中,在strFrom中,至少必须要 * 有“size”个字符。 */ char* CopySubStr(char strTo, charstrFrom, size_t size) { char* strStart = strTo; ASSERT( strTo != NULL && strFrom != NULL ); ASSERT( size <= strlen(strFrom) ); while( size-- > 0 ) strTo++ = strFrom++; *strTo=‘\0’; reurn( strStart ); } 有时允许函数接受无意义的参数 ─── 如大小为0的参数,是值得的,因为这样可以免 除在调用时进行不必要测试。例如,因为memset允许其size参数为零,所以下面程序中 的if语句是不必要的: if( strlen != 0 ) /* 用空格填充str */ memset( str, chSpace, strlen(str) ); 在允许大小为0的参数时要特别小心。程序员处理大小(或计数)为O参数通常是因为他 们能够处理而不是应该处理。如果所编函数有大小参数,那么并不一定非得对大小为0进 行处理,而要问自己:“程序员用大小为0的参数调用这个函数的额度是多少?”如果根 本或者几乎不会这么调用,那就不要对大小为0进行处理,而要加上相应的断言。要记 住,消除限制就是消除捕获相应错误的机会,所以一个良好的准则是,一开始就要为函 数的输入选择严格的定义,并最大限度地利用断言。这样,如果过后发现某个限制过于 苛刻,可以把它去掉而不至于影响到程序的其它部分。 第3章在FreeMemory中包含的NULL指针检查,用到的就是这一原理。因为我从来不会用 NULL指针调用FreeMemory,所以对我来说加强对这一错误的检查就十分重要。对此可能 会有不同的看法。这里并没有对错之分,但要保证所做的是自觉的选择,而不仅仅是一 种随便的习惯。
现在不要让我失败 Microsoft公司招募雇员的政策,是在面试时就一些技术问题向候选者提问。对于程序员 来说,就是给出一些编程问题。我过去常常从要求编写标准的tolower函数开始考核候选 者。我递给候选者一个ASCII表,问候选者“怎样写一个函数把一个大写字母转换成对应 的小写字母?”我有意对如何处理字母以外的其它符号和小写字母说得很含糊,主要是 想看看他们会怎样处理这些情况。这些符号在返回时会保持不变吗?会用断言对这些符 号进行检查吗?它们会不会被忽视?半数以上的程序员写出的函数会是下面这样: char tolower(char ch) { return( ch + ‘a’-‘A’); } 这种写法在ch是大写字母的情况下没问题,但如果ch是其他的符号就会出毛病。当我向 候选者指出这一情况时,有时他们会说:“我假定ch必须是大写字母。如果它不是大写 字母我可以将其不变地返回。”这种解法很合理,但其它的解法就未必。更常见的是那 些未中选的候选者会说:“我没有考虑到这个问题。我可以解决这个问题,当ch不是大 写字母时,令它返回一个错误代码。”有时他们会使tolower返回NULL,有时会返回空字 符。但出于某种原因,无疑-1会占上风: char tolower(char ch) { if( ch >= ‘A’ && ch <= ‘Z’) return( ch + ‘a’-‘A’); else return(-1); } 这些解法都违背了我们前面给出的建议,因为他们把出错值同真正的数据混在了一起。 但真正的问题并不在于候选者没能注意到他们也许从未听说过的建议,而是他们在大可 不必的情况下返回了错误代码。 这提出了另一个问题:如果函数返回错误代码,那么该函数的每个调用者都必须对该错 误进行处理。如果tolower可能返回-1,那么就不能简单地这么写: ch = tolower(ch); 而必须这么写: int chNew; /* 为了容纳-1,它必须是int类型 */ if( (chNew=tolower(ch)) != -1 ) ch = chNew; 这一点与上一节有关。如果你意识到在每次调用时都必须这样使用tolower就会明白让它 返回一个错误代码也许并不是定义这个函数的最佳方式。 如果发现自己在设计函数时要返回一个错误代码,那么要先停下来问自己:是否还有其 它的设计方法可以不用返回该错误情况,因此,不要将tolower定义成返回大写字母对应 的小写字母,而要使其“如果ch是大写字母,就返回它对应的小写字母;否则,将其不 改变地返回。” 如果发现无法消除错误的情况,那么可以考虑干脆不允许这些有问题的情况出现,即用 断言对函数的输入进行验证。如果把这一建议应用于tolower。就会得到: char tolower(char ch) { ASSERT( ch >= ‘A’ && ch <= ‘Z’); return( ch + ‘a’-‘A’); } 这两种方法都可以使函数的调用者不必进行运行时的错误核查,这意味着产生的代码更 小并且错误更少。
看出言外之意 站在调用者的立场上,我并没有过分强调检查所设计的函数界面有多么重要。考虑到函 数只定义一次,但在程序中的许多地方都要调用它,就会明白不检查函数的调用方式是 很愚蠢的。我们见过的getchar,realloc和蹩脚的tolower例子都说明了这一点,它们都 导致了相应调用代码的复杂化。然而,并非只有把输出都合在一起和返回不必要的错误 代码才会导致复杂的代码。有时引起代码复杂化的原因完全由于粗心而忽视了相应的函 数调用“读’的效果。 例如假定在改进所编应用程序的磁盘处理部分时,碰到了一个写成下面这样的文件搜索 调用: if( fseek(fpdocument, offset, l) == 0 ) 你可以说得出它将进行某种搜索,也可以看到相应的错误情况得到了处理,但这个调用 的可读程度究竟如何呢?它进行的是哪种类型的搜索(从文件开始位置、从文件的当前 位置、还是从文件的结束位置开始搜索)?如果该调用返回0值,这究竟表明的是成功还 是失败? 反过来,如果程序员使用预定义的名字来进行相应的调用; #include /* 引入SEEK_CUR 的定义 */ #define ERR_NONE 0 …… if( fseek(fpdocument, offset, SEEK_CUR) == ERR_NONE ) …… 这样不是使相应的调用更清晰吗了?确实如此。但这并不是使人感到惊奇的新鲜事,程 序员在几十年前就已经知道应该在程序中避免使用莫名其妙的数字。有名的常量不仅可 以使代码更可读,而且使代码更可移植(考虑到在其他的系统上,SEEK_CUR可能不是 1)。 我要指出的是,虽然许多程序员把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); 当程序员坐下来编写这种函数时,其布尔参数值似乎非常清楚。程序员先做函数描述, 然后做函数的实现: /* UnsignedToStr * 这一函数将一个无符号的值转换成其对应 * 的正文表示,如果fDecimal为TRUE,u被转 * 换成十进制表示;否则,它被转换成 * 十六进制表示。 */ void UnsignedToStr(unsigned u, char *strResult, flag fDecimal) { ……… 还有什么比这更清楚的吗? 但事实上,布尔参数常常表明设计者对其设计并没有深思熟虑。相应的函数可以做两种 不同的事情,用布尔参数来选择想要做的事情;也可以很灵活地不只限于两种不同的功 能,但程序员使用布尔值来指明唯一感兴趣的两种情况。这两种可能常常都正确。 例如,如果我们把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输入变成了一般的输入,函数的大部分可能取值都没有用到。但要记 住,虽然参数变得一般了,但我们总是可以在函数中包括断言来检查base的取值永远只 能是10或者16。这样如果以后决定还需要进行二进制或者八进制的转换,可以放松这一 断言以便程序员传递等于2和8的基数值。 比起我所见过那些参数取值是TRUE、FALSE、2和-l的函数,这种做法要好得多。因为布 尔参数的值域不容易扩充,所以要么你得继续忍受这些无意义的参数值,要么就得修改 现有的每个调用语句。
向人们提示险情 作为防范错误的最后一个措施,我们可以在函数中写上相应的注解来强调它可能产生的 险情,并给出函数的正确使用方式,这样可以帮助其他的程序员在使用该函数时不致出 错。例如,getchar的注解不应该这样: /* getchar ─── 该函数与getc(stdin)相同 */ int getchar(void) …… 它对程序员真的起不到什么帮助作用,我们应该把它写成: /* getchar ─── 等价于getc(stdin) * getchar从stdin返回了一个字符,当发生了 * 错误时,它返回“int”EOF。该函数的一种 * 典型用法是: * int ch; // 为了容纳EOF,ch必须是int 类型 * if( (ch=getchar()) != EOF ) * 成功 ─── ch是下一个字符 * else * 失败 ─── ferror(stdin)将给出错误的类型 */ int getchar(void) …… 如果把这两种描述都交给初学C库函数的程序员,你认为对于使用getchar时会出现的险 情哪种描述会给程序员留下比较深的印象?当程序员第一次使用getchar时,这两种描述 会产生什么样的差别?你认为他会编写新的代码,还是会从你做的注解中复制下典型用 法给出的例子,然后再根据需要对其进行修改? 按照这种方式对函数进行注解的另一个积极作用,是它可以迫使不够谨慎的程序员停下 来考虑别的程序员怎样才能使用他们编出的函数。如果程序员设计的函数界面很笨,在 编写典型用法时,他就应该注意到界面的笨拙。即使他没有注意到界面的问题,只要典 型用法给出的例子详尽正确,也没有什么关系。例如,倘若realloc被注解成如下形式, 就不会引起那么多的使用问题了: /* realloc( pv, size ) * …… * 该函数的一种典型用法是. * void* pvNew; // 用来保护pv,以防realloc失败 * pvNew = realloc( pv, sizeNew ); * if( pvNew != NULL ) * { * 成功 ─── 修改pv * pv = pvNew; * } * else * 失败 ─── 不要用值为NULL的pvNew冲掉pv */ void realloc( void* pv, size_t size ) 通过复制这样的示例,即使不够谨慎的程序员也很可能会避免本章开始所讲的内存丢失 问题。例子虽然并不能对所有程序员都起作用,但就象药品包装上的警告信息一样,它 会对某些人产生影响。而且从任何一点看,这样做都有所帮助。 然而不要用例子来代替编写良好的界面。 getchar和realloc的界面都使用户容易出错, 这些害处都应该予以消除而不仅仅是给予说明。
小结 设计能够抵御错误的界面并不困难,但这确实需要多加考虑并且愿意放弃根深蒂固的编 码习惯。这一章给出的建议只需简单地改变函数的界面,就可以使程序员编写出正确的 代码,而不必过多地考虑其它部分的代码。本章贯穿始终的关键慨念是“尽可能地使一 切清晰明了”。如果程序员理解并记住了每个细节,也许就不会犯错误 ─── 他们之 所以会犯错误,是因为他们忘记了或者从来就不知道这些重要的内容。因此要设计能够 抵御错误的界面,使程序员很难无意地忽视相应的细节。
要点: l 最容易使用和理解的函数界面,是其中每个输入和输出参数都只代表一种类型数据的 界面。把错误值和其它的专用值混在函数的输入和输出参数中,只会搞乱函数的界面。 l 设计函数的界面迫使程序员考虑所有重要细节(如错误情况的处理),不要使程序员 能够很容易地忽视或者忘记有关的细节。 l 老要想到程序员调用所编函数的方式,找出可能使程序员无意间引入错误的界面缺 陷。尤其重要的是要争取编出永远成功的函数,使调用者不必进行相应的错误处理。 l 为了增加程序的可理解性从而减少错误,要保证所编函数的调用能够被必须阅读这些 调用的程序员所理解。莫明其妙的数字和布尔参数都与这一目标背道而驰,因此应该予 以消除。 l 分解多功能的函数。取更专门的函数名(如ShrinkMemory而不是 realloc)不仅可以 增进人们对程序的理解,而且使我们可以采用更加严格的断言自动地检查出调用错误。 l 为了向程序员展示出所编函数的适当调用方法,要在函数的界面中通过注解的方式详 细说明。要强调危险的方面。
练习: 1) 本章开始的函数strdup为一个字符串分配一个副本,但如果它失败了则返回NULL。更 能抵御错误的strdup界面是什么? 2) 我说过布尔输人参数的存在,常常表示可能还有更好的函数界面。但对于布尔输出参 数怎样呢?例如,如果fGetChar失败,它返回FALSE并要求程序员调用ferror(stdin)来 确定出错的原因,那么更好的getchar界面会是什么? 3) 为什么ANSI的strncpy函数必然会使轻率的程序员犯错误? 4) 如果读者熟悉C++的inline指明符,说说它对于编写能够抵御错误的函数界面的价 值。 5) C++采用了类似于pascal中VAR参数的 & 引用参数。因此,不是这样写: flag fGetChar(char* pch); /* 原型 */ …… if( fGetChar(&ch) ) ch含有新的字符 …… 可以写: flag fGetChar(char &ch); /* 原型 */ …… if( fGetChar(ch) ) /* 自动的传递 &ch */ ch含有新的字符 …… 从表面上看,这一加强似乎不错,因为程序员不可能“忘记”正规C中要求的显式&。但 为什么使用这一特征会产生容易出错的界面,而不是能够抵御错误的界面? 6) 标准的strCmp函数取两个字符串并对它们进行逐字符的比较。如果这两个字符串相 等,strcmp返回0;如果第一个字符串小于第二个,它返回负数;如果第一个字符串大于 第二个,它返回正数。因此当调用strcmp时,相应的代码通常有下面的形式: if( strcmp(str1,str2) re1_op 0 ) …… 这里rel_op是 == 、 != 、> 、>= 、< 或 <= 之一,尽管这样也可以完成相应的比较, 但对于不熟悉strcmp函数的人来说它毫无意义。为字符串比较设计至少两个其它的函数 界面,所设计的界面应该更能抵御错误,更加可读。
课题: 检查一个标准的C库函数对相应的界面重新设计使其更能抵御错误。为了使重新设计的函 数更加明了易懂,给这些函数改变名字的利弊是什么?
课题: 在大量的代码中搜寻所使用的memset、memmove、memcpy和strn系列函数(如strncpy 等)。在所找到的调用中,有多少个要求对应的函数接受为零的计数值?所得到的这种 便利足以说明允许函数接受零计数值是合理的吗?
| | |