C++ At Work 专栏...
拷贝构造和赋值操作符,C#和本机 C++ 代码的互用性
原著:Paul DiLascia
翻译:NorthTibet
下载源代码:CAtWork0509.exe (276KB)
原文出处:Copy Constructors, Assignment Operators, and More
我有一个简单的 C++ 问题。我想让我的拷贝构造函数和赋值操作做同样的事情。你能告诉我最佳实现方法吗?
Shadi Hani
乍一看,这似乎是一个答案简单的简单问题:写一个调用 operator= 的构造函数不就行了:CFoo::CFoo(const CFoo& obj)
{
*this = obj;
}
或者,写一个公用的拷贝方法,拷贝构造函数和 operator= 都调用这个方法也行。就像这样:
CFoo::CFoo(const CFoo& obj)
{
CopyObj(obj);
}
CFoo& CFoo::operator=(const CFoo& rhs)
{
CopyObj(rhs);
return *this;
}
对于大多数类来说,这是行得通的,但还有些特殊情况需要考虑。如果你的类包含有数据成员是另一个类的实例会怎样呢?为了弄清楚这个问题,我写了一个测试程序如
Figure 1 所示。它有一个主类 CMainClass,它包含另一个类 CMember 的实例。两个类都有拷贝构造函数和赋值操作,用 CMainClass
的拷贝构造函数调用 operator=,如下面的代码段所示。代码中使用 printf 语句是为了显示何时调用了哪个方法。为了运行构造函数,cctest
程序首先用缺省构造函数创建 CMainClass 实例,然后用拷贝构造函数创建另一个实例:
CMainClass obj1;
CMainClass obj2(obj1);
如果你编译并运行 cctest,当构造 obj2 时,你会看到下面的 printf 信息:
CMember: default ctor
CMainClass: copy-ctor
CMainClass: operator=
CMember: operator=
成员对象 m_obj 被初始化了两次!第一次是缺省构造,第二次是赋值时再次被初始化。嘿,这是怎么回事?
在 C++
中,赋值和拷贝是不同的,因为拷贝构造函数是对未初始化的内存进行初始化操作,而赋值是对现有的已经初始化的对象进行操作。如果你的类包含其它的类实例作为数据成员,那么拷贝构造在调用
operator=之前必须首先构造这些数据成员。其结果是致使这些成员就像 cctest
那样被初始化两次,明白了吗?当你用赋值操作而不是初始化例程进行成员初始化时,缺省构造函数也会发生同样的事情。例如:
CFoo::CFoo()
{
m_obj = DEFAULT;
}
与下面代码相对:
CFoo::CFoo() : m_obj(DEFAULT)
{
}
使用赋值方式,m_obj 被初始化两次,而用初始化例程语法,m_obj
只被初始化一次。所以,要如何避免拷贝构造期间额外的初始化呢?当它与你的代码重用初衷相抵触时,
最好的解决俄u方法就是分开实现拷贝构造和赋值操作,即便它们做同样的事情。从拷贝构造中调用 operator=
肯定能行得通,但不是最有效率的实现。我对初始化的建议是:
CFoo::CFoo(const CFoo& rhs) : m_obj(rhs.m_obj) {}
现在,主拷贝构造用初始化例程调用成员对象的拷贝构造,并且 m_obj
只被其拷贝构造初始化一次。通常情况下,拷贝构造应该调用其成员的拷贝构造。赋值也是如此。并且,它也同样适用于基类:派生类的拷贝构造和赋值操作应该调用对应的基类方法。当然,有时因为一些具体情况,可能你的做法会有所不同——这里我所描述的是通用规则,只有在你遇到强制性原因时才会破坏这个规则。如果你要在基本对象被初始化之后完成一些公共任务,可以将它们放到一个公共的初始化方法中,并在构造函数和
operator= 中调用。
你能告诉我如何从 C# 中调用 Visual C++ 类,对此我需要什么样的语法?
Sunil Peddi
我有一个用 C#(用户界面)和经典的 C++(业务逻辑)写的应用程序。现在我需要从某个用 C++ 写的
DLL中调用一个函数(或方法),该函数在一个用 Visual C++ .NET 编写的 DLL 中。而这个 Visual C++
.NET DLL 又要调用另一个用 C# 写的 DLL。Visual C++ .NET DLL 相当于一个代理。这样做可行吗?我能用 LoadLibrary
调用 Visual C++ .NET DLL 输出的函数,可以得到返回值,但当我试图向 Visual C++ .NET DLL
中的函数传递参数时,我遇到如下错误:Run-Time Error Check Failure #0—The value of ESP was not properly saved
across a function call. This is usually a result of calling a function
declared with one calling convention with a function pointer declared
with a different calling convention.
我如何解决这个问题?
Giuseppe Dattilo
我得到许多关于 .NET 框架和本机 C++ 之间的互操作问题,所以我不介意再次复习这个(well-covered)主题。有两条路可走:从
C++ 中调用框架;或者从框架调用 C++。我不打算在此涉及 COM 的互用性,我把它放在以后单独的一期专栏里讨论。
让我先从最简单的一种开始:从 C++ 调用框架。从 C++ 程序中调用框架最简单,最轻松的方法是使用托管扩展(Managed
Extensions)。这是微软专用的 C++ 语言扩展,它被设计专门用来调用框架,只要包含两个头文件即可,然后象使用 C++
类一样来使用它们。下面是一个非常简单的调用框架 Console 类的 C++ 程序:#using <mscorlib.dll>
#using <System.dll> // implied
using namespace System;
void main()
{
Console::WriteLine("Hello, world");
}
为了使用托管扩展,你只需引入 <mscorlib.dll> 和你打算使用的框架类所附着的程序集。不要忘了用 /clr 编译。
cl /clr hello.cpp
你的 C++ 代码可以或多或少地使用托管类,就像普通的 C++ 类一样。例如,你可以用操作符 new 创建框架对象,并用
C++ 指针语法存取它们,象下面这样:
DateTime d = DateTime::Now;
String* s = String::Format("The date is {0}\n", d.ToString());
Console::WriteLine(s);
Console::WriteLine(s->Length);
这里,String s 被声明为 String 指针,因为 String::Format 返回一个新的 String 对象。
“Hello,world”和日期/时间程序似乎很简单——它们确实简单——不过要记住不管你的程序多复杂,使用的类和 .NET
程序集有多少,其基本思路是一样的:用 <mscorlib.dll> 以及其它所需的程序集,然后用 new
创建托管对象,并使用指针语法来存取它们。
以上讨论的是如何从 C++ 调用框架。那么反过来从框架调用 C++ 该如何做呢?根据你是否想调用外部 C函数或 C++
类成员函数,有两个选择。我们还是首先从最简单的开始:从 .NET 调用 C 函数。最轻松的方法是使用 P/Invoke。使用
P/Invoke,你将外部函数声明为某个类的静态成员,用 DLLImport 来指定外部 DLL 中的函数。在 C# 是这样做的:
public class Win32 {
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern int MessageBox(IntPtr hWnd, String text, String caption, int type);
}
这段代码告诉编译器 MessageBox 是 user32.dll 中的一个函数,参数是 IntPtr (HWND),两个
String 和一个 int。这样你便可以在 C# 程序中调用:
Win32.MessageBox(0, "Hello World", "Platform Invoke Sample", 0);
当然,使用 MessageBox 你不必通过 P/Invoke,因为 .NET 框架已经具备一个 MessageBox 类,但是大量的
API 函数框架是不直接支持的,调用这些函数时需要 P/Invoke。并且,你还可以用 P/Invoke 调用自己 DLL中输出的 C
函数。尽管在例子中我用的是 C#,但 P/Invoke 支持任何基于 .NET 的语言,如:Visual Basic .NET 或 JScript.NET。函数名称都相同,只是语法有差别。
注意我用 IntPtr 来声明 HWND。尽管使用 int 也可能行,但对于任何象 HWND,HANDLE 或 HDC
这样的句柄,你应该始终用 IntPtr,根据平台的不同,IntPtr 会默认为 32 位或 64 位,所以你根本不用担心句柄的大小。
DllImport 具备各种修饰符,你可以用来说明有关引入函数的细节。在上面的例子中,CharSet=CharSet.Auto
告诉框架根据目标操作系统的具体情况,将 String 作为 Unicode 或 Ansi 来传递。另一个鲜为人知的修饰符是 CallingConvention,回想一下在
C 语言中,我们会有不同的调用规范,通过这些规范来说明编译器如何在函数间通过堆栈传递参数以及返回值的规则。DllImport 默认的 CallingConvention
是 CallingConvention.Winapi。实际上,这是一个伪规范,对于目标平台来说,它用默认规范;例如, Windows 平台上的 StdCall(被调用者负责清除堆栈)以及 Windows CE .NET 上的 CDecl(调用者负责清除堆栈)。CDecl
还可以用于带有可变参数的函数,如:printf。
Giuseppe 碰到的就是调用规范的问题。C++ 还使用第三种调用规范:即 thiscall。用这种调用规范,编译器借助硬件的 ECX
寄存器来向不带可变参数的类成员函数传递“this”指针。我们对 Giuseppe 程序的细节并不了解,从出错信息来分析,他企图从使用
StdCall 规范的 C# 程序中调用使用 thiscall 规范的 C++ 函数——这样当然不行啦!
除了调用规范,另一个从框架调用 C++ 方法时存在的互用性问题是链接:C 和 C++ 使用不同形式的链接,因为 C++
需要名字修饰来支持函数重载。这就是为什么当在 C++ 程序中声明 C 函数时,你得用 extern "C":这样编译器才不会修饰函数名。在
Windows 里,整个 windows.h 文件(现在是 winuser.h)都包含在 extern "C" 里。
虽然使用 P/Invoke 和 DllImport 以及完全修饰过的名称和 CallingConvention=ThisCall
也有办法直接调用某个 DLL 中的 C++ 成员函数,但如果你是一个正常的人,不要去这么做。从托管代码中 调用 C++
类的正确方法——第二种选择——是在托管包装器中包装你的 C++ 类。如果你的类很多,包装可能很繁琐,但别无选择。假设你有一个 C++ 类
CWidget 并想包装它,以便 .NET 客户端能使用它,其基本套路如下:public __gc class Widget
{
private:
CWidget* m_pObj; // ptr to native object
public:
Widget() { m_pObj = new CWidget; }
~Widget() { delete m_pObj; }
int Method(int n) { return m_pObj->Method(n); }
// etc.
};
任何类都是这种模式:
写一个托管类(__gc)保存一个指向本地类的指针;
编写构造函数和析构函数分配和销毁对象实例;
编写对应于 C++ 成员函数的包装器方法;
你不必包装所有的成员函数,仅仅包装那些打算暴露给托管环境的函数即可。
Figure 2 所示的是一个简单完整而具体的例子。CPerson 是一个本地 C++ 类,包含人名,有两个成员函数:GetName 和 SetName,后者用于修改人名。Figure 3 所示的是 CPerson 的托管包装器。在这个例子中,我将 Get/SetName 转换为属性,这样一来,基于 .NET 的程序员就可以用属性语法。在
C# 中是这样用的:
// C# client
MPerson.Person p = new MPerson.Person("Fred");
String name = p.Name;
p.Name = "Freddie";
用不用属性纯粹是编程风格问题,我完全可以照搬本地 C++ 类的做法也输出两个方法:GetName 和 SetName。但属性给人的感觉更像
.NET。包装器类就是一个程序集,只不过与本地 DLL 链接。这是托管扩展一个很酷的特性之一:你可以直接与本地 C/C++
代码链接。如果你下载并编译我的 CPerson 例子源代码,你会发现 makefile 产生两个单独的 DLLs:person.dll 和
mperson.dll,前者实现常规的本地 DLL,后者是包装前者的托管程序集。还有两个测试程序:testcpp.exe,此为调用
person.dll 的本地 C++ 程序;testcs.exe,此为用 C# 编写的程序,它调用托管包装器 mperson.dll(它又调用本地
person.dll)。
以上我用非常简单的例子着重说明了托管和本地之间跨边界通讯的仅有的几种方法。如 Figure 4 所示:
Figure 4 互用性途径
如果 C++
类太复杂,你碰到的最大的互用性问题将会是本地和托管类型之间的参数转换问题,这个过程称为封送(marshaling)。托管扩展所做的一个令人赞誉的工作是使这一过程尽可能轻松(例如,自动转换原始数据类型和字符串
String),但有时你必须了解自己正在做什么。
例如,你不能在没有固定(pinning)住托管对象或嵌入对象的前提下,将其地址传递给本地函数。那是因为托管对象存在于托管堆中,垃圾收集器在托管堆中可以随意重整对象。如果垃圾收集器移动某个对象,它能更新所有针对该对象的托管引用——但它对托管环境以外的原始指针一无所知。那就是
__pin 的作用之所在;它告诉垃圾收集器:不要移动这个对象。对于字符串来说,框架有一个专门的函数 PtrToStringChars,返回一个被固定住的本地字符指针。(顺便提一下,对于那些好奇者来说,PtrToStringChars
是到目前为止定义在<vcclr.h>文件中的唯一一个函数)。其代码如下:
// PtrToStringChars, from vcclr.h
// get an interior gc pointer to the first character contained in a
// System::String object
//
inline const System::Char * PtrToStringChars(const System::String *s) {
const System::Byte *bp = reinterpret_cast<const System::Byte *>(s);
if( bp != 0 ) {
unsigned offset = System::Runtime::CompilerServices::
RuntimeHelpers::OffsetToStringData;
bp += offset;
}
return reinterpret_cast<const System::Char*>(bp);
}
我在 MPerson 中使用 PtrToStringChars 来设置 Name,详细代码参见
指针固定并不是你将遇到的仅有的互用性问题。如果你要处理数组,引用,结构和回调,或者存取某个对象中的嵌入对象,还会碰到其它的问题。这是一些将来要讨论的更高级的技术,如:StructLayout,框入/框出(boxing),__value
类型等等。你还需要专门的代码来处理异常(本地或托管)以及回调/委托。但不要让这些户用性细节遮住了大方向。首先确定你的调用方式(是从托管调用本地还是从本地调用托管),如果你是从托管调用本地,是使用
P/Invoke 还是包装器。
Visual Studio 2005 中(有些人已经开始用beta版了),托管扩展已更名并升级到 C++/CLI。你可以把 C++/CLI
看成是 Managed Extensions Version 2,或者是 Managed Extensions
演变成的一个什么。这个改变几乎都是语法上的,虽然也有一些重要的语义变化。总体上讲,C++/CLI
是设计用来突出而不是模糊托管和本地对象间的差异。使用托管对象的指针语法是明智的想法,但最终也许做的有些过于明智,因为它淡化了托管和本地对象之间的重要差异。C++/CLI
引入了一个处理托管对象的关键概念,CLI 处理托管对象时使用 ^(读作 hat)来代替 C 语言的指针语法:// handle to managed string
String^ s = gcnew String;
正像你已经明确注意到的,还有一个 gcnew 操作符用以来表示你是在托管堆中分配对象,而不是在本地分配。这样做有一个额外的好处是
gcnew 不会与 C++ 的 new 发生冲突,它能被重载或者甚至被重定义成一个宏。C++/CLI
有许多其它很棒的特性,专门用来使互用性尽可能简单明了。
您的提问和评论可发送到 Paul 的信箱:cppqa@microsoft.com.
作者简介
Paul DiLascia 是一名自由作家,顾问和 Web/UI 设计者。他是《Writing Reusable Windows Code in
C++》书(Addison-Wesley, 1992)的作者。通过 http://www.dilascia.com
可以获得更多了解。
本文出自 MSDN Magazine 的
September 2005 期刊,可通过当地报摊获得,或者最好是
本文由 VCKBASE MTT 翻译