تعلّم البرمجة بلغة كوتلن (52): المتتاليات Sequences

تعلّم البرمجة بلغة كوتلن (52): المتتاليات Sequences
أستمع الى المقال

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

فمثلّا، إذا كان لدينا تجميعة قائمة List، وأردنا تصفية عناصرها عبر دالة ()filter، ثم بعدها استخدمنا دالة ()map، ستنتج كلا الدالتين تجميعة List جديدة.

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

لذلك، في هذا الدرس، سنتعلّم مفهوم وطريقة جديدة، لإجراء هذه العمليات المتتالية على التجميعات، دون أن يكون هناك أي إنشاء لتجميعات جديدة، إلا حين الإنتهاء من المهمة. أي بمعنى حفظ هذه العمليات المراد إجراؤها على التجميعة، وتنفيذ كل منها عند الحاجة فقط. ويعرف هذا المفهوم، باسم المتتاليات Sequences.

ماهي المتتاليات Sequences:

المتتالية هي مفهوم أساسي في نمط البرمجة الوظيفية في لغة كوتلن. وهي تشبه إلى حد كبير، مفهوم الـ Streams في اللغات الأخرى التي تطبق هذا النمط البرمجي. واختارت كوتلن اسم مختلف Sequences، للحفاظ على إمكانية التشغيل البيني مع مكتبة Stream في جافا 8. لأنه يمكننا استخدام مكتبة الجافا هذه في شفرة كوتلن، إذا كان برنامجنا موجه للـ JVM.

وأيضًا تتشابه الـ Sequences في لغة كوتلن، مع تجميعة القوائم List. ولكن على الرغم من أنه يمكننا أن نقوم بعمليات التكرار على الاثنتين، لا تمتلك ال Sequences طريقة للفهرسة مثل القوائم. 

أمّا أهمية المتتاليات، فتتمثل في أنها تمكننا من القيام بسلسلة من الاستدعاءات للدوال الوظيفية، باستخدام طريقة التقييم والتنفيذ عند الحاجة Lazy Evaluation (التقييم الكسول أو المؤجل). خلافًا لطريقة الاستدعاء المباشر لهذه الدوال على تجميعة القوائم، والتي ستجري عملياتها بطريقة التقييم والتنفيذ الحماسي Eager Evaluation.

الفرق بين التقييم الحماسي والتقييم عند الحاجة:

التقييم الحماسي Eager Evaluation:

لفهم الفرق بين المفهومين، دعونا نلقي نظرة على المثال التالي:

val numbers = listOf(1, 2, 3, 4)
numbers.filter { it % 2 == 0 }
        .map { it * it }
        .any { it < 10 }

لدينا قائمة أسميناها numbers، عناصرها هي من النوع Int. قمنا بإجراء سلسلة من العمليات على هذه القائمة، بداية بالتصفية عبر دالة ()filter، ثم تحويل العناصر عبر ()map. وأخيرًا معرفة ما إذا كان أيًا من العناصر في القائمة أصغر من الرقم 10، عبر استخدام دالة ()any.

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

val filteredList: List<Int> = numbers.filter { it % 2 == 0 }
val mappedList: List<Int> = filteredList.map { it * it }
val trueOrFalse: Boolean = mappedList.any { it < 10 }

بعد تصفية قائمة numbers التي بها أربعة أعداد (عددين فرديين و عددين زوجيين)، سيتم استخراج العناصر التي توافق شرط دالة ()filter (الأرقام الزوجية)، ثم ستعيد لنا الدالة قائمة جديدة، عناصرها هي هذه الأرقام الزوجية فقط، وهما (العددين 2 و 4). القائمة الجديدة، هي التي سيتم إسنادها إلى المتغير filteredList.

