تتضمن لغة جو العديد من تعليمات التحكم بسير عمل البرنامج مثل if
و switch
و for
…إلخ، وهذه التعليمات موجودة في أغلب لغات البرمجة، إلا أنّ هناك تعليمة خاصة في لغة جو غير موجودة في معظم اللغات الأخرى هي تعليمة التأجيل defer
.
على الرغم من أن هذه التعليمة ليست مشهورة إلا أنها مفيدة، فالهدف الأساسي من هذه التعليمة هو إجراء عملية تنظيف الموارد بطريقة سليمة بعد انتهاء استخدامها مثل الملفات المفتوحة أو الاتصالات بالشبكة أو مقابض قاعدة البيانات handles بعد تأجيلها ريث الانتهاء منها، إذ تُعَدّ عملية تنظيف أو تحرير الموارد بعد استخدامها أمرًا مهمًا جدًا للسماح للمستخدِمين الآخرين باستخدام هذا المورد دون وجود أي بقايا تركها المستخدِم السابق سواءً كان المستخدِم آلةً أو شخصًا أو برنامجًا أو جزءًا آخر من الشيفرة نفسها.
تساعدنا تعليمة التأجيل defer
في جعل الشيفرة أنظف وأكثر متانةً وأقل عرضةً للأخطاء من خلال جعل تعليمة استدعاء أو حجز الملف أو المورد قريبة من تعليمة تحريره، وفي هذا المقال سنتعلم كيفية استخدام تعليمة التأجيل defer
بطريقة صحيحة لتنظيف الموارد بالإضافة إلى العديد من الأخطاء الشائعة التي تحدث عند استخدام هذه التعليمة.
ما هي تعليمة defer؟
تسمح لك جو بتأجيل تنفيذ استدعاء دالة بإضافتها إلى طابور تنفيذ خاص ريثما تكون الدالة المُستدعاة ضمنها قد أكتمل تنفيذها من خلال استخدام الكلمة المفتاحية defer
قبلها بالشكل التالي:
package main import "fmt" func main() { defer fmt.Println("Bye") fmt.Println("Hi") }
أجّلنا هنا استخدام الدالة ("fmt.Println("Bye
إلى حين انتهاء تنفيذ الدالة المُستدعاة ضمنها أي الدالة main
، وبالتالي سيكون الخرج كما يلي:
Hi Bye
أي سيُنفّذ هنا كل شيء داخل الدالة main
ثم بعد الانتهاء من كل التعليمات (هنا لا توجد إلا تعليمة واحدة هي التعليمة التي تطبع Hi
) ستُنفَّذ التعليمة المؤجلة، كما رأيت بالمثال.
والآن قد يتبادر إلى الأذهان السؤال التالي: ماذا لو كان هناك أكثر من استدعاء مؤجل، أي أكثر من دالة مؤجلة، ماذا سيحدث؟ كل دالة تُسبق بتعليمة التأجيل تُضاف إلى مكدس، ومن المعروف أنّ المكدس هو بنية معطيات تخرج منها البيانات وفق مبدأ مَن يدخل آخرًا يخرج أولًا، وبالتالي إذا كان لدينا أكثر من استدعاء مؤجل، فسيكون التنفيذ وفق هذه القاعدة، ولجعل الأمور أبسط سنأخذ المثال التالي:
package main import "fmt" func main() { defer fmt.Println("Bye1") defer fmt.Println("Bye2") fmt.Println("Hi") }
سيكون الخرج كما يلي:
Hi Bye2 Bye1
لدينا في البرنامج السابق استدعاءان مؤجلان، إذ يُضاف أولًا إلى المكدس التعليمة التي تطبع Bye1
ثم التعليمة التي تطبع Bye2
، ووفق القاعدة السابقة ستُطبع التعليمة التي أٌضيفت أخيرًا إلى المكدس أي Bye2
ثم Bye1
، وبالطبع تُنفَّذ التعليمة التي تطبع Hi
وأيّ تعليمة أخرى ضمن الدالة main
قبل تنفيذ أيّ تعليمة مؤجلة، ولدينا مثال آخر كما يلي:
package main import "fmt" func main() { fmt.Println("Hi0") defer fmt.Println("Bye1") defer fmt.Println("Bye2") fmt.Println("Hi1") fmt.Println("Hi2") }
سيكون الخرج كما يلي:
Hi0 Hi1 Hi2 Bye2 Bye1
عند تأجيل تنفيذ دالة ما ستقيَّم الوسائط على الفور، وبالتالي لن يؤثر أيّ تعديل لاحق، لذا انتبه جيدًا إلى المثال التالي:
package main import "fmt" func main() { x := 9 defer fmt.Println(x) x = 10 }
سيكون الخرج كما يلي:
9
كانت هنا قيمة x
تساوي 9 ثم أجلنا تنفيذ دالة الطباعة التي تطبع قيمة هذا المتغير، وعلى الرغم من أنّ تعليمة x=10
ستُنفَّذ قبل تعليمة الطباعة، إلا أنّ القيمة التي طُبعَت هي القيمة السابقة للمتغير x
وذلك للسبب الذي ذكرناه منذ قليل.
ما تعلمناه حتى الآن كان بغرض التوضيح فقط، إذ لا تُستخدم بهذا الشكل بل عادة ما تُستخدم تعليمة التأجيل في تنظيف الموارد وهذا ما سنراه تاليًا.
تنظيف الموارد باستخدام تعليمة التأجيل
يُعَدّ استخدام تعليمة التأجيل لتنظيف الموارد أمرًا شائعًا جدًا في جو، وسنلقي الآن نظرةً على برنامج يكتب سلسلةً نصيةً في ملف ولكنه لا يستخدِم تعليمة التأجيل لتنظيف المورد:
package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } _, err = io.WriteString(file, text) if err != nil { return err } file.Close() return nil }
لدينا في هذا البرنامج دالة تسمى write
والهدف منها بدايةً هو محاولة إنشاء ملف، وإذا حصل خطأ أثناء هذه المحاولة، فستُعيد هذا الخطأ وتُنهي التنفيذ؛ أما في حال نجاح عملية إنشاء الملف، فستكتب فيه السلسلة This is a readme file
، وفي حال فشلت عملية الكتابة، فستُعيد الخطأ وتنهي تنفيذ الدالة أيضًا، وأخيرًا إذا نجح كل شيء، فستغلق الدالة الملف الذي أُنشِئ معيدة إياه إلى نظام الملفات ثم تُعيد القيمة nil
إشارةً إلى نجاح التنفيذ.
هذا البرنامج يعمل بطريقة سليمة، إلا أنّ هناك زلة برمجية صغيرة؛ فإذا فشلت عملية الكتابة في الملف، فسيبقى الملف المُنشئ مفتوحًا، أي توجد هناك موارد غير محررة، ولحل هذه المشكلة مبدأيًا يمكنك تعليمة file.Close()
أخرى ومن دون استخدام تعليمة التأجيل:
package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } _, err = io.WriteString(file, text) if err != nil { file.Close() return err } file.Close() return nil }
الآن سيُغلق الملف حتى إذا فشل تنفيذ io.WriteString
، وقد يكون هذا حلًا بسيطًا وأسهل من غيره، لكن إذا كانت الدوال أكثر تعقيدًا، فقد ننسى إضافة هكذا تعليمات في الأماكن المُحتملة التي قد ينتهي فيها التنفيذ قبل تحرير المورد، لذا يتمثّل الحال الأكثر احترافيةً وأمانًا باستخدام تعليمة التأجيل مع الدالة file.Close
وبالتالي ضمان تنفيذها دومًا بغض النظر عن أيّ مسار تنفيذ أو خطأ مُفاجئ قد يؤدي إلى إنهاء الدالة:
package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return nil }
أضفنا هنا السطر defer file.Close
لإخبار المُصرّف بإغلاق الملف بعد انتهاء تنفيذ الدالة، وبالتالي ضمان تحريره مهما حدث.
انتهينا هنا من زلة برمجية ولكن ستظهر لنا زلة برمجية أخرى؛ ففي حال حدث خطأ في عملية إغلاق الملف file.Close
، فلن تكون هناك أيّ معلومات حول ذلك الخطأ، أي لن يُعاد الخطأ، وبالتالي يمنعنا استخدام تعليمة التأجيل من الحصول على الخطأ أو الحصول على أيّ قيمة تُعيدها الدالة المؤجلة.
يُعَدّ استدعاء الدالة Close
في جو أكثر من مرة آمنًا ولا يؤثر على سلوك البرنامج، وفي حال كان هناك خطأ، فسيُعاد من المرة الأولى التي تُستدعى فيها الدالة، وبالتالي سيسمح لنا هذا باستدعائها ضمن مسار التنفيذ الناجح في الدالة.
سنعالج في المثال التالي المشكلة السابقة التي تتجلى بعدم إمكانية الحصول على معلومات الخطأ في حال حدوثه مع تعليمة الإغلاق المؤجلة:
package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return file.Close() }
لاحظ أنّ التعديل الوحيد على الشيفرة السابقة كان بإضافة السطر return file.Close()
، وبالتالي إذا حصل خطأ في عملية الإغلاق، فسيُعاد مباشرةً إلى الدالة التي تستدعي الدالة write
، ولاحظ أيضًا أنّ التعليمة defer file.Close
ستُنفّذ بكافة الأحوال، مما يعني أنّ تعليمة الإغلاق ستُنفّذ مرتين غالبًا، وعلى الرغم من أنّ ذلك ليس مثاليًّا، إلا أنه مقبول وآمن.
إذا ظهر خطأ في تعليمة WriteString
، فسيُعاد الخطأ وينتهي تنفيذ الدالة ثم ستُنفّذ تعليمة file.Close
لأنها تعليمة مؤجلة، وهنا على الرغم من إمكانية ظهور خطأ عند محاولة إغلاق الملف، إلا أنّ هذا الخطأ لم يَعُد مهمًا لأن الخطأ الذي تعيده WriteString
سيكون غالبًا هو السبب وراء حدوثه.
تعرّفنا حتى الآن على كيفية استخدام تعليمة التأجيل واحدة لضمان تحرير أو تنظيف مورد ما، وسنتعلّم الآن كيفية استخدام عدة تعليمات تأجيل من أجل تنظيف أكثر من مورد.
استخدام تعليمات تأجيل متعددة
من الطبيعي أن تكون لديك أكثر من تعليمة defer
في دالة ما، لذا دعنا ننشئ برنامجًا يحتوي على تعليمات تأجيل فقط لنرى ما سيحدث في هذه الحالة (كما أشرنا سابقًا):
package main import "fmt" func main() { defer fmt.Println("one") defer fmt.Println("two") defer fmt.Println("three") }
سيكون الخرج كما يلي:
three two one
كما ذكرنا سابقًا أنّ سبب الطباعة بهذا الترتيب هو أن تعليمة التأجيل تعتمد على تخزين سلسلة الاستدعاءات ضمن بنية المكدس، والمهم الآن أن تعرف أنه يمكن أن يكون لديك العديد من الاستدعاءات المؤجلة حسب الحاجة في دالة ما، ومن المهم أن تتذكر أنها تُستدعى جميعها بعكس ترتيبها في الشيفرة حسب بنية المكدس.
الآن بعد أن فهمنا هذه الفكرة جيدًا، سننشئ برنامجًا يفتح ملفًا ويكتب عليه ثم يفتحه مرةً أخرى لنسخ المحتويات إلى ملف آخر:
package main import ( "fmt" "io" "log" "os" ) func main() { if err := write("sample.txt", "This file contains some sample text."); err != nil { log.Fatal("failed to create file") } if err := fileCopy("sample.txt", "sample-copy.txt"); err != nil { log.Fatal("failed to copy file: %s") } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return file.Close() } func fileCopy(source string, destination string) error { src, err := os.Open(source) if err != nil { return err } defer src.Close() dst, err := os.Create(destination) if err != nil { return err } defer dst.Close() n, err := io.Copy(dst, src) if err != nil { return err } fmt.Printf("Copied %d bytes from %s to %s\n", n, source, destination) if err := src.Close(); err != nil { return err } return dst.Close() }
لدينا هنا دالة جديدة fileCopy
تفتح أولًا ملف المصدر الذي سننسخ منه ثم تتحقق من حدوث خطأ أثناء فتح الملف، فإذا كان الأمر كذلك، فسنعيد الخطأ ونخرج من الدالة، وإلا فإننا نؤجل إغلاق الملف المصدر الذي فتحناه.
نُنشئ بعد ذلك ملف الوجهة ونتحقق من حدوث خطأ أثناء إنشاء الملف، فإذا كان الأمر كذلك، فسنعيد هذا الخطأ ونخرج من الدالة، وإلا فسنؤجل أيضًا إغلاق ملف الوجهة، وبالتالي لدينا الآن دالتان مؤجلتان ستُستدعيان عندما تخرج الدالة من نطاقها.
الآن بعد فتح كل من ملف المصدر والوجهة فإننا سننسخ ()Copy
البيانات من الملف المصدر إلى الملف الوجهة، فإذا نجح ذلك، فسنحاول إغلاق كلا الملفين، وإذا تلقينا خطأً أثناء محاولة إغلاق أيّ ملف، فسنُعيد الخطأ ونخرج من الدالة.
لاحظ أننا نستدعي ()Close
لكل ملف صراحةً على الرغم من أنّ تعليمة التأجيل ستستدعيها أيضًا، والسبب وراء ذلك هو كما ذكرناه في الفقرة السابقة؛ وهو للتأكد من أنه إذا كان هناك خطأ في إغلاق ملف، فإننا نُعيد معلومات هذا الخطأ، كما يضمن أنه إذا أُنهيَت الدالة مُبكرًا لأيّ سبب من الأسباب بسبب خطأ ما مثل الفشل في عملية النسخ بين الملفين، فسيحاول كل ملف الإغلاق بطريقة سليمة من خلال تنفيذ الاستدعاءات المؤجلة.
الخاتمة
تحدثنا في هذا المقال عن تعليمة التأجيل defer
وكيفية استخدامها والتعامل معها وأهميتها في تنظيف وتحرير الموارد بعد الانتهاء من استخدامها، وبالتالي ضمان توفير الموارد للمستخدِمين المختلفِين وتوفير الذاكرة.
يمكنك أيضًا الرجوع إلى مقال معالجة حالات الانهيار في لغة جو Go: يتضمن حالةً خاصةً من حالات استخدام تعليمة التأجيل.
ترجمة -وبتصرُّف- للمقال Understanding defer in Go لصاحبه Gopher Guides.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.