关闭

什么是 未定义行为

406人阅读 评论(0) 收藏 举报

让C++变得更加容易:偶尔可以工作的程序

《Dr.Dobb's 软件研发》第5期

Andrew Koenig  Barbara E. Moo

在C++编程中避免未定义行为是至关重要的。本文的两位作者向我们展示了一些无需牢记整个C++标准就可以使用的方法。 

引介 

我们常常会在新闻组里看到这样的C++提问:“下面的语句具有良好定义的行为吗?”或者更为天真(也更为直率)地问:“以下语句并不按预期的那样运行,我的编译器出了啥毛病?” 

有时答案是“给定的语句具有良好定义的行为”,但更多的时候,尤其是在“迫使人们提出这样的问题”的某些情形下,答案是:编译器被允许做它乐意做的事情。不同的编译器行为方式不同,如果程序恰好按照编写者的意愿运行了,那也不过是巧合而已。 

你也许对编程语言无法为人们编写的每一条语句定义唯一的含义感到奇怪。但是,确实有一些用法难以定义或是无法高效地进行定义,至少在某些硬件上如此,同时C++程序员也并非总是愿意为这些定义付出代价。 

举一个例子,当n恰好是最大的整数时,一个计算n+1的程序应该怎么办?很容易指出结果将是某个溢出的整数值,但它到底应该是多少? 

假如我们尝试针对一种“当发生整数溢出时不引发硬件异常”的机器实现C++编译器,结果会如何?如果语言标准为整数溢出的情形规定了一种特定的行为,那么我们的编译器就不得不在每一步加法操作后生成额外的代码来检查是否发生了溢出。经验表明,一些程序员认为这样的负担是难以接受的。 

因此,有些语句的行为在C++语言里并没有进行定义,而是留给各家编译器自行决定它们的行为表现。对于那些希望编写强健的、可移植的代码的程序员而言,了解这些用法尤为重要,否则就可能会编写出这样的程序:表现出了某种特定的行为但仅仅是因为使用的编译器碰巧如此这般而已。除非采用不同的编译器进行测试,否则再多的测试工作也无法揭示此类错误。因此,程序员首先应当避免犯这样的错误。 

本文以下部分将给出一些十分常见的用法例子:看上去能够在一些编译器上工作,但其实行为是未定义的。我们同时还给出了一些普遍适用的规则,你可以在程序中利用它们来避免此类未定义的行为。 

多重存取的副作用 

考虑下面的循环,它将一个拥有n个元素的数组拷贝到另一个数组中: 

int i = 0; 
while (i < n)
{
    y[i] = x[i]; 
    ++i;
}

看到这么一个循环,可能有人试图将其缩短为下面的样子: 

// Incorrect abbreviation 
int i = 0; 
while (i < n) y[i] = x[i++];

如果这样的缩略版本也能工作的话,那仅仅是碰巧而已。问题出在这条语句上: 

y[i] = x[i++]; 

这条语句在一个表达式内同时获取和修改i的值(更准确地说,是在两个序列点之间),语言标准上说,这样的语句的结果是未定义的。 

我们曾看到有人试着这么来解释:问题在于编译器允许以任意的次序对赋值表达式的左右两端进行求值运算,因此i的递增可能发生于对y[i]的获取之前也可能在其之后。尽管这种解释符合逻辑也合乎情理,但实际上它只是很好地解释了为什么要去制定语言规则,而并没有真正地解释语言规则本身。实际情况是, 对于这条“对i有多重访问”的语句来说,C++标准允许编译器爱怎么做就怎么做,包括使程序崩溃、根本就不产生任何结果或者发生任何别的行为。 

编译器并没有责任诊断此类错误,实际上它也不可能做到这一点。考虑下面的语句: 

x[i] = x[j]++; 

只要i不等同于j,这条语句就完全具有良好的定义。但如果i等同于j,那么这条语句就等价于 

x[i] = x[i]++; 

这条语句在一个表达式中同时访问和修改x[i]的值,问题就来了。而另一方面,如果i不等同于j,那么当x的一个元素被访问时被修改的是另外一个元素,那就不存在任何问题。 

这是程序员经常遇到的问题的典型例子之一:一段程序表面上看似正确,但实际上却在某种程度上带有编译器不能或不会侦测到的错误。对程序员来说,侦测此类错误显得尤为重要,因为在这方面指望不上编译器。 

避免此类问题的方法之一是通晓规则。另一种比较简单的方法就是避免编写你感觉有任何疑虑的程序。然而还有一种方法(至少在这个特定的例子中如此),就是让标准库来完成这项工作: 

std::copy(x, x + n, y); 