ولأن دالة ()map يتم استدعائها بعد ()filter، فهي ستتعامل مع القائمة التي أنتجتها دالة ()filter (القائمة التي بها العددين 2 و 4)، ولا تعرف شيئًا عن القائمة الأصلية numbers، ذات الأعداد الأربعة. وهذه الدالة أيضًا بعد إنتهاء عملها، والذي يتمثل في ضرب كل عنصر في نفسه، ستنتج قائمة جديدة، عناصرها هي ناتج عملية الضرب. أي ( 2 * 2 و 4 * 4). القائمة التي تنتجها ()map، ستحتوي على العددين ( 4 و 16)، وهي التي سيتم إسنادها للمتغير mappedList.

وأخيرًا، بعد إنتهاء دالة ()map، يأتي دور دالة ()any. وهي ستتعامل مع القائمة التي أنتجتها ()map، ولا تعرف شيئًا عن القائمتين: الأصلية، وقائمة الأعداد الزوجية المنتجة عبر دالة ()filter. عمل هذه الدالة هو، إذا وافق أحد عناصر القائمة شرطها، ستعيد true، غير ذلك، ستعيد false. وبما أن هناك عنصر أصغر من 10 وهو العدد (4)، ستكون القيمة العائدة من هذه الدالة هي true. وهي القيمة التي سيتم إسنادها للمتغير trueOrFalse.

التقييم الأفقي horizontal evaluation:

ويعرف التقييم الحماسي أحيانًا بـ  التقييم الأفقي horizontal evaluation. كما توضحه الصورة التالية:

التقييم الأفقي horizontal evaluation

كما يُظهر الرسم البياني في الصورة، يحتوي السطر الأول على عناصر القائمة الأولى التي تجري العمليات عليها. يعرض كل سطر تالي نتائج العملية السابقة. ولكن قبل الانتقال إلى العملية التالية، يتم فحص جميع العناصر الموجودة في العملية  الحالية، أي بطريقة أفقية.

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

التقييم عند الحاجة (المؤجل) Lazy Evaluation:

البديل المناسب للتقييم السابق، هو التقييم عند الحاجة Lazy Evaluation. لأنه يتم تنفيذ كل عملية عند الحاجة فقط. وفي بعض الأحيان يطلق على هذه العمليات المؤجلة التي يتم إجراؤها على المتتاليات Sequences، اسم التقييم الرأسي vertical evaluation.

التقييم الرأسي vertical evaluatio

في طريقة عمل التقييم عند الحاجة أو التقييم الرأسي، عندما وجدت دالة ()filter أن العنصر الأول لايوافق الشرط في تعبير اللامبدا المرسل إليها، ذهبت للعنصر الثاني في القائمة. العنصر الثاني سيوافق شرط الدالة، لأنه عدد زوجي. لذلك مررته مباشرةً للدالة التي تليها (أي العملية التالية).

العملية التالية، هي الخاصة بالدالة ()map. ستقوم الدالة بضرب العنصر في نفسه، ثم إرساله مباشرةً، إلى الدالة التالية.

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

وعند استدعاء هذه الدوال عبر تجميعة القوائم Lists مباشرةً، سيتم تنفيذها بطريقة التقييم الحماسي. أمّا إذا أردنا أن نقوم بهذه العمليات بطريقة التقييم المؤجل، فيجب أن نحول القائمة من النوع List إلى النوع Sequence، لنتمكن من الاستفادة من مميزات هذا التقييم.

ولكن قبل ذلك، دعونا نجيب على السؤال: لماذا تم إنهاء عمليات المتتالية عندما تم الحصول على نتيجة true عبر دالة ()any، ولم يحدث ذلك مع الدالتان اللتان تسبقانها؟ هذا حدث لأن دالة ()any، هي عبارة عن دالة تقوم بعمليات نهائية Terminal Operations في المتتالية. أمّا دالتي ()filter و ()map، فتقومان بعمليات وسيطة Intermediate Operations.

الفرق بين العمليات الوسيطة والنهائية في المتتالية:

تنقسم العمليات التي تجري على المتتاليات، إلى عمليات وسيطة وأخرى نهائية. وكلا القسمين له دواله الخاصة به.

دوال العمليات الوسيطة:

