קריאה מ-C# ל-DLL נייטיב: עטיפת C++/CLI מול P/Invoke

· עודכן בתאריך: · · C++/CLI, C#, פיתוח Windows, אינטגרציה נייטיבית

הדרישה להשתמש מתוך C# בנכסים קיימים או ב-DLL קיים של Windows היא די נפוצה. אם הצד השני הוא ממשק 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)

קהל היעד וההנחות

המאמר נכתב עבור מפתחים שכבר קראו ל-DLL נייטיב מ-C# ויודעים לכתוב הצהרת DllImport, אבל נעצרים כשהצד השני הופך לספריית מחלקות C++. אין צורך בניסיון קודם ב-C++/CLI עצמו. מצד שני, אם עדיין לא כתבתם P/Invoke, מהיר יותר לקרוא קודם את “קריאה בטוחה ל-Win32 API מ-C# — מדריך מעשי ל-P/Invoke”.

הסביבה המונחת היא Windows‏ + Visual Studio 2022, במבנה שבו אפשר להניח את עטיפת ה-C++/CLI (.vcxproj) ואת פרויקט ה-C# באותו פתרון (solution). היעד יכול להיות גם ‎.NET Framework וגם למשל ‎.NET 8, אבל בצד ‎.NET יש מגבלות ייחודיות שמסוכמות בפרק 7.

מונחים לדעת מראש

מונח משמעות
P/Invoke (Platform Invoke) מנגנון שבו מצהירים בעזרת attribute מסוג DllImport / LibraryImport ב-C# על פונקציית export של DLL נייטיב, וקוראים לה ישירות
מרשלינג המרה הדדית בגבול, בין טיפוסי ‎.NET (string, מערכים וכדומה) לייצוג הנייטיבי (wchar_t*, מצביע גולמי וכדומה)
ABI (Application Binary Interface) ההסכמות שמאפשרות לבינארי מהודר להתאים זה לזה: כלל הקריאה, אופן העברת הארגומנטים, פריסת הזיכרון של מבנים, name mangling וכדומה. לפונקציות C יש הסכמה פשוטה ויציבה, אבל במחלקות C++ ה-name mangling ופריסת ה-vtable תלויים במהדר, ואי אפשר להסתמך עליהם ישירות מ-C# (5.4)
SafeHandle מחלקה מופשטת ב-‎.NET שעוטפת handle נייטיבי. משמשת במקום להחזיק IntPtr חשוף, כדי למנוע דליפת שחרור ותקרית של “שוחרר בזמן שהוא בשימוש” (6.2)
StructLayout attribute להתאמת פריסת הזיכרון של מבנה C# לצד הנייטיבי. משמש למשל עם LayoutKind.Sequential כדי לסדר לפי סדר ההצהרה, ועם CharSet לציון אופן הטיפול במחרוזות (6.2)
marshal_as פונקציית עזר להמרה שמספק C++/CLI. ממירה הדדית בין טיפוסי ‎.NET לטיפוסים נייטיביים, כמו marshal_as<std::wstring>(managedString). נעשה בה שימוש לאחר כלילת כותרות כמו msclr/marshal_cppstd.h (6.3)
assembly מעורב DLL שמכיל גם הוראות מכונה נייטיביות וגם MSIL. עטיפת C++/CLI היא כזו (פרק 7)

תוכן עניינים

  1. קודם המסקנה (במשפט אחד)
  2. מקרים שבהם P/Invoke מספיק
  3. הגבול שבו P/Invoke נהיה פתאום קשה
  4. המבנה שמוסיף עטיפת C++/CLI
  5. מה נעשה קל יותר עם C++/CLI
  6. קטעי קוד
  7. מקרים שבהם עדיין עדיף לא לבחור ב-C++/CLI
  8. סיכום
  9. מקורות

מפת הידע של המאמר

