C++是一种复杂的语言,对不谨慎的开发者来说充满了微妙的陷阱。搞砸事情的方式几乎是无限的。幸运的是,现代编译器非常擅长检测大量这些情况,并通过编译错误或警告通知程序员。最终,任何可由编译器检测到的错误如果处理得当,都不会成为问题,因为它会在程序离开开发阶段之前被捕获并修复。最坏的情况是,可由编译器检测到的错误会导致程序员在寻找解决方案或变通方法时浪费时间。
危险的错误是编译器无法检测到的错误。这些错误更不容易被注意到,并且可能导致严重的后果,例如不正确的输出、数据损坏和/或程序崩溃。随着编程项目规模的增加,逻辑的复杂性和大量的执行路径可能会帮助掩盖这些错误,导致它们只间歇性出现,从而特别难以追踪和调试。尽管这份列表对于经验丰富的程序员来说可能大部分是复习,但由于经验丰富的程序员倾向于处理的项目规模和商业性质,犯这些错误中的任何一个所带来的后果也会被放大。
这些示例均使用Visual Studio 2005 Express,并采用默认警告级别进行测试。您的结果在其他编译器上可能会有所不同。我强烈建议所有程序员使用尽可能高的警告级别!。在默认警告级别可能不会被标记为潜在问题的一些项目,在最高警告级别可能会被捕获!
(注:本文是本系列文章的第一部分)
1) 未初始化的变量
未初始化的变量是C++中常见的最狡猾的错误之一。C++中分配给变量的内存不会在分配时被清除或清零。因此,未初始化的变量将具有某个值,但无法预测该值实际会是什么。此外,变量的值可能在每次程序执行时发生变化。这可能导致间歇性问题,这些问题特别难以追踪。请考虑以下代码片段:
if (bValue) // do A else // do B
如果 bValue 未初始化,它可能评估为 true 或 false,并且两个分支都可能被执行。
在某些基本情况下,编译器能够告知您未初始化的变量。以下情况会在大多数编译器上产生编译器警告:
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_nValue; } }; Foo::Foo() { // Oops, we forget to initialize m_nValue } int main() { Foo cFoo; if (cFoo.GetValue() > 0) // do something else // do something else }
请注意,m_nValue 从未被初始化。因此,GetValue() 返回一个垃圾值,并且任何一个分支都可能被执行。
新程序员在声明多个变量时经常犯以下错误:
int nValue1, nValue2 = 5;
这里的假设是 5 被同时赋值给 nValue1 和 nValue2,而事实上,值 5 只被赋值给 nValue2,nValue1 未初始化。
由于未初始化的变量可以评估为任何值,这可能导致程序每次运行时表现出不同的行为,因此由未初始化变量引起的问题特别难以发现。一次运行,程序可能运行良好。下一次,它可能崩溃。再下一次,它可能产生错误的输出。
为了使查找未初始化变量的问题更加复杂,在调试器中运行程序时声明的变量通常会被清零。这意味着您的程序在调试器中运行时每次都可能正常工作,但在发布模式下却间歇性崩溃!如果出现这种情况,未初始化的变量通常是您问题的根源。
2) 整数除法
C++中的大多数二元运算符要求两个操作数具有相同的类型。如果操作数类型不同,则其中一个操作数会被提升以匹配另一个操作数的类型。
在C++中,除法运算符可以被认为是两个不同的运算符:一个作用于整数操作数,另一个作用于浮点操作数。如果操作数是浮点类型,除法运算符将返回一个浮点值:
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 is promoted to float, floating point division used // fValue = 3.5
许多新程序员尝试执行以下操作:
int nX = 7; int nY = 2; float fValue = nX / nY; // fValue = 3 (not 3.5!)
这里潜在的假设是 nX / nY 将导致浮点除法,因为结果被赋值给一个浮点值。然而,事实并非如此。nX / nY 会首先被求值,得到一个整数值,然后该整数值被提升为浮点数并赋值给 fValue。然而,到那时,小数部分已经丢失了。
为了强制两个整数进行浮点除法,其中一个值应该被强制转换为浮点值:
int nX = 7; int nY = 2; float fValue = static_cast<float>(nX) / nY; // fValue = 3.5
由于 nX 被显式转换为浮点数,nY 将被隐式提升为浮点数,这将导致除法运算符执行浮点除法,结果为 3.5。
通常很难一眼看出某个除法操作是执行整数除法还是浮点除法:
z = x / y; // is this integer or floating point division?
然而,使用匈牙利命名法可以帮助消除歧义并防止错误:
int nZ = nX / nY; // integer division double dZ = dX / dY; // floating point division
整数除法的另一个有趣问题是,C++并未定义当一个操作数为负数时如何截断结果。因此,编译器可以自由地向上或向下截断!例如,-5 / 2 可以求值为 -3 或 -2,具体取决于编译器是向下取整还是向 0 取整。大多数现代编译器都向 0 取整。
3) = 与 ==
这是一个老生常谈但很经典的问题。许多C++初学者混淆了赋值运算符(=)和相等运算符(==)的含义。但即使是知道区别的程序员也可能因为打字错误而导致意想不到的结果:
// if nValue is 0, return 1, otherwise return nValue int foo(int nValue) { if (nValue = 0) // TYPO! 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)
的求值方式与 nValue = 0; if (nValue)
的求值方式相同。因此,if 条件为 false,导致 else 语句返回 nValue,而 nValue 刚刚被赋值为 0!
因此,这个函数总是返回0。
以最高警告级别运行现代编译器时,当在条件语句中使用赋值时,它会发出警告;或者当在条件语句之外使用相等测试而不是赋值时,它会发出一个语句不起作用的提示。这是一个基本可修复的问题——如果你使用更高的警告级别。
4) 混合有符号和无符号值
正如在整数除法部分提到的,C++中的大多数二元运算符要求两个操作数类型相同。如果操作数类型不同,其中一个操作数会提升以匹配另一个操作数的类型。
这在混合有符号和无符号值时可能导致一些非常意想不到的结果!考虑以下情况:
cout << 10 - 15u; // 15u is unsigned
人们会期望答案是 -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 位整数)。
因此,以下程序:
cout << 10 - 15u; // 15u is unsigned
打印 4,294,967,291,而不是 -5。
这种情况可能以更模糊的形式出现:
int nX; unsigned int nY; if (nX - nY < 0) // do something
由于类型转换,这个 if 语句将始终评估为 false,这显然不是程序员的意图!
5) delete vs. delete[]
许多 C++ 程序员忘记了 new 和 delete 运算符实际上都有两种形式:标量版本和数组版本。
运算符 new 用于在堆上分配标量(非数组)数据。如果被分配的对象是类类型,则会调用该对象的构造函数。
Foo *pScalar = new Foo;
delete 运算符用于销毁已使用 new 运算符分配的标量对象。如果被销毁的对象是类类型,则会调用该对象的析构函数。
delete pScalar;
现在考虑以下代码片段:
Foo *pArray = new Foo[10];
此代码片段分配了一个包含 10 个 Foo 的数组。由于下标 [10] 放置在 int 类型说明符之后,许多 C++ 程序员没有意识到正在调用 operator new[] 来进行数组分配,而不是 operator new。operator new[] 确保为正在构造的每个对象调用构造函数。
相反,要删除一个数组,应该使用 delete[] 运算符:
delete[] pArray;
这确保了数组中每个对象的析构函数都被调用。
如果对数组使用 delete 运算符,则只有第一个对象会被析构,并可能导致堆损坏!
6) 复合表达式或函数调用中的副作用
副作用是运算符、表达式、语句或函数的结果,即使在运算符、表达式、语句或函数完成评估后仍然存在。
副作用通常很有用:
x = 5;
赋值运算符具有永久改变 x 值的副作用。其他具有有用副作用的 C++ 运算符包括 *=, /=, %=, +=, -=, <<=, >>=, &=, |=, ^=,以及臭名昭著的 ++ 和 -- 运算符。
然而,C++中有几个地方的操作顺序是未定义的,这可能导致不一致的行为。例如:
int multiply(int x, int y) { return x * y; } 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 从未被求值!因此,y 只会在 x 评估为 1 时才递增,这可能不是程序员的意图!
一个好的经验法则是将任何导致副作用的运算符放在它自己的语句中。
7) 没有break的switch语句
新程序员常犯的另一个经典错误是忘记使用 break 来结束 switch 块:
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。最后,它被设置为 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 returns a class-specific ID number 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(); // prints 1, not 2! return 0; }
在这个程序中,程序员在基类的构造函数中调用了一个虚函数,期望它能解析到 Derived::ClassID()。但它没有——因此,程序打印 1 而不是 2。
当一个派生自基类的类被实例化时,基类对象会在派生类对象之前构造。这样做是因为派生类成员可能依赖于基类中已经初始化的成员。因此,当基类对象构造函数正在执行时,没有派生对象!它尚未被创建。因此,任何对虚函数的调用只能解析到基类的级别,而不是派生类。
就本例而言,当 cDerived 的 Base 部分正在构造时,Derived 部分尚不存在。因此,对 ClassID() 的函数调用解析为 Base::ClassID()(而不是 Derived::ClassID()),这会将 m_nID 设置为 1。
一旦 cDerived 的 Derived 部分被构造,对该对象的任何 ClassID() 调用都将如预期般解析为 Derived::ClassID()。
请注意,其他一些编程语言(如 C# 和 Java)即使派生类尚未初始化,也会将虚函数调用解析到最派生的类!C++ 在这方面有所不同,这样做是为了程序员的安全。这并不是说哪种方式必然更好,而仅仅是为了说明不同的语言可能有不同的行为。
总结
作为本系列的第一篇文章,我认为从新程序员会遇到的一些更基本的问题开始是很合适的。本系列未来的文章将处理越来越复杂的编程错误。
无论程序员的经验水平如何,错误都会发生,无论是由于知识不足、打字错误还是普遍的粗心。了解哪些问题最有可能引起麻烦,可以帮助降低它们确实引起麻烦的可能性。虽然经验和知识无可替代,但良好的单元测试可以帮助在这些问题被埋在其他代码层之下之前发现它们!
相关文章