الكتاب الأساسي

الطرق في Go

تدعم Go بعض خصائص البرمجة كائنية التوجه (object-orient programming). والطرق (methods) إحدى هذه الخصائص. وستقدّم هذه المقالة المفاهيم المتعلقة بالطرق في Go.

تعريفات الطرق

في Go، يمكننا أن نعرّف (صراحةً) طريقة للنوع T والنوع *T، حيث يجب أن يستوفي T أربعة شروط:

  1. يجب أن يكون T نوعًا معرَّفًا؛
  2. يجب أن يكون T معرَّفًا في الحزمة نفسها التي فيها تعريف الطريقة؛
  3. يجب ألّا يكون T نوع مؤشر؛
  4. يجب ألّا يكون T نوع واجهة. وستُشرح أنواع الواجهات في المقالة التالية.

يُسمى النوعان T و *T نوع المستقبِل (receiver type) للطرق المعرَّفة لكلٍّ منهما على الترتيب. ويُسمى النوع T النوع الأساسي للمستقبِل (receiver base type) لجميع الطرق المعرَّفة لكلٍّ من النوعين T و *T.

لاحظ أنه يمكننا أيضًا تعريف طرق لـالأسماء المستعارة للنوع للنوعين T و *T المذكورين أعلاه. والأثر هو نفس أثر تعريف الطرق للنوعين T و *T أنفسهما.

إذا عُرّفت طريقة لنوع ما، يمكننا القول إن النوع يمتلك هذه الطريقة.

من الشروط المذكورة أعلاه نستنتج أنه لا يمكننا أبدًا تعريف طرق (صراحةً) لـ:

  • الأنواع المعرَّفة مسبقًا (predeclared types)، مثل int و string، لأننا لا نستطيع تعريف طرق في الحزمة القياسية builtin.
  • أنواع الواجهات. لكن نوع الواجهة يمكنه امتلاك طرق. يُرجى قراءة المقالة التالية للتفاصيل.
  • الأنواع بلا اسم، باستثناء أنواع المؤشرات ذات الصيغة *T الموضّحة أعلاه.

يشبه تعريف الطريقة تعريف الدالة، لكنه يحتوي على جزء إضافي لتعريف الوسائط. ويمكن أن يحتوي الجزء الإضافي على وسيط واحد فقط من نوع مستقبِل الطريقة. ويُسمى هذا الوسيط الوحيد وسيط المستقبِل (receiver parameter) في تعريف الطريقة. ويجب إحاطة وسيط المستقبِل بـ () وتعريفه بين الكلمة المفتاحية func واسم الطريقة.

فيما يلي بعض أمثلة تعريف الطرق:


// Age و int نوعان مختلفان. لا يمكننا
// تعريف طرق لـ int و *int،
// لكن يمكننا ذلك لـ Age و *Age.
type Age int
func (age Age) LargerThan(a Age) bool {
	return age > a
}
func (age *Age) Increase() {
	*age++
}

// مستقبِل نوع دالة معرَّف مخصص.
type FilterFunc func(in int) bool
func (ff FilterFunc) Filter(in int) bool {
	return ff(in)
}

// مستقبِل نوع خريطة معرَّف مخصص.
type StringSet map[string]struct{}
func (ss StringSet) Has(key string) bool {
	_, present := ss[key]
	return present
}
func (ss StringSet) Add(key string) {
	ss[key] = struct{}{}
}
func (ss StringSet) Remove(key string) {
	delete(ss, key)
}

// مستقبِل نوع بنية معرَّف مخصص.
type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

من الأمثلة أعلاه نعرف أن الأنواع الأساسية للمستقبِل لا يمكن أن تكون أنواع بنى فحسب، بل يمكن أن تكون أيضًا أنواعًا من أصناف أخرى، مثل الأنواع الأساسية والأنواع الحاوية (container types)، ما دامت الأنواع الأساسية للمستقبِل تستوفي الشروط الأربعة المذكورة أعلاه.