حينما نقوم باستدعاء سلسلة من هذه الدوال الوظيفية على تجميعة تم تحويلها إلى متتالية، تعيد قيمة من النوع متتالية Sequence. هذه المتتالية، يخزن بها سلسلة العمليات السابقة، والتي سيتم تنفيذها عند الحاجة. ومن هذه الدوال: ()filter و ()map و ()flatMap و ()sorted و غيرها.

دوال العمليات النهائية:

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

فمثلًا، دالة ()any في مثالنا أعلاه، تعمل على تنفيذ العمليات المتتالية التي تم تخزينها قبلها، وتنتج نتيجة من النوع Boolean وليس من النوع متتالية. ومن ضمن الدوال الأخرى، دالة ()toList والتي تنفذ سلسلة العمليات السابقة لها، وتنتج قائمة List جديدة. ودالة ()count، والتي تنتج عدد العناصر في التجميعة التي تم تحويلها إلى متتالية.

سنفهم هذا الأمر أكثر، عبر الأمثلة في الفقرات أدناه.

تحويل قائمة List إلى متتالية:

عند تحويل القائمة إلى متتالية، ستتوفر لهذه المتتالية جميع الدوال التي يمكننا أن نستدعيها عبر تجميعة القائمة، باستثناء دوال وخاصيّات الفهرسة. ويمكننا القيام بهذا التحويل، عبر دالة ()asSequence:

val numbers: Sequence<Int> = listOf(1, 2, 3, 4).asSequence()

بعد استخدام الدالة، أصبحت قائمة numbers عبارة عن متتالية من الأعداد الصحيحة Int. الآن يمكن استخدام العمليات المؤجلة بطريقة رأسية على numbers.

الفرق بين المتتالية والتجميعة:

عند استدعاء دالة ()filter على قائمة numbers:

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9)
println(
        numbers.filter { it % 2 == 0 }
    )

ستكون نتيجة الطباعة، هي قائمة جديدة من الأعداد الزوجية:

[2, 4, 6, 8]

أمّا لو استدعينا نفس الدالة على القائمة بعد تحويلها إلى متتالية:

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9).asSequence()
    println(
        numbers.filter { it % 2 == 0 }
    )

ستكون نتيجة الطباعة هي:

kotlin.sequences.FilteringSequence@133314b

وهو كائن مرجع يشير إلى عملية الفلترة Filtering. (الرقم بعد العلامة @، هو عنوان هذا الكائن في الذاكرة). ومتى ما تم استدعاء دالة نهائية Terminal، سيتم استدعاء هذه العملية وتنفيذها:

println(
        numbers.filter { it % 2 == 0 }
            .any { it < 10 }
    )

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

تنفيذ سلسلة العمليات:

في الشفرة التالية، نتابع سلسلة العمليات عبر طباعتها عبر دالتي الطباعة ()print و ()println، لمعرفة كم مرة تم الوصول إلى العناصر، عند استخدام الدوال على التجميعة مباشرةً:

fun main() {
    val numbers = listOf(1, 2, 3, 4)

    println(
        numbers
            .filter {
                print("filter(): ")
                println("Element $it is checked.")
                it % 2 == 0
            }
            .map {
                print("map(): ")
                println("Element $it is multiplied")
                it * it
            }
            .any{
                print("any(): ")
                it < 10
            }
    )
}

ستكون نتيجة الطباعة هي:

كما يظهر في الصورة، أن دالة ()filter دارت على كل عناصر القائمة واحدًا تلو الآخر. أمّا إذا حوّلنا هذه القائمة إلى متتالية، باستخدام دالة ()asSequence، فعندها ستكون سلسلة التنفيذ هي كما يظهر في الصورة التالية:

