COM

من ويكي
اذهب إلى: تصفح، ابحث

COM اختصاراً Component Object Model عبارة عن معايير إن اتبعتها فسيمكنك مشاركة الفئات والكائنات بين برامج ومكتبات C++ ترجمت باستخدام مترجمات مختلفة، وحتى باستخدام لغات مختلفة، تستخدم COM في Windows مع مكتبات مثل Direct3D وغيرها من الواجهات البرمجية الكائنية.

تصدير الفئات في C++

واحدة من المشاكل مع C++ هي اختلاف الطريقة التي تدار بها الذاكرة وطريقة استدعاء الدوال وبنية الفئات بين المترجمات المختلفة بل ربما حتى بين إصدارات نفس المترجم، وذلك لعدم وجود معيار موّحد نظراً لأن وثائق اللغة لا تتطرق لهذا الجانب، فلو حاولت تصدير فئة class خلال مكتبة مشتركة مثل DLL فأنت بحاجة لاستخدام نفس المترجم وربما نفس الإصدار، بالإضافة لمشاكل التوافقية عند تعديل بنية الفئات، لذا ممارسة إعادة استخدام الأكواد لاتزال محدودة في C++.

تركيبة جدول الدوال التخيلية، هناك مؤشر يشير لمصفوفة دوال يليها أعضاء الفئة، لاحظ أن هناك 4 بايتات أضيفت لمحاذاة البيانات على 8 بايت لكون المعالج x64

في أوائل التسعينيات طورت Microsoft معيار COM حيث تستغّل كون معظم المترجمات تستخدم نفس الآلية لتطبيق الدوال التخيلية virtual في C++ وذلك باستخدام جدول الدوال التخيلية vtable، حيث أن كل كائن تُنشئه سيبدو في الذاكرة كبنية تحتوي في مقدمتها على بيانات الفئة التي ترث منها إن كان هناك واحدة، يليها مؤشر لمصفوفة عبارة عن مصفوفة مؤشرات دوال كل مؤشر يشير للدالة الاصلية، ويلي ذلك بيانات الفئة كما في الصورة على اليسار، رغم أن C++ لاتجبر المترجمات على استخدام هذه الآلية إلا أنه تقريباً جميع مترجمات C++ التي تدعم Windows تدعهما، ولو أراد المترجم أن يستخدم على Windows فهو بحاجة لاستخدامها كي يتمكن من دعم COM.

تقوم COM على تحديد عناصر رئيسية:

  • استغلال التصميم المشترك لجدول الدوال التخيلية بين المترجمات، هكذا نتكمن من تصدير الفئات
  • توحيد طريقة الاستدعاء لتكون stdcall، حيث ستمرر معاملات الدوال عبر المكدّس وسيتولى المُستدعى -الدالة التي تستدعيها- عليه مسؤولية تنظيف المكدسّ من المعاملات التي مررها عبره
  • جعل عملية حجز الذاكرة وتحريرها تتم من طرف مزّود الفئة، نظراً لاختلاف طريقة عمل مخصص الذاكرة new/malloc ومحررها delete/free فكل مترجم بل حتى أحياناً كل مكتبة تستخدم مخصص ذاكرة مختلف، مع إضافة آلية عداد إشارة
  • جعل عملية التحويل بين الأنواع تتم من طرف المزوّد أيضاً، نظراً لأن كل مترجم يطبّق dynamic_cast ومعلومات أنواع البيانات RTTI بطريقة مختلفة
  • تجنّب استخدام الاستثناءات نظراً لاختلاف طريقة عملها بين المترجمات، والاعتماد على HRESULT للإبلاغ عن الأخطاء

هذه الخصائص تجسّدها واجهة IUnknown، لو فهمتها فستتمكن من استخدام أي واجهة برمجية مصّدر كـ COM، هناك الكثير من التفاصيل الأخرى التي قد لا تهمك إلا في حال كنت تريد إنشاء مكتبة تصدّر فئات باستخدام COM، لن نتطرق لتلك التفاصيل حالياً، بل يهمنا فقط فهم أساسيات COM وكيف تستخدمها في مثلاً Direct3D، ربما أفضل مصدر أنصح به للتوسع في COM هو كتاب Essential COM.

الواجهة IUnknown

جميع واجهات COM ترث الواجهة IUnknown وتعريفها:

struct IUnknown
{
  virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObject) = 0;
  virtual ULONG STDMETHODCALLTYPE AddRef() = 0;
  virtual ULONG STDMETHODCALLTYPE Release() = 0;
};