في بعض لغات البرمجة الأخرى، تكون أسماء وسائط المستقبِل دائمًا this الضمني، وهو معرّف غير موصى به لأسماء وسائط المستقبِل في Go.

يُسمى مستقبِل النوع *T مستقبِل المؤشر (pointer receiver)، وتُسمى المستقبِلات غير المؤشرية مستقبِلات القيمة (value receivers). شخصيًا، لا أوصي بالنظر إلى مصطلح المؤشر على أنه نقيض لمصطلح القيمة، لأن قيم المؤشرات مجرد قيم خاصة. لكنني لا أعارض استخدام مصطلحي مستقبِل المؤشر ومستقبِل القيمة هنا. وسيُشرح السبب أدناه.

يمكن أن تكون أسماء الطرق المعرّف الفارغ _. ويمكن أن يمتلك نوع ما عدة طرق باسم المعرّف الفارغ. لكن هذه الطرق لا يمكن استدعاؤها أبدًا. ولا يمكن استدعاء إلا الطرق المصدَّرة من حزم أخرى. وسيُقدَّم استدعاء الطرق في قسم لاحق.

كل طريقة تقابلها دالة ضمنية

لكل تعريف طريقة، سيُعرّف المصرِّف دالة ضمنية مقابلة لها. فبالنسبة للطريقتين الأخيرتين المعرَّفتين للنوع Book والنوع *Book في المثال الأخير في القسم السابق، يصرّح المصرِّف ضمنيًا بالدالتين التاليتين:


func Book.Pages(b Book) int {
	// الجسم مطابق لطريقة Pages.
	return b.pages
}

func (*Book).SetPages(b *Book, pages int) {
	// الجسم مطابق لطريقة SetPages.
	b.pages = pages
}

في كلٍّ من تعريفي الدالتين الضمنيتين، يُزال وسيط المستقبِل من تعريف الطريقة المقابل له ويُدرج في قائمة الوسائط العادية كأول وسيط. وأجسام الدالتين المعرَّفتين ضمنيًا مطابقة لأجسام الطريقتين الصريحتين المقابلتين لهما.

اسم الدالة الضمنية، Book.Pages و (*Book).SetPages، كلاهما على الصيغة TypeDenotation.MethodName. وبما أن المعرّفات في Go لا يمكن أن تحتوي على المحرف الخاص النقطة، فإن اسمي الدالتين الضمنيتين ليسا معرّفين قانونيين، لذا لا يمكن تعريف الدالتين صراحةً. ولا يمكن للمصرِّفات تعريفهما ضمنيًا فحسب، لكن يمكن استدعاؤهما في شيفرة المستخدم:


package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var book Book
	// استدعِ الدالتين المعرَّفتين ضمنيًا.
	(*Book).SetPages(&book, 123)
	fmt.Println(Book.Pages(book)) // 123
}

في الواقع، لا تقتصر المصرِّفات على تعريف الدالتين الضمنيتين، بل تعيد أيضًا كتابة الطريقتين المعرَّفتين صراحةً لتستدعي الطريقتان الدالتين الضمنيتين في جسمي الطريقتين (على الأقل، يمكننا اعتبار أن هذا ما يحدث)، تمامًا كما تُظهر الشيفرة التالية:


func (b Book) Pages() int {
	return Book.Pages(b)
}

func (b *Book) SetPages(pages int) {
	(*Book).SetPages(b, pages)
}

الطرق الضمنية ذات المستقبِلات المؤشرية

لكل طريقة معرَّفة لنوع مستقبِل قيمي T، سيصرّح المصرِّف ضمنيًا بطريقة مقابلة بالاسم نفسه للنوع *T. ففي المثال أعلاه، عُرّفت الطريقة Pages للنوع Book، لذا تُعرَّف ضمنيًا طريقة بالاسم نفسه Pages للنوع *Book:


