اذهب إلى المحتوى

أنماط التصميم وتقنيات إعادة التصميم في Cpp


محمد بغات

سنستعرض في هذا الدرس بعض أنماط التصميم الشهيرة في C++‎ ثم سنتطرق سريعًا إلى مفهوم إعادة التصميم (Refactoring) والنمط Goto Cleanup المتَّبع.

نمط المحوِّل (Adapter Pattern)

يتيح نمط المحوِّل للأصناف غير المتوافقة أن تعمل معًا، والسبب الأساسي في استخدامه تكمن في أنّه يمكّن المطوّرين من إعادة استخدام البرامج الموجودة عبر تعديل الواجهة فقط.

  1. يعتمد نمط المحول على تركيب الكائنات (object composition).
  2. العميل يستدعي العملية على المحوِّل.
  3. يستدعي المحوِّل الكائن المحوَّل Adaptee لتنفيذ العملية.
  4. تُبنى المكدّسات (stacks) في مكتبة القوالب القياسية STL على المتجهات، فمثلًا: عندما ينفّذ المُكدّس الدّالةَ push()‎، فإنّ المتجه الأساسي (underlying vector) سينفذ التابع vector::push_back()‎.

انظر المثال التالي:

#include <iostream>
 // الواجهة المقصودة
class Rectangle {
    public:
        virtual void draw() = 0;
};
// المركّب القديم - المحوَّل
class LegacyRectangle {
    public:
        LegacyRectangle(int x1, int y1, int x2, int y2) {
            x1_ = x1;
            y1_ = y1;
            x2_ = x2;
            y2_ = y2;
            std::cout << "LegacyRectangle(x1,y1,x2,y2)\n";
        }
    void oldDraw() {
        std::cout << "LegacyRectangle:  oldDraw(). \n";
    }
    private:
        int x1_;
    int y1_;
    int x2_;
    int y2_;
};
// Adapter wrapper مغلِّف المحوَّل
class RectangleAdapter: public Rectangle, private LegacyRectangle {
    public: RectangleAdapter(int x, int y, int w, int h): LegacyRectangle(x, y, x + w, y + h) {
        std::cout << "RectangleAdapter(x,y,x+w,x+h)\n";
    }
    void draw() {
        std::cout << "RectangleAdapter: draw().\n";
        oldDraw();
    }
};
int main() {
    int x = 20, y = 50, w = 300, h = 200;
    Rectangle * r = new RectangleAdapter(x, y, w, h);
    r -> draw();
}
// :الخرج
//LegacyRectangle(x1,y1,x2,y2)
//RectangleAdapter(x,y,x+w,x+h)

شرح الشيفرة أعلاه:

  1. يعتقد العميل أنّه يتحدث إلى ‎Rectangle‎
  2. الهدف هو الصنف ‎Rectangle‎، وهو الذي سيستدعي العميلُ التابعَ عليه.
Rectangle * r = new RectangleAdapter(x, y, w, h);
r -> draw();
  1. لاحظ أنّ صنف المحوِّل (adapter class) يستخدم الوراثة المتعدّدة.
class RectangleAdapter: public Rectangle, private LegacyRectangle {
    ...
}
  1. يتيح المحوِّل‎RectangleAdapter‎ للصنف ‎LegacyRectangle‎ الاستجابة للطلب (استدعاء ‎draw()‎ على ‎Rectangle‎) عن طريق وراثة الصنفين معًا.
  2. لا يملك الصنف ‎LegacyRectangle‎ نفس التوابع (‎draw()‎) التي يملكها ‎Rectangle‎، لكن يمكن أن يأخذ Adapter(RectangleAdapter)‎ استدعاءات التابعِ ‎Rectangle‎ ثمّ يعود لاستدعاء التابع ‎LegacyRectangle‎ على ‎oldDraw()‎.
class RectangleAdapter: public Rectangle, private LegacyRectangle {
    public: RectangleAdapter(int x, int y, int w, int h): LegacyRectangle(x, y, x + w, y + h) {
        std::cout << "RectangleAdapter(x,y,x+w,x+h)\n";
    }
    void draw() {
        std::cout << "RectangleAdapter: draw().\n";
        oldDraw();
    }
};

