البرمجة بالإجراءات

من أنماط البرمجة أن يقبل الإجراءُ إجراءً في عوامله أو يُنتج هو إجراءً جديدًا. والتعبير عن ذلك بالبرمجة يسمى البرمجة الإجراءية (Functional Programming).

صناعة الإجراء

من خصائص الإجراءت في بايثون أنها أشياء (مثلها مثل الرقم) يُمكن إرجاعُها عند return. فمثلاً إجراء make_power هو إجراءٌ مُنشئٌ للإجراءت التي تحسِب القوى. المتغير n هو متغير محلي للإجراء make_power، ويمكن أن يستعمل في الإجراء power الذي يتم إرجاعُه:

def make_power(n):
    def power(x):
        return x ** n
    return power

فيُمكن إنشاء إجراء تربيعي وإجراء تكعيبي منه، على هذا النحو:

square = make_power(2)
cube = make_power(3)

ثم يُمكن طلب هذا الإجراء من خلال القوسين ()، كما يلي:

print("Squares:", square(2), square(3))
print("Cubes:", cube(2), cube(3))
Squares: 4 9
Cubes: 8 27

وإليك نفس الدالة، لكن الآن بذكر الأنواع في تعريفها:

def make_power(n: float) -> callable:
    def power(x: float) -> float:
        return x ** n
    return power

أما callable فمعناه الشيء الذي يقبلُ النداء؛ ويتحقق النداء في بايثون بالقوسين (). ومما يقبَلُ النداء: الإجراءت.

والإجراء العالي (higher-order function): هو الإجراء الذي يقبلُ إجراءً أو يُنتِجُ إجراءً.

تعريف الإجراء من غير اسم

وقد تتم بالإجراء النكرة(lambda) الذي لا اسم له. ويجب حينها وضع جملة واحدة، هي الجملة التي يعود بها الناتج لوجود return مستترة.

وهو على هذا النحو:

lambda parameters: returned_expression

فيجوز أن تكتب:

square = lambda x: x * x

فقد تمَّ تعيين الإجراء المُنكَر إلى متغيِّر. ثم يجري استعماله هكذا:

square(3)
9

فيمكننا أن نعرِّف الإجراء الذي في الداخل بلا اسمٍ فنختصر العبارة هكذا:

def make_power(n):
    return lambda x: x ** n

انظر: مرجع الإجراء المؤجل للمزيد.

الإجراء ذو الحال

الحال (state) هي جميع المتغيرات التي في نطاق الإجراء.

فإذا أردنا أن يكون للإجراء متغيِّرًا يبني عليه معالجته اللاحقة بمعالجته السابقة، فإننا نضعه في نطاق الإجراء المنشئئ على النحو التالي. ونمثل على ذلك بإنشاء العداد make_counter:

def make_counter(start: int, step: int) -> callable:
    count = start
    def counter() -> int:
        nonlocal count
        count += step
        return count
    return counter

ففي القطعة السابقة:

  • الإجراء المُنشئ هو make_counter يقبل عاملين يحكُمان سلوك الإجراء الذي يتم إنشاؤه: counter
  • تبدأ قيمة count بالقيمة المُعطاة start
  • وفي تعريف counter() نلاحظ كلمة nonlocal التي تربط الاسم count بالمتغير الذي في النطاق الأعم، أي: نطاق الإجراء make_counter؛ وذلك حتى يُمكنه تغييره. فلولا هذه الكلمة لم يُمكن تغييره.
  • نلاحظ زيادة count بالقيمة المعطاة step في إجراء الإنشاء
counter = make_counter(10, 2)

x = counter()
print(x)

y = counter()
print(y)
12
14

الإجراء المغيِّر للإجراءات

المزيِّن (decorator) هو الإجراء الذي يأخذ إجراءً لينتج إجراءً جديدًا منه. وشكله العام نحو:

def decorator(func: callable) -> callable:
    def wrapper(*args, **kwargs):
        print("Before function execution")
        result = func(*args, **kwargs)
        print("After function execution")
        return result
    return wrapper