الماكرو STDMETHODCALLTYPE تغليف لـ __stdcall وتستخدم لتوحيد طريقة تمرير معاملات الدوال بحيث تجعل معاملات الدوال تمرر عبر المكدس وتجعل المُستدعى ينظف المكدسّ بعد نهاية استدعاء الدالة وقبل أن تعيد قيمتها، والماكرو REFIID تغليف لمرجع أو مؤشر لـ IID، فكل واجهة في COM لها معرف واجهة interface identifier لها GUID[١] يميزها عن باقي الواجهات، مثلاً الـ GUID الخاص بالواجهة IUnknown اسمه IID_IUnknown وقيمته هي 00000000-0000-0000-C000-000000000046، جميع واجهات COM تأخذ هذا النمط في التسمية.

HRESULT يستخدم للاستلام عن الأخطاء، يمكنك استخدام الماكرو SUCCEEDED(hr) للتحقق من نجاح العملية أو FAILED(hr) للتحقق من فشلها.

الدالة QueryInterface تعمل عمل dynamic_cast لكن بطريقة مستقلة عن المترجم، تأخذ في أول معامل معرّف الواجهة التي تريد استخدامها وتأخذ مؤشر إلى مؤشر ستخزن فيه مؤشر الفئة عند نجاح الاستدعاء أو nullptr إذا لم تكن تلك الواجهة مدعومة، عند نجاح الاستدعاء فسيتم زيادة عدّاد الإشارة بواحد، مثلاً هذا المثال في C++ القياسية:

TypeExtended *typeExtendedInstance = dynamic_cast<TypeExtended *>(typeInstance);
if (typeExtendedInstance != nullptr)
{
  ...
}

يكافئ في COM:

ITypeExtended *typeExtendedInstance;
HRESULT hr = typeInstance->QueryInterface(IID_ITypeExtended, reinterpret_cast<void **>(&typeExtendedInstance));
if (SUCCEEDED(hr))
{
  ...

  typeExtendedInstance->Release();
}

لاحظ أننا احتجنا لاستدعاء Release لأن كل استدعاء ناجح لـQueryInterface يزيد عداد الإشارة.

كل فئة COM تحتوي داخلياً على عدّاد إشارة تكون قيمته 1 عند إنشاء الكائن في جهة المزوّد، تقوم الدالة AddRef بزيادة عّداد الإشارة وتعيد قيمته بعد الزيادة، وتقوم Release بإنقاص واحد وتعيد قيمته بعد الإنقاص، في حال وصول عدّاد الإشارة صفر فإن هذا يعني أن الكائن لم يعد في الإستخدام وستقوم بهدمه، هذه الطريقة تنقل عمليّة تحرير ذاكرة الكائن من المستخدم للمزوّد لنتخلص من مشكلة إختلاف طريقة عمل مخصص الذاكرة.

مثال لواجهة بسيطة

في هذا المثال سننشئ واجهة اسمها IType وفيها دالة واحدة فقط للتجربة، في COM درج العرف على بدء أسماء الواجهات بالحرف I اختصاراً لكلمة واجهة Interface:

// Header.h
#ifndef __HEADER_H__
#define __HEADER_H__

#include <Unknwn.h> // IUnknown

extern const GUID IID_IType;

// الواجهة - نعطيها العميل
struct __declspec(uuid("BFA18AB8-8D86-49F0-B72E-E112BE6733FF")) IType : IUnknown
{
  virtual HRESULT STDMETHODCALLTYPE Do() = 0;
};

HRESULT STDMETHODCALLTYPE CreateType(IType **ppObject);

#endif

__declspec(uuid("BFA18AB8-8D86-49F0-B72E-E112BE6733FF")) عبارة عن إضافة تخص COM يدعمها MSVC تمكنك من استخدام الإضافة الأخرى __uuidof(IType) للحصول على معّرف الواجهة IID_IType، أبقينا على الأخيرة في حال أراد أحد استخدام الواجهة على مترجم لايدعم تلك الإضافات مثل MinGW، الدالة CreateType سنستخدمها بدلاً من البناء لإنشاء الكائن ومنها نخفي التطبيق الداخلي للواجهة.

// Provider.cpp
#include "Header.h"
#include <cstdio>
#include <new> // std::nothrow

const GUID IID_IType =
{ 0xbfa18ab8, 0x8d86, 0x49f0,{ 0xb7, 0x2e, 0xe1, 0x12, 0xbe, 0x67, 0x33, 0xff } };

// هذه الفئة تحتوي على تطبيق الواجهة، يجب أن  نبقيها خاصة كي لايستخدمها
// العميل مباشرة لنتمكن من تعديلها في المستقبل دون كسر التوافقية
class InternalImplementation : public IType
{
public:
  virtual ~InternalImplementation()
  {
    std::puts("~InternalImplementation()");
  }

