何时会破坏二进制兼容性?

10

我曾认为,每当您进行以下操作之一时:

  • 添加一个新的公共虚拟方法 virtual void aMethod();
  • 添加一个新的公共非虚拟方法 void aMethod();
  • 从接口实现一个公共纯虚拟方法 virtual void aMethod override;

实际上会破坏二进制兼容性,这意味着如果一个项目建立在以前版本的 DLL 上,它将无法加载现在有新方法的 DLL。

根据我使用 Visual Studio 2012 进行的测试,这些操作都不会造成任何破坏。Dependency Walker 没有报告错误,我的测试应用程序也能调用适当的方法。

DLL:

class EXPORT_LIB MyClass {
public:
  void saySomething();
}

可执行文件:

int _tmain(int argc, _TCHAR* argv[])
{
  MyClass wTest;
  wTest.saySomething();
  return 0;
}

我发现的唯一未定义行为是,如果MyClass正在实现一个纯虚拟接口,并且从我的可执行文件中调用其中一个纯虚拟方法,然后在使用的那个方法之前,我添加了一个新的纯虚拟方法。在这种情况下,Dependency Walker没有报告任何错误,但在运行时,它实际上调用了错误的方法。

class IMyInterface {
public:
  virtual void foo();
}
在可执行文件中。
IMyInterface* wTest = new MyClass();
wTest->foo();

然后我可以在不重新构建可执行文件的情况下更改界面。

class IMyInterface {
public:
  virtual void bar();
  virtual void foo();
}

现在它正在安静地调用bar()而不是foo()

我所有的三个假设都是安全的吗?

编辑:

这样做

class EXPORT_LIB MyClass {
public:
  virtual void saySomething();
}

执行

MyClass wTest;
wTest.saySomething();

然后使用以下内容重新构建DLL:

class EXPORT_LIB MyClass {
public:
  virtual void saySomething2();
  virtual void saySomething();
  virtual void saySomething3();
}

调用适当的saySomething()函数


请澄清“二进制兼容性”。您是否意味着所有数据和函数在文件中的确切位置都相同? - Thomas Matthews
@LokiAstari 但是为什么我的测试都能正常运行,没有任何奇怪的行为呢? - Ceros
2
@Ceros:很难说。但 UB 可以做任何事情,甚至表现得像是在工作。 - Martin York
考虑使用一个具有自己虚拟方法的类的子类,并重新尝试您的测试。 - user3159253
@Ceros - 这让我感到惊讶。我没有预料到它会起作用。 - davidbak
显示剩余8条评论
2个回答

10

破坏二进制兼容性并不总是导致DLL不能加载,在许多情况下,您将面临可能或不可能立即显而易见的内存损坏。这在很大程度上取决于您所做的更改以及内存中事物的排列方式。

DLL之间的二进制兼容性是一个复杂的主题。让我们从您的三个示例开始;

  • 添加一个新的公共虚拟方法virtual void aMethod();

这几乎肯定会导致未定义的行为,它与编译器高度相关,但大多数编译器将使用某种形式的虚方法表,因此添加新方法将更改该表的布局。

  • 添加一个新的公共非虚方法void aMethod();

对于全局函数或成员函数来说这是可以接受的。成员函数本质上只是带有隐藏“this”参数的全局函数。它不会更改任何东西的内存布局。

  • 从接口实现公共纯虚拟方法virtual void aMethod override;

这不会导致任何未定义的行为,但正如您发现的那样,它不会产生您期望的结果。针对库的先前版本进行编译的代码将不知道该函数已被覆盖,因此不会调用新实现,它将继续调用旧实现。这可能是有问题的,具体取决于您的用例,它不应引起任何其他副作用。但是我认为这里可能会因使用的编译器而异。所以最好避免这种情况。

会阻止DLL加载的是如果您以任何方式更改导出函数的签名(包括更改参数和范围)或删除函数。因为动态链接器无法找到它。仅当所涉及的函数被用作链接器时才适用,因为链接器仅导入在代码中引用的函数。

还有许多破坏dll之间二进制兼容性的方法,这超出了本答案的范围。根据我的经验,它们通常遵循更改内存中某些东西的大小或布局的主题。

编辑:我刚想起来,在KDE维基上有一篇关于C++二进制兼容性的优秀文章,其中包括一个非常好的“该做什么”和“不该做什么”的列表,附带解释和解决方案。


@Di Paolo 我在类声明中添加了许多虚方法,不仅仅是在末尾,但它似乎仍然能够正常工作。此外,如果我删除一个调用者未使用的方法,一切似乎也没问题。但是,如果删除了实际被使用的方法,那么情况就另当别论。 - Ceros
你的应用程序可能看起来工作正常,实际上它甚至可能工作正常,这就是未定义行为和内存损坏的本质。关键在于通过添加或删除虚拟方法来修改vtable的布局,因此您的应用程序可能最终读取或写入内存中错误的位置。由于您的示例应用程序非常简单,您可能会很幸运地不覆盖正在使用的任何内存。在更复杂的应用程序中,您可能会看到微妙的错误、损坏或访问违规。最后,如果该方法未被使用,则没有要破坏的依赖关系。 - Robert Di Paolo
我明白了。“最后,如果该方法未被使用,则没有依赖关系需要打破。”这难道不会像添加新的虚拟方法一样改变vtable吗? - Ceros
如果您删除了一个虚方法,是的。如果您删除了一个全局或成员方法,那么我认为只有在该方法在dll外部被使用时才会引起问题。 - Robert Di Paolo

3

C++并未明确规定。

Visual Studio通常遵循COM规则,允许您在最终派生类的末尾添加虚方法,除非它们是重载。

任何非静态数据成员也会改变二进制布局。

非虚函数不会影响二进制兼容性。

模板由于名称混编会导致非常混乱。

为了保持二进制兼容性,您最好大量使用pimpl惯用法和nvi惯用法。


非虚函数如果改变其签名,可能会影响二进制兼容性。 - Mark Ransom
同时,内联定义如果发生更改,则其编译到调用代码中的位置也不会得到更新 - 这不仅适用于成员,还适用于使用更改后类(例如运算符)的顶层函数。 - davidbak
@Crazy Eddie 我之前添加了虚方法,并在从我的可执行文件调用的方法之前和之后添加了虚方法,没有出现任何问题。它似乎正常工作。 - Ceros
在我看来,默默调用错误的函数似乎不能算作“正常工作”...但我猜每个人都有自己的想法。 - Edward Strange
@CrazyEddie - 当然,你是正确的,但是把这个问题看作一个更实际的问题,即我可以改变什么并期望它能够工作/中断,并且我们通常针对头文件进行编程,即使跨DLLs,我认为指出内联函数,虽然它们不会破坏类或虚表的布局,但实际上会“破坏兼容性”,因为您在DLL中的类不会按照您的预期与未重新编译的代码一起工作。 “破坏封装性”与“二进制兼容性”的区别是微妙的... - davidbak
显示剩余2条评论

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