تعلّم البرمجة بلغة كوتلن (75): تنظيف الموارد Resources Cleanup

تعلّم البرمجة بلغة كوتلن (75): تنظيف الموارد Resources Cleanup
أستمع الى المقال

شرحنا في درس الاستثناءات Exceptions كيف يمكننا معالجة الاستثناءات وتجنب تحطم البرنامج باستخدام كتل try-catch في لغة كوتلن. ثم رأينا في درس معالجة الاستثناءات Exceptions Handling، كيف يمكننا بجانب معالجة الاستثناءات، أن نستخدم كتلة finally، كفرصة أخيرة لإغلاق الموارد أو المصادر الخارجية المتصلة ببرنامجنا، قبل تحطمه. لأنه أحيانًا، قد نحتاج إلى العمل مع مصادر بيانات خارجية مختلفة، مثل الملفات Files التي تتواجد خارج مجلدات مشروع برنامجنا، أو المنافذ Sockets، أو الاتصال بقواعد البيانات …الخ. 

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

ولكن قبل ذلك، دعونا نُلقي نظرة سريعة، على كيفية التواصل بين تطبيقنا وأحد هذه الموارد: الملفات Files.

ما هي الموارد Resources:

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

كمثال، عندما نقوم بإجراء عمليات على الملفات (مثل: إنشاء الملفات، والكتابة على الملفات، قراءة الملفات …الخ)، يتم إنشاء كائن ملف عبر هذه الأصناف. بعدها يقوم نظام الـ JVM، بإعلام نظام التشغيل (OS) ببدء العمليات على الملف. وإذا لم تكن هناك مشكلة أو أخطاء، سيعيد نظام التشغيل واصف الملف file descriptor، والذي يتم استخدامه للوصول إلى الملف. (file descriptor هو رقم صحيح فريد يضعه نظام التشغيل لكل ملف مفتوح. إذا تم فتح 10 ملفات، سيكون هناك 10 أرقام  لواصفات الملفات، يمثل كل منها ملف واحد).

التعامل مع الملفات عبر حزمة io:

يتم التواصل مع موارد الجهاز، والتي يتحكم فيها نظام التشغيل الخاص بالجهاز، عبر نظام الـ JVM. ولدى الـ JVM حزمة اسمها io، بها كل الأصناف التي نحتاجها للتعامل مع الملفات في الجهاز. وعلى الرغم من أن الأصناف في حزمة io هذه كتبت عبر لغة جافا، لكنها ستعمل بسلاسة في تطبيق كتب بلغة كوتلن، لوجود توافق وتشغيل بيني بين اللغتين، توفره لغة كوتلن. (كوتلن بنيت أساسا لتكون جافا أفضل، قبل أن يتحول هذا الهدف لاحقاً مع تطور اللغة. المزيد عن ذلك في هذا الدرس).

تضمين حزمة io:

لذا، أولًا وقبل كل شيء يجب أن نقوم بتضمين import حزمة جافا هذه في برنامجنا عبر الرابط:

import java.io.*

عبر وضع هذا السطر في أعلى الملف الذي سنكتب به شفرة التعامل مع الملفات، يمكننا التعامل مع الملفات عبر شفرة كوتلن. نلاحظ أننا وضعنا (*) بعد آخر نقطة في سطر التضمين. وهذا لأننا نريد أن يتم تضمين كل الأصناف التي تحتويها هذه الحزمة. ولكن، إذا أردنا صنف معين، يمكننا كتابته كالتالي:

import java.io.File

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

import java.io.File
import java.io.FileReader

وهكذا يمكننا تضمين صنف تلو الآخر حتى نغطي العمليات التي نحتاجها فقط، أو يمكننا جلب كل شيء من الحزمة عبر استخدام النجمة (*) كما فعلنا أعلاه.

إنشاء ملف:

لإنشاء ملف، يجب أن نضع المسار الذي يجب أن يتم إنشاء الملف داخله. كمثال، إذا أردنا إنشاؤه داخل مجلد src وهو أحد مجلدات مشروع البرنامج:

يمكننا كتابة مسار الملف كالتالي:

src/file1.txt

اسم المجلد الذي سيتواجد به، وهو هنا src، ثم اسم الملف نفسه file1 ثم نقطة ثم إمتداد الملف txt. (إمتداد txt يعني أننا نريد أن نُنشيء ملف نصي). ويجب أن نرسل السطر أعلاه، كقيمة نصية String لأحد بواني الصنف File:

import java.io.*

