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

المؤشرات في Go

على الرغم من أن Go تستوعب ميزات كثيرة من جميع أنواع اللغات الأخرى، فإن Go تُنظر إليها أساسًا على أنها لغة من عائلة C. ومن الأدلة على ذلك أن Go تدعم المؤشرات أيضًا. وتتشابه مؤشرات Go ومؤشرات C كثيرًا في جوانب عديدة، لكن توجد أيضًا بعض الاختلافات بين مؤشرات Go ومؤشرات C. وستسرد هذه المقالة جميع أنواع المفاهيم والتفاصيل المتعلقة بالمؤشرات في Go.

عناوين الذاكرة

يعني عنوان الذاكرة موضعًا محددًا في الذاكرة في البرمجة.

عمومًا، يُخزَّن عنوان الذاكرة في كلمة أصلية (native word) غير سالبة من نوع عدد صحيح. وحجم الكلمة الأصلية هو 4 (بايت) على معماريات 32-بت و8 (بايت) على معماريات 64-بت. لذا فإن الحجم النظري الأقصى لمساحة الذاكرة هو 232 بايت، أي 4GB (1GB == 230 بايت)، على معماريات 32-بت، وهو 264 بايت أي 16EB (1EB == 1024PB، 1PB == 1024TB، 1TB == 1024GB)، على معماريات 64-بت.

غالبًا ما تُمثَّل عناوين الذاكرة بقيم حرفية صحيحة ست عشرية، مثل 0x1234CDEF.

عناوين القيم

يعني عنوان القيمة عنوان البداية لمقطع الذاكرة الذي يشغله الجزء المباشر من القيمة.

ما هي المؤشرات؟

المؤشر نوع من الأنواع في Go. وتُستخدم قيمة المؤشر لتخزين عنوان ذاكرة، وهو عمومًا عنوان قيمة أخرى.

وعلى خلاف لغة C، وُضعت لأسباب تتعلق بالأمان بعض القيود على مؤشرات Go. يرجى قراءة الأقسام التالية للتفاصيل.

أنواع المؤشرات وقيمها في Go

في Go، يمكن تمثيل نوع مؤشر غير مسمّى بـ *T، حيث يمكن أن يكون T نوعًا اعتباطيًا. ويُسمّى النوع T النوع الأساس (base type) لنوع المؤشر *T.

يمكننا تعريف أنواع مؤشرات مسمّاة، لكن لا يُنصح عمومًا باستخدام أنواع المؤشرات المسمّاة، لأن لأنواع المؤشرات غير المسمّاة قابلية قراءة أفضل.

إذا كان النوع الكامن لنوع مؤشر مسمّى هو *T، فإن النوع الأساس لنوع المؤشر المسمّى هو T.

نوعا مؤشر غير مسمّيين لهما النوع الأساس نفسه هما النوع نفسه.

مثال:


*int  // نوع مؤشر غير مسمّى نوعه الأساس int.
**int // نوع مؤشر غير مسمّى نوعه الأساس *int.

// Ptr نوع مؤشر مسمّى نوعه الأساس int.
type Ptr *int
// PP نوع مؤشر مسمّى نوعه الأساس Ptr.
type PP *Ptr

تُمثَّل القيم الصفرية لأي أنواع مؤشرات بالمعرَّف المعلَن مسبقًا nil. ولا تُخزَّن أي عناوين في قيم المؤشرات nil.

لا يمكن لقيمة من نوع مؤشر نوعه الأساس T إلا تخزين عناوين قيم من النوع T.

حول كلمة "reference"

في Go 101، تشير كلمة "reference" إلى علاقة. فمثلًا، إذا خزّنت قيمة مؤشر عنوان قيمة أخرى، فيمكننا القول إن قيمة المؤشر تُشير (مباشرةً) إلى القيمة الأخرى، وإن للقيمة الأخرى مرجعًا واحدًا على الأقل. واستخدامات كلمة "reference" في Go 101 متوافقة مع مواصفات Go.

