العودة إلى المدونة

10 أخطاء شائعة في Go (Golang) يجب على كل مطور تجنبها

تعرّف على أكثر 10 أخطاء شيوعًا في برمجة Go (Golang) وكيف تتجنبها: التعامل مع بايتات السلاسل النصية، و append مع الـ slice، والقيم الصفرية في map، وتغليف الأخطاء، و value receivers، و defer داخل الحلقات، و unbuffered channels، وغير ذلك.

10 أخطاء شائعة في Go (Golang) يجب على كل مطور تجنبها

لغة Go، التي يبحث عنها كثيرون باسم Golang، لغة صغيرة، وهذا ما يجعل الاستهانة بها سهلة. حتى المطورون الذين أمضوا سنوات مع Go ما زالوا يقعون في الفخاخ القليلة نفسها. معظم هذه الأخطاء البرمجية تمر من الترجمة (compile) دون مشكلة. كل ما في الأمر أن البرنامج يعطي نتيجة خاطئة، أو يسرّب الموارد، أو يعلق في جمود (deadlock) في بيئة الإنتاج.

فيما يلي أكثر 10 أخطاء شيوعًا. لكل منها مثال Golang قابل للتشغيل والحل الاصطلاحي.

جدول المحتويات


1. معاملة السلاسل النصية كأحرف بدلا من بايتات

السلسلة النصية في Go هي slice من البايتات للقراءة فقط، وليست مجموعة من الأحرف. تعيد len(s) عدد البايتات، والوصول بالفهرس s[i] يعيد بايتًا واحدًا. لا مشكلة في ذلك مع نصوص ASCII. لكنه يتعطل بمجرد أن يشغل حرف ما أكثر من بايت.

example.gogo
package 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.gogo
package 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.gogo
package 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.gogo
package 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.gogo
package 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.gogo
func 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.gogo
package 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.gogo
func 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.gogo
func 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.gogo
data, _ := os.ReadFile("config.json")
_ = json.Unmarshal(data, &cfg)

إذا كان الملف غير موجود، تكون data مساوية لـ nil، ويعيد Unmarshal خطأً لا يقرؤه أحد، وتبقى cfg على قيمتها الصفرية. وتكتشف المشكلة لاحقًا، عندما يتصرف التطبيق وكأنه لا توجد إعدادات أصلًا.

الحل: عالج الخطأ أو أعده إلى المستدعي. لا تستخدم _ إلا عندما لا يمكن للخطأ أن يؤثر فعلًا، واترك تعليقًا قصيرًا يوضح السبب.

10. حالات الجمود (deadlock) الناتجة عن unbuffered channels

في الـ channel غير المخزنة (unbuffered channel)، لا يكتمل الإرسال إلا عندما يستلم مستقبِل القيمة في اللحظة نفسها. وإذا لم يستقبل أحد أبدًا، يبقى الإرسال معلقًا إلى الأبد.

example.gogo
package 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.gogo
package main

import "fmt"

func main() {
    ch := make(chan int, 1) // buffered
    ch <- 42
    fmt.Println(<-ch)
}

واصل التعلم

معرفة فخاخ Golang العشرة هذه ستوفر عليك الكثير من التصحيح. أما الباقي فتكتسبه من بناء مشاريع حقيقية ومواجهة الحالات الحدية وأداة التصحيح (debugger) مفتوحة أمامك. إذا كنت تريد تدريبًا منظمًا، فإن مسار Go في LevelUpGo يغطي هذه الأنماط من خلال تمارين قابلة للتشغيل وملاحظات آلية على الكود الذي تكتبه.

اكتب Go كما يكتبها مهندس أول

دروس تفاعلية في متصفحك. الدروس الأولى مجانية.

جرّب درسًا مجانيًاأو أنشئ حسابًا مجانيًا