  HRESULT STDMETHODCALLTYPE QueryInterface(const IID& riid, void** ppvObject) override
  {
    // نتأكد من سلامة المؤشر
    if (ppvObject == nullptr)
      return E_POINTER;

    // نتحقق من أن الواجهة المطلوبة أحد الواجهات التي ندعمها
    if (riid == IID_IUnknown || riid == IID_IType)
    {
      // نزيد عداد الإشارة
      *ppvObject = this;
      AddRef();
      return S_OK;
    }

    // نخبر العمل أن الواجهة غير مدعومة، تشترط الوثائق أن تصفّر المؤشر
    *ppvObject = nullptr;
    return E_NOINTERFACE;
  }

  ULONG STDMETHODCALLTYPE AddRef() override
  {
    // نزيد عداد الإشارة ونعيد قيمته بعد الزيادة
    return ++m_refCounter;
  }

  ULONG STDMETHODCALLTYPE Release() override
  {
    // ننقص عداد الإشارة
    auto current = --m_refCounter;

    // في حال وصوله للصفر نحذف الكائن
    if (current == 0)
      delete this;

    // نعيد قيمة العداد بعد النقصان
    return current;
  }

  HRESULT STDMETHODCALLTYPE Do() override
  {
    std::puts("Do()");
    return S_OK;
  }

private:
  ULONG m_refCounter = 1;
};

// دالة تستخدم لإنشاء كائن - نعطيها العميل
HRESULT STDMETHODCALLTYPE CreateType(IType **ppObject)
{
  if (ppObject == nullptr)
    return E_POINTER;

  // لانريد أن يحدث استثناء عند فشل تخصيص الذاكرة
  *ppObject = new (std::nothrow) InternalImplementation;

  if (*ppObject)
    return S_OK;

  return E_OUTOFMEMORY;
}

الشرح في التعيقات، لكن لاحظ أن InternalImplementation غير معرّف في الملف الرأسي Header.h ولايمكن للعميل أن يراه، هكذا لدينا الحرية لتعديل الفئة دون كسر التوافقية، ولاحظ أيضاً أن إنشاء الكائن وحجز ذاكرته وتحريرها يتم في جهة المزوّد، الكثير من كائنات COM تصمم لتكون آمنة عندما تستخدمها أكثر من خيط معالجة thread-safe، لكن لانريد تعقيد المثال.

// Client.cpp
#include "Header.h"

int main()
{
  // عميل يستخدم الواجهة
  HRESULT hr;
  IType *type;

  // ننشئ الكائن
  hr = CreateType(&type);
  if (SUCCEEDED(hr))
  {
    type->Do();
    // ننقص عداد الإشارة كي يتم تحرير الكائن
    type->Release();
  }

  return 0;
}

مثال للعميل، لاحظ أن العميل لايحجز ذاكرة الكائن بنفسه ولايحررها.

تعديل الواجهة

في المثال السابق لم نستخدم QueryInterface، في هذا المثال سنرى سيناريو تعديل واجهة سبق نشرها، في COM الواجهة بمجرد نشرها تصبح نهائية، ويجب أن لا تعدلها ولا تغير معرفها، لذا لإضافة خصائص جديدة لواجهة فإن واجهة جديدة تنشأ ترث الواجهة القديمة، لنفترض أننا نود إضافة دالة جديدة اسمها DoExtended للواجهة السابقة، لعملها ننشئ واجهة جديدة لنسمها ITypeExtended ترث الواجهة ITypeExtended ولها معرّف جديد ونبقي أيضاً على الواجهة السابقة كي يستخدمها العملاء القديمين، كونها ترث فهي سترث أيضاً IUnknown:

...
extern const GUID IID_IType;

// الواجهة - نعطيها العميل
struct __declspec(uuid("BFA18AB8-8D86-49F0-B72E-E112BE6733FF")) IType : IUnknown
{
  virtual HRESULT STDMETHODCALLTYPE Do() = 0;
};

extern const GUID IID_ITypeExtended;

// الواجهة الجديدة - نعطيها العميل
struct __declspec(uuid("24D30BBE-03DB-4274-B1E3-0D3904CBECAE")) ITypeExtended : IType
{
  virtual HRESULT STDMETHODCALLTYPE DoExtended() = 0;
};
...

التعديلات في المزوّد:

const GUID IID_IType =
{ 0xbfa18ab8, 0x8d86, 0x49f0,{ 0xb7, 0x2e, 0xe1, 0x12, 0xbe, 0x67, 0x33, 0xff } };

const GUID IID_ITypeExtended =
{ 0x24d30bbe, 0x3db, 0x4274,{ 0xb1, 0xe3, 0xd, 0x39, 0x4, 0xcb, 0xec, 0xae } };

