اذهب إلى المحتوى

التعليمة defer في لغة جو Go


هدى جبور

تتضمن لغة جو العديد من تعليمات التحكم بسير عمل البرنامج مثل 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.

اقرأ أيضا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...