ماذا يعني "السلوك غير المعرَّف" فعليًا
أظهرت الصفحة السابقة كيف يتعامل try/catch مع الأخطاء التي يعرّفها برنامجك ويطلقها عن قصد. السلوك غير المعرَّف هو العكس: إنه مجموعة العمليات التي يرفض معيار C++ أن يمنحها أي معنى على الإطلاق. لا يوجد استثناء لالتقاطه، ولا رمز خطأ، ولا ضمان بحدوث تعطل. المترجم حر في أن يفترض أن السلوك غير المعرَّف لا يحدث أبدًا، وأن يفعل ما يشاء عندما يحدث.
تلك الحرية هي ما يجعل السلوك غير المعرَّف بالغ الخطورة. فقد يطبع السطر الخاطئ نفسه الإجابة "الصحيحة" على حاسوبك المحمول، ويعيد قيمًا عشوائية على خادم، ويُحذف بالكامل بواسطة المُحسِّن عند -O2. السلوك غير المعرَّف ليس "سلوكًا لم نوثّقه"، بل هو "سلوك لا تَعِد اللغة بشأنه بشيء". مهمتك هي ألا تكتبه من الأساس أبدًا.
int arr[3] = {1, 2, 3};
int x = arr[5]; // سلوك غير معرَّف: قراءة بعد نهاية المصفوفة
لا يوجد خطأ ترجمة هنا، وفي كثير من عمليات التشغيل سيسلّمك بهدوء عددًا صحيحًا شاردًا. ذلك النجاح الظاهري هو الفخ.
القراءة أو الكتابة خارج الحدود
أكثر أشكال السلوك غير المعرَّف شيوعًا هو لمس ذاكرة لا تملكها. فالمصفوفات المضمَّنة وstd::vector::operator[] لا تجريان أي فحص للحدود؛ فالفهرس بعد النهاية (أو الفهرس السالب) سلوك غير معرَّف فوري، سواء قرأت أو كتبت.
الخطأ الذي يجب الانتباه إليه هو استخدام <= حيث كنت تقصد <: فعندما تكون i == v.size() فإنك تفهرس عنصرًا بعد الأخير، وهذا سلوك غير معرَّف. فضّل حلقة for المعتمدة على المدى (التي تناولناها سابقًا) عندما لا تحتاج إلى الفهرس، لأنها لا يمكن أن تتجاوز النهاية. وعندما تفهرس فعلًا يدويًا وتريد شبكة أمان، فإن v.at(i) تطلق std::out_of_range بدلًا من إفساد الذاكرة بصمت:
استخدم at() أثناء مطاردتك لخطأ ما؛ وعُد إلى [] في الحلقات الحرجة بمجرد أن تثبت أن الفهارس صحيحة.
المؤشرات المعلَّقة وحالة use-after-free
المؤشر أو المرجع الذي يبقى حيًا بعد زوال الكائن الذي يشير إليه يكون معلَّقًا (dangling). واستخدامه سلوك غير معرَّف؛ فالذاكرة ربما أُعيد استخدامها أو حُرِّرت أو لم تكن موجودة من الأساس. هذا هو الفخ الذي تساعدك المؤشرات الذكية (من الفصل السابق) على تجنّبه، لكن المؤشرات الخام لا تزال تتركك تقع فيه.
أحدّ صوره هو إرجاع عنوان متغير محلي. فالمتغير المحلي يموت عند رجوع الدالة، فيبقى المستدعي ممسكًا بمؤشر يشير إلى لا شيء:
int* makeNumber() {
int n = 42;
return &n; // يعيد عنوان متغير محلي - يزول بعد الرجوع
}
// إلغاء مرجعية النتيجة سلوك غير معرَّف.
والأمر نفسه يحدث بعد delete أو عندما تُعيد vector التخصيص فتُبطل المكررات أو المؤشرات التي تشير إليها:
int* p = new int(5);
delete p;
cout << *p; // use-after-free: سلوك غير معرَّف
vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4); // قد تعيد التخصيص - 'first' معلَّق الآن
cout << *first; // سلوك غير معرَّف
الدفاعات هي تلك التي تعرفها بالفعل: أبقِ الكائنات حية ما دام أي مؤشر يحتاجها، وفضّل المراجع والمؤشرات الذكية على المؤشرات الخام المالكة، وأعد جلب المؤشرات/المكررات بعد أي عملية قد تغيّر حجم حاوية.
المتغيرات غير المهيأة وفيض الأعداد ذات الإشارة
قراءة متغير قبل أن تمنحه قيمة سلوك غير معرَّف بالنسبة للأنواع المضمَّنة؛ فلا توجد قيمة 0 افتراضية. يحمل المتغير ما كان موجودًا أصلًا من بتات في تلك الذاكرة، وقد يفترض المُحسِّن أنك لا تقرأه قط وهو غير مهيأ.
لو أن sum أُعلِن بمجرد int sum;، لكان كل sum += i يقرأ أولًا قيمة غير محددة: سلوك غير معرَّف، وخطأ صعب على نحو سيئ السمعة لأنه كثيرًا ما يبدو وكأنه يعمل. اجعل التهيئة عادة: int x = 0; أو int x{};.
ومن المخالفين الصامتين الآخرين فيض الأعداد الصحيحة ذات الإشارة. فدفع int ذي إشارة إلى ما بعد قيمته القصوى سلوك غير معرَّف (الأنواع عديمة الإشارة تلتف بشكل متوقع؛ أما الأنواع ذات الإشارة فلا):
int big = 2147483647; // INT_MAX على int بحجم 32 بت
int oops = big + 1; // فيض ذو إشارة: سلوك غير معرَّف
لا تعتمد على أنه "سيلتف إلى عدد سالب"؛ فالمترجم مسموح له أن يفترض أن الفيض لا يمكن أن يحدث وأن يُحسِّن بناءً على ذلك. إذا كنت تحتاج التفافًا محددًا، فاستخدم نوعًا عديم الإشارة أو افحص الحدود قبل الجمع.
التقاط السلوك غير المعرَّف بالأدوات الكاشفة والتحذيرات
لا يمكنك بلوغ الثقة بشأن السلوك غير المعرَّف عن طريق الاختبار، لأن التشغيل الناجح لا يضمن شيئًا. ما ينجح فعلًا هو جعل السلوك غير المعرَّف صاخبًا أثناء التشغيل بأدوات المترجم الكاشفة (المتوفرة في GCC وClang).
// AddressSanitizer: خارج الحدود، use-after-free، تسريبات
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app
// UndefinedBehaviorSanitizer: فيض ذو إشارة، إلغاء مرجعية فارغة، تحويلات خاطئة
g++ -fsanitize=undefined -g main.cpp -o app && ./app
شغّل اختباراتك القائمة تحت هذه الرايات، فتتحول القراءة خارج الحدود أو حالة use-after-free أو الفيض ذو الإشارة الذي "كان يعمل بلا مشاكل" إلى تقرير دقيق يسمّي الملف والسطر. اجمعها مع -Wall -Wextra كي يشير المترجم أيضًا إلى الشيفرة المريبة (مثل قراءة يُرجَّح أنها غير مهيأة) قبل أن تشغّلها أصلًا.
==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 main.cpp:7 in main
عامِل أي تقرير من الأداة الكاشفة بوصفه خطأً يجب إصلاحه حتمًا، لا تحذيرًا يُتجاهل؛ فهو يخبرك بأن المعيار لا يقطع أي وعد بشأن ذلك السطر.
الخلاصة
السلوك غير المعرَّف هو الجزء من C++ حيث تُرفع حواجز الأمان: الوصول خارج الحدود، والمؤشرات المعلَّقة، وحالات use-after-free، والقراءات غير المهيأة، وفيض الأعداد ذات الإشارة، كلها تنتج شيفرة بلا أي معنى محدد، و"لقد عملت بلا مشاكل" ليست أبدًا دليلًا على صحتها. السبيل إلى البقاء آمنًا هو الكتابة بأسلوب دفاعي (هيّئ كل متغير، واحترم حدود الحاويات، ودع المؤشرات الذكية تملك ذاكرة الكومة لديك)، ثم التحقق باستخدام -fsanitize=address و-fsanitize=undefined و-Wall -Wextra كي يتحول السلوك غير المعرَّف الصامت إلى تقرير صاخب قابل للإصلاح.
بهذا يُختتم فصل الأخطاء والتنقيح. فبين الاستثناءات وtry/catch وخوف صحي من السلوك غير المعرَّف، أصبحت الآن تملك الأدوات لكتابة شيفرة C++ تفشل بصوت عالٍ وعن قصد، لا بصمت وبالصدفة.
الأسئلة الشائعة
ما هو السلوك غير المعرَّف في C++؟
السلوك غير المعرَّف (UB) هو أي عملية يتركها معيار C++ صراحةً دون أي نتيجة محددة؛ مثل القراءة بعد نهاية مصفوفة أو إلغاء مرجعية مؤشر معلَّق. يُسمح للمترجم بأن يفعل أي شيء: أن يتعطل، أو يعيد قيمًا عشوائية، أو يحذف الشيفرة أثناء التحسين، أو يبدو وكأنه يعمل اليوم ثم ينكسر بعد إعادة الترجمة. إنه خطأ في برنامجك، وليس ميزة في اللغة.
لماذا يعمل برنامجي بلغة C++ رغم احتوائه على سلوك غير معرَّف؟
عبارة "لقد عمل بلا مشاكل" لا تُثبت شيئًا عن السلوك غير المعرَّف. فالمعيار لا يقدّم أي ضمان في أي اتجاه، لذا قد ينتج خطأ السلوك غير المعرَّف النتيجة التي تتوقعها على جهازك بمترجمك اليوم، ثم يتعطل عند مستوى تحسين مختلف أو منصة أخرى أو إصدار مترجم آخر. لا تعتبر أبدًا التشغيل الناجح دليلًا على أن السلوك غير المعرَّف غير ضار؛ استخدم أداة كاشفة (sanitizer) لالتقاطه فعليًا.
كيف تلتقط السلوك غير المعرَّف في C++؟
ترجم بأدوات كاشفة: -fsanitize=address (أداة AddressSanitizer) تجد عمليات القراءة/الكتابة خارج الحدود وحالات use-after-free، و-fsanitize=undefined (أداة UndefinedBehaviorSanitizer) تشير إلى فيض الأعداد ذات الإشارة وإلغاء مرجعية المؤشرات الفارغة والتحويلات الخاطئة. فعّل التحذيرات (-Wall -Wextra) وشغّل اختباراتك تحت هذه الرايات؛ فهي تحوّل السلوك غير المعرَّف الصامت إلى تقرير واضح أثناء التشغيل.