本书很好,围绕怎样写出bug少的代码,介绍各种实用技巧,和一些容易犯错的地方,点醒你那种感觉。
书中的练习都要看一看,它不是说在重复书里已经讲过的内容,它是在做补充,很多章节里没讲的内容或例子,会出现在练习题中。
第一章 A Hypothetical Compiler
1、compiler实力有限,无法帮你完成很多错误的查找。
char *strcpy(char *pchTo, char *pchFrom)
{
char *pchStart = pchTo;
while (*pchTo++ = *pchFrom++)
NULL;
// {}
return (pchStart);
}
一般编译器都会提供多种空语句形式,但使用NULL;会出发warning(当-Wall时),而{}这样就不会。
2、C中一个非常容易出错的地方就在于容易把if while中的比较语句写成赋值等。
if ( ch = '\t')
Do something
解决方法是开启编译器中,对于这种在if for while 等控制语句中的简单赋值检查,出现上述错误就会报warning。但是语句while(*pchTo++ = *pchFrom++)也会报warning,虽然它是个正确语句,解决办法是改为:
while((*pchTo++ = *pchFrom) != '\0')
语句是长了,而且看上去似乎多做一次比较,但实际上编译器并不会比最开始版本多产生代码,会自动优化掉,这样写只是为了不出错,不报warning。
3、写函数的时候,都把原型写出来。
ANSI C未对用户自己写的函数要求写出函数原型。但是写原型,并且维护它,是个非常好的习惯,自己写小函数练习也最好写出原型。它能很好的检查出使用函数时,传参数可能出现的错误。但写原型也是有讲究的。
考虑下面原型:
void *memchr(const void *pv, int ch, int size);
这个原型声明出来,对底下使用此函数的地方约束力并不强,因为参数ch 和size都是int 型,如果用户不小心更换了这两参数位置,编译器不可能察觉,所以如果能使用更精确的原型声明,如下:
void *memchr(const void *pv, unsigned char ch, size_t size)
这样交换了ch和size位置,会直接报错,而不是安安静静执行。
但这样写出准确类型的问题在于:使用时,可能需要常使用强制转换,来去掉编译器作出的无关紧要的类型不匹配warning。比如我传个int给size参数,如果不越界的话,理应是可以的,但是编译器要报warning,会很烦,必须要用强转消除。不过想想,严格一点还是好一点吧。
Enable all optional compiler warnings.
上面1~3,也可以看出,作者一直再讲怎么把编译器warning去掉。好的代码是warning全开情况下也没有warning。
4、单元测试很重要。有很多时候,会觉得函数很小,功能简单而放弃单元测试,这是个不好的习惯。单元测试不但能帮你查到错误,而且它能给予你信心,让你对代码的把握更强。
练习题:
1、while (ch = getchar() != EOF)
这个语句里有错误,对,是优先级错误,而编译器碰巧能够抓到,解释原因。
答:编译器抓到的不是优先级错误,它抓到的是while(ch = (getchar() != EOF)),while控制语句中的简单赋值错误,它以为这应该是ch == (getchar() != EOF)。仅仅是碰巧bug位置也在这。优先级bug很难抓到。小心小心再小心,不要那么自信,多大括号。
2、下列bug怎么通过开启compiler的某个报警机制发现bug?怎么改动一下让warning安静下来?
a、打字时候,不小心多加了一个0,if (flight == 063) ,本来是想比较flight 和63的。但比较成八进制的63了。
答:方法是打开编译器八进制检查,即对所有使用八进制的数字的地方都报警,然后把代码中的所有用到数字的地方都改为十进制或十六进制的。这样当compiler报八进制警告时,肯定是某地方用错了。
b、if (pb != NULL & *pb != 0xFF) 不小心把&&打成&了。
答:用编译器发现这种错误的方法在于,像编译器检查=和==错误一样,也编译器也可以通过开启检查,察觉到&和&&错误。但是如下语句即便是正确的也会报错:
if (u & 1) //比如说这里在检测数u是不是奇数
如果开启检查,编译器会在这里报错,解决办法是,上述语句改为:
if ((u & 1) != 0)
c、quot = numer/*pdenom这里有可能出现将/*当作注释的开头了。
答:解决方式是打空格。quot = number / *pdenom,或者把*pdenom括起来。编译器也是有开关能察觉到这种错误的,会报warning。但是这么一来又有个问题,每次使用注释的时候,如下:
/*But note : This comment generates a warning.*/ 这句注释也会报warning,因为把开头/*B当成指针 所以写成下面形式
/* This one does not because of the leading space */ 即开头打个空格,所以注释开头要空格
/*--------Nor does this comment--------------*/ 这样也不会报warning
d、word = bHight << 8 +bLow,可能有优先级错误,如果你本意是word = (bHight <<8) + bLow的。
答:典型的一个优先级问题。解决方法就如上,打括号。但是根本地方在于:尽量不要混用不同类型运算符。一个表达式中,要么都使用算数运算符,要么都是位操作运算符。
都说这里用<<8好,要快,但是作者后续章节有论述,就说现在是个编译器,看到* 8 或/ 8都会进行优化,自动编程位操作的,这里手动写出来还容易错,而且即便不自动优化大部分时间影响也不太大。作者观点主要是如果bug少和性能做权衡,选择最大限度减少bug。他论述也就大概看看,做一参考吧。
3、问题就不说了,就是要避免悬挂的if怎么做才好。每次写if时候,后面都要跟{},即便{}内只有一条语句,甚至空语句都要有{}。养成习惯。
4、为了避免if (ch = '\t') 错误,有人常写成if ('\t' = ch),这样会报错,就发现了误写== 为= 的问题了。评价这个方法,其查错是否全面?这方法和开启编译器对控制语句中简单赋值的检查相比,哪个好?
答:这个方法的局限性就在于其中一个操作数必须是常数或表达式。对于这类错误还是使用开启编译器检查比较好,在编译器无法完成此类检查时候,再用'\t' = ch,这种表达式。
5、每次使用例如 #if UINT_MAX > 65535u这种宏,因为这个宏可能在别的文件中定义,有可能出现漏定义的情况,这时候UINT_MAX自动为0,编译器有的会报错,有的不一顶。所以,每次使用这种宏之前,加上#if defined(UINT_MAX)。
#ifdef和#if defined(XXXXX)功能是一样的,只是#ifdef老一点,而#if defined(XXX)是ANSI C中引入的。
二、Assert Yourself
1、同一份代码,同时维护Ship版和Debug版
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
byte *pbTo = (byte *)pvTo;
byte *pbFrom = (byte *)pvFrom;
if (pvTo == NULL || pvFrom == NULL){
fprintf(stderr,"Bad args in memcpy\n");
abort();
}
while (size-- > 0){
*pbTo++ = *pbFrom++;
}
return pvTo;
}
上面函数,为了检查参数错误,用了个if语句,作者认为这是cure is worse than the disease。函数本来就短,而检查语句占去了一半位置,而且运行是会拖慢速度。所以,好办法是把参数检查放到Debug版本中。如下:
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
byte *pbTo = (byte *)pvTo;
byte *pbFrom = (byte *)pvFrom;
#ifdef DEBUG
if (pvTo == NULL || pvFrom == NULL){
fprintf(stderr,"Bad args in memcpy\n");
abort();
}
#endif
while (size-- > 0){
*pbTo++ = *pbFrom++;
}
return pvTo;
}
即通过宏,来控制Ship和Debug两个版本。能避免错误处理语句拖慢你的发行版程序。
上述程序还是有些问题,仔细看看,看能不能找出来,后面会慢慢说。
函数做成通用的,在函数内部再进行强转,最后返回目的指针,能完成链式操作。
2、使用ASSERT
ANSI C的assert在assert.h中定义。
之前memcpy函数错误处理语句可以直接用一个assert完成,把上面ifdef 和 endif之间那么多行,换成仅一行。assert是个宏,其为了尽力使函数发行版和Debug版保持较高一致,减少些函数调用等性能损耗。
程序员会使用assert后,经常想自己定义断言,因为可能有使用场合要求,比如我并不想让程序断言失败后就停止,还想让程序继续运行,那就要自己写断言了。自己写断言用大写的定义ASSERT,不要产生冲突。
ANSI C中的assert是个? :三目运算符定义的表达式,所以它可以用在需要表达式的场合,比如:
if (assert(p != NULL), p->foo != bar)
....................
作者定义的ASSERT如下:
#ifdef DEBUG
void _Assert(char *, unsigned);
#define ASSERT(f) \
if (f) \
{} \
else \
_Assert(__FILE__, __LINE__); \
#else
#define ASSERT(f)
#endif
因为用了if else ,所以这是一个复合语句,与ANSI C的assert可不同了。assert的定义与上面也有所相似,也是在断言失败的时候,调用个函数。
void _Assert(char *strFile, unsigned uLine)
{
fflush(NULL);
fprintf(stderr, "\nAssertion failed: %s, line %u\n",
strFile, uLine);
fflush(stderr);
abort();
}
注意此函数内的fflush。当fflush()函数参数为NULL的时候,其作用是flush所有打卡的流。作者这里第一个fflush他说很重要,需要在执行abort()之前,把所有流上的东西都flush一下。平时没这么用过,主要是平时函数结束的时候,会自动把各个打开的流flush,而这里是出异常,可能还没执行flush时,就被打断,进入本函数,所以flush一下。
对于printf fprintf中的\n也具有flush本流的作用,对于printf flush的是stdout,fprintf是自己指定的流。作者这里第二个fflush(stderr),感觉更多的目的在于保险。。。。
注意:仔细看本ASSERT打印出的内容,其是和特定某个ASSERT无关的,即没有像ANSI C 的assert那样,把实际断言内容打印出来。作者解释,这是为了节省空间,因为ANSI C的assert,打印断言内容的方法,是通过#,在预处理阶段,将断言内容转换为字符串放入断言显示语句了。这样的话,每个不同的断言,在预处理阶段结束后,都有不同的字符串,且这些字符串都是在.rodata下的,每个不同assert占用一点空间。这在内存本就比较吃紧的地方,如果有许多断言要写,那问题就比较严重。比如嵌入式方向。而作者自己写的断言宏ASSERT里,写多少个断言都只用同一个字符串,打印出不同的内容,是fprintf函数,将字符串内的%s等,更换后效果,且在函数内完成,并不是把原始的字符串改变了。
\nAssertion failed: %s, line %u\n
不过自己感觉,作者因为成书较早,所以在这方面比较忧虑,自己测试,每多一个assert,也就多若干字节。应该还好吧。
在每个函数入口查数据,可以让bug不会存活太久。
3、去除未定义动作
对于前面memcpy函数,如果传入两参数内存具有重叠,那结果未定义。要么采用assert在出现重叠的时候通知程序员,要么改变设计。
void *memcpy(void *pvTo, void *pvFrom, size_t size)
{
char *pbTo = (char *)pvTo;
char *pbFrom = (char *)pvFrom;
ASSERT(pvTo != NULL && pvFrom != NULL);
ASSERT(pbTo >= pbFrom + size || pbFrom >= pbTo + size);
while (size-- > 0){
*pbTo++ = *pbFrom++;
}
return pvTo;
}
如上,是通过加入ASSERT来保证传入的两个内存块没有重叠。
4、对不明晰的assert进行注释
作者举了个例子,说一个森林外,大大的写个Danger,但就是不写出来什么Danger,那还是会有人不顾危险踏进去,因为它不知到什么东西危险,可能仅仅是某处有个坑,地太滑等无关紧要的,也可能是有熊什么的。
所以对于自己无法一眼看出这个ASSERT在干什么时,最好写个注释,简短点。用疑问语句可以包含很多内容,因为它可以激发程序员思考。比如上面第二个ASSERT,前加入注释:
/* Blocks overlap? Use memmove */ 不过后面建议语句如果不是十分十分有自信,最好不要写
5、查看代码中是否做了任何假设呢?
其实这里说的就是运行代码的环境等。比如int 是4字节,long是4字节,一个字节就是有8个位等。虽然现在大多数情况下这些都确实是正确的,但是写代码的时候,也不能想当然的就这么认为。如以下代码:
long *longfill(long *pl, long l, size_t size);
void *memset(void *pv,byte b, size_t size)
{
byte *pb = (byte *)pv;
if (size >= sizeThreshold){
unsigned long l;
l = (b << 8) | b;
l = (l << 16) | l;
pb = (byte *)longfill((long *)pb, l, size / 4);
size = size % 4;
}
while (size-- > 0){
*pb++ = b;
}
return pv;
}
这段程序在干什么呢?看到是memset,这里是为了加速,先一次4字节的向内存块复制数据,然后不足4字节后,再一个一个将内存块内字节值都设为b。
longfill作用是尽可能的在内存块里放置4字节值,直到放不下了,就返回指向未填充的第一个字节的指针。比如现在内存块18字节,那longfill就只填充16字节,然后返回个long型指针,指向第17字节。
这里longfill第三个参数,感觉写size不好啊,容易误解,其实是次数的意思。
上面函数就做了几个假设,比如long是4字节,一个字节有8位等。
那么,这么改进一下怎么样呢:
void *memset(void *pv,byte b, size_t size)
{
byte *pb = (byte *)pv;
if (size >= sizeThreshold){
unsigned long l;
size_t sizeLong;
l = 0;
for (sizeLong = sizof(long); sizeLong-- > 0; ){
l = (l << CHAR_BIT) | b;
}
pb = (byte*)longfill((long *)pb, l, size / sizeof(long));
size = size % sizeof(long);
}
while (size-- > 0){
*pb++ = b;
}
return pv;
}
作者对这个改进版用了一个带引号的"improve"。这里怎么改进的呢?即通过假如一系列的sizeof(long),还有CHAR_BIT宏来保证可移植性。
这里认为CHAR是8个bit也是盲目假设。。很神奇。。不过仔细想,也是有道理的。。。。指不定哪天会编程16位也不一定。
上面函数虽然通用了很多,但是还是不行,作者举到摩托罗拉的一个CPU68000,它byte *无对齐要求,而long *有对齐要求,所以,上面longfill函数那使用了强转的地方,会出现问题。。。
解决办法是,只对摩托68000CPU进行编程,用ASSERT保证你的猜测。
void *memset(void *pv,byte b, size_t size)
{
byte *pb = (byte *)pv;
#ifdef MC680x0
if (size >= sizeThreshold){
unsigned long l;
ASSERT(sizeof(long) == 4 && CHAR_BIT == 8);
ASSERT(sizeThreshold >= 3);
while (((unsigned long)pb & 3) != 0){
*pb++ = b;
size--;
}
l = (b << 8) | b;
l = (l << 16) | l;
pb = (byte*)longfill((long *)pb, l, size / sizeof(long));
size = size % sizeof(long);
}
#endif /* MC680x0 */
while (size-- > 0){
*pb++ = b;
}
return pv;
}
因为通用性很多时候无法完全保证,所有有时只对特定情况编程反而还好一点,至少减少bug,感觉这个问题在嵌入式方面更容易出。
Either remove implicit assumptions, or assert that they are valid.
6、不可能的事是否会发生?
byte *pbExpand(byte *pbFrom, byte *pbTo, size_t sizeFrom)
{
byte b,*pbEnd;
size_t size;
pbEnd = pbFrom + sizeFrom;
while (pbFrom < pbEnd){
b = *pbFrom++;
if (b == bRepeatCode){
b = *pbFrom++;
size = (size_t)*pbFrom++;
while (size-- > 0){
*pbTo++ = b;
}
}
else {
*pbTo++ = b;
}
}
return pbTo;
}
此函数功能是,还原一个被压缩了的代码。压缩方法是,遇到重复的字符时压缩,将重复字符改为:
特殊字符 | 要重复的字 | 重复的次数 |
即,我有个文本,I love youuuu.会变为 I love yo/u4。这假设/为预定的特殊字符。不要想这个代码有没用,主要讲问题。
可以看出,这种压缩方法,如果重复字只重复3次或一下,那就没必要采用压缩,因为此种压缩至少也要3个字符。。。
所以被压缩的一个小块(即指yo/u4中的/u4),压缩后重复次数那栏至少为4。
看I love youuuu。示例估计也看出来了,万一原始文本里有/怎么办?
采用特殊处理,遇到特殊字符(比如 /),就把它变成只重复一次的情况。
如 I love / you。会 变为I love //1 you.
即现在看来,一个被压缩的小块,要么至少重复4,要么重复次数为1且要重复的字就是特殊字符本身。
于是,用ASSERT保证这两种情况,去探测是否 还有别的“不可能的”特殊情况。
。
。
。
b = *pbFrom++;
size = (size_t)*pbFrom++;
ASSERT(size >= 4 || (size == 1) && b == bRepeatCode);
。
。
。
Use assertions to detect impossible conditions.
7、安静处理
defensive programming一般要求,代码要容错,出问题要能自行处理,不要直接跳出来停止程序等。
但作者这里就认为,防御式编程是好的,但是有可能会隐藏bug。比如上节程序改为:
byte *pbExpand(byte *pbFrom, byte *pbTo, size_t sizeFrom)
{
byte b,*pbEnd;
size_t size;
pbEnd = pbFrom + sizeFrom;
while (pbFrom != pbEnd){ //这里做了改动
b = *pbFrom++;
if (b == bRepeatCode){
b = *pbFrom++;
size = (size_t)*pbFrom++;
ASSERT(size >= 4 || (size == 1) && b == bRepeatCode);
//这下面的while做了改动
do {
*pbTo++ = b;
}
while (--size != 0);
}
else {
*pbTo++ = b;
}
}
return pbTo;
}
注意到了那两个做了改动的地方,以前是pbFrom < pbEnd。为什么这么改呢?
这里主要原因在于,while循环下,没循环一次,pbFrom并不是只增长1,其增长大小是不定的。那么,想象这种情况,pbFrom可能因为某些原因,跨过了pbEnd(比如最后刚好是一个bRepeatCode,然后pbEnd后是两个字符值)。然后size = (size_t)*pbFrom++;这句是把pbEnd + 2的值,符给了size,也即size最大可能为255,然后下面的do .........while循环,就会把没用数据写入pbTo,最多写255个。
在防御式编程下,即前一个版本,如果真出现了上述问题,函数安静执行,安静结束,但是有bug,指不定多久以后才能找到。
而现在这个版本,看上去很奇怪,觉得会出很多问题,但是当它的pbFrom超出pbEnd后,函数就不会再停止了,一直执行一直执行,破坏pbTo下一切数据,直到引起程序员注意,知道这有错误。(这个函数仅作示例,也别真的太较真,会做改动的)
所以,如果想让你的Debug版能尽早提醒你有错,又希望ship版能迅速安全从错误恢复,不摧毁一切,那可以沿用防御式编程,但是加上ASSERT,如下。
byte *pbExpand(byte *pbFrom, byte *pbTo, size_t sizeFrom)
{
byte b,*pbEnd;
size_t size;
pbEnd = pbFrom + sizeFrom;
while (pbFrom != pbEnd){ //这里做了改动
b = *pbFrom++;
。。。
。。。
。。。
}
ASSERT(pbFrom == pbEnd);
return pbTo;
}
可能你发现了,那个do ...while没有加ASSERT,为什么呢?
这里pbFrom加个ASSERT是因为它每个循环下,并不只是增加1,所以会出现跳过pbEnd的情况,而do ...while那里是确定的每次只减一,所以没必要再ASSERT了。
Don't hide bugs when you program defensively.
8、两个算法总比一个要好
这里作者写了很多,主要意思就是,如果要写一个特别复杂的算法,经常很难保证一次就写对,而且即便感觉上运行正确了,bug可能还是存在。
于是,用另一种简单点,但能保证正确的算法重执行一次,再对比两次执行的结果,不失为一种办法。当然那种简单算法要用#if DEBUG #endif包住。
比如:
快排很难写,容易出错,那在快排函数中,加上个用#if DEBUG #endif包围的冒泡算法,然后每次算完两个值后,对比两个结果是否相同。
当然这种方法写的函数虽然是DEBUG版,但还是巨慢,只有在一个算法确实很难写正确的时候使用。作者举的例子是一个超复杂的用汇编写的算法,然后写个C的来保证它正确。。。。
Use a second algorithm to validate your results.
9、stop the bug at the starting line
就是说,在每个函数进入的时候做参数检查,甚至是一个程序开始时,有个专门函数去做初始参数检查。
我这写的简略,主要是作者举的例子要叙述起来非常繁,是它自己根据一个摩托罗拉的CPU做的编译器。所以有兴趣看作者具体例子,看p41
Don't wait for bugs to happen; use startup checks
练习题:
三、Fortify your subsystems
这里说的子系统,即指能完成一些特定功能的函数组集合,只以一小部分函数为大门,让用户进行调用,完成功能。比如文件系统,以打开关闭文件为大门,文件系统里还有一大堆支持函数,但是都隐藏起来,他们一起组成一个子系统。还有内存管理子系统,用户能用的函数就几个,malloc free等,但是内部支持函数会有很多,他们一起组成一个子系统。
如果现在让你写 内存管理子系统的大门函数,malloc free realloc等,会怎么做呢?
首先,问自己程序员会怎么错误使用我的子系统,而我又能察觉到误用?
比如内存管理函数,可能出现的问题:
分配一块内存,并在未初始化情况下,直接使用了其下内容
释放了一块内存,但继续使用了里面内容
调用了realloc去扩大内存,但是继续引用指向老内存的指针(realloc扩大时候,可能会把老内存内容移到新内存)
分配了内存,但没用指针存下来其位置
在超出分配内存范围地方,读写
没处理内存分配失败的情况
这些问题都很头疼,难以定位,且难以重现。用assert固然有用,但是并不能很好的防范上述问题。所以本章主要以C内存管理函数为例子,制作一系列大门函数,来展示一些除了assert之外的很好的技巧。
注意:本章虽然以大门函数为例,但是并不是说加强子系统就只是改动大门函数实现啊!作者讲的是道理,仅因为大门函数简单,所以拿来当例子,其使用的思想可以用在任何地方。
1、Now you see it , now you don't
因为子系统很多时候并不会开源,所以这里讲C内存管理函数时,用脚手架程序,去将malloc free这些大门以及一些测试代码一起包装,来加强子系统。
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte **)ppv;
*ppb = (byte *)malloc(size);
return *ppb != NULL;
}
注意:ppv是指针的指针,一不小心就忘了。。。。唉。。。
注意:作者用的应该是老C编译器,malloc(size)那句,现在不用对malloc进行强转了。
这函数看上去似乎感觉麻烦,但真正使用的时候,就显现出来便捷之处了。
原本使用malloc来分配内存,代码如下:
if ((pbBlock = (byte *)malloc(32)) != NULL){
successful -- pbBlock points to the block
}
else
unsuccessful -- pbBlock is NULL
使用新的封装函数后,可以为:
if (fNewMemory(&pbBlock,32)){
succeful -- pbBlock points to the block
}
else {
unsucceful -- pbBlock is NULL
}
不过我个人觉得,if那句话,主要是因为是C 语言,没有BOOL,觉得更好的是把if语句写为:
if (fNewMemory(&pbBlock,32) == TRUE)
以此来表明,fNewMemory返回的,是一个BOOL,类型,没有混淆,下次读代码的时候,也容易看出来。
关键:上面fNewMemory,不仅仅是使用的时候减少了一点点代码而已,最主要的是清晰。他将函数是否成功,与指针输出分离了,而malloc输出的是个dual - puspose值。一个输出只干一件事情,不兼职。
上一章提到,要消除代码中的不确定性,这里的malloc函数,就有两个不确定:
1、如果传分配长度为0会怎么样?
2、返回的分配好的内存块,里面内容未进行过初始化,里面值可以为任意内容。
第一个不确定可以通过assert解决,第二个呢?assert是无法完成任务的,于是只剩下最后一个选择:Eliminate the undefined behavior.
第一反应有可能是填0,但是很明显填0会隐藏BUG。如果程序员忘了初始化一块内存,就使用,因为你帮他初始化了,所以该BUG会隐藏。
那填一个别的值也有可能隐藏bug,比如一个bug的出现,有可能跟特定的内存填充值相关,填某些值的时候,就不会显现bug,有些又显现出来。
注意:只在Debug版的时候,填充内存,ship版不但填了没有用,而且会影响程序执行。
所以,挑这个填充值会比较有技巧,填充的值要能够让人容易发现错误的存在,这段内存如果不小心被执行了,也会立即报错,微软用的0xCC,还有别的特殊功能的填充值,用于Debug版的,一般选的值都较大,MFC用的时候,常可以看到什么“烫烫烫烫烫烫烫烫”,"屯屯屯屯屯屯屯屯",就是填充的0xCC或因别的目的填充的特殊值,被解释成汉字了。
作者后面用的0xA3,因为它用的不是Intel的CPU,即填充值与所用平台相关。注意:填的值可不能凑巧是CPU能执行的指令值。
改动后为:
#define bGarbage 0xA3
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte **)ppv;
ASSERT(ppv != NULL && size != 0 );
*ppb = (byte *)malloc(size);
#ifdef DEBUG
if (*ppb != NULL){
memset(*ppb,bGarbage,size);
}
#endif
return *ppb != NULL;
}
填充的是,bGarbage。
Eliminate random behavior.Force bugs to be reproducible.
2、Shred your garbage 丢掉你的垃圾
free函数的封装如下:
void FreeMemory(void *pv)
{
free(pv);
}
问题有:
1、free函数在pv无效指针的时候,行为未定义
2、如果pv指向的是一个分配的内存块的中间,那free的时候行为也未定义。
3、假如是个链表的节点,在释放了内存之后,没有忘记了对周围节点进行更新,那么即便释放内存之后,还是有可能访问到这片内存,这个节点,就好象没有释放一样
于是,想到了要往释放的内存中填垃圾值,记得代码大全里也有说过,释放的内存要填上垃圾值,进攻式编程那节似乎。
但,问题在于,现在单对FreeMemory这个函数来说,它不知到分配的内存有多大,也确实不知到pv指向位置是否合法,注意,这个合法不是仅指NULL,只得是是不是真的指向了一块内存,且指向内存的开头。
新的FreeMemory函数如下:
void FreeMemory(void *pv)
{
ASSERT(pv != NULL);
#ifdef DEBUG
memset(pv, bGarbage, sizeofBlock(pv)); //bGarbage就是前面讲的0xA3,作者用的摩托的CPU
#endif
free(pv);
}
是得,引入了一个sizeofBlock()函数。
注意:这个sizeofBlock()函数,不只是像他仅执行查看pv指向的内存块大小,它还会检验pv是不是指向了一块内存,是不是指向开头。
注意:当然这个sizeofBlock()函数,不会就它一个,它还有一系列的支持函数(注意,都是DEBUG下用的,Ship版没有这些函数,也没必要,这些函数就是帮助排错的),辅助完成它的工作,包括每次调用fNewMemory()时候,要创建内存信息,将内存信息放在一个struct中,并将这个struct当作一个节点,接入一个链表。sizeofBlock()里面会查找pv指向内存,通过遍历,线性查找,可能会说慢,但作者意思是:直到你真正确定,它确实很慢,影响到你工作的时候,再去修改这个算法。
注意:上面的ASSERT那块,以前常写的是if (pv != NULL),这用ASSERT是说,程序如果使用FreeMemovy去释放一块内存,要由调用这保证传的指针必不是空指针。(ANSI C 上没有对free(NULL)的行为作出规定,属未定义行为)
realloc函数也要进行包装,先可以写为:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
pbNew = (byte *)realloc(*ppb,sizeNew);
if (pbNew != NULL){
*ppb = pbNew;
}
return pbNew != NULL;
}
包装后的行为,与realloc不同之处在于,realloc失败后,返回空指针,即返回值也具有两重行为;而包装后,返回的是函数执行状态,如果失败,返回的ppv是原指针,未改动。
因为realloc行为很讨厌,它既有free的功能,又有malloc的功能,也就是说,当realloc的作用是减小一块内存的大小时,按前面说的,需要往减去的内存中存入垃圾,而如果realloc的作用在于扩大一个内存块,那也需要在新分配的内存中,即新增加的内存中,填入垃圾。就好象fNewMemory 和FreeMemory一样。
于是,realloc变为:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
#ifdef DEBUG
size_t sizeOld;
#endif
ASSERT(ppb != NULL && sizeNew != 0);
#ifdef DEBUG
sizeOld = sizeofBlock(*ppb);
//如果是缩小内存块的话,先填垃圾,再缩小
if (sizeNew < sizeOld){
memset((*ppb) + sizeNew, bGarbage, sizeOld - sizeNew);
}
#endif
pbNew = (byte *)realloc(*ppb,sizeNew);
if (pbNew != NULL){
#ifdef DEBUG
//如果是扩大内存的话,先扩大,然后再填垃圾
if (sizeNew > sizeOld){
memset(pbNew + sizeOld, bGarbage, sizeNew + sizeOld);
}
#endif
*ppb = pbNew;
}
return pbNew != NULL;
}
看着长,其实去掉DEBUG和ASSERT就没什么东西了。
注意:上面那个sizeOld声明的时候,用了#ifdef #endif包围,有人会觉得,如果不用#ifdef #endif包围也是可以的。但有问题:
1、不用ifdef包围,sizeOld在ship版本中,就是个声明后,没有使用的变量,编译器会报warning。
2、sizeOld未初始化,后面再有别人来维护这段代码的时候,可能会误用sizeOld
前面提了,同一段代码,即维护一个DEBUG版,又维护一个ship版,但是,DEBUG版内容,就放在DEBUG管辖范围内,别在ship中再出现。
Destroy your garbage so that it's not misused.
3、Movers and Shakers
flag fResizeMemory(void **ppv, size_t sizeNew)
{
。
。
。
。
pbNew = (byte *)realloc(*ppb,sizeNew);
if (pbNew != NULL){
#ifdef DEBUG
//看着里!看着里!在这出现改动
if (pbNew != *ppb){
memeset(*ppb,bGarbage,sizeOld);
}
//如果是扩大内存的话,先扩大,然后再填垃圾
if (sizeNew > sizeOld){
memset(pbNew + sizeOld, bGarbage, sizeNew + sizeOld);
}
#endif
*ppb = pbNew;
}
return pbNew != NULL;
}
前面部分都一样,就是在realloc之后,函数成功后,ifdef 内,检查新分配的内存位置,和我原来的内存位置,是不是在一起,不在,就用memset填垃圾。
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
#ifdef DEBUG
size_t sizeOld;
#endif
ASSERT(ppb != NULL && sizeNew != 0);
#ifdef DEBUG
sizeOld = sizeofBlock(*ppb);
//如果是缩小内存块的话,先填垃圾,再缩小
if (sizeNew < sizeOld){
memset((*ppb) + sizeNew, bGarbage, sizeOld - sizeNew);
}
//如果是扩大内存块的话,手动新分配一块足够大内存,
//把旧内存内容,拷贝
else if (sizeNew > sizeOld){
byte *pbForceNew;
if (fNewMemory(pbForceNew, sizeNew)){
memcpy(pbForceNew, *ppb, sizeOld);
FreeMemory(*ppb);
*ppb = pbForceNew;
}
}
#endif
pbNew = (byte *)realloc(*ppb,sizeNew);
if (pbNew != NULL){
*ppb = pbNew;
}
return pbNew != NULL;
}
可以看出,这里如果是扩大内存的话,就强制分配一块足够的内存,然后将原来内容复制过来,这样,多余出来的内存块内都填有垃圾。fNewMemory负责填充的。
if (fNewMemory(pbForceNew, sizeNew)){
memcpy(pbForceNew, *ppb, sizeOld);
FreeMemory(*ppb);
*ppb = pbForceNew;
}
void FillMemory(void *pv, byte b, size_t size)
{
ASSERT(fValidPointer(pv, size));
memset(pv, b, size);
}
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte *)ppv;
ASSERT(ppv != NULL && size != 0);
*ppb = (byte *)malloc(size);
#ifdef DEBUG
if (*ppb != NULL){
memset(*ppb, bGarbage, size);
if (!fCreateBlockInfo(*ppb, size)){
free(*ppb);
*ppb = NULL;
}
}
#endif
return *ppb != NULL;
}
注意:还是想唠叨一句,上面void **ppv,为了保证传进来的指针参数可以为任何值,进来以后,要用强转,转换为byte型。感觉这样设计非常好,但是自己为什么老忘记,写函数的时候,看着都会,都懂,一上手问题就来了。。。。。。就没人家写的好。。。唉。。