كما نرى، بعد أن وجدت دالة ()filter أن العنصر الأول الرقم 1 ليس عدد زوجي، ذهبت إلى العنصر الثاني، وهو بالطبع عدد زوجي، لذلك مررته إلى دالة ()map. وبعد أن قامت دالة ()map بضرب العنصر في نفسه، مررته إلى ()any. ولأن العنصر يوافق شرط ()any، وبما أنها دالة Terminal، عملت على إنهاء عمل المتتالية، وأعادت القيمة true.

ولكن ماذا إذا لم يكن العنصر الثاني لا يوافق شرط ()any؟ عندها ستعمل ()any على تنفيذ باقي العمليات في المتتالية، أو حتى آخر عنصر في القائمة numbers.

فمثلًا، إذا عدلنا على الشفرة كالتالي:

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5).asSequence()

    println(
        numbers
            .filter {
                print("filter(): ")
                println("Element $it is checked.")
                it % 2 == 1
            }
            .map {
                print("map(): ")
                println("Element $it is multiplied")
                it * 2
            }
            .any{
                println("any(): ")
                it == 10
            }
    )
}

هذه المرة القائمة numbers تحتوي على 5 عناصر. ودالة ()filter مهمتها هي البحث عن الأرقام الفردية. أمّا دالة ()map فستقوم بضرب كل عنصر يمرر إليها بالرقم 2. وأخيرًا، ()any تبحث عن عنصر يساوي الرقم 10.

عند تنفيذ الشفرة، ستكون نتيجة الطباعة:

في المرة الأولى مررت دالة ()filter العنصر الأول إلى دالة ()map لأنه عدد فردي. ضربت ()map العنصر في الرقم 2 ثم مررته إلى ()any. وجدت الدالة أن العنصر لا يساوي 10، عادت إلى ()filter لتنفيذ العملية التالية.

وهكذا حتى وصلنا العنصر الأخير في القائمة الرقم 5. والذي يوافق شرط ()filter وبعد ضربه في 2 سيوافق شرط ()any والتي أعادت القيمة true. وبالطبع إذا لم يتوفر أي عنصر يوافق شرط الدالة النهائية ()any، سيتم إعادة القيمة false.

طرق أخرى لإنشاء متتالية:

رأينا في الفقرة السابقة، كيف يمكن أن نحوّل تجميعة من العناصر (قائمة List أو طقم Set أو خريطة Map)، إلى متتالية عن طريق استدعاء الدالة ()asSequence، وهي الطريقة الأكثر شيوعًا لإنشاء متتالية.

ولكنها ليست هي الطريقة الوحيدة لإنشاء وتهيئة متتالية. حيث يمكن إنشاؤها بطرق مختلفة، منها:

 دالة ()sequenceOf:

val numbersSequence = sequenceOf(1, 2, 3, 4, 5)
        .filter { it % 2 == 1 }
        .toList()

ستنُشئ الدالة متتالية بعدد العناصر التي تم إرسالها إليها. يمكننا أن نرسل للدالة، أي عدد من العناصر من أي نوع بيانات، لأن لديها معامل vararg من نوع بيانات عام T. ونتيجة الطباعة للمتغير numbersSequence ستكون قائمة List من الأعداد الفردية:

[1, 3, 5]

متتالية ذات عناصر لانهائية:

أمّا إذا أردنا أن يتم إنشاء المتتالية تلقائيًا، فيمكننا استخدام دالة ()generateSequence:

val numbersSequence = generateSequence (0) { it + 1 }
        .filter { it % 2 == 1 }
        .toList()

ستُنشئ الدالة متتالية بعدد عناصر لا نهائي من النوع عدد صحيح Int. بداية من العدد الذي أرسلناه لمعاملها الأول وهو 0، ثم استخدامه في تعبير اللامبدا المرسل لمعاملها الثاني، لإنتاج بقية الأرقام. ولأننا نستدعي دالة ()toList، ستتم محاولة إضافة العناصر التي تم إنتاجها، إلى قائمة List دون توقف. وهو ما سيؤدي إلى تحطم البرنامج في النهاية لحدوث الخطأ الإستثنائي التالي إذا كان برنامجنا موجه لمنصة Java:

