سلسلة ++c للمحترفين الدرس 47: السلوك غير المعرف Undefined Behavior والسلوك غير المحدد Unspecified behavior في Cpp


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

ما المقصود بالسلوك غير المعرَّف (undefined behavior أو UB)؟ وفقًا لمعيار ISO C++‎ (الفقرة 1.3.24، N4296)، فهو:

اقتباس

"أيّ سلوك لا يكون مفروضًا من قبل المواصفة القياسية الدولية."

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

أما السلوك غير المُحدَّد فهو أيّ سلوك قد يتغير من جهاز إلى آخر، أو من مصرّف إلى آخر. لا يمكن التنبؤ الدقيق بسلوكيات البرامج التي تحتوي سلوكات غير محدّدة في مرحلة الاختبار، لأنّها تتعلق بالتقديم (implementation). لذا يُفضّل عموما تجنّب هذا النوع من السلوكات قدر الإمكان.

القراءة أو الكتابة من مؤشّر فارغ

int *ptr = nullptr;
*ptr = 1;    // سلوك غير معرَّف

هذا سلوك غير معرَّف لأنّ المؤشّر الفارغ لا يشير إلى أي كائن صالح، لذلك لا يوجد كائن في ‎*ptr‎ للكتابة عليه. ورغم أنّ هذا قد يتسبب غالبًا في حدوث خطأ في التجزئة (segmentation fault)، إلا أنّه غير مُعرَّف، وقد يحدث أي شيء.

استخدام متغيّر محلي غير مهيّأ

int a;
std::cout << a;    // سلوك غير معرَّف

ينتج عن هذا سلوك غير معرَّف لأنّ ‎a‎ غير مهيّأة. يدّعي البعض (خطأً) أنّ هذا ناتج عن أنّ القيمة "غير معرَّفة "، أو بسبب "القيمة التي كانت سابقًا في ذلك الموقع من الذاكرة". والصحيح أنّ محاولة الوصول إلى قيمة ‎a‎ في المثال أعلاه هي التي أدّت إلى سلوك غير معرَّف.

ومحاولة طباعة "قيمة مُهملة" (garbage value) هي عرض شائع في هذه الحالة لكن هذا ليس سوى شكل واحد ممكن من السلوكات غير المعرَّفة.

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

استخدام قيمة غير معرَّفة من نوع ‎unsigned char‎ لا ينتج عنه سلوك غير معرَّف عند استخدام القيمة كـ: *معامل ثاني أو ثالث للمعامل الشرطي الثلاثي (ternary conditional operator).

  • معامَل أيمن لمُعامل الفاصلة المُضمّن (comma operator).
  • معامَل التحويل إلى ‎unsigned char‎.
  • معامَل أيمن لمُعامل الإسناد إذا كان المعامل الأيسر من النوع ‎unsigned char‎.
  • مُهيئ لكائن ‎unsigned char‎؛

أو حتى عند تجاهل القيمة. ففي مثل هذه الحالات، تنتشر (propagates) القيمة غير المعرَّفة ببساطة إلى نتيجة التعبير إن أمكن. لاحظ أنّ المتغيّرات الساكنة (‎static‎) تُهيّأ دائمًا عند القيمة صفر (إن أمكن):

static int a;
std::cout << a;    // تساوي 0 'a' سلوك معرَّف، و

محاولة الوصول إلى فهرس خارج النطاق

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

int array[] = { 1, 2, 3, 4, 5 };
array[5] = 0;    // سلوك غير معرَّف

لكن يُسمح بالحصول على مؤشّر يشير إلى نهاية المصفوفة (أي ‎array + 5‎ في هذه الحالة)، بيْد أنّه يُحظر تحصيله (dereference)، لأنّه ليس عنصرًا صالحًا.

const int *end = array + 5;    // مؤشر إلى الموضع الذي يلي العنصر الأخير
for (int *p = array; p != end; ++p)
    // `p` افعل شيئا ما بـ

بشكل عام، لا يُسمح لك بإنشاء مؤشّر خارج الحدود، إذ يجب أن يشير المؤشّر إلى عنصر داخل المصفوفة، أو إلى الموضع الذي يلي نهاية المصفوفة.

حذف كائن مشتق عبر مؤشّر يشير إلى صنف أساسي لا يتوفّر على حاذِف وهمي

class base {};
class derived: public base {};
int main()
{
    base* p = new derived();
    delete p;    // سلوك غير معرَّف
}

تشير الفقرة ‎‎5.3.5 / 3‎‎ من المعيار إلى أنّه إذا استُدعِيت ‎delete‎ على كائن لا يحتوي نوعُه الساكن (static type) على مُدمّر وهمي (‎virtual‎)، فإنّه:

اقتباس

إذا كان النوع الساكن للكائن المراد حذفه مختلفًا عن نوعه الديناميكي، فينبغي أن يكون النوع الساكن صنفًا أساسيًا للنوع الديناميكي للعنصر المراد حذفه، ويجب أن يكون للنوع الساكن مدمّر افتراضي، وإلّا فإنّ السلوك سيكون غير معرَّف.

هذا يبقى صحيحًا بغض النظر عمّا إذا كان الصنف المشتق قد أضاف أو لم يضف أيّ حقول إلى الصنف الأساسي.

توسيع فضاءي الاسم "std" أو "posix"

يحظر المعيار (‎‎17.6.4.2.1 / 1) عمومًا توسيع فضاء الاسم ‎std‎:

اقتباس

يكون سلوك برامج C++‎ غير معرَّف عند إضافة تصريحات أو تعريفات إلى فضاء الاسم std، أو إلى فضاء اسم داخل std، ما لم يُحدّد خلاف ذلك.