وعندما تحمل قيمة مؤشر مرجعًا إلى قيمة أخرى، كثيرًا ما نقول أيضًا إن قيمة المؤشر تُشير إلى القيمة الأخرى.

كيف نحصل على قيمة مؤشر وما هي القيم القابلة للعنونة؟

هناك طريقتان للحصول على قيمة مؤشر غير nil.

  1. يمكن استخدام الدالة المدمجة new لتخصيص ذاكرة لقيمة من أي نوع. ستخصّص new(T) ذاكرة لقيمة T (متغير مجهول) وتُرجع عنوان القيمة T. والقيمة المخصَّصة هي قيمة صفرية من النوع T. ويُنظر إلى العنوان المُرجَع على أنه قيمة مؤشر من النوع *T.
  2. يمكننا أيضًا أخذ عناوين القيم القابلة للعنونة في Go. فبالنسبة لقيمة قابلة للعنونة t من النوع T، يمكننا استخدام التعبير &t لأخذ عنوان t، حيث & هو المعامل المستخدم لأخذ عناوين القيم. ويُنظر إلى نوع &t على أنه *T.

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

سنتعلّم لاحقًا قيمًا أخرى قابلة للعنونة وغير قابلة للعنونة من مقالات أخرى. وإذا كنت ملمًّا بالفعل بـ Go، فيمكنك قراءة هذا الملخص للحصول على قوائم القيم القابلة للعنونة وغير القابلة للعنونة في Go.

سيعرض القسم التالي مثالًا على كيفية الحصول على قيم المؤشرات.

فك إشارة المؤشر

بإعطاء قيمة مؤشر p من نوع مؤشر نوعه الأساس T، كيف يمكنك الحصول على القيمة الموجودة عند العنوان المخزَّن في المؤشر (أي القيمة التي يُشير إليها المؤشر)؟ ما عليك إلا استخدام التعبير *p، حيث يُسمّى * معامل فك الإشارة. ويُسمّى *p فك إشارة المؤشر p. وفك إشارة المؤشر هو العملية العكسية لأخذ العنوان. ونتيجة *p قيمة من النوع T (النوع الأساس لنوع p).

يؤدي فك إشارة مؤشر nil إلى panic في وقت التشغيل.

يعرض البرنامج التالي بعض الأمثلة على أخذ العناوين وفك إشارة المؤشرات:


package main

import "fmt"

func main() {
	p0 := new(int)   // p0 يشير إلى قيمة int صفرية.
	fmt.Println(p0)  // (نص عنوان ست عشري)
	fmt.Println(*p0) // 0

	// x نسخة من القيمة الموجودة في
	// العنوان المخزَّن في p0.
	x := *p0
	// كلاهما يأخذ عنوان x.
	// x و*p1 و*p2 تمثّل القيمة نفسها.
	p1, p2 := &x, &x
	fmt.Println(p1 == p2) // true
	fmt.Println(p0 == p1) // false
	p3 := &*p0 // <=> p3 := &(*p0) <=> p3 := p0
	// الآن، يخزّن p3 وp0 العنوان نفسه.
	fmt.Println(p0 == p3) // true
	*p0, *p1 = 123, 789
	fmt.Println(*p2, x, *p3) // 789 789 123

	fmt.Printf("%T, %T \n", *p0, x) // int, int
	fmt.Printf("%T, %T \n", p0, p1) // *int, *int
}

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

لماذا نحتاج إلى المؤشرات؟

لننظر أولًا إلى مثال.


package main

import "fmt"

func double(x int) {
	x += x
}

func main() {
	var a = 3
	double(a)
	fmt.Println(a) // 3
}

