C++调用对象的(虚)方法完全错误

16

我有一些C++代码(由他人编写),似乎调用了错误的函数。以下是情况:

UTF8InputStreamFromBuffer* cstream = foo();
wstring fn = L"foo";
DocumentReader* reader;

if (a_condition_true_for_some_files_false_for_others) {
    reader = (DocumentReader*) _new GoodDocumentReader();
} else {
    reader = (DocumentReader*) _new BadDocumentReader();
}

// the crash happens inside the following call
// when a BadDocumentReader is used
doc = reader->readDocument(*cstream, fn);

满足条件的文件能够正常处理,不满足条件的文件会导致程序崩溃。DocumentReader 的类层次结构如下所示:

class GenericDocumentReader {
    virtual Document* readDocument(InputStream &strm, const wchar_t * filename) = 0;
}

class DocumentReader : public GenericDocumentReader {
    virtual Document* readDocument(InputStream &strm, const wchar_t * filename) {
        // some stuff
    }
};

class GoodDocumentReader : public DocumentReader {
    Document* readDocument(InputStream & strm, const wchar_t * filename);
}

class BadDocumentReader : public DocumentReader {
    virtual Document* readDocument(InputStream &stream, const wchar_t * filename);
    virtual Document* readDocument(const LocatedString *source, const wchar_t * filename);
    virtual Document* readDocument(const LocatedString *source, const wchar_t * filename, Symbol inputType);
}

以下内容也与此相关:

class UTF8InputStreamFromBuffer : public wistringstream {
    // foo
};
typedef std::basic_istream<wchar_t> InputStream;
在Visual C++调试器中运行时,它显示对BadDocumentReader的readDocument调用不是有效的。
readDocument(InputStream&, const wchar_t*)

但是更确切地说

readDocument(const LocatedString* source, const wchar_t *, Symbol)
这一点通过在所有readDocuments中插入cout语句来确认。 在调用后,源参数当然充满了垃圾,这很快导致崩溃。 LocatedString确实有一个从InputStream隐式构造函数的单参数,但使用cout检查表明它没有被调用。 有什么想法可以解释这个问题吗?编辑:其他可能相关的细节:DocumentReader类位于与调用代码不同的库中。 我还对所有代码进行了完整的重建,但问题仍然存在。编辑2:我正在使用Visual C ++ 2008。编辑3:我尝试制作了一个具有相同行为的“最小编译示例”,但无法复制该问题。编辑4:在Billy ONeal的建议下,我尝试更改BadDocumentReader头文件中readDocument方法的顺序。 当我更改顺序时,它会更改哪个函数被调用。 这似乎证实了我的怀疑,即存在一些奇怪的事情涉及索引到vtable中,但我不确定是什么原因导致了这种情况。编辑5:这是函数调用前几行的反汇编:
00559728  mov         edx,dword ptr [reader] 
0055972E  mov         eax,dword ptr [edx] 
00559730  mov         ecx,dword ptr [reader] 
00559736  mov         edx,dword ptr [eax] 
00559738  call        edx  
我不太了解汇编语言,但看起来它正在取消引用reader变量指针。存储在此内存部分中的第一件事应该是指向vtable的指针,因此它将其取消引用为eax。然后它将vtable中的第一项放入edx并调用它。重新编译不同顺序的方法似乎不会改变这一点。它总是想调用vtable中的第一项。(我可能完全误解了这一点,因为我根本不懂汇编语言...)感谢您的帮助。 编辑6:我找到了问题,并为浪费大家的时间道歉。问题在于GoodDocumentReader应该被声明为DocumentReader的子类,但实际上并没有。C风格的转换抑制了编译器错误(如果您想将您的评论作为答案提交,我会将其标记为正确答案)。棘手的是,代码已经通过纯粹的意外运行了几个月,直到有人添加了两个更多的虚函数到GoodDocumentReader,所以它不再通过运气调用正确的函数。

2
你能否简化这段代码为最小可编译示例以展示问题? - Oliver Charlesworth
3
不应该从wistringstream继承。如果你想要使用UTF-8编码,你应该使用codecvt facet来完成;毕竟,这正是codecvt facet的设计目的。boost库有一个相关的实现,而且我相信MSVC++也内置了这样的功能。 - Billy ONeal
1
你说DocumentReader的东西在一个单独的库中。你确定它们使用相同的设置编译了吗? - Billy ONeal
4
可能 "_new" 分配功能出了问题(因为它不是标准的 new)? - ltjax
4
有人告诉过你 C 风格的强制类型转换是邪恶的吗?如果没有它编译不通过,代码一定有严重问题。看起来像是某个 Java 开发者试图用 C++ 写 Java。 - sellibitze
显示剩余16条评论
4个回答