وينطبق نفس الشيء على‎‎‎posix‎‏ (‎‎17.6.4.2.2 / 1):

اقتباس

يكون سلوك برامج C++‎ غير معرَّف عند إضافة تصريحات أو تعريفات إلى فضاء الاسم posix أو إلى فضاء اسم ضمن posix، ما لم يُحدّد خلاف ذلك. انظر:

#include <algorithm>

namespace std
{
    int foo() {}
}

لا شيء في المعيار يحظر على ‎algorithm‎ (أو أيٍّ من الترويسات التي يتضمّنها) إعادة تعريف نفس التعريف، وهذا قد يؤدّي إلى انتهاك قاعدة التعريف الواحد (One Definition Rule). ولذلك فهو ممنوع بشكل عام لكن مع بعض الاستثناءات، فمثلًا يُسمح بإضافة تخصيصات للأنواع المُعرَّفة من المستخدمين.

في المثال التالي، لنفترض أنّ الشيفرة تحتوي على:

class foo
{
    // شيفرة هنا
};

الشيفرة التالية صالحة:

namespace std
{
    template < >
        struct hash < foo>
        {
            public: size_t operator()(const foo &f) const;
        };
}

حسابيات غير صالحة على المؤشّرات Invalid pointer arithmetic

ستتسبّب الاستخدامات التالية لحسابيات المؤشّر في حدوث سلوك غير معرَّف:

  • إضافة أو طرح عدد صحيح إذا لم تنتمي النتيجة إلى نفس المصفوفة التي يشير إليها المؤشّر. (هنا، يُعدّ العنصر الموجود بعد النهاية جزءًا من المصفوفة.)
int a[10];
int* p1 = &a[5];
int* p2 = p1 + 4;    //  a[9] يشير إلى p2 جيد، لأنّ
int* p3 = p1 + 5;    // a يشير إلى الموضع الذي يلي p2 جيد، لأنّ
int* p4 = p1 + 6;    // سلوك غير معرَّف
int* p5 = p1 - 5;    // a[0] يشير إلى p2 جيد، لأنّ
int* p6 = p1 - 6;    // سلوك غير معرَّف
int*  p7 = p3 - 5;    // a[5] يشير إلى p7 جيد، لأنّ
  • طرح (subtraction) مؤشّرين إذا لم ينتمي كلاهما إلى نفس المصفوفة، مرّة أخرى، يعدّ العنصر الذي يلي العنصر الأخير من المصفوفة جزءًا من المصفوفة، والاستثناء الوحيد هو أنّه يمكن طرح مؤشّرين فارغين، والنتيجة ستكون 0.
int a[10];
int b[10];
int *p1 = &a[8], *p2 = &a[3];
int d1 = p1 - p2; // 5
int *p3 = p1 + 2;  //  a يشير إلى الموضع الذي يلي العنصر الأخير في p2 جيد، لأنّ
int d2 = p3 - p2; // 7
int *p4 = &b[0];
int d3 = p4 - p1; // سلوك غير معرَّف
  • طرح مؤشّرين إذا كانت النتيجة تطفح (overflow) عن ‎std::ptrdiff_t‎.
  • أيّ عملية حسابية على المؤشّرات لا يتطابق فيها نوع أحد المعامَليْن (operand) مع النوعَ الديناميكي للكائن المُشار إليه من قبل ذلك المؤشّر (تجاهل التأهيل - cv-qualification). فوفقًا للمعيار فإنه:
اقتباس

"[بشكل خاص]، لا يمكن استخدام مؤشّر يشير إلى صنف أساسي في العمليات الحسابية للمؤشّرات عندما تحتوي المصفوفة على كائنات من نوع صنف مشتق."

struct Base { int x; };
struct Derived : Base { int y; };
Derived a[10];
Base* p1 = &a[1];    // جيد
Base* p2 = p1 + 1;    // Derived  يشير إلى p1 سلوك غير معرَّف، لأنّ
Base* p3 = p1 - 1;    // نفس الشيء
Base* p4 = &a[2];    // سلوك غير معرَّف
auto p5 = p4 - p1;    // Derived  يشيران إلى p4 و  p1  سلوك غير معرَّف، لأنّ
const Derived* p6 = &a[1];
const Derived* p7 = p6 + 1;  // لا تهمّ cv-qualifiers جيد، لأن المؤهّلات 

عدم وجود تعليمة return في دالّة نوعها المُعاد يخالف void

سيؤدّي حذف تعليمة ‎return‎ في دالّة نوعها المُعاد غير فارغ (‎void‎) إلى سلوك غير معرَّف.

int
function()
{
    // return غياب تعليمة 
}

int main()
{
    function();    // سلوك غير معرَّف
}

تطرح معظم المٌصرّفات الحديثة تحذيرًا في وقت التصريف إذا صادَفَت مثل هذه السلوكيات غير المعرَّفة.

ملاحظة: ‎main‎ هي الاستثناء الوحيد لهذه القاعدة. إذا لم تحتو ‎main‎ على تعليمة ‎return‎، فسيُدرِج المصرّف تلقائيًا التعبير ‎return 0;‎ نيابة عنك، لذلك يمكنك عدم وضعها إن شئت.

الوصول إلى مرجع معلّق (Accessing a dangling reference)

لا يمكن الوصول إلى مرجع يشير إلى كائن خرج عن النطاق أو تمّ تدميره، ويقال أن هذا المرجع "مُعلّق" (dangling)، لأنّه لم يُعد يشير إلى كائن صالح.

#include <iostream>
int& getX()
{
    int x = 42;
    return x;
}

int main()
{
    int& r = getX();
    std::cout << r << "\n";
}

