קריאה מ-C# ל-DLL נייטיב: עטיפת C++/CLI מול P/Invoke
· עודכן בתאריך: · Go Komura · 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) |
תוכן עניינים
- קודם המסקנה (במשפט אחד)
- מקרים שבהם P/Invoke מספיק
- הגבול שבו P/Invoke נהיה פתאום קשה
- המבנה שמוסיף עטיפת C++/CLI
- מה נעשה קל יותר עם C++/CLI
- קטעי קוד
- מקרים שבהם עדיין עדיף לא לבחור ב-C++/CLI
- סיכום
- מקורות
מפת הידע של המאמר
המאמר הזה מסדר שכאשר קוראים מ-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, ולכן כשנדרשת תמיכה בריבוי פלטפורמות או שמגבלות ההפצה מחמירות, אי אפשר לבחור בו.
flowchart LR
accTitle: מפת הידע של החלוקה בין עטיפת C++/CLI ל-P/Invoke
accDescr: תרשים המראה איך בוחרים בין P/Invoke לעטיפת C++/CLI בהתאם למורכבות ה-DLL הנייטיב, ואיך משתנה הטיפול במרשלינג, בעלות, חריגות, callback, ומגבלות ההפצה.
cpp_cli["C++/CLI"]
p_invoke["P/Invoke"]
c_api_bridge["שכבת גשר של C API"]
c_native_api["C API שטוח"]
cpp_class_native_library["ספריית C++ נייטיב מבוססת מחלקות"]
com_marshaling["מרשלינג (marshaling)"]
marshal_as["marshal_as"]
safehandle["SafeHandle"]
structlayout["StructLayout"]
dispose_finalize_pattern["דפוס Dispose/Finalize (C++/CLI)"]
ownership_lifetime_management["ניהול בעלות ואורך חיים"]
exception_translation["תרגום החריגה ל-.NET"]
callback_delegate_lifetime["ניהול אורך החיים של ה-delegate של ה-callback"]
native_aot["Native AOT"]
ijwhost["ijwhost.dll"]
clr_compilation_flag["אפשרות הקומפילציה /clr"]
mixed_assembly["assembly מעורב"]
cross_platform_requirement["דרישת ריבוי פלטפורמות"]
p_invoke -.->|"מחייב"| c_api_bridge
p_invoke -->|"מענה מומלץ ל"| c_native_api
cpp_cli -->|"מענה מומלץ ל"| cpp_class_native_library
p_invoke -->|"שימוש לא מומלץ ל"| cpp_class_native_library
cpp_cli -->|"שימוש לא מומלץ ל"| c_native_api
cpp_cli -->|"משתמש ב"| com_marshaling
marshal_as -->|"מממש את"| com_marshaling
p_invoke -.->|"משתמש ב"| safehandle
p_invoke -.->|"משתמש ב"| structlayout
cpp_cli -->|"מממש את"| dispose_finalize_pattern
cpp_cli -->|"מחייב"| ownership_lifetime_management
cpp_cli -->|"מממש את"| exception_translation
cpp_cli -.->|"מחייב"| callback_delegate_lifetime
p_invoke -.->|"מחייב"| callback_delegate_lifetime
cpp_cli -->|"אינו מתיישב עם"| native_aot
cpp_cli -.->|"מחייב"| ijwhost
cpp_cli -->|"מוגדר באמצעות"| clr_compilation_flag
cpp_cli -->|"מממש את"| mixed_assembly
c_api_bridge -->|"מחייב"| c_native_api
cpp_cli -.->|"יורש את"| c_api_bridge
cpp_cli -->|"אינו מתיישב עם"| cross_platform_requirement
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 21, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle
1. קודם המסקנה (במשפט אחד)
- אם הצד השני הוא קבוצת פונקציות C, P/Invoke הוא הפתרון הטבעי
- אם הצד השני הוא ספריית C++, הוספת עטיפת C++/CLI אחת מקלה על התחזוקה
- במיוחד כשמעורבים מחלקות, בעלות, מחרוזות, מערכים, חריגות ו-callback, עדיף לא להעמיס על צד ה-C#
במילים אחרות, לא מכניסים את הצרכים של ה-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 מתאים, לדוגמה, למקרים הבאים.
- ה-API הוא פונקציות שטוחות שנחשפו עם
extern "C" - הארגומנטים וערכי ההחזרה מספיקים כמספרים שלמים, מצביעים, מבנים פשוטים וכדומה
- הסכמת המחרוזות ברורה, ואחריות המאגר (buffer) פשוטה
- ניהול המשאבים ברור, כמו
Create/Destroy - אפשר לכתוב
SafeHandleו-StructLayoutבפשטות בצד C#
אם המצב מסודר כך, מספיק להצהיר ולהשתמש בצד C#, וזה קרוב לתחושה של קריאה ל-Windows API, כך שהמימוש נשאר קריא.
flowchart TB
accTitle: התנאים שמאפשרים לקבוע ש-P/Invoke מספיק
accDescr: כשמתקיימים התנאים של API שטוח ב-C, ארגומנטים וערכי החזרה פשוטים, והסכמת מחרוזות ומשאבים ברורה, מספיק להצהיר ולהשתמש בצד C#.
c1["API שטוח ב-C(extern C)"] --> ok["P/Invoke מספיק"]
c2["ארגומנטים וערכי החזרה פשוטים"] --> ok
c3["הסכמת מחרוזות ומשאבים ברורה"] --> ok
ok --> use["מספיק להצהיר ולהשתמש בצד C#"]
איור 2: אם ה-API של הצד השני מסודר כך, אין צורך להכניס C++/CLI בכוח.
3. הגבול שבו P/Invoke נהיה פתאום קשה
הבעיה מתחילה כשהצד השני הוא לא “רק C API”. מכאן האווירה משתנה פתאום.
3.1. כשמתחילים להתמודד עם מחלקות C++
כש-DLL הנייטיב מתוכנן סביב מחלקות C++, בעצם רוצים לקרוא ישירות לשיטות (methods) של המחלקה, אבל מה ש-P/Invoke יכול להתמודד איתו ישירות הן רק פונקציות ה-export של ה-DLL. כלומר, בסופו של דבר נדרשת שכבה שמורידה לפונקציות בסגנון C במקום כלשהו.
בשלב הזה, מה שעושים זה למעשה “לכתוב עטיפה”.
אם כך, עדיף להעביר את העטיפה לצד C++, במקום לגדל בצד C# הרבה IntPtr ופונקציות שחרור.
flowchart TB
accTitle: התוצאה של בחירה ב-P/Invoke מול מחלקות C++
accDescr: רוצים לקרוא לשיטות של מחלקת C++, אבל P/Invoke יכול להתמודד רק עם פונקציות export, כך שנדרשת שכבה שמורידה לצורת C, ולמעשה כותבים עטיפה.
want["רוצים לקרוא לשיטות של מחלקת C++"] --> limit["אפשר לקרוא רק לפונקציות export"]
limit --> bridge["נדרשת שכבה שמורידה לצורת C"]
bridge --> fact["מה שעושים זה כמעט לכתוב עטיפה"]
fact -.-> better["אז עדיף להעביר אותה לצד C++"]
איור 3: גם אם מנסים להסתדר עם P/Invoke, כשהצד השני הוא מחלקת C++, בסופו של דבר כותבים עטיפה במקום כלשהו.
3.2. כשקשה לראות בעלות וניהול אורך חיים
ב-C++, קיימות שאלות רגילות כמו:
- האם הצד הקורא משחרר
- האם המצביע המוחזר הוא “הלוואה”
- האם זה
const&או העברת בעלות - האם יש מטמון פנימי עם הנחות על אורך חיים
אם מייצגים את זה בבסיס IntPtr ב-C#, זה עשוי לעבוד בהתחלה, אבל קשה מאוד לקרוא מחדש בהמשך.
כשמתחילה “בעיית מי ומתי מוחק את המצביע הזה”, ממשק הגבול מתעכר במהירות.
flowchart TB
accTitle: איך ייצוג ב-IntPtr מעכר את הנחות הבעלות
accDescr: השאלות מי משחרר, האם זה הלוואה או העברת בעלות, והאם יש הנחות על אורך חיים - כשמייצגים אותן בבסיס IntPtr ב-C#, קשה לקרוא מחדש בהמשך וממשק הגבול מתעכר.
q1["מי משחרר"] --> ptr["מיוצג בבסיס IntPtr של C#"]
q2["הלוואה או העברת בעלות"] --> ptr
q3["האם יש הנחות על אורך חיים"] --> ptr
ptr --> bad["עובד בהתחלה אך קשה לקריאה בהמשך"]
bad --> muddy["ממשק הגבול מתעכר מהר"]
איור 4: כשמייצגים הנחות של בעלות ואורך חיים בבסיס IntPtr, מגיעה “בעיית מי ומתי מוחק” וממשק הגבול מתעכר.
3.3. כש-std::wstring, std::vector, callback וחריגות נכנסים לתמונה
מכאן, P/Invoke נכנס לתחום של “אפשר לכתוב, אבל זה לא נעים”.
- רוצים לייצג
std::wstringישירות מ-C# - רוצים להחזיר
std::vector<T> - רוצים לקבל התקדמות של עיבוד נייטיבי דרך callback
- במקרה כישלון נזרקת חריגת C++
ככל שהיסודות האלה מצטברים, מצטברים בצד C# גם MarshalAs, מאגרים ידניים, מערכים באורך קבוע, ניהול אורך חיים של delegate ופענוח קודי שגיאה.
כמובן שאפשר לכתוב את זה במאמץ. אבל הקושי הוא שהמאמץ הזה לא במקום הנכון. מה שבאמת רוצים לעשות הוא לוגיקה עסקית או UI, ולא התמודדות פיזית עם ממשק הגבול.
flowchart TB
accTitle: הצטברות העומס בצד C# עם עלייה ביסודות C++
accDescr: ככל שמצטברים הרצון להחזיר wstring או vector, לקבל התקדמות דרך callback, וזריקת חריגות C++, כך מצטברים בצד C# MarshalAs, מאגרים ידניים, וניהול אורך חיים של delegate.
e1["רוצים להחזיר wstring או vector"] --> pile["הקוד בצד C# מצטבר"]
e2["רוצים לקבל התקדמות דרך callback"] --> pile
e3["חריגת C++ נזרקת במקרה כישלון"] --> pile
pile --> load["MarshalAs, מאגרים ידניים"]
pile --> load2["ניהול אורך חיים של delegate, פענוח שגיאות"]
איור 5: ככל שיסודות אופייניים ל-C++ מצטברים, כך מצטבר גם העומס על קוד ממשק הגבול בצד C#.
3.4. כשלא רוצים “לדלוף” את הצרכים של C++ ל-C#
ה-API בצד ה-DLL הנייטיב לא בהכרח מתאים ישירות ל-C#.
לדוגמה, בצד הנייטיב:
- משלבים כמה קריאות שיטה לתהליך אחד
- שגיאות מוחזרות בערך החזרה ובארגומנט out
- יש הנחות על סדר האתחול
- יש מגבלות של בטיחות לריבוי ת’רדים
גם עם תכנון כזה, לעיתים קרובות רוצים להראות לצד C# API טבעי הרבה יותר. כשכבה שממירה את זה, C++/CLI נוח מאוד.
flowchart TB
accTitle: שכבה שממירה את צרכי הנייטיב ומציגה API ל-C#
accDescr: צרכי התכנון של ה-API הנייטיבי, כמו סדר אתחול ומגבלות בטיחות ת'רדים, מתקבלים בשכבת ההמרה של C++/CLI, ולצד C# מוצג API טבעי הרבה יותר.
nat["צרכי התכנון של ה-API הנייטיבי"] -.-> ex["סדר אתחול, מגבלות ת'רדים וכדומה"]
nat --> conv["שכבת ההמרה של C++/CLI"]
conv --> api["ל-C# מוצג API טבעי"]
איור 6: לא חושפים ל-C# ישירות את צרכי התכנון של הצד הנייטיב, אלא מוסיפים את C++/CLI כשכבת המרה.
4. המבנה שמוסיף עטיפת C++/CLI
מבחינת המבנה, זה פשוט.
flowchart LR
accTitle: מבנה שלושת השכבות של האפליקציה
accDescr: תרשים המראה שאפליקציית C# קוראת ל-DLL עטיפת C++/CLI דרך API מיועד ל-.NET, והעטיפה קוראת ל-DLL C++ נייטיב תוך שימוש ישיר בכותרות ובטיפוסים הנייטיביים.
Cs["אפליקציית C#"] -->|"API מיועד ל-.NET"| Wrapper["DLL עטיפת C++/CLI"]
Wrapper -->|"משתמש ישירות בכותרות ובטיפוסים נייטיביים"| Native["DLL נייטיב C++"]
איור 7: מבנה שמכניס DLL עטיפת C++/CLI אחד בין אפליקציית C# ל-DLL הנייטיב של C++.
מה שנראה מ-C# הוא רק API בסגנון .NET, ו:
- המרת מחרוזות
- המרת מערכים ווקטורים
- המרת חריגות
- סידור הבעלות
- פענוח קודי שגיאה
- במידת הצורך, ספיגה של גבולות ת’רד ו-callback
נסגרים בצד C++/CLI.
מה שחשוב הוא לא לגדל יותר מדי את פרויקט ה-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++/CLI אפשר לכלול (include) את הכותרות הנייטיביות, ולהשתמש בטיפוסי C++ כפי שהם.
כלומר, אין צורך “לשחזר את עולם ה-C++” בכוח בצד C#.
גם std::wstring וגם std::vector אפשר לקבל תחילה כטיפוסי C++, ולהעביר ל-.NET בצורה הנדרשת רק אחר כך.
flowchart TB
accTitle: הזרימה מקבלת טיפוסי C++ ועד המעבר ל-.NET
accDescr: כוללים את הכותרות הנייטיביות, מקבלים wstring או vector כטיפוסי C++ בצד C++/CLI, ממירים לצורה הנדרשת, ומעבירים לצד .NET - כך שבצד C# לא צריך לשחזר את עולם ה-C++.
nt["wstring או vector נייטיבי"] --> recv["מתקבל כטיפוס C++ בצד C++/CLI"]
recv --> conv["ממירים לצורה הנדרשת"]
conv --> net["מועבר לצד .NET"]
net -.-> nofake["ב-C# לא משחזרים את עולם ה-C++"]
איור 9: מקבלים תחילה את טיפוסי C++ כטיפוסי C++, וממירים אותם ל-.NET רק כשצריך.
5.2. אפשר לעצב את ה-API בהתאם ל-.NET
בצד C# אפשר לחשוף:
stringbyte[]List<T>IDisposable- חריגות
בצורה מוכרת שכזו.
ההבדל הזה נראה קטן, אבל הוא משנה משמעותית את העומס על צד המשתמש. במיוחד בפיתוח צוותי, זה עוזר שגם חברי צוות שלא מכירים את הצד הנייטיבי יכולים לגעת בקוד בקלות.
5.3. קל יותר לסדר את האחריות על חריגות ושגיאות
כשבצד הנייטיב מעורבים גם חריגות וגם קודי שגיאה, קשה לטפל בזה כפי שהוא בצד C#. בצד C++/CLI אפשר לרכז את זה פעם אחת:
- להמיר חריגות לחריגות .NET
- להמיר קודי שגיאה לחריגות או לטיפוסי תוצאה בעלי משמעות
- להוסיף את ההקשר הדרוש ליומן (log)
אם מתרגמים פעם אחת בגבול ל”כישלון בעל משמעות”, הצד הקורא נשאר הרבה יותר נקי.
flowchart TB
accTitle: תרגום חריגות וקודי שגיאה בגבול
accDescr: חריגות וקודי שגיאה שמעורבים בצד הנייטיב מרוכזים פעם אחת בצד C++/CLI, החריגות מומרות לחריגות .NET, קודי השגיאה מומרים לצורה בעלת משמעות, ומתווסף הקשר הדרוש ליומן.
mixed["חריגות וקודי שגיאה נייטיביים"] --> tr["מרוכזים פעם אחת בצד C++/CLI"]
tr --> e1["חריגות מומרות לחריגות .NET"]
tr --> e2["קודי שגיאה מומרים לצורה בעלת משמעות"]
tr -.-> log["מתווסף הקשר הדרוש ליומן"]
איור 10: תרגום חד-פעמי ל”כישלון בעל משמעות” בגבול משאיר את הצד הקורא ב-C# נקי הרבה יותר.
5.4. אפשר להסתיר מ-C# את השונות ב-ABI
מחלקות ושיטות של C++ אינן ABI פשוט כמו פונקציות C. כש-C# מתחיל לדעת ישירות על העניינים האלה, צרכי פונקציות ה-export והמרשלינג צפים החוצה.
כשמוסיפים עטיפת 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: לא חושפים את צרכי המרשלינג ופונקציות ה-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.
flowchart TB
accTitle: איך תוכנית P/Invoke הופכת בפועל לתכנון API תואם-C
accDescr: מתכננים לקרוא ישירות עם P/Invoke, מכינים פונקציות גשר C בנפרד, כותבים SafeHandle ו-StructLayout בצד C#, ומצטברות שאלות של נתונים משתנים, שחרור ו-callback, עד שבפועל מתחיל תכנון API תואם-C.
start["מתכננים לקרוא ישירות עם P/Invoke"] --> bridge["מכינים פונקציות גשר C בנפרד"]
bridge --> decl["כותבים SafeHandle ו-StructLayout"]
decl --> more["שאלות של נתונים משתנים, שחרור, callback"]
more --> real["בפועל מתחיל תכנון 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.
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, ולכן
// בלי הבדיקה הזו נכנסים לנייטיב דרך מצביע 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. החלוקה הזו בדרך כלל עובדת היטב.
flowchart TB
accTitle: מצבים שבהם עדיף לא לבחור ב-C++/CLI
accDescr: כשכבר קיים C API נקי, נדרשת תמיכה בריבוי פלטפורמות, ממשק הגבול קטן והטיפוסים פשוטים, או שבודקים בקפדנות מגבלות AOT והפצה - בארבעת המצבים האלה עדיף לא לבחור בעטיפת C++/CLI.
n1["כבר קיים C API"] --> no["לא בוחרים ב-C++/CLI"]
n2["נדרשת תמיכה בריבוי פלטפורמות"] --> no
n3["הגבול קטן והטיפוסים פשוטים"] --> no
n4["מגבלות AOT והפצה קפדניות"] --> no
no -.-> judge["היכן הכי טבעי לתרגם"]
איור 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. מקורות
- ערכת קוד הדוגמה המלאה של המאמר הזה (ספריית 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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
איך קוראים ל-DLL של C# Native AOT מ-C/C++
פרסום ספריית מחלקות C# כ-DLL נייטיבי באמצעות Native AOT, וקריאה לנקודות הכניסה מסוג UnmanagedCallersOnly מ-C/C++ — סקירה לפי מקום השימוש...
רשימת בדיקה לפני מעבר מ-.NET Framework ל-.NET
רשימת בדיקה מעשית לפני מעבר מ-.NET Framework ל-.NET, שבודקת סוג פרויקט, טכנולוגיות לא נתמכות, תלויות NuGet, סגנון SDK, WPF/WinForms, ...
למה להשתמש ב-Generic Host וב-BackgroundService של .NET באפליקציית desktop
בכלי Windows ובאפליקציות שרצות ברקע, מסודר כאן איך להשתמש ב-Generic Host וב-BackgroundService כדי לסדר הפעלה, עיבוד תקופתי, סיום, לוג, הג...
מדריך מעשי ל-FileSystemWatcher — התמודדות עם פספוסים וכפילויות
המאמר מסדר את אופן השימוש ואת נקודות התשומת לב של FileSystemWatcher, מנקודת המבט של פספוסים, התראות כפולות, מלכודות בקביעת סיום, סריקה חו...
רשימת בדיקה לטיפול בטוח בתהליכי ילד ביישום Windows
כדי לטפל בבטחה בתהליכי ילד ביישום Windows, תכנון הבעלות על עץ התהליכים ונוהל הסיום חשוב יותר מבחירת ה-API להפעלה. המאמר מסדר את Job Objec...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
יכולת פעולה הדדית בין 32 ל-64 סיביות
תאימות 32/64 סיביות, גבולות native והחלטות תכנון ב-Windows.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- איך בוחרים בין 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 והפצה, כדאי לבדוק קודם את דרישות המבנה כולו.