合理尝试将 C++/CLI 确立为一流的 CLI 语言






4.40/5 (73投票s)
作者认为 C++/CLI 作为一流的 .NET 编程语言具有其独特的地位
引言
本文并非旨在美化 C++/CLI 语言语义,也不会抨击 C# 或 VB.NET 等其他语言。相反,这只是一个非微软员工,出于对该语言的热爱,所做的一次非官方尝试,旨在证实 C++/CLI 作为一流的 .NET 编程语言具有其独特的地位。新闻组和技术论坛上越来越多的问题是,既然 C# 和 VB.NET 等语言更适合此目的(考虑到这些语言只能用于编写 CLI 应用程序),为什么还要使用 C++ 来编写 .NET 应用程序?通常,此类帖子之后还会附有评论,说明 C++ 语法如何复杂和晦涩,C++ 如何成为过时语言,以及 VS.NET 设计者对 C++ 的支持不如对 C# 和 VB.NET 的支持。其中一些怀疑完全是谬误,而另一些则部分正确(特别是那些谈论缺乏设计器/智能感知/ClickOnce 支持的),但几乎所有这些保留意见都是在没有认真尝试判断 C++/CLI 作为 CLI 语言的目标的情况下提出的。希望本文能消除围绕 C++/CLI 语言规范及其在 VS.NET 语言层次结构中的作用的所有困惑、神秘和不信任。请记住,作者不为微软工作,也不受微软资助,所以从技术上讲,任何检测到的偏见都只是您过度活跃的想象力产生的虚构 ;-)
最快最简单的原生互操作
除了 C# 或 VB.NET 等其他语言中可用的 P/Invoke 机制外,C++ 还提供了一种独特的互操作机制,姑且称之为 C++ 互操作。C++ 互操作比 P/Invoke 更直观,因为您只需 #include
所需的头文件,链接所需的库,然后像在原生 C++ 中一样调用任何函数。它也比 P/Invoke *快得多*——这很容易验证。现在,可以争辩说,在实际应用程序中,通过 C++ 互操作获得的性能优势可能相对于用户 GUI 交互、数据库访问、网络数据传输、复杂算术算法等所花费的时间来说微不足道,但事实是,在某些情况下,每次互操作调用哪怕只获得几纳秒的性能提升,也可能对应用程序的整体性能/响应能力产生巨大影响,这种情况不能完全排除。下面我提供了两个代码片段(一个用 C# 使用 P/Invoke 编写,另一个用 C++ 使用 C++ 互操作编写),我还包括了不同迭代次数所需的时间(以毫秒为单位)。如何解释这些时间以及您认为它可能对您的应用程序产生什么影响,这取决于您。我只想指出,事实上,在大量使用原生互操作调用的情况下,C++ 代码的执行速度比 C# 代码快。
C# 程序(使用 P/Invoke)
[SuppressUnmanagedCodeSecurity]
[DllImport("kernel32.dll")]
static extern uint GetTickCount();
[SuppressUnmanagedCodeSecurity]
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern uint GetWindowsDirectory(
[Out] StringBuilder lpBuffer, uint uSize);
static void Test(int x)
{
StringBuilder sb = new StringBuilder(512);
for (int i = 0; i < x; i++)
GetWindowsDirectory(sb, 511);
}
static void DoTest(int x)
{
uint init = GetTickCount();
Test(x);
uint tot = GetTickCount() - init;
Console.WriteLine(
"Took {0} milli-seconds for {1} iterations",
tot, x);
}
static void Main(string[] args)
{
DoTest(50000);
DoTest(500000);
DoTest(1000000);
DoTest(5000000);
Console.ReadKey(true);
}
C++ 程序(使用 C++ 互操作)
void Test(int x)
{
TCHAR buff[512];
for(int i=0; i<x; i++)
GetWindowsDirectory(buff, 511);
}
void DoTest(int x)
{
DWORD init = GetTickCount();
Test(x);
DWORD tot = GetTickCount() - init;
Console::WriteLine(
"Took {0} milli-seconds for {1} iterations",
tot, x);
}
int main(array<System::String ^> ^args)
{
DoTest(50000);
DoTest(500000);
DoTest(1000000);
DoTest(5000000);
Console::ReadKey(true);
return 0;
}
速度比较
迭代 | C# 应用 | C++ 应用 |
50,000 | 61 | 10 |
500,000 | 600 | 70 |
1,000,000 | 1162 | 140 |
5,000,000 | 6369 | 721 |
性能差异真是令人惊叹!因此,如果您认真地进行原生互操作,这是一个非常好的理由来使用 C++/CLI——性能!恕我直言,对于 .NET 框架的基础类库,我被迫为我所处理的任何非平凡的非基于 Web 的 .NET 应用程序求助于原生互操作。当然,为什么我要为需要如此多原生互操作的应用程序使用 .NET 是一个完全不同的问题,其答案/原因可能不在我的控制范围内(也不在您的控制范围内)。
如果您仍然对性能优势持怀疑态度,那么还有另一个很好的理由,为什么您会想使用 C++/CLI 而不是 C# 或 VB.NET——源代码膨胀!举个例子,我在下面展示了一个 C++ 函数,它使用 IP 助手 API 枚举机器上的网络适配器,并列出与每个适配器关联的 IP 地址。
C++ 代码枚举网络适配器
void ShowAdapInfo()
{
PIP_ADAPTER_INFO pAdapterInfo = NULL;
ULONG OutBufLen = 0;
//Get the required size of the buffer
if( GetAdaptersInfo(NULL, &OutBufLen) == ERROR_BUFFER_OVERFLOW )
{
int divisor = sizeof IP_ADAPTER_INFO;
#if _MSC_VER >= 1400
if( sizeof time_t == 8 )
divisor -= 8;
#endif
pAdapterInfo = new IP_ADAPTER_INFO[OutBufLen/divisor];
//Get info for the adapters
if( GetAdaptersInfo(pAdapterInfo, &OutBufLen) != ERROR_SUCCESS )
{
//Call failed
}
else
{
int index = 0;
while(pAdapterInfo)
{
Console::WriteLine(gcnew String(pAdapterInfo->Description));
Console::WriteLine("IP Address list : ");
PIP_ADDR_STRING pIpStr = &pAdapterInfo->IpAddressList;
while(pIpStr)
{
Console::WriteLine(gcnew String(pIpStr->IpAddress.String));
pIpStr = pIpStr->Next;
}
pAdapterInfo = pAdapterInfo->Next;
Console::WriteLine();
}
}
delete[] pAdapterInfo;
}
}
现在我们来看一个使用 P/Invoke 的 C# 版本——我告诉您,这花了我将近半个小时,访问了 www.pinvoke.net 十几次,才复制/粘贴了所有必需的声明,我丝毫没有夸大其词!
使用 P/Invoke 的 C# 移植
const int MAX_ADAPTER_NAME_LENGTH = 256;
const int MAX_ADAPTER_DESCRIPTION_LENGTH = 128;
const int MAX_ADAPTER_ADDRESS_LENGTH = 8;
const int ERROR_BUFFER_OVERFLOW = 111;
const int ERROR_SUCCESS = 0;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct IP_ADDRESS_STRING
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
public string Address;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct IP_ADDR_STRING
{
public IntPtr Next;
public IP_ADDRESS_STRING IpAddress;
public IP_ADDRESS_STRING Mask;
public Int32 Context;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct IP_ADAPTER_INFO
{
public IntPtr Next;
public Int32 ComboIndex;
[MarshalAs(UnmanagedType.ByValTStr,
SizeConst = MAX_ADAPTER_NAME_LENGTH + 4)]
public string AdapterName;
[MarshalAs(UnmanagedType.ByValTStr,
SizeConst = MAX_ADAPTER_DESCRIPTION_LENGTH + 4)]
public string AdapterDescription;
public UInt32 AddressLength;
[MarshalAs(UnmanagedType.ByValArray,
SizeConst = MAX_ADAPTER_ADDRESS_LENGTH)]
public byte[] Address;
public Int32 Index;
public UInt32 Type;
public UInt32 DhcpEnabled;
public IntPtr CurrentIpAddress;
public IP_ADDR_STRING IpAddressList;
public IP_ADDR_STRING GatewayList;
public IP_ADDR_STRING DhcpServer;
public bool HaveWins;
public IP_ADDR_STRING PrimaryWinsServer;
public IP_ADDR_STRING SecondaryWinsServer;
public Int32 LeaseObtained;
public Int32 LeaseExpires;
}
[DllImport("iphlpapi.dll", CharSet = CharSet.Ansi)]
public static extern int GetAdaptersInfo(
IntPtr pAdapterInfo, ref int pBufOutLen);
static void ShowAdapInfo()
{
int OutBufLen = 0;
//Get the required size of the buffer
if( GetAdaptersInfo(IntPtr.Zero, ref OutBufLen) ==
ERROR_BUFFER_OVERFLOW )
{
IntPtr pAdapterInfo = Marshal.AllocHGlobal(OutBufLen);
//Get info for the adapters
if( GetAdaptersInfo(pAdapterInfo, ref OutBufLen) != ERROR_SUCCESS )
{
//Call failed
}
else
{
while(pAdapterInfo != IntPtr.Zero)
{
IP_ADAPTER_INFO adapinfo =
(IP_ADAPTER_INFO)Marshal.PtrToStructure(
pAdapterInfo, typeof(IP_ADAPTER_INFO));
Console.WriteLine(adapinfo.AdapterDescription);
Console.WriteLine("IP Address list : ");
IP_ADDR_STRING pIpStr = adapinfo.IpAddressList;
while (true)
{
Console.WriteLine(pIpStr.IpAddress.Address);
IntPtr pNext = pIpStr.Next;
if (pNext == IntPtr.Zero)
break;
pIpStr = (IP_ADDR_STRING)Marshal.PtrToStructure(
pNext, typeof(IP_ADDR_STRING));
}
pAdapterInfo = adapinfo.Next;
Console.WriteLine();
}
}
Marshal.FreeHGlobal(pAdapterInfo);
}
}
天哪!如果有人告诉我,复制/粘贴半打常量声明、三个结构和一个 API 方法,再加上求助于 Marshal
类的 AllocHGlobal
、FreeHGlobal
和 PtrToStructure
函数并不麻烦,那我坚决不相信你是在说实话。
栈语义和确定性析构
C++ 通过模拟栈语义为我们提供了确定性析构,如果您还没有读过,您可能想看看我关于此主题的文章: C++/CLI 中的确定性析构。简单来说,栈语义是 Dispose 模式的语法糖。但它在语义上比 C# 的 using
块语法更直观。请看下面的 C# 和 C++ 代码片段(两者做同样的事情——连接两个文件的内容并将它们写入第三个文件)。
C# 代码 - using 块语义
public static void ConcatFilestoFile(
String file1, String file2, String outfile)
{
String str;
try
{
using (StreamReader tr1 = new StreamReader(file1))
{
using (StreamReader tr2 = new StreamReader(file2))
{
using (StreamWriter sw = new StreamWriter(outfile))
{
while ((str = tr1.ReadLine()) != null)
sw.WriteLine(str);
while ((str = tr2.ReadLine()) != null)
sw.WriteLine(str);
}
}
}
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
C++ 代码 - 栈语义
static void ConcatFilestoFile(
String^ file1, String^ file2, String^ outfile)
{
String^ str;
try
{
StreamReader tr1(file1);
StreamReader tr2(file2);
StreamWriter sw(outfile);
while(str = tr1.ReadLine())
sw.WriteLine(str);
while(str = tr2.ReadLine())
sw.WriteLine(str);
}
catch(Exception^ e)
{
Console::WriteLine(e->Message);
}
}
C# 代码不仅比等效的 C++ 代码冗长,而且 using
-block 语法让程序员明确指定他希望在哪里调用 Dispose
[在 using
块的末尾],而 C++/CLI 的栈语义,编译器使用常规作用域规则来处理它。事实上,这使得修改 C# 代码比 C++ 代码更繁琐——例如,让我们修改代码,即使只存在一个输入文件也创建输出文件。请看下面修改后的 C# 和 C++ 代码片段。
修改后的 C# 代码
public static void ConcatFilestoFile(
String file1, String file2, String outfile)
{
String str;
try
{
using (StreamWriter sw = new StreamWriter(outfile))
{
try
{
using (StreamReader tr1 = new StreamReader(file1))
{
while ((str = tr1.ReadLine()) != null)
sw.WriteLine(str);
}
}
catch (Exception) { }
using (StreamReader tr2 = new StreamReader(file2))
{
while ((str = tr2.ReadLine()) != null)
sw.WriteLine(str);
}
}
}
catch (Exception e){ }
}
将 StreamWriter
的 using
块移动到顶部需要相应地重新对齐 using
块的花括号——在上述情况下显然没什么大不了,但对于非平凡的修改,这可能会非常令人困惑,并且是麻烦和潜在逻辑错误的来源。[顺便说一句,我知道单语句块不需要花括号,所以上面的块可以想象成多行块来理解我的观点——我想尽可能减少示例代码片段的长度]。
修改后的 C++ 代码
static void ConcatFilestoFile(
String^ file1, String^ file2, String^ outfile)
{
String^ str;
try
{
StreamWriter sw(outfile);
try
{
StreamReader tr1(file1);
while(str = tr1.ReadLine())
sw.WriteLine(str);
}
catch(Exception^){}
StreamReader tr2(file2);
while(str = tr2.ReadLine())
sw.WriteLine(str);
}
catch(Exception^){}
}
哇,这比我们在 C# 中做的要容易得多,不是吗?我只是将 StreamWriter
声明移到了顶部,并添加了一个额外的 try
块——仅此而已。即使在我的示例代码片段中这样的微不足道的情况,如果 C++ 中涉及的复杂性如此大幅降低,您就可以想象在处理大型项目时使用栈语义对您的编码效率的影响。
还是不相信?好的,让我们看看成员对象及其销毁。假设 CLI GC 类 R1
和 R2
都实现了 IDisposable
并具有名为 F()
的函数,以及一个 CLI GC 类 R
,它分别有一个 R1
和 R2
成员以及一个内部调用 R1
和 R2
成员上的 F()
的函数 F()
。让我们先看看 C# 实现。
可处置类层次结构的 C# 实现
class R1 : IDisposable
{
public void Dispose() { }
public void F() { }
}
class R2 : IDisposable
{
public void Dispose() { }
public void F() { }
}
class R : IDisposable
{
R1 m_r1 = new R1();
R2 m_r2 = new R2();
public void Dispose()
{
m_r1.Dispose();
m_r2.Dispose();
}
public void F()
{
m_r1.F();
m_r2.F();
}
public static void CallR()
{
using(R r = new R())
{
r.F();
}
}
}
好的——我们立即注意到几件事——IDisposable
接口必须为每个可处置类手动实现,并且对于具有 R1
和 R2
成员的类 R
,Dispose
方法还需要在其成员类上调用 Dispose
。现在让我们看看上述类集的 C++ 实现。
等效的 C++ 实现
ref class R1
{
public:
~R1(){}
void F(){}
};
ref class R2
{
public:
~R2(){}
void F(){}
};
ref class R
{
R1 m_r1;
R2 m_r2;
public:
~R(){}
void F()
{
m_r1.F();
m_r2.F();
}
static void CallR()
{
R r;
r.F();
}
};
*窃笑* 我现在就能看到你们脸上的表情!不再需要手动实现 IDisposable
(我们只需在类中放入析构函数),最棒的是——类 R
的析构函数( Dispose
方法)不必费心调用它可能拥有的任何可处置成员上的 Dispose
——它不必这样做,编译器会为所有这些生成代码。
混合类型
好的,所以 C++ 支持原生类型——它一直都这样做;C++ 支持 CLI 类型——是的,这不就是我们写这篇文章的原因吗?嗯,它还支持混合类型——带有 CLI 成员的原生类型和带有原生成员的 CLI 类型!想想它为您提供的可能性。
请注意,截至 Whidbey,混合类型实现尚未完成;据我从 Brandon、Herb 和 Ronald 的帖子中了解,Orcas 中将实现一个非常酷的类型统一模型——您可以在原生 C++ 堆上 new
/delete
CLI 类型,也可以在 CLI 堆上gcnew
/delete
原生类型。但由于这些都是 Whidbey 之后的内容,本文将不讨论统一模型,这仅供您参考。
在讨论混合类型的应用之前,我想向您展示混合类型是什么。如果您了解混合类型,请跳过接下来的几段。我将在此处逐字引用 Brandon Bray 的话:“混合类型是一种原生类或 ref 类,它要求对象成员(通过声明或继承)既分配在垃圾回收堆上,也分配在原生堆上”。因此,如果您有一个带有原生成员的托管类型,或者一个带有托管成员的原生类型,那么您就有了混合类型。VC++ Whidbey 不直接支持混合类型(统一类型模型是 Whidbey 之后的场景),但它为我们提供了库提供的变通方法来实现混合类型。让我们从一个包含托管成员的原生类型开始。
ref class R
{
public:
void F(){}
//Assume non-trivial ctor/dtor
R(){}
~R(){}
};
想象一下,为了我的例子,这个托管类型 R 有一个非平凡的构造函数和一个非平凡的析构函数。
class Native
{
private:
gcroot<R^> m_ref;
public:
Native():
m_ref(gcnew R())
{
}
~Native()
{
delete m_ref;
}
void DoF()
{
m_ref->F();
}
};
由于我不能在我的类中包含 R
成员,我使用了 gcroot
模板类(在 gcroot.h 中声明,尽管你可以 #include
vcclr.h),它封装了 System::Runtime::InteropServices::GCHandle
结构。它是一个类似智能指针的类,重载了 operator->
以返回用作模板参数的托管类型。因此在上述类中,我可以像声明了 R^
一样使用 m_ref
,你可以在 DoF
函数中看到它的作用。实际上,你可以通过使用 auto_gcroot
(类似于 std::auto_ptr
并在 msclr\auto_gcroot.h 中声明)而不是 gcroot
来节省手动 delete
调用。这是一个使用 auto_gcroot
的稍好的实现。
class NativeEx
{
private:
msclr::auto_gcroot<R^> m_ref;
public:
NativeEx() : m_ref(gcnew R())
{
}
void DoF()
{
m_ref->F();
}
};
现在我们来看反向——CLI 类中的原生成员。
ref class Managed
{
private:
Native* m_nat;
public:
Managed():m_nat(new Native())
{
}
~Managed()
{
delete m_nat;
}
!Managed()
{
delete m_nat;
#ifdef _DEBUG
throw gcnew Exception("Uh oh, finalizer got called!");
#endif
}
void DoF()
{
m_nat->DoF();
}
};
我不能将 Native
对象作为 ref
类成员,所以我需要改用 Native*
对象。我在构造函数中 new
了 Native
对象,并在析构函数和终结器中都 delete
了它(以防万一)。如果是调试构建,到达终结器也会抛出异常——这样开发人员就可以及时添加对 delete
的调用,或者为其 CLI 类型使用栈语义。奇怪的是,库团队没有为 gcroot
编写一个反类——但这并不是什么大问题,你可以自己编写。
template<typename T> ref class nativeroot
{
T* m_t;
public:
nativeroot():m_t(new T){}
nativeroot(T* t):m_t(t){}
T* operator->()
{
return m_t;
}
protected:
~nativeroot()
{
delete m_t;
}
!nativeroot()
{
delete m_t;
#ifdef _DEBUG
throw gcnew Exception("Uh oh, finalizer got called!");
#endif
}
};
这是一个相对简单的智能指针式 ref
类实现,它处理原生对象的分配/释放。对于更完整的实现,您可能想看看 Kenny Kerr 的 AutoPtr
模板结构 这里。无论如何,使用 nativeroot
模板类,我们可以将我们的 Managed
类修改如下:-
ref class ManagedEx
{
private:
nativeroot<Native> m_nat;
public:
void DoF()
{
m_nat->DoF();
}
};
好的,你可能会问,混合类型有什么大不了的!大不了的是,现在你可以以最直接的方式将基于 MFC、ATL、WTL、STL 的代码库与 .NET 框架混合在一起——只需编写混合模式代码并编译!在 DLL 中有一个 MFC 类,一个 .NET 应用程序调用这个 DLL 是一回事,而能够将 .NET 类成员添加到你的 MFC 类中,反之亦然,则是另一回事。[我与 Tom Archer 合著了一本书,关于将 MFC 与 .NET 混合——尽管我们针对的是早期的 Managed C++ 扩展—— 使用 .NET 框架扩展 MFC 应用程序,所以请假装承认我的主张,我知道这有多有用]。
举个例子,假设你有一个 MFC 对话框,它通过一个多行编辑框接受用户数据——现在,你有一个新的需求,需要显示一个只读编辑框,该编辑框将显示多行编辑框中文本的实时 md5 哈希值。你的队友抱怨他们将不得不花费数小时深入研究加密 API,你的经理担心你可能不得不购买第三方加密库;就在那时,你用你的 Anakin 声音宣布你将在 15 分钟内完成任务,从而给他们留下深刻印象。方法如下:-
将新的编辑框添加到你的对话框资源中,并添加相应的 DDX 变量。启用 /clr 编译模式,并将以下行添加到你的对话框头文件中:-
#include <msclr\auto_gcroot.h>
using namespace System::Security::Cryptography;
现在使用 auto_gcroot
模板声明一个 MD5CryptoServiceProvider
成员:-
protected:
msclr::auto_gcroot<MD5CryptoServiceProvider^> md5;
在 OnInitDialog
处理程序中,gcnew
MD5CryptoServiceProvider
成员。
md5 = gcnew MD5CryptoServiceProvider();
并为多行编辑框添加一个 EN_CHANGE
处理程序:-
void CXxxxxxDlg::OnEnChangeEdit1()
{
using namespace System;
CString str;
m_mesgedit.GetWindowText(str);
array<Byte>^ data = gcnew array<Byte>(str.GetLength());
for(int i=0; i<str.GetLength(); i++)
data[i] = static_cast<Byte>(str[i]);
array<Byte>^ hash = md5->ComputeHash(data);
CString strhash;
for each(Byte b in hash)
{
str.Format(_T("%2X "),b);
strhash += str;
}
m_md5edit.SetWindowText(strhash);
}
混合类型——我们在这里拥有的是一个 CDialog
派生类(原生)包含一个 MD5CryptoServiceProvider
成员(CLI 类型)。反过来也同样毫不费力(如前面的代码片段所示)——您可能有一个 Windows Forms 应用程序,并希望利用一个原生类库——没问题,使用上面定义的 nativeroot
模板。
托管模板
如果你参加过至少一次关于 .NET 2.0(或 C# 2.0)的技术会议,那么你一定被泛型的概念轰炸过,关于它如何避免 C++ 模板的弊端,它如何是正确的模板方式等等。好吧,假设所有这些都是正确的,C++/CLI 与任何其他 CLI 语言一样支持泛型——但它所做的却是其他 CLI 语言所没有的,那就是它还支持托管模板——意味着带模板的 ref
和 value
类。如果你以前从未使用过模板,你可能不会对此感到太多欣赏,但如果你有模板背景,并且你发现泛型,尽管它号称具有面向对象特性,但却限制了你的编码方式,那么托管模板应该会让你大大松一口气。你可以同时使用泛型和模板——事实上,可以用托管类型的模板参数实例化泛型类型(尽管由于泛型使用的运行时实例化,反向是不可能的)。稍后讨论的 STL.NET(或 STL/CLR)很好地利用了泛型与托管模板的混合。
泛型使用的子类型约束机制阻止您执行以下操作:-
generic<typename T> T Add(T t1, T t2)
{
return t1 + t2;
}
错误 C2676: 二进制 '+' : 'T' 未定义此运算符,或无法转换为预定义运算符可接受的类型
现在看相应的模板版本:-
template<typename T> T Add(T t1, T t2)
{
return t1 + t2;
}
你可以这样做:-
int x1 = 10, x2 = 20;
int xsum = Add<int>(x1, x2);
你也可以这样做:-
ref class R
{
int x;
public:
R(int n):x(n){}
R^ operator+(R^ r)
{
return gcnew R(x + r->x);
}
};
//...
R^ r1 = gcnew R(10);
R^ r2 = gcnew R(20);
R^ rsum = Add<R^>(r1, r2);
这适用于像 int
这样的原生类型,也适用于 ref
类型(只要 ref
类型有一个 + operator
)。泛型的这个缺点并非 bug 或缺陷——它就是这样设计的。泛型在运行时由任何调用程序集实例化,因此编译器无法确定是否可以对泛型参数执行特定操作,除非它符合子类型约束,因此编译器在泛型定义处进行此解析。使用泛型时的另一个障碍是它不允许您使用非类型参数。以下泛型类定义将无法编译:-
generic<typename T, int x> ref class G
{
};
错误 C2978:语法错误:预期为 'typename' 或 'class';找到类型 'int';泛型不支持非类型参数
与托管模板进行比较:-
template<typename T, int x = 0> ref class R
{
};
如果你开始感谢 C++ 为你提供了泛型和托管模板,那么请看看这个:-
template<typename T> ref class R
{
public:
void F()
{
Console::WriteLine("hey");
}
};
template<> ref class R<int>
{
public:
void F()
{
Console::WriteLine("int");
}
};
嗯,你不能用泛型做到这一点。如果你尝试,你会看到这个:-
错误 C2979:泛型不支持显式特化
您也可以在继承链中混合使用模板和泛型:-
generic<typename T> ref class Base
{
public:
void F1(T){}
};
template<typename T> ref class Derived : Base<T>
{
public:
void F2(T){}
};
//...
Derived<int> d;
d.F1(10);
d.F2(10);
哦,最后,您不能从 generic
参数类型派生 generic
类。
以下代码无法编译:-
generic<typename T> ref class R : T
{
};
错误 C3234:泛型类不能从泛型类型参数派生
模板允许你这样做(如果你还不知道的话)。
ref class Base
{
public:
void F(){}
};
generic<typename T> ref class R : T
{
};
//...
R<Base> r1;
r1.F();
所以,下次你参加某个本地用户组会议,你的本地 C# 布道者开始贬低模板时,你知道该怎么做:-)
STL/CLR
重度使用 STL 的 C++ 开发者在转向 .NET 1/1.1 时一定感到非常束手无策,许多人可能放弃并回归原生编码。从技术上讲,你可以将原生 STL 与 .NET 类型一起使用(使用 gcroot
),但结果代码效率低下,更不用说难看了:-
std::vector< gcroot<IntPtr> >* m_vec_hglobal;
//...
for each(gcroot<IntPtr> ptr in *m_vec_hglobal)
{
Marshal::FreeHGlobal(ptr);
}
据推测,VC++ 团队已经考虑到了这一点,在 Whidbey 之后,他们将以单独的 Web 下载形式提供 STL.NET(或 STL/CLR)。
你可能会问为什么?Stan Lippman 在他的 MSDN 文章中给出了 3 个原因: STL.NET Primer (MSDN)
- 可扩展性 - STL 设计将算法和容器分离到不同的领域空间 - 这意味着你有一堆容器和一堆算法,你可以在任何容器上使用算法,也可以将容器与任何算法一起使用。因此,如果你添加一个新算法,你可以将其与任何容器一起使用,同样,一个新容器也可以与任何现有算法一起使用。
- 统一性 - 所有那些拥有宝贵 STL 经验的硬核 C++ 开发者都可以重用他们的经验,而无需学习曲线。熟练使用 STL 需要时间 - 一旦你做到了,能够在 .NET 世界中使用你的技能,难道不是一个明显的优势吗?
- 性能 - STL.NET 是使用实现泛型接口的托管模板实现的。由于其核心是使用 C++ 和托管模板编写的,因此预计它将比 BCL 中可用的泛型容器具有显著的性能优势。
使用过 STL 的人不需要任何演示,所以下面的代码片段是为了那些以前没有使用过 STL 的人准备的。
vector<String^> vecstr;
vecstr.push_back("wally");
vecstr.push_back("nish");
vecstr.push_back("smitha");
vecstr.push_back("nivi");
deque<String^> deqstr;
deqstr.push_back("wally");
deqstr.push_back("nish");
deqstr.push_back("smitha");
deqstr.push_back("nivi");
我使用了两个 STL.NET 容器,vector
和 deque
,填充这两个容器的代码看起来是相同的(两者都使用了 push_back
)。现在,我将在两个容器上使用 replace
算法——同样,代码几乎完全相同。
replace(vecstr.begin(), vecstr.end(),
gcnew String("nish"), gcnew String("jambo"));
replace(deqstr.begin(), deqstr.end(),
gcnew String("nish"), gcnew String("chris"));
重要的是要注意,我对两个不同的 STL 容器使用了“相同”的算法—— replace
——使用了相同的函数调用。这就是 Stan 在谈论“可扩展性”时的意思。我将通过编写一个有史以来最无意义的函数来证明这一点:-
template<typename ForwardIterator> void Capitalize(
ForwardIterator first, ForwardIterator end)
{
for(ForwardIterator it = first; it < end; it++)
*it = (*it)->ToUpper();
}
它遍历一个 System::String^
容器,并将其中每个字符串都大写——这绝对不是那种能说服标准 C++ 委员会将其纳入下一版 STL 的算法 ;-)
Capitalize(vecstr.begin(), vecstr.end());
Capitalize(deqstr.begin(), deqstr.end());
for(vector<String^>::iterator it = vecstr.begin();
it < vecstr.end(); it++)
Console::WriteLine(*it);
Console::WriteLine();
for(deque<String^>::iterator it = deqstr.begin();
it < deqstr.end(); it++)
Console::WriteLine(*it);
我的算法——无论多么笨拙——都适用于 vector
和 deque
容器!好的,我不会再深入了,因为如果我再深入,STL 大牛们会因为我那些愚蠢的代码片段而生我的气,而那些非 STL 用户可能会感到无聊。如果你还没有使用过 STL,那就去读 Stan Lippman 的文章和/或找一本好的 STL 书籍吧。
熟悉的语法
开发者常常爱上他们的编程语言——很少是出于功能或实用的动机。还记得微软宣布不再官方支持 VB6 时,VB6 用户们的反抗吗?非 VB 用户对这种行为感到完全惊讶,在他们看来,这简直是愚蠢至极,但核心 VB 用户却准备为他们的语言而死。事实上,如果 VB.NET 从未发明,大多数 VB 用户都会远离 .NET,因为 C# 对他们来说将是完全陌生的,因为它有 C++ 的血统。许多 VB.NET 用户可能已经转向 C#,但他们不会直接从 VB6 转向;VB.NET 充当了一个渠道,让他们摆脱了 VB 的思维定式。相应地,如果微软只发布了 VB.NET(而没有 C#),.NET 可能已经成为一个新的面向对象的 VB,拥有更大的类库——C++ 社区会不屑一顾——他们甚至不会费心去查看 .NET 基础类库。为什么使用某种特定语言的开发者会鄙视使用不同语言的另一组开发者,这不是我在这里试图回答的问题——这个问题的答案也需要回答为什么有些人喜欢威士忌,另一些人喜欢可乐,还有一些人喜欢牛奶,或者为什么有些人认为艾西瓦娅·雷很漂亮,而另一些人则认为她看起来像猫带来的东西。我在这里要说的就是,就开发者而言,语法的熟悉度是一个大问题。
对于一个有 C++ 背景的人来说,你认为这样的东西有多直观?
char[] arr = new char[128];
他/她首先会想到的就是有人把括号放错了位置。那这个呢?
int y = arr.Length;
“天哪”会是一个很可能的反应。现在将其与以下内容进行比较:-
char natarr[128];
array<char>^ refarr = gcnew array<char>(128);
int y = refarr->Length;
请注意声明原生数组和托管数组之间的语法区别。独特的类似模板的语法直观地提醒开发者,refarr
不是典型的 C++ 数组,它可能是一种 CLI 类的后代(事实上它就是),因此很有可能对其应用方法和属性。
C# 中选择的终结器语法可能是让转向 C# 的 C++ 程序员感到困惑的最大单一来源。请看下面的 C# 代码:-
class R
{
~R()
{
}
}
好吧,所以 ~R
看起来像一个析构函数,但实际上是终结器。我问你,为什么?把它和下面的 C++ 代码比较一下:-
ref class R
{
~R()
{
}
!R()
{
}
};
这里的 ~R
是析构函数(实际上是析构函数的 Dispose-pattern 等价物——但就 C++ 编码器而言,它表现得像一个析构函数),而新的 !R
语法是用于终结器——所以那里没有混淆,并且语法在视觉上与原生 C++ 兼容。
看看 C# 中的泛型语法:-
class R<T>
{
};
现在看看 C++ 中的语法:-
generic<typename T> ref class R
{
};
任何使用过模板的人只需一小部分时间就能弄清楚 C++ 语法,而 C# 语法则莫名其妙地令人困惑和不直观。我大概可以这样一直说下去,但这在思想上会是重复的!我的观点是,如果你有 C++ 背景,C++/CLI 语法将是你迄今为止使用过的最接近的语法。C#(以及 J#)看起来像 C++,但有一些相当奇怪的语义差异,可能会非常令人沮丧和恼火,除非你完全放弃 C++(打消这个念头!!!),否则语法差异将永远不会停止引起混淆和沮丧。从这个意义上说,我认为 VB.NET 更好,至少它有自己独特的语法,所以同时使用 C++ 和 VB.NET 的人不会混淆语法。
结论
最终,你使用哪种语言可能取决于多种因素——你的同事在使用哪种语言,现有代码库是用哪种语言开发的,你的客户是否对你有任何语言规范等等。本文的目的是建立一些 C++/CLI 明显优于任何其他 CLI 语言的可靠场景。如果你的应用程序 90% 的时间都在进行原生互操作,你为什么会考虑使用 C++ 以外的任何东西?如果你正在尝试开发一个泛型集合,为什么只限制自己使用泛型,而不能同时获得泛型和模板的最佳特性?如果你已经在使用 C++,为什么还要学习一门新语言?我常常觉得 C# 和 VB.NET 等语言试图向你(开发者)隐藏 CLR,而 C++ 不仅让你能感受到 CLR,如果你足够努力,你甚至可以亲吻它!
历史
- 2005年7月28日
- 根据我从 Visual C++ MVP Jochen Kalmbach 得到的反馈,我已将
SuppressUnmanagedCodeSecurity
属性添加到 C# P/Invoke 声明中,正如 Jochen 所说,它确实提高了 C# P/Invoke 的性能,但仍远不及 C++ 互操作的性能。 - 我还添加了一些段落/代码片段,展示了 P/Invoke 与 C++ 互操作相比,几乎总是导致源代码膨胀。
- 文章其他部分也进行了细微修改。
- 根据我从 Visual C++ MVP Jochen Kalmbach 得到的反馈,我已将
- 2005 年 7 月 26 日 - 文章首次发布 [本文部分内容早前已撰写,但正是在这一天,我将所有内容整合到一篇(希望)有意义的评论中]