في هذا المثال، يخرج المتغيّر المحلي ‎x‎ عن النطاق عند إعادة ‎getX‎. لاحظ أنّ تمديد دورة الحياة (lifetime extension) لا يمكنه أن يطيل دورة الحياة الخاصة بالمتغيّر المحلي بعد الخروج من نطاق الكتلة التي عُرِّف فيها. لذلك فإن المرجع ‎r‎ أصبح معلقًا. هذا البرنامج له سلوك غير معرَّف رغم أنّه قد يبدو أنّه يعمل بدون مشاكل، بل إنّه قد يطبع ‎42‎ في بعض الحالات.

قسمة عدد صحيح على الصفر

int x = 5 / 0;    // سلوك غير معرَّف

القسمة على ‎0‎ هي عمليّة غير معرَّفة في الرياضيات، فلا جرم أنّها تؤدّي إلى سلوك غير معرَّف في البرمجة.

في المثال التالي، x تساوي موجب ما لا نهاية infinity+.

float x = 5.0f / 0.0f; 

تعتمد معظم التنفيذات (implementaions) على المعيار IEEE-754، الذي ينصّ على أنّ قسمة عدد عشري (floating point) على الصفر ستعيد القيمة الخاصة "ليس عددًا" ‎NaN‎ (إذا كان البسْط يساوي ‎0.0f‎)، أو ‎infinity‎ (إذا كان البسط موجبًا)، أو ‎-infinity‎ (إذا كان البسط سالباً).

الإزاحة بعدد غير صالح من المنازل

بالنسبة لعامل الإزاحة (shift operator) المُضمّن، يجب أن يكون العامل الأيمن غير سالب وأصغر من طول العامل الأيسر بالبتّات. خلاف ذلك، فإنّ السلوك سيكون غير معرَّف.

const int a = 42;
const int b = a << -1;    // سلوك غير معرَّف
const int c = a << 0;    // ok

في السطرين التاليين، ينتج لنا سلوك غير معرَّف إذا كان طول int يساوي 32 أو أقل …

const int d = a << 32;  
const int e = a >> 32;

في حالة const int g = f << 10، هذا مقبول حتى لو كان طول signed char أقل من أو يساوي 10 بت، ويجب ألا يقل طول int عن 16 بت …

const signed char f = 'x';
const int g = f << 10;   

تخصيص الذاكرة وتحريرها بشكل غير صحيح

  • لا يمكن تحرير (deallocated) كائن عبر ‎delete‎ إلا إن كان قد خُصِّص من قبل عبر ‎new‎ ولم يكن مصفوفةً. أما إذا لم يكن الوسيط المُمرّر إلى ‎delete‎ مُعادًا من ‎new‎، أو لم يكن مصفوفةً، فسيكون السلوك غير معرَّف.
  • لا يمكن تحرير كائن عبر ‎delete[]‎ إلا إن كان قد خُصِّص من قبل عبر ‎new‎ ولم يكن مصفوفةً. أما إذا لم يكن الوسيط المُمرّر إلى ‎delete[]‎ مُعادًا من ‎new‎، أو لم يكن مصفوفةً، فسيكون السلوك غير معرَّف.
  • إذا لم يكن الوسيط المُمرّر إلى ‎free‎ مُعادًا من ‎malloc‎، فسيكون السلوك غير معرَّف.
int* p1 = new int;
delete p1;    // صحيح
// delete[] p1;    // غير معرَّف
// free(p1);    //  غير معرَّف
int* p2 = new int[10];
delete[] p2;    // صحيح
// delete p2;    //  غير معرَّف
// free(p2);    //  غير معرَّف
int* p3 = static_cast<int*>(malloc(sizeof(int)));
free(p3);    // صحيح
// delete p3;    //  غير معرَّف
// delete[] p3;    //  غير معرَّف

يمكن تجنّب هذه المشاكل عن طريق تجنّب ‎malloc‎ و ‎free‎ في برامج C++‎، فمن الأفضل استخدام المؤشّرات الذكية بدلًا من ‎new‎ و ‎delete‎ الخام، كما يُفضَّل استخدام ‎std::vector‎ و ‎std::string‎ على ‎new‎ و ‎delete[]‎

طفح الأعداد الصحيحة المُؤشّرة (Signed Integer Overflow)

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

int x = INT_MAX + 1;
// سلوك غير معرَّف
اقتباس

إذا لم تُعرَّف النتيجة رياضيًا أثناء تقييم تعبير ما أو لم تكن ضمن نطاق القيم القابلة للتمثيل لنوع ذلك التعبير، فسيكون السلوك غير معرَّف. (C++‎ 11، الفقرة 5/4 من المعيار)

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

من ناحية أخرى:

unsigned int x = UINT_MAX + 1;
// تساوي 0 x 

يؤدي هذا إلى سلوك معرَّف جيدًا بما أنه:

اقتباس

يجب على الأعداد الصحيحة غير المُؤشّرة والمُصرّح عنها أن تلتزم بقوانين معامل الباقي الحسابي (arithmetic modulo)‏ ‎2^n‎، حيث يمثّل ‎n‎ عدد البتات في تمثيل الأعداد الصحيحة.

(C++‎ 11 الفقرة ‎‎3.9.1 / 4 من المعيار)

أحيانًا قد تستغل المٌصرّفات السلوكات غير المعرَّفة وتحسّنها.

signed int x;
if (x > x + 1)
{
    // افعل شيئا ما
}

وطالما أنّ طفح الأعداد الصحيحة المُؤشّرة (signed integer overflow) يؤدي إلى سلوك غير معرَّف، فيمكن للمُصرّف تجاهله وافتراض أنّه لن يحدث أبدا، ومن ثم يمكنه استبعاد كتلة if.

