C# מול native DLL: מתי P/Invoke ומתי C++/CLI wrapper

· עודכן בתאריך: · · C++/CLI, C#, פיתוח Windows, native interop

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 7 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173285)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). C# מול native DLL: מתי P/Invoke ומתי C++/CLI wrapper. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173285 https://comcomponent.com/he/blog/cpp-cli-wrapper-for-native-dlls/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173285
DOI (הגרסה הזו)
10.5281/zenodo.22173286

הצורך לקרוא מ-C# ל-DLL קיים של Windows, או לקוד native שכבר רץ בשטח, די נפוץ. אם בצד השני יש C interface פשוט כמו Win32 API, P/Invoke מספיק.

בשטח מקבלים לרוב DLL עם אופי אחר. יש C++ classes, יש כללי ownership, יש exceptions, ו-std::wstring / std::vector מופיעים כדבר שבשגרה. אם דוחפים את זה רק עם P/Invoke, הגבול בין השפות נהיה קשה יותר עם הזמן.

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

קטעי הקוד כאן מפורסמים ב-GitHub כסט דוגמאות שאפשר לבנות: native C++ library, C API bridge, C++/CLI wrapper, וקוד C# שצורך גם את גרסת P/Invoke וגם את גרסת C++/CLI.

cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)

למי זה מיועד, ומה מניחים

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

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

מונחים שכדאי להכיר

מונח משמעות
P/Invoke (Platform Invoke) מנגנון שבו ב-C# מצהירים עם DllImport / LibraryImport על export function של native DLL, וקוראים לה ישירות
marshaling המרה בגבול, בין טיפוסי .NET (string, arrays וכו’) לייצוג native (wchar_t*, raw pointer וכו’)
ABI (Application Binary Interface) הכללים שמאפשרים לבינארי מהודר להתאים זה לזה: calling convention, העברת arguments, layout של structs, name mangling. לפונקציות C יש הסכמה פשוטה ויציבה. ב-C++ classes, name mangling ו-vtable תלויים ב-compiler, ואי אפשר לסמוך עליהם ישירות מ-C# (5.4)
SafeHandle abstract class ב-.NET שעוטף native handle. משתמשים בו במקום להחזיק IntPtr חשוף, כדי להקטין leak של release ואת התקלה “שוחרר בזמן שימוש” (6.2)
StructLayout attribute שמתאים את ה-layout בזיכרון של struct ב-C# לצד ה-native. למשל LayoutKind.Sequential לפי סדר ההצהרה, ו-CharSet לטיפול ב-strings (6.2)
marshal_as helper של C++/CLI להמרה. ממיר בין טיפוסי .NET לטיפוסים native, למשל marshal_as<std::wstring>(managedString). אחרי include של headers כמו msclr/marshal_cppstd.h (6.3)
mixed assembly DLL שמכיל גם native machine code וגם MSIL. C++/CLI wrapper הוא כזה (פרק 7)

תוכן עניינים

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

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 21, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

1. המסקנה בקצרה

  • אם בצד השני יש C functions, P/Invoke הוא הדרך הטבעית
  • אם זו C++ library, C++/CLI wrapper אחד מקל על התחזוקה
  • במיוחד כשיש classes, ownership, strings, arrays, exceptions ו-callback, עדיף לא להעמיס את זה על צד C#

במילים אחרות: לא מכניסים את אילוצי ה-native DLL ישר ל-C#. את האילוצים ה-native מקבלים בצד C++, ול-.NET מראים רק את הפנים שמתאימה לו. כשהחלוקה הזו עובדת, גם הקוד וגם ה-debug נהיים הרבה יותר רגועים.

החלוקה לפי צורת ה-DLL בצד השניאם בצד השני יש C functions, P/Invoke הוא הדרך הטבעית. אם זו C++ library, מוסיפים C++/CLI wrapper אחד. זו מסקנת המאמר, כהסתעפות.C functionsC++ libraryמה צורת ה-DLL בצד השניP/Invoke הוא הטבעימוסיפים C++/CLI wrapperאת האילוצים ה-native מקבלים בצד C++

איור 1: החלוקה בקצרה. C functions —‏ P/Invoke. C++ library —‏ C++/CLI wrapper.

