لماذا يكون غلاف C++/CLI في الغالب أفضل طريقة لاستخدام native DLL من C# - مقارنة عمليّة مع P/Invoke
لماذا يكون غلاف C++/CLI في الغالب أفضل طريقة لاستخدام native DLL من C# - مقارنة عمليّة مع P/Invoke
من الشائع جدّاً أن ترغب في استخدام أصول Windows القائمة أو native DLL من C#. وإذا كان الطرف الآخر يعرض واجهة بأسلوب C نظيف، فإنّ P/Invoke يكون كافياً عادةً.
تبدأ المتاعب الفعليّة حين يكون شكل الـ DLL أقرب إلى مكتبة C++.
فيكون لديه أصناف وقواعد ملكيّة واستثناءات واستخدام طبيعي لـ std::wstring و std::vector.
عند هذه النقطة، فإنّ إجبار كلّ شيء على المرور عبر P/Invoke يجعل الحدّ الفاصل نفسه في الغالب الجزء الأصعب من المشروع.
يشرح هذا المقال ما الذي يصبح أيسر حين تضع طبقة غلاف C++/CLI رفيعة واحدة في المنتصف. هذه ليست حجّة بأنّ P/Invoke سيّئ. بل هي حجّة بأنّ المواقف التي يكون فيها P/Invoke كافياً تختلف عن المواقف التي يساعد فيها C++/CLI.
المحتويات
- النسخة المختصرة
- الحالات التي يكون فيها P/Invoke كافياً
- أين يبدأ P/Invoke فجأةً في إيلامك
- البنية مع غلاف C++/CLI
- ما الذي يصبح أيسر مع C++/CLI
- مقتطفات من الشيفرة
- الحالات التي ينبغي فيها مع ذلك ألاّ تختار C++/CLI
- الخلاصة
- مراجع
1. النسخة المختصرة
- إذا كان الطرف الآخر واجهة C مسطّحة (flat C API)، فإنّ P/Invoke هو الخيار الطبيعي
- إذا كان الطرف الآخر مكتبة C++، فإنّ الصيانة تصبح أيسر في الغالب إذا أدرجتَ غلاف C++/CLI رفيعاً واحداً
- يصدُق هذا بشكل خاصّ عندما تكون الأصناف والملكيّة والسلاسل النصّية والمصفوفات والاستثناءات والـ callbacks متضمَّنة
بعبارة أخرى: لا تَنقل قيود native DLL مباشرةً إلى C#. دَع C++ يمتصّ القيود الموجودة على الجانب الأصلي، واعرض إلى الأعلى سطحاً ملائماً لـ .NET فقط. عندما يعمل هذا الفصل، تصبح كلٌّ من الشيفرة وتجربة التنقيح أكثر هدوءاً بكثير.
2. الحالات التي يكون فيها P/Invoke كافياً
النقطة الأولى المهمّة هي أنّه إذا كان P/Invoke كافياً، فإنّه عادةً الخيار الأبسط. لا يوجد سبب لإقحام C++/CLI في الحلّ بالقوّة.
يناسب P/Invoke الحالات التالية:
- يَعرض الـ DLL أصلاً واجهة دوالّ مسطّحة بأسلوب
extern "C" - تكون المعطيات والقيم المُرجَعة أشياء بسيطة مثل الأعداد الصحيحة والمؤشّرات والبنى المباشرة
- قواعد ترميز السلاسل النصّية واضحة وملكيّة الـ buffer بسيطة
- إدارة الموارد صريحة، مثل
Create/Destroy - يستطيع الجانب C# التعبير عن الحدّ الفاصل بطبيعيّة عبر
SafeHandleوStructLayout
عندما تكون الأمور بهذا الوضوح، فإنّك في الغالب تُصرِّح عن التواقيع وتستخدمها فقط. يقترب الإحساس من استدعاء واجهات Win32 APIs.
3. أين يبدأ P/Invoke فجأةً في إيلامك
تتبدّل الأجواء بمجرّد أن لا يعود الطرف الآخر مجرّد “C API”.
3.1. حين تبدأ بالتعامل مع أصناف C++
إذا كان native DLL مصمَّماً حول أصناف C++، فإنّ ما تريده فعلاً من C# هو استدعاء التوابع (methods). لكنّ P/Invoke لا يستطيع استهداف سوى دوالّ DLL المصدَّرة مباشرةً.
يعني هذا أنّك تحتاج في مكان ما إلى طبقة تُسطِّح واجهة C++ إلى دوالّ بأسلوب C.
وعند هذه النقطة، فإنّك تكتب غلافاً بالفعل.
لذلك، بدلاً من نشر IntPtr ودوالّ التحرير في كلّ أرجاء C#، يكون من الأكثر طبيعيّة في الغالب إبقاء ذلك الغلاف على جانب C++.
3.2. حين تصبح الملكيّة وعمر الكائن صعبَي الرؤية
في C++، تكون أسئلة كهذه طبيعيّة:
- مَن يحرّر هذا الكائن؟
- هل المؤشّر المُرجَع مستعار (borrowed)؟
- هل هذا
const&أم نقل ملكيّة؟ - هل ثمّة افتراض خفيّ بشأن العمر بسبب التخزين المؤقّت الداخلي؟
محاولة التعبير عن كلّ ذلك مباشرةً عبر شيفرة C# مرتكزة على IntPtr تميل إلى أن تصبح مؤلمة عندما تعود إليها لاحقاً.
ما إن يصبح “مَن يُتلِف هذا المؤشّر، ومتى؟” غامضاً، يَعكِر الحدّ الفاصل بسرعة.
3.3. حين يظهر std::wstring و std::vector والـ callbacks والاستثناءات
هذه هي المنطقة التي يصبح فيها P/Invoke “ممكناً، لكن غير مريح”.
- تريد التعامل مع
std::wstring - تريد إرجاع
std::vector<T> - تريد استلام التقدّم الأصلي عبر callbacks
- تَطرح الشيفرة الأصليّة استثناءات C++
عند هذه النقطة، يبدأ جانب C# بتراكم MarshalAs و buffers يدويّة وإدارة عمر للـ delegates وتفسير لرموز الأخطاء.
يمكنك بالتأكيد أن تجعل الأمر يعمل.
لكنّ الجزء الصعب يتوقّف عن كونه منطق العمل ويصبح طبقة الـ interop نفسها.
3.4. حين لا تريد لقيود الجانب الأصلي أن تتسرّب إلى C#
ليس سطح API الأصلي دائماً سطح API الذي تريد لمطوّري C# أن يستخدموه.
على سبيل المثال، قد يفترض الجانب الأصلي:
- أن تُجمَع عدّة استدعاءات للتوابع في عمليّة منطقيّة واحدة
- أن تُرجَع حالات الفشل عبر رموز الأخطاء ومعطيات out
- أنّ ترتيب التهيئة مهمّ
- أنّ لـ thread-safety قواعد خاصّة
لكن على جانب C#، تريد في الغالب شكلاً أنظف وأكثر اصطلاحيّة. طبقة الترجمة تلك هي حيث يكون C++/CLI مفيداً بشكل خاصّ.
4. البنية مع غلاف C++/CLI
الشكل العام بسيط:
flowchart LR
Cs["C# app"] -->|.NET-friendly API| Wrapper["C++/CLI wrapper DLL"]
Wrapper -->|direct use of native headers and types| Native["Native C++ DLL"]
من جانب C#، تَعرض API يشبه .NET فقط. تمتصّ طبقة C++/CLI:
- تحويل السلاسل النصّية
- تحويل المتّجهات والمصفوفات
- تحويل الاستثناءات
- تنظيف الملكيّة وعمر الكائنات
- تفسير رموز الأخطاء
- إن لزم، الـ callbacks وضبط حدود مؤشّرات الترابط
نقطة التصميم المهمّة هي عدم السماح لمشروع C++/CLI بالتضخّم أكثر ممّا ينبغي. ينبغي أن يبقى دوره الترجمة والتشكيل، لا منطق العمل.
5. ما الذي يصبح أيسر مع C++/CLI
5.1. تستطيع التعامل مع أنواع C++ بوصفها أنواع C++
هذه ميزة كبرى. داخل C++/CLI، تستطيع تضمين الـ native headers واستخدام أنواع C++ الأصليّة مباشرةً.
يعني هذا أنّك لستَ مضطرّاً إلى إعادة بناء عالم C++ على نحو متكلَّف داخل C#.
تستلم std::wstring و std::vector بوصفها أنواع C++ أوّلاً، ثمّ تحوّلها إلى الأشكال التي تريد فعلاً عرضها في .NET.
5.2. تستطيع إعادة تشكيل الـ API ليكون ملائماً لـ .NET
على جانب C#، تستطيع عرض أشكال مألوفة مثل:
stringbyte[]List<T>IDisposable- الاستثناءات
يبدو ذلك صغيراً، لكنّه يغيّر سهولة الاستخدام كثيراً. في تطوير الفِرَق على وجه الخصوص، يعني أنّ الأشخاص الذين لا يعرفون تفاصيل الجانب الأصلي يستطيعون مع ذلك استخدام الـ API براحة.
5.3. يصبح من الأيسر تنظيم مسؤوليّة الأخطاء والاستثناءات
إذا كان الجانب الأصلي يخلط بين الاستثناءات ورموز الأخطاء، فإنّ كشف ذلك مباشرةً إلى C# يكون مزعجاً. في C++/CLI تستطيع توحيدها:
- تحويل الاستثناءات الأصليّة إلى استثناءات .NET
- ترجمة رموز الأخطاء إلى استثناءات أو كائنات نتائج ذات معنى
- إضافة السياق الذي تريده في السجلّات
ما إن يحوّل الحدّ الفاصل الفشل إلى شيء ذي معنى، يصبح جانب المُستدعِي أكثر نظافة بكثير.
5.4. تستطيع إخفاء عدم استقرار الـ ABI عن C#
أصناف C++ وتوابعها لا تتصرّف مثل ABI الـ C البحتة. إذا بدأ C# يعتمد على تلك التفاصيل، فإنّ الدوالّ المصدَّرة وقواعد الـ marshaling تتسرّب إلى الجانب المُدار (managed).
مع C++/CLI، تبقى قيود C++ على جانب C++، ولا يرى C# سوى سطح مُدار مستقرّ. يساعد هذا الفصل أيضاً عندما تتطوّر المكتبة الأصليّة.
5.5. يصبح الترحيل التدريجي أيسر
إعادة بناء native DLL قائم دفعةً واحدة في الغالب ثقيل جدّاً. مع غلاف C++/CLI، تستطيع تغليف الـ APIs اللازمة فقط أوّلاً والسماح للشاشات أو سير العمل الجديدة في C# بالبدء باستخدامها تدريجيّاً.
هذا مسار عمليّ جدّاً عندما تريد إبقاء أصول Windows الأصليّة حيّة بينما تنقل الطبقات المحيطة تدريجيّاً إلى .NET.
6. مقتطفات من الشيفرة
ليس المقصود من هذه المقتطفات أن تكون عيّنة كاملة قابلة للتشغيل. إنّها هنا فقط لإظهار شكل الحدّ الفاصل.
6.1. صورة API على الجانب الأصلي
// NativeLib.hpp
#pragma once
#include <string>
#include <vector>
namespace NativeLib
{
struct AnalyzeOptions
{
int threshold;
std::wstring modelPath;
};
struct AnalyzeResult
{
bool ok;
std::wstring message;
std::vector<int> scores;
};
class Analyzer
{
public:
explicit Analyzer(const std::wstring& licensePath);
AnalyzeResult Analyze(const std::wstring& imagePath, const AnalyzeOptions& options);
};
}
هذه واجهة C++ أصليّة عاديّة تماماً. لكنّ استخدامها مباشرةً من C# ليس ممتعاً بشكل خاصّ.
6.2. كيف يبدأ مسار P/Invoke في الظهور
لاستدعائها مباشرةً من C#، تحتاج أوّلاً إلى تسطيحها إلى واجهة بأسلوب C في مكان ما.
extern "C"
{
__declspec(dllexport) void* Analyzer_Create(const wchar_t* licensePath);
__declspec(dllexport) void Analyzer_Destroy(void* handle);
__declspec(dllexport) int Analyzer_Analyze(
void* handle,
const wchar_t* imagePath,
const AnalyzeOptionsNative* options,
AnalyzeResultNative* result);
}
ثمّ يبدأ جانب C# بتنمية أشياء مثل:
internal sealed class SafeAnalyzerHandle : SafeHandle
{
private SafeAnalyzerHandle() : base(IntPtr.Zero, ownsHandle: true) { }
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
NativeMethods.Analyzer_Destroy(handle);
return true;
}
}
إن كان هذا كلّ ما تحتاجه، فلا بأس. لكن في المشاريع الفعليّة، سرعان ما تُضيف عادةً أسئلة بشأن البيانات متغيّرة الطول و buffers السلاسل النصّية وتفاصيل الأخطاء وعمر الـ callbacks.
6.3. الفكرة نفسها مع C++/CLI
مع C++/CLI، تمتصّ هناك المخاوف الأصليّة وتعرض إلى الأعلى API بشكل .NET.
// AnalyzerWrapper.h
#pragma once
#include "NativeLib.hpp"
using namespace System;
using namespace System::Collections::Generic;
public ref class AnalysisOptions
{
public:
property int Threshold;
property String^ ModelPath;
};
public ref class AnalysisResult
{
public:
property bool Ok;
property String^ Message;
property List<int>^ Scores;
};
public ref class AnalyzerWrapper : IDisposable
{
public:
AnalyzerWrapper(String^ licensePath);
~AnalyzerWrapper();
!AnalyzerWrapper();
AnalysisResult^ Analyze(String^ imagePath, AnalysisOptions^ options);
private:
NativeLib::Analyzer* _native;
};
// AnalyzerWrapper.cpp
#include "AnalyzerWrapper.h"
#include <msclr/marshal_cppstd.h>
using msclr::interop::marshal_as;
AnalysisResult^ AnalyzerWrapper::Analyze(String^ imagePath, AnalysisOptions^ options)
{
NativeLib::AnalyzeOptions nativeOptions{};
nativeOptions.threshold = options->Threshold;
nativeOptions.modelPath = marshal_as<std::wstring>(options->ModelPath);
try
{
auto nativeResult = _native->Analyze(
marshal_as<std::wstring>(imagePath),
nativeOptions);
auto managed = gcnew AnalysisResult();
managed->Ok = nativeResult.ok;
managed->Message = gcnew String(nativeResult.message.c_str());
managed->Scores = gcnew List<int>();
for (int score : nativeResult.scores)
{
managed->Scores->Add(score);
}
return managed;
}
catch (const std::exception& ex)
{
throw gcnew InvalidOperationException(gcnew String(ex.what()));
}
}
ثمّ يصبح جانب C# عاديّاً جدّاً:
using var analyzer = new AnalyzerWrapper(@"C:\license.dat");
var result = analyzer.Analyze(
@"C:\input.png",
new AnalysisOptions
{
Threshold = 80,
ModelPath = @"C:\model.bin"
});
من جانب C#، ترى string و List<int> و IDisposable.
لستَ بحاجة إلى رؤية IntPtr أو دوالّ التحرير أو قواعد buffers السلاسل النصّية الأصليّة.
7. الحالات التي ينبغي فيها مع ذلك ألاّ تختار C++/CLI
بطبيعة الحال، C++/CLI ليس دائماً الجواب الصحيح.
- يَعرض الجانب الأصلي بالفعل واجهة C نظيفة
- في تلك الحالة، يكون P/Invoke عادةً الخيار الأكثر مباشرة
- تحتاج إلى دعم متعدّد المنصّات
- C++/CLI أداة موجَّهة إلى Windows أوّلاً
- الحدّ الفاصل صغير جدّاً والأنواع بسيطة
- إضافة DLL غلاف قد تكلّف أكثر ممّا توفّره
- لديك قيود AOT أو تعبئة (packaging) صارمة جدّاً
- راجِع قصّة النشر بأكملها أوّلاً
نقطة القرار الحقيقيّة هي: أين يكون من الأطبَع ترجمة تعقيد الجانب الأصلي؟ إذا كان الجانب الأصلي بسيطاً، استخدم P/Invoke. إذا كان معقّداً بطريقة C++، فإنّ C++/CLI في الغالب هو المكان الأنظف لامتصاص ذلك.
8. الخلاصة
لا يزال P/Invoke الطريقة المعياريّة لاستخدام native DLLs من C# عندما يكون الطرف الآخر واجهة C نظيفة. لكن إذا كان الجانب الأصلي مصمَّماً فعلاً بوصفه مكتبة C++، فإنّ من الأنظف في الغالب بناء غلاف C++/CLI رفيع واحد بدلاً من دفع كلّ قاعدة ملكيّة وكلّ مخاوف marshaling إلى داخل C#.
يصبح هذا عمليّاً بشكل خاصّ عندما يكون لديك:
- واجهات APIs قائمة على الأصناف
- افتراضات ملكيّة
std::wstringوstd::vector- ترجمة استثناءات
- callbacks
- ترحيل تدريجي من طبقات واجهة المستخدم أو سير العمل الأصليّة إلى .NET
العمل ليس برّاقاً. لكنّ تقرير أين يجري ترتيب الحدّ الفاصل له تأثير ضخم على قابليّة الصيانة طويلة الأمد. عندما تريد لأصول Windows الأصليّة وإنتاجيّة .NET أن يتعايشا، يبقى C++/CLI أداةً مفيدةً جدّاً.
9. مراجع
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- Performance Considerations for Interop (C++) - Microsoft Learn
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
مزالق تطبيقات serial communication - framing وtimeouts وflow control وreconnects ومحوّلات USB وتجمّد الـ UI
ملخّص عمليّ لمزالق تطبيقات serial communication على Windows: framing وtimeouts وflow control وreconnects ومحوّلات USB-to-serial وتجمّد ال...
أيّها نختار من بين Windows Forms وWPF وWinUI - جدول قرار للتطوير الجديد، الأصول القائمة، التوزيع، والتعبير عن الـ UI
هذا المقال يُنظّم اختيار WinForms أو WPF أو WinUI من زاوية الأصول القائمة والتوزيع وقدرة التعبير في الـ UI، ويُقدّم جدول قرار عمليّاً يُس...
المزالق وأفضل الممارسات عند استخدام shared memory - تنظيم مسبق للتزامن، الرؤية، العمر، ABI، والأمان
نُلخّص أبرز المزالق عند استخدام shared memory ونصمّم للتزامن، الرؤية، العمر، ABI، والاستعادة، حتّى يبني القارئ تكاملاً ثابتاً منخفض الأعطال.
ما الذي يجب التحقّق منه قبل ترحيل .NET Framework إلى .NET - قائمة تحقّق عمليّة لما قبل الترحيل
قائمة عمليّة لما قبل ترحيل .NET Framework إلى .NET: جرد المشاريع وفحص WCF و Web Forms و EF6 و NuGet لتجنّب المفاجآت قبل بدء التنفيذ.
كيف نُحوِّل C# إلى native DLL باستخدام Native AOT - استدعاء exports من نوع UnmanagedCallersOnly من C/C++
يوضِّح هذا المقال كيف نُصدر مكتبة C# بوصفها native DLL عبر Native AOT، ونكشف نقاط دخول UnmanagedCallersOnly تُستدعى مباشرةً من C أو C++ ب...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
ندعم تطوير برامج ويندوز للأعمال، وتكامل الأجهزة، وأدوات التواصل.