التعريفات المتعدّدة غير المتطابقة (قاعدة التعريف الواحد)

إذا كان أي مما يلي معرَّفًا في عدة واحدات ترجمة فيجب أن تكون جميع التعريفات متطابقة أو سيكون السلوك غير معرَّف وفقًا لقاعدة التعريف الواحد (ODR): الأصناف، التعدادات، الدوال المضمنَّة، القوالب، الأعضاء في القوالب.

foo.h‎:

class Foo
{
    public:
        double x;
    private:
        int y;
};
Foo get_foo();

‎‎foo.cpp:

#include "foo.h"

Foo get_foo()
{ /*التنفيذ*/ }

main.cpp:

// Foo أريد الوصول إلى العضو الخاص، لذا سأستخدم النوع الخاص بي بدلًا من 
class Foo
{
    public:
        double x;
    int y;
};
Foo get_foo();    // foo.h سنصرّح عن هذه الدالة بأنفسنا لأننا لم نُضمَِن
int main()
{
    Foo foo = get_foo();
    // foo.y افعل شيئا ما بـ
}

يتسبّب البرنامج أعلاه في سلوك غير معرَّف لأنّه يحتوي على تعريفين غير متطابقين للصنف ‎::Foo‎، والذي له صلة خارجية (external linkage) في وحدات ترجمة مختلفة. وعلى عكس إعادة تعريف صنف داخل وحدة الترجمة نفسها، فإن المٌصرّف ليس مُلزمًا بتشخيص هذه المشكلة.

محاولة تعديل كائن ثابت (Modifying a const object)

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

const int x = 123;
const_cast<int&>(x) = 456;
std::cout << x << '\n';

عادة ما يُضمِّن المٌصرّف قيمة الكائن ‎const int‎، وعليه فيحتمل أن تُصرَّف هذه الشيفرة وتطبع ‎123‎، كذلك يمكن للمٌصرّفات وضع قيم الكائنات الثابتة في ذاكرة مخصّصة للقراءة فقط، ومن ثم قد يحدث خطأ في التجزئة (segmentation fault). وسيكون السلوك بشكل عام غير معرَّف، ولا يمكن التنبؤ بما سيفعله البرنامج.

ينطوي البرنامج التالي على خطأ خفِىّ:

#include <iostream>

class Foo* instance;
class Foo
{
    public:
        int get_x() const
        {
            return m_x;
        }

    void set_x(int x)
    {
        m_x = x;
    }

    private:
        Foo(int x, Foo* &this_ref): m_x(x)
        {
            this_ref = this;
        }

    int m_x;
    friend
    const Foo &getFoo();
};
const Foo &getFoo()
{
    static
    const Foo foo(123, instance);
    return foo;
}

void do_evil(int x)
{
    instance->set_x(x);
}

int main()
{
    const Foo &foo = getFoo();
    do_evil(456);
    std::cout << foo.get_x() << '\n';
}

في الشيفرة السابقة، أنشأت دالة ‎getFoo‎ مفردة (singleton) من نوع ‎const Foo‎، وتمت تهيئة عضوها ‎m_x‎ عند القيمة ‎123‎. ثم استُدعِيت ‎do_evil‎ يبدو أنّ هذا غيّر قيمة ‎foo.m_x‎ إلى 456. فأين مكمن الخطأ؟

لا تفعل ‎do_evil‎ أيّ شيء شرّير رغم اسمها، فكلّ ما تفعله هو استدعاء ضابط (setter) من خلال ‎Foo*‎، لكنّ هذا المؤشّر يشير إلى كائن ‎const Foo‎ رغم عدم استخدام ‎const_cast‎، وقد تمّ الحصول على هذا المؤشّر عبر مُنشئ ‎Foo‎.

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

محاولة إعادة قيمة من دالة لا تعيد قيمة

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

هذا مثال من المعيار [dcl.attr.noreturn]:

[[noreturn]] void f()
    {
        throw "error";    // حسنا
    }

[
    [noreturn]
    ] void q(int i)
    {
        // السلوك غير معرَّف في حال كان الوسيط أصغر من أو يساوي 0
        if (i > 0)
            throw "positive";
    }

تكرار لا نهائي للقالب

هذا مثال من المعيار ‎‎[temp.inst] / 17: يتطلب التوليد الضمني لـ <X<T استنساخًا ضمنيًا والذي يتطلب بدوره استنساخًا ضمنيًا لـ <*X<T والذي يتطلب بدوره استنساخًا ضمنيًا لـ <**X<T وهكذا …

template < class T > class X
{
    X<T> *p;    // OK
    X<T*> a;  
};

التدفق الزائد الناتج عن التحويل من وإلى عدد عشري

عند تحويل:

  • نوع عددي صحيح إلى نوع عدد عشري.
  • أو نوع عدد عشري إلى نوع عددي صحيح.
  • أو نوع نوع عدد عشري إلى نوع عدد عشري أقصر.

فسيحدث سلوك غير معرَّف إن كانت القيمة المصدرية (source value) خارج نطاق القيم التي يمكن تمثيلها في النوع المقصود، انظر المثال التالي حيث لا يمكن لـ int تخزين عناصر كبيرة إلى هذا الحد، لذا يحدث سلوك غير معرَّف.

double x = 1e100;
int y = x;    

تعديل سلسلة نصّية مجردة

الإصدار < C++‎ 11 ‎"hello world"‎ في المثال أدناه هي سلسلة نصّية مجردة، لذا فإنّ محاولة تعديلها ستؤدّي إلى سلوك غير معرَّف.

char *str = "hello world";
str[0] = 'H';