המאמר הזה מסדר שכאשר קוראים מ-C# ל-DLL נייטיב, אם הצד השני הוא קבוצת פונקציות שטוחות של extern C,‏ P/Invoke טבעי, אך אם זו ספרייה מבוססת מחלקות C++ שמעורבים בה בעלות, מחרוזות, חריגות ו-callback, קל יותר לתחזוקה להוסיף עטיפת C++/CLI דקה. ‏P/Invoke נוטה לייצג טיפוסים נייטיביים עם SafeHandle ו-StructLayout, אך בסופו של דבר לכתוב בעצמכם שכבת גשר בסגנון C, בעוד C++/CLI סוגר בצד C++ את המרת הטיפוסים עם marshal_as, את דפוס ה-Dispose/Finalize דרך דסטרוקטור ופינליזר, ואת המרת החריגות ל-‎.NET, וחושף ל-C# רק API יציב. מצד שני, C++/CLI מיועד ל-Windows בלבד ואינו תואם ל-Native AOT, ומחייב תלות בקומפילציה עם /clr וב-ijwhost.dll, ולכן כשנדרשת תמיכה בריבוי פלטפורמות או שמגבלות ההפצה מחמירות, אי אפשר לבחור בו.

מפת הידע של החלוקה בין עטיפת C++/CLI ל-P/Invokeתרשים המראה איך בוחרים בין P/Invoke לעטיפת C++/CLI בהתאם למורכבות ה-DLL הנייטיב, ואיך משתנה הטיפול במרשלינג, בעלות, חריגות, callback, ומגבלות ההפצה.מחייבמענה מומלץ למענה מומלץ לשימוש לא מומלץ לשימוש לא מומלץ למשתמש במממש אתמשתמש במשתמש במממש אתמחייבמממש אתמחייבמחייבאינו מתיישב עםמחייבמוגדר באמצעותמממש אתמחייביורש אתאינו מתיישב עםC++/CLIP/Invokeשכבת גשר של C API‏C API שטוחספריית C++ נייטיב מבוססת מחלקותמרשלינג (marshaling)marshal_asSafeHandleStructLayoutדפוס Dispose/Finalize‏ (C++/CLI)ניהול בעלות ואורך חייםתרגום החריגה ל-‎.NETניהול אורך החיים של ה-delegate של ה-callbackNative AOTijwhost.dllאפשרות הקומפילציה ‎/clr‏assembly מעורבדרישת ריבוי פלטפורמות

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 21, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

1. קודם המסקנה (במשפט אחד)

  • אם הצד השני הוא קבוצת פונקציות C, P/Invoke הוא הפתרון הטבעי
  • אם הצד השני הוא ספריית C++, הוספת עטיפת C++/CLI אחת מקלה על התחזוקה
  • במיוחד כשמעורבים מחלקות, בעלות, מחרוזות, מערכים, חריגות ו-callback, עדיף לא להעמיס על צד ה-C#

במילים אחרות, לא מכניסים את הצרכים של ה-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 מתאים, לדוגמה, למקרים הבאים.

  • ה-API הוא פונקציות שטוחות שנחשפו עם extern "C"
  • הארגומנטים וערכי ההחזרה מספיקים כמספרים שלמים, מצביעים, מבנים פשוטים וכדומה
  • הסכמת המחרוזות ברורה, ואחריות המאגר (buffer) פשוטה
  • ניהול המשאבים ברור, כמו Create / Destroy
  • אפשר לכתוב SafeHandle ו-StructLayout בפשטות בצד C#

אם המצב מסודר כך, מספיק להצהיר ולהשתמש בצד C#, וזה קרוב לתחושה של קריאה ל-Windows API, כך שהמימוש נשאר קריא.

התנאים שמאפשרים לקבוע ש-P/Invoke מספיקכשמתקיימים התנאים של API שטוח ב-C, ארגומנטים וערכי החזרה פשוטים, והסכמת מחרוזות ומשאבים ברורה, מספיק להצהיר ולהשתמש בצד C#.API שטוח ב-C(extern C)P/Invoke מספיקארגומנטים וערכי החזרה פשוטיםהסכמת מחרוזות ומשאבים ברורהמספיק להצהיר ולהשתמש בצד C#

איור 2: אם ה-API של הצד השני מסודר כך, אין צורך להכניס C++/CLI בכוח.

3. הגבול שבו P/Invoke נהיה פתאום קשה

הבעיה מתחילה כשהצד השני הוא לא “רק C API”. מכאן האווירה משתנה פתאום.

3.1. כשמתחילים להתמודד עם מחלקות C++

כש-DLL הנייטיב מתוכנן סביב מחלקות C++, בעצם רוצים לקרוא ישירות לשיטות (methods) של המחלקה, אבל מה ש-P/Invoke יכול להתמודד איתו ישירות הן רק פונקציות ה-export של ה-DLL. כלומר, בסופו של דבר נדרשת שכבה שמורידה לפונקציות בסגנון C במקום כלשהו.

