作者:刘新浙,刘玲,王超,李敬娜等
出版社:人民邮电出版社
格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT
从缺陷中学习C/C++试读:
前言
这是一本在研究大量C/C++程序Bug基础上集结而成的书!
这是一本汇集众多一线C/C++编程人员智慧的书!
这是一本让您学好C/C++,绕过编程陷阱和障碍的必备案头书!为什么写这样一本书
▪在不同的项目或产品中,不同的开发人员重复着同样的Bug,甚至同一个人重复相同的Bug。如果将时间周期拉得更长一些看:一个程序员,从刚毕业参加工作到具备丰富编程经验,从一个新手到成为专家,在这个过程中,每个人都在重复着前人走过的弯路,重复着同样的编程错误。测试人员在日常工作中积累了大量验证Bug方面的经验,这些Bug是有价值的,总结出来可以让更多人受益。
▪C/C++是软件/互联网行业最常用的编程语言之一,相对其他语言学习难度高,从新手到专家往往需要多年的磨练。另一方面,C/C++开发的系统往往更容易产生严重的生产事故,一旦出现事故,定位问题根源也比较困难。所以,每一个程序员掌握扎实的C/C++基础知识,对于构建稳定可靠的生产系统非常重要。我们希望通过这本书帮助C/C++程序员以最快速度全面了解C/C++编程中的陷阱,编写健壮可靠的代码,从而达到提高软件质量、减少生产故障、提高工作效率的目的。
相对其他C/C++类书籍,本书有以下特点和优势:
▪从具体Bug中学习
全书由102个案例组成,每个案例分析一个Bug。读者掌握了一个案例就是掌握了一个知识点,就能避免一类问题。由于是从具体Bug案例中学习,这种学习方式更直接,更让人印象深刻。普通的C/C++编程书,即便看完后,写代码时也无法避免Bug,这是因为,书虽然看完了,知识也理解了,但你并不知道哪里有陷阱。
▪来源于工程实战的宝贵经验
本书中的所有案例都来自于软件/互联网行业开发生产中遇到的实际问题,都是在前人的错误和弯路中总结出来的实战经验,堪称C/C++编程方面的“干货”。
▪学习起来更有成就感
本书由一个个案例组成,在每个案例中,先给出错误代码示例,然后描述Bug的现象和后果,随后对该Bug进行具体分析,最后给出解决方案及建议。这种案例分析式的组织方式,会引导读者先对案例中提出的问题进行思考,当看到案例分析和解决方案时常常有恍然大悟的感觉,让学习过程变得简单,并充满乐趣。
▪更宽的知识面
一个C/C++程序员即使工作多年,由于受所接触项目和技术方向的限制,视野(C/C++编程中值得注意的知识点)往往是有限的。这本书中的案例收集于大量工程项目,几乎涵盖了C/C++编程中的方方面面,远超出一般程序员所能接触的范围。掌握了这本书中的内容,能避免大多数问题。本书适用范围
▪本书适合已经写过一些C/C++代码、期望尽快积累实战经验的C/C++程序员阅读学习。本书也适合打算提高代码编写和代码阅读分析能力的软件测试人员,书中的每个案例都可应用于白盒测试中。本书代码运行环境
▪本书中的所有案例代码都是针对Linux C/C++环境,在Redhat Linux环境下编译(GCC,G++)测试通过。代码可以通过人民邮电出版社网站(www.ptpress.com.cn)下载。
最后,由于本书作者在C/C++编程方面的经验和技能有限,书中可能会有一些描述不够清晰或不正确的地方,读者如有发现请及时告诉我们,我们会再进行修正。我们的联系邮箱是cppbugbook@gmail.com,编辑联系邮箱是zhangtao@ptpress.com.cn。在此表示感谢!
期望这本书能真真切切地帮助到他人。编者第1章 基础问题
本章从基础问题讲起,涉及运算符优先级、宏定义、类型转换(显式和隐式)、指针、数组等C/C++编程的一些基本概念和知识点。这些问题虽然简单,但如果不引起重视,也会给编程带来很多麻烦。1.1 运算符优先级引起的问题
▪代码示例
//To get 2*n+1
int func ( int n )
{
return n << 1 + 1;
}
▪现象&后果
上述代码中的函数func本意是期望计算2*n+1,但程序实际运行结果是4*n。
▪Bug分析
这段代码使用左移1位来代替乘以2的运算,是很好的方法,但是编程者弄错了运算符“<<”和“+”的优先级。C/C++语言规定运算符“+”的优先级高于运算符“<<”,因此,上述语句“return n<<1+1”等同于“return n<<(1+1)”,所以,会先进行加法运算,再进行左移运算,得到结果4*n。
修正的方法是在表达式中添加必要的括号。
▪正确代码
//To get 2*n+1
int func ( int n )
{
return (n << 1) + 1;
}1.2 不加括号的宏定义引起的错误
▪代码示例
#define PERIMETER(X,Y) 2*X+2*Y
int main(void)
{
int lenth = 5;
int width = 2;
int high = 8;
int result = 0;
result = PERIMETER(lenth, width) * high;
printf("result=%d \n", result);
}
▪现象&后果
上述代码是期望通过一个宏先计算一个矩形的周长,然后再乘以高,结果应该为112,但实际计算结果为42,与预期不符。
▪Bug分析
上述代码中,语句“result = PERIMETER(lenth, width) * high”本意是希望先进行宏定义部分的运算,然后再乘以变量high,但是,由于宏替换是在预编译阶段进行的,宏替换本身只是文本替换,上述语句在宏替换后变成了“result = 2 * lenth + 2* width * high”,按照运算符优先级规则会先做乘法运算再做加法运算,故导致运算结果不符合预期。因此,用于表达式的宏,在宏定义时给整体语句加上小括号是比较保险的做法。
▪正确代码
#define PERIMETER(X,Y) (2*(X)+2*(Y))
int main( )
{
int lenth = 5;
int width = 2;
int high = 8;
int result = 0;
result = PERIMETER(lenth, width) * high;
printf("result=%d \n", result);
}
▪编程建议
由于宏替换可能会带来不可预期的结果,因此,使用宏时要非常谨慎。在C++中尽可能少使用宏,改用其他方式来代替。下面是几种常见的替代场景:程序中频繁被调用的代码段或者函数,期望使用宏替换来节省执行时间的,可以用内联函数代替;期望使用宏来存储常量的,可以用 const 变量代替;期望使用宏来“缩短”长变量名的,可以用引用来代替。1.3 污染环境的宏定义
▪代码示例
在文件a.cpp 中:
#define map __gnu_cxx::hash_map
map
在文件b.cpp 中:
#include
#include "a.cpp"
map
▪现象&后果
上述代码假设由两个人所写。a.cpp为程序员A所写,为了书写方便,A将库函数gnu_cxx::hash_map宏定义为map。b.cpp为程序员B所写,B在b.cpp中包含了库map,同时在其后又包含了a.cpp。这最终导致B使用的map并非其预期的系统库map中的定义,而是gnu_cxx::hash_map。
▪Bug分析
由于宏替换是在预编译阶段执行的,所以在 b.cpp 中,代码行 map
这段代码虽然编译可以通过,也可以正常运行,但是用错了 map 和 hash_map可能会引发不可预期的后果(如性能差别、功能扩展的区别),埋下了程序稳定性差的隐患。
▪正确代码
修改文件a.cpp中的宏名字为hmap:
#define hmap __gnu_cxx::hash_map
hmap
▪编程建议
给宏定义命名时,要避免宏名称与系统库的定义同名。1.4 多语句宏定义使用错误
▪代码示例
#define EXIT(info) std::cerr< int main( ) { int data = 0; if(data < 0) EXIT("data is a negative number!"); std::cerr << "data is a non-negative number." << std::endl; } ▪现象&后果 这段代码定义了一个EXIT宏,当data为负数时,打印“data is a negative number”信息然后程序退出;当data大于等于0时,打印“non-negative”,然后程序结束。但是,实际运行结果是,当 data为非负时,程序退出但没有打印“data is a non-negative number”。 ▪Bug分析 上述代码中的if语句,在EXIT宏展开后如下: if(data<0) std::cerr<<"data is a negative number!"< 正确地缩进以后,代码变成: if(data<0) std::cerr<<"data is a negative number!"< exit(1); 显然exit语句不在if分支语句块中,所以,不管data的值是否为负数,exit语句都会被执行。这和原意不符。 就上述问题而言,一种简单的解决办法是用大括号将宏定义的内容括起来: #define EXIT(info) {std::cerr< 这种方法可以解决上述代码中的问题,但是在如下场景中依然有问题: Int fuction(int data) { If(data < 0) EXIT("data is a negative number!"); else cout << data << endl; } 在宏替换之后变成: int fuction(int data) { if(data < 0) { std::cerr< exit(1); }; else cout << data << endl; } 这时多余的分号会出现语法错误。 所以推荐的另外一种修正做法是使用内联函数来替换宏定义,具体见下面的正确代码。 ▪正确代码 inline void EXIT (const char info[]) { std::cerr << info << std::endl; exit(1); } int main( ) { int data = 0; if(data < 0) EXIT("data is a negative number!"); std::cerr << "data is a non-negative number." << std::endl; }1.5 char转为int时高位符号扩展的问题 ▪代码示例 int main( ) { char a = 0x9A; int util; util = (int)a; if( util > 0 ) printf ("positive\n"); else printf ("negative\n"); } ▪现象&后果 上述代码期望最后输出“positive”,但实际输出结果为“negative”。 ▪Bug分析 上述代码中,char变量a的值为0x9A,这个值有迷惑性。0x9A转换为十进制为154,所以,在把a强制转换为int类型的变量util之后以为还是154,所以,期望输出为“positive”。但实际上0x9A的二进制表示为10011010,在强制转换为int时,因为int是有符号的,需要对10011010进行符号扩展,也就是用其最高位1来扩充其他 3个高字节,变成 11111111 11111111 11111111 10011010(假设 int是4个字节),而这个是负数-102的二进制补码表示。所以,在判断util是否小于0时就会输出“negative”。 从另外一个角度来说,变量a的类型为char,一般系统中char为有符号的,0x9A解析为有符号的 char 时,其值实际上也为-102。所以,0x9A 在强制变换前后是保持一致的。 但如果期望 0x9A为正数,实际上需要先把 a强制转换为 unsigned char。这样0x9A才会被解析为154。 ▪正确代码 int main( ) { char a = 0x9A; int util; util = (int)(unsigned char)a; if( util > 0 ) printf ("positive\n"); else printf ("negative\n"); }1.6 int转为char时的数据损失 ▪代码示例 int main() { char c; while ((c = getchar()) != EOF) { putchar(c); } return 0; } ▪现象&后果 各种系统都有自己默认的char类型,可能是unsigned char,也可能是signed char。假如当前系统默认的 char类型是 unsigned char,上述代码运行时会出现死循环。 ▪Bug分析 在默认的 char 类型是 unsigned char 的系统中(可以通过 g++编译时加参数-funsigned-char来模拟),上述代码中,getchar()返回一个int型,将被强制转换为unsigned char赋给 c,这样当 getchar返回EOF(-1)时,转换成 unsigned char后的ascii值是 255。然后,系统在比较 c(unsigned char)和EOF时,会将它们均转换为 unsigned int来比较,对前者是(unsigned int)255,对后者是(unsigned int)-1=232。虽然它们都表示-1,但8位的-1和32位的-1之间的差距却很大,永远不会相等,因而会造成死循环。 修正方法是去除int转为char的强制转换。 ▪正确代码 int main() { int c; while ((c = getchar()) != EOF) { putchar(c); } return 0; } ▪编程建议 在编程过程中,对于变量的强制转换,需要注意类型的截断和扩展,特别是char、unsigned char、unsigned int、int、short等类型在赋值时的隐式转换。1.7 非法的数组下标 ▪代码示例 int main() { const int size = 5; int intArray[size]; float floatArray[size]; for (int i = 0; i < size; i++) { floatArray[i] = size - i - 2.1; intArray[(int)floatArray[i]] = i ; printf("i=%d, floatArray[%d]=%f, intArray[%d])=%d \n", i, i, floatArray[i], (int)floatArray[i], intArray[(int) floatArray[i]]); } return 0; } ▪现象&后果 数组intArray的下标会出现负值的情况,导致程序出错。 ▪Bug分析 上述代码行“intArray[(int)floatArray[i]] = i;”中整型数组 intArray的下标中有floatArray数组的元素。数组下标只能为非负整数,代码中只做了int的强制转换,不能保证它不会出现负数。在这段代码中,当 i 为 4 时,floatArray[4]=−1.1,这时候就出现了负下标−1,因而导致程序出错。 正确做法是,在用一个变量作为数组下标时,使用前需要验证其合理性。 ▪正确代码 int main() { const int size = 5; int intArray[size]; float floatArray[size]; for (int i = 0; i < size; i++) { floatArray[i] = size - i - 2.1; if(floatArray [i] < 0) continue; intArray[(int)floatArray[i]] = i ; printf("i=%d, floatArray[%d]=%f, intArray[%d])=%d \n", i, i, floatArray[i], (int)floatArray[i], intArray[(int) floatArray[i]]); } return 0; } ▪编程建议 C/C++语言中数组下标越界,编译器是不会检查出错误的,但是实际上后果会很严重,可能会导致程序崩溃等。当使用变量作为数组下标访问数组元素时,检查数组下标值的合理性是一个良好的编程习惯,可以避免数组越界的错误发生。1.8 有符号int与无符号int比较的后果 ▪代码示例 int array[] = {23, 24, 12, 204} ; #define TOTAL_ELEMENTS (sizeof(array)/sizeof(array[0])) int main(void) { int d = -1; if(d <= TOTAL_ELEMENTS) printf("TRUE\n"); else printf("FALSE\n"); } ▪现象&后果 程序运行结果打印的是FALSE而不是TRUE。 ▪Bug分析 由于sizeof()的返回类型是无符号整型,因此,上述代码中TOTAL_ELEMENTS的值是 unsigned int类型。if语句在比较 signed int型变量d和TOTAL_ELEMENTS宏返回的 unsigned int型值的时候,signed int型变量被转换为 unsigned int型变量。-1转换成 unsigned int的结果是一个非常巨大的正整数(在 32位操作系统上会转换成2^32-1),导致if判断为假。 解决的办法是将宏TOTAL_ELEMENTS返回的结果强制转换为int型。 ▪正确代码 int array[] = {23, 24, 12, 204} ; #define TOTAL_ELEMENTS (int)(sizeof(array)/sizeof(array[0])) int main(void) { int d = -1; if(d <= TOTAL_ELEMENTS) printf("TRUE\n"); else printf("FALSE\n"); }1.9 有符号的困惑 ▪代码示例 struct data { int flag: 1; int other: 31; }; int status() { return 1; } int main() { struct data test; test.flag = status(); if (test.flag == 1) { printf("test.flag=1 ,it's true !\n"); } else printf("test.flag != 1 ,it's false !!\n"); } ▪现象&后果 无论函数status的返回值是1还是其他值,都无法运行到if分支里面。 ▪Bug分析 这段代码在结构体中定义了一个int型的位域变量,而用一个bit来表示int时,这一位是用来表示符号位的,带符号的一个bit的位域变量的取值范围是0或-1(无符号的一个bit的位域变量的取值范围是0或1)。stauts()的返回值1赋给flag时会出现溢出,flag值变为-1,所以status无论返回什么值给flag,都不会是1,这样就无法运行到if语句分支。 解决的办法是将包含标志位的变量设置为无符号型:unsigned int flag:1。 ▪正确代码 struct data { unsigned int flag: 1; int other: 31; }; int status() { return 1; } int main() { struct data test; test.flag = status(); if (test.flag == 1) { printf("test.flag=1 ,it's true !\n"); } else printf("test.flag != 1 ,it's false !!\n"); }1.10 整除的精度问题 ▪代码示例 int main() { float result; result = 1 / 6; printf("result = %f\n", result); } ▪现象&后果 代码计算出来result的结果是0,不符合预期。 ▪Bug分析 由于 1 和 6 都是整型常量,两个整型常量的运算结果依然是整型,因此,1/6的结果为0,不会保留小数部分。 如果希望保留小数部分,至少需要把一个数写成小数。 ▪正确代码 int main() { float result; result = 1.0 / 6; printf("result = %f\n", result); } ▪编程建议 关于C语言的隐式类型转换,总结有以下两点。(1)赋值时,一律是右边值转换为左边类型,但是右边是表达式时,会先进行运算,然后才对运算的结果进行数据类型转换。(2)当不同类型的变量进行计算时,遵循由低级向高级转换的规则,例如,char型会转换为整型,short型转换为int型,float型会转换为double型。1.11 浮点数比较的精度问题 ▪代码示例 int main(void) { float f = 1.0 / 3.0; float expect_f = 0.333333; double d = 1.0 / 3.0; double expect_d = 0.333333; printf("f=%f, expect_f=%f, d=%lf, expect_d=%lf \n", f, expect_f, d, expect_d); if (f == expect_f && d == expect_d) printf(" Equal! \n"); else printf(" Not equal! \n"); } ▪现象&后果 上述代码中,即使f和expect_f相等并且d和expect_d相等,if的判定条件也未必为真。 ▪Bug分析 在计算机中,浮点数表示精度的位数有限,因此,不能准确地表示一个小数(IEEE 754规定的单精度 float数据类型的表示精度为 7位有效数字,双精度 double为16位有效数字),所以,在代码中对浮点数据类型进行比较时不建议使用== 、<= 、>=、 !=等比较运算符。例如,在计算中作如下比较:if (result == expect_result),这个结果几乎是永远不可能为真的。即使某一次比较结果为真,那也是偶然的,因为,这个比较结果是不稳定的:数据、编译器的微小改变都可能使程序产生不同的结果。 浮点数进行比较时,一般比较它们之间的差值在一定范围之内。 ▪正确代码 int main(void) { float f = 1.0 / 3.0; float expect_f = 0.333333; double d = 1.0 / 3.0; double expect_d = 0.333333; printf("f=%f, expect_f=%f, d=%lf, expect_d=%lf \n", f, expect_f, d, expect_d); if (fabs(f - expect_f) < 0.00001 && fabs(d - expect_d) < 0.00001) printf(" Equal! \n"); else printf(" Not equal! \n"); }1.12 最小负整数取相反数溢出 ▪代码示例 int main() { int minInt = 0xffffffff; if (minInt < 0) { minInt = -minInt; } printf("%d\n", minInt); return 0; } ▪现象&后果 程序目的是取相反数,但是取反后溢出,输出为1。 ▪Bug分析 有符号的数据类型,均有正负数之分,如:int、float、double。int表示范围不对称,例如,在32位机器上,int范围为-2147483648~2147483647,如果对-2147483648取反得到的数将是溢出之后的1。所以,在对int类型数据进行取反处理时,需要额外处理这种特殊情况。 ▪正确代码 int main() { int minInt = 0xffffffff; if(minInt == 0xffffffff) { printf("minInt=%d\n", minInt); } else if (minInt < 0 ) { minInt = -minInt; } printf("%d\n", minInt); return 0; } ▪编程建议 在对数据进行操作时,要考虑数据的取值范围,避免数据溢出造成程序稳定性差的问题。1.13 临时变量溢出 ▪代码示例 long multiply(int m, int n) { long score; score = m * n; return score; } ▪现象&后果 当m、n都取较大的值时,如1亿(在int范围之内的较大值),导致程序运行后处理结果不正确。 ▪Bug分析 在64位操作系统下,int型通常占4个字节,long型通常占8个字节,两个int型变量相乘的值范围是long型变量的值范围。上述代码中,score=m*n 这行代码在执行时,m和n相乘的结果会先存储在一个临时的int变量中,然后再赋值给long变量score,这个临时变量是很容易溢出的。所以,需要在表达式运算前先对m和n做数据类型转换。 ▪正确代码 long multiply(int m, int n) { long score; score = static_cast return score; } ▪编程建议 对隐式的类型转换,一般来说向上是安全的,向下会出现数据截断丢失,导致数据错误。 事实上,上述正确代码只在long型字节数是int型字节数两倍的情况下才是正确的,如果在某些平台下,long 型和int 型字节数一样的话(如32 位操作系统int型和long型通常都占4个字节),仍然需要注意两个int型相乘结果溢出的问题。1.14 size_t导致的死循环 ▪代码示例 int main(void) { size_t size = sizeof(int); while( --size >= 0 ) { cout << "size=" << size << endl; } return 0; } ▪现象&后果 while条件永远为真,程序进入死循环。 ▪Bug分析 size_t是 sizeof操作符返回的结果类型,size_t在32位系统中是 unsigned int,在 64位系统中是 unsigned long int。上述代码中,每执行一遍语句“while(--size >=0)”,size就会减1,当size的值等于0并再次作−−size运算时,size会因溢出再次等于它取值范围内的最大值,所以,size的值恒大于等于0,while条件一直为真。 ▪正确代码 int main(void) { size_t size = sizeof(int); while( --size > 0 ) { cout << "size=" << size << endl; } if(size == 0 ) cout << "size=" << size << endl; return 0; }1.15 误用short引起缓冲区溢出 ▪代码示例 #define MAX_BUF 256 #define OVER_SHORT 50000 //大于short最大值32767 void testCode(char *input) { short len = strlen(input); char buf[MAX_BUF]; cout << "strlen(input) is:" << strlen(input) << endl; cout << "len is :" << len << endl; cout << "MAX_BUF is:" << MAX_BUF << endl; if(len < MAX_BUF) { //do copy strcpy(buf, input); }else{ cout << "overflow!" << endl; } } int main() { char *str = new char[OVER_SHORT]; memset(str, 'a', OVER_SHORT); str[OVER_SHORT - 1]='\0'; testCode(str); return 0; } ▪现象&后果 程序运行时缓冲区溢出引起错误。 ▪Bug分析 strlen()的返回值是 size_t类型,size_t在 32位系统是 unsigned int型,通常占 4个字节,在 64位系统是unsigned long int型,通常占 8个字节,而 short类型一般占两个字节(short的取值范围为-2^15~2^15-1)。一个size_t类型变量赋值给short类型时,如果size_t超过了short能表示的最大值时,将会引起溢出。上述代码中,执行 len = strlen(input)语句时,由于 input字符串长度(49999)超过了 short的最大值,导致 len溢出变成负数,从而导致 if判断为 true,执行 strcpy(buf, input)语句,而 buf缓冲区长度小于input字符串长度,造成缓冲区溢出并引发错误。 正确的做法是将函数TestCode中的变量len的数据类型改为size_t。 ▪正确代码 void testCode(char *input) { size_t len = strlen(input); char buf[MAX_BUF]; cout << "strlen(input) is:" << strlen(input) << endl; cout << "len is :" << len << endl; cout << "MAX_BUF is:" << MAX_BUF << endl; if(len < MAX_BUF) { //do copy strcpy(buf, input); }else{ cout << "overflow!" << endl; } }1.16 区分continue和return ▪代码示例 int main() { double minScore = 0.0; double maxScore = 100.0; double allScore[10] = {99.0, 98, 88, -1, 62, 75.6, -10, 67, -1}; double sumScore = 0.0; double averageScore = 0.0; int validNum = 0; for(int i = 0; i < 10; i++) { if(allScore[i] > maxScore || allScore[i] < minScore) { cout << "Bad score:" << allScore[i] << endl; return 1; } sumScore += allScore[i]; validNum++; } if( validNum > 0 ) averageScore = sumScore / validNum; cout << "average score:" << averageScore << endl; return 0; } ▪现象&后果 这段代码原本是为了计算数组中有效成绩的平均成绩,但是却没有计算出来。 ▪Bug分析 在C语言中,用return来返回某个值并退出程序。在上段代码中,遍历到数组中的-1时会执行return,结束程序,因此,-1后面的成绩无论是否有效,都不能计算出平均成绩。这里应该把return改成continue。在for循环中,continue是结束本轮循环,执行下一次循环,因此,可以过滤掉无效成绩,计算出有效成绩的平均值。 这段代码本身并没有语法错误,只是混淆了return和continue的概念,使用不当导致结果不符合预期。 正确的做法是将main函数 for循环中的 return 1 改为 continue。 ▪正确代码 int main() { double minScore = 0.0; double maxScore = 100.0; double allScore[10] = {99.0, 98, 88, -1, 62, 75.6, -10, 67, -1}; double sumScore = 0.0; double averageScore = 0.0; int validNum = 0; for(int i = 0; i < 10; i++) { if(allScore[i] > maxScore || allScore[i] < minScore) { cout << "Bad score:" << allScore[i] << endl; continue; } sumScore += allScore[i]; validNum++; } if( validNum > 0 ) averageScore = sumScore / validNum; cout << "average score:" << averageScore << endl; return 0; }1.17 指针常量和常量指针的区别 ▪代码示例 int main() { int x = 2; int y = 4; int *const px = &x cout << *px << endl; const int *py = &y cout << *py << endl; px = &y cout << *px << endl; *py = 1; cout << *py << endl; return 0; } ▪现象&后果 编译代码,会提示类似如下的错误: error: assignment of read-only variable 'px' error: assignment of read-only location ▪Bug分析 问题的根源是没有区分指针常量和常量指针的概念。上述代码中 px 是指针常量,它的概念是:指针本身是常量。它指向的地址是不可改变的,但地址里的内容可以通过指针改变。所以,语句px=&y将y的地址重新赋给指针px,编译器会报错。指针 py 是常量指针,它的概念是:指向常量的指针,顾名思义,就是指针指向的是常量,即,它不能指向变量,它指向的内容不能被改变,不能通过指针来修改它指向的内容,但是指针自身不是常量,它自身的值可以改变。代码中,语句*py=1 通过指针 py 的解引用(dereference),来改变指针指向的内容值,所以编译器会报错。 ▪正确代码 int main() { int x = 2; int y = 4; int *const px = &x cout << *px << endl; const int *py = &y cout << *py << endl; py = &x //将x的地址赋值给常量指针py cout << *py << endl; *px = 1; //通过指针常量px,改变px所指的value cout << *px << endl; return 0; }1.18 字符数组和指针不总是等价的 ▪代码示例 文件sub.c中: char str[] = "Hello World!\n"; main函数所在文件中: #include extern char *str; int main() { std::cout << str << std::endl; return 0; } ▪现象&结果 g++编译报错。报错信息如下: error: conflicting declaration 'char* str' error: 'str' has a previous declaration as 'char str [14]' ▪Bug分析 关键字extern可以置于变量或者函数前,以表示变量或者函数的定义在别的文件中,提示编译器遇到此变量或函数时在其他模块中寻找其定义。编译上述代码时,编译器会去sub.cpp中查找 *str变量,但是,sub.cpp中没有定义str这个指针变量,只是定义了str[]数组。因此查找失败,导致编译器报错。所以,char数组和char指针在很多时候可以相互替换,但这种在不同文件中用extern引用时,它们仍然是不同的。 ▪正确代码 extern char str[]; int main() { printf("in main.c , str:%s\n", str); return 0 ; }1.19 结构体成员变量初始化的隐患 ▪代码示例 struct rectangle { int lenth; int width; }; int main( ) { struct rectangle rect1 = {4, 2}; return 0; } ▪现象&后果 这段代码虽然编译和运行时不会有任何问题,但是,一旦结构体的成员变量顺序发生了变化,就可能导致错误。 ▪Bug分析 这段程序对结构体的内存布局作了假设。一旦调整了结构体成员变量的顺序,调用方(示例中的main函数 )即使不修改代码,编译时也不会有错误提示,但是程序的逻辑却发生了变化,这往往会导致错误发生。尤其是当这个结构体是第三方提供的时候,这种调整往往会被调用方的程序员所忽略。 ▪正确代码 struct rectangle { int lenth; int width; }; int main( ) { struct rectangle rect1 = {rect1.lenth = 4, rect1.width = 2}; return 0; } ▪编程建议 对结构体进行赋值时,建议具体到结构体的成员变量名。1.20 返回值非void的函数没有返回值 ▪代码示例 bool initA() { printf("init A done.\n"); return true; } bool initB() { printf("init B done.\n"); return true; } bool initC() { time_t seconds = time(NULL); printf("seconds is %d.\n", seconds); if(seconds%2==0){ printf("init C done.\n"); }else{ printf("init C fail.\n"); } } bool init() { return initA() && initB() &&initC(); } int main() { if( init() ) { printf("all init processes return true.\n"); } else { printf("at least one init returns false.\n"); } } ▪现象&后果 上述代码假设一个初始化函数init包含3个子init函数initA、initB和initC。只有当3个子init函数都成功,才打印初始化成功信息。只要其中一个子init函数初始化失败,就打印初始化失败信息。为简化程序,代码中initA和initB一直返回true,但initC会根据当前时间秒数,如果是偶数,就认为成功;如果是奇数,就认为失败。 上述代码在实际运行中会出现一种错误情况,就是当 initC 打印的时间秒数是偶数,表示这个子init函数成功,但在main函数中打印出来的信息却是init失败。 ▪Bug分析 由于C/C++标准没有规定函数一定要有返回值,gcc/g++编译器通常不会检查函数是否有返回值,因此,bool方法在没有return语句的情况下,使用gcc/g++编译并不会报错。但是,没有return语句的函数的返回值是不确定的,它通常是函数最后一个表达式的值,它存储在EAX寄存器中。 上述代码的问题正是在于,initC函数没有显式return一个bool返回值,其值为不确定,所以会出现“虽然initC成功,但init结果总是失败”这样的情况。 改正的方法是在 initC函数中,如果成功,就显式地 return true;如果失败,也显式地 return false。 ▪正确代码 initC函数修改如下: bool initC() { time_t seconds = time(NULL); printf("seconds is %d.\n", seconds); if(seconds%2==0){ printf("init C done.\n"); return true; }else{ printf("init C fail.\n"); return false; } } ▪编程建议 非void类型的函数,建议在每个分支都要有明确的返回值。1.21 cin>>和getline混用导致的奇怪问题 ▪代码示例 int main () { int zipcode; string address; cout << "Please Input Zipcode:" << endl; std::cin >> zipcode; cout << "Please Input Address:" << endl; getline(cin, address); cout << "Zipcode:" << zipcode << endl; cout << "Address:" << address << endl; return 0; } ▪现象&后果 程序的功能是用操作符“>>”和getline分别从命令行读取两个输入:一个整型的 zipcode 和一个字符串形式的 address。但运行时发现,程序在用户输入 zipcode后不会等待用户输入address,即用getline输入address代码会被自动跳过,用户没有机会输入地址字符串。 ▪Bug分析 cin是C++标准输入流istream类型对象,代表标准输入设备,相当于C语言里的stdin。在程序中包含iostream头文件即可使用cin对象。istream类重载了抽取操作符“>>”,能够读取C++中的各种基础数据类型。抽取操作符“>>”根据后面变量的类型读取数据,从非空白符号开始,遇到Enter、Space、Tab键时结束。std::getline函数则用于从istream中读取一行数据,当遇到换行符“\n”时结束返回。 这个问题的根源在于抽取操作符“>>”。当用户在命令行输入 zipcode 值时, cin>>读取到一个连续的数字(无空格)后立即停止,把用户按下回车键时,换行符“\n”留在了输入流里。cin 不会主动删除输入流里的换行符。这样,“\n”被后续的getline读到,因为getline遇到换行符结束,所以,getline不会等待用户在终端输入数据,而是立即结束返回。因而造成上面所说的奇怪现象,这种情况下 address得到一个空的赋值。一个简单的解决办法是在每一个 cin>>后加一个 cin.ignore(),用于清除留在输入流里的换行符。 ▪正确代码 int main () { int zipcode; string address; cout << "Please Input Zipcode:" << endl; std::cin >> zipcode; cin.ignore(); cout<< "Please Input Address:" << endl; getline(cin, address); cout<<"Zipcode:"< cout<<"Address:"< return 0; } ▪编程建议 上面的简单方法虽然能解决问题,但建议最好不要在同一段代码中混用抽取操作符“>>”和 std::getline()。因为如果混用,需要特别注意“>>”是否在输入流中留下了换行符,如果留下了换行符或者其他字符或者标志,往往会造成意想不到的麻烦。一种更好的办法是使用getline一次读取一行用户输入,将用户输入视为字符串,然后再从该字符串中解析自己需要的数据。下面的代码提供了这样的一种方案。 int main () { int zipcode; string address; string input = ""; while (true) { cout << "Please Input Zipcode:" << endl; getline(cin, input); stringstream myStream(input); if ( myStream >> zipcode ) break; else cout << "Invalid number, please try again" << endl; } cout << "Please Input Address:" << endl; getline(cin, address); cout << "Zipcode:" << zipcode << endl; cout << "Address:" << address << endl; return 0; }1.22 小结 通过以上这些基本问题的分析,我们可以了解C/C++语言最基本的一些特性,很多复杂问题在经过分解之后其根源也可以归结为这些基本问题。本章列出的 20的个例子无法覆盖基础知识的方方面面,期望读者从中得到启发,做到举一反三,进而可以更好地分析复杂问题。第2章 编译问题 本节主要介绍编译环境中遇到的一些典型问题,如动态、静态链接库的加载,命名空间,编译参数的使用等。编译相关的问题,与实际代码的逻辑关联性不大,但对代码的运行却可能产生很大的影响,而且这种影响是全局性的。一旦出现问题也更加难以定位,如果错误地将问题定位到代码上,会耽误很多时间去追踪。因此,理解编译链接阶段的原理和实现,可以帮助我们避免许多环境上的问题,专心于代码的开发。2.1 动态链接库加载错误版本 ▪代码示例 在程序 test.cpp 中, main 函数里使用了动态链接库 libiscore.so 中的函数avg_score,如下: int main(int argc, char **argv) { int sum = 40000; float average = avg_score(sum); cout << "score per morth: " << average << endl; return 0; } 在系统目录/usr/lib64下有libiscore.so.1.0,其中avg_score函数的实现如下: float avg_score(int s) { return (float)s / 12; } 而在用户目录/home/admin/IScore/lib下有libiscore.so.2.0,其中avg_score函数实现在1.0版本上有所修改: float avg_score(int s) { int tmp = (int)(((float)s / 12) * 100); return (float)tmp / 100; } ▪现象&后果 test.cpp编译并没有问题,执行时输出 score per morth: 3333.333252,而预期的输出是只保留小数点后两位,即 score per morth: 3333.33,实际与期望不符。 ▪Bug分析 Linux下动态链接库的加载是通过dlopen来实现的,dlopen函数按指定模式打开指定的动态链接库文件。它有一个加载顺序:(1)RPATH,(2) LD_LIBRARY_PATH,(3)/etc/ld.so.cache 维护的so 列表,(4)/lib 和/usr/lib。RPATH可以在编译的时候通过-r来指定,执行的时候就会到这个路径下去加载,如果不存在就会出错,不会出现加载不兼容版本的问题了。LD_LIBRARY_PATH 是最常用的一种方式,可以把需要的目录加进去。在执行前,通常可以通过ldd命令来检查程序所指向的库是否正确: ldd test libiscore.so => /usr/lib/libiscore.so.1 (0x00002b093c48f000) libdl.so.2 => /lib64/libdl.so.2 (0x0000003242600000) libpthread.so.0 => /lib64/libpthread.so.0 (0x0000003242e00000) librt.so.1 => /lib64/librt.so.1 (0x0000003246200000) libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x0000003246600000) libm.so.6 => /lib64/libm.so.6 (0x0000003242a00000) libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x0000003245e00000) libc.so.6 => /lib64/libc.so.6 (0x0000003242200000) /lib64/ld-linux-x86-64.so.2 (0x0000003241200000) ▪正确代码 将所需so的路径加进LD_LIBRARY_PATH中,比如路径是/home/admin/IScore/lib: export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/a/lib ▪编程建议 so使用前先 export LD_LIBRARY_PATH是一个良好的编程习惯。2.2 相同名称静态库的链接顺序 ▪代码示例 程序 test.cpp 使用了两个包 libexam.so 和 libbucket.a ,分别在目录/home/admin/sear/lib和/home/admin/algo/lib下,编译命令如下: gcc test.cpp -o test -L/home/admin/sear/lib -L/home/admin/algo/lib-lexam -lbucket 然而,这两个路径下都有libbucket.a这个包,但却有着不同的功用。 ▪现象&后果 程序test.cpp原本想链接的是/home/admin/algo/lib下的libbucket.a,但是在实际使用时才发现链接上了错误的包。 ▪Bug分析 编译链接时,当碰到参数-l 时,如上述-lbucket ,首先,链接器会将libbucket.a/libbucket.so作为目标文件到几个标准的系统目录下查找如/lib,然后,如果找不到,则去编译参数-L指定的path下寻找,如果-L参数有多个,则按从左至右的顺序依次查找,一旦找到就会停止查找。例如,这里会优先到/home/admin/sear/lib,找到之后,就不会去后面的-L下查找了。 ▪正确代码 考虑-L的目录查找顺序,或者为包更名,避免冲突。 ▪编程建议 良好的lib命名规范可以避免同类问题的发生。2.3 使用命名空间来区分不同cpp中的同名类 ▪代码示例 程序test.cpp使用了两个动态链接库,即algo_util和algo_common,在编译链接命令中用-l作了指定,如: gcc test.cpp -o test -L/home/a/lib64 -lalgo_util -lalgo_common 但是这两个库中存在相同名称的类log4cpp,用来打印日志,且有相同的接口,如: void ERROR(string message); 但接口的实现在两个包中却不相同。在程序test.cpp里,用algo_util包里的接口来打印algo_util相关的日志,用algo_common包里的接口来打印algo_common相关的日志。 ▪现象&后果 程序运行时,发现所有用到log4cpp类打印日志的地方都是调用了algo_util包中的接口。 ▪Bug分析 上述编译链接命令中,有两个-l参数,即-lalgo_util -lalgo_common,编译器在查找包时,是按从左至右的顺序,因此,对编译器来说 libalgo_util.so 是优先于libalgo_common.so的,这个顺序,会记录到二进制目标文件 test的 needed section中,可以通过ldd命令来查看: ldd test linux-vdso.so.1 => (0x00007fff602d9000) libalgo_util.so => /home/a/lib64/libtesta.so (0x00007f3df0375 000) libalgo_common.so => /home/a/lib64/libtestb.so (0x00007f3df01 73000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3defda2 000) /lib64/ld-linux-x86-64.so.2 (0x00007f3df0579000) 可以看出libalgo_util.so在前,libalgo_common在后。执行test的时候,遇到外部函数就会到动态链接库中查找,当按照顺序在libalgo_util.so中找到之后,就不会去后面的库中查找了。 ▪正确代码 可以在 export LIBRARY_PATH中将 algo_common所在路径提前,但是解决不了根本。最好是在algo_util和algo_common程序代码中都增加命名空间: namespace util { // codes of algo_util }; namespace common { // codes of algo_common }; 然后在使用的时候引用命名空间,并用标识符来指定使用哪个命名空间下的函数:: using namespace common; using namespace util; { common::ERROR("error info"); } ▪编程建议 注意命名空间的使用,不能滥用,防止冲突。2.4 C++模板编译时依赖名称查找 ▪代码示例 template class ClassA { protected: int num; }; template class ClassB : public ClassA { public: void init() { num=0; } }; ▪现象&后果 上面这段代码在早年版本的GCC下编译能通过,但使用新的GCC版本编译时出现错误: 'num' was not declared in this scope ▪Bug分析 因为在编译时,C++会分两个阶段的名字查找来解析模板的语法,第一阶段会扫描所有非依赖名称,第二阶段(实例化模板)会扫描所有的依赖名称。所谓依赖名称,是指那些在编译时被认为是依赖于模板参数,只有在模板实例化时才能消歧的名称;反之则是非依赖名称。在上面的代码中, num变量被编译器看作是非依赖名称,所以要在第一阶段的解析过程中识别num变量,但由于目前编译器还没有实现在模板基类中对非依赖名称的查找,所以在类 ClassB 早期的GCC并没有真正实现两阶段名字查找机制,在对模板类的语法解析时,将所有的名字查找都留到了实例化的时候进行,所以上面的代码可以通过编译。 ▪正确代码 在变量num之前加上this->,使变量num成为依赖名称,这样对变量num的查找会在第二阶段进行,程序会正常编译通过: void init() { this->num=0; }2.5 违背ODR原则可能会带来的意想不到的问题 ▪代码示例 testapp.h: #include #include #include #include using namespace std; class Test { public: Test(); virtual ~Test(); private: vector bool isv1, isv2, isv3, isv4, isv5; int flag; string name; bool usev1, usev3, usev5; }; class testApp{ public: void init(); Test t1; Test t2; vector }; testapp.cpp: #include " testapp.h" Test::Test() {} Test::~Test() {} void testApp::init(){ std::cout << sv.size() << std::endl; } main.cpp: #define _GLIBCXX_DEBUG #include "testapp.h" int main(){ testApp* obj = new testApp(); obj->init(); std::cout << obj->sv.size() << std::endl; } ▪现象&后果 执行程序,输出结果如下: 18446744073709025614 0 从上面的代码可以看到,main函数中对象obj的成员sv并没有被赋值,其size()本应为0,但两次打印sv.size()出现很大差距。 ▪Bug分析 ODR(One-Define-Rule),即单定义规则:C++标准规定,对于类和内联函数,在每个编译单元中最多被定义一次,而且要保证不同编译单元之间定义的一致性;对其他函数,在所有文件中只能存在一处定义。一个编译单元是对程序员提交给编译器的文件执行预处理后的结果,预处理包括按照条件编译命令(#if,#ifdef)去除掉未被选中的代码块、去除注释、递归地插入“#include”指令所引用的文件、将宏展开等操作。 上面的代码经过预处理器之后会产生两个编译单元,即main.cpp和testapp.cpp,两者都包含了testapp.h的代码,即类TestApp和Test的定义。但是经过编译之后,由于main.cpp中存在宏_GLIBCXX_DEBUG,导致类Test在两个编译单元中的定义有微小的差别。加上_GLIBCXX_DEBUG 的编译方式会改变 C++标准类模板,如std::vector的大小和定义,所以在setup函数中和main.cpp 对testApp类的访问,会因为对 Test 有不同的定义和大小,最终导致不一致结果的发生(关于宏_GLIBCXX_DEBUG,更详细的说明可查阅C++官方文档)。 ▪正确代码 将宏定义#define _GLIBCXX_DEBUG从main.h中移到 testapp.h中,保持一致性。或者去掉宏定义,以编译参数的方式加到编译命令中: g++ -o test testapp.cpp main.cpp –D _GLIBCXX_DEBUG ▪编程建议 单定义规则是构建一个良好C++工程的基础,尤其当程序文件较多,声明与实现相分离,需要include头文件的情况下,更容易违反。多次引用.h文件造成变量重复定义是比较常犯的错误,编译器也能事先发现。像上述类型较为隐蔽,也不易追查,须更加注意,养成良好的编码规范。2.6 变量共用内存时使用O2优化编译 ▪代码示例 int main() { int64_t mem = 0; int32_t* low = (int32_t*)&mem int32_t* high = low + 1; *high = 0xffffeeee; *low = 0xaaaabbbb; printf("mem=%lx, high=%x, low=%x\n", mem, *high, *low); return 0; } ▪现象&后果 上述代码能顺利通过编译,但使用不同的编译参数编译后,运行时 mem 的输出结果却完全不同。例如,当编译选项不包含“-O2”时,输出为: "mem=ffffeeeeaaaabbbb, high=ffffeeee, low=aaaabbbb" 但在编译选项中加上“–O2”时,输出为: "mem=0, high=ffffeeee, low=aaaabbbb" 即当使用优化选项O2时,mem的值为0。有无O2编译选项使mem的值完全不同。 该问题在 g++ (GCC) 3.4.6和 g++ (GCC) 4.1.2环境下均存在。 ▪Bug分析 上面代码的目的是希望在一个int64位的变量中存放两个int32位变量,当改变32位变量的值时,64位变量的值也相应改变。但当加上-O2优化编译选项,则int64变量mem的值没有发生变化,依然为0。问题分析的过程是,首先发现使用-O1选项编译正常,但-O2选项不正常。于是分析O2比O1实际多增加了哪些优化内容,最终确定是优化标识-fstrict-aliasing起了作用。如果编译时使该编译优化标识失效,则编译后的程序运行正常,即使用如下命令行编译后正常: g++ sample.cpp -g -O2 -fno-strict-aliasing -fstrict-aliasing 优化标识生效会在编译过程中应用最严格的别名(aliasing)规则,不允许一种变量类型与另一种不同的变量类型处于相同的内存地址。所以,这里不允许int32与int64的变量处于同一块内存地址,因此,在编译优化之后,mem、low和high已经指向了不同的内存地址,给low赋值只能改变它自己的值,并不能操作mem的地址空间。 ▪正确代码 去掉-O2 编译选项即可。或者使用-O 选项,但是将让优化标识-fstrict-aliasing失效,即: g++ sample.cpp -O2 -fno-strict-aliasing ▪编程建议 优化编译可以在一定程度上提升性能,但它有可能会带来一些不可预料的问题,而且经过优化的程序也不易调试,因此,如果对性能的要求不高,在编译时可通过编译选项“-O0”去掉编译器的优化。2.7 小结 编译链接是程序代码变为机器可执行代码的第一步,也是很重要的一步,了解编译器链接器的过程和原理,能有效避免后续执行中出现的一些与程序逻辑无关的问题。这章主要列举了编译链接过程中容易出现的一些问题,包括动态和静态链接库的链接加载顺序、编译参数等,相信读者从这几个案例中可以有所借鉴。第3章 库函数问题 本章主要介绍库函数的使用中会遇到的问题。使用库函数可以降低软件开发的难度,提高代码编写的效率。这一章主要涵盖的内容有,调用字符串库函数时需要注意对字符串结束符‘\0’的处理,复制字符串的时候要注意内存空间是否写溢出,函数调用前需要做必要的初始化,函数使用后对其返回值需要做正确处理,容器类的增、删操作要注意迭代器失效等,忽视这些方面将带来各种各样的问题。3.1 sprintf函数引起的缓冲区溢出 ▪代码示例 int main() { char src[50] = "abcdefghijklmnopqrstuvwxyz"; char buf[10] = ""; int len = sprintf(buf, "%s", src); printf("src=%s\n", src); printf("len=%d\n", len); printf("buf=%s\n", buf); return 0; } ▪现象&后果 程序运行时,用sprintf函数把字符数组src的内容往字符数组buf复制时会溢出,可能出现段错误(Segmentation fault)。 ▪Bug分析 上述代码从一个字符数组src复制字符串到另外一个字符数组buf中,src的字符串长度为26,但buf的长度只有10,用sprintf函数进行复制的时候会把src的所有字符往buf里写,从而引起buf溢出。 正确的做法是在复制之前检查 buf 的长度是否足够,或者直接用更安全的snprintf函数代替sprintf。 ▪正确代码 int main() { char src[50] = "abcdefghijklmnopqrstuvwxyz"; char buf[10] = ""; int len = snprintf(buf, sizeof(buf), "%s", src); if(len > sizeof(buf) - 1) { printf("[Error] Source string length is %d. The buf size %d is not enough. Copy incomplete!\n", len, sizeof(buf)); } else { printf("src=%s\n", src); printf("len=%d\n", len); printf("buf=%s\n", buf); } return 0; } ▪编程建议 在libc参考手册对sprintf函数的说明中有一个警告,如果复制的字符串长度超过提供的 buf 串的长度,sprintf 函数会变得很危险。为了避免这个问题,可以用snprintf函数来代替sprintf函数。但在使用snprintf的时候,在调用这个函数之后需要对返回值作检查,如果返回值比分配的buf长度要大,表示复制不完整,则需要重新分配大的空间之后再一次调用snprintf函数。 在libc参考手册中,也同时提到,在实际使用过程中,用asprintf函数代替snprintf函数会更方便些。asprintf函数不需要预先分配buf,它能在复制过程中根据实际复制源字符串的大小动态分配空间,具体可参考libc参考手册。3.2 snprintf函数format参数的问题 ▪代码示例 int main() { char buf[10] = ""; char src[10] = "hello %s"; int len = snprintf(buf, sizeof(buf), src); printf("buf=%s\n", buf); return 0; } ▪现象&后果 上述代码的字符串src中如果存在格式化字符串(如本例中src包含“%s”),程序运行时最后打印出的 buf 值将是不可预期的字符串或者出现段错误(Segmentation fault)。 ▪Bug分析 snprintf函数本身是可变参数函数,原型如下: int snprintf( char* buffer, int buf_size, const char* format, ... ) 当函数只有3个参数时,如果第三个参数中没有包含定义格式化的字符串,函数调用没有问题,但如果第三个参数中包含定义格式化的字符串,如例子中的“%s”,由于没有提供第四个参数,函数将会访问一个不确定的内存地址读取内容作为这个参数的值,从而导致不可预期的输出或者段错误。正确的做法是提供完整的参数,防止出现参数遗漏的现象。 ▪正确代码 int main() { char buf[10] = ""; char src[10] = "hello %s"; int len = snprintf(buf, sizeof(buf), "%s", src); printf("buf=%s\n", buf); return 0; }3.3 错误使用snprintf函数返回值 ▪代码示例 int main() { char buf[10] = ""; char src[30] = "hello world! hello world!"; int len = snprintf(buf, sizeof(buf), "%s", src); printf("return len=%d\n", len); buf[len] = '\0'; printf("buf=%s, bufLen=%d\n", buf, strlen(buf)); return 0; } ▪现象&后果 上述代码运行时返回的len是25,写buf[len]=’\0’时将出现错误。 ▪Bug分析 snprintf 函数返回的是预写入的字符串长度。以上述代码为例,如果源字符串src的长度小于等于sizeof(buf)-1,返回值则为实际写入目标字符串buf的字符数,也就是 src 的长度;如果 src 的长度大于 sizeof(buf)-1,实际写入 buf 的字符数为sizeof(buf)-1,但返回值依然为src的长度。但snprintf返回值不一定就是src的长度,例如,当格式化字符串包含其他字符时,如上面的“%s”改为“ab=%s”,返回值就比src的长度要大。实际上snprintf返回值可以理解为当buf大小没有限制时写到buf的字符个数。 在例子中len=25,buf[25]=‘\0’产生写越界,从而产生段错误,可能导致重大问题。上述代码实际上是想在snprintf复制完之后显式地在buf结尾处添加一个‘\0’。但实际上,snprintf函数在复制结束时自动就会处理字符串结束标志‘\0’的问题,不用额外处理。 snprintf函数的返回值常被用来和目标字符串buf的大小进行比较,以判断此次复制是否完全。 ▪正确代码 int main() { char buf[10] = ""; char src[30] = "hello world! hello world!"; int len = snprintf(buf, sizeof(buf), "%s", src); printf("return len=%d\n", len); if(len>sizeof(buf)-1) { printf("[Error] Source string length is %d. The buf size %d is not enough. Copy incomplete!\n", len, sizeof(buf)); }else{ printf("buf=%s, bufLen=%d\n", buf, strlen(buf)); } return 0; }3.4 字符串复制不完整 ▪代码示例 int main() { char str[] = "ab\0c"; char buf[5]; strcpy(buf, str); int bufSize = sizeof(buf); printf("bufSize=%d\n", bufSize); for(int i=0; i { printf("%c\n", buf[i]); } return 0; } ▪现象&后果 复制到目标字符串buf中的内容与源字符串str中的内容不相同。 ▪Bug分析 字符串在内存中是以二进制流的形式存在的,对二进制流来说‘\0’也是一个正常的01序列,但这个‘\0’对C语言的字符串来说是一个特殊的字符,它是用来标志字符串的终结符,因此,对字符串函数strcpy来说,其操作的范围,只是字符串的开始到它遇到的第一个‘\0’。故当字符串中间存在‘\0’时,‘\0’后面的字符就会被忽略。正确的做法是使用memcpy函数,这个函数是对内存的复制,如前面说的,任何字符都只是01序列,‘\0’也一样。所以,如果被复制的字符数组中含有‘\0’,可以使用memcpy,复制的长度由它的第三个参数决定,不会依赖‘\0’。 ▪正确代码 int main() { char str[] = "ab\0c"; char buf[5]; memcpy(buf, str, 5); int bufSize = sizeof(buf); printf("bufSize=%d\n", bufSize); for(int i=0; i { printf("%c\n", buf[i]); } return 0; } ▪编程建议 strcpy函数和memcpy函数之间的区别:strcpy只能用于字符串复制,不需要指定长度,它遇到被复制字符串的结束符‘\0’就结束;memcpy 可用于复制任意内容,复制长度由其第三个参数决定。3.5 string类的c_str方法使用不当 ▪代码示例 int main() { string str = "abcd"; const char *pcStr = str.c_str(); printf("cStr=%s, pcStr=%p\n", pcStr, pcStr); str.append("efg"); const char *pcStr2 = str.c_str(); printf("cStr2=%s, pcStr2=%p\n", pcStr2, pcStr2); return 0; } ▪现象&后果 在语句“str.append("efg")”之后,指针 pcStr 成为“野指针”,可能造成严重后果。 ▪Bug分析 string类的c_str()方法返回的是一个常量指针,它所指向地址的内容是不会改变的。上述代码中,在没有改变str的值之前,指针变量pcStr就是str的首地址,但当通过append函数对str追加了一些内容之后,因为append会先开辟一段新内存,然后再将原来的值复制过来,所以,str的首地址已经发生了改变,而pcStr指向的内存实际上已经被释放,因而pcStr成为“野指针”。 改正的方法是,在将str.c_str()赋值给pcStr之后,不去改变str的值 ;或者当需要改变 str 的值时,使用 strcpy 函数把 str.c_str 返回的内容复制出来,再赋值给pcStr。下面正确代码使用的是第二种方法。 ▪正确代码 int main() { string str = "abcd"; char pcStr[20]; strcpy(pcStr,str.c_str()); str.append("efg"); return 0; } ▪编程建议 最好使用strcpy函数来操作string类的c_str方法返回的指针,从而避免带来“野指针”。3.6 string类的“[]”操作符使用不正确 ▪代码示例 void messageCopy(const char* msg, size_t length, string &str) { for( int i = 0; i < length; i++) { str[i] = msg[i]; } } int main() { char message[10]="123456789"; string str; str.reserve(10); messageCopy(message, 10, str); printf("str=%s, length=%d\n", str.c_str(), str.size()); if (str.empty()) { printf("empty message\n"); } return 0; } ▪现象&后果 上述代码通过messageCopy函数接收一个字符串,并通过string类的empty函数判断返回的字符串 str是否为空,若为空则打印“empty message”。但不管给的源字符串message是多长的字符串,程序总是打印“empty message”,但直接打印str.c_str函数的输出并不为空。 ▪Bug分析 问题出在messageCopy函数上。在函数里面输入的源字符串msg的内容被逐个按字符复制给传入的目标字符串str,确实msg的内容也复制到了str的内存空间中,这通过打印str.c_str函数的输出可以验证。string对象内部维持了一段连续空间,以及表示所存字符串长度的 size。这里的问题就是,string 对象 size 的值与实际存在string对象中的字符串长度出现了不一致。上述代码中,虽然改动了str的内存空间,但并没有触发size的改变,而empty()函数只是根据原有的size是否为0作判断,而没有重新计算str所占内存长度,所以,造成非预期结果。 ▪正确代码 int main()
试读结束[说明:试读内容隐藏了图片]