在 C++Builder 工程里使用 Visual C++ DLL——第2部分:C++ 类
shadowstar's home: http://shadowstar.126.com/
source:http://www.bcbdev.com/articles/vcdll2.htm
注意:这篇文章描述如何把 C++ 类从 Visual C++ DLL 引入到 BCB 的工程中。在我们开始之前,我觉得必须给出一点警告。这篇文章不是真的准备大量发布的。如果“文章”跌宕起伏,难以阅读,或包含错误,我道赚!我没有时间去改良它。我决定继续并发布的唯一原因是因为很多开发者问到怎么处理这个问题。我认为,一篇写的很烂的文章总比什么都没有好。我希望这个不连贯概念的搜集品会给你带来帮助。
在上一篇文章如何“在 C++Builder 工程里使用 Visual C++ DLL”中,我描述了如何为 MSVC DLL 创建一个 Borland 兼容的引入库。主要的难点在于 MSVC 和 Borland 使用的函数命名格式不同。举例来说,Borland 认为 __cdecl 函数在它们的开头有一个下划线,但 MSVC 没有。幸运的是,你可以用 Borland 命令行实用工具克服名称的不同,这些工具有 TDUMP、IMPLIB、IMPDEF、和 COFF2OMF。方法是用这些命令行工具创建一个带有 Borland 兼容函数名的 Borland 兼容引入库。一旦你拥有了 Borland 兼容引入库,你便可以开始工作了。你可以简单的连接引入库来使用 MSVC DLL。
不幸地,这种策略不能完全带你走出这片森林。在上一篇 DLL 文章的结尾,我丢下了一个小炸弹。你只能调用 MSVC DLL 里简单的 C 函数,而不能引入类或类成员函数。Doh!
那么如果你需要从 MSVC DLL 引入 C++ 类要做些什么呢?啊……这个,如果是那样的话,你就被关到角落里了,没有多少可选择的余地(通常在你退到角落里的时候,你的选项都不是令人满意的)。这篇文描述了三种可以带你走出角落的方法。
坏消息:当你准备花点时间研究这篇垃圾的时候,我觉得,再次,被迫发出警告。所有三种技术需要你有 Microsoft Visual C++。你不需要有要调用的 DLL 的源代码,但你需要有可以调它的工具。三种技术都或多或少使用包装技术,我们用 MSVC 把 DLL 包装成可以在 BCB 里使用的某种形式。
三种技术摘要
Ok, 现丑了。这就是那三种技术。
用 MSVC 创建一个 DLL,把 C++ 类包裹成简单的 C 函数。简单的 C 函数是可以在 BCB 里引入的。
用 MSVC 创建一个 COM 对象,把 C++ 类经过限制包装。BCB 可以作为 COM 客户端来调用 VC++ COM 对象。
把 C++ 类用抽象类包装起来,这个抽象类只带有一些没有实体的虚函数。这从本质上说还是 COM,只是没有了难看的部分。
下面描述各种技术的更多详细内容。在每一个例子中,我们将假定 MSVC DLL 导出的类形式如下:
class CFoo
{
public:
CFoo(int x);
~CFoo();
int DoSomething(int y);
};
技术 1: 把 C++ 类包裹到 C 库里
在前一篇有关 VC++ DLL 的文章里,我们知道在一个 Borland 工程里调用从一个 MSVC DLL 导出的简单的 C 函数是可能的。利用这条信息可知,我们可以在 MSVC 里创建一个 DLL 工程,来导出简单的 C 函数给 BCB 用。这个 MSVC 包裹的 DLL 会作为 C++ DLL 的客户端。包裹 DLL 将导出简单的 C 函数,以创建的 CFoo 对象调,调用 CFoo 成员函数,和销毁 CFoo 对象。
CFoo 类包含三个我们关心的函数:构造函数,析构函数,和所有重要的 DoSomething 函数。我们需要把每一个函数包裹成与其等价的 C 函数。
// original class
class CFoo
{
public:
CFoo(int x);
~CFoo();
int DoSomething(int y);
};
// flattened C code
void* __stdcall new_CFoo(int x)
{
return new CFoo(x);
}
int __stdcall CFoo_DoSomething(void* handle, int y)
{
CFoo *foo = reinterpret_cast<CFoo *>(handle);
return foo->DoSomething(y);
}
void __stdcall delete_CFoo(void *handle)
{
CFoo *foo = reinterpret_cast<CFoo *>(handle);
delete foo;
}
这里有几个比较重要的地方要注意。首先,注意每一个 C++ 成员函数被映射为一个简单的 C 函数。其次,观察到我们为 C 函数明确地使用 __stdcall 调用习惯。在前一篇 DLL 文章里,我们知道简单的调用在 MSVC DLL 里的无格式 C 函数,真是很麻烦。如果我们放弃越过种种艰难困苦去用它,我们可以使这个努力稍微容易一点。让 Borland 调用 Microsoft DLL 最简单的办法是 DLL 导出无格式,无修饰,__stdcall 调用习惯的 C 函数。Borland 和 Microsoft 对 __cdecl 函数的处理上是不同的。通常,他们对 __stdcall 函数也不同,因为 MSVC 修饰 __stdcall 函数,但我们可以通过添加一个 DEF 文件到 MSVC 工程里来阻止这种行为。参见下载部分的例子有 DEF 文件的例子。
其它关于代码要注意的事情是 new_CFoo 函数返回一个指向 CFoo 对象的指针。BCB 调用者必须在本地保存这个指针。这可能看起来和这篇文章的主题有点矛盾。毕竟,我想 BCB 不能使用来自 MSVC DLL 的 C++ 对象?如果那是正确的,那么为什么我们还要返回一个 CFoo 对象指针呢?
答案是 BCB 不能调用 MSVC DLL 导出类的成员函数。但是,这并不意味着它不能存储这样对象的地址。new_CFoo 返回的是一个 CFoo 对象的指针。BCB 客户端可以存储这个指针,但不能用。BCB 不能废弃它(不应当尝试这么做)。让这个观点更容易理解一点,new_CFoo 返回一个空指针(总之它不能返回别的什么东西)。在 BCB 这边,除了存储它,然后把它传回给 DLL,没有什么可以安全地处理这个空指针的方法。
Ok,在我们继续前进之前,还有另外两个要十分注意的地方。首先,注意 CFoo_DoSomething 把空指针作为它的第一个参数。这个空指针与 new_CFoo 返回的是同一个空指针。空指针用 reinterpret_cast 被追溯到 CFoo 对象(你知道,当你看到一个 reinterpret_cast 的时候,你正在处理是难看的代码)。DoSomething 成员函数在转换之后被调用。最后,注意空指针也是 delete_CFoo 函数的参数。包装 DLL 删除对象是至关紧要的。你不应当在 BCB 里对空指针调用 delete。显然它不会按你想的去做。
下面的程序清单展示了 C 函数的 DLL 头文件。这个头文件可以在 MSVC 和 BCB 之间共享。
// DLL header file
#ifndef DLL_H
#define DLL_H
#ifdef BUILD_DLL
#define DLLAPI __declspec(dllexport)
#else
#define DLLAPI __declspec(dllimport)
#else
#ifdef __cplusplus
extern "C" {
#endif
DLLAPI void* __stdcall new_CFoo(int x);
DLLAPI int __stdcall CFoo_DoSomething(void* handle, int y);
DLLAPI void __stdcall delete_CFoo(void *handle);
#ifdef __cplusplus
}
#endif
#endif
这是一个典型的 DLL 头文件。注意到一个令人好奇的事情,在头文件里看不到 CFoo 类。头文件仅包含用以包装 CFoo 的无格式 C 函数。
下面的程序清单展示了如何在 BCB 里调用 DLL。
#include "dll.h"
void bar()
{
int x = 10;
int y = 20;
int z;
void * foo = new_CFoo(x);
z = CFoo_DoSomething(foo, y);
delete_CFoo(foo);
}
这样就可以了。尽管不太漂亮,但还能用。事实上,不管这个技术多么奇异,在其它一些不能调用 DLL 的情形,同样可以用这种方法。举例来说,Delphi 程序员使用相同的技术,因为 Delphi 不能调用 C++ 成员函数。Delphi 程序员必须把 C++ 类包裹成 C 代码,并连接成 C OBJ 文件。开源工具 SWIG (swig.org) 被设计用来生成象这样的包装函数,在那里允许你使用类似 Python 的角本语言调用 C++ 对象。
技术 2: 创建 COM 包装
不幸地,我还没有这种技术的例子(嗨,我说过这篇文章不是为黄金时段准备的)。但这个主意是这样工作的。在 MSVC 里创建一个 COM 对象。或许你可以运行向导。创建一个进程内服务器(如 DLL,不是 EXE)。同样,确认你创建了一个 COM 对象,而不是自动控制对象。自动控制只会是使每一件事更困难。除非你也需要在 VB 或 ASP 页面用 C++ 类,那也可以用无格式 COM,而不用自动控制。
在 COM 工程内部,创建一个新的 COM 对象。MSVC 大概想让你创建一个 COM 接口。既然我们正在包装一个称做 CFoo 的类,一个好的接口名应当是 IFoo。MSVC 也会让你为执行类的 COM 对象命名。CFooImpl 是一个不错的候选者。
COM 对象应当用聚合包装 C++ DLL 类。换句话说,COM 对象包含 CFoo 成员变量。不要设法从 CFoo 继承你的 COM 类。对每一个 C++ DLL 类(CFoo)的成员函数,在你的 COM 对象里创建一个类似的函数。如果可能的话,用相同的名字,传递相同的参数,返回相同类型的值。你需要调整一些事情。比如,字符串在 COM 里通常被传递为 BSTR。同样,返回值被特别地传递为输出参数,因为 COM 方法应当返回一个错误代码。当你做完这些,C++ 类的每一个成员函数在 COM 包装里应当有一个相应的函数。
在你 build COM 包装之后,用 regsrv32.exe 注册它。一旦注册,你应当能例示这个 COM 对象,并且用 BCB 代码调用它包装的成员函数。
再一次,我为上面介绍的这种技术没有可运行的演示道歉。
技术 3: 使用带虚函数的抽象基类(pseudo-COM)
技术 3 是一种 pseudo-COM 方法。COM 是一个二进制对象规范。COM 对象可以被 BCB 和 MSVC 调用,而不管 COM 对象是用什么编译器编译的。因此,这个二进制用什么魔法工作的呢?答案就是基于要讲的这种技术。
COM 函数调用通过函数查找表来分派。神奇地是这个函数查找表与 C++ 虚函数表用同样的方法正确地工作。事实上,他们就是相同的。COM 不过是虚函数和虚函数表的一种美称的形式。
COM 可以工作,是因为 BCB 和 MSVC 真正使用相同的虚分派系统。COM 依赖于大多数 Win32 C++ 编译器都用相同的方法生成和使用 vtable 的这个事实。因为两个编译器用相同的虚分派系统,我们就能在 MSVC 里用虚函数创建一个包装类,它可以被 BCB 调用。这正是 COM 所做的。
这是 pseudo-COM 包装类的 DLL 头文件。它包括一个抽象基类,IFoo,它服务于 pseudo-COM 接口。它还包括两个 C 函数,用来创建和删除 IFoo 对象。这个头文件在 MSVC 和 BCB 之间共享。
#ifndef DLL_H
#define DLL_H
#ifdef BUILD_DLL
#define DLLAPI __declspec(dllexport)
#else
#define DLLAPI __declspec(dllimport)
#endif
// psuedo COM interface
class IFoo
{
public:
virtual int __stdcall DoSomething(int x) = 0;
virtual __stdcall ~IFoo() = 0;
};
#ifdef __cplusplus
extern "C" {
#endif
DLLAPI IFoo* __stdcall new_IFoo(int x);
DLLAPI void __stdcall delete_IFoo(IFoo *f);
#ifdef __cplusplus
}
#endif
#endif
注意到两个 C 函数类似技术 1 的函数,除了现在它们与 IFoo 合作,而不是空指针。这种技术比第一种提供了更多的类型安全。
这里是 MSVC 包装的源代码。它包括一个从 IFoo 继承而来的称作 CFooImpl 的类。CFooImpl 是 IFoo 接口的实现。
#define BUILD_DLL
#include "dll.h"
IFoo::~IFoo()
{
// must implement base class destructor
// even if its abstract
}
// Note: we declare the class here because no one outside needs to be concerned
// with it.
class CFooImpl : public IFoo
{
private:
CFoo m_Foo; // the real C++ class from the existing MSVC C++ DLL
public:
CFooImpl(int x);
virtual ~CFooImpl();
virtual int __stdcall DoSomething(int x);
};
CFooImpl::CFooImpl(int x)
: m_Foo(x)
{
}
int __stdcall CFooImpl::DoSomething(int x)
{
return m_Foo.DoSomething(x);
}
CFooImpl::~CFooImpl()
{
}
IFoo * __stdcall new_IFoo(int x)
{
return new CFooImpl(x);
}
void __stdcall delete_IFoo(IFoo *f)
{
delete f;
}
这儿有许多好的素材资料。首先,注意到现在我们有一个类在 BCB 和 MSVC 之间共享的头文件。好象是一件好事。更重要的是,注意到 BCB 工程将只与 IFoo 类打交道。真正的 IFoo 实现由叫做 CFooImpl 的派生类提供,那是在 MSVC 工程内部。
BCB 客户端代码将与 IFoo 对象以多态性合作。要得到一个包装实例,BCB 代码可以调用 new_IFoo 函数。new_IFoo 的工作像一个函数工厂,提供新的 IFoo 实例。new_Foo 返回一个指向 IFoo 实例的指针。然而,指针是多态的。指针的静态类型是 IFoo,但它实际的动态类型将被指向 CFooImpl(BCB 代码是不知道真相的)。
这是 BCB 客户端的代码。
#include "dll.h"
void bar()
{
int x = 10;
int y = 20;
int z;
IFoo *foo = new_IFoo(x);
z = foo->DoSomething(y);
delete_IFoo(foo);
}
现在给出在技术 3 上某些部分的注释。第一,至关紧要的是你从 MSVC DLL 里删除 IFoo 指针。这个由调用 delete_IFoo 函数传递 IFoo 指针完成。不要尝试从 BCB 里删除对象。
void bar()
{
IFoo *foo = new_IFoo(x);
delete foo; // BOOM!!!
}
这段代码将在痛苦中死去。问题是 IFoo 是被在 MSVC 包装 DLL 里的 new_IFoo 函数创建的。同样地,IFoo 对象占的内存是被 MSVC 内存管理器分配的。当你删除一个对象时,只有权删除和它用同一个内存管理器创建的对象。如果你在 BCB 这边对指针调用 delete,那么你是用 Borland 内存管理器删除。现在,我可能错了,但是我愿意拿我的房子和一个生殖器打赌,要么二个,不能企图让 Microsoft 内存管理器和 Borland 内存管理器一起工作。当你用 Borland 内存管理器删除指针的时候,难道它会尝试联系 Microsoft 内存管理器,让它知道它应当释放的哪些内存?
另外解释一项,BCB 代码完全按照 IFoo 虚函数接口工作。在 BCB 这边你看不到任何 CFooImpl 类的事件。CFooImpl 在 MSVC 包装工程的内存。当你从 BCB 这边调用 DoSomething 的时候,调用通过虚函数表被分派到 CFooImpl。
如果你在这个概念上理解有困难的话,不要着急。我或许没有把它描述的很好。下面的内容可以帮助理解,在 BCB 这边,你可以用 CPU viewer 单步跟踪代码。它允许你单步跟踪每一条汇编指令,看看 vtable 是怎么进行查找工作的。
注意:
如果你使用这种 pseudo-COM 技术,确定你没有尝试重载虚函数。换句话说,不要创建象这样的接口:
class IFoo
{
public:
virtual int __stdcall DoSomething(int x) = 0;
virtual int __stdcall DoSomething(float x) = 0;
virtual int __stdcall DoSomething(const char *x) = 0;
};
不应当重载虚接口函数的原因是 MSVC 和 BCB 在 vtable 上不可能(或许不会)制定相同的方法。当我试验重载时,在 BCB 这边调用 DoSomething(int),在 MSVC 那边象是分派到 DoSomething(float)。Borland 和 Microsoft 在 vtable 格式上不重载的时候是一致的。这可能解释了为什么 COM 对象不使用重载函数。
If you need to wrap a C++ class with overloaded functions, then you should create a distinct function name for each one.
class IFoo
{
public:
virtual int __stdcall DoSomething_int (int x) = 0;
virtual int __stdcall DoSomething_float(float x) = 0;
virtual int __stdcall DoSomething_str (const char *x) = 0;
};
结论:
Ok, 我们到哪儿了?啊,在文章开始,我们讲了关于为什么 BCB 不能调用 DLL 里的 C++ 成员函数,如果 DLL 是被 MSVC 编译的。原因就是两种编译器在成员函数命名上不一致。我们讨论了三种(有点讨厌)工作方法。每一种工作方法由一个为 C++ DLL 而建立的 MSVC 包装 DLL。包装 DLL 用一些 BCB 可以理解的格式揭露 C++ 类。第一种技术把每一个 C++ 类的成员函数包裹成无格式的 C 函数。第二种技术把每一个成员函数映射成 COM 对象的成员。最后一种技术依赖虚函数是按查找表分派而不是名称的事实。在这种策略里,每一个 C++ 成员函数被映射成一个抽象类的虚函数。
下载部分包括这篇文章的例子代码。第一个下载包含原始的 MSVC C++ DLL,我们设法与它合作。三种技术的每一个例程使用相同的 DLL。仍就没有为技术 2 准备例子。
下载
为这篇文章的下载
VC++ 5 DLL 工程,导出 C++ CFoo 类
技术 1 的代码, 把类包裹成 C 函数
技术 3 的代码,虚函数/抽象基类包装