这个改写版本展示了一种重要的简化程序的方法(同时也减少了程序与未定义行为纠缠不清的几率),即使用别人已经定义好且测试过的工具进行编程。 

当然了,你可能持有反对意见,说使用std::copy要比直接编写循环来得慢。然而,没有任何技术上的理由表明两者存在任何性能上的差异。实际上,在一个优秀的编译器上std::copy甚至会运行得更快一些。因为std::copy是标准库中的一个零件,编译器为了判断对std::copy的调用意图而做的工作要比判断对相应循环代码的调用意图所要作的工作来得少,因而可以比较容易生成优化代码。 

删除数组 

程序中经常出现的另一个未定义行为的例子发生于试图处理长度可变的字符串时: 

char* p = new char[n];
delete p; // should be delete[] p; 

在这方面,语言规则很简单:如果你配置了一个数组,你就必须使用delete []来释放它,其他情况下则不可包含[]。正如上面的例子所示,编译器并非总能够在编译期侦测到这个错误。从原理上来说,在运行期是可以检测到此类错误的,但大多数编译器宁愿避免承受这样的负担。 

使得这一例子变得尤其“阴险”的原因在于很多编译器都以相同的方式处理它:如果你使用new分配了一个数组,但在释放时delete后面没跟[],那么将仅仅针对数组的第一个元素调用析构函数。如果数组中的元素是char这样的类型(这样的类型不具备一个“真正做点事情”的析构函数),那么这种行为就显得尤为合宜。实际上,在这样的一个编译器上,这个例子将会运行得很好,从而使程序员很难判断程序中是否存在错误。为此,许多程序员似乎坚信这个特定行为还是有某种必要性的,至少我们从他们在新闻组中发的帖子上可以看出这一点。 

C++标准并没有为这种“信仰”提供任何依据。如果一个程序使用new分配一个数组但使用不带[]的delete来释放它,那么编译器就被允许做任何它乐意做的事情 — 包括崩溃。 

程序员应该如何避免这一灾难呢?最为明显的方法就是根据相应的new来检查每一个delete的用法。然而,正如前面的一个例子所展示的那样,最明显的方法并非总是最好的方法。标准库中提供了诸如string和vector类,可以使很多程序更加容易地处理动态内存,而不必使用低级的new和delete设施。一如既往,避免问题最好的办法就是首先看看是否已经有人替你解决了这些问题。 

虚析构函数 

如果你正在使用new和delete处理个体对象(而非数组),那么可能会出现另外一种未定义行为。例如,考虑下面的这些类: 

class Base { };
class Derived: public Base { }; 

假设我们创建一个Derived类型的对象并将其地址放入一个指向Base类型的指针中: 

Base* bp = new Derived; 

那么稍后我们就可以利用该指针来释放对象: 

delete bp; 

首先要注意的是,在这个例子中没有使用[]是正确的,因为我们并非在处理一个数组。然而,这儿存在另外一个问题:指针bp指向的对象的类型和创建时的类型不一样,那么通过它来delete对象安全吗? 

关于这一点语言标准说得很清楚:如果Base具有一个虚拟析构函数,那么这样的delete的行为就具有良好的定义,否则就不然。但是,我们听过有人声称,只要类Derived不存在需要析构的成员,这样的delete就是可以接受的。这个虽不正确但似乎可信的理由在于,编译器将利用指针的类型(此处是Base)来决定删除什么样的成员,而这种决策在Derived没有额外的成员时是无害的。 

关于这个理由为什么不正确的最直接的论据就是它跟语言标准说的不一样,但这么说难以令人满意,因为这并没有解释清楚语言标准为什么要那么做。 

如果我们为Derived添加一个需要析构的成员然后再看看发生了什么,我们就会找到一个更加令人满意的论据: 

// revised version
class Derived: public Base { string s; }; 

现在删除bp时编译器应该怎么做呢?如果要求每一个编译器去检查bp是否指向一个Derived对象,那将会强求每一个类都要有一个虚析构函数,即使程序的作者很清楚每一个delete都与相对应的new一致也是如此。预先设置虚析构函数的出发点在于将以后某些情境下的需求提前准备好。因此当删除bp时,不应该要求编译器去清除成员s。 

如果编译器不去释放成员s,那么当bp指向Derived对象时删除bp就存在内存泄漏的可能性,因此程序员不可以这么做,那这个错误应该由编译器来检查吗?这同样是一个关于负担的权衡问题:如果要求编译器去检查bp确实是指向一个Base对象,那么编译器也同样能处理“bp指向一个Derived对象”的情况。因此,要求编译器检查此类错误与要求编译器去释放s相比,只会更加低效、更没意义。 

