إعادة بناء printf ومراجعتها بمساعدة الذكاء الاصطناعي
درس 2 من 3
2إعادة بناء printf ومراجعتها بمساعدة الذكاء الاصطناعي
مشروعان مترابطان: أولًا بناء نسخة مبسّطة من printf من الصفر، ثم استخدام أداة ذكاء اصطناعي كمراجع كود إضافي (لا بديل) لتحسين تلك النسخة.
ماذا تشاهد في الرسم: printf("x=%d!", 42) يطبع الأحرف العادية كما هي، وعند %d يأخذ 42 ويطبعه، فيخرج x=42!.
طريقان إلى نفس الفكرة
دالة printf مثل قالب رسالة فيه فراغات: «مرحبًا يا ___، رصيدك ___». تعيد بناءها بنفسك لتفهم كيف تملأ الفراغات بأنواع مختلفة. ثم تعرض عملك على الذكاء الاصطناعي كمراجع ثانٍ يلاحظ ما فاتك، دون أن يكتب العمل بدلًا منك.
printf دالة متغيرة المعاملات تُبنى بـstdarg.h (va_list وva_start وva_arg). تقرأ نص الصيغة حرفًا حرفًا وتعالج %c و%s و%d و%%، وتعيد عدد الحروف المطبوعة. ثم تطلب مراجعة تركز على الحالات الحدّية مثل INT_MIN وNULL.
قبل أن تبدأ
🧠الدوال المتغيّرة الوسائط
تحتاج فهم va_list/va_start/va_arg من الجزء السابع في دليل الأساسيات، printf نفسها دالة Variadic.
الفكرة الجوهرية: كيف "تقرأ" printf محدِّدات التنسيق؟
عندما تكتب printf("Name: %s, Age: %d\n", name, age)، الدالة لا تعرف مسبقًا كم وسيطًا استُدعيت به ولا أنواعها، هي تعتمد كليًا على سلسلة التنسيق (Format String) نفسها لتفسير البقية:
الآلية هنا تبدأ بمسح تسلسلي صِرف: الدالة تتقدّم في سلسلة التنسيق حرفًا بحرف من بدايتها، وأي حرف عادي لا علاقة له بالتنسيق يُطبَع كما هو فورًا دون أي معالجة إضافية. حين تقابل حرف %، هذا بالذات هو الإشارة الوحيدة في كامل السلسلة التي تخبر الدالة "الحرف القادم ليس نصًا للطباعة، بل تعليمات": يجب قراءة الحرف الذي يليه مباشرة لمعرفة نوع الوسيط القادم (d لعدد صحيح، s لسلسلة نصية، % أخرى لعلامة % حرفية). والنقطة الجوهرية التي يسهل تفويتها: هذا المسح ليس مجرد عرض بصري متسلسل، بل هو فعليًا المحرّك الذي يسحب الوسائط واحدًا تلو الآخر من القائمة غير المحدودة التي استُدعيت بها الدالة. في كل مرة يكتمل محدِّد تنسيق كامل (حرف d مثلًا يلي % مباشرة)، يُستدعى va_arg(args, النوع المطابق) مرة واحدة بالضبط، لا أكثر ولا أقل، لسحب القيمة التالية من قائمة الوسائط، ثم يتقدّم مؤشر المسح لمتابعة قراءة الحرف الذي يلي المحدِّد مباشرة. فإن كانت السلسلة تحتوي ثلاثة محدِّدات، فستُستدعى va_arg ثلاث مرات بالضبط، وبالترتيب الدقيق الذي ظهرت به المحدِّدات في النص، لا بترتيب آخر، ولا عدد آخر، لأنه لا توجد طريقة أخرى على الإطلاق تعرف بها الدالة عدد الوسائط أو أنواعها.
السبب الذي يجعل va_arg بحاجة لمعرفة النوع صريحًا في كل استدعاء (وليس فقط "أعطني القيمة التالية") هو أن معلومات النوع في لغة C تُمحى تمامًا بحلول وقت تنفيذ الدالة المستدعاة، الدالة لا ترى أي أثر لكون الوسيط الذي مُرِّر فعليًا كان int أو char *؛ كل ما تملكه هو موقع في الذاكرة يحمل بايتات خام. va_arg تحتاج أن تعرف من النوع الذي تمرّره لها كم بايت تقرأ ومن أي تمثيل (قيمة صحيحة مباشرة أم عنوان مؤشر)، ثم تُقدّم مؤشرها الداخلي بذلك المقدار بالضبط لتكون جاهزة للوسيط التالي. على المعماريات الشائعة اليوم مثل x86-64 (وهي ما تعمل عليه بيئة لينكس)، الأمر أعقد من "مجرد مؤشر يتقدّم على المكدس" كما قد تتخيّل: أول عدد قليل من وسائط أي استدعاء دالة يُمرَّر فعليًا عبر سجلّات المعالج (Registers) لا عبر المكدس، فحين تبدأ دالة Variadic، يُنشئ المُصرِّف كودًا يُسمّى تلقائيًا عند va_start ينسخ قيم تلك السجلّات أولًا إلى منطقة محجوزة خاصة داخل إطار المكدس (Stack Frame) للدالة نفسها، ثم يتابع va_arg قراءة قيمه من هذه المنطقة المحفوظة أولًا قبل أن "يفيض" للقراءة من المكدس الفعلي للوسائط المتبقية التي لم تتسع لها السجلّات. هذا تحديدًا سبب أن va_arg ليست مجرد "ماكرو بسيط يضيف حجم النوع لمؤشر" كما قد تكون على معماريات أبسط، إنها عملية محاسبة داخلية (Bookkeeping) يبنيها المُصرِّف نفسه اعتمادًا على النوع الذي طلبته أنت بالضبط.
وهذا يفسّر مباشرة لماذا المسؤولية تقع عليك كمبرمج لا على المُصرِّف: إن كتبت %d في سلسلة التنسيق لكن مُرِّر فعليًا char * في مكانه، لا شيء يمنعك وقت التصريف، va_arg(args, int) ستقرأ من تلك المنطقة المحفوظة بافتراض أنها تحمل عددًا صحيحًا بحجم int، بصرف النظر عمّا مُرِّر فعليًا. النتيجة ليست مجرد "قراءة بايتات خاطئة في نفس العنوان" بسيطة، بل قد تكون تفسيرًا كاملًا خاطئًا لجزء من بنية المحاسبة الداخلية هذه نفسها، فتحصل على رقم بلا معنى، أو تحاول طباعة مؤشر كأنه عدد صحيح (أو العكس: تحاول print_str قراءة عنوان من قيمة عددية عشوائية لم تكن مؤشرًا فعليًا أصلًا، فتقرأ ذاكرة غير صحيحة قد تنهار البرنامج). بعض المُصرِّفات الحديثة (مثل GCC و Clang) تقدّم فعليًا تحذيرًا اختياريًا (-Wformat) حين تُزيَّن دالتك بسمة __attribute__((format(printf, ...))) تخبر المُصرِّف أن يعاملها كـprintf ويتحقق من تطابق الأنواع المكتوبة في السلسلة مع الوسائط الفعلية، لكن هذا تحسين إضافي من المُصرِّف خارج نطاق لغة C نفسها، لا ضمانًا توفّره اللغة لأي دالة Variadic تكتبها بنفسك بشكل افتراضي.
شكل 1، حلقة المسح نفسها هي ما يحرّك استهلاك الوسائط: كل محدِّد تنسيق مكتمل يستدعي va_arg مرة واحدة بالضبط قبل أن تتابع الحلقة مسح بقية السلسلة.
كيف يعمل، خطوة بخطوة: هيكل مبسّط لـft_printf
#include <stdarg.h>
#include <unistd.h>
int ft_printf(const char *format, ...)
{
va_list args;
int printed = 0;
int i = 0;
va_start(args, format);
while (format[i])
{
if (format[i] == '%' && format[i + 1])
{
i++;
if (format[i] == 'd')
printed += print_int(va_arg(args, int));
else if (format[i] == 's')
printed += print_str(va_arg(args, char *));
else if (format[i] == '%')
printed += write(1, "%", 1);
}
else
printed += write(1, &format[i], 1);
i++;
}
va_end(args);
return (printed);
}
شكل 2، الفكرة الأساسية: حلقة واحدة تمسح السلسلة، وكل محدِّد تنسيق يستهلك بالضبط وسيطًا واحدًا من args بالنوع المطابق له: نفس منطق sum_all من دليل الأساسيات، لكن بتفريع أكبر حسب نوع المحدِّد.
لنتتبّع هذا الكود حرفيًا على استدعاء محدَّد: ft_printf("Age: %d\n", 7). عند i = 0 إلى i = 4، الحروف A، g، e، :، مسافة كلها ليست %، فيُنفَّذ الفرع else في كل مرة: write تطبع الحرف مباشرة من &format[i]، ثم i++. عند i = 5، format[5] == '%' وهناك حرف يليه فعليًا (format[6] موجود)، فيدخل الفرع الأول: i++ يصبح i = 6 فيقرأ format[6] == 'd'، فيُستدعى va_arg(args, int) مرة واحدة بالضبط، هذا هو اللحظة الوحيدة في كامل هذا الاستدعاء التي يُسحَب فيها الوسيط 7 فعليًا من قائمة الوسائط، وتُمرَّر قيمته لـprint_int التي تطبع "7" وتُعيد عدد الحروف المطبوعة. بعد الشرط الداخلي، i++ النهائية في أسفل الحلقة (التي تُنفَّذ دائمًا بصرف النظر عن أي فرع) تُقدّم i إلى 7، أي الحرف الذي يلي d في السلسلة الأصلية، \n، فيُعامَل كحرف عادي ويُطبَع مباشرة عبر write. عند i = 8، format[8] هو المحرف الفاصل الفارغ \0 الذي تُنهي به السلسلة فعليًا، فشرط while (format[i]) يفشل والحلقة تتوقف. لاحظ أن va_arg لم تُستدعَ إلا مرة واحدة بالضبط في كل هذا المسح، تمامًا بعدد محدِّدات التنسيق الموجودة في السلسلة، لا أكثر.
مثال محلول: المراجعة بمساعدة الذكاء الاصطناعي كطبقة إضافية
بعد أن يعمل ft_printf فعليًا، المشروع الثاني يضيف خطوة مراجعة: تشغيل أداة ذكاء اصطناعي على الكود المكتوب فعليًا (لا توليد كود جديد) لاكتشاف مشاكل قد تفوتك:
| ما تطلبه من المراجع الذكي | لماذا مفيد |
|---|---|
| "هل هناك حالة محدِّد تنسيق لم أتعامل معها؟" | يكتشف فجوات في التغطية (مثل %x أو %u) لم تفكر فيها |
| "هل استخدامي لـva_arg متوافق مع الأنواع المتوقَّعة؟" | يفحص تطابق النوع المطلوب بمحدِّد التنسيق مع نوع va_arg الفعلي |
| "هل يوجد تسريب ذاكرة أو نسيان va_end؟" | مراجعة سريعة لأخطاء شائعة قد تفوت القراءة السريعة للكود |
تعمّق: الوسائط المتغيرة، تصميم printf، ونقد المراجع الآلي
يجمع هذا الجزء مشروعين: بناء printf ثم مراجعة شيفرتها بمساعدة الذكاء الاصطناعي. نفهم أولًا كيف تعمل الدوال ذات الوسائط المتغيرة وماذا يحدث داخل printf، ثم كيف نقرأ نقد الذكاء الاصطناعي بعين ناقدة.
أولًا: الدوال ذات الوسائط المتغيرة
دالة مثل _printf(const char *format, ...) تستقبل عددًا غير معروف من الوسائط بعد الوسيط الثابت. لا تحمل الدالة أي معلومة عن عددها أو أنواعها؛ سلسلة التنسيق هي العقد الوحيد: كل %d يعدك بأن الوسيط التالي عدد صحيح. إن كذبت (قلت %d وأعطيت سلسلة نصية) فالنتيجة سلوك غير معرّف. وتعمل الأدوات الأربع في stdarg كالتالي: va_start تبدأ القراءة بعد آخر وسيط ثابت، va_arg تأخذ الوسيط التالي بالنوع الذي تحدده أنت، va_copy تنسخ حالة القراءة، و va_end تنظّف في النهاية.
شكل 3، دورة printf الأساسية: امشِ على السلسلة، وجّه كل حرف أو محدِّد إلى معالجه، وعدّ ما طُبع.
ثانيًا: قرارات التصميم
🔢قيمة الإرجاع
عدد الأحرف المطبوعة بالضبط؛ كل معالج يعيد كم كتب ويجمعها المنسِّق.
🗂️جدول التوجيه
ربط كل محدِّد بمعالجه يجعل إضافة محدِّد جديد تعديلًا صغيرًا بدل تعديل شرط ضخم.
🧯الحالات الحدّية
محدِّد غير معروف، علامة % في آخر السلسلة، سلسلة فارغة، وسلسلة NULL: ما لم يُحدَّد فافعل ما تفعله printf الحقيقية.
🚰التخزين المؤقت والأداء
كل نداء write يكلّف انتقالًا إلى النواة؛ تجميع الأحرف في مخزن ثم كتابتها دفعة واحدة أسرع بكثير، وإن كانت المشروع لا يشترط محاكاة مخزن المكتبة.
ثالثًا: الأعلام والعرض والدقة (نظرة مفهومية)
| الجزء | موقعه في المحدِّد | ماذا يفعل |
|---|---|---|
| أعلام | بعد % مباشرة | تغيّر الشكل: إظهار الإشارة، حشو بالأصفار، بادئة للقواعد |
| العرض | رقم بعد الأعلام | أقل عدد من الخانات يشغلها الناتج |
| الدقة | نقطة ثم رقم | عدد الأرقام أو أقصى طول لنص |
| معدِّل الطول | قبل الحرف النهائي | يحدد حجم النوع مثل long و short |
رابعًا: العمل في فريق ورسم المخططات
المخطط الانسيابي يحوّل الخوارزمية إلى لغة يفهمها الفريق كله قبل أن يكتب أحد سطرًا: مستطيل لخطوة، معيّن لقرار، شكل بيضاوي لبداية أو نهاية، وأسهم للتدفق. ارسمه قبل الشيفرة، وقسّموا العمل بحسب الدوال المتفق عليها في ملف الترويسة، وجرّبوا معًا على مجموعة اختبارات مشتركة.
خامسًا: الذكاء الاصطناعي كمراجع يخطئ
في مشروع مراجعة الشيفرة بالذكاء الاصطناعي لا يكون الهدف تحسين الشيفرة بل تقييم قدرة الأداة على التفكير في شيفرة C منخفضة المستوى. الأداة سريعة في الملاحظات الأسلوبية، لكنها تضعف في دلالات الذاكرة (من يملك المؤشر؟ كم يعيش؟)، وفي السلوك غير المعرّف، وفي القيود التي يفرضها المشروع نفسه، وقد تقترح تغييرات تخالف شروطًا مثل منع المتغيرات العامة أو قصر الدوال المسموحة.
| ادّعاء الأداة | كيف تفحصه |
|---|---|
| "هناك تسريب ذاكرة هنا" | تتبّع كل malloc إلى free بنفسك، ثم أكّد بأداة مثل Valgrind |
| "استخدم دالة كذا بدلًا من write" | هل هي ضمن الدوال المسموحة في المشروع؟ |
| "هذا السطر سلوك غير معرّف" | ابحث في المعيار أو صفحة الدليل ولا تقبل المصطلح بلا دليل |
| "الشيفرة ممتازة" | تذكّر ميل النماذج للمجاملة؛ اطلب الحالات الحدّية والافتراضات |
شكل 4، مراجعة الشيفرة بالذكاء الاصطناعي: الأداة تقترح والإنسان يتحقق ويحكم.
جرّب بنفسك
كيف تعرف printf عدد الوسائط وأنواعها؟
الدالة لا تحمل أي معلومة عن الوسائط سوى ما تعدك به محدِّدات التنسيق. إن كذبت (%d وأعطيت نصًا) فالسلوك غير معرّف.
لماذا تحتاج va_arg أن تُمرِّر لها النوع في كل استدعاء؟
الدالة ترى بايتات خام في الذاكرة فقط. النوع الذي تحدده يبيّن حجم القراءة وهل هي قيمة مباشرة أم عنوان.
استدعاء ft_printf بثلاثة محدِّدات. كم مرة تُستدعى va_arg في المسح؟
كل محدِّد مكتمل يسحب وسيطًا واحدًا بالنوع المطابق. الحروف العادية تُطبع مباشرة ولا تسحب شيئًا.
بطاقات مراجعة سريعة
تحقق من فهمك
لماذا نتحقق من format[i+1] عند رؤية % في الكود أعلاه، لا نكتفي بفحص format[i]؟
لتفادي قراءة خارج حدود السلسلة إن كانت % آخر حرف فيها، بدون هذا التحقق، format[i+1] قد يقرأ ذاكرة خارج المصفوفة المخصَّصة فعليًا.
أعطتك الأداة اقتراحًا يستخدم دالة غير مسموحة في مشروعك؛ ماذا تكتب في نقدك؟
أن الاقتراح يتجاهل قيود المشروع، وتذكر القيد بالنص، وتصنفه كمحدودية في فهم السياق.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي3
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.