זהות ובעלות ברכיב - COM¶
נניח שכתבנו ספרייה שמחזיקה ערך, ומחר תוכנית שנכתבה בקומפיילר אחר צריכה להשתמש בה. להעביר לה כתובת של אובייקט C++ זה לא מספיק. איזה vtable היא מצפה למצוא? מי מוחק את האובייקט? ואיך נוסיף יכולת בלי לשבור תוכנית שכבר מותקנת?
המנגנון COM מתחיל מחוזה בינארי קטן. הלקוח מקבל מצביע לממשק, והממשק מתחיל בשלוש פונקציות של IUnknown: בירור ממשק, הוספת reference ושחרור reference. מעבר לחוזה הזה יש מערכת של activation, apartments וmarshalling. בשיעור הזה נבנה את החלק הראשון ביד, כדי שלא יהיה קסם מאחורי smart pointer.
מתחילים מאובייקט אחד ושתי כניסות¶
לכל ממשק יש IID, מזהה שאינו משתנה כשהמימוש משתנה. למימוש שניתן ליצור דרך COM יש בדרך כלל CLSID אחר. המזהה הראשון אומר איזו יכולת אנחנו מבקשים; השני אומר איזה class ליצור. שני classים שונים יכולים לספק אותו ממשק.
ניצור שני ממשקים קטנים. האחד קורא ערך והשני מחליף אותו. זהו קובץ מלא בשם identity.cpp, לבנייה מתוך Developer Command Prompt של x64.
#include <windows.h>
#include <unknwn.h>
#include <atomic>
#include <cstdio>
#include <new>
const IID IID_IReadValue =
{0x9474fd21,0x344f,0x43ee,{0x99,0xb2,0xa6,0x65,0xaa,0x50,0x06,0x01}};
const IID IID_IWriteValue =
{0x9474fd22,0x344f,0x43ee,{0x99,0xb2,0xa6,0x65,0xaa,0x50,0x06,0x01}};
struct IReadValue : IUnknown {
virtual HRESULT STDMETHODCALLTYPE Read(LONG* value) = 0;
};
struct IWriteValue : IUnknown {
virtual HRESULT STDMETHODCALLTYPE Write(LONG value) = 0;
};
class Value final : public IReadValue, public IWriteValue {
std::atomic<ULONG> refs_{1};
std::atomic<LONG> value_{0};
~Value() { std::puts("destroyed"); }
public:
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void** out) override {
if (!out) return E_POINTER;
*out = nullptr;
if (IsEqualIID(iid, IID_IUnknown) || IsEqualIID(iid, IID_IReadValue))
*out = static_cast<IReadValue*>(this);
else if (IsEqualIID(iid, IID_IWriteValue))
*out = static_cast<IWriteValue*>(this);
else
return E_NOINTERFACE;
AddRef();
return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override {
return refs_.fetch_add(1, std::memory_order_relaxed) + 1;
}
ULONG STDMETHODCALLTYPE Release() override {
const ULONG remaining = refs_.fetch_sub(1, std::memory_order_acq_rel) - 1;
if (!remaining) delete this;
return remaining;
}
HRESULT STDMETHODCALLTYPE Read(LONG* value) override {
if (!value) return E_POINTER;
*value = value_.load(std::memory_order_relaxed);
return S_OK;
}
HRESULT STDMETHODCALLTYPE Write(LONG value) override {
value_.store(value, std::memory_order_relaxed);
return S_OK;
}
};
HRESULT create_value(REFIID iid, void** out) noexcept {
if (!out) return E_POINTER;
*out = nullptr;
auto object = new (std::nothrow) Value;
if (!object) return E_OUTOFMEMORY;
const HRESULT hr = object->QueryInterface(iid, out);
object->Release();
return hr;
}
int main() {
IReadValue* reader = nullptr;
HRESULT hr = create_value(IID_IReadValue,
reinterpret_cast<void**>(&reader));
if (FAILED(hr)) return 1;
IWriteValue* writer = nullptr;
hr = reader->QueryInterface(IID_IWriteValue,
reinterpret_cast<void**>(&writer));
if (FAILED(hr)) { reader->Release(); return 2; }
IUnknown* first = nullptr;
IUnknown* second = nullptr;
const HRESULT a = reader->QueryInterface(IID_IUnknown,
reinterpret_cast<void**>(&first));
const HRESULT b = writer->QueryInterface(IID_IUnknown,
reinterpret_cast<void**>(&second));
if (SUCCEEDED(a) && SUCCEEDED(b)) {
writer->Write(42);
LONG value = 0;
reader->Read(&value);
std::printf("reader=%p writer=%p same_identity=%d value=%ld\n",
static_cast<void*>(reader), static_cast<void*>(writer),
first == second, value);
}
if (second) second->Release();
if (first) first->Release();
writer->Release();
reader->Release();
return FAILED(a) || FAILED(b);
}
הדפסת הכתובות מאפשרת לראות שממשק אינו בהכרח תחילת האובייקט. בירושה מרובה הקומפיילר עשוי להתאים את this לכל ממשק. אנחנו לא מסיקים מזה layout נייד של C++, ולא ממירים מצביע של לקוח מממשק אחד לאחר באמצעות cast. הלקוח משתמש בQueryInterface; הcast בתוך המימוש הוא בחירה שלנו בsubobject הנכון.
מה בעצם ספרנו¶
האובייקט נוצר עם reference פנימי אחד. הבירור בתוך create_value מוסיף reference עבור הלקוח, ואז הפונקציה משחררת את זה שהיה שלה. אם הבירור נכשל, השחרור האחרון מוחק את האובייקט. אם הצליח, נשאר אחד אצל הלקוח.
כל בירור מוצלח בהמשך יוצר התחייבות לשחרור, גם אם קיבלנו כתובת שכבר ראינו. העתקת מספר הכתובת למשתנה אינה יוצרת reference חדש. אם שני בעלים רוצים לחיות באופן עצמאי, צריך reference לכל בעלים. לעומת זאת, פרמטר מושאל שנשאר תקף רק לאורך קריאה לא בהכרח דורש זוג פעולות נוסף.
המונה האטומי פותר עדכון מקביל של המונה. הוא לא מאפשר לקחת מצביע שכבר שוחרר ולקרוא עליו AddRef. כדי להוסיף reference, צריך קודם להחזיק מצביע שחייו מובטחים. גם value_ אטומי כאן רק משום שהמצב שלנו הוא מספר בודד; אוסף של שלושה שדות עם invariant משותף ידרוש פתרון אחר.
איך מזהים שזה אותו אובייקט¶
הזהות הקנונית מתקבלת מבירור IID_IUnknown. שתי תוצאות כאלה עבור אותו אובייקט צריכות להיות אותו מצביע. אין דרישה שכל בירור של כל ממשק יחזיר תמיד אותה כתובת פיזית.
קבוצת הממשקים שהאובייקט תומך בה נשארת קבועה לאורך חייו. לא מחזירים היום E_NOINTERFACE ומחר הצלחה לאותו IID בגלל מצב משתמש או רישיון. הרשאה לפעולה נבדקת בפעולה. אפשר להחזיק ממשק נתמך שהפעולה שלו מחזירה כשל הרשאה.
בנוסף, אם אפשר לעבור מממשק א לממשק ב, צריך להיות אפשר לחזור ולברר את א דרך ב. הבירור מתאר יכולות של אותו אובייקט, ולא מסלול ניווט שתלוי מאיזו דלת נכנסנו. את הניסוח המדויק נמצא בכללי הבירור.
איפה נכנס הfactory¶
הפונקציה create_value היא factory מקומי לצורך הניסוי. היא עדיין אינה רישום class בCOM. בעת activation רגיל הruntime מוצא class factory שמממש IClassFactory, ומבקש ממנו ליצור instance עם הממשק המבוקש. במימוש שאינו תומך aggregation, פרמטר outer שאינו null חייב להידחות עם CLASS_E_NOAGGREGATION.
בספרייה בתוך התהליך נקודת הכניסה DllGetClassObject מספקת factory. הפונקציה DllCanUnloadNow חייבת להביא בחשבון אובייקטים וserver locks, כדי שלא נפרוק קוד כשעדיין אפשר לקרוא לvtable שלו. בשרת מקומי מחוץ לתהליך רושמים factory עם CoRegisterClassObject, מנהלים חיי שרת ומבטלים את הרישום בזמן סגירה. אין להעתיק את אותו delete this ולהניח שהוא גם קובע מתי התהליך כולו יוצא.
הדוגמה המקומית אינה מפעילה את runtime הCOM ולכן אינה זקוקה לCoInitializeEx. ברגע שנעבור לactivation או marshalling נאתחל את התהליכון לפי המודל המתאים. הממשקים הפרטיים שלנו גם לא קיבלו עדיין proxy/stub, ולכן לא נציג העברת כתובת שלהם לתהליך אחר כתקשורת עובדת.
מתי נשבור את החוזה¶
הוספת מתודה באמצע ממשק קיים מזיזה slotים בvtable. לקוח ישן עלול לקרוא לפונקציה הלא נכונה. כדי להרחיב חוזה שפורסם, מגדירים ממשק חדש עם IID חדש ומאפשרים בירור שלו. הלקוח יכול להשתמש בו אם קיים ולחזור למסלול הישן אם לא.
אסור גם להעביר exception של C++ דרך גבול ABI אל לקוח שאינו יודע לפענח אותו. מימוש שמשתמש בקוד שעלול לזרוק יתפוס את החריגה בתוך המתודה, ישחרר משאבים ויחזיר HRESULT מתועד. בדוגמה שלנו פעולות המתודה מצומצמות ואינן מקצות. המנגנון נועד לאפשר חוזה בין רכיבים, ולכן כל פרט שלא כתוב בחוזה הוא מקום שבו שני הצדדים עלולים לנחש אחרת.