ملخص
علّم الدرسان اللذان سبقا هذا الدرس ما الدالة وكيف تدخل بياناتها وتخرج؛ وهذا الدرس يعلّم كيف نقرّر أيّ الدوال نكتب. والطريقة هي التنقيح التدريجي: تُكتب main قصيرة تستدعي دوال لم توجد بعد، لكلٍّ منها عقدها، ثم ننزل إلى كل واحدة منها حتى نبلغ أجزاءً تُكتب في أسطر قليلة. ويضع الدرس معايير الواجهة الجيدة، وقاعدة «دالة واحدة، مهمة واحدة» مع العلامات التي تكشف مخالفتها، والتحميل الزائد الذي يعطي اسمًا واحدًا للمهمة نفسها على أنواع مختلفة. وبهذه المعايير يعيد كتابة خوارزميات الوحدة 4، التي كانت تعيش داخل main، ويختبر كلًّا منها على حدة. والمفهوم الذي يوحّد ذلك كله هو التفكيك من الأعلى إلى الأسفل: البرنامج الحسن التصميم يُقرأ كأنه مواصفاته نفسها.
أهداف التعلم
- تفكيك مسألة إلى مسائل فرعية بالتنقيح التدريجي وكتابة شجرة التفكيك.
- تصميم واجهة كل دالة (الاسم والوسائط والقيمة المُعادة والعقد) قبل كتابة جسمها.
- إعادة كتابة برنامج أحادي الكتلة من الوحدة 4 في صورة مجموعة من الدوال المتماسكة، واختبار كل واحدة على حدة بدالة
mainللاختبار. - الحكم على تصميم وفق قاعدة «دالة واحدة، مهمة واحدة» وغياب الآثار الخفية.
- تحميل دالة تحميلًا زائدًا، والتنبؤ بالنسخة التي يختارها كل استدعاء، وتفسير سبب كون استدعاءٍ ما ملتبسًا.
التنقيح التدريجي
من نص المسألة إلى main من أسطر قليلة
كُتبت برامج الوحدة 4 كاملةً داخل main. وما دامت تتسع لشاشة واحدة فذلك محتمل؛ غير أن برنامجًا من مئتي سطر مكتوبًا على هذا النحو يقتضي أن يحمل المرء كل شيء في رأسه دفعة واحدة، ولا يقبل إلا اختبار المجموع. أما التنقيح التدريجي، أو التصميم من الأعلى إلى الأسفل، فيقلب ترتيب العمل: يُكتب أولًا ما يفعله البرنامج، بلغة المسألة، في صورة سلسلة من الاستدعاءات لدوال لم توجد بعد؛ ثم تؤخذ كل واحدة من تلك الدوال مسألةً جديدة أصغر، ويُكرَّر الإجراء حتى نبلغ دوال بسيطة إلى حدّ أن جسمها يُكتب من غير تفكيك شيء.
والمسألة الآتية مثال على ذلك. يكون العدد الصحيح تامًّا إذا كان مجموع قواسمه الفعلية (القواسم الموجبة المختلفة عن ) مساويًا لـ ، وناقصًا إذا كان أصغر، وزائدًا إذا كان أكبر: ، فالعدد 28 تام؛ و8، مع ، ناقص؛ و12، مع ، زائد. والمطلوب برنامج يقرأ ويكتب مجموع قواسمه الفعلية وفئته. والمستوى الأول من التصميم هو main، ويُكتب قبل كل شيء:
#include <cstdint>#include <iostream>#include <print>#include <string>enum class Kind { deficient, perfect, abundant };// مجموع القواسم الموجبة لـ n.// الشرط المسبق: n >= 1.// الشرط اللاحق: يُعيد مجموع كل القواسم الموجبة لـ n، ومنها n نفسه.std::int64_t divisorSum(int n);// فئة n بحسب مجموع قواسمه الفعلية.// الشرط المسبق: n >= 1.// الشرط اللاحق: يُعيد deficient أو perfect أو abundant بحسب كون المجموع أصغر من n أو مساويًا له أو أكبر منه.Kind classify(int n);// اسم الفئة، لكتابته.// الشرط المسبق: لا شيء.// الشرط اللاحق: يُعيد «ناقص» أو «تام» أو «زائد».std::string kindName(Kind kind);int main(){ int n{}; std::cin >> n; std::println("القواسم الفعلية للعدد {}: مجموعها {}", n, divisorSum(n) - n); std::println("{} عدد {}", n, kindName(classify(n)));}/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':main.cpp:27:(.text+0x22a): undefined reference to `divisorSum(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x3fd): undefined reference to `classify(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x40d): undefined reference to `kindName[abi:cxx11](Kind)'collect2: error: ld returned 1 exit statusيُترجَم البرنامج دون أي تحذير، لأن كل استدعاء يوافق تصريحه، وهذا وحده ما يتحقق منه المترجم، كما علّم الدرس الدوال. أما ما يفشل فهو الربط: يبحث الرابط عن التعريفات فلا يجدها («undefined reference»، أي مرجع بلا تعريف؛ والإضافة [abi:cxx11] علامة داخلية يضعها GCC للدوال التي تُعيد std::string). وهذه الحال هي بالضبط حال التصميم عند هذه النقطة: الواجهات مقرَّرة، والأجسام لا. وإذا كان الأمر كذلك، فإن main تُقرأ منذ الآن على أنها مواصفات البرنامج، والتصريحات الثلاثة هي المهام المعلّقة، وكل واحدة منها أصغر من المسألة الأصلية.
شجرة التفكيك
ينزل المستوى الثاني إلى كل تصريح. فـ kindName قرار من ثلاث حالات. وclassify تستند إلى divisorSum: يكون العدد تامًّا حين يكون مجموع كل قواسمه . وdivisorSum هي حلقة القواسم في أزواج من الدرس الخوارزميات العددية، التي تتوقف عند . وتُرسم النتيجة شجرةً: جذرها المسألة كاملة؛ وأبناء كل عقدة المسائل الفرعية التي تحلّها؛ وأوراقها عمليات توفّرها اللغة أصلًا.
يبسط الرسم الشجرة مستوًى بعد مستوى، بالترتيب الذي صُمّمت به.
البرهان
المستوى الأول هو المسألة كلها، وهي على عاتق
main، التي لم تُفكَّك بعد.المستوى الثاني هو
mainذات العمليات الخمس: القراءة والجمع والتصنيف والتسمية والكتابة. كل عقدة استدعاء؛ استدعاءات المكتبة موجودة أصلًا، والبقية مصرَّح بها ومعلّقة.المستوى الثالث يفكّك
classify، التي لا تفعل أكثر من استدعاءdivisorSumوالمقارنة. والدالة نفسها تظهر مرتين في الشجرة، وتُكتب مرة واحدة: إعادة الاستعمال تُرى في الشجرة قبل كتابة أي شيفرة.المستوى الأخير هو الأوراق: اختبار قابلية القسمة وحدّ الأزواج، وهما عمليتان من اللغة. وعند بلوغهما ينتهي التنقيح، إذ لا يبقى ما يُفكَّك.
ومع اكتمال الشجرة تُكتب التعريفات من الأسفل إلى الأعلى، بحيث تستند كل دالة إلى دوال كُتبت قبلها:
#include <cstdint>#include <iostream>#include <print>#include <string>enum class Kind { deficient, perfect, abundant };// مجموع القواسم الموجبة لـ n.// الشرط المسبق: n >= 1.// الشرط اللاحق: يُعيد مجموع كل القواسم الموجبة لـ n، ومنها n نفسه.std::int64_t divisorSum(int n){ std::int64_t sum{0}; for (int d{1}; d <= n / d; ++d) { if (n % d == 0) { sum += d; if (d != n / d) { sum += n / d; } } } return sum;}// فئة n بحسب مجموع قواسمه الفعلية.// الشرط المسبق: n >= 1.// الشرط اللاحق: يُعيد deficient أو perfect أو abundant بحسب كون المجموع أصغر من n أو مساويًا له أو أكبر منه.Kind classify(int n){ const std::int64_t proper{divisorSum(n) - n}; if (proper < n) { return Kind::deficient; } if (proper == n) { return Kind::perfect; } return Kind::abundant;}// اسم الفئة، لكتابته.// الشرط المسبق: لا شيء.// الشرط اللاحق: يُعيد «ناقص» أو «تام» أو «زائد».std::string kindName(Kind kind){ if (kind == Kind::deficient) { return "ناقص"; } if (kind == Kind::perfect) { return "تام"; } return "زائد";}int main(){ int n{}; std::cin >> n; std::println("القواسم الفعلية للعدد {}: مجموعها {}", n, divisorSum(n) - n); std::println("{} عدد {}", n, kindName(classify(n)));}28القواسم الفعلية للعدد 28: مجموعها 2828 عدد تامثلاث سمات في النتيجة تعود إلى الطريقة. أولاها أن main لم تتغير حين كُتب ما تحتها. والثانية أن المجموع يُعاد بالنوع std::int64_t (من <cstdint>، وهو العدد الصحيح ذو 64 بتًا الذي يستعمله أيضًا الدرس الاستدعاء الذاتي)، لأنه مع قريب من أكبر قيمة لـ int قد يتجاوزها مجموع قواسمه، واتخاذ القرار في الواجهة يحمي كل من يستدعي. والثالثة أن كل دالة تُختبر على حدة: يجب أن تعطي divisorSum(28) القيمة 56، وأن تعطي divisorSum(1) القيمة 1، وأن تعطي divisorSum(36) القيمة 91، حيث يُحسب الزوج مرة واحدة؛ والقسم الأخير من الدرس يحوّل هذه الاختبارات إلى برنامج.
جارٍ تحميل المحتوى…