طريقة تهيئة ‎str‎ في المثال أعلاه أصبحت مهملة رسميًا في C++‎ 03 (من المقرر إزالتها من الإصدارات المستقبلية للمعيار)، قد تصدر بعض المٌصرّفات القديمة (قبل عام 2003) تحذيرًا بشأن ذلك، وأصبحت المٌصرّفات تحذّر عادة من أنّ هناك تحويلات متجاوَزة بعد عام 2003.

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

المثال أعلاه غير جائز ويؤدّي إلى إطلاق عملية تشخيص (diagnostic) من قبل المصرّف في الإصدار C++‎ 11 والإصدارات الأحدث. يمكن إنشَاء مثال لتوضيح السلوك غير المعرَّف من خلال السماح بتحويل النوع بشكل صريح على النحو التالي:

char *str = const_cast<char*> ("hello world");
str[0] = 'H';

الوصول إلى كائن بافتراض أنّه من النوع الخاطئ

لا تجوز محاولة الوصول إلى كائن من نوع معيّن كما لو كان من نوع آخر مختلف في معظم الحالات -متجاهلين المؤهِّلات cv- qualifiers. انظر المثال التالي:

float x = 42;
int y = reinterpret_cast<int&>(x);

النتيجة ستكون سلوكًا غير معرَّف.

هناك بعض الاستثناءات لقاعدة التسمية البديلة الصارمة (strict aliasing rule):

  • يمكن الوصول إلى كائن كما لو كان من صنف أساسي (base class) للصنف الفعلي الذي ينتمي إليه.
  • يمكن الوصول إلى أيّ نوع كما لو كان من النوع ‎char‎ أو ‎unsigned char‎، لكنّ العكس ليس صحيحًا، مثلًا: لا يمكن الوصول إلى مصفوفة مكوّنة من حروف كما لو كانت نوعًا عشوائيًا.
  • يمكن الوصول إلى نوع عددي صحيح مؤشّر كما لو كان من النوع غير المؤشَّر المقابل له، والعكس صحيح.

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

struct Base {
};
struct Derived : Base {
void f() {}
};
struct Unrelated {};
Unrelated u;
Derived& r1 = reinterpret_cast<Derived&>(u); // ok
r1.f(); //  سلوك غير معرَّف
Base b;
Derived& r2 = reinterpret_cast<Derived&>(b); // ok
r2.f();   //  سلوك غير معرَّف

التحويلات غير الصالحة من صنف مشتق إلى صنف أساسي، للمؤشّرات العضوية (pointers to members)

عند استخدام ‎static_cast‎ لتحويل ‎T D::*‎ إلى ‎T B::*‎، يجب أن ينتمي العضو المشار إليه إلى صنف يمثّل صنفًا أساسيًا (base class) أو مشتقًّا من ‎B‎. بخلاف ذلك، فإنّ السلوك سيكون غير معرَّف.

محاولة تدمير كائن دُمِّر من قبل

في هذا المثال، يُستدعى المُدمّر بشكل صريح على كائن سيتم تدميره تلقائيًا في وقت لاحق.

struct S
{
    ~S()
    {
        std::cout << "destroying S\n";
    }
};
int main()
{
    S s;
    s.~S();
}    // سلوك غير معرَّف، بسبب تدمير العنصر للمرة الثانية هنا

تحدث مشكلة مماثلة عندما يشير مؤشّر ‎std::unique_ptr<T>‎ إلى نوع ‎T‎ ذي مدّة تخزين (storage duration) تلقائية أو ساكنة، إذ يؤدي تدمير s في حال عودة f إلى سلوك غير معرَّف، لأن s مدمَّرة فعلًا.

void f(std::unique_ptr<S> p);
int main()
{
    S s;
    std::unique_ptr<S> p(&s);
    f(std::move(p)); 
}

محاولة تدمير كائن ما مرتين قد ينتج عنها وجود مؤشّرين مشتركين ‎shared_ptr‎ يديران الكائن دون مشاركة الملكية مع بعضهما البعض.

void f(std::shared_ptr<S> p1, std::shared_ptr<S> p2);
int main()
{
    S* p = new S;
    // أريد تمرير نفس الكائن مرّتين
    std::shared_ptr<S> sp1(p);
    std::shared_ptr<S> sp2(p);
    f(sp1, sp2);
}           

نتيجة الشيفرة السابقة سلوك غير معرف لأن sp2 و sp1 سيحاولان تدمير s بشكل منفصل، لكن بأي حال، فما يلي صحيح:

std::shared_ptr<S> sp(p);
f(sp, sp);

محاولة الوصول إلى عضو غير موجود عبر مؤشّر عضوي

عند محاولة الوصول إلى عضو غير ساكن من كائن معيّن عبر مؤشّر عضوي (pointer to member)، فسيحدث سلوك غير معرَّف إذا لم يكن الكائن يحتوي فعليًا ذلك العضو الذي يشير إليه المؤشّر، إذ يمكن الحصول على المؤشّر العضوي عبر ‎static_cast‎.

struct Base { int x; };
struct Derived : Base { int y; };
int Derived::*pdy = &Derived::y;
int Base::*pby = static_cast<int Base::*>(pdy);
Base* b1 = new Derived;
b1->*pby = 42;    // Derived  في كائن من النوع y جيد، تعيين
Base* b2 = new Base;
b2->*pby = 42;    // Base في  y سلوك غير معرَّف، لأنّه لا يوجد عضو

تحويل غير صالح من الصنف الأساسي إلى صنف مشتق

عند استخدام ‎static_cast‎ على مؤشّر أو مرجع يشير إلى صنف أساسي، من أجل تحويله إلى مؤشّر أو مرجع يشير إلى صنف مشتق منه، فسيكون السلوك غير معرَّف إن لم يكن المُعامل يشير (يرجِع) إلى كائن من الصنف المشتق.

