Skip to content

خطأ المؤشر nil

مقدمة

في إحدى المرات أثناء كتابة الكود، احتجت لاستدعاء الدالة Close() لإغلاق عدة كائنات، مثل الكود التالي:

go
type A struct {
  b B
  c C
  d D
}

func (a A) Close() error {
  if a.b != nil {
    if err := a.b.Close(); err != nil {
      return err
    }
  }

  if a.c != nil {
    if err := a.c.Close(); err != nil {
      return err
    }
  }

    if a.d != nil {
        if err := a.d.Close(); err != nil {
            return err
        }
    }

  return nil
}

لكن كتابة العديد من عبارات if للتحقق بدت غير أنيقة، وبما أن B و C و D جميعها تنفذ الدالة Close، يجب أن يكون هناك طريقة أكثر إيجازًا، لذا وضعتها في شريحة ثم تكررت على العناصر:

go
func (a A) Close() error {
  closers := []io.Closer{
    a.b,
    a.c,
    a.d,
  }

  for _, closer := range closers {
    if closer != nil {
      if err := closer.Close(); err != nil {
        return err
      }
    }
  }
  return nil
}

هذا يبدو أفضل، لنجرّب تشغيله:

go
func main() {
  var a A
  if err := a.Close(); err != nil {
    panic(err)
  }
  fmt.Println("success")
}

النتيجة كانت غير متوقعة، البرنامج تحطم، ورسالة الخطأ كالتالي، وتعني أنه لا يمكن استدعاء دالة على مستقبل nil:

panic: value method main.B.Close called using nil *B pointer

عبارة if closer != nil في الحلقة لم تعمل كفلتر كما هو متوقع.

هذا المثال هو نسخة مبسطة من خطأ واجهته سابقًا، ويمكن للمبتدئين أن يرتكبوا نفس الخطأ. لنشرح ما يحدث بالضبط.

الواجهات

في الفصول السابقة ذكرنا أن nil هو القيمة الصفرية للأنواع المرجعية، مثل الشرائح والخرائط والقنوات والدوال والمؤشرات والواجهات. بالنسبة للشرائح والخرائط والقنوات والدوال، يمكن اعتبارها جميعًا مؤشرات تشير للتطبيق المحدد.

لكن الواجهات مختلفة، فالواجهة تتكون من شيئين: النوع والقيمة.

عند محاولة إسناد nil لمتغير، لن ينجح الترجمة وستظهر الرسالة التالية:

use of untyped nil in assignment

المحتوى تقريبًا أنه لا يمكن التصريح عن متغير قيمته untyped nil. بما أنه يوجد untyped nil، فبالضرورة يوجد typed nil، وهذا غالبًا ما يظهر مع الواجهات. انظر المثال البسيط التالي:

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(pa)
  fmt.Println(pa == nil)
}

المخرجات:

<nil>
true
<nil>
false

النتيجة غريبة جدًا، فبرغم أن مخرجات pa هي nil، إلا أنها لا تساوي nil. يمكننا استخدام الانعكاس لرؤية ما هي بالفعل:

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(reflect.TypeOf(pa))
  fmt.Println(reflect.ValueOf(pa))
}

المخرجات:

<nil>
true
*int
<nil>

من النتيجة يتضح أنها فعليًا (*int)(nil)، أي أن pa تخزن نوع *int وقيمتها الفعلية nil. عند إجراء عملية المساواة على قيمة واجهة، أولًا يُحكم على ما إذا كانت أنواعها متساوية، إذا لم تكن الأنواع متساوية، يُحكم مباشرة بعدم التساوي، ثم يُحكم على ما إذا كانت القيم متساوية. منطق حكم الواجهة هذا يمكن الرجوع له في الدالة cmd/compile/internal/walk.walkCompare.

لذا، إذا أردت واجهة تساوي nil، يجب أن تكون قيمتها nil ونوعها أيضًا nil، لأن نوع الواجهة فعليًا هو مؤشر:

go
type iface struct {
  tab  *itab
  data unsafe.Pointer
}

إذا أردت تجاوز النوع والحكم مباشرة على ما إذا كانت القيمة nil، يمكنك استخدام الانعكاس، مثال:

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(reflect.ValueOf(pa).IsNil())
}

من خلال IsNil() يمكن الحكم مباشرة على ما إذا كانت القيمة nil، وبذلك لا تحدث المشكلة المذكورة أعلاه. لذا عند الاستخدام العادي، إذا كانت القيمة المرجعة للدالة هي نوع واجهة، إذا أردت إرجاع قيمة صفرية، يُفضَّل إرجاع nil مباشرة، لا تُرجع القيمة الصفرية لأي تطبيق محدد، فبرغم أنه ينفذ الواجهة، لكنه لن يساوي nil أبدًا، وهذا قد يؤدي للأخطاء كما في المثال.

ملخص

بعد حل المشكلة أعلاه، لنرَ الأمثلة التالية:

عندما يكون مستقبل البنية مؤشرًا، يمكن استخدام nil، انظر المثال التالي:

go
type A struct {

}

func (a *A) Do()  {

}

func main() {
  var a *A
  a.Do()
}

هذا الكود يمكنه العمل بشكل طبيعي، ولن يُنتج خطأ مؤشر nil.

عندما تكون الشريحة nil، يمكن الوصول لطولها وسعتها، ويمكن إضافة عناصر لها:

func main() {
  var s []int
  fmt.Println(len(s))
  fmt.Println(cap(s))
  s = append(s, 1)
}

عندما تكون الخريطة nil، يمكن الوصول إليها، لكن الخريطة nil للقراءة فقط، ومحاولة الكتابة ستسبب panic:

go
func main() {
  var s map[string]int
  i, ok := s[""]
  fmt.Println(i, ok)
  fmt.Println(len(s))

  // محاولة الكتابة ستسبب panic
  s["a"] = 1 // panic: assignment to entry in nil map

}

هذه الخصائص المتعلقة بـ nil قد تكون محيرة، خاصة لمبتدئي Go. nil يمثل القيمة الصفرية للأنواع المذكورة أعلاه، أي القيمة الافتراضية، والقيمة الافتراضية يجب أن تُظهر السلوك الافتراضي، وهذا ما يريده مصممو Go: جعل nil أكثر فائدة بدلاً من رمي خطأ المؤشر الفارغ مباشرة. هذه الفكرة تظهر أيضًا في المكتبة القياسية، مثل بدء خادم HTTP可以这样写:

go
http.ListenAndServe(":8080", nil)

يمكننا تمرير nil Handler مباشرة، ثم مكتبة http ستستخدم Handler الافتراضي لمعالجة طلبات HTTP.

TIP

لمزيد من المعلومات يمكنك مشاهدة هذا الفيديو Understanding nil - Gopher Conference 2016، شرحه واضح وسهل الفهم.

Golang تم تحريره بواسطة www.golangdev.cn