// ملاحظة: هذه ليست صيغة Go قانونية.
// وهي معروضة هنا لغرض الشرح فقط.
// وهي تشير إلى أن التعبير (&aBook).Pages
// يُقيَّم على أنه aBook.Pages (انظر الأقسام أدناه).
func (b *Book) Pages = (*b).Pages

ولهذا لا أرفض استخدام مصطلح مستقبِل القيمة (كنقيض لمصطلح مستقبِل المؤشر). ففي نهاية المطاف، عندما نعرّف طريقة صراحةً لنوع غير مؤشري، فإننا في الحقيقة نعرّف طريقتين: الطريقة الصريحة للنوع غير المؤشري والطريقة الضمنية لنوع المؤشر المقابل.

كما ذُكر في القسم السابق، لكل طريقة معرَّفة، ستعرّف المصرِّفات أيضًا دالة ضمنية مقابلة لها. لذا بالنسبة للطريقة المعرَّفة ضمنيًا، تُعرّف المصرِّفات الدالة الضمنية التالية.


func (*Book).Pages(b *Book) int {
	return Book.Pages(*b)
}

بعبارة أخرى، لكل طريقة معرَّفة صراحةً بمستقبِل قيمي، تُعرَّف في الوقت نفسه أيضًا دالتان ضمنيتان وطريقة ضمنية واحدة.

مواصفات الطرق ومجموعات الطرق

يمكن النظر إلى مواصفة الطريقة (method specification) على أنها نموذج دالة أولي بدون الكلمة المفتاحية func. ويمكننا اعتبار أن كل تعريف طريقة يتكوّن من الكلمة المفتاحية func وتعريف وسيط المستقبِل ومواصفة الطريقة وجسم الطريقة (الدالة).

على سبيل المثال، مواصفتا الطريقتين Pages و SetPages المعروضتين أعلاه هما


Pages() int
SetPages(pages int)

لكل نوع مجموعة طرق (method set). وتتكوّن مجموعة طرق نوع غير واجهة من جميع مواصفات الطرق المعرَّفة للنوع، صراحةً أو ضمنيًا، باستثناء تلك التي أسماؤها المعرّف الفارغ _. وستُشرح أنواع الواجهات في المقالة التالية.

على سبيل المثال، مجموعة طرق النوع Book المعروض في الأقسام السابقة هي


Pages() int

ومجموعة طرق النوع *Book هي


Pages() int
SetPages(pages int)

ترتيب مواصفات الطرق داخل مجموعة الطرق لا يهم بالنسبة لمجموعة الطرق.

بالنسبة لمجموعة طرق ما، إذا كانت كل مواصفة طريقة فيها موجودة أيضًا في مجموعة طرق أخرى، نقول إن مجموعة الطرق الأولى مجموعة جزئية (subset) من الثانية، وإن الثانية مجموعة شاملة (superset) للأولى. وإذا كانت مجموعتا طرق مجموعة جزئية (أو شاملة) إحداهما للأخرى، نقول إن مجموعتي الطرق متطابقتان.

بالنظر إلى نوع T، بافتراض أنه ليس نوع مؤشر ولا نوع واجهة، فللسبب المذكور في القسم السابق، تكون مجموعة طرق النوع T دائمًا مجموعة جزئية من مجموعة طرق النوع *T. على سبيل المثال، مجموعة طرق النوع Book المعروض أعلاه مجموعة جزئية من مجموعة طرق النوع *Book.

يُرجى الملاحظة أن أسماء الطرق غير المصدَّرة، التي تبدأ بحروف صغيرة، من حزم مختلفة ستُعتبر دائمًا اسمي طريقة مختلفين، حتى لو كان الاسمان متطابقين حرفيًا.

تؤدي مجموعات الطرق دورًا مهمًا في خاصية تعدد الأشكال (polymorphism) في Go. ولمعرفة المزيد عن تعدد الأشكال، يُرجى قراءة المقالة التالية (الواجهات في Go) للتفاصيل.