15
这是因为不同的源文件对类的虚函数表布局存在分歧。调用该函数的代码认为 readDocument(InputStream &, const wchar_t *) 在特定偏移量处,而实际的虚函数表将其放置在不同的偏移量上。
通常情况下,当你更改虚函数表(例如在该类或任何父类中添加或删除虚函数)并重新编译其中一个源文件而不是另一个源文件时,就会出现这种情况。然后,你会得到不兼容的目标文件,当你链接它们时,程序会崩溃。
要解决此问题,请全面清理并重新构建所有代码:包括库代码和使用库的代码。如果你没有库的源代码但具有包含类定义的头文件,则无法进行修改类定义。在这种情况下,你应该将其恢复为原始状态并重新编译所有代码。

.h文件定义了对象的布局,包括虚表;如果你在不重新编译库的情况下更改它,一切都会变得非常混乱。 - Mark Ransom
1
从理论上讲,如果源代码是使用不同的编译器、不同的版本或不同的设置编译的,则会出现问题。但在实践中,编译器/选项必须相当不同才能出现这个问题(更不用说如果构建系统无法检测到需要重新编译的情况下,它就会非常糟糕)。我认为这是一个与C风格转换有关的问题,它没有正确地移动指针值(将其从派生类的vtable移回到基类的vtable)。 - Mikael Persson
@Mikael Persson:我之前遇到过非常类似的问题,即类布局已更改,但未重新编译使用它的每个源文件。发生这种情况的原因是使用的编译器标志类似于-I- -Isomedir,因此在使用-MM生成依赖项时,somedir中的头文件未列为依赖项。 - Adam Rosenfield
通过谷歌搜索来到这里。问题与 OP 描述的一样,原因是在添加虚函数后 Visual Studio 进行了“智能”部分重建。清理解决方案并重新构建即可解决问题。 - Szak1

3

我建议首先尝试删除C-cast。

  • 这是完全不必要的,从Derived到Base的转换在语言中是自然的
  • 它可能会导致错误(虽然不应该)

看起来像是编译器的问题...这肯定不是VS中的第一个问题。

不幸的是,我手头没有VS 2008,在gcc中转换是正确的:

struct Base1
{
  virtual void foo() {}
};

struct Base2
{
  virtual void bar() {}
};

struct Derived: Base1, Base2
{
};

int main(int argc, char* argv[])
{
  Derived d;
  Base1* b1 = (Base1*) &d;
  Base2* b2 = (Base2*) &d;

  std::cout << "Derived: " << &d << ", Base1: " << b1
                                 << ", Base2: " << b2 << "\n";

  return 0;
}


> Derived: 0x7ffff1377e00, Base1: 0x7ffff1377e00, Base2: 0x7ffff1377e08

0

我曾经也碰到过这个问题,我的问题是我把它存储在了一个类成员变量中。当我把它改为指针并且使用new/delete时,成功地注册了子类及其函数。


0

基于汇编,似乎很清楚绑定是动态的并来自于虚表的第一个条目,问题是哪个虚表呢?!我建议您使用 static_cast 而不是C样式转换(当然,@VJo: dynamic_cast 在这种情况下不需要!)。标准中没有要求指针BadDocumentReader* ptr具有与其转换static_cast<DocumentReader*>(ptr)相同的实际值(地址)。这就解释了为什么它将调用绑定到BadDocumentReader的虚表的第一个条目,而不是其基类的虚表。而且,顺便说一句,在这种情况下您根本不需要转换。

有一种可能性与asm不太一致,但仍然值得知道。 因为您在与调用reader->readDocument相同的作用域中创建了BadDocumentReader,编译器变得过于聪明,并决定可以解析调用而无需动态查找vtable。这是因为它知道读取器指针的“真实”类型实际上是BadDocumentReader。因此,它绕过vtable并静态绑定调用。至少,在一个几乎相同的情况下,我遇到了这种可能性。但根据asm,我相信第一种可能性是发生在您的情况中的。


C风格的转换应该首先尝试使用static_cast,因此我认为这不是问题所在。 - Billy ONeal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接