2. מתי P/Invoke מספיק

אם P/Invoke סוגר את הבעיה, זה הפתרון הפשוט ביותר. אין סיבה לדחוף C++/CLI בכוח.

P/Invoke מתאים, למשל, למקרים האלה.

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

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

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

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

3. איפה P/Invoke נהיה קשה בבת אחת

הבעיה מתחילה כשזה כבר לא “סתם C API”. משם האווירה משתנה מהר.

3.1. כשמתחילים לעבוד מול C++ classes

אם ה-native DLL מתוכנן סביב C++ classes, בפועל רוצים לקרוא ל-methods של ה-class. מה ש-P/Invoke יכול לפגוש ישירות הן רק export functions של ה-DLL. כלומר, בסוף צריך שכבה שמורידה לפונקציות בסגנון C במקום כלשהו.

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

התוצאה של בחירה ב-P/Invoke מול C++ classesרוצים לקרוא ל-methods של C++ class, אבל P/Invoke יכול לפגוש רק export functions, ולכן צריך שכבה שמורידה לסגנון C. בפועל כותבים wrapper.רוצים לקרוא ל-methods של C++ classאפשר לקרוא רק ל-export functionsצריך שכבה שמורידה לסגנון Cבפועל כותבים wrapperאז עדיף להעביר אותו לצד C++

איור 3: גם אם מנסים להסתדר עם P/Invoke, מול C++ class בסוף כותבים wrapper במקום כלשהו.

3.2. כש-ownership ו-lifetime קשים לראות

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

  • האם הצד הקורא משחרר
  • האם ה-pointer שחוזר הוא “השאלה”
  • האם זה const& או העברת ownership
  • האם יש cache פנימי עם הנחות על lifetime

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

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

איור 4: כשמייצגים ownership ו-lifetime ב-IntPtr, מגיעה שאלת “מי מוחק ומתי”, והגבול מתערפל.

3.3. כשנכנסים std::wstring, std::vector, callback ו-exceptions

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

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

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

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

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

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

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

ה-API של ה-native DLL לא בהכרח מתאים ישירות ל-C#.

למשל בצד ה-native:

  • מחברים כמה קריאות method לתהליך אחד
  • שגיאות חוזרות ב-return value וב-out argument
  • יש הנחות על סדר אתחול
  • יש מגבלות thread-safety

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

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

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

4. מבנה עם C++/CLI wrapper

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

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

איור 7: מבנה עם C++/CLI wrapper DLL אחד בין אפליקציית C# ל-native C++ DLL.

מה שנראה מ-C# הוא רק API בסגנון .NET, ואת אלה סוגרים בצד C++/CLI:

  • המרת strings
  • המרת arrays ו-vectors
  • המרת exceptions
  • סידור ownership
  • פענוח קודי שגיאה
  • אם צריך, ספיגה של גבולות thread ו-callback

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

העבודה שנשארת בתוך C++/CLI wrapperתפקיד C++/CLI wrapper הוא המרת strings ו-arrays, המרת exceptions וקודי שגיאה, וסידור ownership. רק תרגום ועיצוב, בלי business logic.תפקיד C++/CLI wrapperהמרת strings ו-arraysהמרת exceptions וקודי שגיאהסידור ownershipלא מכניסים business logic

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

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

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

זה יתרון גדול. בצד C++/CLI אפשר לעשות include ל-headers ה-native, ולהשתמש בטיפוסי C++ כמו שהם.

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

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

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

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

בצד C# אפשר לחשוף צורה מוכרת:

  • string
  • byte[]
  • List<T>
  • IDisposable
  • exceptions

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

5.3. קל יותר ליישר אחריות על exceptions ושגיאות

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

  • להמיר exceptions ל-.NET exceptions
  • להמיר error codes ל-exceptions או לטיפוסי תוצאה שיש להם משמעות
  • להוסיף את ה-context שצריך ל-log

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

תרגום exceptions ו-error codes בגבולexceptions ו-error codes שמתערבבים בצד ה-native מרוכזים פעם אחת בצד C++/CLI. exceptions הופכים ל-.NET exceptions, error codes לצורה שיש לה משמעות, ומתווסף context ל-log.exceptions ו-error codes nativeמרוכזים פעם אחת בצד C++/CLIexceptions הופכים ל-.NET exceptionserror codes הופכים לצורה שיש לה משמעותמתווסף context שצריך ל-log

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

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