Exception in thread “main” java.lang.OutOfMemoryError: Java heap space

وهو خطأ يدل على امتلاء الذاكرة المخصصة لعمليات البرنامج في آلة الـ JVM الافتراضية.

ولتجنب هذا الخطأ، يمكننا الاستفادة من الدالة الوسيطة ()take من مكتبة كوتلن. مهمة الدالة هي أخذ عدد معين من المتتالية ذات الأعداد اللانهائية:

val numbersSequence = generateSequence (0) { it + 1 }
       .take(10)
       .filter { it % 2 == 0 }
       .toList()

ستأخذ الدالة أول 10 أرقام من المتتالية فقط، وهي الأعداد من 0 إلى 9، ثم تمررها إلى دالة ()filter. وستكون نتيجة طباعة المتغير numbersSequence هي:

[0, 2, 4, 6, 8]

إنشاء متتالية من نطاق معين:

وبدلًا عن الطريقتين أعلاه، يمكننا استخدام النطاقات Ranges ودالة ()asSequence لإنشاء المتتالية:

val numbersSequence = (0..9).asSequence()
        .filter { it % 2 == 0 }
        .toList()

وستكون نتيجة الطباعة هي نفس السابق:

[0, 2, 4, 6, 8]

أهمية ترتيب العمليات في المتتالية:

يمكن أن تعمل المتتالية بأي ترتيب للعمليات، ولكن الفرق سيكون هو عدد الخطوات التي يتم تنفيذها للحصول على نفس النتيجة. فمثلًا، يمكننا جعل دالة ()map تسبق دالة ()filter:

fun main() {

    val numbersSequence = (1..5).asSequence()
        .map {
            print("\nmap(): ")
            println("Element $it is multiplied\n")
            it * it
        }
        .filter {
            print("filter(): ")
            println("Element $it is checked.")
            it % 2 == 0
        }
        .toList()
    
    println("\n$numbersSequence")

}

لدينا في الشفرة نطاق عدد صحيح من 1 إلى 5، حوّلناه إلى متتالية عبر دالة ()asSequence. نضرب كل عنصر في نفسه عبر دالة ()map أولًا، ثم نصفي العناصر إلى العناصر الزوجية عبر دالة ()filter. بعد ذلك نحوّل العناصر التي تم تصفيتها، إلى قائمة List عبر ()toList.

ما يهمنا هنا معرفة عدد الخطوات التي تمت حتى الحصول على النتيجة النهائية. لذا نطبع رسالة في كل عملية في المتتالية عبر دوال الطباعة، لمتابعة هذه الخطوات. وعند تنفيذ الشفرة كانت النتيجة في تبويب Run في برنامج IntelliJ:

تم الوصول إلى النتيجة النهائية بعد القيام بـ 10 عمليات.

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

فمثلًا، يمكننا أن نعيد ترتيب الدوال الوسيطة، عبر وضع دالة ()filter قبل دالة ()map. وعند تنفيذ الشفرة ستكون النتيجة:

تم الحصول على نفس النتيجة السابقة بعدد أقل من الخطوات. وهذا يعني وقت أقل واستخدام أقل لموارد الجهاز الذي يعمل عليه برنامجنا.

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

وكقاعدة عامة، يجب أن نتذكر: وضع الدوال الوسيطة التي تقلل من حجم البيانات data reducing مثل ()filter، قبل الدوال التي تعمل على تحويل data transformation هذه العناصر إلى شيء آخر مثل ()map.

الخلاصة:

تعتبر كل الدوال التي نستدعيها على تجميعةٍ ما، دوال نهائية Terminal. لذلك، تنتج كل منها قيمة جديدة (قائمة List مثلًا) قد تؤثر في أداء وسرعة البرنامج إذا كانت التجميعة كبيرة الحجم. البديل المناسب هو استخدام المتتاليات. حيث يتم تخزين مرجع إلى العملية ولن يتم تنفيذها إلا عند استدعاء إحدى الدوال النهائية.

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

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