طفح الأعداد العشرية Floating point overflow

إذا أنتجت عملية حسابية قيمة لا تنتمي إلى مجال القيم التي يمكن تمثيلها في النوع الناتج، وكان يُفترض أن تعيد عددًا عشريًا، فإنّ السلوك سيكون غير معرَّف وفقًا لمعيار C++‎، لكن قد يكون معرَّفا في معايير أخرى قد تتوافق معها الآلة، مثل IEEE 754.

float x = 1.0;
for (int i = 0; i < 10000; i++)
{
    x *= 10.0;    // قد يؤدّي في النهاية إلى حدوث تدفق زائد، وسينتج عن ذلك سلوك غير معرَّف
}

استدعاء أعضاء وهمية (خالصة) من مُنشئ أو مدّمر

ينصّ المعيار (10.4) على ما يلي:

اقتباس

يمكن استدعاء التوابع من مُنشئ أو مُدمّر خاص بصنف مجرّد، وسيحدث سلوك معرَّف عند إجراء استدعاء وهمي - virtual call - ‏(10.3) لدالّة وهمية خالصة - pure virtual function - بشكل مباشر أو غير مباشر على الكائن الذي يجري إنشاءه (أو تدميره) من مثل هذا المُنشئ (أو المدمّر).

وعمومًا، يقترح بعض كُبراء C++‎، مثل سكوت مايرز (Scott Meyers) تجنّب استدعاء الدوال الوهميّة (حتى غير الخالصة منها) من المنشئات والمُدمِّرات. انظر المثال التالي المُعدّل من الرابط أعلاه:

class transaction
{
    public:
        transaction()
        {
            log_it();
        }

    virtual void log_it() const = 0;
};
class sell_transaction: public transaction
{
    public: virtual void log_it() const
    { /* افعل شيئا ما */ }
};

لنفترض أنّنا أنشأنا كائنًا ‎sell_transaction‎:

sell_transaction s;

يستدعي هذا ضمنيًا مُنشئ ‎sell_transaction‎، الذي يستدعي أولًا مُنشئ ‎transaction‎. وفي لحظة استدعاء مُنشئ ‎transaction‎ لن يكون الكائن من النوع ‎sell_transaction‎ بعدُ وإنّما سيكون من النوع ‎transaction‎. وعليه فإنّ استدعاء ‎log_it‎ في ‎transaction::transaction()‎ لن يقوم بالمُتوقّع منه - أي استدعاء ‎sell_transaction::log_it‎.

  • إذا كانت ‎log_it‎ وهمية خالصة، كما هو مُوضّح في هذا المثال، فإنّ السلوك سيكون غير معرَّف
  • إذا لم تكن ‎log_it‎ وهمية خالصة، فستُستدعى ‎transaction::log_it‎.

استدعاء دالّة عبر مؤشّر دالّة من نوع غير مطابق

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

int f();
void(*p)() = reinterpret_cast<void(*)() > (f);
p();    // غير معرَّف

الإشارة إلى أعضاء غير ساكنة في قائمة مهيئ

فيما يلي مزيد من الأمثلة عن كيفية حدوث الأخطاء في C++‎. يمكن أن تؤدي الإشارة إلى أعضاء غير ساكنين (non-static members) في قوائم المهيئات (initializer lists) قبل بدء تنفيذ المُنشئ إلى حدوث سلوك غير معرَّف، ذلك أنه في هذه المرحلة لن يكون جميع الأعضاء قد أُنشِؤوا بعد. انظر هذا المُقتطف من المسوَّدة القياسية:

اقتباس

12.7.1: بالنسبة لكائن ذي مُنشئ غير اعتيادي (non-trivial constructor)، ستؤدّي الإشارة إلى عضو غير ساكن أو صنف أساسي للكائن قبل أن يبدأ تنفيذ المُنشئ إلى نتائج غير مُحدّدة.

هذا مثال على ذلك:

struct W { int j; };
struct X : public virtual W { };
struct Y {
    int *p;
    X x;
    Y() : p(&x.j) { // لم يُنشأ بعد x غير محدد، لأنّ
}
};

نكتفي بالحديث عن السلوك غير المُعرَّف وننتقل بدءًا من هذا القسم وما يليه إلى الحديث عن السلوك غير المُحدَّد (Unspecified behavior).

قيمة لتعداد خارج النطاق

عند تحويل تعداد إلى نوع عددِي صحيح، وكان ذلك النوع أصغر من أن يحتفظ بقيمة التعداد، فستكون القيمة الناتجة غير محددة، انظر المثال التالي:

enum class E {
    X = 1,
        Y = 1000,
};
// char نفترض أنّ العدد 1000 لا يُمكن أن يُخزَّن في
char c1 = static_cast < char > (E::X); // تساوي 1 c1 قيمة
char c2 = static_cast < char > (E::Y); // غير معينة c2 قيمة

كذلك عند تحويل عدد صحيح إلى تعداد، وكانت قيمة العدد الصحيح خارج نطاق قيم التعداد، فإنّ القيمة الناتجة ستكون غير محددة. انظر المثال التالي:

enum Color {
    RED = 1,
        GREEN = 2,
        BLUE = 3,
};
Color c = static_cast < Color > (4);

بالمقابل، لمّا كانت قيمة المصدر في المثال التالي تقع في نطاق التعداد، فإنّ السلوك لن يكون "غير معيّن"، رغم أنّها لا تساوي أيّ عدّاد (enumerator):

enum Scale {
    ONE = 1,
        TWO = 2,
        FOUR = 4,
};
Scale s = static_cast < Scale > (3);