C++ classes ו-methods אינם ABI פשוט כמו C functions. ברגע ש-C# מתחיל לדעת את זה ישירות, אילוצי export functions ו-marshaling צפים החוצה.

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

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

איור 11: לא חושפים אילוצי marshaling ו-export functions. ל-C# מראים רק פנים יציבה.

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

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

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

6. קטעי קוד

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

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

כ-native C++ זה API רגיל לגמרי. לגעת בו ישירות מ-C# זה כבר לא פשוט.

6.2. אם מנסים את זה עם P/Invoke, זה נראה כך

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

// דוגמה ל-bridge שהורד ל-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);
}

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

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

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

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

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

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

נקודה שעולה ב-P/Invoke טיפול טיפוסי בצד P/Invoke איך זה נראה בצד C++/CLI
איך מחזירים נתונים באורך משתנה מכינים בשני שלבים “פונקציה ששואלת את הגודל” ו”פונקציה שממלאת את ה-buffer”, ו-C# מקצה את ה-buffer מקבלים ישירות את ה-std::vector שה-native מחזיר, ומעבירים ל-List<int> או ל-array (6.3)
מי משחרר את ה-string buffer מוסיפים פונקציית release ל-C API, ומקפידים לקרוא לה תמיד ב-C# ה-lifetime של std::wstring מסתיים בצד ה-native, ול-C# פשוט יוצרים ומחזירים String^ חדש (6.3)
איפה שמים את פרטי השגיאה בנוסף ל-error code ב-return value, מכינים פונקציה שמוציאה פרטים או struct ב-out argument תופסים את ה-native exception ב-try / catch, וממירים וזורקים מחדש כ-.NET exception שיש לו משמעות (6.3)
איך שומרים על lifetime של callback ממשיכים להחזיק reference, למשל בשדה, כדי שה-delegate לא ייאסף על ידי ה-GC רישום וביטול ה-callback נשארים בצד C++, ול-C# מוצגים רק events או delegate
איך מייצגים ownership על handle יורשים מ-SafeHandle, וקוראים לפונקציית ה-release מתוך ReleaseHandle לאובייקט ה-native עושים delete ב-destructor/finalizer של ה-wrapper (6.3)

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

בצד C++/CLI מקבלים את האילוצים ה-native, ומעצבים את ה-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(). שניהם נראים כמו destructor של C++, אבל התפקיד מתאים ל-Dispose pattern של .NET.

כתיבה ב-C++/CLI מה שה-compiler מייצר ההתנהגות מנקודת המבט של C#
~AnalyzerWrapper() (destructor) Dispose() שמממש IDisposable רץ כשיוצאים מ-using, או כשקוראים ל-Dispose()
!AnalyzerWrapper() (finalizer) Finalize() שדורס את Object::Finalize רץ כשה-GC אוסף. מתי זה קורה לא ידוע מראש

השיטה הנפוצה היא לכתוב את שחרור המשאבים ה-native ב-finalizer, ולקרוא לו מתוך ה-destructor. במימוש הבא ~AnalyzerWrapper() פשוט קורא ל-this->!AnalyzerWrapper(). זו בדיוק הנקודה: גם אם צד C# שוכח לקרוא ל-Dispose(), בסוף ה-GC תופס. אם נקרא ה-destructor, GC::SuppressFinalize מדכא את ה-finalization, כך שאין double-free.

שימו לב ש-Dispose(), Finalize() ו-Dispose(bool) נוצרים על ידי ה-compiler, ולכן לא כותבים אותם בעצמכם בצד C++/CLI. מצד שני, גם מקוד C++/CLI אי אפשר לקרוא ישירות ל-Dispose() — קוראים ל-destructor עם האופרטור delete. ההתאמה הזו מסוכמת בסעיף “Destructors and finalizers” של How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn.