مجموعات طرق الأنواع التالية فارغة دائمًا:

  • الأنواع الأساسية المدمجة.
  • أنواع المؤشرات المعرَّفة.
  • أنواع المؤشرات التي تكون أنواعها الأساسية أنواع واجهات أو أنواع مؤشرات.
  • أنواع المصفوفات والشرائح والخرائط والدوال والقنوات بلا اسم.

قيم الطرق واستدعاءات الطرق

الطرق في الحقيقة دوال خاصة. وغالبًا ما تُسمى الطرق دوال الأعضاء (member functions). وعندما يمتلك نوع ما طريقة، ستمتلك كل قيمة من قيم النوع عضوًا غير قابل للتغيير من نوع دالة. واسم العضو مطابق لاسم الطريقة، ونوع العضو مطابق للدالة المعرَّفة بصيغة تعريف الطريقة لكن بدون جزء المستقبِل.

واستدعاء الطريقة ليس إلا استدعاءً لدالة عضو كهذه. وبالنسبة لقيمة v، يمكن تمثيل طريقتها m بصيغة المحدِّد (selector) v.m، وهي قيمة دالة.

مثال يتضمن بعض استدعاءات الطرق:


package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var book Book

	fmt.Printf("%T \n", book.Pages)       // func() int
	fmt.Printf("%T \n", (&book).SetPages) // func(int)
	// لـ &book طريقة ضمنية.
	fmt.Printf("%T \n", (&book).Pages) // func() int

	// استدعِ الطرق الثلاث.
	(&book).SetPages(123)
	book.SetPages(123) // مكافئ للسطر السابق
	fmt.Println(book.Pages())    // 123
	fmt.Println((&book).Pages()) // 123
}

(على خلاف لغة C، لا يوجد المعامل -> في Go لاستدعاء الطرق ذات المستقبِلات المؤشرية، لذا فإن (&book)->SetPages(123) غير قانوني في Go.)

مهلًا! لماذا يُصرَّف السطر book.SetPages(123) في المثال أعلاه بنجاح؟ ففي نهاية المطاف، الطريقة SetPages غير معرَّفة للنوع Book. من ناحية، يمكن النظر إلى هذا على أنه سكر نحوي (syntactic sugar) لتسهيل البرمجة. وهذا السكر يعمل فقط مع مستقبِلات القيمة القابلة للعنونة. وسيأخذ المصرِّف ضمنيًا عنوان القيمة القابلة للعنونة book عند تمريرها كمعطى مستقبِل في استدعاء الطريقة SetPages. ومن ناحية أخرى، ينبغي أن نعتبر أيضًا أن aBookExpression.SetPages محدِّد قانوني دائمًا (من وجهة نظر الصيغة النحوية)، حتى لو قُيِّم التعبير aBookExpression كقيمة Book غير قابلة للعنونة، وفي هذه الحالة يكون المحدِّد aBookExpression.SetPages غير صالح (لكنه قانوني).

كما ذُكر أعلاه للتو، عندما تُعرَّف طريقة لنوع ما، ستمتلك كل قيمة من قيم النوع دالة عضو. والقيم الصفرية ليست استثناءً، سواء مُثِّلت القيم الصفرية للأنواع بـ nil أم لا.

مثال:


package main

type StringSet map[string]struct{}
func (ss StringSet) Has(key string) bool {
	// لا يحدث panic هنا أبدًا، حتى لو كانت ss تساوي nil.
	_, present := ss[key]
	return present
}

type Age int
func (age *Age) IsNil() bool {
	return age == nil
}
func (age *Age) Increase() {
	*age++ // إذا كان age مؤشرًا nil، فإن
	       // إلغاء الإشارة إليه سيسبب panic.
}

