لغة Go، التي يبحث عنها كثيرون باسم Golang، لغة صغيرة، وهذا ما يجعل الاستهانة بها سهلة. حتى المطورون الذين أمضوا سنوات مع Go ما زالوا يقعون في الفخاخ القليلة نفسها. معظم هذه الأخطاء البرمجية تمر من الترجمة (compile) دون مشكلة. كل ما في الأمر أن البرنامج يعطي نتيجة خاطئة، أو يسرّب الموارد، أو يعلق في جمود (deadlock) في بيئة الإنتاج.
فيما يلي أكثر 10 أخطاء شيوعًا. لكل منها مثال Golang قابل للتشغيل والحل الاصطلاحي.
جدول المحتويات
- 1. معاملة السلاسل النصية كأحرف بدلا من بايتات
- 2. نسيان إعادة إسناد نتيجة append
- 3. القيم الصفرية تخفي المفاتيح المفقودة في map
- 4. استخدام %v بدلا من %w عند تغليف الأخطاء
- 5. مستقبلات القيمة (value receivers) تتجاهل التعديلات بصمت
- 6. المتغيرات المظللة التي تعيد أخطاء nil
- 7. حقول struct غير المصدرة تختفي من JSON
- 8. استخدام defer داخل الحلقات
- 9. تجاهل الأخطاء باستخدام المعرف الفارغ
- 10. حالات الجمود (deadlock) الناتجة عن unbuffered channels
1. معاملة السلاسل النصية كأحرف بدلا من بايتات
السلسلة النصية في Go هي slice من البايتات للقراءة فقط، وليست مجموعة من الأحرف. تعيد len(s) عدد البايتات، والوصول بالفهرس s[i] يعيد بايتًا واحدًا. لا مشكلة في ذلك مع نصوص ASCII. لكنه يتعطل بمجرد أن يشغل حرف ما أكثر من بايت.
example.gogopackage main import ( "fmt" "unicode/utf8" ) func main() { word := "kött" fmt.Println("len:", len(word)) fmt.Println("runes:", utf8.RuneCountInString(word)) }
الكلمة السويدية التي تعني «لحم» تتكون من أربعة أحرف، لكنها تشغل خمسة بايتات. التقطيع حسب فهرس البايت قد يشطر rune إلى نصفين وينتج UTF-8 غير صالح.
الحل: استخدم utf8.RuneCountInString لعدّ الأحرف. عند المرور على النص، تعطيك for i, r := range s قيم rune. وعند التقطيع، حوّل النص إلى []rune أولًا.
2. نسيان إعادة إسناد نتيجة append
لا تحدّث الدالة append الـ slice الذي تمرره إليها. عندما تنفد السعة تحجز مصفوفة أساسية (backing array) جديدة وتعيد slice يشير إليها. وإذا تجاهلت القيمة المعادة، تضيع العناصر التي أضفتها.
example.gogopackage main import "fmt" func main() { nums := []int{1, 2, 3} appendWrong(nums) fmt.Println("wrong:", nums) nums = appendRight(nums) fmt.Println("right:", nums) } func appendWrong(s []int) { _ = append(s, 4) } func appendRight(s []int) []int { return append(s, 4) }
استدعاء append(s, 4) وحده هو في الواقع خطأ في الترجمة في Go، لأنه يجب استخدام القيمة المعادة. وجود _ = في appendWrong هدفه الوحيد تجاوز المترجم. وسلوكه مطابق لاستدعاء append دون إسناد النتيجة من جديد أبدًا.
الحل: أعد الإسناد دائمًا عبر s = append(s, x). وإذا كانت دالة مساعدة تضيف عناصر إلى slice، فاجعلها تعيد الـ slice الجديد، ثم أسنده من جديد في مكان الاستدعاء.
3. القيم الصفرية تخفي المفاتيح المفقودة في map
قراءة مفتاح غير موجود من map لا تعيد خطأ. بل تعيد القيمة الصفرية لنوع القيم فيها. في حالة map[string]int تكون هذه القيمة 0، ولا يمكنك تمييزها عن مفتاح ضُبطت قيمته على صفر عمدًا.
example.gogopackage main import "fmt" func main() { scores := map[string]int{"alice": 0} if v := scores["alice"]; v == 0 { fmt.Println("alice looks the same as missing users") } if v, ok := scores["bob"]; ok { fmt.Println("bob:", v) } else { fmt.Println("bob is missing") } }
الحل: استخدم صيغة comma-ok الاصطلاحية، v, ok := m[key]. تخبرك القيمة المنطقية (boolean) ما إذا كان المفتاح موجودًا.
4. استخدام %v بدلا من %w عند تغليف الأخطاء
عند استخدام fmt.Errorf مع %v يُنسَّق الخطأ الأصلي داخل نص جديد وتُهمل قيمة الخطأ نفسها. أما مع %w فإن الخطأ الجديد يغلّف الخطأ الأصلي، فيظل بإمكان errors.Is و errors.As العثور عليه.
example.gogopackage main import ( "errors" "fmt" "os" ) func main() { _, err := os.Open("does-not-exist.txt") wrapped := fmt.Errorf("reading config: %w", err) fmt.Println(wrapped) fmt.Println("is not-exist:", errors.Is(wrapped, os.ErrNotExist)) }
الحل: غلّف الخطأ باستخدام %w كلما احتاج المستدعون إلى فحص الخطأ الأساسي. ولا تستخدم %v إلا عندما يكون الخطأ الأصلي تفصيلًا داخليًا في التنفيذ لا تريد أن يعتمد عليه المستدعون.
5. مستقبلات القيمة (value receivers) تتجاهل التعديلات بصمت
الدالة (method) التي تستخدم value receiver تحصل على نسخة من الـ struct. وأي تغيير تجريه على تلك النسخة يضيع بمجرد انتهاء الدالة.
example.gogopackage main import "fmt" type Counter struct { n int } func (c Counter) IncValue() { c.n++ } func (c *Counter) IncPointer() { c.n++ } func main() { c := Counter{} c.IncValue() fmt.Println("after value receiver:", c.n) c.IncPointer() fmt.Println("after pointer receiver:", c.n) }
الحل: استخدم مستقبل المؤشر (pointer receiver) عندما تغيّر الدالة المستقبِل، أو عندما يكون الـ struct كبيرًا بما يجعل نسخه هدرًا. وإذا كانت بعض دوال النوع تحتاج إلى pointer receiver، فمن الأنظف عادةً أن تمنحه لها جميعًا.
6. المتغيرات المظللة التي تعيد أخطاء nil
التصريح المختصر عن متغير (:=) داخل if ينشئ متغيرًا جديدًا لا يعيش إلا داخل تلك الكتلة. فإذا ظلّل (shadowing) متغير err خارجيًا عن غير قصد، يبقى المتغير الخارجي nil، وتبلغ الدالة عن نجاح رغم أن شيئًا ما قد فشل.
example.gogofunc loadUser(id string) (*User, error) { var err error user, err := fetch(id) if err != nil { return nil, err } if err := validate(user); err != nil { log.Println(err) // the outer err is still nil here } return user, err }
الحل: أسند القيمة إلى المتغير الخارجي عبر = بدلًا من :=، أو اخرج من الدالة فورًا داخل كتلة if. فعّل go vet -shadow أو أداة الفحص (linter) govet لاكتشاف هذه الحالات قبل مراجعة الكود.
7. حقول struct غير المصدرة تختفي من JSON
لا ترى الحزمة encoding/json إلا الحقول المصدَّرة (exported). الحقل الذي يبدأ اسمه بحرف صغير لا يراه الـ marshaller، فيُستبعد من المخرجات دون أي خطأ.
example.gogopackage main import ( "encoding/json" "fmt" ) type User struct { Name string `json:"name"` email string Age int `json:"age,omitempty"` } func main() { u := User{Name: "Ada", email: "[email protected]", Age: 36} data, _ := json.Marshal(u) fmt.Println(string(data)) }
لا يوجد مفتاح email في المخرجات، ولا شيء ينبهك إلى ذلك.
الحل: اجعل أسماء الحقول التي تريد تضمينها عند التسلسل تبدأ بحرف كبير، واستخدم struct tags لتحديد اسم مفتاح JSON. وأضف omitempty إلى الحقول الاختيارية.
8. استخدام defer داخل الحلقات
يُنفَّذ defer عند انتهاء الدالة المحيطة به، وليس في نهاية كل دورة من الحلقة. فإذا فتحت حلقة ألف ملف وأجّلت file.Close() لكل منها، ستبقى واصفات الملفات (file descriptors) الألف كلها مفتوحة حتى تنتهي الدالة.
example.gogofunc processAll(paths []string) error { for _, p := range paths { f, err := os.Open(p) if err != nil { return err } defer f.Close() // all closes pile up until processAll returns // ... read file ... } return nil }
الحل: انقل العمل الخاص بكل دورة إلى دالة مساعدة، حتى يُنفَّذ defer في نهاية كل استدعاء.
example.gogofunc processAll(paths []string) error { for _, p := range paths { if err := processOne(p); err != nil { return err } } return nil } func processOne(path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() // ... read file ... return nil }
9. تجاهل الأخطاء باستخدام المعرف الفارغ
كتابة result, _ := doThing() تتخلص من أي فشل تبلغ عنه الدالة. يواصل البرنامج عمله بقيمة صفرية أو بحالة غير مكتملة، وعندما ينهار في النهاية يكون ذلك في مكان بعيد عن السبب الحقيقي.
example.gogodata, _ := os.ReadFile("config.json") _ = json.Unmarshal(data, &cfg)
إذا كان الملف غير موجود، تكون data مساوية لـ nil، ويعيد Unmarshal خطأً لا يقرؤه أحد، وتبقى cfg على قيمتها الصفرية. وتكتشف المشكلة لاحقًا، عندما يتصرف التطبيق وكأنه لا توجد إعدادات أصلًا.
الحل: عالج الخطأ أو أعده إلى المستدعي. لا تستخدم _ إلا عندما لا يمكن للخطأ أن يؤثر فعلًا، واترك تعليقًا قصيرًا يوضح السبب.
10. حالات الجمود (deadlock) الناتجة عن unbuffered channels
في الـ channel غير المخزنة (unbuffered channel)، لا يكتمل الإرسال إلا عندما يستلم مستقبِل القيمة في اللحظة نفسها. وإذا لم يستقبل أحد أبدًا، يبقى الإرسال معلقًا إلى الأبد.
example.gogopackage main import "fmt" func main() { ch := make(chan int) // unbuffered ch <- 42 // deadlock: no goroutine is receiving fmt.Println(<-ch) }
ينتظر الإرسال مستقبِلًا، لكن الاستقبال موجود في السطر التالي ولا يصل إليه التنفيذ أبدًا. يلاحظ وقت التشغيل (runtime) أن كل الـ goroutines عالقة، فينهي البرنامج مع الرسالة fatal error: all goroutines are asleep - deadlock!.
الحل: تأكد من وجود goroutine جاهزة للاستقبال قبل أن ترسل، أو استخدم channel مخزنة (buffered) عندما يعمل المنتج والمستهلك بسرعات مختلفة. وللنتيجة الواحدة، تُعد channel بمخزن حجمه 1 خيارًا افتراضيًا آمنًا.
example.gogopackage main import "fmt" func main() { ch := make(chan int, 1) // buffered ch <- 42 fmt.Println(<-ch) }
واصل التعلم
معرفة فخاخ Golang العشرة هذه ستوفر عليك الكثير من التصحيح. أما الباقي فتكتسبه من بناء مشاريع حقيقية ومواجهة الحالات الحدية وأداة التصحيح (debugger) مفتوحة أمامك. إذا كنت تريد تدريبًا منظمًا، فإن مسار Go في LevelUpGo يغطي هذه الأنماط من خلال تمارين قابلة للتشغيل وملاحظات آلية على الكود الذي تكتبه.