חלוקת התפקידים בין destructor ל-finalizerקריאת using או Dispose ב-C# מפעילה את ה-destructor, שקורא ל-finalizer ומשחרר את המשאב ה-native. שכחת Dispose גורמת ל-finalizer לרוץ באיסוף GC, ו-SuppressFinalize מונע double-free.using או Dispose בצד C#destructor (מקביל ל-Dispose)שכחת Disposeה-finalizer נקרא באיסוף GCקורא ל-finalizerעושה delete למשאב ה-nativeSuppressFinalize מונע double-free

איור 13: השיטה הנפוצה: כותבים את ה-release ב-finalizer וקוראים לו מה-destructor. גם אם שוכחים 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)
{
    // אם קוראים אחרי שהאובייקט כבר disposed, עוצרים כאן לפני הכניסה ל-native.
    // ה-destructor (= Dispose) שם את _native ל-nullptr, ולכן
    // בלי הבדיקה הזו נכנסים ל-native דרך null pointer,
    // וה-process כולו קורס עם access violation במקום .NET exception.
    // מנקודת המבט של C#, ההתנהגות הצפויה היא "אם נוגעים אחרי Dispose,
    // מתקבל ObjectDisposedException" — וזה נדרש בכל method שמשתמש ב-_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, פונקציות release, או אילוצי ה-string buffer ה-native. זה ההבדל הגדול.

7. מתי עדיף לא לבחור ב-C++/CLI

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

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

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

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

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

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

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

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

8. סיכום

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

אם הצד ה-native מתוכנן כ-C++ library, לרוב C++/CLI wrapper דק שומר על גבול נקי יותר, במקום לערום ב-C# הצהרות IntPtr ו-attributes של marshaling.

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

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

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

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

9. מקורות

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

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

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

שאלות נפוצות

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

איך בוחרים בין P/Invoke ל-C++/CLI wrapper?
אם בצד השני יש C API שטוח שיוצא ב-extern "C", P/Invoke הוא הדרך הפשוטה והטבעית. אם זו ספריית C++ מבוססת classes, עם ownership, strings, arrays, exceptions ו-callback, בדרך כלל קל יותר לתחזק wrapper דק ב-C++/CLI. הקריטריון הוא איפה הכי טבעי לעשות את ה-translation ביחס למורכבות של ה-native DLL: פשוט — P/Invoke, מורכב — C++/CLI. החלוקה הזו בדרך כלל עובדת.
מה נהיה קל יותר אחרי שמוסיפים C++/CLI wrapper?
בצד C++/CLI אפשר לעשות include ל-headers ה-native ולעבוד עם std::wstring ו-std::vector כטיפוסי C++ רגילים, בלי לשחזר את עולם ה-C++ ב-C#. ל-C# חושפים רק API בסגנון .NET: string, byte[], List<T>, IDisposable ו-exceptions. IntPtr, פונקציות release ופרטי marshaling נשארים מאחורי הגבול. אפשר להמיר C++ exceptions וקודי שגיאה ל-.NET exceptions בגבול, וגם מעבר הדרגתי על בסיס קוד קיים נהיה קל יותר.
מתי P/Invoke לבד נהיה כואב?
כשה-native DLL בנוי סביב C++ classes, P/Invoke יכול לקרוא רק ל-export functions של ה-DLL. בסוף צריך שכבה שמורידה הכל לפונקציות בסגנון C, ואז בפועל מתחילים לתכנן C-compatible API בעצמכם. ברגע שרוצים להחזיר std::wstring או std::vector, לקבל progress דרך callback, או שיש C++ exceptions — בצד C# מצטברים MarshalAs, buffers ידניים וניהול lifetime של delegate. אם מייצגים ownership ו-lifetime עם IntPtr חשוף, אחר כך קשה מאוד לקרוא את הקוד מחדש.
יש מצבים שעדיף לא לבחור ב-C++/CLI?
כן. אם כבר יש C API נקי, P/Invoke פשוט יותר. C++/CLI מניח Windows, אז cross-platform לא רלוונטי. כשהגבול קטן והטיפוסים פשוטים, עלות של wrapper DLL נוסף לפעמים גדולה מהתועלת. גם אם בודקים בקפדנות מגבלות AOT והפצה, כדאי קודם לראות את דרישות המבנה כולו.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג