您的位置:首页 > 其它

"Pure Virtual Function Called" 纯虚函数调用错误分析(翻译)

2014-05-14 17:38 429 查看

翻译:http://www.artima.com/cppsource/pure_virtual.html

概要:

"Pure Virtual Function Called"是C++程序偶然崩溃时程序结束前的提示信息。什么意思呢?对于那些在后期调试时很容易找到的原因,你可以找到很多简单、合理的解释,但是还有其他一些很莫名奇妙的bug导致这个问题。如果你碰到这样的问题,可能意味着你的程序间接的使用了悬挂指针。这篇文章解释了这些可能的原因。

面向对象C++ :程序员的视角

在c++里,虚函数使相关类的实例在运行时有不同的行为。 (aka, runtime polymorphism) :



class Shape {

public:

virtual double area() const;

double value() const;

// Meyers 3rd Item 7:

virtual ~Shape();

protected:

Shape(double valuePerSquareUnit);

private:

double valuePerSquareUnit_;

};

class Rectangle : public Shape
{

public:

Rectangle(double width, double height, double valuePerSquareUnit);

virtual double area() const;

// Meyers 3rd Item 7:

virtual ~Rectangle();

// ...

};

class Circle : public Shape
{

public:

Circle(double radius, double valuePerSquareUnit);

virtual double area() const;

// Meyers 3rd Item 7:

virtual ~Circle();

// ...

};

double Shape::value() const

{

// Area is computed differently, depending

// on what kind of shape the object is:

return valuePerSquareUnit_ * area();

}

(在析构函数之前的注释引用了Scott Meyers 的Effective C++ 第三版的第七章:“在多态基类中声明虚析构函数”,这些符合大众规范的代码已经在几个项目中使用,就像这样放在代码里,用来提醒维护者和审核者。对于有些人,这样的问题是明显的,提醒可能会分散注意力;但是一个人的分心会给于另一个人有用的提示,并且匆忙的开发者总是会忘记这些明显的东西。)
在C++中,一个功能的接口由函数声明指定。成员函数在类定义中声明。功能的实现由函数的定义指定。派生类可以重定义一个函数,为这个派生类以及派生自这个派生类的类指定一个特殊实现。当一个虚函数被调用时,选择函数的哪个实现不是由指针或者引用的静态类型决定,而是由指向的对象的类型决定,从而实现在运行时多台态:
print(shape->area()); // Might invoke Circle::area() or Rectangle::area().
在基类声明一个纯虚函数时,可以不用对其定义。声明有纯虚函数的类是一个抽象类(和实体类相反),因此定义一个抽象类的实例显然是不合理的。派生类必须定义所有继承自基类的纯虚函数,转换为一个实体类。
class AbstractShape {

public:

virtual double area() const = 0;

double value() const;

// Meyers 3rd Item 7:

virtual ~AbstractShape();

protected:

AbstractShape(double valuePerSquareUnit);

private:

double valuePerSquareUnit_;

protected:

AbstractShape(double valuePerSquareUnit);

private:

double valuePerSquareUnit_;

};

// Circle and Rectangle are derived from AbstractShape.

// This will not compile, even if there's a matching public constructor:

// AbstractShape* p = new AbstractShape(value);

// These are okay:

Rectangle* pr = new Rectangle(height, weight, value);

Circle* pc = new Circle(radius, value);

// These are okay, too:

AbstractShape* p = pr;

p = pc;

面向对象C++:Under the Covers

​(如果你已经知道什么是虚函数表(vtbl),可以跳过这一段)

这些运行时技巧是如何发生的呢?通常的实现是,每个包含虚函数的类有一个函数指针数组,叫做虚函数表。每一个这样类的实例都有一个指向类虚函数表的指针,就像下图描述的那样。



图1:类的虚函数表指向类实例的成员函数
如果一个包含纯虚函数的抽象类没有定义这个函数,在虚函数表的对应位置会出现什么?通常C++会提供一个特殊的函数,输出“纯虚函数调用”(或者其他类似的语句),然后使程序崩溃。



图2:一个抽象类的虚函数表有一个指向特殊函数的指针

Build 'em Up, Tear 'em Down

​当你构造一个派生类的实例时,具体发生了什么?如果类包含虚函数表,过程会像下面这样:

第一步:构造最顶层的基类部分
a、让实例指向基类的虚函数表
b、构造基类实例成员变量
c、执行基类构造函数
第二步:构造派生部分(递归的)
a、让实例指向派生类的虚函数表
b、构造派生类实例成员变量
c、执行派生类构造函数
析构时则是按相反的顺序,就像这样:

第一步:析构派生部分(递归的)
a、(实例已经指向派生类的虚函数表)
b、执行派生类析构函数
c、析构派生类实例成员变量

第二步:析构基类部分(递归的)
a、让实例指向基类的虚函数表
b、执行基类析构函数
c、析构基类实例成员变量

两个经典错误(Two of the Classic Blunders)

如果在基类的构造函数里调用虚函数会发生什么?



// From sample program 1:

AbstractShape(double valuePerSquareUnit)

: valuePerSquareUnit_(valuePerSquareUnit)

{

// ERROR: Violation of Meyers 3rd Item 9!

std::cout << "creating shape, area = " << area() << std::endl;

}

((Meyers, 3rd edition, Item 9: "Never call virtual functions during construction or destruction.") )
这是显式的调用纯虚函数,编译器应该提醒这个错误,有些编译器的确会提醒。如果基类析构函数直接调用了纯虚函数,本质和构造函数里调用纯虚函数是一样的。
在更复杂的情况下,错误非常不明显(编辑器很可能也不会提醒)


// From sample program 3:

AbstractShape::AbstractShape(double valuePerSquareUnit)

: valuePerSquareUnit_(valuePerSquareUnit)

{

// ERROR: Indirect violation of Meyers 3rd Item 9!

std::cout << "creating shape, value = " << value() << std::endl;

}

这个基类的构造函数就是上面在第一步里描述的构造过程,调用了一个实例成员函数(value()),这个函数调用了一个纯虚函数(
area()
)。此时对象还是AbstractShape类型。当调用纯虚函数时会发生什么?你的程序会崩溃掉,提示一个“纯虚函数调用”错误。
类似的,在基类的析构函数里间接的调用虚函数(sample program 4)会导致相同的崩溃。相同的还有传递一个部分构造或者部分析构(partially-constructed
(or partially-destructed))的对象给任何调用虚函数的函数。
这是一些通常引起“纯虚函数调用”错误提示的根源。他们很容易通过调试找出,栈跟踪会明确指出错误的地方。

(找出责任)Pointing Out Blame

还有至少一种其他原因会导致这个问题,这个原因在出版物和网上都没有确切的描述(在ACE邮件列表里有一些讨论涉及到这个问题,但是他们没有深入讨论)
思考下面的代码:


// From sample program 5:

AbstractShape* p1 = new Rectangle(width, height, valuePerSquareUnit);

std::cout << "value = " << p1->value() << std::endl;

AbstractShape* p2 = p1; // Need another copy of the pointer.

delete p1;

std::cout << "now value = " << p2->value() << std::endl;

我们一次看一行
AbstractShape* p1 = new Rectangle(width, height, valuePerSquareUnit);
创建了一个新对象,分两步构造:第一步,对象作为一个基类实例,第二部,对象作为一个派生类对象
std::cout << "value = " << p1->value() << std::endl;
一切都正常。
AbstractShape* p2 = p1; // Need another copy of the pointer.
指针p1可能会有些改变,所以我们复制一份。
delete p1;
对象分两步析构:第一步,对象作为一个派生类对象,第二步,对象作为一个基类实例
注意,p1的值在delete后会改变。指针指向的数据析构后,允许编译器把指针归零。我们有另一个拷贝的指针p2,它是没变的,觉得很幸运?
std::cout << "now value = " << p2->value() << std::endl;
当然不会,这是另一个经典的严重错误:间接使用悬挂指针。一种指向的对象被删除了的指针,或者指向的对象和指针都被删除。c++程序员绝不会写这样的代码,除非他们是愚蠢的(不太可能)或者是匆忙的(太有可能了)。
现在p2指向一个曾经存在的对象,p2代表什么?根据C++标准,它是未定义的。这是一个技术术语,理论上,任何情况都会发生:程序崩溃,或者继续运行但是产生垃圾数据,或者向Bjarne Stroustrup发一封邮件,说:你太丑了,你妈妈把你打扮的真滑稽。无法取决于任何事,这种行为根据编译器的不同、机器的不同、每次的运行而不同。实际上有几种常见的可能性(这些可能性不会总是出现)