בשלב הזה, מה שעושים זה למעשה “לכתוב עטיפה”. אם כך, עדיף להעביר את העטיפה לצד C++, במקום לגדל בצד C# הרבה IntPtr ופונקציות שחרור.

התוצאה של בחירה ב-P/Invoke מול מחלקות C++רוצים לקרוא לשיטות של מחלקת C++, אבל P/Invoke יכול להתמודד רק עם פונקציות export, כך שנדרשת שכבה שמורידה לצורת C, ולמעשה כותבים עטיפה.רוצים לקרוא לשיטות של מחלקת C++אפשר לקרוא רק לפונקציות exportנדרשת שכבה שמורידה לצורת Cמה שעושים זה כמעט לכתוב עטיפהאז עדיף להעביר אותה לצד C++

איור 3: גם אם מנסים להסתדר עם P/Invoke, כשהצד השני הוא מחלקת C++, בסופו של דבר כותבים עטיפה במקום כלשהו.

3.2. כשקשה לראות בעלות וניהול אורך חיים

ב-C++, קיימות שאלות רגילות כמו:

  • האם הצד הקורא משחרר
  • האם המצביע המוחזר הוא “הלוואה”
  • האם זה const& או העברת בעלות
  • האם יש מטמון פנימי עם הנחות על אורך חיים

אם מייצגים את זה בבסיס IntPtr ב-C#, זה עשוי לעבוד בהתחלה, אבל קשה מאוד לקרוא מחדש בהמשך. כשמתחילה “בעיית מי ומתי מוחק את המצביע הזה”, ממשק הגבול מתעכר במהירות.

איך ייצוג ב-IntPtr מעכר את הנחות הבעלותהשאלות מי משחרר, האם זה הלוואה או העברת בעלות, והאם יש הנחות על אורך חיים - כשמייצגים אותן בבסיס IntPtr ב-C#, קשה לקרוא מחדש בהמשך וממשק הגבול מתעכר.מי משחררמיוצג בבסיס IntPtr של C#הלוואה או העברת בעלותהאם יש הנחות על אורך חייםעובד בהתחלה אך קשה לקריאה בהמשךממשק הגבול מתעכר מהר

איור 4: כשמייצגים הנחות של בעלות ואורך חיים בבסיס IntPtr, מגיעה “בעיית מי ומתי מוחק” וממשק הגבול מתעכר.

3.3. כש-std::wstring,‏ std::vector, callback וחריגות נכנסים לתמונה

מכאן, P/Invoke נכנס לתחום של “אפשר לכתוב, אבל זה לא נעים”.

  • רוצים לייצג std::wstring ישירות מ-C#
  • רוצים להחזיר std::vector<T>
  • רוצים לקבל התקדמות של עיבוד נייטיבי דרך callback
  • במקרה כישלון נזרקת חריגת C++

ככל שהיסודות האלה מצטברים, מצטברים בצד C# גם MarshalAs, מאגרים ידניים, מערכים באורך קבוע, ניהול אורך חיים של delegate ופענוח קודי שגיאה.

כמובן שאפשר לכתוב את זה במאמץ. אבל הקושי הוא שהמאמץ הזה לא במקום הנכון. מה שבאמת רוצים לעשות הוא לוגיקה עסקית או UI, ולא התמודדות פיזית עם ממשק הגבול.

הצטברות העומס בצד C# עם עלייה ביסודות C++ככל שמצטברים הרצון להחזיר wstring או vector, לקבל התקדמות דרך callback, וזריקת חריגות C++, כך מצטברים בצד C# MarshalAs, מאגרים ידניים, וניהול אורך חיים של delegate.רוצים להחזיר wstring או vectorהקוד בצד C# מצטבררוצים לקבל התקדמות דרך callbackחריגת C++ נזרקת במקרה כישלוןMarshalAs, מאגרים ידנייםניהול אורך חיים של delegate, פענוח שגיאות

איור 5: ככל שיסודות אופייניים ל-C++ מצטברים, כך מצטבר גם העומס על קוד ממשק הגבול בצד C#.

3.4. כשלא רוצים “לדלוף” את הצרכים של C++ ל-C#

