在跨DLL边界使用的类中将字符串用作私有数据成员是否安全?

Is it safe to use strings as private data members in a class used across a DLL boundry?

本文关键字:数据成员 字符串 安全 是否 边界 DLL 在跨      更新时间:2023-10-16

我的理解是,在DLL边界上暴露接受或返回stl容器(如std::string)的函数可能会由于两个二进制文件中这些容器的stl实现的差异而导致问题。但是导出像

这样的类安全吗?
class Customer
{
public:
  wchar_t * getName() const;
private:
  wstring mName;
};

如果没有某种hack, mName将不能被可执行文件使用,所以它将不能在mName上执行方法,也不能构造/销毁这个对象。

我的直觉是"不要这样做,这是不安全的",但我找不到一个好的理由。

没关系。因为它被更大的问题所压倒,所以您不能在包含该类代码的模块以外的模块中的代码中创建该类的对象。另一个模块中的代码不能准确地知道所需的对象大小,它们对std::string类的实现很可能是不同的。正如声明的那样,它也会影响Customer对象的大小。即使是同一个编译器也不能保证这一点,例如,将这些模块的优化和调试版本混合在一起。尽管这通常很容易避免。

因此,必须为Customer对象创建一个类工厂,该工厂位于同一个模块中。这就自动暗示了任何触及"mName"成员的代码也存在于同一个模块中。因此是安全的。 下一步是根本不公开Customer,而是公开一个纯抽象基类(也就是接口)。现在,您可以防止客户端代码创建Customer的实例并把它们的脚踢掉。您还可以简单地隐藏std::字符串。基于接口的编程技术在模块互操作场景中很常见。COM采用的方法也是如此。

只要类的实例的分配器和分配器具有相同的设置,就应该没有问题,但是避免这种情况是正确的。
就调试/发布而言,.exe和. DLL之间的差异,代码生成(多线程DLL与单线程DLL)可能会在某些情况下导致问题。我建议在DLL接口中使用抽象类,创建和删除只在DLL内部完成。接口:

class A {
protected:
  virtual ~A() {}
public:
  virtual void func() = 0;
};
//exported create/delete functions
A* create_A();
void destroy_A(A*);

DLL实现如下:

class A_Impl : public A{
public:
  ~A_Impl() {}
  void func() { do_something(); }
}
A* create_A() { return new A_Impl; }
void destroy_A(A* a) { 
  A_Impl* ai=static_cast<A_Impl*>(a);
  delete ai;
}

应该没问题。

即使您的类没有数据成员,您也不能期望它可以从使用其他编译器编译的代码中使用。c++类没有通用的ABI。对于初学者,您可以期望名称混淆的差异。

如果你准备约束客户端使用与你相同的编译器,或者提供源代码以允许客户端使用他们的编译器编译你的代码,那么你就可以在你的接口上做几乎任何事情。否则你应该坚持使用C风格的接口。

如果您想在真正安全的DLL中提供面向对象的接口,我建议将其构建在COM对象模型之上。这就是它的设计目的。

在由不同编译器编译的代码之间共享类的任何其他尝试都有可能失败。你可能能够得到一些似乎在大多数时候都有效的东西,但它不能保证有效。

有可能在某些时候,你将依赖于调用约定或类结构或内存分配方面的未定义行为。

c++标准没有说明实现所提供的ABI。即使在单一平台上,更改编译器选项也可能改变二进制布局或函数接口。

因此,为了确保标准类型可以跨DLL边界使用,您有责任确保:

  • 标准类型的资源获取/释放由同一个DLL完成。(注意:您可以在一个进程中有多个crt,但是由crt1.DLL获取的资源必须由crt1.DLL释放。)

这不是c++特有的。在C语言中,例如malloc/free, fopen/fclose调用对必须各自去一个单独的C运行时。

这可以通过以下任意一种方式完成:

  • 通过显式导出获取/释放函数(Photon的答案)。在这种情况下,您被迫使用工厂模式和抽象类型。基本上是COM或COM克隆版
  • 强制一组DLL链接到同一个动态CRT。在这种情况下,你可以安全地导出任何类型的函数/类。

还有两个"潜在的错误"(在其他错误中)您必须注意,因为它们与语言"下面"的内容有关。

第一个是std:: string是一个模板,因此它在每个翻译单元中被实例化。如果它们都链接到同一个模块(exe或dll),链接器将把相同的函数解析为相同的代码,最终不一致的代码(相同的函数具有不同的主体)被视为错误。
但是,如果它们链接到不同的模块(以及exe和dll),则没有任何共同之处(编译器和链接器)。因此-取决于模块的编译方式-您可能对具有不同成员和内存布局的同一个类有不同的实现(例如,一个可能有一些调试或分析添加的特性,另一个可能没有)。如果没有其他方法来保证实现的一致性,那么访问在一端创建的对象和在另一端编译的方法可能会以失败告终。

第二个问题(更微妙)与内存的分配/释放有关:由于windows的工作方式,每个模块都可以有一个不同的堆。但是标准c++并没有指定newdelete是如何处理对象来自哪个堆的。如果字符串缓冲区在一个模块上分配,而不是移动到另一个模块上的字符串实例,则有可能(在销毁时)将内存返回到错误的堆(这取决于new/deletemalloc/free相对于HeapAlloc/HeapFree的实现方式:这仅仅与STL实现相对于底层操作系统的"感知"级别有关)。这个操作本身并不是破坏性的——操作只是失败了——但是它泄漏了源的堆)。

尽管如此,传递容器并非不可能。由于编译器和链接器没有交叉检查的办法,所以在双方之间授予一致的实现完全取决于您。