مثلاً: يُمكننا أن نحفظ نتيجة نداء الإجراء بمدخلات معيَّنة، حتى إذا ما تكرر الطلب بنفس المدخلات؛ لا نحتاج إلى تنفيذ الإجراء بتفاصيله حقيقةً، وإنَّما نعيد القيمة التي سبق حفظها. وهذا يفيد في أمرين:

  1. الاقتصاد في موارد المعالجة
  2. تقليل وقت الاستجابة

ويُسمَّى الإجراء الذي يحفظ مدخلاته؛ إجراءً حافظًا (memoized). ويشترط فيه أن يكون الإجراء نقيًّا؛ وهو الإجراء الذي لا تعتمد مخرجاته إلا على مدخلاته المباشرة.

def memoize(f: callable) -> callable:
    cache = {}
    def memoized(x):
        if x in cache:
            print("Cache hit!")
            return cache[x]
        else:
            print("miss!")
            result = f(x)
            cache[x] = result
            return result
    return memoized

فهذا الإجراء يُنشئُ إجراءً من إجراء.

  1. أنشأنا في البداية cache قاموسًا لحفظ نتائج الإجراء .. أيًّا ما كان الإجراء.
  2. عرفنا إجراءً جديدًا يسمَّى memoized يحسب نتيجة الإجراء المُعطى f بالمدخل x، ويحفظها في cache إذا لم يكن موجودًا.

فلو أن لدينا إجراءً يأخذ مدخلاً واحدًا فقط، يُمكننا جعله إجراءً ذا حافظة، هكذا:

def square(x):
    return x ** 2

square = memoize(square)

print(square(2))
print(square(2))
print(square(3))
miss!
4
Cache hit!
4
miss!
9
  1. في أول مرة يرى فيها الإجراء x=2 فإنه يحسِبُ تلك القيمة ثم يحفظها ويرجعها
  2. أما في المرة الثانية عند x=2 فإنه سبق حفظها في cache؛ فلا نحتاج لتشغيل الإجراء square أصلاً، بل نسترجع النتيجة من cache
  3. أما القيمة الجديدة x=3 فإنها كالقيمة x=2 أول مرة

ولأن نمط التزيين شائع في البرمجة، فقد خصصت له بايثون علامة خاصة بها: @. ففي هذا المثال نزيِّن إجراء factorial بالإجراء memoize، ليكون ذا ذاكرة ولا يحتاج لحساب العمليات المتسلسلة أكثر من مرة:

@memoize
def factorial(n):
    if n == 0:
        return 1
    return n * factorial(n - 1)

بل إن بايثون توفر هذه الخاصية للإجراءت عن طريق المزيِّن @functools.lru_cache، الذي يمكن استيراده من المكتبة الأساسية functools. وهو الذي يجب استعماله في الواقع؛ إذ يقبل أن يكون الإجراء له أي عدد من المدخلات، وليس كما اقتصرنا فيه بالمثال السابق على عامل واحد (x).

فهكذا تكون:

import functools

@functools.lru_cache
def factorial(n):
    if n == 0:
        return 1
    return n * factorial(n - 1)

ومثل هذا يتسعمل كثيرًا:

الإطار / المكتبة المزخرف الاستخدام
functools @lru_cache التسريع: تخزين النتائج لتفادي تكرار الحسابات.
Python Core @property التغليف: التعامل مع الدالة كأنها متغير (Attribute).
Flask / FastAPI @app.route() التوجيه: ربط الرابط (URL) بدالة برمجية.
Django @login_required التحقق: قصر الوصول على المستخدمين المسجلين.
Click / Typer @click.command() واجهة أوامر: تحويل الدالة لأداة سطر أوامر.
Pytest @pytest.fixture الاختبار: إعداد بيئة بيانات الاختبار تلقائياً.
Celery @app.task مهام خلفية: تشغيل الدوال بشكل غير متزامن.