هنا، ستكون قيمة ‎s‎ مساوية لـ 3، ولن تساوي ‎ONE‎ أو ‎TWO‎ أو ‎FOUR‎.

ترتيب تقييم وسائط دالة (Evaluation order of function arguments)

إذا كان لدالّة ما عدّة وسائط، فسيكون ترتيب تقييمها غير محدد. انظر المثال التالي، حيث يحتمل أن تطبع الشيفرة التالية إمّا ‎x = 1, y = 2‎ أو ‎x = 2, y = 1‎.

int f(int x, int y) {
    printf("x = %d, y = %d\n", x, y);
}
int get_val() {
    static int x = 0;
    return ++x;
}
int main() {
    f(get_val(), get_val());
}

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

في C++‎ 17، يظل ترتيب تقييم وسائط الدالّة غير محدد، رغم أن كل وسائط الدالة تُقيّم بشكل كامل كما يُضمن تقييم كائن الاستدعاء calling object قبل تقييم وسائط الدالة.

struct from_int {
    from_int(int x) {
        std::cout << "from_int (" << x << ")\n";
    }
};
int make_int(int x) {
    std::cout << "make_int (" << x << ")\n";
    return x;
}
void foo(from_int a, from_int b) {}
void bar(from_int a, from_int b) {}
auto which_func(bool b) {
    std::cout << b?"foo":"bar" << "\n";
    return b ? foo : bar;
}
int main(int argc, char const*const* argv) {
    which_func(true)(make_int(1), make_int(2));
}

سيكون الخرج إمّا:

bar
make_int(1)
from_int(1)
make_int(2)
from_int(2)

أو:

bar
make_int(2)
from_int(2)
make_int(1)
from_int(1)

قد لا تطبع الشيفرة السلسلة النصية ‎bar‎ بعد ‎make‎ أو ‎from‎، وقد لا تطبع ما يلي أيضًا:

bar
make_int(2)
make_int(1)
from_int(2)
from_int(1)

كانت طباعة ‎bar‎ بعد ‎make_int‎ قبل C++‎ 17 غير جائزة، ولا تنفيذ ‎make_int‎ s قبل ‎from_int‎.

نتيجة التحويلات من النوع reinterpret_cast

تكون نتيجة التحويل ‎reinterpret_cast‎ من نوع مؤشر دالة إلى آخر أو من نوع مرجع دالة ما إلى آخر، تكون غير محددة. انظر المثال التالي حيث تكون قيمة fp غير محددة.

int f();
auto fp = reinterpret_cast<int(*)(int)>(&f); 

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

بالمثل، ستكون نتيجة تحويل ‎reinterpret_cast‎ من نوع مؤشّر كائن (object pointer) إلى آخر، أو من نوع مرجع كائن (object reference) إلى آخر، غير محددة. انظر المثال التالي حيث تكون قيمة p غير محددة:

int x = 42;
char* p = reinterpret_cast<char*>(&x); 

يكافئ هذا في معظم المُصرِّفات تحويلَ ‎static_cast<char*>(static_cast<void*>(&x))‎، لذلك يشير المؤشّر الناتج ‎p‎ إلى البايت الأول من ‎x‎، وهذا هو السلوك القياسي في C++‎ 11.

المساحة التي يشغلها مرجع ما

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

وبعض المزايا في C++‎ 17 تجعل مسألة التحقق من أيّ تخزين قد يشغله المرجع بشكل محمول (portably) أمرًا مستحيلًا:

  • فعند تطبيق ‎sizeof‎ على مرجع ما فإنها تُعيد حجم النوع المشار إليه، ومن ثم لن تعطي معلومات عمّا إذا كان المرجع يشغل مساحة تخزين ما.
  • مصفوفات المراجع (Arrays of references) غير جائزة، لذا لا يمكن التحقّق من عنواني عنصرين متتاليين لمرجع يشير إلى مصفوفات بُغية تحديد حجم المرجع.
  • في حال أخذ عنوان مرجع ما، فإنّ النتيجة ستكون عنوان العنصر المشار إليه في ذلك المرجع، لذا لا يمكننا الحصول على مؤشّر يشير إلى المرجع نفسه.
  • إذا كان لصنف ما عضو مرجعي (reference member)، فإنّ محاولة استخراج عنوان ذلك العضو باستخدام ‎offsetof‎ ستؤدي إلى سلوك غير معرَّف، لأنّ مثل هذا الصنف لن يكون صنفَ تخطيط قياسي (standard-layout class).
  • إذا كان لصنف ما عضوٌ مرجعي، فلن يعدّ الصنف تخطيطًا قياسيًا (standard layout)، لذا سينتج عن محاولة الوصول إلى البيانات المُستخدمة لتخزين المرجع سلوكًا غير معرَّف، أو سلوكًا غير محدد.

وعمليًا، يمكن في بعض الحالات تنفيذ متغيّر مرجعي (reference variable) على هيئة متغيّر مؤشّر (pointer variable)، وسيشغل حينها نفس مساحة التخزين التي يشغلها المؤشّر، بينما قد لا يشغل المرجع في حالات أخرى أيّ مساحة على الإطلاق نتيجة لعمليات التحسين (optimisation). على سبيل المثال، في الشيفرة التالية:

void f() {
    int x;
    int& r = x;
    // r افعل شيئا ما بـ
}

يستطيع المُصرِّف معاملة ‎r‎ كاسم بديل (alias) لـ ‎x‎، واستبدال كل تكرارات ‎x‎ في بقية الدالّة ‎f‎ بـ r، مع عدم تخصيص أيّ ذاكرة لـ ‎r‎.