يُتوقَّع من الدالة double في المثال أعلاه أن تعدّل المعطى المُدخَل بمضاعفته. لكنها تفشل في ذلك. لماذا؟ لأن جميع إسنادات القيم، بما فيها تمرير معطيات الدوال، هي نسخ للقيم في Go. وما عدّلته الدالة double هو نسخة (x) من المتغير a وليس المتغير a.

أحد الحلول لإصلاح الدالة double أعلاه هو جعلها تُرجع نتيجة التعديل. ولا يصلح هذا الحل دائمًا لجميع الحالات. ويعرض المثال التالي حلًا آخر باستخدام وسيط مؤشر.


package main

import "fmt"

func double(x *int) {
	*x += *x
	x = nil // هذا السطر للتوضيح فقط
}

func main() {
	var a = 3
	double(&a)
	fmt.Println(a) // 6
	p := &a
	double(p)
	fmt.Println(a, p == nil) // 12 false
}

يمكننا أن نجد أنه بتغيير الوسيط إلى نوع مؤشر، فإن معطى المؤشر الممرَّر &a ونسخته x المستخدمة في جسم الدالة يُشيران معًا إلى القيمة نفسها، لذا فإن التعديل على *x مكافئ لتعديل على *p، أي المتغير a. بعبارة أخرى، يمكن الآن أن ينعكس التعديل الحاصل في جسم الدالة double خارج الدالة.

وبالطبع، لا يزال تعديل نسخة معطى المؤشر الممرَّر نفسه غير قادر على الانعكاس على معطى المؤشر الممرَّر. فبعد الاستدعاء الثاني للدالة double، لا يُعدَّل المؤشر المحلي p إلى nil.

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

إرجاع مؤشرات المتغيرات المحلية آمن في Go

على خلاف لغة C، فإن Go لغة تدعم جمع النفايات، لذا فإرجاع عنوان متغير محلي آمن تمامًا في Go.


func newInt() *int {
	a := 3
	return &a
}

القيود على المؤشرات في Go

لأسباب أمنية، تفرض Go بعض القيود على المؤشرات (مقارنةً بمؤشرات لغة C). وبتطبيق هذه القيود، تحافظ Go على فوائد المؤشرات وتتجنّب في الوقت نفسه خطورة المؤشرات.

قيم المؤشرات في Go لا تدعم العمليات الحسابية

في Go، لا يمكن للمؤشرات إجراء عمليات حسابية. فبالنسبة لمؤشر p، يُعدّ p++ وp-2 غير قانونيين كلاهما.

وإذا كان p مؤشرًا إلى قيمة عددية، فسيرى المصرِّف أن *p++ جملة قانونية ويعاملها على أنها (*p)++. بعبارة أخرى، أسبقية معامل فك إشارة المؤشر * أعلى من أسبقية معامل الزيادة ++ ومعامل النقصان --.

مثال:


package main

import "fmt"

func main() {
	a := int64(5)
	p := &a

	// السطران التاليان لا يُصرَّفان.
	/*
	p++
	p = (&a) + 8
	*/

	*p++
	fmt.Println(*p, a)   // 6 6
	fmt.Println(p == &a) // true

	*&a++
	*&*&a++
	**&p++
	*&*p++
	fmt.Println(*p, a) // 10 10
}

لا يمكن تحويل قيمة مؤشر إلى نوع مؤشر اعتباطي

في Go، لا يمكن تحويل قيمة مؤشر من نوع المؤشر T1 مباشرةً وبشكل صريح إلى نوع مؤشر آخر T2 إلا إذا تحقق أحد الشرطين التاليين.

  1. أن يكون النوعان الكامنان للنوعين T1 وT2 متماثلين (بإهمال وسوم البنيات (struct tags))، وبوجه خاص إذا كان أحد النوعين T1 وT2 غير مسمّى وكان نوعاهما الكامنان متماثلين (مع مراعاة وسوم البنيات)، فيمكن أن يكون التحويل ضمنيًا. وستُشرح أنواع البنيات وقيمها في المقال التالي.
  2. أن يكون النوعان T1 وT2 كليهما نوعي مؤشر غير مسمّيين وأن يكون النوعان الكامنان لنوعيهما الأساسيين متماثلين (بإهمال وسوم البنيات).

