استدعاء 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)

المحتويات

  1. الخلاصة أوّلاً (في جملة)
  2. الحالات التي يكفي فيها P/Invoke
  3. الحدّ الذي يصبح فيه P/Invoke شاقاً فجأة
  4. التكوين مع غلاف C++/CLI في الوسط
  5. ما الذي يصبح أيسر مع C++/CLI
  6. مقتطفات من الشيفرة
  7. حالات يُفضَّل فيها مع ذلك عدم اختيار C++/CLI
  8. الخلاصة
  9. مراجع

في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 21، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle

1. الخلاصة أوّلاً (في جملة)

  • إذا كان الطرف مجموعة دوالّ C، فـ P/Invoke هو المسار الطبيعيّ
  • إذا كان الطرف مكتبة C++، فصيانة غلاف C++/CLI رفيع واحد أيسر
  • ويصدق هذا خصوصاً حين تتداخل الأصناف والملكيّة والسلاسل النصّية والمصفوفات والاستثناءات والـ callbacks

بعبارة أخرى: لا تُدخل مقتضيات الـ native DLL مباشرة إلى C#. يتلقّى جانب C++ مقتضيات الجانب الأصلي، ويُرتَّب السطح المعروض على .NET فقط. حين ينجح هذا التقسيم، تهدأ الشيفرة والتنقيح كثيراً.

الاختيار حسب شكل الـ DLL المقابليبيّن بالتفريع خلاصة المقال: إن كان الطرف مجموعة دوالّ C فـ P/Invoke طبيعيّ، وإن كان مكتبة C++ فإنّ وضع غلاف C++/CLI واحد أيسر للصيانة.مجموعة دوالّ Cمكتبة C++ما شكل الـ DLL المقابل؟P/Invoke طبيعيّوضع غلاف C++/CLIمقتضيات الأصلي تُعالَج في 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، فتصبح الشيفرة أسهل قراءة أيضاً.

شروط يكفي معها P/Invokeيبيّن أنّه إذا اجتمعت واجهة C مسطّحة ووسائط وقيم مُرجَعة بسيطة واصطلاح واضح للسلاسل والموارد، يكفي التصريح في C# والاستخدام.واجهة دوالّ مسطّحة بـ extern CP/Invoke يكفيوسائط وقيم مُرجَعة بسيطةاصطلاح السلاسل والموارد واضحالتصريح في C# والاستخدام فقط

الشكل 2: إذا كانت واجهة الطرف منتظمة بهذا القدر، فلا داعي لجلب C++/CLI بالقوّة.

3. الحدّ الذي يصبح فيه P/Invoke شاقاً فجأة

المشكلة حين لا يكون الطرف «مجرّد C API». من هنا يتغيّر المناخ فجأة.

3.1. حين تبدأ بالتعامل مع أصناف C++

إذا كان الـ native DLL مصمَّماً حول أصناف C++، فالمراد في الحقيقة استدعاء توابع الصنف كما هي، لكنّ ما يستطيع P/Invoke مخاطبته مباشرة هو دوالّ التصدير في الـ DLL. أي أنّك تحتاج في موضع ما إلى طبقة تُسقط الواجهة إلى دوالّ بأسلوب C.

عند هذه النقطة، ما تفعله هو كتابة غلاف تقريباً. إذن، بدلاً من إنبات كمّ كبير من IntPtr ودوالّ التحرير في جانب C#، أطبيع أن تضع الغلاف في جانب C++.

عاقبة اختيار P/Invoke أمام أصناف C++حتّى إن أردت استدعاء توابع صنف C++، فإنّ P/Invoke لا يخاطب سوى دوالّ تصدير الـ DLL، فتلزم طبقة إسقاط إلى أسلوب C، وتصير العملية كتابة غلاف في الجوهر.أريد استدعاء توابع صنف C++ما يُستدعى هو دوالّ التصدير فقطتلزم طبقة إسقاط إلى دوالّ Cما تفعله هو غلاف تقريباًإذن أطبيع وضعه في جانب C++

الشكل 3: حتّى إن حاولت المضيّ بـ P/Invoke، فأمام أصناف C++ ستكتب غلافاً في موضع ما على أيّ حال.

