C++编译器无法捕捉到的8种错误实例分析

本文实例分析了C++编译器无法捕捉到的8种错误,分享给大家供大家参考之用。有助于深入理解C++运行原理,具体分析如下:

众所周知,C++是一种复杂的编程语言,其中充满了各种微妙的陷阱。在C++中几乎有数不清的方式能把事情搞砸。幸运的是,如今的编译器已经足够智能化了,能够检测出相当多的这类编程陷阱并通过编译错误或编译警告来通知程序员。最终,如果处理得当的话,任何编译器能检查到的错误都不会是什么大问题,因为它们在编译时会被捕捉到,并在程序真正运行前得到解决。最坏的情况下,一个编译器能够捕获到的错误只会造成程序员一些时间上的损失,因为他们会寻找解决编译错误的方法并修正。

那些编译器无法捕获到的错误才是最危险的。这类错误不太容易察觉到,但可能会导致严重的后果,比如不正确的输出、数据被破坏以及程序崩溃。随着项目的膨胀,代码逻辑的复杂度以及众多的执行路径会掩盖住这些bug,导致这些bug只是间歇性的出现,因此使得这类bug难以跟踪和调试。尽管本文的这份列表对于有经验的程序员来说大部分都只是回顾,但这类bug产生的后果往往根据项目的规模和商业性质有不同程度的增强效果。

这些示例全部都在Visual Studio 2005 Express上测试过,使用的是默认告警级别。根据你选择的编译器,你得到的结果可能会有所不同。我强烈建议所有的程序员朋友都采用最高等级的告警级别!有一些编译提示在默认告警级别下可能不会被标注为一个潜在的问题,而在最高等级的告警级别下就会被捕捉到!

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深埋于我们的代码中之前将它们捕获。

相信本文所述对大家的C++程序设计有一定的学习借鉴价值。

(0)