一种替代的方法就是要求每个编译器器不要去检查bp所指的对象类型。但是在这种情形下,当bp指向一个Derived对象时删除bp就会发生错误,而该错误又是编译器被禁止去侦测的,即使它原本想去侦测也侦测不了。 

因此,对于“在基类没有虚析构函数时通过基类指针来删除派生类对象的程序究竟该怎么办”这一问题,语言标准将其交给编译器自行处理。通过将此类程序的行为方式交由编译器处理,语言标准允许编译器去检查此类错误 — 如果它们想这么做的话,同时也允许不去检查这类问题而将负担最小化 — 如果编译器更喜欢这种方式的话。在你的程序中避免此类问题的最直接了当的方式就是:除非有什么不可抗拒的原因,否则每一个被用作基类的类都应当拥有一个虚析构函数。 

什么时候可以安全地使用memcpy? 

最后一个例子来自于最近同事提出的一个问题:什么时候可以安全地使用memcpy将一个数组中的元素拷贝到另一个数组中?例如,早先我们提倡将: 

int i = 0;
while (i < n)
{
    y[i] = x[i];
    ++i;
}

改写为: 

std::copy(x, x + n, y); 

难道我们不能将其改写为如下所示吗? 

memcpy(y, x, n * sizeof(x[0])) 

这当然可以,这样可以使我们的程序跑得更快。 

和前面三个问题一样,这个问题也有一个答案:只有当数组x与数组y中的元素的类型都是所谓的POD时,语言定义才能保证本例中的memcpy正确运行。术语POD表示“"plain old data”。不太严格地说,一个POD类型中不存在用户定义的拷贝构造函数、赋值运算符或者析构函数,这样,构成其内部表示的那些位(bits)就完全定义了POD的值。可以想象memcpy也同样适用于其他类型。例如,只要程序员努力避免重复析构和内存泄漏,在许多编译器上memcpy也可以完美地拷贝string和vector这样的对象。例如: 

string s, t;

// Swap the contents of s and t
// This code works only by accident
char x[sizeof(s)];
memcpy(x, (char*) &s, sizeof(s));
memcpy((char*) &s, (char*) &t, sizeof(s));
memcpy((char*) &t, x, sizeof(s)); 

这个程序片断用于交换构成s与t的内部表示的位(bits),它可以在许多编译器上正确地工作,因为我们能够想到的大多数string类的实现都并不关心string对象自身的存储位置。然而,语言标准并没有要求所有编译器都应该保证这个例子可以工作,因为string并不是POD类型。我们可以使用下面的方式更为安全地交换s和t的值: 

std::swap(s, t); 

总结 

和C一样,C++并没有对编译器所能接受的每一个程序的运行期效果都进行了定义。相反,程序员能够做出许多编译器可以接受但在运行期没有定义的行为。 

未定义行为实际上是:编译器碰到此类情况可以为所欲为(译注:当碰到未定义行为时,gcc编译器的第一个版本开玩笑地启动游戏RogueJ)。由于编译器具有如此之大的自由度,所以这些未定义行为甚至当然可以看似完全正确的方式运行。因此不能因为你的程序看似可以运行就认定它真的可以工作,因为可能编译器的行为不过碰巧和你所期望的相同而已。如果你在不同的编译器甚至是同一编译器的后续版本更有甚者是在同一编译器上再次运行同样的程序,你可能就不会那么走运了。 

避免未定义行为的最保险的办法就是“知之为知之、不知为不知”。如果你对将要编写的程序的含义有丝毫的疑虑,那就干脆使用别的方式去编写它。上面每一个例子中都有一个可以避免问题发生的简单办法。进行这样的改写的机会并不少见,而且 的确值得付出这个劳动。 

Andrew Koenig是AT&T Shannon实验室大规模程序设计研究部门的成员,并且是C++标准委员会的项目编辑。作为一名编程长达30年、使用C++ 15年之久的程序员,他已经发表了150多篇关于C++的文章,并在世界范围发表关于该主题的演讲。他是C Traps and Pitfalls 一书的作者,也是Ruminations on C++的作者之一。 

Barbara E. Moo是一个在软件领域拥有20年经验的独立顾问。在AT&T近15年里,她参与了第一个使用C++编写的商业项目,管理了公司的第一个C++编译器项目,并负责“为AT&T赢得赞誉”的WorldNet Internet服务业务的开发。她是Ruminations on C++的作者之一,并在世界范围内发表演讲。

0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:1200次
    • 积分:47
    • 等级:
    • 排名:千里之外
    • 原创:3篇
    • 转载:1篇
    • 译文:0篇
    • 评论:0条
    文章分类
    文章存档