ה-API בצד ה-DLL הנייטיב לא בהכרח מתאים ישירות ל-C#.

לדוגמה, בצד הנייטיב:

  • משלבים כמה קריאות שיטה לתהליך אחד
  • שגיאות מוחזרות בערך החזרה ובארגומנט out
  • יש הנחות על סדר האתחול
  • יש מגבלות של בטיחות לריבוי ת’רדים

גם עם תכנון כזה, לעיתים קרובות רוצים להראות לצד C# API טבעי הרבה יותר. כשכבה שממירה את זה, C++/CLI נוח מאוד.

שכבה שממירה את צרכי הנייטיב ומציגה API ל-C#צרכי התכנון של ה-API הנייטיבי, כמו סדר אתחול ומגבלות בטיחות ת'רדים, מתקבלים בשכבת ההמרה של C++/CLI, ולצד C# מוצג API טבעי הרבה יותר.צרכי התכנון של ה-API הנייטיביסדר אתחול, מגבלות ת'רדים וכדומהשכבת ההמרה של C++/CLIל-C# מוצג API טבעי

איור 6: לא חושפים ל-C# ישירות את צרכי התכנון של הצד הנייטיב, אלא מוסיפים את C++/CLI כשכבת המרה.

4. המבנה שמוסיף עטיפת C++/CLI

מבחינת המבנה, זה פשוט.

מבנה שלושת השכבות של האפליקציהתרשים המראה שאפליקציית C# קוראת ל-DLL עטיפת C++/CLI דרך API מיועד ל-.NET, והעטיפה קוראת ל-DLL C++ נייטיב תוך שימוש ישיר בכותרות ובטיפוסים הנייטיביים.API מיועד ל-.NETמשתמש ישירות בכותרות ובטיפוסים נייטיבייםאפליקציית C#DLL עטיפת C++/CLIDLL נייטיב C++

איור 7: מבנה שמכניס DLL עטיפת C++/CLI אחד בין אפליקציית C# ל-DLL הנייטיב של C++.

מה שנראה מ-C# הוא רק API בסגנון ‎.NET, ו:

  • המרת מחרוזות
  • המרת מערכים ווקטורים
  • המרת חריגות
  • סידור הבעלות
  • פענוח קודי שגיאה
  • במידת הצורך, ספיגה של גבולות ת’רד ו-callback

נסגרים בצד C++/CLI.

מה שחשוב הוא לא לגדל יותר מדי את פרויקט ה-C++/CLI עצמו. התפקיד הוא רק “תרגום” ו”עיצוב”. אם מתחילים להכניס גם לוגיקה עסקית, השכבה הזו הופכת בעצמה לגיבורה הראשית.

העבודה שנסגרת בתוך עטיפת C++/CLIתפקיד עטיפת C++/CLI הוא המרת מחרוזות ומערכים, המרת חריגות וקודי שגיאה, וסידור בעלות - עבודת תרגום ועיצוב בלבד, בלי לוגיקה עסקית.תפקיד עטיפת C++/CLIהמרת מחרוזות ומערכיםהמרת חריגות וקודי שגיאהסידור הבעלותלא מכניסים לוגיקה עסקית

איור 8: תפקיד העטיפה מוגבל ל”תרגום” ו”עיצוב”, וחשוב לא לגדל אותו יותר מדי.

5. מה נעשה קל יותר עם C++/CLI

5.1. אפשר לטפל בטיפוסי C++ כפי שהם

זהו יתרון גדול מאוד. בצד C++/CLI אפשר לכלול (include) את הכותרות הנייטיביות, ולהשתמש בטיפוסי C++ כפי שהם.

כלומר, אין צורך “לשחזר את עולם ה-C++” בכוח בצד C#. גם std::wstring וגם std::vector אפשר לקבל תחילה כטיפוסי C++, ולהעביר ל-‎.NET בצורה הנדרשת רק אחר כך.

הזרימה מקבלת טיפוסי C++ ועד המעבר ל-.NETכוללים את הכותרות הנייטיביות, מקבלים wstring או vector כטיפוסי C++ בצד C++/CLI, ממירים לצורה הנדרשת, ומעבירים לצד .NET - כך שבצד C# לא צריך לשחזר את עולם ה-C++.wstring או vector נייטיבימתקבל כטיפוס C++ בצד C++/CLIממירים לצורה הנדרשתמועבר לצד .NETב-C# לא משחזרים את עולם ה-C++