// هذه الفئة تحتوي على تطبيق الواجهة، يجب أن  نبقيها خاصة كي لايستخدمها
// العميل مباشرة لنتمكن من تعديلها في المستقبل دون كسر التوافقية
class InternalImplementation : public ITypeExtended
{
  ...
  HRESULT STDMETHODCALLTYPE QueryInterface(const IID& riid, void** ppvObject) override
  {
    // نتأكد من سلامة المؤشر
    if (ppvObject == nullptr)
      return E_POINTER;

    // نتحقق من أن الواجهة المطلوبة أحد الواجهات التي ندعمها
    if (riid == IID_IUnknown || riid == IID_IType || riid == IID_ITypeExtended)
    {
      // نزيد عداد الإشارة
      *ppvObject = this;
      AddRef();
      return S_OK;
    }

    // نخبر العمل أن الواجهة غير مدعومة، تشترط الوثائق أن تصفّر المؤشر
    *ppvObject = nullptr;
    return E_NOINTERFACE;
  }

  ...

  HRESULT STDMETHODCALLTYPE Do() override
  {
    std::puts("Do()");
    return S_OK;
  }

  HRESULT STDMETHODCALLTYPE DoExtended() override
  {
    std::puts("DoExtended()");
    return S_OK;
  }
  
  ...

الآن لو نفذت العميل فسيعمل مستخدماً الواجهة القديمة بشكل طبيعي، لو أراد العميل التحديث و استخدام الواجهة الجديدة فيمكنه استخدام QueryInterface هكذا:

  ...
  HRESULT hr;
  IType *type;

  // ننشء الكائن
  hr = CreateType(&type);
  if (SUCCEEDED(hr))
  {
    type->Do();

    // تكافئ استخدام dynamic_cast
    ITypeExtended *typeExtended;
    hr = type->QueryInterface(__uuidof(ITypeExtended), reinterpret_cast<void**>(&typeExtended));
    if (SUCCEEDED(hr))
    {
      typeExtended->DoExtended();
      typeExtended->Release();
    }

    // ننقص عداد الإشارة كي يتم تحريره
    type->Release();
  }
  ...

طبعاً يمكنك استخدام IID_ITypeExtended بدلاً من __uuidof(ITypeExtended) والنتيجة واحدة.

استخدام القالب ComPtr

هناك فئة اسمها ComPtr في الملف الرأسي wrl/client.h تحت المجموعة Microsoft::WRL والتي تحتوي على مجموعة فئات تسهل استخدام COM، حيث تعمل هذه الفئة عمل مؤشر ذكي smart pointer لكائنات COM، حيث تدير عملية تحرير الكائن، فبمجرد خروج الكائن من المجال scope مثل {} فإن الهدام سيستدعي Release بدلاً عنك، هذا بالإضافة لوظائف أخرى مفيدة تقلل الأسطر.

لو أعدنا كتابة العميل باستخدام ComPtr سيصبح:

// Client.cpp
#include "Header.h"
#include <wrl/client.h>

using Microsoft::WRL::ComPtr;

int main()
{
  // عميل يستخدم الواجهة
  {
    HRESULT hr;
    ComPtr<IType> type;

    // ننشء الكائن
    hr = CreateType(&type); // أو CreateType(type.GetAddressOf());
    if (SUCCEEDED(hr))
    {
      type->Do();

      ComPtr<ITypeExtended> typeExtended;
      hr = type.As(&typeExtended);
      if (SUCCEEDED(hr))
      {
        typeExtended->DoExtended();
      }
    }
  }

  return 0;
}

ستلاحظ أن الكود أجمل وأننا لسنا بحاجة لاستخدام Release كون ComPtr ستستدعيه لنا، وضعت {} حول البرنامج لإنشاء مجال كي يتسنى لك رؤية هدم الكائن قبل الخروج من الدالة الرئيسية main().

انتبه لنقطة مهمة وهي أن العملية ComPtr::operator& التي استخدمناها في &type و &typeExtended تستدعي ComPtr::ReleaseAndGetAddressOf والتي تقوم باستدعاء Release ثم إعادة المؤشر الداخلي، الهدف هو تحرير الكائن في حال كان مستخدم سابقاً، في حالتنا قيمته nullptr لذا لن تستدعى Release، لكن لو أردت تمرير type مثلاً لدالة تأخذ IType** فيجب أن لاتستخدم ComPtr::operator& لأنها ستحرر الكائن، بل استخدم بدلها ComPtr::GetAddressOf مثل type.GetAddressOf()، في حال أردت الحصول على الكائن نفسه وليست عنوانه - أي IType *- فاستخدام ComPtr::Get.

الدالة ComPtr::As تعمل عمل QueryInterface، انتبه أنها لاقبل T** لذا لايمكن استخدام GetAddressOf معها.
  1. GUID هو UUID، الفرق هو أن UUID معيار قياسي جميع مركّباته تستخدم big endian لترتيب البايتات، مثلاً 0x1122 يستخزن 0x11 في البايت الأول، بينما GUID من Microsoft يستخدم ترتيب بيانات المعالج المستخدم، مثلاً على x86/x64 يخزن 0x1122 كـ little endian بمعنى أن البايت الأول سيكون 0x22.