الآلة الحاسبة البسيطة
درس 1 من 3
1🖩 الآلة الحاسبة البسيطة (C - Simple Calculator)
أول برنامج C "حقيقي" يقرأ مُدخلًا، يعالجه بمنطق متعدد الحالات، ويُخرج نتيجة، تمرين مثالي على ربط كل أساسيات المرحلة الأولى في برنامج واحد صغير.
ماذا تشاهد في الرسم: البرنامج يقرأ "3" و"+" و"4" من argv، ويحوّل الرقمين، ويختار العملية بـ switch، ثم يطبع 7.
طريقان إلى نفس الفكرة
الآلة الحاسبة أول برنامج C يجمع كل ما تعلّمته: يأخذ منك رقمين وعملية، يقرر ماذا يفعل حسب العملية، ثم يعطيك الجواب. وعليه أن يتصرف بأدب إن كتبت حرفًا بدل رقم أو حاولت القسمة على صفر، بدل أن ينهار.
تقرأ المدخل بـscanf أو fgets ثم strtod، وتتحقق من القيمة المعادة، وتختار العملية بـswitch، وتتعامل مع القسمة على صفر والمدخلات غير الصالحة. وتقسّم البرنامج إلى دوال صغيرة وتصرّفه بـ-Wall -Wextra لترى التحذيرات.
قبل أن تبدأ
⚙️أساسيات C
الشروط، argc/argv، وتحويل النصوص لأرقام (atoi) هي الأدوات الأساسية هنا.
الفكرة الجوهرية: تفكيك مسألة "احسب a عملية b"
آلة حاسبة بسيطة تستقبل ثلاثة أجزاء منفصلة من argv، رقم، ثم رمز عملية، ثم رقم آخر، وكلها نصوص خام بلا أي معنى حسابي بعد. المهمة الحقيقية هنا ليست "اجمع رقمين"، بل ثلاث قرارات متتالية ومستقلة: كيف تُقرأ هذه النصوص وتُحوَّل لقيم فعلية، كيف تُحدَّد أي عملية من بين أربع تُنفَّذ، وكيف تُنفَّذ تلك العملية دون أن تنهار في الحالة الواحدة التي قد تفشل فيها (القسمة على صفر). كل قرار من الثلاثة له طريقة فشل مختلفة تمامًا عن الآخرين، وهذا بالضبط سبب معاملتها بثلاث آليات مختلفة بدل حل واحد يغطي كل شيء.
ابدأ بالتوزيع: switch مقابل سلسلة if/else if. من الناحية الدلالية البحتة، لا فرق كبير بينهما هنا، كلاهما يُنفّذ فرعًا واحدًا فقط حسب قيمة op. الفرق الحقيقي في كيف يُسمح للمُصرِّف أن يُنفّذ ذلك القرار فعليًا. سلسلة if/else if تلزم المُصرِّف بتنفيذ كل مقارنة بترتيبها الكودي: للوصول لحالة / الرابعة، يجب أولًا فحص + ثم - ثم * والفشل في كل واحدة، تكلفة تتناسب خطيًا مع عدد الحالات الموجودة أمامها في الكود. أما switch فيُعطي المُصرِّف كل القيم الممكنة دفعة واحدة في بنية واحدة، فيصبح حرًا في اختيار طريقة التفريع الأنسب لتلك المجموعة تحديدًا: مقارنة متتابعة قصيرة إن كانت القيم قليلة ومتفرّقة (وهذا تحديدًا وضع أربعة رموز عمليات متناثرة كـ+ - * /)، أو شجرة بحث ثنائي إن كانت القيم كثيرة، أو، في الحالة المثالية حيث تكون قيم case متقاربة ومتسلسلة رقميًا، جدول قفزات (Jump Table) يُستخدَم فيه الرقم نفسه كفهرس مباشر لعنوان الكود المطلوب تنفيذه، بلا أي مقارنة إطلاقًا. أي من هذه الأسلوبين يُستخدَم فعليًا قرار داخلي للمُصرِّف لا يظهر في الكود المصدري أبدًا؛ ما يضمنه معيار C فقط هو السلوك الملاحَظ: أول حالة (case) تطابق قيمة op هي التي يبدأ التنفيذ منها.
من هذه النقطة فصاعدًا، التنفيذ تسلسلي صريح لا ضمني: الكود بعد علامة case المطابقة يستمر بالتنفيذ عاديًا سطرًا بعد سطر، لا "عودة" تلقائية لبداية switch، حتى يصطدم بعبارة break (التي تقفز فورًا لما بعد الـswitch بالكامل) أو بنهاية الـswitch نفسها. هذا يعني أن حذف break سهوًا لا يعيد تنفيذ الحالة الحالية، بل "يتسرّب" (Fall-through) فينفّذ أيضًا كود الحالة التالية في الترتيب الكودي، بصرف النظر عن قيمة op الفعلية، خطأ صامت تمامًا لأن المُصرِّف لا يعتبره خللًا بل سلوكًا مقصودًا ومسموحًا به في اللغة. و default ليست "حالة أخيرة" في ترتيب الفحص، بل مسار يُنفَّذ فقط حين تفشل كل حالات case المذكورة في المطابقة، تمامًا كما كان سيحدث لو وصلت سلسلة if/else if لجملة else النهائية بعد فشل كل شروطها.
بعد تحديد العملية، يبقى تحويل النصين الآخرين لأرقام فعلية عبر atoi. المهم هنا ليس فقط "أنها تحوّل نصًا لرقم"، بل كيف تتصرف عند الفشل: atoi لا ترفع أي خطأ ولا تُعلمك بأي طريقة أن التحويل فشل، إن أعطيتها نصًا لا يبدأ برقم صالح أصلًا (كـ"abc")، فستُعيد 0 بصمت كامل، وهي بالضبط القيمة نفسها التي ستحصل عليها لو كتب المستخدم "0" فعليًا. بعد هذا الاستدعاء، برنامجك لا يملك أي وسيلة للتمييز بين "المستخدم كتب صفرًا حقيقيًا" و"المستخدم كتب نصًا غير رقمي فتحوّل تلقائيًا لصفر"، وهذا تحديدًا سبب وجود دوال أخرى في مكتبة C القياسية مثل strtol التي تمنحك مؤشر نهاية صريحًا لتتحقق منه، لكنها خارج نطاق هذا المشروع البسيط.
وأخيرًا، حالة القسمة على صفر تحديدًا ليست مجرد "خطأ منطقي" مثل أي خطأ آخر، هي سلوك غير مُعرَّف رسميًا في معيار لغة C نفسه (الفقرة 6.5.5 من المعيار تنص صراحة على ذلك). وهذا التصنيف ليس تفصيلًا نظريًا: على المعالجات الشائعة التي تُصرِّف وتُشغِّل كودك عليها فعليًا (معالجات x86/x86-64 التي تعمل عليها بيئة لينكس)، تعليمة القسمة الصحيحة في المعالج نفسها (idiv) هي التي تكتشف المقام الصفري كخطأ عتادي (Hardware Fault)، ليس برنامجك هو من "يلاحظ" الخطأ، بل المعالج نفسه يرفض تنفيذ التعليمة ويرفع استثناءً عتاديًا، تترجمه نواة النظام فورًا لإشارة SIGFPE تُرسَل لعمليتك؛ وبما أن برنامجك لم يُسجّل أي معالج (Handler) لهذه الإشارة، فإن الإجراء الافتراضي لها هو إنهاء العملية فورًا، وغالبًا ما يطبع الصدفة (Shell) رسالة مثل "Floating point exception" رغم أن الخطأ عملية صحيحة لا عشرية إطلاقًا. والأهم: هذا السلوك قد يختلف فعليًا بين مُصرِّف ومعالج ومستوى تحسين (Optimization)، فلا يوجد "سلوك متوقَّع" واحد يمكن الاعتماد عليه مطلقًا، وهذا تحديدًا ما يجعل الحراسة المسبقة الطريقة الوحيدة المعقولة: لا يوجد "بعد" ذو معنى تعالج فيه الخطأ لاحقًا، لأن العملية قد تُنهى فورًا ودون إشعار برمجي عادي يمكن اعتراضه بسهولة. هذا هو السبب الحقيقي الذي يجعل ترتيب الفحوصات في المثال التالي مهمًا: كل حارس يمنع فئة فشل مختلفة تمامًا، في اللحظة الوحيدة التي يكون فيها ذلك الفشل ممكنًا فعليًا، لا قبلها ولا بعدها.
شكل 1، switch يقارن قيمة op بكل حالة بالتتابع حتى يجد تطابقًا، فينتقل مباشرة لتنفيذ ذلك الفرع فقط دون غيره؛ عدم التطابق مع أي حالة مذكورة يقود تلقائيًا لـdefault.
مثال محلول: هيكل آلة حاسبة كاملة
int main(int argc, char *argv[])
{
int a, b, result;
char op;
if (argc != 4)
{
printf("Usage: %s number operator number\n", argv[0]);
return (1);
}
a = atoi(argv[1]);
op = argv[2][0]; /* أول حرف من النص، مثلًا argv[2]="+" */
b = atoi(argv[3]);
switch (op)
{
case '+': result = a + b; break;
case '-': result = a - b; break;
case '*': result = a * b; break;
case '/':
if (b == 0)
{
printf("Error: division by zero\n");
return (1);
}
result = a / b;
break;
default:
printf("Error: unknown operator '%c'\n", op);
return (1);
}
printf("%d\n", result);
return (0);
}
شكل 2، لاحظ ترتيب التحقق: عدد الوسائط أولًا، ثم صحة العملية، ثم حالة القسمة على صفر تحديدًا قبل تنفيذها: كل حارس يمنع حالة فشل محتملة بالضبط في مكانها.
argc قبل الوصول لـargv[1] أو argv[3]. الوصول لعنصر argv غير موجود فعليًا (لأن المستخدم مرّر وسائط أقل من المتوقَّع) هو سلوك غير مُعرَّف خطير.
تعمّق: منهج المشروع، وآلية scanf، والحالات الحدّية
مشروع الآلة الحاسبة اختبار لنضجك البرمجي أكثر منه مشروع حساب. نفصّل هنا ثلاثة أمور: طريقة التفكير المنهجية في أي مشروع، وكيف تتعامل scanf مع الإدخال فعليًا، وما الذي تعنيه إعدادات المترجم الصارمة.
أولًا: إطار التعامل مع أي مشروع
أكبر خطأ مبكر هو فتح المحرر قبل أن تعرف ما المطلوب بالضبط. الإطار الذي تعتمده المدرسة يؤخر الكتابة على الحاسوب حتى تتأكد على الورق: اقرأ المطلوب، أعد صياغته بكلماتك، تحقق من السلوك المتوقع بأمثلة مدخلات ومخرجات، ثم صمّم الحل بالكلمات، فبالشيفرة الوهمية، فبالشيفرة، وفي كل مرحلة "نفّذ كأنك آلة": افعل فقط ما كُتب. بعدها فقط تكتب وتختبر وتصحح. وقسّم المشروع لأجزاء صغيرة تعيد عليها الدورة، وعدّل ثم اختبر جزءًا واحدًا في كل مرة.
شكل 3، إطار التعامل مع المشروع: الورق أولًا ثم الحاسوب، وجزء صغير في كل دورة.
ثانيًا: كيف تتعامل scanf مع الإدخال؟
ما تكتبه في الطرفية لا يذهب مباشرة إلى متغيراتك؛ يُخزَّن أولًا في مخزن إدخال (stdin) ثم تقرأ منه scanf. تقرأ scanf بحسب صيغة التحويل (مثل %d للعدد الصحيح)، فتتجاوز المسافات البيضاء ثم تستهلك الأحرف التي تصلح لهذا النوع وتتوقف عند أول حرف لا يصلح. وتعيد عدد العناصر التي قرأتها بنجاح. هذه القيمة المُعادة هي مفتاح كل تحقق: إن طلبتَ عنصرًا واحدًا وعادت 1 فقد نجحت، وإن عادت 0 فقد فشلت المطابقة، وإن عادت EOF فقد انتهى الإدخال.
شكل 4، سبب الحلقة اللانهائية الشهيرة: scanf لا تستهلك ما لم يطابق، فيبقى في المخزن.
وهذا بالضبط ما يطلبه التحدي الاختياري: ابحث عما يبقى في stdin بعد الفشل، وابتكر استراتيجية لتجاهل المتبقي حتى نهاية السطر، ثم أعد المحاولة. الفكرة العامة (دون حل جاهز): بعد أي فشل، استهلك الأحرف حتى نهاية السطر قبل أن تسأل المستخدم من جديد.
ثالثًا: الحالات الحدّية في الحساب
| الحالة | ما يحدث | كيف تفكر |
|---|---|---|
| القسمة على صفر | سلوك غير معرّف للأعداد الصحيحة وقد ينهار البرنامج | افحص المقسوم عليه قبل القسمة |
| القسمة الصحيحة | 7 ÷ 2 تعطي 3 لأن الكسر يُقتطع | قرّر وثّق: أعداد صحيحة أم عشرية؟ |
| الأعداد العشرية | 0.1 + 0.2 قد لا تساوي 0.3 تمامًا | الأعداد العشرية تقريبية في الذاكرة |
| الفيض | تجاوز أكبر عدد صحيح يعطي نتيجة غير صحيحة | اعرف حدود النوع واذكرها في README |
رابعًا: ماذا تقول أعلام المترجم؟
| العلم | الفكرة |
|---|---|
-Wall / -Wextra | فعّل أغلب التحذيرات المفيدة |
-Werror | حوّل كل تحذير إلى خطأ يمنع الترجمة |
-pedantic | التزم بالمعيار حرفيًا |
-std=gnu89 | نسخة قديمة من اللغة: عرّف المتغيرات في بداية الكتلة |
التحذير ليس إزعاجًا بل مراجعة مجانية: مترجم صارم يكشف أخطاء كانت ستظهر لاحقًا كأعطال غامضة.
جرّب بنفسك
ماذا تعيد scanf عندما تطلب منها قراءة عدد صحيح واحد ويكتب المستخدم حروفًا؟
scanf تعيد عدد العناصر التي قرأتها بنجاح. 0 تعني فشل المطابقة، وما لم يطابق لا تستهلكه فيبقى في stdin وهو سبب الحلقة اللانهائية الشهيرة.
أي ما يلي يضمنه معيار C عن switch؟
طريقة التفريع (مقارنات أو شجرة أو جدول قفزات) قرار داخلي للمُصرِّف. المضمون هو السلوك: البدء من الحالة المطابقة ثم الاستمرار حتى break.
لماذا تُعامَل القسمة على صفر بحالة خاصة؟
القسمة الصحيحة على صفر سلوك غير معرّف، فنفحص المقسوم عليه قبل أن ننفّذ العملية.
بطاقات مراجعة سريعة
تحقق من فهمك
لو مرّر المستخدم عملية غير معروفة مثل "%"، ماذا يحدث في الكود أعلاه؟
تدخل حالة default في switch، فتُطبَع رسالة خطأ واضحة ويُعاد رمز فشل (1) بدل تنفيذ عملية غير معرَّفة أو الانهيار الصامت.
لماذا نؤجل الكتابة على الحاسوب في إطار المشروع؟
لأن اكتشاف الخطأ على الورق أرخص بكثير من اكتشافه داخل شيفرة كبيرة.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي2
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.