سلسلة ++c للمحترفين الدرس 25: الدوال المضمنة (Inline functions) والدوال التابعة (Member Functions) في Cpp


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

تُسمى الدوال المُعرّفة بالكلمة المفتاحية ‎inline‎ دوالًا مُضمّنة (inline functions)، ويمكن تعريفها أكثر من مرة دون انتهاك قاعدة التعريف الواحد (One Definition Rule)، وعليه يمكن تعريفها في الترويسة مع الارتباطات الخارجية.

التصريح عن دالّة ما بأنها مضمّنة يُخبر المصرّف أنّ تلك الدالّة يجب أن تُضمَّن أثناء توليد الشِّيفرة البرمجية (لكنّ ذلك غير مضمون).

الدوال المضمنة

تعريف الدوال غير التابعة المضمنة (Non-member inline function definition)

inline int add(int x, int y) {
    return x + y;
}

الدوال التابعة المضمنة (Member inline functions)

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

// header (.hpp)
struct A {
    void i_am_inlined() {}
};
struct B {
    void i_am_NOT_inlined();
};
// source (.cpp)
void B::i_am_NOT_inlined() {}

ما المقصود بتضمين دالّة؟

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

inline int add(int x, int y) {
    return x + y;
}
int main() {
    int a = 1, b = 2;
    int c = add(a, b);
}

في الشيفرة أعلاه، عندما تكون ‎add‎ مضمّنة، فستصبح الشيفرة الناتجة كما يلي:

int main() {
    int a = 1, b = 2;
    int c = a + b;
}

لا يمكن رؤية الدالة المضمّنة إذ يُضمَّن متنها في متن المُستدعي، ولو لم يكن التابع ‎add‎ مضمّنًا، لاستدعيَت الدالّة. الحِمل الإضافي (overhead) الناتج عن استدعاء دالة -مثل إنشاء إطار مكدِّس (stack frame) جديدة، ونسخ الوسائط، وإنشاء المتغيرات المحلية، وغير ذلك - سيكون مكلفًا.

التصريح عن الدوال المضمنة غير التابعة

يمكن التصريح عن الدوالّ المضّمنة غير التابعة على النحو التالي:

inline int add(int x, int y);

الدوال التابعة الخاصة

المنشئ الافتراضي (Default Constructor)

المُنشئ الافتراضي هو نوع من المُنشئات التي لا تتطلّب أيّ معاملات عند استدعائها وتُسمى باسم النوع الذي تنشئه، كما أنها تُعدّ دالة تابعة من توابعه، كما هو حال جميع المُنشئات.

class C {
    int i;
    public:
        // تعريف المنشئ الافتراضي
        C(): i(0) {         // إلى 0  i قائمة تهيئة العضو، هيئ
                // إنشاء جسم الدالّة -- يمكن فعل أشياء أكثر تعقيدا هنا
        }
};

C c1; 
C c2 = C(); 
C c3();
C c4 {}; 
C c5[2];
C * c6 = new C[2]; 

تحليل الشيفرة السابقة:

  • C c1: يستدعي منشئ C الافتراضي لإنشاء الكائن c1.
  • C c2: يستدعي المنشئ الافتراضي صراحة.
  • C c3: خطأ: هذا الإصدار غير ممكن بسبب غموض في التحليل، أو ما يعرف بمشكلة “The most vexing parse”.
  • C c4: يمكن استخدام { } في C++ 11 بطريقة مماثلة.
  • C c5[2]‎: يستدعي المنشئ الافتراضي على عنصري المصفوفة.
  • C * c6: يستدعي المنشئ الافتراضي على عنصري المصفوفة.

هناك طريقة أخرى للاستغناء عن تمرير المعاملات، وهي أن يوفّر المطوِّر قيمًا افتراضية لها جميعًا:

class D {
    int i;
    int j;
    public:
        // من الممكن أيضا استدعاء منشئ افتراضي بدون معاملات
        D(int i = 0, int j = 42): i(i), j(j) {}
};
D d; // مع القيم الافتراضية للوسائط D استدعاء منشئ 

يوفر المصرِّف منشئًا افتراضيًا فارغًا بشكل ضمني في بعض الحالات، كأن يكون المطور لم يوفر منشئًا ولم تكن ثمة شروط مانعة أخرى.

