这是一本老书,作者 Steve Maguire 在微软工作期间写了这本书,英文版于 1993 年发布。2013 年推出了 20 周年纪念第二版。我们看到的标题是中译版名字,英文版的名字是《Writing Clean Code ─── Microsoft’s Techniques for Developing》,这本书主要讨论如何编写健壮、高质量的代码。作者在书中分享了许多实际编程的技巧和经验,旨在帮助开发人员避免常见的编程错误,提高代码的可靠性和可维护性。
不记录,等于没读。本文记录书中第五章内容:糖果机接口。
我们在第一章介绍了一些
方法
,以便检测到更多的错误,比如启用所有可选的编译器警告、使用语法和可移植性检查工具等。但是这些措施只能查出所有错误的一小部分。
所以我们在第二章看到了断言
的威力,相比编译器等工具,它能够检查出更多的错误。但断言也有弱点,它只能静静地等待,直到错误出现。
所以我们在第三章介绍了子系统完整性检查
,不再被动的等错误发生,而是“挨门挨户”的搜查错误。但这对研发人员的要求比较高,你必须知道哪里可能会发生错误,为了主动抓到错误,你得知道错误长什么样子。这可需要经验和艰苦的思考。
然后我们在第四章介绍了每个人都能轻松实践的、发现错误的最佳方法:使用调试器逐步执行所有新代码。
上面所有的内容都在帮助我们写出无错的函数。但是函数只是无错还不够,函数还必须易于使用,且不会引入意外的 BUG。
本章将详细阐述如何设计 接口
(Interfaces),通常我们所提及的 API 接口
,就是本章所聚焦的核心内容。在编程世界中,接口
扮演着至关重要的角色,它们定义了不同模块、组件或系统之间交互的规范。对于 C 代码而言,函数名及其参数列表实质上构成了我们所说的接口,它们定义了函数的 行为
和 期望的输入
。
如果要降低 函数调用
带来的 BUG,每个函数需要有一个明确单一的目的,具有明确单一用途的输入和输出,具有可读性,并且理想情况下从不返回错误状态。具有这些属性的函数易于使用断言和调试代码进行验证,并且最大限度地减少必须编写的错误处理代码量。
好的设计会引导人们去做正确的事情。不好的设计则会让事情一团糟。
有一次作者想在自动贩卖机购买黄油饼干。购买的步骤是:
- 确认商品金额
- 向贩卖机投入硬币
- 按下表示商品的编码
- 取走商品
黄油饼干售价 45 美分,编码为 21。作者向贩卖机投入硬币时满脑子都是数字 45,在输入商品编码时,他本应该按下 2 和 1,但却阴差阳错的按下了 4 和 5。售货机不会出错,它吐出了一个泡泡糖。
这里的关键在于,自动售货机的商品编码和金额都用数字表示是不好的设计,因为容易将编码和金额混淆。如果商品编码用字母表示,金额用数字表示就是一种好的设计方式,因为这样可以防止
顾客出错。
程序员设计的函数接口也要符合好的设计原则:函数既要无错,还要使用起来安全。
不要在返回值中隐藏错误
getchar
getchar
是标准 C 库提供的函数, 用于从某个设备上读取一个字符或返回 EOF
。单看函数名,我们会很自然的认为它返回一个 char
类型数据,就像下面所示的代码:
char c;
c = getchar();
if( c == EOF )// 处理内容
这段代码是有问题的:在不进行符号扩展的机器上,char
类型变量 c
总是整数,无法表示 EOF
,因为 EOF
定义为 int
类型,值为 -1,表示文件结束或出错。为了避免这一点,必须使用 int
类型变量来保存 getchar
函数的返回值!
即便是有经验的程序员也容易在这里栽跟头。
molloc
molloc
也是标准 C 库提供的函数,用于返回分配的内存地址或 NULL
(表示内存耗尽或出错);这类函数返回的值都不精确:有时返回有效数据,但另一些时候却返回不可思议的错误值。程序员可能会忘记对相应的错误进行处理,因为这类函数将错误隐藏在程序员极易忽视的正常返回值中。这是不好的设计。
更麻烦的是这类函数能写出虽然有缺陷,但表面上仍能工作的代码。直到碰到一连串不易发生的事件而导致这些代码失败。
接口的返回值规则
设计函数接口时,不要使用引起混淆的返回值:每个输出应该只代表一种数据类型。具体到 getchar
函数,我们可以设计一个封装函数:
bool new_getchar(char *pch)
{int ch;ch = getchar();if(ch == EOF)return false*pch = ch;return ture;
}
这个封装函数的返回值强调错误情况。
realloc
realloc
同样是标准 C 库提供的函数,用于重新分配内存块的大小。观察下面的代码:
pbBuf = (byte *)realloc(pbBuf, sizeNew);
这句代码可能有个严重的错误:如果 pbBuf
是指向要改变大小的内存块的唯一指针,那么当 realloc
函数调用失败,会把 NULL
填入 pbBuf
,然后这个内存块就再也找不到了。
我们有多少次在要改变一个内存块的大小时,想到要把指向新内存块的指针存储到另一个变量中?
所以realloc
函数同样有引起混淆的返回值。我们在第 3 章介绍了 realloc
的封装函数,去掉其中的调试代码后,它的形式如下:
bool fResizeMemory(void **ppv, size_t sizeNew)
{byte **ppb = (byte **)ppv;byte *pbResize;pbRsize = (byte *)realloc(*ppb, sizeNew);if(pbResize == NULL)return false;*ppb = pbResize;return true;
}
使用这个封装函数,绝不会破坏原有指针。
再谈接口的返回值规则
将函数的返回值设计成单一功能,在很大程度上让我们设计出避免隐藏陷阱的接口。找出这些暗藏陷阱的唯办法是停下来思考所做的设计。检查输入和输出的各种可能组合,寻找可能引起问题的副作用。
要不遗余力地寻找并消除函数接口中的缺陷!
编写功能单一的函数
realloc
函数的原型为:
void *realloc(void *pv, size_t size)
它的另一个问题是集多种功能于一身:
- 做
malloc
函数做的事情(当pv
为NULL
时) - 做
free
函数做的事情(当size
为 0 时) - 实现内存块的缩小或扩大,扩大时,指针可能发生移动。
- 扩大内存块时可能返回
NULL
,缩小内存块总是会成功,返回的指针与pv
相同。 pv
为NULL
且size
为 0 ,结果未定义。
它在一个函数中完成了所有的内存管理工作,真不知道还要 malloc
和 free
干什么。这样的函数缺点在于:
- 因为复杂,程序员难以掌握;它包含了如此多的细节。
- 功能重复、多余
- 为测试参数带来了困难(
size
为 0 合法、pv
为NULL
也合法)。
不管出于什么样的理由编写了多功能的函数,都要把它分解为不同的功能。编写功能单一的函数:一个函数只做一件事。
输入参数值不要模棱两可
前面谈过了 函数的返回值
应具有单一功能,将这一建议应用于 函数的输入
也是成立的。比如下面的代码,是改变内存块大小,还是分配内存块或者释放内存块?
pbNew = realloc(pb, size);
都有可能。这取决于 pb
和 size
的值。但如果我们知道 pb
指向一个有效的内存块,size
是个非零的合法块长,我们立刻就知道该函数是改变内存块的大小。
明确的输入使人容易理解函数的行为,这对于提高程序的可维护性非常重要!
再来看个例子,如下所示的代码实现字符串抽取,即从一个大字符串 strFrom
中抽取一个子字符串,放到 strTo
中:
char* CopySubStr( char* strTo, char* strFrom, size_t size ) {char* strStart = strTo;while(size-- > 0)strTo++ = strFrom++;*strTo='\0';return(strStart);
}
一个使用该函数的例子是:从一个组合串中抽出星期几:
static char* strDayNames = "SunMonTueWedThuFriSat";
char strDay[4];
// ……
ASSERT(day>=0 && day<=6);
CopySubStr(strDay, strDayNames+day*3, 3);
现在我们明白了 CopySubStr
的工作方式,但你看得出该函数的输入有问题吗?至少有以下问题:
- 参数 strTo 和 strFrom 应该使用断言确保非
NULL
- 参数 size 值不应大于 strFrom 指向的字符串长度
修改过的代码如下所示:
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 );
}
一开始就要为函数的输入参数选择严格的定义,并最大限度的利用断言。
尽可能不返回失败
如果函数返回错误代码,那么每个调用该函数的地方都必须对错误值进行处理。这也许不是这个函数的最佳实现方式。如果发现自己在设计函数时要返回一个错误代码,那么要先停下来问自己:是否还有其它的设计方法可以不用返回该错误情况。
努力编写在给定有效输入的情况下不会失败的函数。
考虑一个小问题,给定一个 ASCII 表,写一个函数,把其中的大写字母转换成对应的小写字母。我知道 C 库函数可以很好的完成这个需求,但请自己来设计一个。在解决这个问题时,需要注意给定的输入范围是整个 ASCII 表,所以输入不一定是大写字母,也可能是个逗号,因此需要考虑异常处理。
一个不好的实现方法是:当输入参数不是大写字母时,返回一个错误代码,比如 NULL
、空字符或者 -1 :
char tolower(char ch) {if( ch >= 'A' && ch <= 'Z')return( ch + 'a'-'A');elsereturn(-1);
}
这个函数把错误信息和真正的数据混在了一起,而且更重要的事,这个函数大可不必返回错误代码:如果输入参数不是大写字母时,返回该参数 (不做任何改变)。
使程序在调用点明了易懂
设计一个将无符号数转为字符串的函数,转换后的字符串可以是十进制样式也可以是十六进制样式,比如无符号数 16
,可以转换为字符串 "16"
或者 "0x10"
。
第一种方法:
#define BASE10 1
#define BASE16 0 /* UnsignedToStr 这一函数将一个无符号的值转换成对应的字符串表示,
* 如果 fDecimal 为 TRUE,u 被转换成十进制表示;
* 否则,它被转换成十六进制表示。
*/
void UnsignedToStr(unsigned u, char *strResult, flag fDecimal)
{ …
}
这种方法通过传递布尔参数fDecimal
来表示十进制还是十六进制表示。
这是一种不合理的设计。
布尔参数常常表明设计者在设计这个函数时并没有深思熟虑。如果真的只需要布尔量的两种情况,就应该把函数拆成两个。
第二种方法:
void UnsignedToDecStr(unsigned u, char* str);
void UnsignedToHexStr(unsigned u, char* str);
拆成两个函数,一个函数只做一件事件。
通用的方法:
void UnsignedToStr(unsigned u, char* str, unsigned base);
{ASSERT(base == 2 || base == 8 || base == 10 || base == 16);...
}
把布尔参数改成通用参数,从而使 UnsignedToStr
函数更灵活,因为可能还需要转换为二进制、八进制的字符格式。我们需要在函数中增加断言来约束参数的范围。
strcmp
函数对两个字符串进行比较,如果两个字符串相等返回0、如果第一个字符串小于第二个返回负数、如果第一个字符串大于第二个返回正数。
尽管这样设计函数接口可以完成字符串比较功能,但对于不熟悉strcmp
函数的人来说毫无意义。如何设计新的接口,使得调用者更能抵御错误、更加可读?
一种解决办法用命名良好的宏包装strcmp
函数,提高可读性的同时,在空间和速度方面也没有损失:
#define fStrLess(strLeft, strRight) ( strcmp(strLeft, strRgiht) < 0 )
#define fStrGreater(strLeft, strRight) ( strcmp(strLeft, strRight) > 0 )
#define fStrEqual(strLeft, strRight) ( strcmp(strLeft, strRgiht)== 0 )
小结:
- 易于使用和理解的函数:每个输入参数和输出都精确表示一种类型的数据;将错误和其它专用值混入输入参数和输出中只会使函数接口混乱。
- 设计函数接口的原则:应迫使使用接口的程序员考虑所有重要细节(如处理错误条件)。不要让他们很容易忽视或忘记细节。
- 考虑程序员必须如何调用你的函数。寻找函数接口中的缺陷,这些缺陷可能导致程序员无意间引入错误。尤其重要的是:努力编写永远成功的函数,这样调用者就不必做任何的错误处理。
- 函数接口要具有可读性,程序员容易理解这些函数的调用,可以减少BUG。魔法数和布尔量参数都与这一目标背道而驰。
- 将多功能函数拆分成单独功能的多个函数,不仅可以通过合理的函数命名来增加程序的可读性,而且可以用更严格的断言自动地检查不合理的参数。
每一份打赏,都是对创作者劳动的肯定与回报。!