איור 9: מקבלים תחילה את טיפוסי C++ כטיפוסי C++, וממירים אותם ל-‎.NET רק כשצריך.

5.2. אפשר לעצב את ה-API בהתאם ל-‎.NET

בצד C# אפשר לחשוף:

  • string
  • byte[]
  • List<T>
  • IDisposable
  • חריגות

בצורה מוכרת שכזו.

ההבדל הזה נראה קטן, אבל הוא משנה משמעותית את העומס על צד המשתמש. במיוחד בפיתוח צוותי, זה עוזר שגם חברי צוות שלא מכירים את הצד הנייטיבי יכולים לגעת בקוד בקלות.

5.3. קל יותר לסדר את האחריות על חריגות ושגיאות

כשבצד הנייטיב מעורבים גם חריגות וגם קודי שגיאה, קשה לטפל בזה כפי שהוא בצד C#. בצד C++/CLI אפשר לרכז את זה פעם אחת:

  • להמיר חריגות לחריגות ‎.NET
  • להמיר קודי שגיאה לחריגות או לטיפוסי תוצאה בעלי משמעות
  • להוסיף את ההקשר הדרוש ליומן (log)

אם מתרגמים פעם אחת בגבול ל”כישלון בעל משמעות”, הצד הקורא נשאר הרבה יותר נקי.

תרגום חריגות וקודי שגיאה בגבולחריגות וקודי שגיאה שמעורבים בצד הנייטיב מרוכזים פעם אחת בצד C++/CLI, החריגות מומרות לחריגות .NET, קודי השגיאה מומרים לצורה בעלת משמעות, ומתווסף הקשר הדרוש ליומן.חריגות וקודי שגיאה נייטיבייםמרוכזים פעם אחת בצד C++/CLIחריגות מומרות לחריגות .NETקודי שגיאה מומרים לצורה בעלת משמעותמתווסף הקשר הדרוש ליומן

איור 10: תרגום חד-פעמי ל”כישלון בעל משמעות” בגבול משאיר את הצד הקורא ב-C# נקי הרבה יותר.

5.4. אפשר להסתיר מ-C# את השונות ב-ABI

מחלקות ושיטות של C++ אינן ABI פשוט כמו פונקציות C. כש-C# מתחיל לדעת ישירות על העניינים האלה, צרכי פונקציות ה-export והמרשלינג צפים החוצה.

כשמוסיפים עטיפת C++/CLI, אפשר לסגור את הצרכים של C++ בצד C++, ולהראות ל-C# רק פנים יציבה. ההפרדה הזו עוזרת גם כשמעדכנים את הספרייה.

חסימת השונות ב-ABI על ידי העטיפהמחלקות ושיטות C++ אינן ABI פשוט כמו פונקציות C, ולכן הצרכים האלה נסגרים בצד C++, ול-C# מוצגת רק פנים יציבה, וההפרדה הזו עוזרת גם בעדכוני ספרייה.ABI של מחלקת C++ אינו פשוטצרכי C++ נסגרים בצד C++ל-C# מוצגת רק פנים יציבהההפרדה עוזרת גם בעדכוני ספרייה

איור 11: לא חושפים את צרכי המרשלינג ופונקציות ה-export, ומראים ל-C# רק פנים יציבה.

5.5. קל לבצע מעבר הדרגתי

לבנות מחדש בבת אחת את כל ה-DLL הנייטיב הקיים הוא מעמסה כבדה. עם עטיפת C++/CLI, אפשר לעטוף תחילה רק את ה-API הדרוש בעטיפה דקה, ולהתחיל להשתמש בו ממסכים חדשים או מתהליכי עבודה חדשים בצד C# - מה שמקל על מעבר הדרגתי.

זה מתאים היטב במצבים שבהם רוצים לנצל נכסי Windows קיימים תוך העברת הסביבה שלהם בהדרגה ל-‎.NET.

6. קטעי קוד

כאן לא נביא “דוגמה מלאה שרצה כמו שהיא”, אלא רק קטעים שמראים את התמונה הכללית של ממשק הגבול.

6.1. דוגמה ל-API בצד ה-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);
    };
}