3.2. حين يصعب رؤية الملكيّة وإدارة العمر

في C++، أسئلة كهذه شائعة:

  • هل يحرّر المستدعي؟
  • هل المؤشّر المُرجَع مُستعار؟
  • هل هو const& أم نقل ملكيّة؟
  • هل هناك تخزين مؤقّت داخلي يفرض افتراضاً على العمر؟

إن عبّرت عن ذلك في C# على أساس IntPtr، فقد يعمل أوّلاً، لكنّ العودة إليه لاحقاً شاقة. ما إن يبدأ سؤال «من يحذف هذا المؤشّر، ومتى؟» حتّى يعكّر الحدّ الفاصل بسرعة.

كيف يعكّر التعبير عن افتراضات الملكيّة بـ IntPtr الحدّيبيّن أنّ التعبير في C# بـ IntPtr عن من يحرّر، وهل المؤشّر مُستعار أم نقل ملكيّة، وهل للعمر افتراض، يجعل الشيفرة صعبة القراءة لاحقاً ويعكّر الحدّ الفاصل.من يحرّر؟التعبير بـ IntPtr في C#مُستعار أم نقل ملكيّة؟هل للعمر افتراض؟يعمل أوّلاً ثمّ يتعذّر فهمهالحدّ يعكّر بسرعة

الشكل 4: حمل افتراضات الملكيّة والعمر على أساس IntPtr يعكّر الحدّ بسؤال «من يحذف، ومتى؟».

3.3. حين يظهر std::wstring و std::vector والـ callbacks والاستثناءات

من هنا يدخل P/Invoke منطقة «يمكن كتابته، لكنّه غير مريح».

  • تريد تمثيل std::wstring كما هو من C#
  • تريد إرجاع std::vector<T>
  • تريد استلام تقدّم المعالجة الأصليّة عبر callback
  • عند الفشل تُطرَح استثناءات C++

كلّما زادت هذه العناصر، تراكم في جانب C# MarshalAs و buffers يدويّة ومصفوفات ثابتة الطول وإدارة عمر الـ delegate وتفسير رموز الأخطاء.

يمكنك بالطبع أن تكتب ذلك بجهد. غير أنّ موضع الجهد ليس هو الجوهر، وهذا ما يضايق. المراد منطق العمل أو واجهة المستخدم، لا مصارعة الحدّ الفاصل.

تزايد عناصر C++ وتراكم العبء في جانب C#يبيّن أنّه كلّما زادت عناصر مثل إرجاع wstring أو vector، واستلام التقدّم عبر callback، وطرح استثناءات C++، تراكم في جانب C# MarshalAs والـ buffers اليدويّة وإدارة عمر الـ delegate.أريد إرجاع wstring أو vectorيتراكم الوصف في جانب C#أريد استلام التقدّم عبر callbackعند الفشل تُطرَح استثناءات C++MarshalAs و buffers يدويّةإدارة عمر الـ delegate وتفسير الأخطاء

الشكل 5: كلّما زادت العناصر بطابع C++، تراكم عبء شيفرة الحدّ في جانب C#.

3.4. حين لا تريد تسريب مقتضيات C++ إلى C#

لا تكون واجهة الـ native DLL موجَّهة إلى C# بالضرورة كما هي.

مثلاً، قد يكون الجانب الأصلي مصمَّماً بحيث:

  • تُجمَع عدّة استدعاءات توابع في معالجة واحدة
  • يُرجَع الخطأ بقيمة الإرجاع ووسيط out
  • لترتيب التهيئة افتراض
  • لأمان الخيوط قيد

ومع ذلك تريد في الغالب أن تعرض على جانب C# واجهة أصرح. C++/CLI مناسب جدّاً كطبقة تحويل هنا.

طبقة تحوّل مقتضيات الجانب الأصلي وتعرضها على C#يبيّن تقسيم العمل: تتلقّى طبقة التحويل في C++/CLI مقتضيات تصميم الواجهة الأصليّة مثل ترتيب التهيئة وقيود أمان الخيوط، وتعرض على جانب C# واجهة أصرح.مقتضيات تصميم الواجهة الأصليّةترتيب التهيئة وقيود الخيوط وغيرهاطبقة التحويل في C++/CLIعرض واجهة صريحة على C#

