استدعاء native DLL من C#: غلاف C++/CLI مقابل P/Invoke
· آخر تحديث: · 小村 豪 · C++/CLI, C#, تطوير Windows, التكامل الأصلي
سجل التعديلات (2 تحديثات، آخر تحديث 2 Sep، 2026)
سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.
- أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
- أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
- النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621311)
هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.
小村 豪 (2026). استدعاء native DLL من C#: غلاف C++/CLI مقابل P/Invoke. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621311 https://comcomponent.com/ar/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/
- DOI (أحدث نسخة)
- 10.5281/zenodo.21621311
- DOI (هذه النسخة)
- 10.5281/zenodo.22240801
كثيراً ما نحتاج إلى استخدام أصول Windows القائمة أو DLL موجود من C#. إذا كان الطرف الآخر واجهة C صريحة مثل Win32 API، فإنّ P/Invoke يكفي.
غير أنّ ما يظهر في العمل الفعليّ DLL ذات طابع أشدّ تعقيداً.
فيها أصناف C++، ولها أسلوب في الملكيّة، وتُطرَح استثناءات، ويظهر std::wstring و std::vector بصورة عاديّة.
إن دفعت الأمر بـ P/Invoke وحده هنا، يزداد الحدّ الفاصل ضيقاً في الغالب.
يشرح هذا المقال ما الذي يصبح أيسر حين تضع غلاف C++/CLI رفيعاً واحداً في الوسط. ليس المقصود أنّ P/Invoke سيّئ، بل أنّ المواضع التي يكفي فيها P/Invoke تختلف عن المواضع التي ينفع فيها C++/CLI.
ومقتطفات الشيفرة في هذا المقال منشورة على GitHub كعيّنة كاملة قابلة للبناء (مكتبة C++ أصليّة، جسر C API، غلاف C++/CLI، وشيفرة C# استهلاك لصيغة P/Invoke وصيغة C++/CLI).
cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)
القرّاء المستهدفون والافتراضات
المقال موجَّه إلى من استدعى native DLL من C# ويستطيع كتابة تصريح DllImport، لكنّه يتوقّف حين يصبح الطرف مكتبة أصناف C++. لا بأس إن لم تستخدم C++/CLI من قبل. أمّا إن لم تكتب P/Invoke بعد، فأسرع أن تبدأ بقراءة «استدعاء Win32 API بأمان من C# ── دليل P/Invoke العمليّ».
البيئة المفترضة هي Windows + Visual Studio 2022، بحيث يمكن وضع غلاف C++/CLI (.vcxproj) ومشروع C# في الحلّ نفسه. الهدف قد يكون .NET Framework أو .NET 8 وما شابه، غير أنّ لجانب .NET قيوداً خاصّة جُمعت في الفصل 7.
مصطلحات يجدر ضبطها أوّلاً
| المصطلح | المعنى |
|---|---|
| P/Invoke (Platform Invoke) | آليّة تعلن دوالّ التصدير في native DLL عبر سمة DllImport / LibraryImport في C# وتستدعيها مباشرة |
| marshaling | تحويل أنواع .NET (string والمصفوفات وغيرها) وتمثيلات الجانب الأصلي (wchar_t* والمؤشّرات الخام وغيرها) عند الحدّ، في الاتجاهين |
| ABI (Application Binary Interface) | الاتفاق الذي يجعل الثنائيات المترجَمة تتوافق: اصطلاح الاستدعاء، طريقة تمرير الوسائط، تخطيط الذاكرة للبنية، وتزيين الأسماء. دوالّ C بسيطة ومستقرّة في اتفاقها، أمّا أصناف C++ فتعتمد تزيين الأسماء وتخطيط vtable على المترجم، ولا يمكن الاعتماد عليها مباشرة من C# (5.4) |
SafeHandle |
صنف مجرّد في .NET يغلّف الـ handle الأصلي. يُستخدم بدلاً من حمل IntPtr عارياً، لتفادي نسيان التحرير وتحرير الـ handle أثناء الاستخدام (6.2) |
StructLayout |
سمة تضبط تخطيط ذاكرة البنية في C# ليوافق الجانب الأصلي. تُستخدم لصفّ الحقول بترتيب التصريح عبر LayoutKind.Sequential، أو لتعيين التعامل مع السلاسل عبر CharSet (6.2) |
marshal_as |
مساعد تحويل يوفّره C++/CLI. يحوّل أنواع .NET والأنواع الأصليّة في الاتجاهين، مثل marshal_as<std::wstring>(managedString). يُستخدم بعد تضمين ترويسة مثل msclr/marshal_cppstd.h (6.3) |
| تجميعة مختلطة | DLL يحتوي تعليمات آلة أصليّة و MSIL معاً. غلاف C++/CLI يكون من هذا النوع (الفصل 7) |
المحتويات
- الخلاصة أوّلاً (في جملة)
- الحالات التي يكفي فيها P/Invoke
- الحدّ الذي يصبح فيه P/Invoke شاقاً فجأة
- التكوين مع غلاف C++/CLI في الوسط
- ما الذي يصبح أيسر مع C++/CLI
- مقتطفات من الشيفرة
- حالات يُفضَّل فيها مع ذلك عدم اختيار C++/CLI
- الخلاصة
- مراجع
في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 21، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle
1. الخلاصة أوّلاً (في جملة)
- إذا كان الطرف مجموعة دوالّ C، فـ P/Invoke هو المسار الطبيعيّ
- إذا كان الطرف مكتبة C++، فصيانة غلاف C++/CLI رفيع واحد أيسر
- ويصدق هذا خصوصاً حين تتداخل الأصناف والملكيّة والسلاسل النصّية والمصفوفات والاستثناءات والـ callbacks
بعبارة أخرى: لا تُدخل مقتضيات الـ native DLL مباشرة إلى C#. يتلقّى جانب C++ مقتضيات الجانب الأصلي، ويُرتَّب السطح المعروض على .NET فقط. حين ينجح هذا التقسيم، تهدأ الشيفرة والتنقيح كثيراً.
flowchart TB
accTitle: الاختيار حسب شكل الـ DLL المقابل
accDescr: يبيّن بالتفريع خلاصة المقال: إن كان الطرف مجموعة دوالّ C فـ P/Invoke طبيعيّ، وإن كان مكتبة C++ فإنّ وضع غلاف C++/CLI واحد أيسر للصيانة.
q{"ما شكل الـ DLL المقابل؟"}
q -->|"مجموعة دوالّ C"| pi["P/Invoke طبيعيّ"]
q -->|"مكتبة C++"| cli["وضع غلاف C++/CLI"]
cli -.-> note["مقتضيات الأصلي تُعالَج في C++"]
الشكل 1: خلاصة الاختيار. إن كان الطرف مجموعة دوالّ C فـ P/Invoke، وإن كان مكتبة C++ فغلاف C++/CLI.
2. الحالات التي يكفي فيها P/Invoke
إن كان P/Invoke يكفي، فهو الأبسط. لا داعي لجلب C++/CLI بالقوّة.
يناسب P/Invoke حالات مثل هذه:
- الواجهة مجموعة دوالّ مسطّحة منشورة بـ
extern "C" - الوسائط والقيم المُرجَعة أعداد صحيحة ومؤشّرات وبنى بسيطة
- اصطلاح السلاسل النصّية واضح، ومسؤوليّة الـ buffer بسيطة
- إدارة الموارد صريحة على نمط
Create/Destroy - جانب C# يستطيع كتابة
SafeHandleوStructLayoutبسلاسة
إن كانت الأمور بهذا الانتظام، يكفي التصريح عنها في C# واستخدامها، والإحساس قريب من استدعاء Windows API، فتصبح الشيفرة أسهل قراءة أيضاً.
flowchart TB
accTitle: شروط يكفي معها P/Invoke
accDescr: يبيّن أنّه إذا اجتمعت واجهة C مسطّحة ووسائط وقيم مُرجَعة بسيطة واصطلاح واضح للسلاسل والموارد، يكفي التصريح في C# والاستخدام.
c1["واجهة دوالّ مسطّحة بـ extern C"] --> ok["P/Invoke يكفي"]
c2["وسائط وقيم مُرجَعة بسيطة"] --> ok
c3["اصطلاح السلاسل والموارد واضح"] --> ok
ok --> use["التصريح في C# والاستخدام فقط"]
الشكل 2: إذا كانت واجهة الطرف منتظمة بهذا القدر، فلا داعي لجلب C++/CLI بالقوّة.
3. الحدّ الذي يصبح فيه P/Invoke شاقاً فجأة
المشكلة حين لا يكون الطرف «مجرّد C API». من هنا يتغيّر المناخ فجأة.
3.1. حين تبدأ بالتعامل مع أصناف C++
إذا كان الـ native DLL مصمَّماً حول أصناف C++، فالمراد في الحقيقة استدعاء توابع الصنف كما هي، لكنّ ما يستطيع P/Invoke مخاطبته مباشرة هو دوالّ التصدير في الـ DLL. أي أنّك تحتاج في موضع ما إلى طبقة تُسقط الواجهة إلى دوالّ بأسلوب C.
عند هذه النقطة، ما تفعله هو كتابة غلاف تقريباً.
إذن، بدلاً من إنبات كمّ كبير من IntPtr ودوالّ التحرير في جانب C#، أطبيع أن تضع الغلاف في جانب C++.
flowchart TB
accTitle: عاقبة اختيار P/Invoke أمام أصناف C++
accDescr: حتّى إن أردت استدعاء توابع صنف C++، فإنّ P/Invoke لا يخاطب سوى دوالّ تصدير الـ DLL، فتلزم طبقة إسقاط إلى أسلوب C، وتصير العملية كتابة غلاف في الجوهر.
want["أريد استدعاء توابع صنف C++"] --> limit["ما يُستدعى هو دوالّ التصدير فقط"]
limit --> bridge["تلزم طبقة إسقاط إلى دوالّ C"]
bridge --> fact["ما تفعله هو غلاف تقريباً"]
fact -.-> better["إذن أطبيع وضعه في جانب C++"]
الشكل 3: حتّى إن حاولت المضيّ بـ P/Invoke، فأمام أصناف C++ ستكتب غلافاً في موضع ما على أيّ حال.
3.2. حين يصعب رؤية الملكيّة وإدارة العمر
في C++، أسئلة كهذه شائعة:
- هل يحرّر المستدعي؟
- هل المؤشّر المُرجَع مُستعار؟
- هل هو
const&أم نقل ملكيّة؟ - هل هناك تخزين مؤقّت داخلي يفرض افتراضاً على العمر؟
إن عبّرت عن ذلك في C# على أساس IntPtr، فقد يعمل أوّلاً، لكنّ العودة إليه لاحقاً شاقة.
ما إن يبدأ سؤال «من يحذف هذا المؤشّر، ومتى؟» حتّى يعكّر الحدّ الفاصل بسرعة.
flowchart TB
accTitle: كيف يعكّر التعبير عن افتراضات الملكيّة بـ IntPtr الحدّ
accDescr: يبيّن أنّ التعبير في C# بـ IntPtr عن من يحرّر، وهل المؤشّر مُستعار أم نقل ملكيّة، وهل للعمر افتراض، يجعل الشيفرة صعبة القراءة لاحقاً ويعكّر الحدّ الفاصل.
q1["من يحرّر؟"] --> ptr["التعبير بـ IntPtr في C#"]
q2["مُستعار أم نقل ملكيّة؟"] --> ptr
q3["هل للعمر افتراض؟"] --> ptr
ptr --> bad["يعمل أوّلاً ثمّ يتعذّر فهمه"]
bad --> muddy["الحدّ يعكّر بسرعة"]
الشكل 4: حمل افتراضات الملكيّة والعمر على أساس IntPtr يعكّر الحدّ بسؤال «من يحذف، ومتى؟».
3.3. حين يظهر std::wstring و std::vector والـ callbacks والاستثناءات
من هنا يدخل P/Invoke منطقة «يمكن كتابته، لكنّه غير مريح».
- تريد تمثيل
std::wstringكما هو من C# - تريد إرجاع
std::vector<T> - تريد استلام تقدّم المعالجة الأصليّة عبر callback
- عند الفشل تُطرَح استثناءات C++
كلّما زادت هذه العناصر، تراكم في جانب C# MarshalAs و buffers يدويّة ومصفوفات ثابتة الطول وإدارة عمر الـ delegate وتفسير رموز الأخطاء.
يمكنك بالطبع أن تكتب ذلك بجهد. غير أنّ موضع الجهد ليس هو الجوهر، وهذا ما يضايق. المراد منطق العمل أو واجهة المستخدم، لا مصارعة الحدّ الفاصل.
flowchart TB
accTitle: تزايد عناصر C++ وتراكم العبء في جانب C#
accDescr: يبيّن أنّه كلّما زادت عناصر مثل إرجاع wstring أو vector، واستلام التقدّم عبر callback، وطرح استثناءات C++، تراكم في جانب C# MarshalAs والـ buffers اليدويّة وإدارة عمر الـ delegate.
e1["أريد إرجاع wstring أو vector"] --> pile["يتراكم الوصف في جانب C#"]
e2["أريد استلام التقدّم عبر callback"] --> pile
e3["عند الفشل تُطرَح استثناءات C++"] --> pile
pile --> load["MarshalAs و buffers يدويّة"]
pile --> load2["إدارة عمر الـ delegate وتفسير الأخطاء"]
الشكل 5: كلّما زادت العناصر بطابع C++، تراكم عبء شيفرة الحدّ في جانب C#.
3.4. حين لا تريد تسريب مقتضيات C++ إلى C#
لا تكون واجهة الـ native DLL موجَّهة إلى C# بالضرورة كما هي.
مثلاً، قد يكون الجانب الأصلي مصمَّماً بحيث:
- تُجمَع عدّة استدعاءات توابع في معالجة واحدة
- يُرجَع الخطأ بقيمة الإرجاع ووسيط out
- لترتيب التهيئة افتراض
- لأمان الخيوط قيد
ومع ذلك تريد في الغالب أن تعرض على جانب C# واجهة أصرح. C++/CLI مناسب جدّاً كطبقة تحويل هنا.
flowchart TB
accTitle: طبقة تحوّل مقتضيات الجانب الأصلي وتعرضها على C#
accDescr: يبيّن تقسيم العمل: تتلقّى طبقة التحويل في C++/CLI مقتضيات تصميم الواجهة الأصليّة مثل ترتيب التهيئة وقيود أمان الخيوط، وتعرض على جانب C# واجهة أصرح.
nat["مقتضيات تصميم الواجهة الأصليّة"] -.-> ex["ترتيب التهيئة وقيود الخيوط وغيرها"]
nat --> conv["طبقة التحويل في C++/CLI"]
conv --> api["عرض واجهة صريحة على C#"]
الشكل 6: لا توجَّه مقتضيات تصميم الجانب الأصلي إلى C# كما هي، بل يُوضَع C++/CLI طبقة تحويل في الوسط.
4. التكوين مع غلاف C++/CLI في الوسط
التكوين بسيط.
flowchart LR
Cs["تطبيق C#"] -->|API موجَّه إلى .NET| Wrapper["DLL غلاف C++/CLI"]
Wrapper -->|التعامل مباشرة مع الترويسات والأنواع الأصليّة| Native["native C++ DLL"]
الشكل 7: تكوين يضع DLL غلاف C++/CLI واحداً بين تطبيق C# و native C++ DLL.
ما يظهر من C# هو واجهة بطابع .NET فقط، وتُحبَس في جانب C++/CLI:
- تحويل السلاسل النصّية
- تحويل المصفوفات والمتّجهات
- تحويل الاستثناءات
- ترتيب الملكيّة
- تفسير رموز الأخطاء
- وإن لزم، امتصاص حدود الخيوط والـ callbacks
المهمّ ألّا يتضخّم مشروع C++/CLI نفسه أكثر ممّا ينبغي. دوره «ترجمة» و«تشكيل» فحسب. إن بدأ يدخل منطق العمل أيضاً، صارت تلك الطبقة هي البطل.
flowchart TB
accTitle: العمل الذي يُحبَس في غلاف C++/CLI
accDescr: يبيّن خطّ الدور: تُحبَس في جانب C++/CLI أعمال الترجمة والتشكيل مثل تحويل السلاسل والمصفوفات وتحويل الاستثناءات ورموز الأخطاء وترتيب الملكيّة، ولا يُدخَل منطق العمل.
w["دور غلاف C++/CLI"] --> t1["تحويل السلاسل والمصفوفات"]
w --> t2["تحويل الاستثناءات ورموز الأخطاء"]
w --> t3["ترتيب الملكيّة"]
w -.-> warn["لا يُدخَل منطق العمل"]
الشكل 8: دور الغلاف يقتصر على «الترجمة» و«التشكيل»، والمهمّ ألّا يتضخّم.
5. ما الذي يصبح أيسر مع C++/CLI
5.1. التعامل مع أنواع C++ كما هي أنواع C++
هذه ميزة كبيرة. في جانب C++/CLI تضمّن الترويسات الأصليّة وتستخدم أنواع C++ كما هي.
أي أنّك لست مضطرّاً إلى «إعادة بناء عالم C++» عنوة في جانب C#.
تستقبل std::wstring و std::vector أوّلاً كأنواع C++، ثمّ تمرّرها إلى جانب .NET بالشكل المطلوب.
flowchart TB
accTitle: استقبال أنواع C++ ثمّ تمريرها إلى .NET
accDescr: يبيّن أنّ تضمين الترويسات الأصليّة واستقبال wstring و vector بأنواع C++ كما هي ثمّ تحويلها إلى الشكل المطلوب وتمريرها إلى .NET يغني عن إعادة بناء عالم C++ في C#.
nt["wstring و vector في الجانب الأصلي"] --> recv["الاستقبال في C++/CLI بأنواع C++"]
recv --> conv["التحويل إلى الشكل المطلوب"]
conv --> net["التمرير إلى جانب .NET"]
net -.-> nofake["لا تُعاد بناء عالم C++ في C#"]
الشكل 9: تُستقبَل أنواع C++ أوّلاً كأنواع C++، ثمّ تُحوَّل وتُمرَّر إلى .NET.
5.2. تشكيل الواجهة لتناسب .NET
تستطيع أن تعرض على جانب C# أشكالاً مألوفة مثل:
stringbyte[]List<T>IDisposable- الاستثناءات
يبدو الفرق بسيطاً، لكنّه يغيّر عبء المستخدم كثيراً. وفي تطوير الفريق خصوصاً، ينفع أن يتمكّن الأعضاء غير العارفين بتفاصيل الجانب الأصلي من التعامل مع الواجهة.
5.3. سهولة ترتيب مسؤوليّة الاستثناءات والأخطاء
إذا اختلطت الاستثناءات ورموز الأخطاء في الجانب الأصلي، فاستقبالها كما هي في C# مزعج. في جانب C++/CLI تستطيع توحيدها مرّة:
- تحويل الاستثناءات إلى استثناءات .NET
- تحويل رموز الأخطاء إلى استثناءات أو أنواع نتائج ذات معنى
- إكمال السياق اللازم للسجلّ
إن تُرجم الفشل عند الحدّ مرّة إلى «فشل ذي معنى»، صفا جانب المستدعي كثيراً.
flowchart TB
accTitle: ترجمة الاستثناءات ورموز الأخطاء عند الحدّ
accDescr: يبيّن توحيد الاستثناءات ورموز الأخطاء المختلطة في الجانب الأصلي مرّة في C++/CLI، وتحويل الاستثناءات إلى استثناءات .NET ورموز الأخطاء إلى شكل ذي معنى، وإكمال السياق اللازم للسجلّ.
mixed["استثناءات ورموز أخطاء الجانب الأصلي"] --> tr["التوحيد مرّة في جانب C++/CLI"]
tr --> e1["تحويل الاستثناءات إلى استثناءات .NET"]
tr --> e2["تحويل رموز الأخطاء إلى شكل ذي معنى"]
tr -.-> log["إكمال السياق اللازم للسجلّ"]
الشكل 10: ترجمة الفشل عند الحدّ مرّة إلى «فشل ذي معنى» تصفّي جانب الاستدعاء في C#.
5.4. إخفاء تذبذب ABI عن جانب C#
أصناف C++ وتوابعها ليست ABI بسيطاً مثل دوالّ C. إذا بدأ C# يعرف تلك التفاصيل مباشرة، ظهرت مقتضيات دوالّ التصدير والـ marshaling على السطح.
بغلاف C++/CLI تستطيع حبس مقتضيات C++ في جانب C++، وعرض سطح مستقرّ فقط على C#. هذا الفصل ينفع أيضاً عند تحديث المكتبة.
flowchart TB
accTitle: حجب تذبذب ABI بالغلاف
accDescr: يبيّن أنّ أصناف C++ وتوابعها ليست ABI بسيطاً مثل دوالّ C، لذا تُحبَس تلك التفاصيل في جانب C++ ويُعرض على C# سطح مستقرّ فقط، فيبقى الفصل نافعاً عند تحديث المكتبة.
abi["ABI أصناف C++ ليس بسيطاً"] --> hide["حبس مقتضيات C++ في جانب C++"]
hide --> stable["عرض سطح مستقرّ فقط على C#"]
stable -.-> update["فصل ينفع أيضاً عند تحديث المكتبة"]
الشكل 11: لا تُظهر مقتضيات الـ marshaling ودوالّ التصدير على السطح، ويُعرض على C# سطح مستقرّ فقط.
5.5. سهولة الترحيل التدريجي
إعادة بناء native DLL قائم دفعة واحدة ثقيلة. بغلاف C++/CLI يسهل الترحيل التدريجي: تغليف الواجهات اللازمة فقط أوّلاً بطبقة رفيعة، والبدء باستخدامها من الشاشات أو مسارات العمل الجديدة في جانب C#.
التوافق جيّد جدّاً حين تريد الإبقاء على أصول Windows القائمة مع تقريب المحيط إلى .NET.
6. مقتطفات من الشيفرة
هنا مقتطفات تكفي لتصوّر الحدّ الفاصل، وليست عيّنة كاملة تعمل كما هي.
6.1. صورة واجهة جانب الـ native DLL
// 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. ستعدّ مثلاً دوالّ جسر منفصلة كهذه.
// C API に落としたブリッジのイメージ
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;
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct AnalyzeOptionsNative
{
public int Threshold;
public IntPtr ModelPath;
}
internal static class NativeMethods
{
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern SafeAnalyzerHandle Analyzer_Create(string licensePath);
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern void Analyzer_Destroy(IntPtr handle);
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern int Analyzer_Analyze(
SafeAnalyzerHandle handle,
string imagePath,
ref AnalyzeOptionsNative options,
out AnalyzeResultNative result);
}
إن اكتفى الأمر بهذا فحسن، لكن في الواقع تزداد نقاط مثل:
- كيف تُرجَع البيانات متغيّرة الطول
- من يحرّر buffer السلسلة
- أين تُوضَع تفاصيل الخطأ
- كيف يُحفَظ عمر الـ callback
أي أنّك ظننت أنّك اخترت P/Invoke، فإذا بك تبدأ تصميم واجهة متوافقة مع C.
flowchart TB
accTitle: كيف يصير مسار P/Invoke تصميم واجهة متوافقة مع C
accDescr: يبيّن المسار: حتّى مع نيّة الاستدعاء المباشر بـ P/Invoke، تُعدّ دوالّ جسر C، وتُكتب SafeHandle و StructLayout في C#، وتزداد نقاط البيانات متغيّرة الطول والتحرير والـ callback، فتبدأ عمليّاً تصميم واجهة متوافقة مع C.
start["نيّة الاستدعاء المباشر بـ P/Invoke"] --> bridge["إعداد دوالّ جسر C على حدة"]
bridge --> decl["كتابة SafeHandle و StructLayout"]
decl --> more["نقاط الطول المتغيّر والتحرير والـ callback"]
more --> real["يبدأ تصميم واجهة متوافقة مع C"]
الشكل 12: كثيراً ما تظنّ أنّك «اخترت P/Invoke فحسب»، ثمّ تكتشف أنّك تصمّم واجهة متوافقة مع C.
هذه النقاط، إن نُقلت إلى C++/CLI، تُستبدَل كما يلي. لا تختفي كالسحر؛ الأدقّ أنّها تنتقل إلى شكل يمكن كتابته طبيعيّاً في جانب C++.
| نقطة تظهر مع P/Invoke | التعامل النموذجيّ في مسار P/Invoke | ماذا يحدث في جانب C++/CLI |
|---|---|---|
| كيف تُرجَع البيانات متغيّرة الطول | إعداد دالتين على مرحلتين: «الاستعلام عن الحجم اللازم» و«ملء الـ buffer»، وتأمين الـ buffer في جانب C# | استقبال std::vector الذي يرجعه الجانب الأصلي كما هو، وإعادة تعبئته في List<int> أو مصفوفة ثمّ إرجاعه (6.3) |
| من يحرّر buffer السلسلة | إضافة دالة تحرير إلى C API، والالتزام باستدعائها حتماً من جانب C# | يكتمل عمر std::wstring في الجانب الأصلي، ويُرجَع إلى C# String^ جديد فقط (6.3) |
| أين تُوضَع تفاصيل الخطأ | إضافة رمز خطأ في القيمة المُرجَعة، مع دالة لاستخراج التفاصيل أو بنية وسيط out | استقبال استثناء الجانب الأصلي بـ try / catch، وتحويله إلى استثناء .NET ذي معنى ثمّ إعادة طرحه (6.3) |
| كيف يُحفَظ عمر الـ callback | الإبقاء على مرجع للـ delegate في حقل ونحوه حتّى لا يجمعه GC | حبس تسجيل الـ callback وإلغائه في جانب C++، وعرض حدث أو delegate فقط على C# |
| كيف تُمثَّل ملكيّة الـ handle | اشتقاق SafeHandle واستدعاء دالة التحرير من ReleaseHandle |
delete للكائن الأصلي في مدمّر / منهي الغلاف (6.3) |
6.3. هكذا يمكن الكتابة بغلاف C++/CLI
في جانب C++/CLI تتلقّى مقتضيات الجانب الأصلي وترتّب الواجهة المعروضة على C#.
// 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;
};
ما يخصّ C++/CLI هنا هو الاثنان ~AnalyzerWrapper() و !AnalyzerWrapper(). يبدو كلاهما مدمّر C++، لكنّ الدور يقابل نمط Dispose في .NET.
| كتابة C++/CLI | ما يولّده المترجم | السلوك كما يراه C# |
|---|---|---|
~AnalyzerWrapper() (المدمّر) |
Dispose() الذي ينفّذ IDisposable |
يُنفَّذ عند الخروج من using، أو عند استدعاء Dispose() |
!AnalyzerWrapper() (المنهي) |
Finalize() الذي يتجاوز Object::Finalize |
يُنفَّذ عندما يجمعه GC. توقيت التشغيل غير محدّد |
القاعدة: تُكتب تحرير الموارد الأصليّة في المنهي، ويُستدعى من المدمّر. في التنفيذ التالي يقتصر ~AnalyzerWrapper() على استدعاء this->!AnalyzerWrapper()، وهذا هو المقصود؛ بهذا إن نسي جانب C# استدعاء Dispose()، يلتقطه GC في النهاية. وإذا استُدعي المدمّر، يُكبح الإنهاء عبر GC::SuppressFinalize فلا يحدث تحرير مزدوج.
لاحظ أنّ Dispose() و Finalize() و Dispose(bool) يولّدها المترجم، فلا تكتبها بنفسك في جانب C++/CLI. وبالعكس، لا تستطيع استدعاء Dispose() مباشرة من شيفرة C++/CLI، بل تستدعي المدمّر بمعامل delete. هذه المقابلة ملخَّصة في فقرة «Destructors and finalizers» من How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn.
flowchart TB
accTitle: تقسيم العمل بين المدمّر والمنهي
accDescr: يبيّن أنّ using أو Dispose في C# يستدعي المدمّر فيحرّر الموارد الأصليّة عبر المنهي، وأنّ نسيان Dispose يجعل GC يستدعي المنهي فيلتقط الأمر أخيراً، وأنّ SuppressFinalize يمنع التحرير المزدوج.
us["using أو Dispose في C#"] --> dtor["المدمّر (يعادل Dispose)"]
forget["نسيان استدعاء Dispose"] -.-> gc["المنهي عند جمع GC"]
dtor --> fin["استدعاء المنهي"]
fin --> del["delete للموارد الأصليّة"]
gc -.-> del
dtor -.-> sup["منع التحرير المزدوج بـ SuppressFinalize"]
الشكل 13: القاعدة أن يُكتب التحرير في المنهي ويُستدعى من المدمّر. حتّى مع نسيان Dispose يلتقط GC الأمر أخيراً.
// AnalyzerWrapper.cpp
#include "AnalyzerWrapper.h"
#include <msclr/marshal_cppstd.h>
using msclr::interop::marshal_as;
AnalyzerWrapper::AnalyzerWrapper(String^ licensePath)
{
_native = new NativeLib::Analyzer(marshal_as<std::wstring>(licensePath));
}
AnalyzerWrapper::~AnalyzerWrapper()
{
this->!AnalyzerWrapper();
}
AnalyzerWrapper::!AnalyzerWrapper()
{
delete _native;
_native = nullptr;
}
AnalysisResult^ AnalyzerWrapper::Analyze(String^ imagePath, AnalysisOptions^ options)
{
// إن استُدعي بعد التخلّص فتوقّف هنا قبل دخول الأصلي.
// المدمّر (= Dispose) يضع _native على nullptr،
// فبدون هذا الفحص تدخل الأصلي عبر مؤشّر فارغ،
// ويسقط العملية كلّها بانتهاك وصول لا باستثناء .NET.
// من جانب C# السلوك المتوقَّع هو ObjectDisposedException عند اللمس بعد Dispose،
// ويلزم في كلّ تابع يستخدم _native
if (_native == nullptr)
{
throw gcnew ObjectDisposedException("AnalyzerWrapper");
}
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"
});
if (!result.Ok)
{
Console.WriteLine(result.Message);
}
ما يظهر من C# هو string و List<int> و IDisposable.
لا تظهر مقتضيات IntPtr ولا دوالّ التحرير ولا buffers السلاسل الأصليّة.
هذا هو الفرق الكبير.
7. حالات يُفضَّل فيها مع ذلك عدم اختيار C++/CLI
بالطبع C++/CLI ليس علاجاً لكلّ شيء. توجد مواضع يُفضَّل فيها عدم اختياره.
- الطرف ينشر أصلاً واجهة C نظيفة
- في هذه الحالة P/Invoke أصرح.
- الحاجة إلى العمل عبر المنصّات
- C++/CLI مفترض على Windows.
- سطح الحدّ صغير والأنواع بسيطة
- قد تفوق تكلفة إضافة DLL غلاف الفائدة.
- النظر بصرامة إلى قيود AOT والتوزيع
- الأولى النظر إلى متطلّبات التكوين بأكمله أوّلاً.
البند الأخير «قيود AOT والتوزيع» مجرّد إن بقي كذلك، لذا نرتّب القيود التي تؤثّر فعلاً. هنا يسهل الزلل إن بقيت على إحساس حقبة .NET Framework.
| القيد | المحتوى | الأثر في العمل |
|---|---|---|
| نظام التشغيل | C++/CLI الذي يستهدف .NET (عائلة .NET Core) مخصَّص لـ Windows | إن وُجدت خطّة للتشغيل في حاوية Linux أو على macOS، لا يمكن اختياره عند هذه النقطة |
| Native AOT | قائمة ما لا يدعمه Native AOT تنصّ صراحة على C++/CLI. ومعه لا تعمل التحميل الديناميكي مثل Assembly.LoadFile، ولا System.Reflection.Emit، ولا COM المضمَّن في Windows |
لا يتوافق مع سياسة جعل ثنائي أصلي واحد عبر PublishAot |
| شكل الإخراج | عند استهداف .NET، لا يمكن إنتاج exe، بل DLL فقط. ولا يمكن استهداف .NET Standard أيضاً |
تضع نقطة الدخول في exe جانب C#، وتُرجع إلى C++/CLI كـ DLL |
| شكل المشروع | لا تستخدم csproj بأسلوب SDK بل .vcxproj. ولا يمكن استهداف عدّة إصدارات .NET من مشروع واحد |
إن احتجت نسخة .NET Framework ونسخة .NET معاً، تفصل ملفّات المشروع |
| اعتماد وقت التشغيل | تعيين /clr يفعّل /MD أيضاً، لذا تلزم DLL وقت تشغيل MSVC. وعند استهداف .NET يلزم أيضاً وضع ijwhost.dll في الإخراج |
إن افترضت توزيع XCOPY أو نشراً بملفّ واحد، راجع ذلك أوّلاً |
| معماريّة المعالج | التجميعة المختلطة تحتوي تعليمات آلة أصليّة، فلا يمكن لثنائي واحد أن يغطي كلّ المعماريّات كما يفعل AnyCPU في C# | تبني وتوزّع لكلّ هدف مثل x86 / x64 |
| طريقة التحميل | من .NET 7 فصاعداً يُحمَّل دائماً في AssemblyLoadContext الافتراضي. وقبل .NET 6 قد يُحمَّل في AssemblyLoadContext آخر إن استُدعي أوّلاً من الجانب الأصلي |
في تكوين يفصل سياق التحميل لكلّ إضافة، راجع السلوك |
لاحظ أنّ مشروع C++/CLI يستطيع استهداف .NET (عائلة .NET Core) من Visual Studio 2019 فصاعداً. إن لم تتوفّر إلّا بيئة أقدم، فستفكّر أوّلاً بافتراض .NET Framework.
أي أنّ معيار الحكم هو: «أين يكون ترجمة تعقيد الـ native DLL أطبيع؟». إن كان بسيطاً فـ P/Invoke، وإن كان معقّداً فـ C++/CLI. بهذا التقسيم ينجح الأمر في الغالب.
flowchart TB
accTitle: مواضع يُفضَّل فيها عدم اختيار C++/CLI
accDescr: يبيّن أنّه يُفضَّل عدم اختيار غلاف C++/CLI في أربعة مواضع: واجهة C نظيفة موجودة سلفاً، الحاجة إلى العمل عبر المنصّات، سطح حدّ صغير وأنواع بسيطة، والنظر بصرامة إلى قيود AOT والتوزيع.
n1["واجهة C موجودة سلفاً"] --> no["عدم اختيار C++/CLI"]
n2["الحاجة إلى دعم عبر المنصّات"] --> no
n3["حدّ صغير وأنواع بسيطة"] --> no
n4["قيود AOT والتوزيع صارمة"] --> no
no -.-> judge["أين تكون الترجمة أطبيع؟"]
الشكل 14: C++/CLI ليس علاجاً لكلّ شيء. إن انطبق أحد هذه الأربعة، فالأولى P/Invoke أو مراجعة التكوين.
8. الخلاصة
P/Invoke ما زال المسار المعتمد لاستخدام native DLL من C#. غير أنّ ذلك حين يكون الطرف صريحاً كواجهة C.
أمّا إن كان الجانب الأصلي مصمَّماً كمكتبة C++، فغالباً ما يحافظ غلاف C++/CLI رفيع على نظافة الحدّ أفضل من صفّ IntPtr وسمات الـ marshaling في جانب C# وبذل الجهد هناك.
وخصوصاً حين تتداخل:
- واجهة قائمة على الأصناف
- افتراضات الملكيّة
std::wstringوstd::vector- تحويل الاستثناءات
- الـ callbacks
- الترحيل التدريجي
فإنّ C++/CLI خيار واقعيّ إلى حدّ بعيد.
العمل ليس برّاقاً. لكنّ «أين تُرتَّب الحدود» يؤثّر بوضوح في سهولة الصيانة لاحقاً. حين تريد الإبقاء على أصول Windows و .NET معاً، يبقى C++/CLI مفيداً.
9. مراجع
- مجموعة شيفرة العيّنة لهذا المقال (مكتبة C++ أصليّة، غلاف C++/CLI، جانب استهلاك C#) - komurasoft-blog-samples (GitHub)
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn
- /clr (Common Language Runtime compilation) - Microsoft Learn
- Native AOT deployment overview - 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
- اعتبارات أداء Interop (C++) - Microsoft Learn
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
هل تعمل تطبيقات الأعمال على Windows بإصدار Arm؟ ── واقع محاكاة x64 (Prism) ومكتبات DLL وCOM الأصليّة
نجيب المطوّرين ومسؤولي الأنظمة عن سؤال «هل تعمل تطبيقات الأعمال على Windows بإصدار Arm؟». نستعرض آليّة محاكاة x64 (Prism)، والطبقات التي ...
لماذا تتعطّل الوسائط ── قواعد وسائط سطر أوامر Windows
في Windows لا توجد مصفوفة وسائط؛ يصل إلى CreateProcess سلسلة واحدة ويتولّى الطرف المستقبل تقسيمها. نشرح قواعد CommandLineToArgvW وCRT و.N...
ما الذي يبقى بعد موت الأب ── تربية العمليات الابنة في كائن Job
لماذا يبقى مساعد SDK ماسكاً الكاميرا أو منفذ COM بعد إنهاء الواجهة قسراً. كيف تجعل كائن Job شجرة العمليات وحدة واحدة وتصمم عمر العملية ال...
الأنابيب المسمّاة عمليّاً ── IPC القياسيّ في Windows من التصميم إلى الأمان
دليل عمليّ للأنابيب المسمّاة، وسيلة IPC القياسيّة في Windows. يغطّي وضع البايت مقابل وضع الرسالة، وخوادم متعدّدة العملاء، وأمان ACL وانتح...
استخدام WMI/CIM من C# وPowerShell ── دليل عمليّ لاسترجاع معلومات العتاد ومراقبة العمليّات والاستعلام عن بُعد
الإجابة الكلاسيكيّة للحصول على الرقم التسلسليّ للحاسوب، ومراقبة المساحة الحرّة على القرص، وكشف بدء عمليّة هي WMI/CIM. يشرح المقال أوامر C...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
ندعم تطوير برامج ويندوز للأعمال، وتكامل الأجهزة، وأدوات التواصل.
الأسئلة الشائعة
أسئلة شائعة حول موضوع هذه المقالة.
- كيف أختار بين P/Invoke وغلاف C++/CLI؟
- إذا كان الطرف الآخر مجموعة دوالّ C مسطّحة منشورة بـ extern "C"، فإنّ P/Invoke هو المسار الطبيعيّ والأبسط. أمّا إذا كان مكتبة قائمة على أصناف C++ وتتداخل فيها الملكيّة والسلاسل النصّية والمصفوفات والاستثناءات والـ callbacks، فصيانة غلاف C++/CLI رفيع واحد أيسر. معيار الحكم هو: أين يكون ترجمة تعقيد الـ native DLL أطبيع؟ إن كان بسيطاً فـ P/Invoke، وإن كان معقّداً فـ C++/CLI، وبهذا التقسيم ينجح الأمر في الغالب.
- ماذا يصبح أيسر عند وضع غلاف C++/CLI؟
- في جانب C++/CLI تستطيع تضمين الترويسات الأصليّة والتعامل مع std::wstring و std::vector بأنواع C++ كما هي، فلا تحتاج إلى إعادة بناء عالم C++ داخل C#. تستطيع أن تعرض على C# واجهة .NET مألوفة فقط: string و byte[] و List<T> و IDisposable والاستثناءات، وأن تخفي IntPtr ودوالّ التحرير ومقتضيات الـ marshaling. ويمكن تحويل استثناءات C++ ورموز الأخطاء عند الحدّ إلى استثناءات .NET، كما يسهل الترحيل التدريجي مع الإبقاء على الأصول القائمة.
- متى يصبح المضيّ بـ P/Invoke وحده شاقاً؟
- إذا كان الـ native DLL مصمَّماً حول أصناف C++، فإنّ ما يستطيع P/Invoke استدعاءه مباشرة هو دوالّ التصدير في الـ DLL فقط، لذا تحتاج في موضع ما إلى طبقة تُسقط الواجهة إلى دوالّ بأسلوب C، وتبدأ عمليّاً بتصميم واجهة متوافقة مع C. وإذا أردت إرجاع std::wstring أو std::vector، أو استلام التقدّم عبر callback، أو كانت استثناءات C++ تُطرَح، تتراكم في جانب C# سمات MarshalAs والـ buffers اليدويّة وإدارة عمر الـ delegate. والتعبير عن افتراضات الملكيّة والعمر على أساس IntPtr يصبح شاقاً عند العودة إلى الشيفرة لاحقاً.
- هل توجد حالات يُفضَّل فيها عدم اختيار C++/CLI؟
- نعم. إذا كان الطرف الآخر ينشر أصلاً واجهة C نظيفة، فإنّ P/Invoke أصرح. كذلك C++/CLI مفترض على Windows، فلا يصلح إن احتجت دعماً عبر المنصّات. وإذا كان سطح الحدّ صغيراً والأنواع بسيطة، فقد تفوق تكلفة إضافة DLL غلاف الفائدة. وإذا كنت تقيّد AOT وقيود التوزيع بصرامة، فالأولى مراجعة متطلّبات التكوين بأكمله أوّلاً.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.