网站首页 > 文章精选 正文
作者:Chad Austin, 2002.02.15
翻译自
:https://chadaustin.me/cppinterface.html
概述
本文阐述如何创建可跨多编译器和不同编译配置(Release,Debug... etc.)的C++ DLL APIs.
背景
许多平台对于平台上偏好的编程语言存在ABI(
Application-Binary-Interface,应用程序二进制接口)。例如, BeOS系统的主要编程语言是C++,因此C++编译器必须能够生成与操作系统的C++系统调用(和类,etc.)保持二进制兼容的代码 .
Windows API和ABI是为C语言定义, 所以 C++编译器编写者可以随心所欲地实现C++ ABI\. 然而,Microsoft最终为windows创建了面向对象的ABI叫做COM. 为了简化 COM 使用, 他们使C++ ABI的虚表与COM接口要求的虚表相匹配. 由于不能使用COM的Windows编译器十分有限, 其他编译器供应商也强制要求COM虚表与C++虚表之间相映射关系.
ABI涉及几个方面. 本文只讨论在Windows上使用C++的问题。其他平台有不通的要求。(幸运的是,由于其他大部分平台并没有Windows那样流行,他们只有1或2个款编译器,因此不会有太大问题。)
概念
- ABI - 应用程序二进制接口. 系统之间的二进制接口.如果一个二进制接口变化,则双方的接口(用户和实现)必须重新编译。
- API - 应用程序接口. 系统之间的源接口。如果一个源接口发生变化,使用这个接口的代码必须相应修改。 API变化通常意味着ABI改变.
- Interface - 一个类包含的每个方法是纯虚的,且因此没有固有实现。一个接口仅仅是不通对象之间通信的一个协议。
- Factory - 工厂是某种用于创建对象的地方。在本文中,我们用单个全局函数作为我们的工厂。
- DLL Boundary - 实例化于DLL内部的代码与调用进程内部的代码之间的界线叫做DLL边界. 在某些场景下,代码可能存在边界两侧:例如,考虑一个位于头文件中的inline函数,它用于DLL和可执行程序中。这个函数实际上在边界两侧都进行了实例化。 因此,如果一个inline函数有一个静态变量,这种情况会创建两个变量, 一个在可行性程序中,另一个一个在DLL中, 而哪个变量被使用取决于是DLL还是可执行程序中的代码在调用该函数。
初次尝试
让我们假设你想创建一套可移植的windowing API,而且你想坚持在DLL实现。我将创建一个叫做Window的类,它代表在几个不通窗口系统上一个窗口:例如Win32,MFC,wxWindows, Qt, Gtk, Aqua, X11, Swing (*gasp*),等等。 我们将尝试创建一个接口,直到它能够跨不同的实现、编译器和编译配置工作。
// Window.h
#include <string>
#ifdef WIN32
#ifdef EXPORTING
#define DLLIMPORT __declspec(dllexport)
#else
#define DLLIMPORT __declspec(dllimport)
#endif
#define CALL __stdcall
#else
#define DLLIMPORT
#define CALL
#endif
class DLLIMPORT Window {
public:
Window(std::string title);
~Window();
void setTitle(std::string title);
std::string getTitle();
// ...
private:
HWND m_window;
};
我不打算展示它的实现,因为我假设你已经知道如何实现。这个接口有一个明显的问题:它假设您使用的是基本的Win32 API。也就是说,它持有一个HWND作为私有成员,这在Window类和Win32SDK之间引入了依赖关系。一种可能的解决方案是使用pImpl习惯用法从类定义中删除类的私有成员。您可以在[1]、[2]、[3]和[4]的其他地方阅读更多关于这方面的内容。此外,由于类的大小会发生变化,你不能在不破坏二进制兼容性的情况下向类中添加新成员。 此外
也许这方法最重要的问题是它的方法是非虚的。因此,他们被实现为以‘this’指针作为它们第一个参数的特殊命名的函数。不幸的是,我并不知道有哪两类编译器使用了相同方式修改(mangle)方法名。因此,不要以为你的DLL可以与另一个编译器编译的可执行文件一起工作。!
第二次尝试 #2
对于那些在面向对象编程方面有经验的人来说,您知道每个类都可以分为两个概念:接口和工厂。工厂是一种创建对象的机制,接口允许您与它们进行通信。下一版本的Window.h将分离这些概念。请注意,您不再需要导出该类(但必须导出工厂函数!),因为它是抽象的:所有方法调用都通过对象的vtable,而不是直接链接到DLL。只有对工厂函数的调用是直接调用了DLL。
// Window.h
#include <string>
class Window {
public:
virtual ~Window() { }
virtual void setTitle(std::string title) = 0;
virtual std::string getTitle() = 0;
};
Window* DLLIMPORT CreateWindow(std::string title);
这好多了。使用窗口对象的代码不再关心窗口对象的实际类型,只是它实现了window接口。然而,仍然存在一个问题:不同的编译器对符号名称的处理方式不同,因此不同编译器生成的DLL中的CreateWindow函数将具有不同的名称。这意味着,如果使用Visual C++6编译窗口DLL,则无法在Borland C++中使用它,反之亦然。幸运的是,C++标准允许我们通过外部“C”禁用对指定名称的符号篡改。
你们中的一些人可能注意到了此代码的另一个问题。不同的编译器实现标准C++库的方式不同。在不太明显的情况下,有些人会用另一个(例如STLPort)替换编译器对库的实现。由于不能依赖STL对象在编译器之间是二进制兼容的,因此不能在DLL接口中安全地使用它们(STL对象)。
如果一个C++ABI是为Windows创建的,那么它将需要精确地指定如何与标准库中的每个类连接,但我认为这不会很快发生。
这里的最后一个问题是一个小问题。按照约定,COM方法和DLL函数使用__stdcall调用约定。我们可以用我上面定义的CALL宏来解决这个问题。(您需要在项目中重命名它。)
修订版 3
// Window.h
class Window {
public:
virtual ~Window() { }
virtual void CALL setTitle(const char* title) = 0;
virtual const char* CALL getTitle() = 0;
};
extern "C" Window* CALL CreateWindow(const char* title);
我们已经差不多了!这个特殊的接口可能在很多情况下都能工作。然而,虚析构函数使事情有点有趣...... 由于COM不使用虚析构函数,因此不能依赖不同的编译器来同样地使用它们。但是,你可以用虚方法替换虚析构函数,在实现类中,该方法通过‘delete this’来实现;这样,构造和析构都在DLL边界的同一侧实现。例如,如果您尝试将VC++6 Debug DLL与Release的可执行文件一起使用,则可能会崩溃或遇到类似“ESP值未在函数调用中保存”的警告。发生此错误是因为VC++运行库的调试版本与发布版本具有不同的分配器。由于两个分配器不兼容,我们不能在DLL边界的一侧分配内存,而在另一侧删除内存。
“但虚析构函数与另一个虚方法有何不同?”虚析构函数不负责释放对象所使用的内存:它们只是在对象被释放之前执行必要的清理。使用DLL的可执行文件将尝试释放对象本身的内存。另一方面,destroy()方法负责释放内存,因此所有new和delete调用都停留在DLL边界的同一侧。
将接口的析构函数设置为受保护的也是一个好主意,这样接口的用户就不会在无意中对其使用delete。
修订版 4
// Window.h
class Window {
protected:
~Window() { } // use destroy()
public:
virtual void CALL destroy() = 0;
virtual void CALL setTitle(const char* title) = 0;
virtual const char* CALL getTitle() = 0;
};
extern "C" Window* CreateWindow(const char* title);
由于这段代码不使用任何COM未定义的语义,因此它应该在编译器和配置设置之间完美地工作。不幸的是,这并不理想。你必须记住使用object->destroy();来删除对象,这远不如delete object;那么直观。也许更重要的是,你不能再对这种类型的对象使用std::auto_ptr。auto_ptr希望使用delete object;删除它所持有的对象。是否有一种方法使语法delete object;实际上调用object->destroy();?是的。这就是事情变得有点奇怪的地方……您可以重载接口的delete操作符,并让它调用destroy()。因为operator delete接受一个void指针,你必须假设你从来没有在任何不是Window的东西上调用Window::operator delete。这是一个相当安全的假设。这是运算符的实现:
...
void operator delete(void* p) {
if (p) {
Window* w = static_cast<Window*>(p);
w->destroy();
}
}
...
看起来不错……现在你可以再次使用auto_ptr,且仍然拥有稳定的二进制接口。当你重新编译和测试你的新代码时(你正在测试,对吗??),你会注意到在WindowImpl::destroy发生栈溢出!这是怎么回事呢?如果您还记得destroy方法是如何实现的,就会看到它只是简单地执行delete this;。由于接口重载operator delete, WindowImpl::destroy调用Window::operator delete,而它又调用WindowImpl::destroy…如此无限递归。这个特殊问题的解决方案是重载实现类中的delete操作符来调用全局delete操作符:
...
void operator delete(void* p) {
::operator delete(p);
}
...
收尾工作
如果您的系统有很多接口和实现,您会发现需要某种方法来自动取消定义delete操作符。幸运的是,这也是可能的。只需创建一个名为DefaultDelete的模板类,并从类DefaultDelete<I>派生实现类,而不是从接口I派生实现类。下面是DefaultDelete的定义:
template<typename T>
class DefaultDelete : public T {
public:
void operator delete(void* p) {
::operator delete(p);
}
};
最终实现
下面是最终版本的代码。
// Window.h
class Window {
public:
virtual void CALL destroy() = 0;
virtual void CALL setTitle(const char* title) = 0;
virtual const char* CALL getTitle() = 0;
void operator delete(void* p) {
if (p) {
Window* w = static_cast<Window*>(p);
w->destroy();
}
}
};
extern "C" Window* CALL CreateWindow(const char* title);
// Window.cpp
#include <string>
#include <windows.h>
#include "DefaultDelete.h"
class WindowImpl : public DefaultDelete<Window> {
public:
WindowImpl(HWND window) {
m_window = window;
}
~WindowImpl() {
DestroyWindow(m_window);
}
void CALL destroy() {
delete this;
}
void CALL setTitle(const char* title) {
SetWindowText(m_window, title);
}
const char* CALL getTitle() {
char title[512];
GetWindowText(m_window, title, 512);
m_title = title; // save the title past the call
return m_title.c_str();
}
private:
HWND window;
std::string m_title;
};
Window* CALL CreateWindow(const char* title) {
// create the Win32 window object
HWND window = ::CreateWindow(..., title, ...);
return (window ? new WindowImpl(window) : 0);
}
// DefaultDelete.h
template<typename T>
class DefaultDelete : public T {
public:
void operator delete(void* p) {
::operator delete(p);
}
};
总结
差不多就是这样。最后,我将列举创建C++接口时要牢记的指导原则。你可以回顾这篇文章作为参考,或者用它来巩固你的知识。
- 所有的接口类都应该是完全抽象的。每个方法都应该是纯虚的。(或内联……您可以安全地编写调用其他方法的内联方便方法。)
- 所有全局函数都应该是extern“C”,以防止不兼容的命名修改。此外,导出的函数和方法应该使用__stdcall调用约定,因为DLL函数和COM传统上使用该调用约定。这样,如果库的用户默认使用__cdecl编译,对DLL的调用仍将使用正确的约定。
- 不要使用标准c++库。
- 不要使用异常处理。
- 不要使用虚析构函数。相反,创建destroy()方法和重载操作符delete来调用destroy()。
- 不要在DLL边界的一边分配内存,而在另一边释放内存。不同的DLLs和可执行文件可以使用不同的堆来构建,使用不同的堆来分配和释放内存块肯定会导致崩溃(的原因)。例如,不要内联你的内存分配函数,这样它们就可以在可执行文件和DLL中以不同的方式构建。
- 不要在接口中使用重载方法。不同的编译器在虚表中对它们的排序是不同的。
参考文献
- STLPort 这是STL另一种实现。
- SGI 有另一个标准C++库实现。
- Corona 是一个镜像IO库使用了本文中介绍的方法.
反馈
我真的想要这篇文章的反馈。有什么地方不清楚吗?你想知道具体情况的更多细节吗?代码有错吗?发送电子邮件至aegis@aegisknight.org。
- 上一篇: C++面试题总结(二)
- 下一篇: 步步惊心,看“降魔神兵”如何克敌制胜……
猜你喜欢
- 2025-05-28 军事训练须走进“云”深处
- 2025-05-28 一文学会Eigen库
- 2025-05-28 宝鸡老师整理全国卷—抽象函数有魅力探究通法更给力
- 2025-05-28 是什么、为什么、怎么办?这里有个答案
- 2025-05-28 MySQL数据库的预处理详解
- 2025-05-28 彻底搞清楚内存泄漏的原因,如何避免内存泄漏,如何定位内存泄漏
- 2025-05-28 步步惊心,看“降魔神兵”如何克敌制胜……
- 2025-05-28 C++面试题总结(二)
- 2025-05-28 C++真的很难学好?大师告诉你程序设计要怎么做
- 2025-05-28 C++:如何正确的定义一个接口类
- 最近发表
- 标签列表
-
- newcoder (56)
- 字符串的长度是指 (45)
- drawcontours()参数说明 (60)
- unsignedshortint (59)
- postman并发请求 (47)
- python列表删除 (50)
- 左程云什么水平 (56)
- 计算机网络的拓扑结构是指() (45)
- 编程题 (64)
- postgresql默认端口 (66)
- 数据库的概念模型独立于 (48)
- 产生系统死锁的原因可能是由于 (51)
- 数据库中只存放视图的 (62)
- 在vi中退出不保存的命令是 (53)
- 哪个命令可以将普通用户转换成超级用户 (49)
- noscript标签的作用 (48)
- 联合利华网申 (49)
- swagger和postman (46)
- 结构化程序设计主要强调 (53)
- 172.1 (57)
- apipostwebsocket (47)
- 唯品会后台 (61)
- 简历助手 (56)
- offshow (61)
- mysql数据库面试题 (57)