Writing Solid Code

本书很好,围绕怎样写出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;
}
此函数功能是,还原一个被压缩了的代码。压缩方法是,遇到重复的字符时压缩,将重复字符改为:

特殊字符要重复的字重复的次数
特殊字符就是bRepeatCode。

即,我有个文本,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

看到这的时候,可能你就有疑问了,因为realloc并不是总在同一个位置分配内存块的,它先尝试在原地扩大内存,但是原来地方不够时,它就会找块新的,更大的位置开辟内存,并将原来内容复制过来。
那么,如果是出现了复制,按上面的函数,原来的内存位置可没有填垃圾就直接销毁了。为了保持一致,需要把原来位置也填上垃圾。
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填垃圾。

注意:先读上面代码,思考一下这么干行不行,能不能完成我们填垃圾的任务?考虑完了再往下看。

问题在于,realloc后,如果发生了移动,即把原来位置内存块内容,复制到了新开辟的内存块里,然后realloc会释放原来内存块。那么这里新添加的代码,是在往一块已经释放了的内存里,写bGarbage。这是不允许的,即便你知道原来位置在哪,大小多少,但释放后,内存已不再属于你了。

注意:到这时候,你还有招吗?怎么才能又检测出来realloc进行了内存移动,又能在内存不释放的情况下填垃圾给原来的内存块??思考思考,再往下看。

考虑一下后,你就会发现这是不可能的,你无法自己改动realloc函数,realloc执行时候,是否进行了移动,只能在realloc执行之后被探测,但那时候内存块已经释放了,永远没办法再往里面填值。

那是不是在这里设个例外,不给realloc出现移动后,原内存块填值,反正它出现的时候非常少,其他情况下我们也都填垃圾了,放过这里?
作者这里意见是,还是要想办法在这里填垃圾,为了能很好的探测realloc出的bug,因为如果是这里realloc因为内存移动出bug,会非常非常难重现。非常难以检测到,ship发布前的测试过程中可能并不会发现这个bug。要解决bug,你还至少要让它能够重现,才能找到bug所在位置,而realloc是否进行移动,跟整个系统中的内存分配相关,每次运行时候几乎不可能完全相同,所以这个问题必须要解决。

作者给了一条建议:You don't want anything to happen rarely. 
                               If you find rare behavior in your subsystems, be sure to do something ------anything to stir things up.

这里让realloc重现内存移动的方法,就是DEBUG版本中,每次执行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);
                }
                //如果是扩大内存块的话,手动新分配一块足够大内存,
                //把旧内存内容,拷贝            
                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;
                        }
这块可能会奇怪,这块执行完后,已经没有工作了,直接加 return TRUE;返回岂不更好,现在这种写法,如果函数用来扩大内存块,那么if (fNewMemory)那里要分配内存,下面realloc又在做重复工作,虽然realloc不会真正分配内存,这样是不是出现重复工作了?

答案:因为这个if 块,在DEBUG中,DEBUG和SHIP是不同的版本,但是DEBUG用于保证SHIP的执行,所以,除非有极为强大的理由,不然一定要保证SHIP版语句的执行,不能因为DEBUG版语句,跳过SHIP版语句。

这块做法可能会引起争议,但是作者还是认为,保证代码无BUG是第一位的,DEBGU版效率问题是次要的。
If something happens rarely , force it to happen often.

4、Keep a journal to jog your memory

看这节得题目就可以感觉到本节要讲什么了。

前面看到sizeofBlock()函数很有用,它就是建立在对分配内存做记录的基础上,做记录后,不但能得到每块内存的大小,还能去验证内存是否是有效的,这比大多数情况下,验证是否为NULL要有效的多。

比如,我们前面用的memset函数可以包装一下,为:
void FillMemory(void *pv, byte b, size_t size)
{
        ASSERT(fValidPointer(pv, size));

        memset(pv, b, size);
}
fValidPointer()函数,就是用来检测一块内存是否有效的,这种包装又是一个用代码大小和执行速度,来换取额外安全性的例子。

或者,你可以把DEBUG时FillMemory()函数用上面的版本,SHIP时,还是用普通的memset,用宏完成:
#define FillMemory(pb, b, size)    memset((pb), (b), (size))

重点是:在DEBUG版,保留一些额外信息,可以提供更强大的检错方法。

前面讲的给内存填充垃圾,用于查错方法,比这种直接记录内存信息的方法,查错能力要弱一些。

做内存记录,需要一系列辅助函数,比如,fNewMemory时候,要同时创建一个新内存块,用于放置log 项;
                                                                            FreeMemory时候,要记得同时释放对应的log项;
                                                                             fResizeMemory时候,要更新对应的log项;
每个log项,在作者代码示例里,用的链表进行连接。

有了这些辅助函数后,还要对前面的fNewMemory FreeMemory fResizeMemory三个函数进行更改,以加入对内存信息的记录。

注意:fNewMemroy()函数,更改时,必须保证分配的内存块,和存log项的块要同步,只有在新分配内存块,和新分配log项块,都分配成功时候,函数fNewMemory()才返回成功。这块一定要小心些,忘了就容易出问题。

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型。感觉这样设计非常好,但是自己为什么老忘记,写函数的时候,看着都会,都懂,一上手问题就来了。。。。。。就没人家写的好。。。唉。。

Keep debug information to allow stronger error checking.







  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值