سلسلة ++c للمحترفين الدرس 44: قاعدة الثلاثة، والخمسة، والصفر في Cpp


محمد الميداوي

قاعدة الصفر

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

عند الجمع بين مبادئ "قاعدة الخمسة" (Rule of Five) و RAII نحصل على "قاعدة الصفر" (Rule of Zero) ذات الواجهة الرشيقة، والتي قدمها مارتينيو فرنانديز لأول مرة، وتنصّ على أنّ أيّ مورِدٍ تجب إدارته ينبغي أن يكون في نوعه الخاص. ويجب أن يتبع ذلك النوع "قاعدة الخمسة"، لكن ليس على كلّ مستخدمي ذلك المورد أن يكتبوا التوابع الخمسة التي تتطلّبها "قاعدة الخمسة" (كما سنرى لاحقا)، إذ يمكنهم استخدام الإصدار الافتراضي ‎default‎ من تلك التوابع. وسننشئ في مثال قاعدة الثلاثة أدناه كائنًا لإدارة موارد cstrings باستخدام الصنف Person، انظر:

class cstring {
private:
char* p;

public:
 ~cstring() { delete [] p; }
 cstring(cstring const& );
 cstring(cstring&& );
 cstring& operator=(cstring const& );
 cstring& operator=(cstring&& );

    /* أعضاء آخرون */
};

يصبح الصنف ‎Person‎ أكثر بساطة بعد فصل الشيفرة:

class Person
{
    cstring name;
    int arg;
    public:
        ~Person() = default;
    Person(Person
            const &) = default;
    Person(Person &&) = default;
    Person &operator=(Person
            const &) = default;
    Person &operator=(Person &&) = default;
    /*أعضاء آخرون */
};

لا يلزم التصريح عن الأعضاء الخاصين في ‎Person‎ صراحة إذ سيتكفّل المُصرّف باعتماد الإصدار الافتراضي أو حذفها استنادًا إلى محتويات ‎Person‎. إليك مثالًا آخر عن قاعدة الصفر.

struct Person
{
    cstring name;
    int arg;
};

إذا كان النوع ‎cstring‎ للنقل فقط (move-only type)، وكان يحتوي على عامل إنشاء/ إسناد ‎deleted‎، فسيكون ‎Person‎ تلقائيًا للنقل فقط أيضًا.

قاعدة الخمسة Rule of Five

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

قدَّمت C++‎ 11 دالتين تابعتين جديدتين هما مُنشئ النقل (move constructor) وعامل إسناد النقل (operator move assignment)، وستجد أن نفس الأسباب التي قد تجعلك ترغب في اتّباع "قاعدة الثلاثة" في C++‎ 03 (انظر أدناه) ستجعلك تتّبع أيضًا "قاعدة الخمسة" في C++‎ 11: أنه إذا كان الصنف يتطلّب إحدى الدوال التابعة الخاصّة وكانت دلالات النقل (move semantics) مطلوبة، فالراجح أن الصنف سيتطّلب كلّ التوابع الخمسة، لكن اعلم أنّ عدم اتباع "قاعدة الخمسة" لا يُعدُّ خطأ عادةً طالما أنّك تتّبع قاعدة الثلاثة، لكنّه قد يضيّع عليك فرصة تحسين برنامجك.

إذا لم يكن مُنشئ نقل أو مُعامل إسناد النقل متاحًا عندما يحتاجه المصرّف فسيَستخدم دلالات النسخ إن أمكن، هذا قد يؤدّي إلى إضعاف الكفاءة بسبب إجراء عمليات نسخ غير ضرورية. كذلك، لن تحتاج إلى التصريح عن مُنشئ نقل أو مُعامل إسناد إذا لم يكن الصنف بحاجة إلى دلالات النقل. انظر المثال التالي:

class Person
{
    char *name;
    int age;
    public:
        // مدمّر
        ~Person()
        {
            delete[] name;
        }

    // نفِّذ دلالات النَّسخ
    Person(Person
        const &other): name(new char[std::strlen(other.name) + 1]), age(other.age)
    {
        std::strcpy(name, other.name);
    }

