C++是一种复杂的编程语言,其中充满了各种微妙的陷阱。在 C++ 中几乎有数不清的方式能把事情搞砸。幸运的是,如今的编译器已经足够智能化了,能够检测出相当多的这类编程陷阱并通过编译错误或编译警告来通知程序员。最 终,如果处理得当的话,任何编译器能检查到的错误都不会是什么大问题,因为它们在编译时会被捕捉到,并在程序真正运行前得到解决。最坏的情况下,一个编译 器能够捕获到的错误只会造成程序员一些时间上的损失,因为他们会寻找解决编译错误的方法并修正。
那些编译器无法捕获到的错误才是最危险的。这类错误不太容易察觉到,但可能会导致严重的后果,比如不正确的输出、数据被破坏以及程序崩溃。随着 项目的膨胀,代码逻辑的复杂度以及众多的执行路径会掩盖住这些 bug,导致这些 bug 只是间歇性的出现,因此使得这类 bug 难以跟踪和调试。尽管本文的这份列表对于有经验的程序员来说大部分都只是回顾,但这类 bug 产生的后果往往根据项目的规模和商业性质有不同程度的增强效果。
这些示例全部都在 Visual Studio 2005 Express 上测试过,使用的是默认告警级别。根据你选择的编译器,你得到的结果可能会有所不同。我强烈建议所有的程序员朋友都采用最高等级的告警级别!有一些编译提示在默认告警级别下可能不会被标注为一个潜在的问题,而在最高等级的告警级别下就会被捕捉到!(注:本文是这个系列文章的第 1 部分)
1)变量未初始化
变量未初始化是 C++ 编程中最为常见和易犯的错误之一。在 C++ 中,为变量所分配的内存空间并不是完全“干净的”,也不会在分配空间时自动做清零处理。其结果就是,一个未初始化的变量将包含某个值,但没办法准确地知道 这个值是多少。此外,每次执行这个程序的时候,该变量的值可能都会发生改变。这就有可能产生间歇性发作的问题,是特别难以追踪的。看看如下的代码片段:
if (bValue) // do A else // do B
如果 bValue 是未经初始化的变量,那么 if 语句的判断结果就无法确定,两个分支都可能会执行。在一般情况下,编译器会对未初始化的变量给予提示。下面的代码片段在大多数编译器上都会引发一个警告信息。
int foo () { int nX; return nX; }
但是,还有一些简单的例子则不会产生警告:
void increment (int &nValue) { ++nValue; } int foo () { int nX; increment (nX); return nX; }
以上的代码片段可能不会产生一个警告,因为编译器一般不会去跟踪查看函数 increment ()到底有没有对 nValue 赋值。
未初始化变量更常出现于类中,成员的初始化一般是通过构造函数的实现来完成的。
class Foo { private: int m_nValue; public: Foo (); int GetValue () { return m_bValue; } }; Foo::Foo () { // Oops, 我们忘记初始化m_nValue 了 } int main () { Foo cFoo; if (cFoo.GetValue () > 0) // do something else // do something else }
注意,m_nValue 从未初始化过。结果就是,GetValue ()返回的是一个垃圾值,if 语句的两个分支都有可能会执行。
新手程序员通常在定义多个变量时会犯下面这种错误:
int nValue1, nValue2 = 5;
这里的本意是 nValue1 和 nValue2 都被初始化为5,但实际上只有 nValue2 被初始化了,nValue1从未被初始化过。
由于未初始化的变量可能是任何值,因此会导致程序每次执行时呈现出不同的行为,由未初始化变量而引发的问题是很难找到问题根源的。某次执行时, 程序可能工作正常,下一次再执行时,它可能会崩溃,而再下一次则可能产生错误的输出。当你在调试器下运行程序时,定义的变量通常都被清零处理过了。这意味 着你的程序在调试器下可能每次都是工作正常的,但在发布版中可能会间歇性的崩掉!如果你碰上了这种怪事,罪魁祸首常常都是未初始化的变量。
2)整数除法
C++中的大多数二元操作都要求两个操作数是同一类型。如果操作数的不同类型,其中一个操作数会提升到和另一个操作数相匹配的类型。在 C++ 中,除法操作符可以被看做是 2 个不同的操作:其中一个操作于整数之上,另一个是操作于浮点数之上。如果操作数是浮点数类型,除法操作将返回一个浮点数的值:
float fX = 7; float fY = 2; float fValue = fX / fY; // fValue = 3.5
如果操作数是整数类型,除法操作将丢弃任何小数部分,并只返回整数部分。
int nX = 7; int nY = 2; int nValue = nX / nY; // nValue = 3
如果一个操作数是整型,另一个操作数是浮点型,则整型会提升为浮点型:
float fX = 7. 0; int nY = 2; float fValue = fX / nY; // nY 提升为浮点型,除法操作将返回浮点型值 // fValue = 3.5
有很多新手程序员会尝试写下如下的代码:
int nX = 7; int nY = 2; float fValue = nX / nY; // fValue = 3(不是3.5哦!)
这里的本意是 nX/nY 将产生一个浮点型的除法操作,因为结果是赋给一个浮点型变量的。但实际上并非如此。nX/nY 首先被计算,结果是一个整型值,然后才会提升为浮点型并赋值给 fValue。但在赋值之前,小数部分就已经丢弃了。
要强制两个整数采用浮点型除法,其中一个操作数需要类型转换为浮点数:
int nX = 7; int nY = 2; float fValue = static_cast<float>(nX) / nY; // fValue = 3.5
因为 nX 显式的转换为 float 型,nY 将隐式地提升为 float 型,因此除法操作符将执行浮点型除法,得到的结果就是3.5。
通常一眼看去很难说一个除法操作符究竟是执行整数除法还是浮点型除法:
z = x / y; // 这是整数除法还是浮点型除法?
但采用匈牙利命名法可以帮助我们消除这种疑惑,并阻止错误的发生:
int nZ = nX / nY; // 整数除法 double dZ = dX / dY; // 浮点型除法
有关整数除法的另一个有趣的事情是,当一个操作数是负数时 C++ 标准并未规定如何截断结果。造成的结果就是,编译器可以自由地选择向上截断或者向下截断!比如,-5/2可以既可以计算为-3也可以计算为-2,这和编译 器是向下取整还是向 0 取整有关。大多数现代的编译器是向 0 取整的。
3)= vs ==
这是个老问题,但很有价值。许多 C++ 新手会弄混赋值操作符(=)和相等操作符(==)的意义。但即使是知道这两种操作符差别的程序员也会犯下键盘敲击错误,这可能会导致结果是非预期的。
// 如果 nValue 是0,返回1,否则返回 nValue int foo (int nValue) { if (nValue = 0) // 这是个键盘敲击错误 ! return 1; else return nValue; } int main () { std::cout << foo (0) << std::endl; std::cout << foo (1) << std::endl; std::cout << foo (2) << std::endl; return 0; }
函数 foo ()的本意是如果 nValue 是0,就返回1,否则就返回 nValue 的值。但由于无意中使用赋值操作符代替了相等操作符,程序将产生非预期性的结果:
0 0 0
当 foo ()中的 if 语句执行时,nValue 被赋值为0。if (nValue = 0)实际上就成了 if (nValue)。结果就是 if 条件为假,导致执行 else 下的代码,返回 nValue 的值,而这个值刚好就是赋值给 nValue 的0!因此这个函数将永远返回0。
在编译器中将告警级别设置为最高,当发现条件语句中使用了赋值操作符时会给出一个警告信息,或者在条件判断之外,应该使用赋值操作符的地方误用 成了相等性测试,此时会提示该语句没有做任何事情。只要你使用了较高的告警级别,这个问题本质上都是可修复的。也有一些程序员喜欢采用一种技巧来避免= 和==的混淆。即,在条件判断中将常量写在左边,此时如果误把==写成=的话,将引发一个编译错误,因为常量不能被赋值。
4)混用有符号和无符号数
如同我们在整数除法那一节中提到的,C++中大多数的二元操作符需要两端的操作数是同一种类型。如果操作数是不同的类型,其中一个操作数将提升自己的类型以匹配另一个操作数。当混用有符号和无符号数时这会导致出现一些非预期性的结果!考虑如下的例子:
cout << 10 – 15u; // 15u 是无符号整数
有人会说结果是-5。由于 10 是一个有符号整数,而 15 是无符号整数,类型提升规则在这里就需要起作用了。C++中的类型提升层次结构看起来是这样的:
long double (最高) double float unsigned long int long int unsigned int int (最低)
因为 int 类型比 unsigned int 要低,因此 int 要提升为 unsigned int。幸运的是,10已经是个正整数了,因此类型提升并没有使解释这个值的方式发生改变。因此,上面的代码相当于:
cout << 10u – 15u;
好,现在是该看看这个小把戏的时候了。因为都是无符号整型,因此操作的结果也应该是一个无符号整型的变量!10u-15u = -5u。但是无符号变量不包括负数,因此-5这里将被解释为4,294,967,291(假设是 32 位整数)。因此,上面的代码将打印出4,294,967,291而不是-5。
这种情况可以有更令人迷惑的形式:
int nX; unsigned int nY; if (nX – nY < 0) // do something
由于类型转换,这个 if 语句将永远判断为假,这显然不是程序员的原始意图!
5) delete vs delete []
许多 C++ 程序员忘记了关于 new 和 delete 操作符实际上有两种形式:针对单个对象的版本,以及针对对象数组的版本。new 操作符用来在堆上分配单个对象的内存空间。如果对象是某个类类型,该对象的构造函数将被调用。
Foo *pScalar = new Foo;
delete 操作符用来回收由 new 操作符分配的内存空间。如果被销毁的对象是类类型,则该对象的析构函数将被调用。
delete pScalar;
现在考虑如下的代码片段:
Foo *pArray = new Foo[10];
这行代码为 10 个 Foo 对象的数组分配了内存空间,因为下标[10]放在了类型名之后,许多 C++ 程序员没有意识到实际上是操作符 new[]被调用来完成分配空间的任务而不是 new。new[]操作符确保每一个创建的对象都会调用该类的构造函数一次。相反的,要删除一个数组,需要使用 delete[]操作符:
delete[] pArray;
这将确保数组中的每个对象都会调用该类的析构函数。如果 delete 操作符作用于一个数组会发生什么?数组中仅仅只有第一个对象会被析构,因此会导致堆空间被破坏!
6) 复合表达式或函数调用的副作用
副作用是指一个操作符、表达式、语句或函数在该操作符、表达式、语句或函数完成规定的操作后仍然继续做了某些事情。副作用有时候是有用的:
x=5
赋值操作符的副作用是可以永久地改变x的值。其他有副作用的 C++ 操作符包括*=、/=、%=、+=、-=、<<=、>>=、&=、=、^=以及声名狼藉的++和—操作符。但 是,在 C++ 中有好几个地方操作的顺序是未定义的,那么这就会造成不一致的行为。比如:
void multiply (int x, int y) { using namespace std; cout << x * y << endl; } int main () { int x = 5; std::cout << multiply (x, ++x); }
因为对于函数 multiply ()的参数的计算顺序是未定义的,因此上面的程序可能打印出 30 或 36,这完全取决于x和++x谁先计算,谁后计算。
另一个稍显奇怪的有关操作符的例子:
int foo (int x) { return x; } int main () { int x = 5; std::cout << foo (x) * foo (++x); }
因为 C++ 的操作符中,其操作数的计算顺序是未定义的(对于大多数操作符来说是这样的,当然有一些例外),上面的例子也可能会打印出 30 或 36,这取决于究竟是左操作数先计算还是右操作数先计算。
另外,考虑如下的复合表达式:
if (x == 1 && ++y == 2) // do something
程序员的本意可能是说:“如果x是1,且y的前自增值是 2 的话,完成某些处理”。但是,如果x不等于1,C++将采取短路求值法则,这意味着++y将永远不会计算!因此,只有当x等于 1 时,y才会自增。这很可能不是程序员的本意!一个好的经验法则是把任何可能造成副作用的操作符都放到它们自己独立的语句中去。
7)不带break的switch语句
另一个新手程序员常犯的经典错误是忘记在 switch 语句块中加上 break:
switch (nValue) { case 1: eColor = Color::BLUE; case 2: eColor = Color::PURPLE; case 3: eColor = Color::GREEN; default: eColor = Color::RED; }
当 switch 表达式计算出的结果同 case 的标签值相同时,执行序列将从满足的第一个 case 语句处执行。执行序列将继续下去,直到要么到达 switch 语句块的末尾,或者遇到 return、goto 或 break 语句。其他的标签都将忽略掉!
考虑下如上的代码,如果 nValue 为 1 时会发生什么。case 1 满足,所以 eColor 被设为 Color::BLUE。继续处理下一个语句,这又将 eColor 设为 Color::PURPLE。下一个语句又将它设为了 Color::GREEN。最终,在 default 中将其设为了 Color::RED。实际上,不管 nValue 的值是多少,上述代码片段都将把 eColor 设为 Color::RED!
正确的方法是按照如下方式书写:
switch (nValue) { case 1: eColor = Color::BLUE; break; case 2: eColor = Color::PURPLE; break; case 3: eColor = Color::GREEN; break; default: eColor = Color::RED; break; }
break 语句终止了 case 语句的执行,因此 eColor 的值将保持为程序员所期望的那样。尽管这是非常基础的 switch/case 逻辑,但很容易因为漏掉一个 break 语句而造成不可避免的“瀑布式”执行流。
8)在构造函数中调用虚函数
考虑如下的程序:
class Base { private: int m_nID; public: Base () { m_nID = ClassID (); } // ClassID 返回一个 class 相关的 ID 号 virtual int ClassID () { return 1;} int GetID () { return m_nID; } }; class Derived: public Base { public: Derived () { } virtual int ClassID () { return 2;} }; int main () { Derived cDerived; cout << cDerived.GetID (); // 打印出1,不是2! return 0; }
在这个程序中,程序员在基类的构造函数中调用了虚函数,期望它能被决议为派生类的 Derived::ClassID ()。但实际上不会这样——程序的结果是打印出 1 而不是2。当从基类继承的派生类被实例化时,基类对象先于派生类对象被构造出来。这么做是因为派生类的成员可能会对已经初始化过的基类成员有依赖关系。结 果就是当基类的构造函数被执行时,此时派生类对象根本就还没有构造出来!所以,此时任何对虚函数的调用都只会决议为基类的成员函数,而不是派生类。
根据这个例子,当 cDerived 的基类部分被构造时,其派生类的那一部分还不存在。因此,对函数 ClassID 的调用将决议为 Base::ClassID ()(不是 Derived::ClassID ()),这个函数将m_nID 设为1。一旦 cDerived 的派生类部分也构造好时,在 cDerived 这个对象上,任何对 ClassID ()的调用都将如预期的那样决议为 Derived::ClassID ()。
注意到其他的编程语言如C#和Java会将虚函数调用决议为继承层次最深的那个 class 上,就算派生类还没有被初始化也是这样!C++的做法与这不同,这是为了程序员的安全而考虑的。这并不是说一种方式就一定好过另一种,这里仅仅是为了表示 不同的编程语言在同一问题上可能有不同的表现行为。
结论
因为这只是这个系列文章的第一篇,我认为以新手程序员可能遇到的基础问题入手会比较合适。今后这个系列的文章将致力于解决更加复杂的编程错误。 无论一个程序员的经验水平如何,错误都是不可避免的,不管是因为知识上的匮乏、输入错误或者只是一般的粗心大意。意识到其中最有可能造成麻烦的问题,这可 以帮助减少它们出来捣乱的可能性。虽然对于经验和知识并没有什么替代品,良好的单元测试可以帮我们在将这些 bug 深埋于我们的代码中之前将它们捕获。