内存可能会被标记为已释放。任何对指针的访问都会立即提示使用了悬挂指针。这是一些工具(BoundsChecker, Purify, valgrind, and others)尽力去实现的。正如我们看到得,来自微软的.Net
框架的通用语言运行时(CLR)和Sun Studio 11's dbx debugger,都是这样工作的。
内存可能会混乱。内存管理系统会在释放后的内存里写入垃圾数据(一种值是“dead beef“,0xDEADBEEF,无符号十进制数3735928559,有符号十进制数
-559038737)。
内存可能被重用。如果有其他代码在对象删除和使用悬挂指针的地方之间执行,内存分配系统可能会创建一个新的对象占用一部分或者全部的被删除的对象的内存空间。幸运的话,这些数据被当做是垃圾数据,程序立即崩溃。否则程序会一段时间后崩溃, possibly
after curdling other objects(难以翻译),经常在导致问题发生的根源以后很久。这是一类让c++程序员抓狂的问题(让java程序员沾沾自喜)。
内存可能保持不变(The memory might have been left exactly the way it was.)。

最后是个有趣的例子。”exactly the way it was“是什么意思?这种情况下,是个抽象基类的实例;certainly
that's the way the vtbl was left.如果我们使用这样的对象调用一个纯虚函数会发生什么?
“纯虚函数调用”
(读者练习:假设有这样一个函数,不明智和不幸的,返回一个局部变量的指针或者引用。这是另一种不同的悬挂指针?这也会产生这样的消息吗?)

Meanwhile, Back in the Real World

理论很不错,实际会发什么?
思考下面五个测试程序,每个都有不同的缺陷:
1、在基类的构造函数里直接调用虚函数
2、在基类的析构函数里直接调用虚函数
3、在基类的构造函数里间接调用虚函数

4、在基类的析构函数里间接调用虚函数

5、使用悬挂指针调用虚函数
这些程序由不同的编译器编译和测试(在x86 windows xp 上运行除非另外说明)

Visual C++ 8.0
Digital Mars C/C++ compiler version 8.42n
Open Watcom C/C++ version 1.4
SPARC Solaris 10, Sun Studio 11
gcc:

x86 Linux (Red Hat 3.2), gcc 2.96 / 3.0 / 3.2.2
x86 Windows XP (Cygwin), gcc 3.4.4
SPARC Solaris 8, gcc 3.2.2
PowerPC Mac OS X.4 (Tiger), gcc 3.3 / 4.0

(直接调用)Direct Invocation

前两个例子一些编译器可以识别出发生了什么,给出不同的结果。
Visual C++ 8.0, Open Watcom C/C++ 1.4, and gcc 4.x识别出基类的构造函数和析构函数不能调用一个派生类的成员函数。结果是这些编译器优化所有的运行时多态,把调用当做是基类成员函数的调用。如果这个成员函数没有定义,程序不会链接。如果成员函数定义了,程序正确的运行。gcc
4.x产生一个警告(第一个程序"abstract virtual 'virtual double AbstractShape::area() const' called from constructor",第二个程序和第一个类似)。Visual
C++ 8.0和Open Watcom C/C++ 1.4编译时没有任何警告,甚至在最大警告级别(/wall)。
gcc 3.x and Digital Mars C/C++ compiler 8.42n不能执行程序,分别提示"abstract virtual `virtual
double AbstractShape::area() const' called from constructor" (or "from destructor") 和"Error: 'AbstractShape::area' is a pure virtual function"。
Sun Studio 11产生一个警告,"Warning: Attempt to call a pure virtual function AbstractShape::area()
const will always fail",但是生成了程序。正如所预料的,崩溃并且提示"Pure virtual function called"。

(间接调用)Indirect Invocation

​下面的两个例子所有编译器编译都没有警告(这是预料中的,这不是通常的由静态分析引起的问题)。程序的结果是所有都崩溃,但提示不同的错误消息。