相关推荐

  • Java虚拟机JVM性能优化(二):编译器

    本文将是JVM 性能优化系列的第二篇文章(第一篇:传送门),Java 编译器将是本文讨论的核心内容. 本文中,作者(Eva Andreasson)首先介绍了不同种类的编译器,并对客户端编译,服务器端编译器和多层编译的运行性能进行了对比.然后,在文章的最后介绍了几种常见的JVM优化方法,如死代码消除,代码嵌入以及循环体优化. Java最引以为豪的特性"平台独立性"正是源于Java编译器.软件开发人员尽其所能写出最好的java应用程序,紧接着后台运行的编译器产生高效的基于目标平台的可执行代

  • Linux上安装GCC编译器过程

    2004年4月20日最新版本的GCC编译器3.4.0发布了.目前,GCC可以用来编译C/C++.FORTRAN.JAVA.OBJC.ADA等语言的程序,可根据需要选择安装支持的语言.GCC 3.4.0比以前版本更好地支持了C++标准.本文以在Redhat Linux上安装GCC3.4.0为例,介绍了GCC的安装过程. 安装之前,系统中必须要有cc或者gcc等编译器,并且是可用的,或者用环境变量CC指定系统上的编译器.如果系统上没有编译器,不能安装源代码形式的GCC 3.4.0.如果是这种情况,可

  • C/C++编译器GCC下的常用编译命令总结

    简介 GCC的意思是GUN C Compiler.经过了多年的发展,GCC已经不仅仅能支持C语言了,它现在还支持Ada语言.C++语言.Java语言.Objective C语言.Pascal语言.COBOL语言,以及支持函数式编程和逻辑编程的Mercury语言等等.而GCC也不再单只是GUN C语言编译器的意思了,而是变成了GUN Complier Collection也即是GUN编译器家族的意思了.另一方面,说到GCC对于操作系统平台以及硬件平台支持,概括起来就一句话:无所不在. 编译 示例程

  • 详解编译器编译原理

    详解编译器编译原理 什么是gcc  什么是gcc:gcc是GNU Compiler Collection的缩写.最初是作为C语言的编译器(GNU C Compiler),现在已经支持多种语言了,如C.C++.Java.Pascal.Ada.COBOL语言等. gcc支持多种硬件平台,甚至对Don Knuth 设计的 MMIX 这类不常见的计算机都提供了完善的支持 gcc主要特征  1)gcc是一个可移植的编译器,支持多种硬件平台 2)gcc不仅仅是个本地编译器,它还能跨平台交叉编译. 3)gcc

  • 详解 linux c++的编译器g++的基本使用

    linux c++的编译器g++基本使用 g++是 linux下c++的编译器,在执行编译工作的时候,总共需要4步 1.预处理,生成.i的文件 2.将预处理后的文件不转换成汇编语言,生成文件.s 3.有汇编变为目标代码(机器代码)生成.o的文件 4.连接目标代码,生成可执行程序 g++ 编译c++经常使用的参数: -c 只编译,不连接.例如: g++ -c helloworld.cpp 只生成helloworld.o不连接 -o 指定输出文件名.例如:g++ -c helloworld.cpp

  • C++编译器无法捕捉到的8种错误实例分析

    本文实例分析了C++编译器无法捕捉到的8种错误,分享给大家供大家参考之用.有助于深入理解C++运行原理,具体分析如下: 众所周知,C++是一种复杂的编程语言,其中充满了各种微妙的陷阱.在C++中几乎有数不清的方式能把事情搞砸.幸运的是,如今的编译器已经足够智能化了,能够检测出相当多的这类编程陷阱并通过编译错误或编译警告来通知程序员.最终,如果处理得当的话,任何编译器能检查到的错误都不会是什么大问题,因为它们在编译时会被捕捉到,并在程序真正运行前得到解决.最坏的情况下,一个编译器能够捕获到的错误只

  • C#实现向多线程传参的三种方式实例分析

    本文实例讲述了C#实现向多线程传参的三种方式.分享给大家供大家参考,具体如下: 从<C#高级编程>了解到给线程传递参数有两种方式,一种方式是使用带ParameterizedThreadStart委托参数的Thread构造函数,另一种方式是创建一个自定义类,把线程的方法定义为实例的方法,这样就可以初始化实例的数据,之后启动线程. 方式一:使用ParameterizedThreadStart委托 如果使用了ParameterizedThreadStart委托,线程的入口必须有一个object类型的

  • AngularJS中指令的四种基本形式实例分析

    本文实例讲述了AngularJS中指令的四种基本形式.分享给大家供大家参考,具体如下: 指令的四种基本形式中, 注意注释型指令 M 的使用方法是 <!--  directive:指令名称  --> 注意左右俩测必须有空格才会正常识别 所有指令是可以相互组合 的,不写restrict ,将会默认为A属性 指令要支持IE8 浏览器 一般最好将指令设置为属性 <!doctype html> <html ng-app="myapp"> <head>

  • C#获取路径的几种方式实例分析

    本文实例讲述了C#获取路径的几种方式.分享给大家供大家参考.具体如下: string str1 =Process.GetCurrentProcess().MainModule.FileName;//可获得当前执行的exe的文件名. string str2=Environment.CurrentDirectory;//获取和设置当前目录(即该进程从中启动的目录)的完全限定路径. //备注按照定义,如果该进程在本地或网络驱动器的根目录中启动,则此属性的值为驱动器名称后跟一个尾部反斜杠(如"C:\&q

  • Python实现运行其他程序的四种方式实例分析

    本文实例讲述了Python实现运行其他程序的四种方式.分享给大家供大家参考,具体如下: 在Python中,可以方便地使用os模块来运行其他脚本或者程序,这样就可以在脚本中直接使用其他脚本或程序提供的功能,而不必再次编写实现该功能的代码.为了更好地控制运行的进程,可以使用win32process模块中的函数,如果想进一步控制进程,则可以使用ctype模块,直接调用kernel32.dll中的函数. [方式一]使用os.system()函数运行其他程序 os模块中的system()函数可以方便地运行

  • 动态加载jQuery的两种方法实例分析

    本文实例讲述了动态加载jQuery的两种方法.分享给大家供大家参考.具体如下: 第一种方法参考本站之前有人发的代码,增加了加载检测: 第二种方法来自去年的12306刷票脚本. 第一种方法: function withjQuery(callback) { if(!(window.jQuery)) { var js = document.createElement('script'); js.setAttribute('src', 'https://dynamic.12306.cn/otsweb/j

  • Python中逗号的三种作用实例分析

    本文实例讲述了Python中逗号的三种作用.分享给大家供大家参考.具体分析如下: 最近研究python  遇到个逗号的问题 一直没弄明白 今天总算搞清楚了 1.逗号在参数传递中的使用: 这种情况不多说  没有什么不解的地方 就是形参或者实参传递的时候参数之间的逗号 例如def  abc(a,b)或者abc(1,2) 2.逗号在类型转化中的使用 主要是元组的转换 例如: >>> a=11 >>> b=(a) >>> b 11 >>> b

  • javascript实现html页面之间参数传递的四种方法实例分析

    本文实例讲述了javascript实现html页面之间参数传递的四种方法.分享给大家供大家参考,具体如下: 我们知道,在服务器端asp,jsp等程序可以接受html页面上的form传来的参数.那么,可不可以传递参数给html页面呢.可以. 原理:通过window.location.href中的分割符获得各个参数 方法一: /* *函数功能:从href获得参数 *sHref: http://www.cscenter.com.cn/arg.htm?arg1=d&arg2=re *sArgName:a

  • Android三种菜单实例分析

    本文实例讲述了Android的三种菜单.分享给大家供大家参考.具体分析如下: Android的菜单分为三种类型:选项菜单(Option Menu).上下文菜单(Context Menu).子菜单(Sub Menu) 一.选项菜单 当用户单击设备上的菜单按钮(Menu),触发事件弹出的菜单就是选项菜单.选项菜单最多只有六个,超过六个第六个就会自动显示 更多 选项来展示显示. 创建方法: 1.覆盖Activity的onCreateOptionsMenu(Menu menu)方法,当我们第一次打开菜单

  • Java中线程死亡的几种情况实例分析

    Java线程会议如下三种方式结束,结束后就处于死亡状态 1.run()或者call()方法执行完成,线程正常结束: 2.线程抛出一个未捕获的Exception或Error: 3.直接调用该线程的stop()方法来结束该线程: 注意:当主线程结束时,其他线程不受任何影响,并不会随之结束.一旦子线程启动起来后,它就拥有和主线程相同的地位,不会受到主线程结束的影响. 为了测试某个线程是否已经死亡,可以调用线程对象的isAlive()方法,当线程处于就绪.运行.阻塞三种状态时,该方法将返回true:当线

随机推荐