ה-API הזה רגיל לגמרי כ-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);
}

אם זה מספיק, מצוין, אבל בפועל יש עוד:

  • איך מחזירים נתונים באורך משתנה
  • מי משחרר את מאגר המחרוזת
  • היכן שמים את פרטי השגיאה
  • איך שומרים על אורך חיי ה-callback

שאלות מהסוג הזה מצטברות.

כלומר, לעיתים קרובות חשבתם שבחרתם ב-P/Invoke, אבל בפועל התחלתם לתכנן API תואם-C.

איך תוכנית P/Invoke הופכת בפועל לתכנון API תואם-Cמתכננים לקרוא ישירות עם P/Invoke, מכינים פונקציות גשר C בנפרד, כותבים SafeHandle ו-StructLayout בצד C#, ומצטברות שאלות של נתונים משתנים, שחרור ו-callback, עד שבפועל מתחיל תכנון API תואם-C.מתכננים לקרוא ישירות עם P/Invokeמכינים פונקציות גשר C בנפרדכותבים SafeHandle ו-StructLayoutשאלות של נתונים משתנים, שחרור, callbackבפועל מתחיל תכנון API תואם-C

איור 12: מה שהתחיל כ”רק בחרנו ב-P/Invoke” הופך לעיתים קרובות, בלי לשים לב, לתכנון API תואם-C.

הנקודות האלה, כשעוברים ל-C++/CLI, מוחלפות באופן הבא. הן לא נעלמות בקסם - האמירה המדויקת יותר היא שהן עוברות לצורה שקל לכתוב אותה באופן טבעי בצד C++.

נקודה שעולה ב-P/Invoke טיפול טיפוסי בצד P/Invoke איך זה נראה בצד C++/CLI
איך מחזירים נתונים באורך משתנה מכינים בשני שלבים “פונקציה ששואלת את הגודל הנדרש” ו”פונקציה שממלאת את המאגר”, והצד C# מקצה את המאגר מקבלים ישירות את ה-std::vector שהנייטיב מחזיר, ומעבירים ל-List<int> או למערך (6.3)
מי משחרר את מאגר המחרוזת מוסיפים פונקציית שחרור ל-C API, ומקפידים לקרוא לה תמיד בצד C# אורך חיי ה-std::wstring מסתיים בצד הנייטיב, ולצד C# פשוט יוצרים ומחזירים String^ חדש (6.3)
היכן שמים את פרטי השגיאה בנוסף לקוד שגיאה בערך ההחזרה, מכינים פונקציה שמוציאה פרטים או מבנה בארגומנט out תופסים את חריגת הנייטיב ב-try / catch, וממירים וזורקים מחדש כחריגת ‎.NET בעלת משמעות (6.3)
איך שומרים על אורך חיי ה-callback ממשיכים להחזיק הפניה, למשל בשדה, כדי שה-delegate לא ייאסף על ידי ה-GC רישום וביטול ה-callback נסגרים בצד C++, ול-C# מוצגים רק אירועים או delegate
איך מייצגים בעלות על handle יורשים מ-SafeHandle, וקוראים לפונקציית השחרור מתוך ReleaseHandle המבנה הנייטיבי מבוצע לו delete בדסטרוקטור/פינליזר של העטיפה (6.3)

6.3. כך אפשר לכתוב עם עטיפת C++/CLI

בצד C++/CLI, מקבלים את הצרכים הנייטיביים ומסדרים את ה-API שנחשף ל-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. מצד שני, גם מקוד C++/CLI אי אפשר לקרוא ישירות ל-Dispose() - קוראים לדסטרוקטור באמצעות האופרטור 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, ולכן
    // בלי הבדיקה הזו נכנסים לנייטיב דרך מצביע null,
    // והתהליך כולו קורס עם הפרת גישה (access violation) במקום חריגת .NET.
    // מנקודת המבט של C#, ההתנהגות הצפויה היא "אם נוגעים אחרי Dispose,
    // מתקבל ObjectDisposedException" - וזה נדרש בכל שיטה שמשתמשת ב-_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, פונקציות שחרור, או צרכי מאגר המחרוזת הנייטיבי. זה ההבדל הגדול.

7. מקרים שבהם עדיין עדיף לא לבחור ב-C++/CLI