class C {
    std::string s; // تحتاج الأعضاء إلى أن تكون قابلة للإنشاء
};
C c1; // لها منشئ افتراضي معرّف ضمنيا C 

وجود نوع آخر من المنشئ هو أحد الشروط المانعة المذكورة سابقًا:

class C {
    int i;
    public:
        C(int i): i(i) {}
};
C c1; // ليس لها منشئ افتراضي ضمني C ،خطأ في التصريف

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

إحدى الطرق الشائعة لمنع إنشاء مُنشئ افتراضي ضمني هي جعله خاصًّا ‎private‎ (بلا تعريف)، والهدف من ذلك هو إطلاق خطأ تصريفي في حال حاول شخص ما استخدام المنشئ، وينتج عن هذا إمّا خطأ في الوصول (Access to private error) أو خطأ ربط (linker error)، حسب نوع المصرّف.

وللتأكد من أنّ مُنشئًا افتراضيًّا (مشابه وظيفيًا للمنشئ الضمني) قد تمّ تعريفه، يكتب المُطوِّر منشئًا فارغًا بشكل صريح.

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

في الإصدار C++‎ 11، يستطيع المطوّر أيضًا استخدام الكلمة المفتاحية ‎delete‎ لمنع المصرّف من توفير مُنشئ افتراضي.

class C {
    int i;
    public:
        // يُحذف المنشئ الافتراضي بشكل صريح
        C() = delete;
};
C c1; // C خطأ تصريفي: حذف منشئ

أيضًا، إن أردت أن يوفرّ المصرّف مُنشئًا افتراضيًّا، فذلك يكون على النحو التالي:

class C {
    int i;
    public:
        // توفير منشئ افتراضي تلقائيا
        C() =
        default;
    C(int i): i(i) {}
};
C c1; // مُنشأة افتراضيا
C c2(1); // int مُنشأة عبر المنشئ

الإصدار C++‎ 14

يمكنك تحديد ما إذا كان لنوعٍ ما مُنشئ افتراضي (أو أنه نوع أولي - primitive type) باستخدام

std::is_default_constructible from <type_traits>

إليك الشيفرة:

class C1 {};
class C2 {
    public: C2() {}
};
class C3 {
    public: C3(int) {}
};

using std::cout;
using std::boolalpha;
using std::endl;
using std::is_default_constructible;
cout << boolalpha << is_default_constructible < int > () << endl; // true
cout << boolalpha << is_default_constructible < C1 > () << endl; // true
cout << boolalpha << is_default_constructible < C2 > () << endl; // true
cout << boolalpha << is_default_constructible < C3 > () << endl; // false

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

في الإصدار C++‎ 11، من الممكن استخدام الإصدار غير الدّالي (non-functor) لـ ‎std::is_default_constructible‎:

cout << boolalpha << is_default_constructible<C1>::value << endl; // true

المُدمِّر (Destructor)

المُدمّر هو دالّة بدون وسائط تُستدعى قُبيْل تدمير كائن مُعرّف من المستخدم (user-defined object)، ويُسمّى باسم النوع الذي يدمِّره مسبوقًا بـ ‎~‎ .

class C {
    int* is;
    string s;
    public:
        C(): is(new int[10]) {}~C() { // تعريف المُدمّر
            delete[] is;
        }
};
class C_child: public C {
    string s_ch;
    public:
        C_child() {}~C_child() {} // مدمّر الصنف الفرعي
};
void f() {
    C c1; 
    C c2[2];
    C* c3 = new C[2];
    C_child c_ch; 
    delete[] c3; 
} // يتم تدمير المتغيرات التلقائية هنا

تحليل الشيفرة السابقة:

  • C c1: استدعاء المدمر الافتراضي.
  • [C c2[2: استدعاء المُدمّر الافتراضي على العنصرين.
  • [C* c3 = new C[2: استدعاء المُدمّر الافتراضي على عنصرَي المصفوفة
  • C_child c_ch: عند تدميره يستدعي مدمر s_ch من قاعدة C (ومن ثم، s)
  • delete[] c3: يستدعي المدمرات على [c3[0 و [c3[1.

يوفر المصرِّف مدمرًا افتراضيًا بشكل ضمني في بعض الحالات، كأن يكون المطور لم يوفر مدمرًا ولم تكن ثمة شروط مانعة أخرى.

class C {
    int i;
    string s;
};
void f() {
    C* c1 = new C;
    delete c1; // له مُدمّر C 
}
class C {
    int m;
    private:
        ~C() {} // لا يوجد مُدمّر عام
};
class C_container {
    C c;
};
void f() {
    C_container* c_cont = new C_container;
    delete c_cont; // Compile ERROR: C has no accessible destructor
}

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

في C++‎ 11، يستطيع المُطوِّر تغيير هذا السلوك عن طريق منع المُصرّف من توفير مدمِّر افتراضي.

class C {
    int m;
    public:
        ~C() = delete; // لا يوجد مُدمّر ضمني
};
void f {
    C c1;
} // Compile ERROR: C has no destructor

يمكنك أن تجعل المصرّف يوفّر مدمّرًا افتراضيًّا، انظر:

class C {
    int m;
    public:
        ~C() =
        default;
};
void f() {
    C c1;
} // بنجاح c1 لها مُدمّر وقد تم تدمير  C

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

يمكنك تحديد ما إذا كان لنوعٍ ما مدمّرٌ ما (أو أنّه نوع أولي) باستخدام ‎std::is_destructible‎ من <type_traits>:

class C1 {};
class C2 {
    public: ~C2() = delete
};
class C3: public C2 {};
using std::cout;
using std::boolalpha;
using std::endl;
using std::is_destructible;
cout << boolalpha << is_destructible < int > () << endl; // true
cout << boolalpha << is_destructible < C1 > () << endl; // true
cout << boolalpha << is_destructible < C2 > () << endl; // false
cout << boolalpha << is_destructible < C3 > () << endl; // false

النسخ والمبادلة (Copy and swap)

إذا أردت كتابة صنف لإدارة الموارد فستحتاج إلى تنفيذ جميع الدوال التابعة الخاصّة (انظر قاعدة الثلاثة/خمسة/صفر). والطريقة الأبسط لكتابة مُنشئ النسخ (copy constructor) وعامل الإسناد (assignment operator) هي:

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

person& operator = (person
    const& rhs) {
    if (this != &other) {
        delete[] name;
        name = new char[std::strlen(other.name) + 1];
        std::strcpy(name, other.name);
        age = other.age;
    }

    return *this;
}

لكنّ هذه المقاربة تعتريها بعض العيوب، فهي تفشل في ضمان الاعتراض القوي (strong exception guarantee) إذا أطلق ‎new[]‎ فسنكون قد محونا الموارد التي يملكها ‎this‎ سلفًا، ولن نستطيع استردادها.

وهناك الكثير من التكرار في شيفرتي إنشاء النسخ (copy construction) وإسناد النسخ (copy assignment)، ولا ننسى كذلك التحقّق من الإسناد الذاتي (self-assignment) الذي يضيف حِملًا زائدًا إلى عملية النسخ عادة، لكنّه يبقى ضروريًّا.

يمكننا استخدام أسلوب النسخ والمبادلة (copy-and-swap idiom) لتلبية ضمان الاستثناء القوي (strong exception guarantee) وتجنّب تكرار الشيفرة:

class person {
    char* name;
    int age;
    public:
        /* كل الدوالّ الأخرى */
        friend void swap(person & lhs, person & rhs) {
            using std::swap; // enable ADL
            swap(lhs.name, rhs.name);
            swap(lhs.age, rhs.age);
        }
    person & operator = (person rhs) {
        swap( *this, rhs);
        return *this;
    }
};

إن عجبت من سبب نجاح الشيفرة السابقة، فانظر ما سيحدث حين يكون لدينا ما يلي:

person p1 = ...;
person p2 = ...;
p1 = p2;

أولاً، ننسخ ‎rhs‎ من ‎p2‎ (والذي لم يكن علينا تكراره هنا). لن يكون علينا فعل أيّ شيء في ‎operator=‎ في حال رفع اعتراض (throwing an exception) وسيبقى ‎p1‎ دون تغيير. بعد ذلك، سنُبادل الأعضاء بين ‎*this‎ و ‎rhs‎، ثم سيخرُج ‎rhs‎ عن النطاق. ستُنظّف المواردَ الأصلية الخاصّة بالمؤشّر this ضمنيًا داخل العامل ‎operator=‎من خلال المدمّر، والذي لم يكن علينا أن نكرره.

حتى الإسناد الذاتي (self-assignment) سيعمل بلا مشاكل - لكنّه سيكون أقل كفاءة باستخدام النسخ والمبادلة (إذ يتطلّب تخصيصًا - allocation - وإلغاء تخصيص - deallocation - إضافي)، ولكن إذا كان هذا الأمر مستبعدًا، فلن يؤثّر على الأداء العامّ.

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

تعمل الصيغة أعلاه كما هي بالنسبة لإسناد النقل (move assignment).

p1 = std::move(p2);

هنا، ننقل/ننشئ ‎rhs‎ من ‎p2‎، وتبقى الشيفرة الأخرى صالحة، فإذا كان صنفٌ ما قابلًا للنقل لكن غير قابل للنسخ، فلا داعي لحذف إسناد النسخ، لأنّ عامل الإسناد سيكون معطوبَ الصياغة بسبب مُنشئ النسخ المحذوف.

النقل والنسخ الضمني

اعلم أنّ التصريح عن مدمِّر يمنع المصرّف من إنشاء مُنشئات النقل (move constructors) وعوامل إسناد النقل (move assignment operators) ضمنيًّا، وتذكر في حال صرّحت بمدمّر أن تضيف التعريفات المناسبة لعمليات النقل.

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

class Movable {
    public:
        virtual~Movable() noexcept = default;
    // المنشئ لن ينشئ هذا التعبير الخاطئ لأننا عرّفنا مُدمّرا
    Movable(Movable && ) noexcept = default;
    Movable & operator = (Movable && ) noexcept = default;
    // التصريح عن عمليات النقل سيمنع إنشاء عمليات النسخ إلا إن قمنا بتمكين ذلك صراحة
    Movable(const Movable& ) = default;
    Movable & operator = (const Movable& ) = default;
};

الدوال التابعة غير الساكنة (Non-static member functions)

من الممكن أن يكون للأصناف (‎class‎) والبنيات (‎struct‎) دوال تابعة أو متغيرات عضوية، وصياغة الدوالّ التابعة تشبه صياغة الدوالّ المستقلة، ويمكن تعريفها إمّا داخل الصنف أو خارجه، فإذا عُرِّفت خارج تعريف الصنف فإنّ اسم الدالّة سيُسبَق باسمِ الصنف ومعامل النطاق (‎::‎).

class CL {
    public: void definedInside() {}
    void definedOutside();
};
void CL::definedOutside() {}

تُستدعى هذه الدوالّ على نسخة (أو مرجع إلى نسخة) من الصنف باستخدام العامل النُّقَطي (dot operator -‏‏ (‎.‎))، أو مؤشّر إلى نسخة باستخدام عامل السهم (arrow operator -‏‏ (‎->‎))، ويرتبط كل استدعاء بالنسخة التي استُدعِيت عليها الدالّة.

وعندما يُستدعى تابع على نسخة فسيكون له حقّ الوصول إلى كافّة حقول تلك النسخة (عبر المؤشّر ‎this‎)، ولكن لا يمكنه الوصول إلى حقول النسخ الأخرى إلا إذا مُرِّرت كمعاملات.

struct ST {
    ST(const std::string & ss = "Wolf", int ii = 359): s(ss), i(ii) {}
    int get_i() const {
        return i;
    }
    bool compare_i(const ST & other) const {
        return (i == other.i);
    }
    private:
        std::string s;
    int i;
};
ST st1;
ST st2("Species", 8472);
int i = st1.get_i();            // st2.i ولكن ليس إلى st1.i يمكن الوصول إلى 
bool b = st1.compare_i(st2);    // st1 و  st2 يمكن الوصول إلى

يُسمح لهذه الدوالّ بالوصول إلى الحقول و/أو التوابع الأخرى بغضّ النظر عن مُحدِّدات الوصول (access modifiers) الخاصّة بالمتغيرات والتوابع. كذلك يمكن كتابتها والوصول إلى الحقول و/أو استدعاء التوابع المُصرّحة قبلها، إذ يجب تحليل تعريف الصنف بأكمله قبل أن يبدأ المصرّف في تصريفه.

class Access {
    public:
        Access(int i_ = 8088, int j_ = 8086, int k_ = 6502): i(i_), j(j_), k(k_) {}
    int i;
    int get_k() const {
        return k;
    }
    bool private_no_more() const {
        return i_be_private();
    }
    protected:
        int j;
    int get_i() const {
        return i;
    }
    private:
        int k;
    int get_j() const {
        return j;
    }
    bool i_be_private() const {
        return ((i > j) && (k < j));
    }
};

التغليف (Encapsulation)

هُنالك عدّة استعمالات للتوابع، من ذلك أنها تُستخدم للتغليف (encapsulation)، وذلك باستخدام جالِب (getter) ومُعيِّن (setter) بدلاً من السماح بالوصول إلى الحقول مباشرة.

class Encapsulator {
    int encapsulated;
    public:
        int get_encapsulated() const {
            return encapsulated;
        }
    void set_encapsulated(int e) {
        encapsulated = e;
    }
    void some_func() {
        do_something_with(encapsulated);
    }
};

يمكن الوصول إلى الحقل ‎encapsulated‎ داخل الصنف من قِبل أيّ دالة تابعة غير ساكنة، أمّا خارج الصنف، فيُنظّم حق الوصول إليه بواسطة الدوال التابعة إذ يُستخدم ‎get_encapsulated()‎ لقراءته و ‎set_encapsulated()‎ لتعديله، هذا يمنع التعديلات غير المقصودة على المتغيّر (هناك العديد من النقاشات حول ما إذا كانت الجوالب والمعيّنات تدعم التغليف أم تكسره، ولكلٍّ من الطرفين وجهة نظرة وجيهة).

إخفاء الأسماء واستيرادها

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

struct HiddenBase {
    void f(int) {
        std::cout << "int" << std::endl;
    }
    void f(bool) {
        std::cout << "bool" << std::endl;
    }
    void f(std::string) {
        std::cout << "std::string" << std::endl;
    }
};
struct HidingDerived: HiddenBase {
    void f(float) {
        std::cout << "float" << std::endl;
    }
};
// ...
HiddenBase hb;
HidingDerived hd;
std::string s;
hb.f(1); // الخرج:  int
hb.f(true); // الخرج: bool
hb.f(s); // الخرج: std::string;
hd.f(1. f); // الخرج: float
hd.f(3); // الخرج: float
hd.f(true); // الخرج: float
hd.f(s); // Error: Can't convert from std::string to float.

هذا السلوك ناتج عن قواعد تحليل الاسم (name resolution rules): فأثناء البحث عن الاسم، يتوقف البحث بمجّرد العثور على الاسم الصحيح حتى لو لم يُعثَر على الإصدار الصحيح للكيان الذي يحمل ذلك الاسم (مثل ‎hd.f(s)‎)، ونتيجة لهذا، تؤدي زيادة تحميل دالّة في الصنف المشتق إلى منع الوصول إلى التحميل الزائد الموجود في الصنف الأساسي. ولكي لتجنّب ذلك، يمكن استخدام using لأجل "استيراد" الأسماء من الصنف الأساسي إلى الصنف المشتق حتى تكون متاحة أثناء البحث عن الاسم. انظر المثال التالي حيث يجب أن تُعد جميع الأعضاء المسمّاة HiddenBase::f أعضاءً من HidingDerived أثناء البحث:

struct HidingDerived: HiddenBase {
    using HiddenBase::f;
    void f(float) {
        std::cout << "float" << std::endl;
    }
};
// ...
HidingDerived hd;
hd.f(1. f); //  الخرج: float
hd.f(3); // الخرج: int
hd.f(true); // الخرج:  bool
hd.f(s); // الخرج: std::string

إذ كان الصنف المشتق يستورد الأسماء باستخدام using ولكن يُصرّح كذلك عن دوالّ لها نفس بصمات الدوالّ في الصنف الأساسي، فسيُعاد تعريف دوالّ الصنف الأساسي بصمت أو تُخفى.

struct NamesHidden {
    virtual void hide_me() {}
    virtual void hide_me(float) {}
    void hide_me(int) {}
    void hide_me(bool) {}
};
struct NameHider: NamesHidden {
    using NamesHidden::hide_me;
    void hide_me() {} // NamesHidden::hide_me() إعادة تعريف
    void hide_me(int) {} // NamesHidden::hide_me(int) إخفاء
};

يمكن أيضًا استخدام تصريح using لتغيير معدِّلات الوصول (Access Modifiers)، بشرط أن يكون الكيان المستورد إمّا عامًّا (‎public‎) أو محميًا (‎protected‎) في الصنف الأساسي.

struct ProMem {
    protected: void func() {}
};
struct BecomesPub: ProMem {
    using ProMem::func;
};
// ...
ProMem pm;
BecomesPub bp;
pm.func(); // خطأ: محميّ
bp.func(); // جيد

وبالمثل إذا أردنا استدعاء دالة تابعة من صنف محدد في التسلسل الهرمي الوراثي (inheritance hierarchy) بشكل صريح، فيمكننا تأهيل اسم الدالّة عند استدعائها، وتحديد ذلك الصنف بالاسم.

struct One {
    virtual void f() {
        std::cout << "One." << std::endl;
    }
};

struct Two: One {
    void f() override {
        One::f(); // this->One::f();
        std::cout << "Two." << std::endl;
    }
};
struct Three: Two {
    void f() override {
        Two::f(); // this->Two::f();
        std::cout << "Three." << std::endl;
    }
};

// ...

Three t;

t.f(); 
t.Two::f();
t.One::f();
}

تحليل الشيفرة السابقة:

  • ()t.f: الصيغة العادية.
  • ()t.Two::f: استدعاء إصدار ()f المعرَّف في Two.
  • ()t.One::f: استدعاء إصدار ()f المعرَّف في One.

الدوال التابعة الوهميّة

الدوال التوابع يمكن أن تكون وهمية (‎virtual‎)، وإن استُدعِيت على مؤشّر أو مرجع إلى نسخة فلن يتم الوصول إليها مباشرة، بل سيُبحَث عن الدالّة في جدول الدوالّ الوهمية (قائمة من المؤشّرات-إلى-الدوال التابعة التي تشير إلى الدوالّ الوهمية، والمعروفة باسم ‎vtable‎ أو ‎vftable‎)، ثم تُستخدَم لاستدعاء الإصدار المناسب للنوع (الفعلي) الديناميكي للنسخة. ولن يتم أي بحث إذا استُدعيت الدالّة مباشرة من متغير داخل صنف ما.

struct Base {
    virtual void func() {
        std::cout << "In Base." << std::endl;
    }
};
struct Derived: Base {
    void func() override {
        std::cout << "In Derived." << std::endl;
    }
};
void slicer(Base x) {
    x.func();
}
// ...
Base b;
Derived d;
Base * pb = & b, * pd = & d; // مؤشّرات
Base & rb = b, & rd = d; // مراجع

int main() {

b.func(); // الخرج: In Base.
d.func(); // الخرج: In Derived.

pb -> func(); // الخرج: In Base.
pd -> func(); // الخرج: In Derived.

rb.func(); // الخرج: In Base.
rd.func(); // الخرج: In Derived.

slicer(b); // الخرج: In Base.
slicer(d); // الخرج: In Base.
}

ورغم أن ‎pd‎ من النوع ‎Base*‎ و‎rd‎ من النّوع ‎Base&‎، إلا أن استدعاء ‎func()‎ على أيّ منهما سيؤدّي إلى استدعاء ‎Derived::func()‎ بدلاً من Base::func()‎؛ وذلك لأنّ جدول ‎vtable‎ الخاصّ بالبنية ‎Derived‎ يُحدِّث المدخل ‎Base::func()‎ بدلاً من الإشارة إلى ‎Derived::func()‎.

ومن ناحية أخرى، لاحظ كيف يؤدّي تمرير نسخة إلى ‎slicer()‎ إلى استدعاء ‎Base::func()‎ حتى عندما تكون النسخة المُمرَّرة من نوع ‎Derived‎، وهذا بسبب مفهوم يُعرف بتشريح البيانات (Data Slicing)، وفيه يؤدّي تمرير نسخة من ‎Derived‎ إلى مُعامل من النوع ‎Base‎ بالقيمة (by value) إلى عرض ذلك الجزء من ‎Derived‎ الذي يمكنه الوصول إلى نسخة Base.

حين تُعرَّف دالة تابعة على أنّها وهمية فإنّ جميع دوال الصنف التابعة المشتقة التي تحمل نفس البصمة ستعيد تعريفه (override) بغضّ النظر عمّا إذا كانت الدالّة التي أعادت تعريفه وهمية أم لا، هذا سيُصعِّب عملية تحليل الأصناف المشتقّة على المبرمجين لعدم وجود أيّ إشارة تحدّد أيٌّ من تلك دوالّ وهمية.

struct B {
    virtual void f() {}
};
struct D: B {
    void f() {} // B::f وهمية بشكل ضمني، إعادة تعريف
    // B لكن عليك التحقق من
};

لاحظ أنّ الدالّة مشتقة (Derived Function) لا يمكنها أن تعيد تعريف دالّة أساسية (Base Function) إلا إذا تطابقت بصماتهما، حتى لو صُرِّح بأنّ الدالّة المشتقة وهمية (‎virtual‎)، فستنشئ دالّةً وهمية جديدة إن لم لتطابق البصمات.

struct BadB {
    virtual void f() {}
};
struct BadD: BadB {
    virtual void f(int i) {} // BadB::f لا تُعِد تعريف
};

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

اعتبارًا من الإصدار C++‎ 11، يمكن التصريح بنِيَّة إعادة التعريف (override) باستخدام الكلمة المفتاحية ‎override‎، واعلم أن هذه الكلمة حساسة للسياق، وسيخبر ذلك المصرّف أنّ المبرمج يتوقع منه أن يعيد تعريف دالّة الصنف الأساسي، وعليه يطلق المصرِّف خطأً إذا لم تحدث عملية إعادة التعريف.

struct CPP11B {
    virtual void f() {}
};
struct CPP11D: CPP11B {
    void f() override {}
    void f(int i) override {} // Error: Doesn't actually override anything.
};

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

يجب تضمين المُحدِّد ‎virtual‎ في تصريح الدالّة وعدم تكراره في التعريف عند التصريح بأنّ دالّة ما وهمية ‎virtual‎ وتكون معرَّفة خارج تعريف الصنف.

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

ينطبق هذا أيضًا على الكلمة المفتاحية ‎override‎.

struct VB {
    virtual void f(); // هنا "virtual" ضع
    void g();
};
/* virtual */
void VB::f() {} // لكن ليس هنا
virtual void VB::g() {} // خطأ 

وإن نفذ الصنف الأساسي زيادة تحميل على دالّة وهمية، فإن التحميلات التي حُدِّدَت على أنها وهمية بشكل صريح هي وحدها التي ستكون وهمية.

struct BOverload {
    virtual void func() {}
    void func(int) {}
};
struct DOverload: BOverload {
    void func() override {}
    void func(int) {}
};
// ...
BOverload* bo = new DOverload;
bo - > func(); // DOverload::func() استدعاء
bo - > func(1); // BOverload::func(int) استدعاء

الثباتية (Const Correctness)

أحد الاستخدامات الرئيسية لمؤهلات المؤشّر ‎this‎ هو الصحة الثباتية، أو الثباتية باختصار (const correctness). تضمن هذه الممارسة عدم تعديل الكائن إلا عند الحاجة لهذا، وأنّ أيّ دالّة (عضوة أو غير عضوة) لا تحتاج إلى تعديل كائن ما لن تملك حق الكتابة في ذلك الكائن (سواء بشكل مباشر أو غير مباشر). هذا يمنع التعديلات غير المقصودة مما يجعل الشيفرة أكثر متانة، كما يسمح لأيّ دالّة لا تحتاج إلى تعديل الحالة لأن تقبل الكائنات سواء كانت ثابتة (‎const‎) أو غير ثابتة دون الحاجة إلى إعادة كتابة أو تحميل الدالّة.

تبدأ الثباتية من أسفل إلى أعلى، وذلك بسبب طبيعتها، إذ تُصرَّح أيّ دالة تابعة في الصنف لا تحتاج إلى تغيير الحالة على أنها ثابتة (‎const‎)، وذلك كي يمكنَ استدعاؤها على النسخ الثابتة. هذا يسمح بدوره بالتصريح أنّ المُعاملات المُمرّرة بالمرجع (passed-by-reference) ثابتة عندما لا تكون هناك حاجة إلى تعديلها، ممّا يسمح للدوالّ بأخذ كائنات ثابتة أو غير ثابتة دون مشاكل، كما يمكن للثباتيّة أن تنتشر للخارج بهذه الطريقة. وتكون الجالبات (Getters) ثابتة كأي دالّة أخرى لا تحتاج إلى تعديل حالة الكائن المنطقية.

class ConstIncorrect {
    Field fld;
    public:
        ConstIncorrect(const Field & f): fld(f) {} // تعديل
    const Field & get_field() {
        return fld;
    } // لا يوجد تعديل، ينبغي أن تكون ثابتة
    void set_field(const Field & f) {
        fld = f;
    } // تعديل
    void do_something(int i) { // تعديل
        fld.insert_value(i);
    }
    void do_nothing() {} // لا يوجد تعديل، ينبغي أن تكون ثابتة
};
class ConstCorrect {
    Field fld;
    public:
        ConstCorrect(const Field & f): fld(f) {} // غير ثابتة: يمكن التعديل
    const Field & get_field() const {
        return fld;
    } // ثابتة: لا يمكن التعديل
    void set_field(const Field & f) {
        fld = f;
    } // غير ثابتة: يمكن التعديل
    void do_something(int i) { // غير ثابتة: يمكن التعديل
        fld.insert_value(i);
    }
    void do_nothing() const {} // ثابتة: لا يمكن التعديل
};
// ...
const ConstIncorrect i_cant_do_anything(make_me_a_field());
Field f = i_cant_do_anything.get_field();
// ليست ثابتة get_field() ،خطأ
i_cant_do_anything.do_nothing();
// خطأ كالأعلى
const ConstCorrect but_i_can(make_me_a_field());
Field f = but_i_can.get_field(); //جيد 
but_i_can.do_nothing(); // جيد

كما هو موضّح في تعليقات ‎ConstIncorrect‎ و ‎ConstCorrect‎، فإنّ تأهيل الدوالّ يمكن أن يُستخدم في التوثيق. يمكن افتراض أن أي دالة غير ثابتة ستغير الحالة إذا كان صنفُ ما صحيحًا وفق مفهوم الثبات،وكذلك أيّ دالّة ثابتة لن تغيّر الحالة.

الدوال التابعة الثابتة للأصناف

يوضّح المثال التالي مفهوم الدوال التابعة الثابتة:

#include <iostream>
#include <map>
#include <string>

using namespace std;

class A {
public:
map<string, string> * mapOfStrings;
public:
A() {
mapOfStrings = new map<string, string>();
}

void insertEntry(string const & key, string const & value) const {
(*mapOfStrings)[key] = value;            // هذا يعمل بنجاح.
delete mapOfStrings;                // وهذا أيضًا.
mapOfStrings = new map<string, string>();     // أما هذا فلا يعمل.
}

void refresh() {
delete mapOfStrings;
mapOfStrings = new map<string, string>(); // ليست دالة ثابتة refresh يعمل لأن.
}

void getEntry(string const & key) const {
cout << mapOfStrings->at(key);
}
};

int main(int argc, char* argv[]) {

A var;
var.insertEntry("abc", "abcValue");
var.getEntry("abc");
getchar();
return 0;
}

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

ترجمة -بتصرّف- للفصول:

  • Chapter 39: Inline functions
  • Chapter 40: Special Member Functions
  • Chapter 41: Non-Static Member Functions
  • Chapter 42: Constant class member functions

من كتاب C++ Notes for Professionals





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


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



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

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

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


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

تسجيل الدخول

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


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