السلاح السري للمصحح: ما يغفل عنه مبرمجو C++
\n\nهل سبق لك أن كتبت شيئًا مثل i = i++ + ++i; وحصلت على نتائج مختلفة في مترجمات مختلفة؟ أو واجهت خطأً غريبًا يظهر فقط على الخادم وليس على حاسوبك المحمول؟ تعرف على: السلوك غير المحدد (undefined behavior) — أحد أخطر أعداء مطوري C++. وفي قلب هذه الفوضى تقف نقاط التسلسل (sequence points).
في هذه المقالة، سنشرح ما هي نقاط التسلسل، وكيف ترتبط بترتيب العمليات الحسابية في C++، والأهم من ذلك — كيف لا تقع في هذه الأخطاء. المادة مستندة إلى سؤال شهير على Stack Overflow حصل على آلاف المشاهدات. هيا بنا!
\n\nما هي نقاط التسلسل (sequence points)؟
\n\nتخيل أنك قائد أوركسترا. كل عازف (تعليمة معالج) يجب أن يدخل في لحظة محددة بدقة. نقطة التسلسل هي لحظة زمنية تكون فيها جميع "الآثار الجانبية" السابقة (تغييرات المتغيرات، استدعاءات الدوال) قد اكتملت بشكل مضمون، ولم تبدأ الآثار التالية بعد.
\n\nفي معيار C++ 98/03، تم استخدام مصطلح sequence point. في C++11، تم استبداله بعلاقات أكثر صرامة "sequenced before / sequenced after"، لكن الجوهر بقي: بين نقطتي تسلسل، يحق للمترجم إعادة ترتيب العمليات بالكامل، إذا لم يغير ذلك النتيجة (وفقًا لقاعدة "as-if").
\n\nببساطة: إذا حاولت تغيير نفس المتغير مرتين بين نقطتي تسلسل — فستحصل على سلوك غير محدد (undefined behavior). يمكن للمترجم أن يفعل أي شيء: من نتيجة صحيحة إلى تهيئة قرصك الصلب (مزاح، لكنه قريب من الحقيقة).
\n\nأمثلة كلاسيكية على نقاط التسلسل
\n- \n
- الفاصلة المنقوطة
;— نهاية التعبير \n - الفاصلة
,كعامل (ولكن ليس في وسائط الدوال!) \n - نهاية كل تعبير في
for(بعد التهيئة، الفحص، والخطوة) \n - العوامل المنطقية
&&و||\n - العامل الثلاثي
?:\n - استدعاء الدالة (قبل أن يبدأ تنفيذ جسم الدالة) \n
كيف تولد نقاط التسلسل سلوكًا غير محدد؟
\n\nهذا مثال كلاسيكي يجب أن يعرفه كل مطور C++:
\n\nint i = 0;\ni = i++ + ++i; // UB! سلوك غير محدد\n\nلماذا هذا سلوك غير محدد؟ لأنه بين نقطتي تسلسل (في هذه الحالة — بين بداية ونهاية التعبير) يتم تغيير المتغير i مرتين:
- \n
- الزيادة اللاحقة
i++\n - الزيادة السابقة
++i\n - الإسناد
i = ...\n
المترجم لا يعرف بأي ترتيب ينفذ هذه العمليات. النتيجة؟ مترجم سيعطي 2، وآخر 3، وثالث 0. وفي وضع التحسين قد يتجاهل هذا الكود تمامًا.
\n\nحالة أخرى خادعة: وسائط الدالة
\n\nint foo(int a, int b) { return a + b; }\n\nint x = 5;\nint result = foo(++x, ++x); // UB!\n\nهنا الفاصلة بين الوسائط هي ليست عامل الفاصلة، بل فاصل. لا توجد نقطة تسلسل بين حساب الوسيطين ++x! كلا الزيادتين تُنفذان "بالتوازي" (من وجهة نظر المعيار)، ونحصل مرة أخرى على سلوك غير محدد.
أمثلة عملية: كيف يبدو السلوك غير المحدد في الكود الحقيقي
\n\nمثال 1: خطأ غير واضح في الفهرسة
\n\nint arr[5] = {0, 10, 20, 30, 40};\nint idx = 1;\narr[idx] = idx++; // UB!\n\nللوهلة الأولى، كل شيء بسيط: نريد كتابة 1 في arr[1]، ثم زيادة idx. لكن المعيار لا يضمن ترتيب حساب الفهرس idx والجزء الأيمن idx++. النتيجة؟ قد يُكتب في arr[1]، وقد يُكتب في arr[2].