托管环境下的原生代码
一篇关于如何在同一个解决方案中混合原生代码和托管代码的指南
引言
DotNET 在桌面软件开发(尤其是 UI 方面)越来越广泛地被使用,同时,原生 C/C++ 仍然是许多性能敏感功能的主要方面。我们有许多合理的动机同时使用原生和托管代码来构建功能强大且易于维护的软件。我希望本文能为那些尚未体验过这种混合编程便利性的人们提供指导。
概述
我假设您已经熟悉 C# 和(原生)C++ 的编码方式,并且可能了解一些 CLI C++。如果您不熟悉 CLI C++,您可以从搜索引擎中获得许多灵感;《C++/CLI 语言规范》也应该是一本很好的手册。
有两种方法可以将原生和托管程序结合在一起。一种是托管下的原生,另一种是原生下的托管。在本文中,我们专注于第二种,即使用托管代码作为顶层 UI,这更为常见。
尽管 P/Invoke 可以在托管环境中调用原生接口,但在许多情况下它仍然受到很大的限制。
Using the Code
技巧
本文附带的所有源代码均使用 NativeC++/CLI C++/C# 和 VS2008(SP1) 构建;但将其移植到 VS2005 及更高版本仍然是可行的。
如果您想在混合环境中调试原生代码,需要在入口托管项目上勾选“启用非托管代码调试”选项,更多详细信息请参阅 MSDN。
原生
演示项目仅是一个示例,展示了如何通过 CLI C++ 将原生 C++ 组合到 C# 中。例如,我们有一个纯原生 C++ 类如下
class MyNativeClass {
public:
class Listener {
public:
Listener();
virtual ~Listener();
virtual void Callback(const ObjParamDict &p);
};
public:
MyNativeClass();
virtual ~MyNativeClass();
void SetFuncPtr(FuncPointer fp);
void SetListener(Listener* lsn);
void Fun0(const ParamList &p);
void Fun1(const ObjParamDict &p);
s32 GetIntegerProp(void) const;
void SetIntegerProp(s32 val);
private:
FuncPointer mFuncPtr;
Listener* mListener;
s32 mInteger;
};
您可能会发现此类中常用的编程概念,如要调用的函数、函数指针/监听器回调、带 getter/setter 的字段等。在此项目中,我封装了一个简单的通用类型数据持有类 `Obj`;您可以根据自己的选择将其替换为其他 `type-list`/`variant`/`any` 库。
在 `Fun0` 中,我们从 `ParamList` 中提取三个 `f32`,并使用这些参数计算一个结果,然后使用计算结果作为参数调用函数指针回调。请看下面
void MyNativeClass::Fun0(const ParamList &p) {
f32 f0 = p.at(0);
f32 f1 = p.at(1);
f32 f2 = p.at(2);
f32 sum = f0 + f1 + f2;
ParamList _p;
_p.push_back(sum);
(*mFuncPtr)(_p);
}
在 `Fun1` 中,我们遍历 `ObjParamDict` 并将迭代的键值对序列化为单个 `StdStr`,然后将序列化结果作为参数调用监听器回调。请看下面
void MyNativeClass::Fun1(const ObjParamDict &p) {
StdStr ret;
for(ObjParamDict::const_iterator it = p.begin(); it != p.end(); ++it) {
ret += "[";
ret += it->first.getStr();
ret += ": ";
ret += it->second.getStr();
ret += "]; ";
}
ObjParamDict _p;
_p["RET"] = ret.c_str();
mListener->Callback(_p);
}
CLI 包装器
CLI C++ 是标准 C++ 的超集,因此我们可以在 CLI 包装器项目中编写原生代码(使用原始的纯原生 C/C++ 语法)和托管代码(使用 CLI C++ 语法),它在原生和托管端之间起到粘合剂的作用。通常在此层处理一些数据类型/结构适配和调用传输。
1. 数据类型适配
将基本数据类型从原生转换为托管,反之亦然非常容易。我们可以直接将值赋给原生/托管变量,例如 `int ni = 1986; Int32 mi = ni;` 或 `Single ms = 3.14f; float ns = ms;`。关于原生/托管数据类型对应关系,请看下面
如果我们想调用托管函数(如 `System.Object.ToString()`),则必须使用显式的托管对象,例如 `int ni = 123; String mfi = (Int32(ni)).ToString();`。
与基本数据类型不同,我们必须对复杂类型进行一些必要的转换。例如,字符串的转换如下
String^ WrapperUtil::ConvertString(ConstStr n) {
return gcnew String(n);
}
StdStr WrapperUtil::ConvertString(String^ m) {
IntPtr p = Marshal::StringToHGlobalAnsi(m);
ConstStr linkStr = static_cast<Str>(p.ToPointer());
StdStr result(linkStr);
Marshal::FreeHGlobal(p);
return result;
}
更进一步说,整篇文章几乎都在讨论如何转换更复杂的自定义用户类型。
2. 原生字段 getter/setter 到托管属性
要将原生字段 getter/setter 适配到托管属性,只需将其封装在 CLI C++ 属性定义中,如下所示
property Int32 IntegerProp {
Int32 get();
Void set(Int32 v);
}
然后,在 `get()` 中调用原生 getter,在 `set()` 中调用原生 setter。
3. 原生回调到托管委托/事件
在 CLI C++ 中定义事件有点复杂。请看下面
public:
delegate Void SomeEventHandler(List<Object^>^ p);
protected:
SomeEventHandler^ OnSomeEvent;
public:
event SomeEventHandler^ SomeEvent {
public:
void add(SomeEventHandler^ _d) {
OnSomeEvent += _d;
}
void remove(SomeEventHandler^ _d) {
OnSomeEvent -= _d;
}
private:
void raise(List<Object^>^ p) {
if(OnSomeEvent)
OnSomeEvent->Invoke(p);
}
}
我们必须编写一些原始的原生 C++ 代码来桥接原生函数指针或监听器到 CLI C++ 项目中的托管事件,例如定义一些全局/静态函数处理器或派生的监听器处理器实例;然后将这些指针传递给原生端,并在原生回调发生时调用 `SomeEvent(List<Object^>^)`,将其提升到上层的纯托管环境中。这将是一项繁琐的工作。
4. std::exception 到 System::Exception
我总是将当前的原生调用用 `try-catch` 语句包围在 CLI C++ 中,然后将异常反向重新抛出一个托管版本的异常到纯托管环境中。请看下面
try {
DoSomething();
} catch(const std::exception &ex) {
String^ msg = WrapperUtil::ConvertString(ex.what());
throw gcnew Exception(msg);
}
托管
引用包装好的 CLI C++ 项目后,您可以遵循纯托管编程的经验来集成它。对于这个例子,我们可以在 C# 中这样做
public partial class FormMain : Form
{
private MyCliClass underManaged = null;
public FormMain()
{
InitializeComponent();
underManaged = new MyCliClass();
underManaged.FuncPtrEvent += new MyCliClass.MyFuncPtrEventHandler(underManaged_FuncPtrEvent);
underManaged.ListenerEvent += new MyCliClass.MyListenerEventHandler(underManaged_ListenerEvent);
propGrid1.SelectedObject = underManaged;
}
private void btnFun0_Click(object sender, EventArgs e)
{
float f0 = float.Parse(txtN0.Text);
float f1 = float.Parse(txtN1.Text);
float f2 = float.Parse(txtN2.Text);
List<object> p = new List<object>();
p.Add(f0);
p.Add(f1);
p.Add(f2);
underManaged.Fun0(p);
}
private void btnFun1_Click(object sender, EventArgs e)
{
string k0 = txtKey0.Text;
string k1 = txtKey1.Text;
string k2 = txtKey2.Text;
string v0 = txtVal0.Text;
string v1 = txtVal1.Text;
string v2 = txtVal2.Text;
Dictionary<object, object> p = new Dictionary<object, object>();
p[k0] = v0;
p[k1] = v1;
p[k2] = v2;
underManaged.Fun1(p);
}
private void underManaged_FuncPtrEvent(List<object> p)
{
MessageBox.Show(this, p[0].ToString(), "Native under Managed", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void underManaged_ListenerEvent(Dictionary<object, object> p)
{
MessageBox.Show(this, p["RET"].ToString(), "Native under Managed", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
有效的设计
在一个解决方案中使用不同的语言会带来一些不可避免的间隙;在弥合这些跨语言边缘的间隙时,会带来一些缺点,如函数调用时的时空复杂度,数据类型适配时的时空复杂度。同时,在编写包装器时,我们必须用两种语言重写某些内容,被包装的东西越多,项目就越接近重复代码的地狱。因此,尽可能将交互操作保持在单个层内,并偶尔使用跨语言调用非常重要。像消息分发这样的抽象方法可能非常有帮助,可以创建一个灵活而稳定的中间层包装器。
超越
虽然本文只讨论原生下的托管,但我认为现在您很容易推断出如果真的想,如何反过来做。
我在本文中抛砖引玉,如果这对您进行原生和托管混合编程有所帮助,我将不胜荣幸。