    Person &operator=(Person
        const &other)
    {
        // استخدام أسلوب النسخ والمبادلة لتقديم عملية الإسناد
        Person copy(other);
        swap(*this, copy);
        return * this;
    }
    // نفذ دلالات النقل.

اعلم أن الأفضل هو جعل عوامل النقل كـ noexcept، إذ يسمح ذلك بتنفيذ بعض التحسينات من قبل المكتبة القياسية عند استخدام الصنف داخل حاوية، نتابع المثال …

       Person(Person && that) noexcept: name(nullptr) // ضبط القيمة لنعلم أنها غير محددة
    , age(0)
        {
            swap(*this, that);
        }

    Person &operator=(Person && that) noexcept
    {
        swap(*this, that);
        return * this;
    }

    friend void swap(Person &lhs, Person &rhs) noexcept
    {
        std::swap(lhs.name, rhs.name);
        std::swap(lhs.age, rhs.age);
    }
};

تستطيع -كخيار آخر- استبدال كل من مُعامل إسناد النسخ والنقل بمُعامل إسناد واحد يأخذ نسخة بالقيمة (by value) بدلاً من أخذها بالمرجع أو بالمرجع اليميني (rvalue reference)، وذلك لتسهيل استخدام تقنيات النسخ والمبادلة.

Person &operator=(Person copy)
{
    swap(*this, copy);
    return * this;
}

واعلم أن التوسّع من "قاعدة الثلاثة" إلى "قاعدة الخمسة" مهمّ لأسباب تتعلق بالأداء لكنّه في أغلب الحالات غير ضروري، ويضمن إضافة مُنشئ النسخ ومُعامل الإسناد أنّ نقل النوع لن يؤدّي إلى تسرّب الذاكرة -سيتحوّل إنشاء النقل إلى النسخ في هذه الحالة- لكنّه سيجري عمليات نسخ لم يتوقعها المُستدعي على الأرجح.

قاعدة الثلاثة Rule of Three

الإصدار ≤ C++‎ 03

تنصّ قاعدة الثلاثة على أنّه إن احتاج نوع معيّن أن يكون له مُنشئ نسخ مُعرَّف من قبل المستخدم (user-defined copy constructor)، أو مُعامل إسناد نسخ أو مُدمِّر، فيجب أن يتحوي على الثلاثة معًا.

وسبب إنشاء هذه القاعدة هو أنّ الصنف الذي يحتاج أيًّا من تلك الوظائف الثلاثة سيحتاج أيضًا إلى إدارة الموارد (مقابض الملفات، الذاكرة المخصّصة ديناميكيًا، الخ)، لكن إدارة تلك المورد تحتاج دائمًا إلى تلك الوظائف الثلاث، وتتكفّل دوال النسخ بنسخ الموارد من كائن لآخر بينما يتكفّل المدمّر بتدمير المورد وفقًا لمبادئ RAII.

يقدّم المثال التالي نوعًا يدير السلاسل النصية:

class Person
{
    char *name;
    int age;
    public:
        Person(char
            const *new_name, int new_age): name(new char[std::strlen(new_name) + 1]), age(new_age)
        {
            std::strcpy(name, new_name);
        }~Person()
        {
            delete[] name;
        }
};

وبما أن ذاكرة ‎name‎ مُخصّصة في المنشئ، فسيُلغي المدمّر تخصيصها لتجنّب تسرّب الذاكرة. لكن ماذا سيحدث في حال نُسِخ الكائن؟

int main()
{
    Person p1("foo", 11);
    Person p2 = p1;
}

أولاً، سيُنشَأ ‎p1‎، ثم يُنسَخ ‎p2‎ من ‎p1‎. لكن مُنشئ النسخ المُولَّد من C++‎ سينسخ كل مكوّن من مكوّنات النوع كما هو، ممّا يعني أنّ كلًّا من ‎p1.name‎ و ‎p2.name‎ سيشيران إلى نفس السلسلة النصّية.

وعند انتهاء ‎main‎ ستُستدعى المدمّرات ابتداءً بمدمّر ‎p2‎ الذي سيحذف السلسلة النصّية. ثم سيُستدعى مدمّر ‎p1‎. لكنّ السلسلة النصية قد حُذِفت سلفًا. سينتج سلوك غير محدد عند استدعاء ‎delete‎ على ذاكرة حُذِفت فعلًا، ويجب توفير مُنشئ نسخ مناسب لتجنّب هذا.