حالة "منقول-منه" Moved-from لأغلب أصناف المكتبات القياسية

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

تُترك جميع حاويات المكتبات القياسية في حالة غير محددة لكن صالحة بعد النقل منها. على سبيل المثال، في الشيفرة التالية، ‎v2‎ ستتضمّن ‎{1, 2, 3, 4}‎ بعد النقل، لكن لن تكون ‎v1‎ فارغة بالضرورة.

int main() {
    std::vector v1{1, 2, 3, 4}; 
    std::vector < int > v2 = std::move(v1);
}

تكون لبعض الأصناف حالة مُحدّدة بدقة بعد النقل منها، والحالة الأهم هي حالة std::unique_ptr<T>‎، التي تكون فارغة بعد النقل منها.

نتيجة المقارنة بين المؤشّرات

ستكون النتيجة غير محددة عند مقارنة مؤشّرين باستخدام العوامل ‎<‎ أو ‎>‎ أو ‎<=‎ أو ‎>=‎، وذلك في الحالات التالية:

  • إذا كانت المؤشّرات تشير إلى مصفوفات مختلفة، إذ تُعدّ الكائنات التي ليست مصفوفات عبارة عن مصفوفات من الحجم 1.
int x;
int y;
const bool b1 = &x < &y; // غير محدد
int a[10];
const bool b2 = &a[0] < &a[1]; // true
const bool b3 = &a[0] < &x; // غير محدد
const bool b4 = (a + 9) < (a + 10); // true
// إلى الموضع الذي يلي المصفوفة a+10 تشير
  • إذا كانت المؤشرات تشير إلى داخل نفس الكائن، لكن إلى أعضاء ذات متحكمات وصول (access control) مختلفة.
class A {
    public:
        int x;
    int y;
    bool f1() { return &x < &y; } // true
    bool f2() { return &x < &z; } // غير محدد
    private:
        int z;
};

التحويل الساكن من من قيمة من النوع void*‎

إذا حُوِّلَت قيمةٌ من نوع ‎void*‎ إلى مؤشّر يشير إلى نوع ‎T*‎، لكن لم تحاذى بشكل صحيح مع ‎T‎، فستكون قيمة المؤشّر الناتجة غير محددة. انظر المثال التالي، لنفترض أن (alignof(int تساوي 4:

int x = 42;
void* p1 = &x;
// إنجاز بعض العمليات الحسابية على المؤشر
void* p2 = static_cast<char*>(p1) + 2;
int* p3 = static_cast<int*>(p2);

قيمة ‎p3‎ غير محددة لأنّ ‎p2‎ لا يمكن أن تشير إلى كائن من النوع ‎int‎؛ فمُحاذاة عنوان قيمتها غير صحيحة.

ترتيب تهيئة الكائنات العامّة عبر وحدة الترجمة

يكون ترتيب تهيئة المتغيّرات العامة محددًا داخل وحدة الترجمة، بينما يكون ترتيب التهيئة عبر عدة وحدات ترجمة غير محدد. لذلك فالبرنامج الذي به الملفات التالية:

  • foo.cpp
#include <iostream>

int dummyFoo = ((std::cout << "foo"), 0);
  • bar.cpp
#include <iostream>

int dummyBar = ((std::cout << "bar"), 0);
  • main.cpp
int main() {}

قد يُنتِج الخرج التالي:

foobar

أو:

barfoo

وقد يؤدّي ذلك إلى إخفاق ترتيب التهيئة الساكنة (Static Initialization Order Fiasco).

الاتحادات Unions والسلوك غير المعرَّف

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

union U {
    int a;
    short b;
    float c;
};
U u;
u.a = 10;
if (u.b == 10) {
}

سينجم عن الشيفرة أعلاه سلوك غير معرَّف، ذلك أن a كان آخر عضو يُكتَب فيه. وتجيز الكثير من المصرّفات هذا الأمر، وقد تكتفي بإصدار تحذير، بيْد أنّ النتيجة ستكون "كما هو متوقع"؛ أن هذه إضافة للمصرّف (extension compiler)، لذا لا يمكن ضمان هذا السلوك في جميع المصرّفات (فهي شيفرة غير محمولة ولا متوافقة).

الاتحادات (Unions) هي بنيات مخصصة تحتلّ أعضاؤها مساحة مشتركة في الذاكرة.

union U {
    int a;
    short b;
    float c;
};
U u;
//سيكونان متساويين a و b عنوانا
(void*)&u.a == (void*)&u.b;
(void*)&u.a == (void*)&u.c;
// إسناد قيمة إلى أيّ عضو من الاتحاد يغير الذاكرة المشتركة
u.c = 4. f;
u.a = 5;
u.c != 4. f;

تساعد الاتحادات على ترشيد استخدام الذاكرة المخصّصة للبيانات الحصرية (exclusive data)، كما في تنفيذ أنواع مختلطة من البيانات.

struct AnyType {
    enum {
        IS_INT,
        IS_FLOAT
    }
    type;

    union Data {
        int as_int;
        float as_float;
    }
    value;
    AnyType(int i): type(IS_INT) {
        value.as_int = i;
    }
    AnyType(float f): type(IS_FLOAT) {
        value.as_float = f;
    }
    int get_int() const {
        if (type == IS_INT)
            return value.as_int;
        else
            return (int) value.as_float;
    }

    float get_float() const {
        if (type == IS_FLOAT)
            return value.as_float;
        else
            return (float) value.as_int;
    }
};

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

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

  • Chapter 104: Undefined Behavior
  • Chapter 140: More undefined behaviors in C++‎
  • Chapter 121: Unspecified behavior
  • Chapter 111: Unions

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





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


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



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

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

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


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

تسجيل الدخول

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


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