الشكل 6: لا توجَّه مقتضيات تصميم الجانب الأصلي إلى C# كما هي، بل يُوضَع C++/CLI طبقة تحويل في الوسط.

4. التكوين مع غلاف C++/CLI في الوسط

التكوين بسيط.

API موجَّه إلى .NETالتعامل مباشرة مع الترويسات والأنواع الأصليّةتطبيق C#DLL غلاف C++/CLInative C++ DLL

الشكل 7: تكوين يضع DLL غلاف C++/CLI واحداً بين تطبيق C# و native C++ DLL.

ما يظهر من C# هو واجهة بطابع .NET فقط، وتُحبَس في جانب C++/CLI:

  • تحويل السلاسل النصّية
  • تحويل المصفوفات والمتّجهات
  • تحويل الاستثناءات
  • ترتيب الملكيّة
  • تفسير رموز الأخطاء
  • وإن لزم، امتصاص حدود الخيوط والـ callbacks

المهمّ ألّا يتضخّم مشروع C++/CLI نفسه أكثر ممّا ينبغي. دوره «ترجمة» و«تشكيل» فحسب. إن بدأ يدخل منطق العمل أيضاً، صارت تلك الطبقة هي البطل.

العمل الذي يُحبَس في غلاف C++/CLIيبيّن خطّ الدور: تُحبَس في جانب C++/CLI أعمال الترجمة والتشكيل مثل تحويل السلاسل والمصفوفات وتحويل الاستثناءات ورموز الأخطاء وترتيب الملكيّة، ولا يُدخَل منطق العمل.دور غلاف C++/CLIتحويل السلاسل والمصفوفاتتحويل الاستثناءات ورموز الأخطاءترتيب الملكيّةلا يُدخَل منطق العمل

الشكل 8: دور الغلاف يقتصر على «الترجمة» و«التشكيل»، والمهمّ ألّا يتضخّم.

5. ما الذي يصبح أيسر مع C++/CLI

5.1. التعامل مع أنواع C++ كما هي أنواع C++

هذه ميزة كبيرة. في جانب C++/CLI تضمّن الترويسات الأصليّة وتستخدم أنواع C++ كما هي.

أي أنّك لست مضطرّاً إلى «إعادة بناء عالم C++» عنوة في جانب C#. تستقبل std::wstring و std::vector أوّلاً كأنواع C++، ثمّ تمرّرها إلى جانب .NET بالشكل المطلوب.

استقبال أنواع C++ ثمّ تمريرها إلى .NETيبيّن أنّ تضمين الترويسات الأصليّة واستقبال wstring و vector بأنواع C++ كما هي ثمّ تحويلها إلى الشكل المطلوب وتمريرها إلى .NET يغني عن إعادة بناء عالم C++ في C#.wstring و vector في الجانب الأصليالاستقبال في C++/CLI بأنواع C++التحويل إلى الشكل المطلوبالتمرير إلى جانب .NETلا تُعاد بناء عالم C++ في C#

الشكل 9: تُستقبَل أنواع C++ أوّلاً كأنواع C++، ثمّ تُحوَّل وتُمرَّر إلى .NET.

5.2. تشكيل الواجهة لتناسب .NET

تستطيع أن تعرض على جانب C# أشكالاً مألوفة مثل:

  • string
  • byte[]
  • List<T>
  • IDisposable
  • الاستثناءات

يبدو الفرق بسيطاً، لكنّه يغيّر عبء المستخدم كثيراً. وفي تطوير الفريق خصوصاً، ينفع أن يتمكّن الأعضاء غير العارفين بتفاصيل الجانب الأصلي من التعامل مع الواجهة.

5.3. سهولة ترتيب مسؤوليّة الاستثناءات والأخطاء

إذا اختلطت الاستثناءات ورموز الأخطاء في الجانب الأصلي، فاستقبالها كما هي في C# مزعج. في جانب C++/CLI تستطيع توحيدها مرّة:

  • تحويل الاستثناءات إلى استثناءات .NET
  • تحويل رموز الأخطاء إلى استثناءات أو أنواع نتائج ذات معنى
  • إكمال السياق اللازم للسجلّ