func main() {
	_ = (StringSet(nil)).Has   // لن يحدث panic
	_ = ((*Age)(nil)).IsNil    // لن يحدث panic
	_ = ((*Age)(nil)).Increase // لن يحدث panic

	_ = (StringSet(nil)).Has("key") // لن يحدث panic
	_ = ((*Age)(nil)).IsNil()       // لن يحدث panic

	// السطر التالي سيسبب panic. لكن
	// الـ panic ليس ناتجًا عن استدعاء الطريقة.
	// بل ينتج عن إلغاء الإشارة إلى مؤشر nil
	// داخل جسم الطريقة.
	((*Age)(nil)).Increase()
}

تُمرَّر معطيات المستقبِل بالنسخ

مثل معطيات الدوال العادية، تُمرَّر معطيات المستقبِل أيضًا بالنسخ. لذا فإن التعديلات على الجزء المباشر لمعطى المستقبِل في استدعاء طريقة لن تنعكس خارج الطريقة.

مثال:


package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var b Book
	b.SetPages(123)
	fmt.Println(b.pages) // 0
}

مثال آخر:


package main

import "fmt"

type Book struct {
	pages int
}

type Books []Book

func (books Books) Modify() {
	// التعديلات على الجزء الكامن من
	// المستقبِل ستنعكس خارج
	// الطريقة.
	books[0].pages = 500
	// التعديلات على الجزء المباشر من
	// المستقبِل لن تنعكس خارج
	// الطريقة.
	books = append(books, Book{789})
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{500} {456}]
}

خارج الموضوع قليلًا، إذا عُكس ترتيب السطرين في طريقة Modify أعلاه، فلن ينعكس أي من التعديلين خارج جسم الطريقة.


func (books Books) Modify() {
	books = append(books, Book{789})
	books[0].pages = 500
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{123} {456}]
}

والسبب هنا هو أن استدعاء append سيخصص كتلة ذاكرة جديدة لتخزين عناصر نسخة معطى المستقبِل من نوع الشريحة الممرَّر. ولن ينعكس هذا التخصيص على معطى المستقبِل من نوع الشريحة الممرَّر نفسه.

ولجعل كلا التعديلين ينعكس خارج جسم الطريقة، يجب أن يكون مستقبِل الطريقة مؤشريًا.


func (books *Books) Modify() {
	*books = append(*books, Book{789})
	(*books)[0].pages = 500
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{500} {456} {789}]
}

تطبيع قيم الطرق

في وقت التصريف، ستطبّع المصرِّفات كل تعبير قيمة طريقة، بتحويل عمليات أخذ العنوان وإلغاء الإشارة إلى المؤشر الضمنية إلى عمليات صريحة في تعبير قيمة الطريقة ذلك.

بافتراض أن v قيمة من النوع T وأن v.m تعبير قيمة طريقة قانوني،

  • إذا كانت m طريقة معرَّفة صراحةً للنوع *T، فستطبّعها المصرِّفات على أنها (&v).m؛
  • وإذا كانت m طريقة معرَّفة صراحةً للنوع T، فإن تعبير قيمة الطريقة v.m يكون مطبَّعًا بالفعل.

بافتراض أن p قيمة من النوع *T وأن p.m تعبير قيمة طريقة قانوني،

  • إذا كانت m طريقة معرَّفة صراحةً للنوع T، فستطبّعها المصرِّفات على أنها (*p).m؛
  • وإذا كانت m طريقة معرَّفة صراحةً للنوع *T، فإن تعبير قيمة الطريقة p.m يكون مطبَّعًا بالفعل.

سيُشرح تطبيع قيم الطرق المُرقّاة (promoted) في مقال تضمين الأنواع التالي.

تقييم قيم الطرق

بافتراض أن v.m تعبير قيمة طريقة مطبَّع، في وقت التشغيل، عند تقييم قيمة الطريقة v.m، يُقيَّم معطى المستقبِل v وتُحفظ نسخة من نتيجة التقييم وتُستخدم في الاستدعاءات اللاحقة لقيمة الطريقة.

على سبيل المثال، في الشيفرة التالية،

  • تعبير قيمة الطريقة b.Pages مطبَّع بالفعل. في وقت التشغيل، تُحفظ نسخة من معطى المستقبِل b. والنسخة مطابقة لـ Book{pages: 123}، والتعديل اللاحق على القيمة b ليس له تأثير على هذه النسخة. ولهذا يطبع الاستدعاء f1() القيمة 123.
  • تعبير قيمة الطريقة p.Pages مطبَّع على أنه (*p).Pages في وقت التصريف. في وقت التشغيل، يُقيَّم معطى المستقبِل *p إلى قيمة b الحالية، وهي Book{pages: 123}. وتُحفظ نسخة من نتيجة التقييم وتُستخدم في الاستدعاءات اللاحقة لقيمة الطريقة، ولهذا يطبع الاستدعاء f2() أيضًا القيمة 123.
  • تعبير قيمة الطريقة p.Pages2 مطبَّع بالفعل. في وقت التشغيل، تُحفظ نسخة من معطى المستقبِل p. والقيمة المحفوظة هي عنوان القيمة b، لذا ستنعكس أي تغييرات على b من خلال إلغاء الإشارة إلى القيمة المحفوظة، ولهذا يطبع الاستدعاء g1() القيمة 789.
  • تعبير قيمة الطريقة b.Pages2 مطبَّع على أنه (&b).Pages2 في وقت التصريف. في وقت التشغيل، تُحفظ نسخة من نتيجة تقييم &b. والقيمة المحفوظة هي عنوان القيمة b، لذا ستنعكس أي تغييرات على b من خلال إلغاء الإشارة إلى القيمة المحفوظة، ولهذا يطبع الاستدعاء g2() القيمة 789.

package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) Pages2() int {
	return (*b).Pages()
}

func main() {
	var b = Book{pages: 123}
	var p = &b
	var f1 = b.Pages
	var f2 = p.Pages
	var g1 = p.Pages2
	var g2 = b.Pages2
	b.pages = 789
	fmt.Println(f1()) // 123
	fmt.Println(f2()) // 123
	fmt.Println(g1()) // 789
	fmt.Println(g2()) // 789
}

النوع المعرَّف لا يكتسب الطرق المعرَّفة صراحةً للنوع المصدر المستخدم في تعريفه

على سبيل المثال، في الشيفرة التالية، وعلى خلاف النوع المعرَّف MyInt، لا يمتلك النوع المعرَّف Age طريقة IsOdd.


package main

type MyInt int
func (mi MyInt) IsOdd() bool {
	return mi%2 == 1
}

type Age MyInt

func main() {
	var x MyInt = 3
	_ = x.IsOdd() // لا بأس

	var y Age = 36
	// _ = y.IsOdd() // خطأ: y.IsOdd غير معرَّف
	_ = y
}

هل ينبغي تعريف الطريقة بمستقبِل مؤشري أم بمستقبِل قيمي؟

أولًا، من القسم السابق نعرف أننا أحيانًا يجب أن نعرّف الطرق بمستقبِلات مؤشرية.

في الواقع، يمكننا دائمًا تعريف الطرق بمستقبِلات مؤشرية دون أي مشاكل منطقية. إنها مجرد مسألة أداء البرنامج، فأحيانًا يكون من الأفضل تعريف الطرق بمستقبِلات قيمية.

في الحالات التي يكون فيها كلٌّ من المستقبِلات القيمية والمستقبِلات المؤشرية مقبولًا، إليك بعض العوامل التي يجب أخذها في الاعتبار لاتخاذ القرار.

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

إذا كان اتخاذ قرار بشأن ما إذا كانت الطريقة ينبغي أن تستخدم مستقبِلًا مؤشريًا أم مستقبِلًا قيميًا أمرًا صعبًا، فاختر ببساطة أسلوب المستقبِل المؤشري.