כמובן, C++/CLI אינו פתרון-על. יש גם מצבים שבהם עדיף לא לבחור בו.

  • הצד השני חושף מלכתחילה C API נקי
    • במקרה הזה, P/Invoke פשוט יותר.
  • נדרשת תמיכה בריבוי פלטפורמות
    • C++/CLI מניח Windows.
  • ממשק הגבול קטן, והטיפוסים פשוטים
    • לעיתים העלות של הוספת DLL עוטף גדולה יותר.
  • בודקים בקפדנות רבה מגבלות AOT והפצה
    • כדאי לבדוק קודם את דרישות המבנה כולו.

מכיוון ש”מגבלות AOT והפצה” בסעיף האחרון מופשטות, נפרט את המגבלות שבאמת משפיעות בפועל. זו נקודה שקל להיכשל בה אם נשארים עם התחושה של תקופת ‎.NET Framework.

מגבלה תוכן השפעה בפועל
מערכת הפעלה C++/CLI שמכוון ל-‎.NET (משפחת ‎.NET Core) הוא ל-Windows בלבד אם יש תוכנית להריץ במיכל Linux או ב-macOS, אי אפשר לבחור בזה מהשלב הזה
Native AOT C++/CLI מופיע במפורש ברשימת אי-התמיכה של Native AOT. בנוסף אי אפשר להשתמש בטעינה דינמית כמו Assembly.LoadFile,‏ System.Reflection.Emit, ו-COM המובנה של Windows לא תואם למדיניות של PublishAot ליצירת בינארי נייטיב יחיד
צורת הפלט כשמכוונים ל-‎.NET, אי אפשר ליצור exe, רק DLL. גם .NET Standard לא ניתן למיקוד שמים את נקודת הכניסה כ-exe בצד C#, ו-C++/CLI מוגדר כ-DLL שמפנים אליו
צורת הפרויקט משתמשים ב-.vcxproj, לא בסגנון SDK של csproj. אי אפשר גם למקד כמה גרסאות ‎.NET מפרויקט אחד אם צריך גם גרסת ‎.NET Framework וגם גרסת ‎.NET, מפצלים לקבצי פרויקט נפרדים
תלות בזמן ריצה /clr מפעיל גם /MD, ולכן נדרש ה-DLL של זמן הריצה של MSVC. כשמכוונים ל-‎.NET, נדרש גם להניח ijwhost.dll בפלט אם מניחים XCOPY או פרסום כקובץ יחיד, כדאי לבדוק את זה מראש
ארכיטקטורת מעבד מכיוון ש-assembly מעורב מכיל הוראות מכונה נייטיביות, אי אפשר לכסות את כל הארכיטקטורות בבינארי אחד כמו AnyCPU ב-C# בונים ומפיצים לפי יעד, למשל x86 / x64 בנפרד
אופן הטעינה מ-‎.NET 7 ואילך, תמיד נטען ל-AssemblyLoadContext ברירת המחדל. עד ‎.NET 6 כולל, אם נקרא לראשונה מהצד הנייטיבי, יכול להיטען ל-AssemblyLoadContext נפרד במבנה שבו כל plugin מפריד הקשר טעינה, כדאי לבדוק את ההתנהגות מראש

נציין שפרויקט C++/CLI יכול למקד ל-‎.NET (משפחת ‎.NET Core) רק מ-Visual Studio 2019 ואילך. אם יש רק סביבה ישנה יותר, צריך לחשוב תחילה בהנחת ‎.NET Framework.

במילים אחרות, הקריטריון להחלטה הוא “היכן הכי טבעי לתרגם, ביחס למורכבות ה-DLL הנייטיב”. פשוט - P/Invoke. מורכב - C++/CLI. החלוקה הזו בדרך כלל עובדת היטב.

מצבים שבהם עדיף לא לבחור ב-C++/CLIכשכבר קיים C API נקי, נדרשת תמיכה בריבוי פלטפורמות, ממשק הגבול קטן והטיפוסים פשוטים, או שבודקים בקפדנות מגבלות AOT והפצה - בארבעת המצבים האלה עדיף לא לבחור בעטיפת C++/CLI.כבר קיים C APIלא בוחרים ב-C++/CLIנדרשת תמיכה בריבוי פלטפורמותהגבול קטן והטיפוסים פשוטיםמגבלות AOT והפצה קפדניותהיכן הכי טבעי לתרגם

