C++/CLI桥接技术:封装C动态库为.NET托管组件的完整实践
1. 项目概述与核心价值
最近在重构一个遗留的工业控制上位机软件时,遇到了一个典型的“技术债”场景:核心算法库是一个用C语言编写的、经过十几年迭代的CDLL(动态链接库),稳定但接口古老且复杂;新的业务模块希望用C#来快速开发,以利用WPF的现代化UI和丰富的生态。直接P/Invoke调用这个C库?面对动辄几十个参数、包含复杂结构体和回调函数指针的接口,那简直是自讨苦吃,调试和维护会成为噩梦。这时候,一个经典的跨语言桥接方案就浮出水面了:用C++/CLI这座“桥”,将原生的C DLL封装成一个干净的、面向对象的.NET托管程序集,再由C#项目像引用普通.NET库一样轻松调用。
这个项目的核心,就是解决C语言原生模块与.NET托管世界之间的高效、安全互操作问题。它绝不仅仅是简单的函数转发,而是一次精密的接口设计与封装。对于需要集成历史C/C++代码库到现代.NET应用(如工业软件、游戏引擎插件、高性能计算前端)的开发者来说,这是一项必备技能。通过CLR(公共语言运行时)这座桥梁,我们既能保留C代码的高性能和稳定性,又能享受C#开发的效率与便捷。整个流程涉及对C++/CLI语法的深入理解、内存边界的管理、数据类型的精确映射,以及最终的部署策略,每一步都有不少细节需要琢磨。
2. 技术选型与方案设计思路
面对C库集成到.NET的需求,通常有几种路径:最直接的是Platform Invocation Services (P/Invoke),适合接口简单、扁平化的C函数;另一种是使用C++/CLI创建混合模式程序集作为代理层。为什么在这个项目中,后者成为了更优解?
2.1 为何放弃纯P/Invoke?
P/Invoke通过[DllImport]属性声明,CLR会自动完成大部分封送处理。但对于复杂场景,它的局限性很明显:
- 复杂数据结构:当C函数参数或返回值包含多层嵌套的结构体、联合体,或者需要传递结构体指针的指针时,在C#侧定义对应的托管结构体变得异常繁琐且容易出错,尤其是需要控制内存布局(
[StructLayout])时。 - 回调函数:C库经常使用函数指针回调来报告进度或返回数据。在P/Invoke中,需要将C#委托(delegate)转换为函数指针,涉及到
Marshal.GetFunctionPointerForDelegate,并且必须小心保持委托实例不被垃圾回收,否则会导致回调时访问无效内存而崩溃。 - 资源管理:如果C函数返回了需要手动释放的内存指针(如通过
malloc分配),P/Invoke模型下,需要在C#侧用IntPtr接收,并调用相应的C释放函数,这破坏了.NET的自动内存管理体验,容易导致内存泄漏。 - 错误处理:C库通常通过返回值或输出参数表示错误,P/Invoke调用后需要手动检查并转换为.NET的异常机制,代码会显得冗长。
当我们的C库接口具备上述一个或多个特征时,引入一个C++/CLI中间层就能将复杂性封装起来,为C#提供一个纯粹的、面向对象的、异常安全的.NET API。
2.2 C++/CLI的核心优势
C++/CLI是微软推出的语言扩展,允许在同一个项目、甚至同一个文件中混合编写原生C++代码和托管.NET代码。它编译后产生的是混合程序集(Mixed Assembly),其中既包含IL(中间语言)代码,也包含原生机器码。用它来封装C DLL,优势突出:
- 无缝类型转换:在C++/CLI代码内部,可以近乎“原生”地调用C函数,因为两者都是原生环境。同时,它又能方便地将C类型(如
char*,struct*)转换为.NET类型(如String^, 托管类),或者进行反向转换。这种转换发生在封装层内部,对C#调用者完全透明。 - 对象生命周期管理:可以利用.NET的垃圾回收机制来管理封装层创建的对象。例如,可以将一个需要
free()的C结构体指针,封装在一个实现了IDisposable接口的托管类中,在Dispose()方法或析构函数中调用C的释放函数,实现自动资源管理。 - 异常桥接:可以在C++/CLI层捕获C函数返回的错误码,并将其转换为标准的.NET
Exception并抛出,使得C#侧可以使用熟悉的try-catch进行错误处理。 - 面向对象封装:可以将一组相关的C函数封装到一个托管类中,通过属性、方法、事件来暴露功能,完全符合.NET的设计范式,大大提升易用性。
2.3 项目整体架构设计
基于以上分析,我们设计的架构清晰分为三层:
- 底层:原有的、未经修改的C语言动态链接库(
LegacyAlgorithm.dll或.so)。 - 桥接层:新建的C++/CLI类库项目(例如
NativeBridge.dll)。该项目引用C库的头文件和导入库(.lib),内部实现一系列托管类(ref class),这些类的方法内部调用原始的C函数,并处理所有数据类型转换和错误处理。 - 应用层:C#主应用程序(如WPF/WinForms项目)。它直接添加对
NativeBridge.dll的引用,然后像使用任何其他.NET库一样,实例化对象、调用方法、处理事件。
这个架构的关键在于,桥接层NativeBridge.dll是一个混合模式程序集,它对于C#应用来说是一个纯.NET程序集,但其内部承载着与原生的C DLL进行交互的重任。
3. 开发环境搭建与项目配置
工欲善其事,必先利其器。搭建一个正确的C++/CLI开发环境是第一步,这里以Visual Studio 2022为例。
3.1 安装必要的组件
首先确保Visual Studio 2022安装了“使用C++的桌面开发”工作负载。此外,必须勾选一个关键的子组件:“C++/CLI支持”。这个组件默认可能未安装。你可以在Visual Studio Installer中,找到对应的工作负载,点击“修改”,在右侧的“安装详细信息”列表中确保“C++/CLI支持”被选中。这是编译C++/CLI项目的基石。
3.2 创建C++/CLI类库项目
打开VS2022,选择“创建新项目”,在搜索框中输入“CLR”,选择“CLR 类库(.NET Framework)”模板。注意,虽然名称是.NET Framework,但根据你选择的目标框架,它也可以用于.NET Core/.NET 5+,前提是项目配置正确。给项目起名,例如NativeBridge。
创建完成后,你会得到一个包含Class1.h和Class1.cpp的文件。.h文件是头文件,用于声明托管类;.cpp文件是实现文件。我们需要将项目属性调整到适合我们任务的状态。
3.3 关键项目属性配置
右键项目 -> “属性”,进行以下关键配置:
- 常规 -> 目标框架:选择与你C#主程序匹配的.NET版本,例如
.NET 6.0或.NET Framework 4.8。对于较新的.NET Core/5+,需要确保平台工具集支持。 - 高级 -> 公共语言运行时支持:必须设置为“公共语言运行时支持(/clr)”。这是项目的核心编译选项。
- C/C++ -> 常规 -> 调试信息格式:建议在Debug配置下选择“程序数据库(/Zi)”,方便调试。
- 链接器 -> 输入 -> 附加依赖项:这里需要添加你的C DLL对应的导入库文件(
.lib)。例如,如果你的C库叫MyCLib.dll,通常会有一个MyCLib.lib文件。你需要将其路径(或仅文件名,如果它位于库目录中)添加到这里。 - 链接器 -> 常规 -> 附加库目录:添加你的
.lib文件所在的目录路径。 - C/C++ -> 常规 -> 附加包含目录:添加你的C库头文件(
.h)所在的目录路径。这样#include "my_c_lib.h"才能找到文件。
3.4 处理C库的依赖
将你的C DLL(例如LegacyAlgorithm.dll)及其所有依赖项(如特定的运行时库)复制到以下位置之一:
- 桥接层项目
NativeBridge的生成输出目录(通常是$(SolutionDir)$(Configuration)\,如x64\Debug\)。 - 最终C#应用程序的生成输出目录。
- 系统的
PATH环境变量包含的目录。
为了简化部署,我通常采用方法1:在NativeBridge项目的“生成事件 -> 后期生成事件”中,添加一个命令行,将C DLL从它的源目录复制到$(TargetDir)。这样每次编译桥接层,最新的C DLL都会自动就位。
4. C++/CLI封装层核心实现详解
这是整个项目的技术核心。我们将通过一个具体的例子来拆解每一步。假设我们有一个简单的C库,头文件calclib.h如下:
// calclib.h #ifdef CALCLIB_EXPORTS #define CALCLIB_API __declspec(dllexport) #else #define CALCLIB_API __declspec(dllimport) #endif typedef struct { int x; int y; } Point; typedef int (*ProgressCallback)(int percent); // 回调函数指针类型 CALCLIB_API int add(int a, int b); CALCLIB_API double compute_distance(Point* p1, Point* p2); CALCLIB_API char* create_greeting(const char* name); // 返回需要free的字符串 CALCLIB_API void free_string(char* str); // 配套的释放函数 CALCLIB_API int process_data(const double* input, int length, double* output, ProgressCallback callback);4.1 基本数据类型的封装
我们从最简单的add函数开始。在NativeBridge项目中,我们创建一个新的头文件ManagedCalculator.h。
// ManagedCalculator.h #pragma once #include "calclib.h" // 包含C库头文件 namespace NativeBridge { public ref class ManagedCalculator { public: ManagedCalculator(); ~ManagedCalculator(); // 析构函数 !ManagedCalculator(); // 终结器(Finalizer) // 封装 add 函数 int Add(int a, int b); private: // 如果需要维护C库的上下文句柄,可以在这里声明为原生指针 // void* _nativeContext; }; }对应的实现文件ManagedCalculator.cpp:
// ManagedCalculator.cpp #include "pch.h" // 预编译头 #include "ManagedCalculator.h" namespace NativeBridge { ManagedCalculator::ManagedCalculator() { // 可以进行C库的初始化,例如:_nativeContext = create_context(); } ManagedCalculator::~ManagedCalculator() { this->!ManagedCalculator(); // 析构函数调用终结器 // 托管析构函数,当用户调用Dispose()或使用using语句时触发 } ManagedCalculator::!ManagedCalculator() { // 终结器,由垃圾回收器在最终化时调用 // 进行资源清理,例如:if(_nativeContext) destroy_context(_nativeContext); } int ManagedCalculator::Add(int a, int b) { // 直接调用C函数,类型完全匹配,无需转换 return ::add(a, b); } }在C#中,你可以这样调用:
using NativeBridge; var calc = new ManagedCalculator(); int result = calc.Add(5, 3); // result = 8非常简单直接。基础类型(int,double,float等)在C++/CLI中基本是透明的。
4.2 复杂结构体与指针的封装
接下来处理compute_distance,它接收两个Point*。我们需要在托管世界创建一个对应的托管结构体或类。这里我们选择使用value struct(C#中的struct)来对应C的struct,因为它也是值类型。
首先,在ManagedCalculator.h中定义托管结构体:
namespace NativeBridge { public value struct ManagedPoint { int X; int Y; }; public ref class ManagedCalculator { public: // ... 其他成员 double ComputeDistance(ManagedPoint p1, ManagedPoint p2); }; }实现ComputeDistance方法:
double ManagedCalculator::ComputeDistance(ManagedPoint p1, ManagedPoint p2) { // 将托管值类型转换为原生C结构体(栈上分配) Point nativeP1 = { p1.X, p1.Y }; Point nativeP2 = { p2.X, p2.Y }; // 调用C函数,传入结构体的地址(指针) return ::compute_distance(&nativeP1, &nativeP2); }这里的关键是“钉住”(Pinning)。ManagedPoint是托管对象,垃圾回收器可能会移动它。当我们取它的地址(&p1)传递给原生代码时,如果期间发生GC,地址就失效了。但是,在这个例子中,p1和p2是方法的参数,是局部变量,存在于栈上,其地址在方法执行期间是固定的,不存在被GC移动的问题。然而,如果我们要封装一个接收或返回指向托管堆上数据的指针的C函数,就必须使用pin_ptr来钉住托管对象,防止GC移动。例如,如果C函数需要一个int*指向一个托管数组,就需要:
array<int>^ managedArray = gcnew array<int>(10); pin_ptr<int> pinnedPtr = &managedArray[0]; c_function_expecting_int_ptr(pinnedPtr); // pinnedPtr 作用域结束后,对象解除钉住4.3 字符串与内存管理的封装
create_greeting函数返回一个需要手动释放的char*。这是封装的重点和难点,目标是让C#调用者完全感觉不到原生内存的存在。
我们在ManagedCalculator类中添加方法:
// ManagedCalculator.h public ref class ManagedCalculator { public: // ... 其他成员 System::String^ CreateGreeting(System::String^ name); };实现如下:
#include <vcclr.h> // 可能需要用于字符串转换 #include <msclr/marshal.h> // 使用marshal_context简化转换 using namespace msclr::interop; System::String^ ManagedCalculator::CreateGreeting(System::String^ name) { // 方法1:使用marshal_context(适用于.NET Framework,简单但非最优) // marshal_context context; // const char* nativeName = context.marshal_as<const char*>(name); // char* nativeResult = ::create_greeting(nativeName); // System::String^ managedResult = gcnew System::String(nativeResult); // ::free_string(nativeResult); // 必须释放! // return managedResult; // 方法2:更现代和灵活的方式,直接使用封送处理函数 // 将System::String^ 转换为原生 const char* // 注意:marshal_as 在跨不同字符集(如Unicode/ANSI)时需要指定 // 假设C库函数接受UTF-8字符串 marshal_context^ context = gcnew marshal_context(); try { const char* nativeName = context->marshal_as<const char*>(name); char* nativeResult = ::create_greeting(nativeName); if (nativeResult == nullptr) { throw gcnew System::Exception("C function returned null."); } // 将结果char*转换回System::String^ System::String^ managedResult = gcnew System::String(nativeResult); // 调用C库提供的释放函数清理内存 ::free_string(nativeResult); return managedResult; } finally { delete context; // marshal_context 需要手动释放以清理临时内存 } }重要提示:字符串封送是错误高发区。必须明确C库函数期望的字符串编码(ANSI还是UTF-8)。
marshal_as默认行为可能与你的C库不匹配。对于UTF-8,更安全的方式是使用System::Runtime::InteropServices::Marshal类中的方法,或者先将System::String转换为array<Byte>^再处理。务必在调用C函数后,立即将返回的char*转换为System::String^并释放原生内存,避免泄漏。
4.4 回调函数的封装
process_data函数接受一个回调函数指针ProgressCallback。我们需要在C++/CLI层定义一个与C回调签名匹配的静态函数,并将其指针传递给C函数。同时,需要将托管委托(C#传递过来的)与这个静态函数关联起来。
首先,在ManagedCalculator.h中声明一个委托,该委托与C回调函数签名兼容:
public delegate int ManagedProgressCallback(int percent);然后,在ManagedCalculator类中添加方法:
public ref class ManagedCalculator { public: // ... 其他成员 array<double>^ ProcessData(array<double>^ input, ManagedProgressCallback^ callback); private: // 静态原生回调函数,签名与C的ProgressCallback一致 static int NativeProgressCallbackStub(int percent); // 用于保存托管委托的GCHandle,防止被GC回收 static System::Runtime::InteropServices::GCHandle _callbackGCHandle; };实现部分:
#include <msclr/lock.h> // 用于线程安全 System::Runtime::InteropServices::GCHandle ManagedCalculator::_callbackGCHandle; int ManagedCalculator::NativeProgressCallbackStub(int percent) { // 从GCHandle中取出托管委托 if (_callbackGCHandle.IsAllocated) { ManagedProgressCallback^ callback = static_cast<ManagedProgressCallback^>(_callbackGCHandle.Target); if (callback != nullptr) { // 调用托管委托 return callback->Invoke(percent); } } return 0; // 默认返回值 } array<double>^ ManagedCalculator::ProcessData(array<double>^ input, ManagedProgressCallback^ callback) { // 1. 准备输入数据 int length = input->Length; pin_ptr<double> pinnedInput = &input[0]; // 钉住托管数组 // 2. 准备输出缓冲区(托管数组) array<double>^ output = gcnew array<double>(length); // 3. 处理回调 // 将托管委托转换为函数指针,并固定防止GC // 注意:每次调用都需要设置,因为GCHandle是静态的,多线程调用会有竞争。 // 更好的做法是将GCHandle作为上下文参数传递给C函数,或使用更复杂的线程本地存储。 // 这里为简化,假设单线程调用。 { msclr::lock lock(this); // 简单同步锁 if (_callbackGCHandle.IsAllocated) { _callbackGCHandle.Free(); } _callbackGCHandle = System::Runtime::InteropServices::GCHandle::Alloc(callback); } // 4. 调用C函数 pin_ptr<double> pinnedOutput = &output[0]; int result = ::process_data(pinnedInput, length, pinnedOutput, &NativeProgressCallbackStub); // 5. 清理 { msclr::lock lock(this); if (_callbackGCHandle.IsAllocated) { _callbackGCHandle.Free(); } } if (result != 0) // 假设0表示成功 { throw gcnew System::Exception(System::String::Format("Process data failed with error code: {0}", result)); } return output; }在C#侧,调用就非常直观了:
var calc = new ManagedCalculator(); double[] data = { 1.0, 2.0, 3.0 }; double[] result = calc.ProcessData(data, percent => { Console.WriteLine($"Progress: {percent}%"); return 0; // 返回0给C库表示继续 });注意事项:回调封装是C++/CLI互操作中最易出错的部分之一。核心挑战在于生命周期管理。传递给C库的函数指针,其对应的托管委托必须在其整个被C库使用的生命周期内保持活跃(即不被垃圾回收)。上面的例子使用静态
GCHandle是一种简单方式,但在多线程环境下是不安全的。生产环境中,更健壮的做法是将GCHandle作为“用户数据”指针(void*)通过C库回调函数的上下文参数传递回去(如果C库支持),或者使用线程本地存储(ThreadLocal<GCHandle>)。务必确保在C库不再需要回调后,及时释放GCHandle,否则会导致内存泄漏。
5. 编译、部署与C#调用实战
5.1 编译配置与目标平台
确保你的C++/CLI桥接层项目、C#主项目以及原始的C DLL都使用相同的目标平台(例如x64或x86)。混合模式程序集对平台非常敏感。在Visual Studio中,为解决方案统一设置“解决方案平台”为x64。右键解决方案 -> “属性” -> “配置属性”,确保所有项目的“平台”都一致。
编译NativeBridge项目。如果一切配置正确,你会在输出目录(如x64\Debug)下得到NativeBridge.dll(你的托管桥接层)和LegacyAlgorithm.dll(你的原生C库)。
5.2 在C#项目中引用与调用
在C#应用程序项目中,直接添加对NativeBridge.dll的引用(右键项目->添加->引用->浏览)。注意,你不需要(也无法)直接引用原始的C DLL。
现在,你可以像使用任何其他.NET库一样使用封装好的类:
using System; using NativeBridge; // 你的桥接层命名空间 namespace MyCSharpApp { class Program { static void Main(string[] args) { try { using (var calculator = new ManagedCalculator()) // 使用using确保Dispose被调用 { // 调用简单函数 int sum = calculator.Add(10, 20); Console.WriteLine($"Sum: {sum}"); // 调用处理结构体的函数 ManagedPoint p1 = new ManagedPoint { X = 0, Y = 0 }; ManagedPoint p2 = new ManagedPoint { X = 3, Y = 4 }; double distance = calculator.ComputeDistance(p1, p2); Console.WriteLine($"Distance: {distance}"); // 调用返回字符串的函数 string greeting = calculator.CreateGreeting("World"); Console.WriteLine(greeting); // 调用带回调的函数 double[] inputData = { 1.5, 2.5, 3.5 }; double[] outputData = calculator.ProcessData(inputData, OnProgress); Console.WriteLine("Processing complete."); } } catch (Exception ex) { Console.WriteLine($"An error occurred: {ex.Message}"); } } static int OnProgress(int percent) { Console.WriteLine($"Progress updated: {percent}%"); return 0; // 返回0表示继续 } } }5.3 部署注意事项
部署应用程序时,你需要将以下文件一同发布:
YourCSharpApp.exe(或相关的程序集)NativeBridge.dll(C++/CLI桥接层)LegacyAlgorithm.dll(原始C库)- 可能需要的C/C++运行时库(如
vcruntime140.dll,msvcp140.dll等),这些通常可以通过Visual C++ Redistributable安装,或者使用“静态链接运行时库”选项编译你的C++/CLI项目来避免依赖。
可以将这些DLL放在应用程序的同一目录下,这是最简单的方式。
6. 调试技巧与常见问题排查
即使按照上述步骤操作,在实际开发中仍会遇到各种问题。以下是一些常见的坑和调试方法。
6.1 “无法加载DLL ‘NativeBridge.dll’”或“找不到指定的模块”
这是最常见的问题。
- 原因1:依赖缺失。
NativeBridge.dll依赖于LegacyAlgorithm.dll和VC运行时库。使用Dependency Walker或Visual Studio自带的dumpbin /dependents NativeBridge.dll命令查看其依赖。确保所有依赖的DLL都存在于应用程序的搜索路径中(如exe所在目录)。 - 原因2:平台不匹配。尝试加载一个x86的DLL到x64进程,或者反之。使用
dumpbin /headers NativeBridge.dll查看其机器类型。确保所有组件(C#应用、C++/CLI桥接层、C库)的平台一致。 - 原因3:DLL文件损坏或版本不对。重新编译并部署所有组件。
6.2 “尝试读取或写入受保护的内存”或Access Violation异常
这通常意味着在C++/CLI层发生了非法的内存访问。
- 原因1:指针或地址错误。检查封送代码,尤其是涉及
pin_ptr和GCHandle的部分。确保在原生代码访问托管内存期间,pin_ptr一直处于作用域内。确保GCHandle有效且指向正确的对象。 - 原因2:回调函数问题。如果崩溃发生在回调期间,几乎可以肯定是托管委托已经被垃圾回收,而C库还在调用那个函数指针。仔细检查回调封装的生命周期管理,确保
GCHandle在回调期间有效。 - 原因3:C库内部错误。可能是传入的参数不符合C库的预期(如空指针、数组长度错误)。在C++/CLI方法入口处添加参数校验。
6.3 调试C++/CLI代码
你可以在C#项目中启动调试,并单步跳入(F11)到C++/CLI代码中。
- 确保C++/CLI项目的“调试信息格式”设置为
/Zi,并且生成了PDB文件。 - 在Visual Studio的“调试”->“窗口”->“模块”中,加载符号后,应该能看到
NativeBridge.dll的符号已加载。 - 你甚至可以在C++/CLI代码中设置断点,当C#调用到相应方法时,调试器会中断。
6.4 性能考量
C++/CLI桥接本身会带来微小的性能开销,主要发生在托管与非托管边界的转换(封送处理)上。对于频繁调用的、性能关键的小函数,这种开销可能变得显著。
- 批处理:如果可能,设计C库的接口或封装层时,尽量一次传递更多数据,减少跨边界调用的次数。例如,传递整个数组而不是在循环中多次调用单个元素处理的函数。
- 避免不必要的复制:使用
pin_ptr直接访问托管数组,而不是将数据复制到原生缓冲区再传递。 - 分析热点:使用性能分析工具确定瓶颈是否真的在互操作层。很多时候,瓶颈在算法本身。
6.5 处理C库的线程安全性
如果原始的C库不是线程安全的,那么你的封装层默认也不是。你需要在封装层添加同步机制(如msclr::lock或.NET的lock语句)来保护对C库的调用。但要注意,过度同步会影响性能。更好的做法是,如果C库本身是线程安全的,或者你的使用场景是单线程的,就无需额外同步。
7. 进阶封装模式与设计建议
对于大型、复杂的C库,简单的函数封装会显得杂乱。可以考虑以下更高级的封装模式:
7.1 面向对象的重构
将相关的C函数和数据结构封装到有意义的.NET类中。例如,如果C库有一组关于“设备”操作的函数(device_open,device_read,device_close),可以创建一个ManagedDevice类,在构造函数中调用device_open并保存句柄,在Dispose方法中调用device_close,并提供Read等方法。
7.2 使用SafeHandle管理原生资源
对于代表原生资源(如文件句柄、设备上下文)的指针,推荐使用System::Runtime::InteropServices::SafeHandle或其派生类(如CriticalHandle)来封装。SafeHandle提供了更强大的生命周期保证和终结器安全机制,能更好地与.NET的垃圾回收和using语句协同工作。
7.3 事件封装
如果C库通过回调提供异步通知(如数据到达、状态改变),可以将其封装为标准的.NET事件。在C++/CLI类中声明一个事件,在私有的原生回调函数中触发这个事件。这为C#客户端提供了非常友好的编程模型。
7.4 考虑使用SWIG或C++/WinRT
对于极其庞大的C/C++代码库,手动编写C++/CLI封装层工作量巨大。可以考虑使用自动化工具如SWIG,它可以自动生成多种目标语言(包括C#)的绑定代码。对于Windows平台上的现代C++库,C++/WinRT也是一个强大的选择,它提供了更自然、类型安全的Windows运行时组件投影模型,可以直接在C#中消费。
手动编写C++/CLI封装层是一项细致且需要耐心的工作,但它带来的价值是巨大的:它将难以驾驭的遗留C代码变成了现代.NET应用中一个温顺的、可被良好管理的组件。每一次成功的封装,都像是为一座老旧的桥梁进行了现代化的加固和装修,让它不仅能继续承重,还能融入新的交通网络,焕发新的生机。当你看到C#代码流畅地调用着那些深藏于C库中的复杂算法,并且拥有完整的异常处理和资源管理时,你会觉得这一切的付出都是值得的。