وإحدى طرق ذلك هي تطبيق نظام عدٍّ للمراجع (reference counted system)، حيث تتشارك مختلف نُسخ ‎Person‎ نفس البيانات النصية، ويُزاد عدّاد المرجع المشترك عند كلّ عملية نسخ، ثم يُنقِص المدمّر بعدها عدّاد المرجع، ولا يُحرِّر الذاكرة إلّا إذا كان العدّاد يساوي الصفر.

يمكننا أيضًا تطبيق الدلالات القيمية (value semantics) وسلوك النسخ العميق (deep copying):

Person(Person const &other): name(new char[std::strlen(other.name) + 1]), age(other.age)
{
    std::strcpy(name, other.name);
}

Person &operator=(Person const &other)
{
    // استخدام أسلوب النسخ والمبادلة لتطبيق معامل الإسناد
    Person copy(other);
    swap(copy);    // *this و copy تُبادِلُ محتويات swap() افتراض أن
    return * this;
}

تطبيق مُعامل إسناد النسخ معقّد بسبب الحاجة إلى تحرير مخزن مؤقّت (buffer)، وتنشئ تقنية النسخ والمبادلة كائنًا مؤقّتًا يحتفظ بالمخزن المؤقّت، ثم يمنح تبديل محتويات ‎*this‎ و ‎copy‎ مِلكِية المخزن المؤقّت لـ ‎copy‎، وعند تدمير ‎copy‎ -عند عودة الدالّة- فسيُحرَّر المخزن المؤقّت الذي يملكه ‎*this‎.

الوقاية من الإسناد الذاتي

عندما تكتب عامل إسناد نسخٍ فيجب أن تدرك أنه يجب أن يظل عاملًا في حالة حدوث إسناد ذاتي، أي يجب أن يسمح بما يلي:

SomeType t = ...;
t = t;

لا يحدث الإسناد الذاتي عادة بهذه الطريقة، وإنما يحدث في مسار دائري (circuitous route) في أنظمة الشيفرات، إذ يكون لموضع الإسناد (location of the assignment) مؤشّران أو مرجعان يشيران إلى كائن من النوع ‎Person‎، دون أن يدركا أنّهما في الحقيقة يمثّلان نفس الكائن.

ويجب أن يُصمَّم أي عامل إسناد نسخ تكتبه للتعامل مع هذا الأمر، والطريقة المعتادة لفعل ذلك هي بتغليف كامل منطق الإسناد في عبارة شرطية على النحو التالي:

SomeType &operator=(const SomeType &other)
{
    if (this != &other)
    {
        // منطق الإسناد هنا
    }

    return * this;
}

ملاحظة: من المهم أخذ الإسناد الذاتي في الحسبان، والحرص على أنّ شيفرتك ستتصرّف بالشكل الصحيح عند حدوثه. وبما أن الإسناد الذاتي أمر نادر الحدوث، وقد يؤدّي تحسين الشيفرة خصّيصًا لمنعه إلى التشويش على الحالة الطبيعية، فقد يؤدّي ذلك إلى تقليل كفاءة الشيفرة لأنّ الحالة العادية أكثر شيوعًا (لذا كن حذرًا عند استخدامه).

على سبيل المثال، الأسلوب العادي لتقديم مُعامل الإسناد هو أسلوب النسخ والمبادلة ، لكن التطبيق العادي لهذه التقنية لا يكلف نفسه عناء التحقق من الإسناد الذاتي -رغم أنّ الإسناد الذاتي مكلّف لأنّه ينطوي على عمليّة نسخ- والسبب هو أن الاحتياط أثبت أنه أكثر كلفة بكثير من الإسناد الذاتي بما أنه يحدث بكثرة.

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

كذلك يجب وقاية مُعاملات إسناد النقل من الإسناد الذاتي، لكن يبنى منطق عديد من هذه العوامل على ‎std::swap‎، والتي تستطيع التعامل مع التبديل من/إلى نفس الذاكرة بلا مشاكل. لذا إذا كان منطق إسناد النقل الخاص بك يتألّف حصرًا من سلسلة من عمليات التبديل فلن تحتاج إلى الاحتياط من الإسناد الذاتي. أما خلاف هذا فيجب عليك اتخاذ تدابير مماثلة على النحو الوارد أعلاه.

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

ترجمة -بتصرّف- للفصل Chapter 82: The Rule of Three, Five, And Zero من كتاب C++ Notes for Professionals





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


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



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن