تعلّم البرمجة بلغة كوتلن (53): الدوال المحلية والمجهولة Local and Anonymous Functions

تعلّم البرمجة بلغة كوتلن (53): الدوال المحلية والمجهولة Local and Anonymous Functions
أستمع الى المقال

درسنا حتى الآن في سلسلة دروس تعلم لغة البرمجة كوتلن، أنواع عديدة من الدوال التي تقوم بمهام مختلفة. إذ يمكن عبر الدوال ذات المستوى الأعلى Top-Level Functions، تقسيم شفرة برنامجنا إلى أجزاء صغيرة قابلة لإعادة الاستخدام. أو نستخدم الدوال المُلحقة Extension Functions، لإضافة وظائف للأصناف التي تم إنشاؤها مسبقًا، ولانستطيع أو لا نريد تعديلها مباشرةً. ثم استفدنا من خاصيّة التحميل الزائد Overloading لتغيير عمل ومعاملات نفس الدالة مع الاحتفاظ باسمها كما هو.

كل السابق، يمكننا تطبيقه دون الغوص حتى في عالم نمط البرمجة الوظيفية (الداليّة)، الذي تعتمده لغة كوتلن بجانب نمط البرمجة الكائنية. وهو نمط تكون فيه الدوال، هي أساس كل شيء في البرنامج. 

وفي طريق دراستنا لنمط البرمجة الوظيفية في كوتلن، رأينا كيف يمكننا أن نتعامل مع الدوال ككائنات لديها نوع بيانات. أو أن نضع لها معامل Parameter من هذا النوع، ثم لاحقًا يمكن أن نرسل لهذا المعامل (من نوع بيانات الدالة)، تعابير اللامبدا أو تعابير مرجع الدوال.

وتزخر مكتبة كوتلن القياسية، بالعديد من الدوال التي لديها معامل من نوع بيانات الدالة، أي يمكن أن نرسل لها تعبير لامبدا أو تعبير مرجع لدالة. لذا لضمان الاستخدام الصحيح لهذه الدوال، تعرّفنا على كيفية إنشائها أساسًا، في درس الدوال ذات المرتبة الأعلى Higher-Order Functions.

أمّا في هذا الدرس، سنواصل دراسة تطبيقات كوتلن لنمط البرمجة الوظيفية، ونتعلم مفاهيم وطرق أخرى لاستخدام الدوال في شفرة كوتلن، وهي الدوال المحلية Local Functions والدوال المجهولة Anonymous Functions.

ولكن قبل أن ندلف إلى تعريف هاتين الدالتين، دعونا نتعرّف على مفهومين مهمّين في الدوال: المجال Scope والـ Closure.

المجال Scope:

عندما نعلن عن متغير ما، فسيكون هذا المتغير متوفرًا فقط في المجال الذي تم إعلانه به، ولا نستطيع استخدامه خارج هذا المجال. فمثلًا، إذا كان لدينا الشفرة التالية:

val globalVariable = 10

fun main() {
    val outerVariable = 10

    if (outerVariable > 0) {

        val innerVariable = 10
        println(globalVariable) // يمكن استخدام هذا المتغير هنا
        println(outerVariable) // يمكن استخدام هذا المتغير هنا
        println(innerVariable) // يمكن استخدام هذا المتغير هنا


    } else {
        println(globalVariable) // يمكن استخدام هذا المتغير هنا
        println(outerVariable) // يمكن استخدام هذا المتغير هنا
        println(innerVariable) // هذا السطر سينتج خطأ
    }
}

لدينا في الشفرة ثلاث متغيرات:

  • globalVariable: تم الإعلان عنه في أعلى الملف، لذلك يمكن لكل المكونات داخل الشفرة في هذا الملف الوصول إليه، سواء كانت دوال أو كتلة if و else وغيرها.
  • outerVariable: تم الإعلان عنه داخل دالة ()main، لا يمكن للمكونات خارج هذه الدالة الوصول إليه.
  • innerVariable: أمّا هذا المتغير، فتم الإعلان عنه داخل كتلة if. لذلك مجاله محصور داخل هذه الكتلة فقط، ولا يمكن لأي مكون آخر الوصول إليه، حتى ولو كان كتلة else التابعة لها.

مجال الدوال:

يمكن للدوال إلتقاط المتغيرات التي يتم الإعلان عنها في نفس المجال الذي تتواجد به. فإذا كان لدينا دالة أعلناها داخل الملف مباشرةً، مثل الدالة التالية:

fun func() {

    println(globalVariable) // يمكن استخدام هذا المتغير هنا
    println(outerVariable) // هذا السطر سينتج خطأ
    println(innerVariable) // هذا السطر سينتج خطأ

}

تستطيع الدالة func إلتقاط المتغير globalVariable، لأنهما يتواجدان في نفس المجال وهو الملف هنا. ولكنها لا تستطيع الوصول إلى المتغيرين اللذين تم الإعلان عنهما في دالة ()main. فإذا أردنا استخدام المتغيرين في الدالة func، يجب أن نضع لها معاملات تستقبل فيهما قيمة المتغيرين.

وحينما تلتقط الدالة متغير تم الإعلان عنه خارجها، ولكن في نفس مجالها، تسمى دالة Closure.

الدالة Closure:

الدالة Closure يمكن أن تكون دالة عادية كما في دالة func في الشفرة أعلاه، أو دالة لامبدا (أو حتى دالة محلية أو مجهولة، كما سنرى لاحقاً في هذا الدرس):

fun main() {
    var num = 0

    val lambda = { ++num }

    println(lambda())
}

لأن المتغير num وتعبير اللامبدا يتواجدان داخل نفس المجال وهو الدالة ()main، استطاعت اللامبدا إلتقاط المتغير وتغيير قيمته من صفر إلى 1، وهي القيمة التي سيتم طباعتها عند طباعة المتغير الذي تم إسناد اللامبدا إليه.

ملحوظة: في بعض لغات البرمجة، دوال اللامبدا هي ما تعرف بالدالة ال Closure. ولكن في كوتلن يختلف الوضع، إذ يمكن الإعلان عن دالة Closure وليست لامبدا والعكس صحيح.

الدوال المحلية Local Functions:

نستخدم الدوال عمومًا، لنتمكن من إعادة استخدام نفس الشفرة في أماكن كثيرة داخل مشروع البرنامج. ولكن، كثرة هذه الدوال نفسها قد يؤدي إلى مشكلة تشتت شفرة البرنامج، لوجودها في كل مكان داخل المشروع. لذا يمكننا كحل لهذه المشكلة، استخدام مفهوم الدوال المحلية (أو الدوال المتداخلة)، ووضع الدوال التي تنتمي لبعضها داخل دالة أخرى، وبذلك نكون قد حددنا لها المجال أو النطاق الذي تنتمي إليه.

إذًا الدوال المحلية هي الدوال التي نُنشئها عبر الكلمة fun ونضع لها اسم ثم نضعها داخل دالة أخرى. وتكون هذه الدالة، مرئية فقط داخل الدالة التي تحيط بها. تعمل الدوال المحلية على تقليل التكرار في التعليمات البرمجية، عن طريق وضع مقتطفات منها في دالة. أي أنها طريقة أخرى، لإعادة استخدام نفس الشفرة دون الاضطرار إلى التكرار.

إنشاء دالة محلية:

على الرغم من أن الدالة ()hello في الشفرة التالية تم إنشائها كأي دالة عادية، ولكن لأنها تتواجد داخل دالة أخرى وهي الدالة ()main، لذلك تعتبر دالة محلية، ومجالها هو الدالة ()main:

fun main() {
    
    val name = "ExVar"
    
    fun hello() = "Hello, $name"
    
    println(hello())
}

عند تنفيذ هذه الشفرة، سيتم طباعة Hello, ExVar. وذلك لأن الدوال المحلية، هي دوال Closure تستطيع إلتقاط المتغيرات في المجال المحيط بها. بشرط أن تكون هذه المتغيرات تم الإعلان عنها قبل الدالة، أي فوقها.

برنامج عملي:

ولفهم الأمر أكثر، دعونا نكتب دالة للتأكد من معلومات المستخدم قبل تسجيل دخوله إلى البرنامج:

fun main() {

    val email = "user@gmail.com"
    val password = "12345"
    
    val checkLogin =
        if (login(email, password)) "You are logged in"
        else "Wrong email or password"

    println(checkLogin)
}

fun login(email: String, password: String): Boolean {
    fun validate(): Boolean {
        return when {
            email == "example@exvar.com" && password == "12345" -> true
            else -> false
        }
    }
    return validate()
}

أعلنا عن الدالة ()login على مستوى الملف Top-Level Function. ولأنها لا تستطيع الوصول إلى المتغيرات داخل دالة ()main، وضعنا لها معاملات تستقبل بها قيم المتغيرات والتي هي مدخلات المستخدم.

داخل دالة ()login لدينا دالة محلية ()validate، وعملها هو فحص مدخلات المستخدم إذا كانت صحيحة أم لا. ولأنها دالة محلية ومجالها هو الدالة ()login، لذلك تستطيع إلتقاط المعاملات من دالة ()login.

وضعنا دالة ()login خارج ()main، لأن عملها منفصل عنها. أمّا دالة ()validate وضعناها داخل ()login، لأن عملها مرتبط بهذه الدالة. وفي النهاية الدالة ()login، ستعيد القيمة العائدة من ()validate.

الدوال المجهولة Anonymous Functions:

مثل الدوال المحلية، نعلن عن الدوال المجهولة داخل دوال أخرى. الفرق بين الإثنين، هو أن الدوال المجهولة، لا نضع لها اسم بعد الكلمة fun. وكمثال، إذا كان لدينا الشفرة التالية:

data class User(val email: String, val password: String)

val users = listOf(
    User("user1@gmail.com", "78549"),
    User("example@exvar.com", "12345"),
    User("user45@hotmail.com", "89523")
)

val admins = setOf("example@exvar.com", "user45@hotmail.com")

fun main() {

    val email = "example@exvar.com"

    fun isAdmin(user: User): Boolean {
        if (user.email.contains(email)
            && email in admins
        ) {
            return true
        }
        return false
    }

    println(users.any(::isAdmin))
}

لدينا صنف بيانات User به معلومات المستخدم. ثم قائمة users عناصرها هي كائنات من صنف User، وتجميعة من النوع Set أسميناها admins، بها إيميلات المدراء Admins.

في دالة ()main، لدينا المتغير email ونريد أن نعرف عبر استخدام الدالة المحلية ()isAdmin، ما إذا كان هذا الايميل متواجد في قائمتي users و admins. ستعيد الدالة true إذا كان الإيميل متواجد بالقائمتين، أو false إذا كان أيًا من أو كلا الشرطين خطأ.

واستخدمنا دالة ()any والتي ستعيد لنا true أو false حسب نتيجة الدالة ()isAdmin. ولأننا لا نستطيع إرسال الدالة مباشرةً إلى ()any، أرسلنا لها تعبير مرجع للدالة.

أمّا إذا أردنا إرسال شفرة الدالة مباشرةً إلى ()any، يمكننا استخدام تعبير لامبدا بدلًا عن الدالة المحلية ()isAdmin. ولكن لأنه من المستحسن استخدام اللامبدا للشفرات الصغيرة فقط، لأنها ستصبح صعبة القراءة عند تشعب الشفرات، لذا يمكننا بدلًا عنها استخدام الدوال المجهولة:

println(
        users.any(
            fun(user: User): Boolean {
                if (user.email.contains(email)
                    && email in admins
                ) {
                    return true
                }
                return false
            }
        )
    )

بمجرد حذف اسم الدالة الكلمة isAdmin، أصبحت دالة مجهولة سنتمكن من إرسالها مباشرةً إلى دالة ()any. هكذا نكون قد اختصرنا الشفرة، ولكننا لم نخسر قابلية القراءة في نفس الوقت، لأننا نعرف أن هذه دالة ونستطيع تتبع الشفرة، وحتى ولو كانت الدالة بدون اسم.

وهكذا يستحسن أن نفعل دائمًا. فإذا كانت اللامبدا ستصبح معقدة وغير قابلة للقراء، نستبدلها بدالة محلية أو دالة مجهولة.

استخدام التسميات Labels مع أمر العودة return:

رأينا في درس أوامر القفز والعودة، كيف يمكننا استخدام التسميات مع أمر الإيقاف break وأمر المواصلة continue. ولكن في بعض الأحيان، نحتاج إلى استخدام التسميات مع return. لأن return ستعمل على إنهاء أقرب دالة محيطة بها تحمل الكلمة fun:

fun main() {

    val numbers = listOf(1, 2, 3, 4, 5)

    numbers.forEach {
        if (it == 5) {
            return
        }
        println(it)
    }

    println("لن يتم طباعة هذه الجملة")
}

في الشفرة أعلاه، لأن return تتواجد داخل ()main، فعند وصول العنصر داخل استدعاء دالة ()forEach إلى الرقم 5، سيتم إنهاء دالة ()main عبر return، لأنها الدالة الأقرب التي بها الكلمة fun. (استدعاء دالة ()forEach هو عبارة عن تنفيذ للدالة وليس الإعلان عن الدالة).

وستكون نتيجة تنفيذ الشفرة هي:

1
2
3
4

ولضمان إنهاء عمل ()forEach فقط، وتنفيذ الشفرات خارجها ومنع return من إنهاء الدالة الخارجية، يمكننا استخدام التسميات:

fun main() {

    val numbers = listOf(1, 2, 3, 4, 5)

    numbers.forEach {
        if (it == 5) {
            return@forEach
        }
        println(it)
    }

    println("هذه المرة سيتم طباعة هذه الجملة")
}

بعد إضافة التسمية لـ return، ستكون نتيجة تنفيذ الشفرة هي:

إسناد الدوال المجهولة إلى متغير:

تمامًا مثل تعابير اللامبدا، يمكننا حفظ الدوال المجهولة في متغير. وسيكون هذا المتغير، هو المعرِّف identifier الذي يمكننا استخدامه عندما نريد استدعاء الدالة:

val id1: (Int) -> Int = fun(i: Int) = i + 1

val id2: () -> String = fun() = "Hello"

تم إسناد دالة مجهولة لديها معامل واحد من النوع Int وتعيد قيمة من النوع Int أيضًا، إلى المتغير id1. لذلك نوع بيانات المتغير هو: Int) -> Int). أمّا الدالة المجهولة التي تم إسنادها إلى المتغير id2، ليس لديها معاملات وتعيد قيمة من النوع String. لذلك نوع بياناته هو: String <- ().

يمكننا استخدام أيًا من المتغيرين، لاستدعاء وتنفيذ الدالة التي يُمثلها:

println(id1(2))
println(id1.invoke(2))

println(id2())
println(id2.invoke())

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

هذا الدرس هو جزء من سلسلة تعليم مبادئ البرمجة بلغة كوتلن. لمُتابعة الدروس منذ البداية ومُشاهدة فهرس المحتويات يمكنك الانتقال إلى الدرس الأول من هنا.

هل أعجبك المحتوى وتريد المزيد منه يصل إلى صندوق بريدك الإلكتروني بشكلٍ دوري؟
انضم إلى قائمة من يقدّرون محتوى إكسڤار واشترك بنشرتنا البريدية.