איור 14: C++/CLI אינו פתרון-על. אם מתקיים אחד מארבעת המצבים האלה, כדאי קודם לבדוק P/Invoke או לשקול מחדש את המבנה.

8. סיכום

כדרך להשתמש ב-DLL נייטיב מ-C#,‏ P/Invoke עדיין הדרך המלכותית. אבל זה נכון כשהצד השני פשוט כ-C API.

אם הצד הנייטיב מתוכנן כספריית C++, לרוב יצירת עטיפת C++/CLI דקה שומרת על ממשק גבול נקי יותר, מאשר להתאמץ ולערום בצד C# הצהרות IntPtr ו-attribute של מרשלינג.

במיוחד כשמעורבים:

  • API מבוסס מחלקות
  • הנחות בעלות
  • std::wstring או std::vector
  • המרת חריגות
  • callback
  • מעבר הדרגתי

אז C++/CLI היא בחירה מציאותית למדי.

מה שעושים כאן לא מרשים במיוחד. אבל השאלה “היכן מסדרים את הגבול” משפיעה בבירור על יכולת התחזוקה בהמשך. כשרוצים לנצל יחד גם נכסי Windows קיימים וגם ‎.NET,‏ C++/CLI עדיין שימושי מאוד.

9. מקורות

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

איך בוחרים בין P/Invoke לעטיפת C++/CLI?
אם הצד השני הוא קבוצת פונקציות C שטוחות שנחשפו עם extern "C", P/Invoke הוא הפתרון הפשוט והטבעי ביותר. אם הצד השני הוא ספרייה מבוססת מחלקות C++, שכוללת בעלות, מחרוזות, מערכים, חריגות ו-callback, עטיפת C++/CLI דקה בדרך כלל קלה יותר לתחזוקה. הקריטריון להחלטה הוא "היכן הכי טבעי לתרגם, ביחס למורכבות ה-DLL הנייטיב" - כשפשוט, P/Invoke; כשמורכב, C++/CLI. החלוקה הזו בדרך כלל עובדת היטב.
מה נעשה קל יותר כשמוסיפים עטיפת C++/CLI?
בצד C++/CLI אפשר לכלול (include) את הכותרות הנייטיביות ולטפל ב-std::wstring וב-std::vector כטיפוסי C++ כפי שהם, כך שאין צורך לשחזר את עולם ה-C++ בצד C#. אפשר לחשוף ל-C# רק API בסגנון ‎.NET כמו string,‏ byte[],‏ List<T>,‏ IDisposable וחריגות, ולהסתיר את הצורך ב-IntPtr, בפונקציות שחרור ובעניינים של המרשלינג. אפשר להמיר חריגות ושגיאות של C++ לחריגות ‎.NET בגבול, וגם קל יותר לבצע מעבר הדרגתי שמנצל נכסים קיימים.
מתי קשה להמשיך רק עם P/Invoke?
כשה-DLL הנייטיב מתוכנן סביב מחלקות C++, מה ש-P/Invoke יכול לקרוא לו ישירות הן רק פונקציות ה-export של ה-DLL, ולכן בסופו של דבר נדרשת שכבה שמורידה לפונקציות בסגנון C, ולמעשה מתחילים לתכנן API תואם-C בעצמכם. ככל שמצטברים עוד יסודות כמו רצון להחזיר std::wstring או std::vector, קבלת התקדמות דרך callback, וזריקת חריגות C++ - כך מצטברים בצד C# יותר MarshalAs, מאגרים ידניים וניהול אורך חיים של delegate. אם מייצגים הנחות של בעלות ואורך חיים בבסיס IntPtr, זה נהיה קשה מאוד לקריאה מחדש בהמשך.
יש מקרים שבהם עדיף לא לבחור ב-C++/CLI?
כן, יש. אם הצד השני חושף מלכתחילה C API נקי, P/Invoke פשוט יותר. בנוסף, מכיוון ש-C++/CLI מניח Windows, אי אפשר להשתמש בו כשנדרשת תמיכה בריבוי פלטפורמות. כשממשק הגבול קטן והטיפוסים פשוטים, לעיתים העלות של הוספת DLL עוטף גדולה יותר, וגם כשבודקים בקפדנות מגבלות AOT והפצה, כדאי לבדוק קודם את דרישות המבנה כולו.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג