如果调试程序是移除Bug的过程,那编写程序就是把Bug放进来的过程。-- Dijkstra
本文整理介绍了C/C++程序员在实际开发中,可能会犯下的常见错误。它们大多具有一定的迷惑性,初看起来似乎毫无问题,但实际运行起来竟完全不合常理。有些则是很多程序员未曾认真关注过的细节,当然对于一些不太主流的用法和技巧,或许笔者和各位都不打算在实际工作中来使用,但学习了解一下类似未尝试用过的技巧,也还算是个有趣的过程。其中大部分的错误,是笔者亲身实经历过的。
一. 运算符
有经验的程序员经常会告诉我们,按位运算比数学运算来的更加高效,隐约记得多年前各种大学课本上也时不时地来个提示。计算2*x + 1,基于以上考虑,写出以下代码:
int CalcValue(int x)
{
return x<<1 + 1;
}
使用向左移1位可以实现乘2运算,细心的程序员还会考量负数的情况。略微回忆下补码的表示形式,可以得出,左右移位都不会对符号造成影响。这看起来是对的,但实际运行却得不到想要的结果。这其中隐藏了一个运算符优先级的基础问题,加法的优先级高于移位,所以实际上的计算会是 x << (1 + 1),也就是4*x。
此处正确的写法应该是return (x << 1) + 1,得到的经验是,在书写运算表达式时,总是应该合理地加上括号。
二. 奇怪的符号位
无符号数与有符号数的计算和比较,是一个非常容易出错的地方,大家普遍也对此非常熟悉,同时编译器和扫描工具也能够尽早察觉,这里便不再老生常谈。
另一个比较容易忽略的,是有符号数的移位操作。需要了解,C++标准对右移时高位补1并没有要求。是否补1取决于编译器的具体实现。虽然大部分编译器会补1来保证符号不变,但并非所有都是如此。合理的建议是按位表示整形,最好按无符号进行定义。
除了以上,再举一个不太寻常的例子:
struct A {
int IsOK:1;
int Value:31;
};
A a;
a.IsOK = 1;
if (a.IsOK == 1) ...
A是一个位域结构,出于精打细算的考虑这里使用1位来表示是否OK,其他位来存放数值。按照上述代码a.IsOK 首先被设置为1。但随后使用 if (a.IsOK == 1) 进行判断时,便会发现,条件并不成立。有点奇怪,其问题根源在于IsOK是有符号的,而仅有的1位将被优先用来做符号位,IsOK的取值或者是0,或者是-1。改正此问题的办法,一个是使用if (a.IsOK) 进行判断,另一个更好一点,是将IsOK改为无符号类型。
三. snprntf的困惑
有经验的程序员会拒绝使用sprintf。凭着对输出buffer长度的严格检查,snprintf得到了众多程序员的青睐。但使用了snprintf就万事无忧了么,下面举例说明一个比较容易忽略的细节:
intSendMsg(int fd, constchar* msg)
{
char buffer[32];
int len = snprintf(buffer, sizeof(buffer), "MsgBody: %s", msg);
buffer[len] = '\0';
write(fd, buffer, len);
return len;
}
假设该场景中以字符串进行协议交换,也不必关心write能否成功写入,仅把关注重点放到snprintf的基本使用上来,这样的代码是正确的么?
首先buffer[len] = '\0' 这一行代码是多余的,snprintf会保证目标字符串以\0结尾,即便在目标字符串被截断的情况下。
其次,更加重要也是致命的一点,snprintf的返回值,并非是实际写入的长度,而是它原本应该写入的长度。也就是说,它返回的是格式化后的完整字符串长度,因此当发生截断时,返回值将超过buffer长度。同时从函数手册上来看,仍然存在返回负数的可能。
综合以上,两个错误,在置结束符时发生了写越界,在随后write时发生了读越界。同时还有另一个可恶之处,就是当没有发生截断时,他总是运行正确。
或许有人认为snprintf返回值的设定有点说不过去,但考虑到将返回值与buffer长度进行比较,是一种有效识别是否截断的手段,这样的设计也就合情合理了。
四. 迭代器失效
STL是C++对泛型编程思想的实现,广义上有算法、容器和迭代器三个部分组成。从作用上来说,迭代器是STL的最基本部分,使得算法和容器能够解耦分离,并用迭代器精妙的连携起来。迭代器提供了比下标操作更一般化的方式,现代C++更倾向于使用迭代器而不是下标访问容器。
即便迭代器具有如此重要的地位,提供更加一般化的操作,但稍不留心,也还是会搞出点儿麻烦。至少在笔者所经历过的项目中,不止一次的遇到,很多老手也未能幸免。
1. vector的遍历删除
for (std::vector<int>::iterator iter = numbers.begin();
iter != numbers.end(); ++iter)
{
if (NeedDelete(*iter))
{
numbers.erase(iter);
}
}
上述代码忽略了一个细节,vector的存储空间是连续分配的,删除当前元素时,会导致后续的元素集体前移。erase过后iter已经指向下一个元素,而for循环中的++iter,则再次跳过一个元素。跳过一个也许只会导致结果错误,但令人更加担心的情形是,如果恰好跳过了尾端指示器end()呢。干得漂亮,一个不会结束的循环产生了,直到越界访问到非法区域而崩溃。
这里推荐一种针对vector的有效处理方式,当执行erase时通过返回值修正iter,否则++iter:
for (std::vector<int>::iterator iter = numbers.begin();
iter != numbers.end(); )
{
if (*iter ==value)
{
iter = numbers.erase(iter);// 通过返回值修正
}
else
{
++iter;
}
}
除了删除操作,增加操作也同样存在问题,或许问题更大。因为当vector元素增加超过现有空间时,需要重新申请一片连续空间并作迁移,此时所有迭代器都将失效。
vector因为有内存空间连续的要求,对迭代器也有了如上的限制,那对于结点空间独立分配的map或是list,是不是就不存在问题了呢?
上述的正确用法,对于list容器也完全奏效,但对于map,set等关联容器时,上面的方式行不通,原因是标准的C++关联容器,erase()方法返回void而并非下一个结点(某些特殊版本STL实现会返回结点,如VC,但这并不符合标准)。
2. map的遍历删除
针对map的遍历删除,即便结点独立存在,但以下方式仍然是错的:
for (std::map<int, int>::iterator iter = numbers.begin();
iter != numbers.end(); ++iter)
{
if (NeedDelete(iter->second))
{
numbers.erase(iter);// erase会导致iter失效
}
}
其根本原因在于,删除当前iter指向元素后,该iter也立即失效,随后在循环体中执行的++iter,其结果是不确定的。然而能够仿照vector,使用iter = numbers.erase(iter) 么? 答案也是否定的,原因是标准的C++关联容器,erase()方法返回值是void,而不是迭代器(尽管也有某些特殊STL实现也会返回迭代器,但这并不符合标准)。
针对本例错误,推荐使用以下的方式:
for (std::map<int, int>::iterator iter = numbers.begin();
iter != numbers.end(); )
{
if (NeedDelete(iter->second))
{
numbers.erase(iter++);// 先取值,然后++,再erase
}
else
{
++iter;
}
}
理解该方式的关键点在于理解numbers.erase(iter++)的行为。按照后自增操作的定义,该条语句拆解如下:
1. iterator temp_iter = iter;
2. iter++;
3. numbers.erase(temp_iter);
可见在erase操作前iter已经指向为下一个结点,随后的erase也无法对iter造成影响。前面的实效问题得到化解。
程序员需要对不同容器的实现机制有所认识,进而清楚地了解迭代器将在何时实效,如何实效。本节仅列举了两个常见容器的删除情景,限于篇幅,未能对所有容器所有情形逐一介绍。希望此节能让大家有所警醒,当有朝一日操起键盘写下类似代码时,请务必想起迭代器失效这回事儿。
五. 成员函数指针
成员函数指针是一个不太普遍使用的特性,至少在笔者所在的项目中,尚未有实际使用过。而普通的函数指针,还是经常活跃在各个项目之中,特别是一些采用了面向对象设计的纯C项目。成员函数指针与普通函数指针有着很大不同,课本里教导我们指针就是地址,但成员函数指针并非一个简单的地址。为了支持虚函数的多态,需要额外存储型别相关信息,这点可以通过对sizeof进行验证。
我们通过以下代码,一窥函数指针的使用细节:
class Foo { ... };
typedef int (Foo::*MemFunc)(int, int);
MemFunc func1 = &(Foo::Add); // 1.错误,不能加括号
MemFunc func2 = Foo::Add; // 2.错误,必须有取址符
MemFunc func3 = &Add; // 3.错误,必须有类限定符
MemFunc func4 = &Foo::Add; // 4.正确
Foo* foo = new Foo;
foo->func(); // 5.错误,将误以为func是具名的成员函数
foo->*func(); // 6.错误,()优先级高于->*,优先结合成func()
(foo->*func)(); // 7.正确
(foo->(*func))(); // 8.仍然错误,->*是一个操作符,不可拆分
相对于普通函数指针,获取成员函数指针有着严格的语法要求:
1. 不能使用括号,如错误1
2. 必须使用取址符,如错误2
3. 必须有限定符,如错误3,即便已在类的作用域内也需要
对于一个成员函数指针的使用:
1. 不能没有*,如错误5
2. 不能缺少括号,如错误6
3. 不能有多的括号,如错误7
不得不说,这里出现了一个形迹可疑的家伙,->*。稍作了解可以得知,它并非是->和*的某种组合,而是一个独立操作符,oprator ->*,专门为成员指针而量身定制。->*能够通过对象的指针提领到该对象的成员指针。同样还有.*操作符,则是通过对象的引用进行提领。
成员函数指针带来的问题并不严重,它是静态的,在编译期间便可发现。但个人在第一次尝试使用时,确实也折腾了几次。通过此节,希望能让更多的同学少费周折吧。
六. 类型转换
C++程序员首先会建议尽量不要强制类型转换。如果必须要做,也提醒你放弃老旧的C语言形式,使用C++推荐的四种类型转换,分别是static_cast,reinterpret_cast,dynamic_cast,const_cast。其中后两者都具备非常确定的使用场景,这里不再详细讨论。本节以代码举例的方式揭示下static_cast和reinterpret_cast的区别。
1. 指针转换
int* ip = NULL;
char* cp = static_cast<char*>(ip); // 非法,不安全的转换
char* cp = reinterpret_cast<char*>(ip); // 合法,强制转换
如上,对于指针类型,static_cast会进行类型安全检查,而reinterpret_cast则不作安全判断。
void*是一个特例,static_cast允许双向转换,当然reinterpret_cast也是可以的。
void* vp = static_cast<void*>(ip); // 合法,允许退化为void*
int* ip2 = static_cast<int*>(vp); // 合法,void*允许转换
2. 基本类型转换
int i = 1000;
double d = 9.5;
char c = static_cast<char>(i); // 合法,即便可能截断
int i2 = static_cast<int>(d); // 合法,即便精度丢失
int i3 = reinterpret_cast<int>(d); // 非法,不能转换基本类型
可见static_cast对基本类型转换,相当于C语言中的隐式自动转换,包括字节截断或精度丢失,都可以合法转换。而对于reinterpret_cast则不能转换基本类型,其只能对指针到指针,以及指针和整数间的互相转换。
3. 基类和子类间的转换
class B {
int b;
};
class C :public B {
int c;
};
C c;
B* bp = &c; // 合法,子类就是基类,持有基类所有成员
C* cp = bp; // 非法,基类不是子类,没有子类成员
C* cp1 = static_cast<C*>(bp); // 合法,允许基类强制转换为子类
C* cp2 = reinterpret_cast<C*>(bp); // 合法,可以转换任意指针
参考以上代码,在已知基类指针确定为一个具体子类时,程序上可以做这个强制转换。而且以上两种方式都可以编译通过。但是,这两种方式是不是完全等价呢?答案是否定的,这也是本条目最想描述的重点。
当存在多重继承时,reinterpret_cast将得到错误的结果。比如随着项目的演进,直到有一天,CCC需要从两个类继承:
class C :public A, public B {
int c;
};
这里关系到一个C++对象布局的问题。如下图所示,对于多重继承,同样一个C对象,当把其视作基类A和基类B时,两者的地址是不同的,存在一个偏移。
reinterpret_cast的转换,恰恰是无视一切信息的强制转换,转换前后的指针数值不会发生变化,这也与C语言中的强制转换行为一致。而static_cast在做子类向基类的转换时,则会根据类型信息做一个偏移的修正。虽然这是C++多继承带来的复杂化之一,但也是保证正确的必要手段。
至此,可以得出一个重要的结论,就是在基类向子类转换时,在确认具体类型的前提下,使用static_cast是安全的,reinterpret_cast可能得到错误的地址。
当然在使用多态类型时,从基类向子类的转换,可以使用dynamic_cast并检查返回值。
七. 数组长度
对于C形式的数组,有经验的程序员通常会用sizeof来求得数组长度。其好处有目共睹,无论以后数组增加还是减少元素,代码总是能保证正确。
int numbers[] = {1, 2, 3, 4, 5};
for (int i = 0; i <sizeof(numbers)/sizeof(numbers[0]); ++i)
但这样的写法并不总能得到想要的结果!存在这样一个特列,当numbers[]作为函数参数时:
void Print(int nums[])
{
for (int i =0; i <sizeof(nums)/sizeof(nums[0]); ++i)
...
}
int numbers[] = {1, 2, 3, 4, 5};
Print(numbers);
numbers[]传入到Print()函数后,虽然参数的形式为int nums[],仍然保留了一对方括号,但实际上它已经不是一个数组了。此时它退化为一个指针,sizeof(nums)求得的将是指针类型的长度。没有补救的办法,即便按以下明确指定长度的方式,也于事无补:
void Print(int nums[5]) { ... }
int numbers[5] = {1, 2, 3, 4, 5};
Print(numbers);
在作为函数参数时,数组将退化为指针并按地址传递,同时也丢失了数组的长度信息。要解决这个问题可以有几个方式:1.通过额外参数传入数组长度,2.将数组封装为struct,3.使用已有容器如vector。
八. 不恰当的注释
还记得较早时的公司编码规范,建议注释应占到代码的20%左右。这样的规定不免有点过于教条,对于注释的好坏不应仅仅考察数量,“最好的注释,就是没有注释”,如果程序代码自身已经简单明确地说明了它所做的一切,那么额外的注释就是多此一举。考察代码注释的好坏还有更多的方面,如注释的必要性、有效性、准确性、可读性、一致性。我们提倡代码要有必要的好的注释,而不是滥竽充数的无用注释。
1. 无用的注释
// 把b赋值给a
int a = b;
// 依次循环遍历n次
for (int i = 0; i < n; ++i)
如上的注释显然是多余的。首先要明确代码注释是写给谁来看的,普遍认为应该是写给程序员,通常是你自己或是小组同事。基本常识不要需要用注释来传授,注释应该说明为什么这样做,而不是逐一说明做了什么。
2. 本该删除的代码
项目中偶尔会有被注释掉的成片的代码片段,被发现时已经年代久远,并且未来被恢复使用的概率通常接近于零。以注释来代替删除并不是一个好主意,即便考虑到未来有可能被重新使用,svn等版本控制工具同样可以快速找回。同时代码必须依赖其所处环境,外界调整时很难让注释代码也保持同步,一来它们不被编译而难于发现,二来当前改动者通常并非原有作者。时光流逝,渐渐地,这些注释代码偏离了它的初目的。所以,不要以成片注释的方式来保留代码,这是版本控制应该做的事情。
3. 不匹配的注释
维护注释与维护代码同样重要,注释的目的是帮助人了解你的意图,不匹配的注释比没有注释更加恐怖,如果这个注释恰恰又是接口使用方式的说明,那就更加要命了。
“如果代码和注释未能匹配,那么有可能都是错的。” -- Norm Schryer
请善待你身边的,跟你一起加班加点养活全家老小的同事。
4. 烂代码和好注释
由于各种原因,项目中会逐渐形成一些“烂代码”和“坏味道”,通常是看起来令人迷惑,但又很神奇地能把结果搞对。应对这种情况,洋洋洒洒深入浅出地写下一大段剖析注释,的确是对后来人帮助不小。但更好的办法是重构这段代码,使其达到不用或是尽可能少注释的程度。如果可以,优先用你的代码去消灭注释,然后才是用必要的注释来支撑代码。对于新写的代码同样适用,优先通过准确良好的命名,遵循标准的习惯,清晰的控制流程,接近自然语言的叙事过程等方式,让代码做到充分的自说明。之后才是考虑辅以必要的注释来支撑。
本节把一些不好的注释拿出来痛批一顿,但我们的目的不是否定和拒绝注释,而是提倡在代码容易理解和维护的前提下,尽可能少的写注释,写准确一致的注释。
九. 最后
限于时间和精力,未能分享更多的小节和示例。本文也刻意避免了一些过于常见或过于冷门的问题。本文所述均摘自于笔者以往的学习整理和实践总结,所示例子也在近期经过再次验证。如有错误之处,还请帮忙指正。也希望本文能够对学习和应用C++的程序员们有所帮助。