فمثلًا، بالنسبة لأنواع المؤشرات المعروضة أدناه:


type MyInt int64
type Ta    *int64
type Tb    *MyInt

توجد الحقائق التالية:

  1. يمكن تحويل قيم من النوع *int64 ضمنيًا إلى النوع Ta، والعكس صحيح، لأن نوعيهما الكامنين هما *int64.
  2. يمكن تحويل قيم من النوع *MyInt ضمنيًا إلى النوع Tb، والعكس صحيح، لأن نوعيهما الكامنين هما *MyInt.
  3. يمكن تحويل قيم من النوع *MyInt صراحةً إلى النوع *int64، والعكس صحيح، لأنهما غير مسمّيين والنوعان الكامنان لنوعيهما الأساسيين هما int64.
  4. لا يمكن تحويل قيم من النوع Ta مباشرةً إلى النوع Tb، حتى ولو بشكل صريح. لكن بفضل الحقائق الثلاث الأولى المذكورة للتو، يمكن تحويل قيمة pa من النوع Ta بشكل غير مباشر إلى النوع Tb بتداخل ثلاثة تحويلات صريحة، Tb((*MyInt)((*int64)(pa))).

ولا يمكن تحويل أي من أنواع المؤشرات هذه إلى النوع *uint64 بأي من الطرق الآمنة.

لا يمكن مقارنة قيمة مؤشر بقيم من نوع مؤشر اعتباطي

في Go، يمكن مقارنة المؤشرات بالمعاملين == و!=. ولا يمكن مقارنة قيمتي مؤشر في Go إلا إذا تحقق أحد الشروط الثلاثة التالية.

  1. أن يكون نوعا المؤشرين في Go متماثلين.
  2. أن يمكن تحويل إحدى قيمتي المؤشر ضمنيًا إلى نوع المؤشر الآخر. بعبارة أخرى، يجب أن يكون النوعان الكامنان للنوعين متماثلين وأن يكون أحد نوعي المؤشرين في Go نوعًا غير مسمّى.
  3. أن يكون واحد فقط من المؤشرين ممثَّلًا بالمعرّف العاري (غير المنسوب إلى نوع) nil.

مثال:


package main

func main() {
	type MyInt int64
	type Ta    *int64
	type Tb    *MyInt

	// 4 مؤشرات nil من أنواع مختلفة.
	var pa0 Ta
	var pa1 *int64
	var pb0 Tb
	var pb1 *MyInt

	// الأسطر الستة التالية تُصرَّف كلها بنجاح.
	// نتائج المقارنات كلها true.
	_ = pa0 == pa1
	_ = pb0 == pb1
	_ = pa0 == nil
	_ = pa1 == nil
	_ = pb0 == nil
	_ = pb1 == nil

	// لا يُصرَّف أي من الأسطر الثلاثة التالية بنجاح.
	/*
	_ = pa0 == pb0
	_ = pa1 == pb1
	_ = pa0 == Tb(nil)
	*/
}

لا يمكن إسناد قيمة مؤشر إلى قيم مؤشرات من أنواع مؤشرات أخرى

شروط إسناد قيمة مؤشر إلى قيمة مؤشر أخرى هي نفس شروط مقارنة قيمة مؤشر بقيمة مؤشر أخرى، المذكورة أعلاه.

من الممكن كسر قيود المؤشرات في Go

كما ذُكر في بداية هذه المقالة، يمكن استخدام الآليات (وتحديدًا النوع unsafe.Pointer) التي توفّرها حزمة unsafe القياسية لكسر القيود المفروضة على المؤشرات في Go. والنوع unsafe.Pointer شبيه بـ void* في C. وعمومًا لا يُنصح باستخدام الطرق غير الآمنة.