يُترجمُ نمط المحوِّل واجهةَ صنف معيّن إلى واجهة أخرى متوافقة، ولكن مختلفة. لذلك، فهو يشبه نمط الوكيل من حيث أنّه مغلّف أحادي المكوّنات (single-component wrapper)، لكن قد تكون واجهة الصنف المحوَّل والصنف الأصلي مختلفة.

ويمكن استخدام نمط المحوِّل لإظهار واجهة برمجية (API) معيّنة للسماح لها بالعمل مع شيفرات أخرى كما رأينا في المثال أعلاه. أيضًا، يمكننا أن نأخذ واجهات غير متجانسة، ونحوّلها لواجهة برمجية موحّدة ومتسقة.

لدى نمط الجسر بنية مشابهة للكائنات المحوِّلة، بيْد أنّ للجسور هدفًا مختلفًا، إذ يُرادُ منها فصل الواجهة عن التقديم، حتّى يسهل تعديلها بشكل مستقل. أمّا المحوِّل فيُراد منه تعديل واجهة كائن موجود.

نمط المراقب (Observer pattern)

الهدف من نمط المراقب هو تعريف اعتمادية واحد-إلى-متعدد (one-to-many) بين الكائنات، بحيث إذا تغيرت حالة كائن تُرسل إشعارات إلى جميع الكائنات المتعلّقة به وتحديثها تلقائيًا.

يعرِّف الهدف (subject) والمراقبُون (observers) اعتمادية الواحد-إلى-متعدد، وفي هذه الاعتمادية يعتمد المراقبون على الأهداف، وعندما تتغيّر حالة الهدف يتم إشعار المراقبين تلقائيًا. وبناءً على ذلك يمكن تحديث المراقبين وإعطاؤهم قيمًا جديدة.

فيما يلي مثال من كتاب "Design Patterns" من تأليف جاما (Gamma).

#include <iostream>
#include <vector>

class Subject;
class Observer {
    public:
        virtual ~Observer() = default;
    virtual void Update(Subject&) = 0;
};
class Subject {
    public:
virtual ~Subject() = default;
void Attach(Observer& o) { observers.push_back(&o); }
void Detach(Observer& o)
{
observers.erase(std::remove(observers.begin(), observers.end(), &o));
}
void Notify()
{
for (auto* o : observers) {
o->Update(*this);
}
}
private:
std::vector<Observer*> observers;
};

class ClockTimer: public Subject {
    public: void SetTime(int hour, int minute, int second) {
        this -> hour = hour;
        this -> minute = minute;
        this -> second = second;
        Notify();
    }
    int GetHour() const {
        return hour;
    }
    int GetMinute() const {
        return minute;
    }
    int GetSecond() const {
        return second;
    }
    private: int hour;
    int minute;
    int second;
};
class DigitalClock: public Observer {
    public: explicit DigitalClock(ClockTimer& s) : subject(s) { subject.Attach(*this); }
              ~DigitalClock() {  subject.Detach( *this); }
    void Update(Subject& theChangedSubject) override {
        if ( &theChangedSubject == &subject) {
            Draw();
        }
    }
    void Draw() {
        int hour = subject.GetHour();
        int minute = subject.GetMinute();
        int second = subject.GetSecond();
        std::cout << "Digital time is " << hour << ":" <<
            minute << ":" <<
            second << std::endl;
    }
    private: ClockTimer& subject;
};
class AnalogClock: public Observer {
    public: explicit AnalogClock(ClockTimer& s): subject(s) {
        subject.Attach( *this); }
             ~AnalogClock() {  subject.Detach( * this);}
    void Update(Subject& theChangedSubject) override {
        if ( &theChangedSubject == &subject) {
            Draw();
        }
    }
    void Draw() {
        int hour = subject.GetHour();
        int minute = subject.GetMinute();
        int second = subject.GetSecond();
        std::cout << "Analog time is " << hour << ":" <<
            minute << ":" <<
            second << std::endl;
    }
    private: ClockTimer& subject;
};
int main() {
    ClockTimer timer;
    DigitalClock digitalClock(timer);
    AnalogClock analogClock(timer);
    timer.SetTime(14, 41, 36);
}