fun main() {

   File("src/file1.txt")

}

أصبح كائن الملف جاهزًا، ولكن لم يتم إنشاؤه بعد في المسار المُحدد. لإنشاء الملف، يجب أن نستدعي دالة ()createNewFile المتواجدة داخل نفس الصنف:

import java.io.*

fun main() {

   File("src/file1.txt").createNewFile()

}

عند تنفيذ البرنامج، سيتم إنشاء الملف داخل مجلد src:

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

import java.io.*

fun main() {

    val file = File("src/file1.txt")

    if (!file.exists()) {
        file.createNewFile()
        println("File created")
    } else {
        println("File is already exists")
    }
}

في كتلة if نتحقق عبر دالة ()exists مما إذا كان الملف موجود مسبقًا في المسار src/file1.txt. إذا لم يكن موجود، سيتم إنشاؤه وطباعة الجملة: “File created”. أمّا إذا كان موجود، فسيتم طباعة الجملة: “File is already exists”.

الكتابة على الملف:

للكتابة على الملفات، نستخدم الصنف FileWriter. فمثلًا، إذا أردنا الكتابة على ملفنا السابق، يمكننا فعل التالي:

import java.io.*

fun main() {
    val writer = FileWriter("src/file1.txt")
    writer.write("Hello from Kotlin!")
    writer.close()
}

نُنشيء كائن من الصنف FileWriter عبر إرسال مسار الملف إليه، ثم نسنده للمتغير writer والذي سيعمل كمرجع reference للكائن الذي تم أسندناه له. ثم نستخدم هذا المتغير لاستدعاء دالة ()writ، والتي مهمتها هي فتح الملف في المسار المحدد، ووضع النص الذي نرسله لها كقيمة لمعاملها، داخل الملف. ولأن فتح الملف يعني حجز مكان في الذاكرة، يجب أن نغلق الملف عند الانتهاء من الكتابة عليه، حتى تعمل آلة الـ JVM على تحرير مكانه في الذاكرة، أي تنظيف المورد. ونفعل ذلك عبر دالة ()close.

وبعد تنفيذ البرنامج أعلاه، ومن ثم فتح الملف في برنامج IntelliJ، نجد أنه تم فعليًا كتابة النص المُرسل لدالة ()write داخل الملف:

قراءة الملف:

لقراءة النص المتواجد في هذا الملف، يمكننا استخدام صنف آخر FileReader:

import java.io.*

fun main() {
    
    val reader = FileReader("src/file1.txt")
    val text = reader.readText()
    reader.close()

    println(text)
}

في الشفرة أعلاه، نُنشيء أولًا كائن FileReader عبر استخدام المسار الكامل لمكان الملف. ثم بعد ذلك، نستخدم دالة ()readText لقراءة النص من الملف، ونسنده للمتغير text. دالة ()readText هي دالة مُلحقة بالصنف Reader وهو الصنف الأب للصنف FileReader، لذا أمكننا أن نستدعيها عبر كائن مُنشأ منه. ثم نغلق الملف عبر دالة ()close. 

وبما أن المتغير text أصبح يُمثل النص الذي تمت قراءته من الملف، يمكننا طباعة قيمته عبر وضعه في دالة الطباعة. وستكون نتيجة تنفيذ الشفرة هي:

Hello from Kotlin!

وهو نفس النص الذي تمت كتابته داخل الملف عبر دالة ()write في الفقرة السابقة.

الاستثناءات التي يمكن أن تحدث عند التعامل مع الملفات:

يستخدم نظام الـ JVM دالة ()close الوارد ذكرها في الفقرات أعلاه، لتحرير الملف الذي تم فتحه بواسطة برنامجنا – سواء للكتابة عليه أو قراءة محتوياته – وتنظيف مكانه في ذاكرة الحاسب. ولأن نظام التشغيل الذي يعمل عليه برنامجنا، يوفر عددًا محدودًا من واصفات الملفات file descriptors لكل عملية process، فإذا واصلنا فتح الملفات دون الإهتمام بتحريرها عند إنتهاء العمل عليها، يمكن أن يحدث استثناء بسبب إمتلاء مساحة الذاكرة المتوفرة لبرنامجنا. حينها سيجبر نظام التشغيل برنامجنا على التحطم، وقد يظهر رسالة للمستخدم بأن برنامجنا يستهلك الكثير من الذاكرة دون داعي. هذا هو السبب في ضرورة إغلاق الملفات والموارد عمومًا، عندما لا يتم استخدامها.