إن تُرجم الفشل عند الحدّ مرّة إلى «فشل ذي معنى»، صفا جانب المستدعي كثيراً.

ترجمة الاستثناءات ورموز الأخطاء عند الحدّيبيّن توحيد الاستثناءات ورموز الأخطاء المختلطة في الجانب الأصلي مرّة في C++/CLI، وتحويل الاستثناءات إلى استثناءات .NET ورموز الأخطاء إلى شكل ذي معنى، وإكمال السياق اللازم للسجلّ.استثناءات ورموز أخطاء الجانب الأصليالتوحيد مرّة في جانب C++/CLIتحويل الاستثناءات إلى استثناءات .NETتحويل رموز الأخطاء إلى شكل ذي معنىإكمال السياق اللازم للسجلّ

الشكل 10: ترجمة الفشل عند الحدّ مرّة إلى «فشل ذي معنى» تصفّي جانب الاستدعاء في C#.

5.4. إخفاء تذبذب ABI عن جانب C#

أصناف C++ وتوابعها ليست ABI بسيطاً مثل دوالّ C. إذا بدأ C# يعرف تلك التفاصيل مباشرة، ظهرت مقتضيات دوالّ التصدير والـ marshaling على السطح.

بغلاف C++/CLI تستطيع حبس مقتضيات C++ في جانب C++، وعرض سطح مستقرّ فقط على C#. هذا الفصل ينفع أيضاً عند تحديث المكتبة.

حجب تذبذب ABI بالغلافيبيّن أنّ أصناف C++ وتوابعها ليست ABI بسيطاً مثل دوالّ C، لذا تُحبَس تلك التفاصيل في جانب C++ ويُعرض على C# سطح مستقرّ فقط، فيبقى الفصل نافعاً عند تحديث المكتبة.ABI أصناف C++ ليس بسيطاًحبس مقتضيات C++ في جانب C++عرض سطح مستقرّ فقط على C#فصل ينفع أيضاً عند تحديث المكتبة

الشكل 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.

كيف يصير مسار P/Invoke تصميم واجهة متوافقة مع Cيبيّن المسار: حتّى مع نيّة الاستدعاء المباشر بـ P/Invoke، تُعدّ دوالّ جسر C، وتُكتب SafeHandle و StructLayout في C#، وتزداد نقاط البيانات متغيّرة الطول والتحرير والـ callback، فتبدأ عمليّاً تصميم واجهة متوافقة مع C.نيّة الاستدعاء المباشر بـ P/Invokeإعداد دوالّ جسر C على حدةكتابة SafeHandle و StructLayoutنقاط الطول المتغيّر والتحرير والـ callbackيبدأ تصميم واجهة متوافقة مع 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.

تقسيم العمل بين المدمّر والمنهييبيّن أنّ using أو Dispose في C# يستدعي المدمّر فيحرّر الموارد الأصليّة عبر المنهي، وأنّ نسيان Dispose يجعل GC يستدعي المنهي فيلتقط الأمر أخيراً، وأنّ SuppressFinalize يمنع التحرير المزدوج.using أو Dispose في C#المدمّر (يعادل Dispose)نسيان استدعاء Disposeالمنهي عند جمع GCاستدعاء المنهيdelete للموارد الأصليّةمنع التحرير المزدوج بـ 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. بهذا التقسيم ينجح الأمر في الغالب.

مواضع يُفضَّل فيها عدم اختيار C++/CLIيبيّن أنّه يُفضَّل عدم اختيار غلاف C++/CLI في أربعة مواضع: واجهة C نظيفة موجودة سلفاً، الحاجة إلى العمل عبر المنصّات، سطح حدّ صغير وأنواع بسيطة، والنظر بصرامة إلى قيود AOT والتوزيع.واجهة C موجودة سلفاًعدم اختيار C++/CLIالحاجة إلى دعم عبر المنصّاتحدّ صغير وأنواع بسيطةقيود AOT والتوزيع صارمةأين تكون الترجمة أطبيع؟

الشكل 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. مراجع

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

كيف أختار بين 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 وقيود التوزيع بصرامة، فالأولى مراجعة متطلّبات التكوين بأكمله أوّلاً.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

العودة إلى المدونة