الخرج:

Digital time is 14: 41: 36
Analog time is 14: 41: 36

توضّح النقاط التالية ملخّص نمط المراقب:

  1. تستخدم الكائنات (‎DigitalClock‎ أو ‎AnalogClock‎) واجهات الموضوع (‎Attach()‎ أو ‎Detach()‎) إمّا لأجل الاشتراك (subscribe) كمراقبين، أو إلغاء الاشتراك (إزالة أنفسهم) من كونهم مراقبين (‎subject.Attach(*this);‎، ‎subject.Detach(*this);‎.
  2. يمكن أن يكون لكل موضوع عدّة مراقبين (‎vector<Observer*> observers;‎).
  3. يحتاج جميع المراقبين إلى تنفيذ واجهة المراقب (Observer interface). لدى هذه الواجهة تابع واحد فقط، وهو ‎Update()‎، ويُستدعى عند تغيّر حالة الموضوع (‎Update(Subject &)‎)
  4. بالإضافة إلى التابعين ‎Attach()‎ و ‎Detach()‎، ينفِّذ الهدف الحقيقي التابعَ ‎Notify()‎ الذي يُستخدم لتحديث جميع المراقبين الحاليين عندما تتغيّر الحالة، لكن تتم جميعها في هذه الحالة في الصنف الأب، Subject (Subject::Attach (Observer&)‎ و ‎void Subject::Detach(Observer&)‎ و void Subject::Notify()‎.
  5. قد يحتوي الكائن الحقيقي أيضًا على توابع لضبط قيمة حالته، أو الحصول عليها.
  6. يمكن أن تكون المراقبات الحقيقية أيّ صنف ينفذ واجهة المراقب (Observer interface)، ويشترك كل مراقب مع هدف حقيقي ليحصل على التحديثات (‎subject.Attach(*this);‎).
  7. كائنا نمط المراقب مترابطان بشكل طفيف، إذ يمكنهما التفاعل مع بعضهما البعض، لكنّ معرفتها ببعضهما محدودة.

الإشارات والفتحات (Slots)

الإشارات والفتحات (Slots) هي بنية لغوية قُدِّمت في Qt، وتسهّل على المطوّرين تقديم نمط المراقب دون الحاجة لاستخدام الشيفرات المُتداولة (boilerplate code).

الفكرة الرئيسية وراء الإشارات والفتحات هي أنّ عناصر التحكم (controls) التي تُعرف أيضًا باسم الودجات widgets) يمكنها إرسال إشارات تحتوي على معلومات حول الحدث، والتي يمكن استقبالها من قبل عناصر تحكم أخرى باستخدام دوال خاصة تُعرف باسم الفتحات (slots)، وهي أعضاء أصناف في Qt.

يتوافق نظام الإشارة / الفتحة مع تصميم واجهات المستخدم الرسومية، كما يمكن استخدام نظام الإشارة-الفتحة للدخل / الخرج غير المتزامن (asynchronous I/O) بما في ذلك المقابس sockets، والأنابيب pipes، والأجهزة التسلسلية serial devices، وغيرها مما يختص بإشعارات الأحداث أو لربط أزمنة الأحداث (timeout events) مع نُسخ الكائن والتوابع أو الدوالّ المناسبة.

لا يلزم كتابة شيفرة خاصة بالتسجيل/إلغاء التسجيل/الاستدعاء، لأنّ الكائن الوصفي للمصرّف (Meta Object Compiler أو اختصارًا MOC) الخاصّ بـ Qt يولّد البنية الأساسية اللازمة تلقائيًا .

تدعم لغة C#‎ أيضًا إنشاءات مشابهة، لكنها تستخدم مصطلحات وصيغة مختلفة: فالإشارات تسمّى أحداثًا، والفتحات تسمّى مفوِّضَات (delegates). إضافة إلى ذلك يمكن أن يكون المفوّض متغيّرًا محليًا، مثل مؤشّرات الدوال، بينما يجب أن تكون الفتحة في Qt عضوًا في صنف.

نمط المصنع (Factory Pattern)

يقسّم نمط المصنع (Factory pattern) عمليّة إنشاء الكائنات، ويتيح الإنشاء بالاسم باستخدام واجهة مشتركة:

class Animal {
    public:
        virtual std::shared_ptr < Animal > clone() const = 0;
    virtual std::string getname() const = 0;
};
class Bear: public Animal {
    public: virtual std::shared_ptr < Animal > clone() const override {
        return std::make_shared < Bear > ( * this);
    }
    virtual std::string getname() const override {
        return "bear";
    }
};
class Cat: public Animal {
    public: virtual std::shared_ptr < Animal > clone() const override {
        return std::make_shared < Cat > ( *this);
    }
    virtual std::string getname() const override {
        return "cat";
    }
};
class AnimalFactory {
    public:
        static std::shared_ptr < Animal > getAnimal(const std::string& name) {
            if (name == "bear")
                return std::make_shared < Bear > ();
            if (name == "cat")
                return std::shared_ptr < Cat > ();

            return nullptr;
        }
};

نمط الباني

يفصل نمط الباني (Builder Pattern) عملية إنشاء الكائن عن الكائن نفسه، والفكرة الرئيسية وراء ذلك هي أنّ الكائن ليس عليه مسؤولية إنشائه، وقد تكون عمليّة تصريف الكائنات المعقّدة مهمّة معقدة في حدّ ذاتها، لذا يمكن تفويض هذه المهمة إلى صنف آخر.

سأنشئ فيما يلي بانيًا بريديًا Email Builder بلغة C++‎، وهو مستوحى من فكرة مشابهة في في C#‎، كائن البريد الإلكتروني ليس بالضرورة كائنًا معقدًا، ولكنّه مثال جيد لتوضيح كيفية عمل هذا النمط.

#include <iostream>
#include <sstream>
#include <string>

using namespace std;
// التصريح اللاحق للباني
class EmailBuilder;
class Email {
    public:
        friend class EmailBuilder; //  Email يمكن للباني الوصول إلى الأعضاء الخاصة في

    static EmailBuilder make();

    string to_string() const {
        stringstream stream;
        stream << "from: " << m_from <<
            "\nto: " << m_to <<
            "\nsubject: " << m_subject <<
            "\nbody: " << m_body;
        return stream.str();
    }

    private:
        Email() = default; // قصر الإنشاء على الباني

    string m_from;
    string m_to;
    string m_subject;
    string m_body;
};
class EmailBuilder {
    public:
        EmailBuilder& from(const string &from) {
            m_email.m_from = from;
            return *this;
        }

    EmailBuilder& to(const string &to) {
        m_email.m_to = to;
        return *this;
    }

    EmailBuilder& subject(const string &subject) {
        m_email.m_subject = subject;
        return *this;
    }

    EmailBuilder& body(const string &body) {
        m_email.m_body = body;
        return *this;
    }

    operator Email&& () {
        return std::move(m_email); // لاحظ عمليّة النقل
    }

    private:
        Email m_email;
};
EmailBuilder Email::make() {
    return EmailBuilder();
}
// مثال إضافي
std::ostream& operator << (std::ostream& stream,
    const Email& email) {
    stream << email.to_string();
    return stream;
}
int main() {
    Email mail = Email::make().from("me@mail.com")
        .to("you@mail.com")
        .subject("C++ builders")
        .body("I like this API, don't you?");

    cout << mail << endl;
}

بالنسبة للإصدارات الأقدم من C++‎، يمكن تجاهل عملية ‎std::move‎ وإزالة && من عامل التحويل لكنّ هذا سيؤدّي إلى إنشاء نسخة مؤقتة.

ينهي المنشئ عمله عندما يُرسَل البريد الإلكتروني بواسطة ‎operator Email&&()‎. يكون المنشئ في هذا المثال كائنًا مؤقتًا، ويعيد البريدَ الإلكتروني قبل تدميره.

يمكنك أيضًا استخدام عملية صريحة مثل ‎Email EmailBuilder::build() {...}‎ بدلًا من عامل التحويل.

تمرير الباني

من الميزات الرائعة التي يوفّرها "نمط الباني" هي القدرةُ على استخدام عدّة عوامل (actors) لإنشاء كائن معيّن، ويمكن ذلك عن طريق تمرير المنشئ إلى العوامل الأخرى، والتي سيعطي كل منها بعض المعلومات الإضافية للكائن المبنِيّ. هذا مفيد بشكل خاص عندما تريد بناء الاستعلامات query، أو إضافة المُرشِّحات، وغيرها من المواصفات.

void add_addresses(EmailBuilder& builder) {
    builder.from("me@mail.com")
        .to("you@mail.com");
}
void compose_mail(EmailBuilder& builder) {
    builder.subject("I know the subject")
        .body("And the body. Someone else knows the addresses.");
}
int main() {
    EmailBuilder builder;
    add_addresses(builder);
    compose_mail(builder);

    Email mail = builder;
    cout << mail << endl;
}

الكائنات القابلة للتغيير

يمكنك تغيير تصميم نمط الباني بما يناسب احتياجاتك، سنوضّح هذا الأمر في هذه الفقرة: كائن البريد الإلكتروني في المثال السابق كان غير قابل للتغيير (immutable)، أي أنّه لا يمكن تعديل خاصّياته لأنّه لا يمكن الوصول إليها، وقد كانت هذه الميزة مطلوبة، لكن ماذا لو كنت بحاجة إلى تعديل الكائن بعد إنشائه، سيكون عليك أن توفّر له بعض الضوابط (setters). ولمّا كانت تلك الضوابط تتكرّر في المنشئ، فقد تفكر في جمعها جميعًا في صنف واحد -لن تكون هناك حاجة للصنف الباني إذن-. لكن قد يكون الأفضل جعل الكائن المبنِيّ قابلاً للتغيير.

نمط تصميم المفردة (Singleton Design Pattern)

التهيئة المُرجأة (Lazy Initialization)

عثرت على هذا المثال في قسم ‎Q & A‎ في هذا الرابط.

انظر أيضًا هذه المقالة للحصول على تصميم بسيط لتقييم مُرجأ مع مفردة مضمونة التدمير.

انظر المثال التالي عن مفردة تقليدية ذات تقييم مُرجأ ومُدمَّرة بشكل صحيح.

class S {
    public:
        static S& getInstance() {
            static S instance;    // تدميرها مضمون
                        // تُستنسخ عند أوّل استخدام
            return instance;
        }
    private:
        S() {};            // القوسان المعقوصان ضروريان هنا

    // C++ 03
    // ========
    // لا تنس التصريح عن هذين الاثنين، احرص على أن يكونا غير مقبولين
    // وإلّا فقد تُنسخ المفردة

    S(S const&); // لا تنفذها
    void operator=(S const&); // لا تنفذها
    // C++ 11
    // =======
    // بإمكاننا أيضا استخدام طريقة حذف التوابع، لكنّنا لن نفعل
    public:
        S(S  const& ) = delete;
    void operator = (S const& ) = delete;
};

ملاحظة: ذكر سكوت مايرز (Scott Meyers) في كتابه Effective Modern C++ أن التوابع المحذوفة يجب أن تكون عامة، فذلك يسهل اكتشاف الأخطاء لأن رسائل الخطأ تكون أفضل حينها، فالمصرِّفات تتحقق من قابلية الوصول (accessibility) قبل الحالة المحذوفة.

يمكنك معرفة المزيد عن المتفرّدات من الروابط التالية:

المفردات الساكنة الآمنة من إلغاء التهيئة (Static deinitialization-safe singleton)

قد تعتمد بعض الكائنات الساكنة (static objects) في بعض الحالات على المفردة، وقد ترغب في ضمان منع تدميرها إلا عند عدم الحاجة إليها. لأجل ذلك يمكن استخدام ‎std::shared_ptr‎ لمنع تدمير المُتفرّدات وإبقائها متاحة لجميع من يستخدمها حتى عندما تُستدعى المدمّرات الساكنة في نهاية البرنامج:

class Singleton {
    public:
        Singleton(Singleton const&) = delete;
        Singleton& operator=(Singleton const&) = delete;

    static std::shared_ptr < Singleton > instance() {
        static std::shared_ptr < Singleton > s {
            new Singleton
        };
        return s;
    }
    private:
        Singleton() {}
};

ملاحظة: يظهر هذا المثال كإجابة في قسم الأسئلة والأجوبة في موقع SO.

المفردات الآمنة خيطيًا (Thread-safe Singeton)

الإصدار ≥ C++‎ 11

يضمن معيار C++‎ 11 أنّ كائنات نطاق الدوالّ (function scope objects) تُهيَّأ بطريقة متزامنة، ويمكن استخدام هذا لتقديم مفردة آمنة خيطيًا مع تهيئة مُرجأة.

class Foo {
    public:
        static Foo& instance() {
            static Foo inst;
            return inst;
        }
    private:
        Foo() {}
    Foo(const Foo&) = delete;
    Foo& operator =(const Foo&) = delete;
};

الأصناف الفرعية (Subclasses)

انظر المثال التالي:

class API {
    public:
        static API& instance();

    virtual~API() {}

    virtual
    const char* func1() = 0;
    virtual void func2() = 0;

    protected:
        API() {}
    API(const API& ) = delete;
    API& operator = (const API& ) = delete;
};
class WindowsAPI: public API {
    public: virtual
    const char* func1() override {
        /* شيفرة ويندوز */ }
    virtual void func2() override {
        /* شيفرة ويندوز */ }
};
class LinuxAPI: public API {
    public: virtual
    const char* func1() override {
        /* شيفرة لينكس */ }
    virtual void func2() override {
        /* شيفرة لينكس */ }
};
API& API::instance() {
   #if PLATFORM == WIN32
       static WindowsAPI instance;
   #elif PLATFORM = LINUX
       static LinuxAPI instance;
  #endif
return instance;
}

المُصرّف في هذا المثال يربط الصنف API بالصنف الفرعي المناسب، من أجل الوصول إلى API حتّى لو لم يكن مربوطًا بشيفرة مخصوصة بمنصّة معينة.

تقنيات إعادة التصميم (Refactoring Techniques)

يشير مفهوم إعادة البناء (Refactoring) إلى تعديل الشيفرة واستبدال نسخة مُحسّنة بها، ورغم أنّ إعادة البناء تُجرى غالبًا أثناء تغيير الشيفرة بُغية إضافة بعض الميزات أو تصحيح الأخطاء، إلّا أنّ هذا المصطلح مخصوص أساسًا بعمليات تحسين الشيفرة بدون إضافة ميزات أو تصحيح الأخطاء.

Goto Cleanup

يُستخدم أحيانًا نمط التصميم ‎goto cleanup‎ في شيفرات C++‎ التي بُنِيت على شيفرات مكتوبة بلغة C، ونظرًا لأنّ الأمر ‎goto‎ يصعِّب فهم سير عمل الدوال، فغالبًا ما يُوصى بتجنّبه. ويمكن استبدال تعليمة return أو الحلقات أو الدوال بالأمر ‎goto‎. بالمقابل، يتيح استخدام ‎goto cleanup‎ التخلُّص من منطق التنظيف (cleanup logic).

short calculate(VectorStr **data) {
    short result = FALSE;
    VectorStr *vec = NULL;
    if (!data)
        goto cleanup; //< return false يمكن أن يُستعاض عنها بـ
    result = TRUE;
    cleanup:
        delete[] vec;
    return result;
}

في C++‎، يمكنك استخدام تقنية RAII لحلّ هذه المشكلة:

struct VectorRAII final {
    VectorStr *data {
        nullptr
    };
    VectorRAII() =
        default;
    ~VectorRAII() {
        delete[] data;
    }
    VectorRAII(const VectorRAII & ) = delete;
};
short calculate(VectorStr **data) {
    VectorRAII vec {};
    if (!data)
        return FALSE; //< return false يمكن الاستعاضة عنها بـ
    return TRUE;
}

بعد هذا، يمكنك الاستمرار في إعادة بناء الشيفرة. مثلًا، عن طريق استبدال ‎VectorRAII‎ بمؤشّر فريد std::unique_ptr أو متّجه std::vector.

هذا الدرس جزء من سلسلة دروس عن C++‎.

ترجمة -بتصرّف- للفصل Chapter 112: Design pattern implementation in C++‎ والفصل Chapter 113: Singleton Design Pattern من كتاب C++ Notes for Professionals


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...