عدم القدرة على إغلاق الملف:

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

fun main() {
    val writer = FileWriter("src/file1.txt")
    writer.write("Hello ExVar", 6, 5)
    writer.close()
}

في الشفرة داخل دالة ()main أعلاه، أنشأنا كائن FileWriter، عبر إرسال مسار الملف المراد إلى الصنف FileWriter. ثم استخدمنا دالة ()write للكتابة على الملف. نلاحظ أننا استخدمنا دالة ()write مختلفة هذه المرة. لأنه تم اتباع مفهوم الـ Overloading في إنشائها، أمكننا أن نستدعي الدالة التي لديها ثلاث معاملات. أول معامل من النوع String، نرسل له النص المُراد كتابته على الملف. وثاني معامل من النوع Int، نرسل له فهرس الحرف الذي يجب أن يبدأ إقتطاع النص بدايةً منه. وأخر معامل من النوع Int، نرسل له عدد الحروف المُرادة.

فهرس index سلسلة المحارف String:

فإذا كان لدينا النص التالي:

"Hello ExVar"

كل مابين علامتي التنصيص هو سلسلة محارف بما فيها المسافة الفارغة بين الكلمتين. وسلسلة المحارف تُمثل نوع البيانات String، حيث كل محرف هو عبارة عن عنصر داخل السلسلة. وفي الـ String، يبدأ فهرس العناصر من الرقم صفر كما يظهر في الصورة التالية:

إذًا، للوصول للحرف H مثلًا، نستخدم الفهرس رقم صفر. وللوصول للمسافة الفارغة داخل النص، نستخدم الفهرس رقم 5. وللحرف r الرقم 10 وهكذا. وهذا ما فعلناه عند إرسال النص إلى دالة ()write:

write("Hello ExVar", 6, 5)

عند استدعاء الدالة بهذه الطريقة، فكأننا نقول: اقتطع النص بداية من الحرف الذي فهرسه الرقم 6، ثم أحضر 5 أحرف فقط من النص. لذلك، بعد تنفيذ الشفرة، ستكتب الدالة على الملف، النص: ExVar فقط، وتتجاهل باقي النص. ثم يتم إغلاق الملف عبر دالة ()close.

ولكن ماذا إذا أخطأنا وكتبنا الفهرس 7 بدلًا عن 6؟ ستبدأ الدالة الاقتطاع من النص بداية من الحرف الذي فهرسه 7، ثم ستحاول كتابة 5 حروف. ولكن لأنه ليس هناك 5 حروف بداية من الفهرس 7 بل 4 حروف فقط، سنجد أن الـ JVM قذف الاستثناء التالي:

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

begin 7, end 12, length 11

أي أنه تم البدء من الفهرس رقم 7 وحتى الفهرس رقم 12، ولكن طول length (عدد عناصر) النص فقط 11.

معالجة الاستثناءات في كتلة finally:

يمكننا معالجة هذا الاستثناء بوضع الشفرة في كتلة try، ثم إلتقاطه عبر كتلة catch ووضع رسالة مناسبة حتى نتجنب تحطم البرنامج. ولكن سيظل الملف الذي تم فتحه للكتابة عليه مفتوحًا، خاصةً إذا حدثت استثناءات أخرى داخل البرنامج. ولضمان إغلاق الملف المفتوح، يمكننا أن نضع دالة ()close داخل كتلة finally، والتي سيتم تنفيذ شفرتها مهما حدث، كما شرحنا في درس معالجة الاستثناءات.

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

fun main() {
    val writer = FileWriter("src/file1.txt")
    try {
        writer.write("Hello ExVar", 7, 5)
    } finally {
        writer.close()
    }
}

هذه المرة أيضًا سيحدث الاستثناء، ولكننا على الأقل ضمنا إغلاق الملف الذي فتحناه للكتابة عليه، بوضع شفرة الإغلاق في كتلة finally.

وعلى الرغم من أن هذا الحل لا بأس في شفرة صغيرة كشفرتنا هذه، ولكن لاحقًا قد نضع شفرة أخرى داخل كتلة finally، قد تسبب هي الأخرى استثناء. في هذه الحالة يمكننا أن نضع كتلتي try و catch داخل finally. ومع إزدياد حجم الشفرة، يزداد التعقيد. ولتجنب التعقيد والكثير من كتل try و catch و finally، لدى كوتلن دالة تريحنا من كل ذلك، دالة ()use.

دالة ()use:

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

import java.io.*

fun main() {
    
    val writer = FileWriter("src/file1.txt")
    writer.use { fileWriter ->
        fileWriter.write("Hello ExVar", 7, 5)
    }
    
}

نلاحظ أن  دالة ()use لديها معامل من نوع بيانات الدالة، لذا أمكننا أن نرسل لها تعبير لامبدا. داخل أقواس اللامبدا المعقوفة، نضع الشفرة التي تتعامل مع المورد الخارجي. مرة أخرى، عند تنفيذ هذه الشفرة، سيحدث نفس الاستثناء السابق StringIndexOutOfBoundsException. ولكن سيتم إغلاق الملف، حتى مع عدم استخدامنا لدالة ()close وكتلة finally، لأن دالة ()use تقوم بتنفيذهما بطريقة أكثر أمانًا نيابةً عنّا.

ويمكننا أن نختصر الشفرة أعلاه بحذف مرجع الكائن writer، لأنه لا داعي له إذا لم نكن نحتاج للإشارة في مكان آخر في الشفرة:

FileWriter("src/file1.txt").use { fileWriter ->
        fileWriter.write("Hello ExVar", 7, 5)
}

ويمكننا أيضًا حذف معامل اللامبدا واستبداله بـ it، لأنه معامل واحد فقط. و it تشير إلى كائن FileWriter الحالي والذي يستدعي دالة ()use عبر استخدام النقطة:

FileWriter("src/file1.txt").use { 
        it.write("Hello ExVar", 7, 5)
}

تعمل دالة ()use مع تطبيقات كوتلن الموجهة لنظام الـ JVM فقط. أي تلك التطبيقات التي كتبت بشفرة كوتلن، والتي يمكن أن يتم ترجمتها لشفرة java bytecode، وهي شفرة الآلة التي يمكن تنفيذها على حاسوب جافا الافتراضي JVM. بالإضافة إلى ذلك، تعمل الدالة مع أي صنف يطبِّق الواجهة AutoCloseable.

الواجهة AutoCloseable:

واجهة AutoCloseable في لغة جافا، هي كائن يحتفظ بالموارد (مثل منفذ socket أو ملف File …الخ) حتى يتم إغلاقها. والدالة ()use، هي دالة مُلحقة بهذا الصنف. عند استخدام ()use مع كائن يطبِّق الواجهة AutoCloseable، يتم استدعاء دالة ()close تلقائيًا لتحرير المورد. ودالة ()close، هي دالة مجرّدة abstract والوحيدة في الواجهة AutoCloseable. لذا، على كل صنف يطبِّق هذه الواجهة، أن يضع تطبيق لهذه الدالة أيضًا.

كمثال، إذا كان لدينا هذا الصنف الذي يطبِّق الواجهة AutoCloseable:

class MyClass : AutoCloseable {
    override fun close() {
        // close resources
    }
}

نلاحظ أن الصنف MyClass يعيد تعريف override دالة ()close التابعة للواجهة AutoCloseable التي يطبِّقها. والآن يمكننا أن نستخدم دالة ()use مع الصنف MyClass:

fun main() {

    MyClass().use {
        println("Working with a resource")
    }

}

بالطبع مثال الصنف MyClass، هو للتوضيح فقط. لأن الصنف ليس به شفرة تتعامل مع الموارد الخارجية (يمكننا أن نكتبها إذا أردنا). ولكن في غالب الأحيان لن نحتاج ذلك لوجود عشرات الأصناف والواجهات في لغة جافا مهمتها هي التعامل مع هذه الموارد، وتطبِّق أو تمدد الواجهة AutoCloseable، والتي يمكننا الاطلاع عليها في وثائق جافا الرسمية.

الخلاصة:

يجب تحرير الموارد مثل: الملفات وقواعد البيانات ومصادر المعلومات الأخرى بعد الانتهاء من العمل عليها. غير ذلك، يمكن أن يتجاوز برنامجنا حد الذاكرة المسموح له احتلالها في جهاز المستخدم. ولتجنب ذلك، قدمت لغة جافا بنية try-with-resources للتعامل مع هذه الموارد وإغلاقها دون أو عند حدوث استثناء وتحطم البرنامج. 

في الجانب الآخر، تمتلك لغة كوتلن دالة موجزة وواضحة لإدارة الموارد تلقائيًا، ألا وهي دالة ()use. وتضمن الدالة تحرير وتنظيف المورد، بدلاً عن أن نقوم على كتابة شفرة تنظيف وتحرير بأنفسنا، والتي قد يشوبها الكثير من الأخطاء.

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

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