الأسبوع 4: الدوال
🎯 أهداف التعلّم
بنهاية هذا الأسبوع ستكون قادرًا على:
- تعريف دالة باستخدام
def، والمعاملات، وقيمةreturn. - شرح الفرق بين القيمة الافتراضية لمعامل والوسيط المُمرَّر وقت الاستدعاء.
- التفكير في نطاق (scope) المتغيرات: ما تستطيع الدالة رؤيته وما لا تستطيع من خارجها.
- إعادة أكثر من قيمة واحدة من دالة، وتوثيق ما تفعله الدالة بـ docstring.
- التعرّف على العودية (recursion)، ومعرفة متى تعكس تعريفًا رياضيًا بشكل طبيعي.
الدرس
الدوال كـ
أنت تقرأ بالفعل كـ"قاعدة تأخذ رقمًا وتُعيد رقمًا آخر". دالة بايثون هي بالضبط ذلك:
def f(x):
return x**2 + 1
f(3) # 10
f(0) # 1
تُسمّي def الدالة ومعاملاتها؛ يحسب الجسم نتيجة؛ ترسل return تلك النتيجة إلى المستدعي. الدالة بلا جملة return تُعيد ضمنيًا None.
من الممارسات الجيدة توثيق ما تفعله الدالة بـ docstring — نص حرفي مباشرة بعد سطر def، يمكن للأدوات (وللمبرمجين الآخرين، بمن فيهم أنت في المستقبل) قراءته دون فتح جسم الدالة:
def f(x):
"""Return x squared, plus one."""
return x**2 + 1
معاملات متعددة، وقيم افتراضية
يمكن للدوال أن تأخذ عدة مدخلات، بعضها بقيم افتراضية — مكافئ لكن حيث تُفترض قيمة لـ إن لم يوفّرها المستدعي:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
greet("Amina") # "Hello, Amina!"
greet("Youssef", "Hi") # "Hi, Youssef!"
greet(name="Sara", greeting="Hey") # وسائط بالاسم (keyword arguments) — الترتيب لا يهم
يجب أن تأتي المعاملات ذات القيمة الافتراضية بعد المعاملات بدونها — def greet(greeting="Hello", name): تُعتبر SyntaxError، لأن بايثون تحتاج معرفة أي الوسائط مطلوبة قبل أن تحدد أيها اختياري.
إعادة أكثر من قيمة
يمكن للدالة return شيئًا واحدًا فقط، لكن ذلك "الشيء الواحد" يمكن أن يكون tuple — وصياغة فك تغليف الصفوف في بايثون (من الأسبوع 3) تجعل هذا يُقرأ وكأنه إعادة عدة قيم مباشرة:
def min_and_max(numbers):
return min(numbers), max(numbers) # هذا يبني صفًا: (min, max)
lowest, highest = min_and_max([4, 8, 1, 9, 3])
print(lowest, highest) # 1 9
هذا هو نفس النمط الذي ستستخدمه طوال بقية الدورة كلما أنتج حساب أكثر من نتيجة مترابطة بشكل طبيعي.
النطاق (Scope): ما تستطيع الدالة رؤيته
المتغيّر المُنشأ داخل دالة موجود فقط بداخلها — هذا هو النطاق المحلي (local scope):
def compute():
total = 42
return total
compute()
print(total) # NameError: total موجود فقط داخل compute()
يمكن للدالة قراءة متغيرات معرَّفة خارجها (النطاق العام، global scope)، لكن إعادة إسناد متغيّر عام من داخل دالة دون صياغة خاصة يُنشئ متغيرًا محليًا جديدًا بدلًا من ذلك — مصدر شائع للحيرة:
counter = 0
def increment():
counter = counter + 1 # UnboundLocalError! تعتبر بايثون counter محليًا
# في كل الدالة بمجرد رؤية إسناد له،
# حتى عند القراءة قبل الإسناد.
الحل (global counter) موجود لكنه نادرًا ما يكون التصميم الصحيح — يُفضَّل تمرير القيم كمدخلات وإعادة النتائج كمخرجات، وهو أيضًا أسهل في الاختبار والتفكير فيه.
الدوال التي تستدعي دوالًا
بما أن الدالة مجرد قيمة مثل أي قيمة أخرى، يمكن للدوال استدعاء دوال أخرى، وبناء تعقيد من قطع صغيرة — بنفس الطريقة التي تُركّب بها دالتين:
def double(x):
return x * 2
def add_one(x):
return x + 1
def double_then_add_one(x):
return add_one(double(x))
double_then_add_one(3) # double(3)=6, add_one(6)=7
العودية (Recursion): دالة تستدعي نفسها
بعض الدوال تُعرَّف بشكل أطبع من خلال نفسها — تمامًا مثل علاقة تكرارية. المضروب (factorial)، مع الحالة الأساسية ، يُترجَم شبه حرفيًا:
def factorial(n):
if n == 0:
return 1 # الحالة الأساسية — توقف العودية
return n * factorial(n - 1) # الحالة العودية
factorial(5) # 5 * factorial(4) = 5 * 4 * factorial(3) = ... = 120
تحتاج كل دالة عودية إلى حالة أساسية لا تستدعي نفسها (وإلا ستتكرر إلى الأبد، لتنهار في النهاية بـ RecursionError) وحالة عودية تقترب أكثر من تلك الحالة الأساسية في كل استدعاء — هنا، n - 1 تتقلص نحو 0 في كل مرة. أي شيء تستطيع العودية فعله، يمكن لحلقة فعله أيضًا (وغالبًا بكفاءة أعلى، لأن كل استدعاء عودي له تكلفة إضافية) — لكن بالنسبة للتعريفات العودية طبيعيًا بالفعل، مثل المضروب أو خط أنابيب معالجة CSV في الأسبوع 5، يمكن أن تكون النسخة العودية هي الأوضح للقراءة.
⚠️ أخطاء شائعة
- نسيان
return. الدالة التي تحسب قيمة لكن لا تُعيدها أبدًا تُعطيNone— تصبحresult = add(2, 3)بصمتNoneإن نسيتaddجملةreturnالخاصة بها، وغالبًا لا يظهر الخطأ إلا لاحقًا عندما تحاول استخدامresult. - استخدام وسيط افتراضي قابل للتغيير.
def add_item(item, items=[]):تبدو معقولة، لكن تلك القائمة الافتراضية تُنشأ مرة واحدة فقط، عند تعريف الدالة، وتُعاد استخدامها في كل استدعاء لا يوفّرitemsخاصة به — يمكن لعناصر من استدعاء واحد أن تتسرب إلى استدعاء آخر. الحل: اجعل الافتراضيNoneوأنشئ قائمة جديدة داخل الدالة عند الحاجة. - عدم وجود حالة أساسية في دالة عودية. نسيان
if n == 0: return 1فيfactorialيعني أن كل استدعاء يتكرر مجددًا، بلا نهاية، حتى تستسلم بايثون بـRecursionError: maximum recursion depth exceeded. - حجب اسم مدمج. تسمية دالتك الخاصة
sumأوlistيعمل، لكنه يحجبsum()/list()الحقيقيتين في بايثون لبقية ذلك الملف — خطأ محيّر يصعب تتبعه لاحقًا.
🧩 تحديات
اكتب دالة is_even(n) تُعيد True إن كان n زوجيًا، وFalse خلاف ذلك.
اكتب دالة average(numbers) تأخذ قائمة أرقام وتُعيد متوسطها. ماذا يحدث إن استدعيتها بقائمة فارغة؟
حوّل منطق "هل هذا العدد أولي؟" من الأسبوع الماضي إلى دالة is_prime(n)، ثم استخدمها داخل list comprehension لبناء قائمة كل الأعداد الأولية تحت 50.
اكتب دالة f(x, y=1) تُعيد x + y. استدعها مرة بـx فقط ومرة بكلا الوسيطين، واشرح لماذا تختلف النتيجتان.
اكتب دالة عودية fibonacci(n) تُعيد الحد رقم n من متتالية فيبوناتشي، باستخدام التعريف ، ، .
اكتب دالة stats(numbers) تُعيد ثلاث قيم دفعة واحدة — الأدنى، الأعلى، والمتوسط — واستدعها باستخدام فك تغليف الصفوف لالتقاط الثلاثة في متغيرات منفصلة.
🤔 أسئلة سقراطية
- تستخدم دالتان متغيرًا باسم
totalداخليًا. هل تتداخلان مع بعضهما؟ لماذا أو لماذا لا، بناءً على ما تعلمته عن النطاق؟ - لماذا تُنهي
returnالدالة فورًا، حتى لو كان هناك كود إضافي بعدها؟ جرّب كتابة دالة بكود لا يمكن الوصول إليه بعدreturnوانظر ماذا يفعل الملعب البرمجي معها. - تُركّب
double_then_add_oneبينdoubleوadd_one. هل يمكنك كتابة دالة عامةcompose(f, g)تُعيد دالة جديدة تجمع بين أي دالتين؟ (لا تحتاج classes لهذا — يمكن للدالة إعادة دالة أخرى.) - تحسب
factorialالعودية ونسخة تكرارية باستخدام حلقةforنفس النتيجة. جرّب توقيت كليهما على مدخل كبير (مثلfactorial(900)) — هل تلاحظ فرقًا؟ ما الذي تعتقد أنه يحدث في كل استدعاء عودي ولا تحتاجه دورة الحلقة؟ - يحدث خطأ الوسيط الافتراضي القابل للتغيير لأن القيمة الافتراضية تُنشأ مرة واحدة فقط، وقت تعريف الدالة، وليست جديدة في كل استدعاء. لماذا قد تُصمَّم بايثون بهذه الطريقة، بدلًا من إعادة إنشاء القيمة الافتراضية في كل استدعاء (مما يتجنب الفخ لكن يكلّف عملًا إضافيًا بسيطًا في كل مرة)؟
✅ اختبار الأسبوع
✅ اختبار الأسبوع
🎁 إضافي: معالجة الأخطاء بـ try/except
يُتاح بعد اجتيازك اختبار هذا الأسبوع.