Visual C++ 8.0: "R6025 - pure virtual function call (__vftpr[0] == __purecall)".
Digital Mars C/C++ compiler 8.42n: did not generate an error message when the program crashed. (That's fine; this is "undefined" behavior, and the compiler is free to do whatever it wants.)
Open Watcom C/C++ 1.4: "pure virtual function called!".
Sun Studio 11: "Pure virtual function called" (same as for the first two programs).
gcc: "pure virtual method called".

(由悬挂指针调用)Invocation via a Dangling Pointer

先前列表里的第五个例子在编译时没有警告,运行时不会崩溃。再次被预料到。所有的编译器除了微软的,错误消息和第三个第四个例子一样。Sun的编译产生相同的错误消息,但是Sun的调试器提供了一些附加信息。
Microsoft Visual C++ 8.0有一些运行时库,每个库以不同的方式处理这个消息。

Win32 console application:

当不调试运行时,程序默默地崩溃?(crashes silently)。
当调试运行时,程序在Debugger模式下编译,产生"Unhandled exception ... Access violation reading location 0xfeeefeee."消息,这明显是"dead
beef"行为,当内存释放后,runtime用垃圾数据重写内存,
当在Release模式下编译,在Debugger模式下运行,程序产生消息, "Unhandled exception ... Illegal Instruction".

CLR console application:

当在debug模式下编译,消息是"Attempted to read or write protected memory. This is often an indication that other memory is corrupt." ,debug
runtime system已标记释放的内存,并且当访问此处内存时会终止程序
当在Release模式下编译时,程序崩溃,提示"Object reference not set to an instance of an object."

当用Sun Studio 11编译时,使用 Run-Time Checking在dbx下运行,程序提示一个新的错误死掉:"Read
from unallocated (rua): Attempting to read 4 bytes at address 0x486a8 which is 48 bytes before heap block of size 40 bytes at 0x486d8"。从调试器角度的说法:"You just used something in a block of memory, but this
isn't a block of memory I think you should be using." (你想使用内存块里的数据,但是我认为这块内存你不应该使用。)一旦对象析构,内存释放,程序不能再次使用这个对象和那块内存。

Owning Up

​ 如何避免这些错误?
前四个例子的问题很好解决。多留意Scott Meyers和(对于前两个例子)你得到的任何警告。
第五个例子里悬挂指针问题怎么解决?任何语言的开发者,应该按照对象所有权形式设计。一些东西或者一些东西的集合拥有一个对象。所有权可能是:

转移给其他东西或者其他东西集合
不转移所有权借出
通过使用引用计数或垃圾回收共享

什么东西可以拥有一个对象

显然另一个对象可以
对象集合;例如,指向拥有的对象的所有智能指针
一个函数。当函数被调用时,他可能有所有权(转移时)或者没有(借出)。函数通常对它自己的局部变量拥有所有权,但不包括局部变量指向的或者引用的。

在我们的例子中,没有明确的所有权。一些函数创造了一个对象,用两个指针指向它。谁拥有这个对象?可能是这个函数,这样的话,它应该有责任用某种方法避免这个问题。可以使用"dumb"指针(在删除后明确的置零)代替使用两个指针或者使用某种类型的智能指针。

实际上,没那么简单,尤其在追溯的时候。对象可以从一个模块传递给另一个迥然不同的模块,被其他人或者其他组织改写。对象所有权同样跟着跨越这样长的距离。
任何时候传递对象时,你都要知道对象的所有权问题。它是个简单的问题,有时候答案也很简单,但是一个问题绝对不会神奇的回答了它自己。没有东西可以替代思考。
思考不意味你要独立思考,有很多已有的工作可以帮到你。Tom Cargill写了一个模式语言,“本地化的所有权”(Localized Ownership),描述了一些替代的策略。Scott
Meyers也在Effective C++第三版的13章描述了,“使用对象管理资源”("Use objects to manage resources,"),在14章描述了“ 慎重考虑在资源管理类之间的复制行为”("Think
carefully about copying behavior in resource-managing classes,"),详细查看参考(
References
)。


智能指针不是万能药(No Smart Pointer Panacea)

使用​引用计数的智能指针对于避免这类问题非常有用。使用智能指针时,所有权属于指向这个对象的指针集合。当最后一个这样的指针不再指向对象,才删除这个对象。这当然解决了我们的我们的问题。

但是很多开发人员刚开始学习和使用智能指针。如果你用不明智的方法使用了智能指针也可能会出现这样的问题。
这又是另一个问题了。

参考(References)

Tom Cargill, "Localized Ownership: Managing Dynamic Objects in C++"; in Vlissides, Coplien, and Kerth, Pattern Languages of Program Design 2, 1996, Addison-Wesley.

Scott Meyers, Effective C++, Third Edition: 55 Specific Ways to Improve Your Programs and Designs, 2005, Addison-Wesley.




第一次翻译:写了一天,很多地方翻译的不恰当。大家凑合着看下,如果你们也遇到这样的问题,欢迎留言
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: