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

المؤشرات غير الآمنة من حيث النوع

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

المؤشرات المشروحة في ذلك المقال تُسمّى في الواقع مؤشرات آمنة من حيث النوع (type-safe pointers). وعلى الرغم من أن القيود المفروضة على المؤشرات الآمنة من حيث النوع تمكّننا فعلاً من كتابة شيفرة Go آمنة بسهولة، فإنها تضع أيضاً بعض العقبات أمام كتابة شيفرة فعّالة في بعض السيناريوهات.

في الواقع، تدعم Go أيضاً المؤشرات غير الآمنة من حيث النوع (type-unsafe pointers)، وهي مؤشرات بلا القيود المفروضة على المؤشرات الآمنة. وتُسمّى المؤشرات غير الآمنة من حيث النوع أيضاً المؤشرات غير الآمنة في Go. وتشبه المؤشرات غير الآمنة في Go مؤشرات C كثيراً؛ فهي قوية وخطيرة في آنٍ واحد. وفي بعض الحالات يمكننا كتابة شيفرة أكثر كفاءة بمساعدة المؤشرات غير الآمنة. ومن ناحية أخرى، باستخدام المؤشرات غير الآمنة يسهل كتابة شيفرة سيئة تكون أدقّ من أن تُكشف في الوقت المناسب.

خطر كبير آخر لاستخدام المؤشرات غير الآمنة ينبع من كون آلية unsafe غير محمية بـ إرشادات التوافق مع Go 1. فالشيفرة التي تعتمد على المؤشرات غير الآمنة وتعمل اليوم قد تتعطل بدءاً من إصدار Go لاحق.

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

عن الحزمة القياسية unsafe

توفّر Go صنفاً خاصاً من الأنواع للمؤشرات غير الآمنة. ويجب علينا استيراد الحزمة القياسية unsafe لاستخدام المؤشرات غير الآمنة. ويُعرَّف النوع unsafe.Pointer كما يلي


type Pointer *ArbitraryType

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

تعني المؤشرات غير الآمنة في Go الأنواع التي تكون أنواعها الكامنة هي unsafe.Pointer.

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

قبل Go 1.17، كانت الحزمة القياسية unsafe قد قدّمت بالفعل ثلاث دوال.

  • func Alignof(variable ArbitraryType) uintptr، وتُستخدم للحصول على محاذاة عنوان قيمة. لاحظ أن المحاذاة لقيم حقول البنية والقيم غير الحقلية من النوع نفسه قد تختلف، رغم أنها متطابقة دائماً في المصرِّف القياسي لـ Go. أما في مصرِّف gccgo فقد تختلف.
  • func Offsetof(selector ArbitraryType) uintptr، وتُستخدم للحصول على إزاحة عنوان حقل في قيمة بنية. وتكون الإزاحة نسبةً إلى عنوان قيمة البنية. وينبغي أن تكون نتائج الإرجاع متطابقة دائماً للحقل المقابل نفسه في قيم النوع البنيوي نفسه في البرنامج نفسه.
  • func Sizeof(variable ArbitraryType) uintptr، وتُستخدم للحصول على حجم قيمة (أي حجم نوع القيمة). وينبغي أن تكون نتائج الإرجاع متطابقة دائماً لجميع قيم النوع نفسه في البرنامج نفسه.

لاحظ،

  • أنواع نتائج الإرجاع للدوال الثلاث كلها uintptr. وسنتعلم أدناه أنه يمكن تحويل قيم uintptr إلى مؤشرات غير آمنة (والعكس صحيح).
  • على الرغم من أن نتائج الإرجاع لاستدعاءات أي من الدوال الثلاث متسقة في البرنامج نفسه، فقد تختلف عبر أنظمة التشغيل والمعماريات والمصرِّفات وإصدارات المصرِّفات.
  • تُقيَّم استدعاءات الدوال الثلاث دائماً في وقت التصريف. ونتائج التقييم ثوابت ذات نوع uintptr.
  • يجب أن تكون المعطاة الممرَّرة إلى استدعاء الدالة unsafe.Offsetof على صيغة محدِّد حقل البنية value.field. وقد يدل المحدِّد على حقل مضمَّن، لكن يجب أن يكون الحقل قابلاً للوصول دون إشارات غير مباشرة ضمنية عبر المؤشرات.

مثال على استخدام الدوال الثلاث.


package main

import "fmt"
import "unsafe"

func main() {
	var x struct {
		a int64
		b bool
		c string
	}
	const M, N = unsafe.Sizeof(x.c), unsafe.Sizeof(x)
	fmt.Println(M, N) // 16 32

	fmt.Println(unsafe.Alignof(x.a)) // 8
	fmt.Println(unsafe.Alignof(x.b)) // 1
	fmt.Println(unsafe.Alignof(x.c)) // 8

	fmt.Println(unsafe.Offsetof(x.a)) // 0
	fmt.Println(unsafe.Offsetof(x.b)) // 8
	fmt.Println(unsafe.Offsetof(x.c)) // 16
}

مثال يوضّح الملاحظة الأخيرة المذكورة أعلاه.


package main

import "fmt"
import "unsafe"

func main() {
	type T struct {
		c string
	}
	type S struct {
		b bool
	}
	var x struct {
		a int64
		*S
		T
	}

	fmt.Println(unsafe.Offsetof(x.a)) // 0

	fmt.Println(unsafe.Offsetof(x.S)) // 8
	fmt.Println(unsafe.Offsetof(x.T)) // 16

	// هذا السطر يُصرَّف، لأن c يمكن الوصول إليه
	// دون إشارات غير مباشرة ضمنية عبر المؤشرات.
	fmt.Println(unsafe.Offsetof(x.c)) // 16

	// هذا السطر لا يُصرَّف، لأن b يجب الوصول إليه
	// عبر حقل المؤشر الضمني S.
	//fmt.Println(unsafe.Offsetof(x.b)) // خطأ

	// هذا السطر يُصرَّف. لكنه يطبع إزاحة
	// الحقل b في القيمة x.S.
	fmt.Println(unsafe.Offsetof(x.S.b)) // 0
}

لاحظ أن نتائج الطباعة المعروضة في التعليقات هي للمصرِّف القياسي لـ Go الإصدار 1.25.n على معمارية Linux AMD64.

لا تبدو الدوال الثلاث الموفَّرة في الحزمة unsafe خطيرة كثيراً. وتوقيعات هذه الدوال من المستبعد جداً تغييرها في إصدارات Go 1 المستقبلية. بل إن Rob Pike قدّم اقتراحاً لنقل الدوال الثلاث إلى مكان آخر. ومعظم ما في الحزمة unsafe من انعدام أمان يأتي من المؤشرات غير الآمنة. فهي خطيرة كخطورة مؤشرات C، وهو ما تحاول مؤشرات Go الآمنة تجنّبه دائماً.

يقدّم Go 1.17 نوعاً جديداً ودالتين جديدتين إلى الحزمة unsafe. النوع الجديد هو IntegerType، وتعريفه كما يلي. لا يدل هذا النوع على نوع محدَّد. إنه يمثّل فقط أي نوع عدد صحيح عشوائي. ويمكننا اعتباره نوعاً جنيساً.


type IntegerType int

الدالتان المقدَّمتان في Go 1.17 هما:

  • func Add(ptr Pointer, len IntegerType) Pointer. تضيف هذه الدالة إزاحة إلى العنوان الذي يمثّله مؤشر غير آمن وتُرجع مؤشراً غير آمناً جديداً يمثّل العنوان الجديد. وتغطي هذه الدالة جزئياً استخدامات نمط استخدام المؤشرات غير الآمنة رقم 3 المقدَّم أدناه.
  • func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType. تُستخدم هذه الدالة لاشتقاق شريحة بالطول المحدَّد من مؤشر آمن، حيث ArbitraryType هو نوع عناصر الشريحة الناتجة.

ثم قدّم Go 1.20 ثلاث دوال أخرى:

  • func String(ptr *byte, len IntegerType) string. تُستخدم هذه الدالة لاشتقاق نص بالطول المحدَّد من مؤشر byte آمن.
  • func StringData(str string) *byte. تُستخدم هذه الدالة للحصول على المؤشر إلى تسلسل البايتات الكامن لنص. لاحظ، لا تمرّر نصوصاً فارغة كمعطيات إلى هذه الدالة.
  • func SliceData(slice []ArbitraryType) *ArbitraryType. تُستخدم هذه الدالة للحصول على المؤشر إلى تسلسل العناصر الكامن لشريحة.

لهذه الدوال المقدَّمة منذ Go 1.17 خطورة معيّنة. ويجب استخدامها بحذر. وفيما يلي مثال يستخدم الدالتين المقدَّمتين في Go 1.17.


package main

import (
	"fmt"
	"unsafe"
)

func main() {
	a := [16]int{3: 3, 9: 9, 11: 11}
	fmt.Println(a)
	eleSize := int(unsafe.Sizeof(a[0]))
	p9 := &a[9]
	up9 := unsafe.Pointer(p9)
	p3 := (*int)(unsafe.Add(up9, -6 * eleSize))
	fmt.Println(*p3) // 3
	s := unsafe.Slice(p9, 5)[:3]
	fmt.Println(s) // [9 0 11]
	fmt.Println(len(s), cap(s)) // 3 5

	t := unsafe.Slice((*int)(nil), 0)
	fmt.Println(t == nil) // true

	// الاستدعاءان التاليان خطران.
	// فهما يجعلان النتائج تشير إلى
	// كتل ذاكرة مجهولة.
	_ = unsafe.Add(up9, 7 * eleSize)
	_ = unsafe.Slice(p9, 8)
}

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


import "unsafe"

func String2ByteSlice(str string) []byte {
	if str == "" {
		return nil
	}
	return unsafe.Slice(unsafe.StringData(str), len(str))
}

func ByteSlice2String(bs []byte) string {
	if len(bs) == 0 {
		return ""
	}
	return unsafe.String(unsafe.SliceData(bs), len(bs))
}

قواعد التحويل المتعلقة بالمؤشرات غير الآمنة

حالياً (Go 1.25)، تسمح مصرِّفات Go بالتحويلات الصريحة التالية.

  • يمكن تحويل مؤشر آمن صراحةً إلى مؤشر غير آمن، والعكس صحيح.
  • يمكن تحويل قيمة uintptr صراحةً إلى مؤشر غير آمن، والعكس صحيح. لكن لاحظ أنه لا ينبغي تحويل قيمة unsafe.Pointer تساوي nil إلى uintptr ثم إرجاعها مع عمليات حسابية.

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

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

بعض الحقائق في Go التي ينبغي أن نعرفها

قبل تقديم أنماط استخدام المؤشرات غير الآمنة الصالحة، نحتاج إلى معرفة بعض الحقائق في Go.

الحقيقة 1: المؤشرات غير الآمنة مؤشرات وقيم uintptr أعداد صحيحة

كل مؤشر آمن أو غير آمن لا يساوي nil يشير إلى قيمة أخرى. أما قيم uintptr فلا تشير إلى أي قيم، بل هي مجرد أعداد صحيحة عادية، وإن كانت كل واحدة منها غالباً تخزّن عدداً صحيحاً يمكن استخدامه لتمثيل عنوان ذاكرة.

Go لغة تدعم جمع النفايات التلقائي. فعند تشغيل برنامج Go، ستتحقق بيئة تشغيل Go من كتل الذاكرة التي لم تعد تستخدمها أي قيمة وتجمع الذاكرة المخصَّصة لهذه الكتل غير المستخدمة، من وقت لآخر. وتؤدي المؤشرات دوراً مهماً في عملية التحقق. فإذا كانت كتلة ذاكرة غير قابلة للوصول من (أو غير مشار إليها بواسطة) أي قيم لا تزال قيد الاستخدام، فستعتبر بيئة تشغيل Go أنها قيمة غير مستخدمة ويمكن جمعها كنفايات بأمان.

وبما أن قيم uintptr أعداد صحيحة، فيمكنها المشاركة في العمليات الحسابية.

يوضّح المثال في القسم الفرعي التالي الفروق بين المؤشرات وقيم uintptr.

الحقيقة 2: قد تُجمع كتل الذاكرة غير المستخدمة في أي وقت

في وقت التشغيل، قد يعمل جامع النفايات في وقت غير محدد، وقد تستمر كل عملية جمع نفايات لمدة غير محددة. لذا عندما تصبح كتلة ذاكرة غير مستخدمة، فقد تُجمع في وقت غير محدد.

على سبيل المثال:


import "unsafe"

// لنفترض أن createInt لن تُضمَّن inline.
//go:noinline
func createInt() *int {
	return new(int)
}

func foo() {
	p0, y, z := createInt(), createInt(), createInt()
	var p1 = unsafe.Pointer(y)
	var p2 = uintptr(unsafe.Pointer(z))

	// في هذه اللحظة، حتى لو كان عنوان قيمة int
	// المشار إليها بواسطة z لا يزال مخزَّناً في p2،
	// فقد أصبحت قيمة int غير مستخدمة بالفعل، لذا
	// يمكن لجامع النفايات جمع الذاكرة المخصَّصة لها
	// الآن. ومن ناحية أخرى، فإن قيم int المشار
	// إليها بواسطة p0 وp1 لا تزال قيد الاستخدام.

	// يمكن لـ uintptr المشاركة في العمليات الحسابية.
	p2 += 2; p2--; p2--

	*p0 = 1                         // لا بأس
	*(*int)(p1) = 2                 // لا بأس
	*(*int)(unsafe.Pointer(p2)) = 3 // خطر!
}

في المثال أعلاه، لا تضمن حقيقة أن القيمة p2 لا تزال قيد الاستخدام أن كتلة الذاكرة التي استضافت قيمة int المشار إليها بواسطة z لم تُجمع كنفايات بعد. بعبارة أخرى، عند تنفيذ *(*int)(unsafe.Pointer(p2)) = 3، قد تكون كتلة الذاكرة قد جُمعت أو لا. ومن الخطر إلغاء الإشارة إلى العنوان المخزَّن في القيمة p2 للحصول على قيمة int، لأنه من الممكن أن تكون كتلة الذاكرة قد أُعيد تخصيصها لقيمة أخرى (حتى لبرنامج آخر).

الحقيقة 3: قد تتغير عناوين بعض القيم في وقت التشغيل

يرجى قراءة مقال كتل الذاكرة للتفاصيل (انظر نهاية القسم المرتبط). هنا، يكفي أن نعرف أنه عندما يتغير حجم مكدس goroutine، ستُنقل كتل الذاكرة المخصَّصة على المكدس. بعبارة أخرى، ستتغير عناوين القيم المستضافة على كتل الذاكرة هذه.

الحقيقة 4: قد لا يكون مدى حياة قيمة في وقت التشغيل كبيراً كما يبدو في الشيفرة

في المثال التالي، لا تضمن حقيقة أن القيمة t لا تزال قيد الاستخدام أن القيم المشار إليها بواسطة القيمة t.y لا تزال قيد الاستخدام.


type T struct {
	x int
	y *[1<<23]byte
}

func bar() {
	t := T{y: new([1<<23]byte)}
	p := uintptr(unsafe.Pointer(&t.y[0]))

	... // استخدم T.x وT.y

	// يمكن لمصرِّف ذكي أن يكتشف أن القيمة
	// t.y لن تُستخدم مرة أخرى أبداً ويعتبر أن
	// كتلة الذاكرة التي تستضيف t.y يمكن جمعها الآن.

	// استخدام *(*byte)(unsafe.Pointer(p)))
	// خطر هنا.

	// واصل استخدام القيمة t، لكن استخدم حقلها x فقط.
	println(t.x)
}

الحقيقة 5: *unsafe.Pointer نوع مؤشر آمن عام

نعم، *unsafe.Pointer نوع مؤشر آمن. ونوعه الأساس هو unsafe.Pointer. وبما أنه مؤشر آمن، ووفق قواعد التحويل المذكورة أعلاه، يمكن تحويله إلى النوع unsafe.Pointer والعكس صحيح.

على سبيل المثال:


package main

import "unsafe"

func main() {
	x := 123                // من النوع int
	p := unsafe.Pointer(&x) // من النوع unsafe.Pointer
	pp := &p                // من النوع *unsafe.Pointer
	p = unsafe.Pointer(pp)
	pp = (*unsafe.Pointer)(p)
}

كيف نستخدم المؤشرات غير الآمنة استخداماً صحيحاً؟

يسرد توثيق الحزمة القياسية unsafe ستة أنماط لاستخدام المؤشرات غير الآمنة. وفيما يلي سنقدّمها ونشرحها واحدة واحدة.

النمط 1: حوّل قيمة *T1 إلى مؤشر غير آمن، ثم حوّل قيمة المؤشر غير الآمن إلى *T2.

كما ذُكر أعلاه، باستخدام قواعد تحويل المؤشرات غير الآمنة السابقة، يمكننا تحويل قيمة من *T1 إلى النوع *T2، حيث T1 وT2 نوعان عشوائيان. لكن ينبغي أن نجري مثل هذه التحويلات فقط إذا لم يكن حجم T1 أصغر من T2، وفقط إذا كانت التحويلات ذات معنى.

ونتيجة لذلك، يمكننا أيضاً تحقيق التحويلات بين النوعين T1 وT2 باستخدام هذا النمط.

أحد الأمثلة على ذلك الدالة math.Float64bits، التي تحوّل قيمة float64 إلى قيمة uint64 دون تغيير أي بت في قيمة float64. وتقوم الدالة math.Float64frombits بالتحويلات العكسية.


func Float64bits(f float64) uint64 {
	return *(*uint64)(unsafe.Pointer(&f))
}

func Float64frombits(b uint64) float64 {
	return *(*float64)(unsafe.Pointer(&b))
}

لاحظ أن نتيجة إرجاع استدعاء الدالة math.Float64bits(aFloat64) تختلف عن نتيجة التحويل الصريح uint64(aFloat64).

في المثال التالي، نستخدم هذا النمط لتحويل شريحة []MyString إلى النوع []string والعكس صحيح. وتتشارك الشريحة الناتجة والشريحة الأصلية العناصر الكامنة. ومثل هذه التحويلات مستحيلة بالطرق الآمنة،


package main

import (
	"fmt"
	"unsafe"
)

func main() {
	type MyString string
	ms := []MyString{"C", "C++", "Go"}
	fmt.Printf("%s\n", ms)  // [C C++ Go]
	// ss := ([]string)(ms) // خطأ تصريف
	ss := *(*[]string)(unsafe.Pointer(&ms))
	ss[1] = "Zig"
	fmt.Printf("%s\n", ms) // [C Zig Go]
	// ms = []MyString(ss) // خطأ تصريف
	ms = *(*[]MyString)(unsafe.Pointer(&ss))

	// منذ Go 1.17، يمكننا أيضاً استخدام
	// الدالة unsafe.Slice لإجراء التحويلات.
	ss = unsafe.Slice((*string)(&ms[0]), len(ms))
	ms = unsafe.Slice((*MyString)(&ss[0]), len(ms))
}

بالمناسبة، منذ Go 1.17، يمكننا أيضاً استخدام الدالة unsafe.Slice لإجراء التحويلات:


func main() {
	...

	ss = unsafe.Slice((*string)(&ms[0]), len(ms))
	ms = unsafe.Slice((*MyString)(&ss[0]), len(ss))
}

من الممارسات العملية باستخدام هذا النمط تحويل شريحة بايتات لن تُستخدم بعد التحويل إلى نص، كما توضّح الشيفرة التالية. وفي هذا التحويل، يُتجنَّب تكرار تسلسل البايتات الكامن.


func ByteSlice2String(bs []byte) string {
	return *(*string)(unsafe.Pointer(&bs))
}

هذا هو التطبيق المعتمد في الطريقة String للنوع Builder المدعوم منذ Go 1.10 في الحزمة القياسية strings. فحجم شريحة البايتات أكبر من حجم النص، وبنيتهما الداخلية متشابهة، لذا فالتحويل صالح (بالنسبة للمصرِّفات الرئيسية لـ Go). لكن، ورغم أن هذا التطبيق قد يُستخدم بأمان في الحزم القياسية الآن، لا يُنصح باستخدامه في شيفرة المستخدم العامة. ومنذ Go 1.20، ينبغي أن نحاول في شيفرة المستخدم العامة استخدام التطبيق الذي يستعمل الدالة unsafe.String المذكورة أعلاه في هذا المقال.

أما العكس، أي تحويل نص إلى شريحة بايتات بالطريقة المشابهة، فهو غير صالح، لأن حجم النص أصغر من حجم شريحة البايتات.


func String2ByteSlice(s string) []byte {
	return *(*[]byte)(unsafe.Pointer(&s)) // خطر!
}

وفي قسم النمط 6 أدناه، يُقدَّم تطبيق صالح للقيام بالمهمة نفسها.

ملاحظة: عند استخدام الطريقة غير الآمنة المقدَّمة للتو لتحويل شريحة بايتات إلى نص، يرجى التأكد من عدم تعديل البايتات في شريحة البايتات إذا كان النص الناتج لا يزال موجوداً.

النمط 2: حوّل مؤشراً غير آمن إلى uintptr، ثم استخدم قيمة uintptr.

هذا النمط ليس مفيداً كثيراً. فعادةً نطبع قيم uintptr الناتجة للتحقق من عناوين الذاكرة المخزَّنة فيها. لكن توجد طرق أخرى آمنة وأقل إسهاباً للقيام بهذه المهمة. لذا فهذا النمط ليس مفيداً كثيراً.

مثال:


package main

import "fmt"
import "unsafe"

func main() {
	type T struct{a int}
	var t T
	fmt.Printf("%p\n", &t)                          // 0xc6233120a8
	println(&t)                                     // 0xc6233120a8
	fmt.Printf("%x\n", uintptr(unsafe.Pointer(&t))) // c6233120a8
}

قد تختلف العناوين المطبوعة في كل تشغيل.

النمط 3: حوّل مؤشراً غير آمن إلى uintptr، وأجرِ عمليات حسابية بقيمة uintptr، ثم أرجعه

في هذا النمط، يجب أن يظل المؤشر غير الآمن الناتج يشير إلى كتلة الذاكرة الأصلية المخصَّصة. على سبيل المثال:


package main

import "fmt"
import "unsafe"

type T struct {
	x bool
	y [3]int16
}

const N = unsafe.Offsetof(T{}.y)
const M = unsafe.Sizeof(T{}.y[0])

func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(&t)
	// "uintptr(p)+N+M+M" هو عنوان t.y[2].
	ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
	fmt.Println(*ty2) // 789
}

في الواقع، منذ Go 1.17، يُوصى أكثر باستخدام الدالة unsafe.Add المقدَّمة أعلاه لإجراء عمليات إزاحة العناوين هذه.

لاحظ أنه في هذا المثال تحديداً، لا ينبغي تقسيم التحويل unsafe.Pointer(uintptr(p) + N + M + M) إلى سطرين، كما توضّح الشيفرة التالية. يرجى قراءة التعليقات في الشيفرة لمعرفة السبب.


func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(&t)
	// ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
	addr := uintptr(p) + N + M + M

	// ... (بعض العمليات الأخرى)

	// الآن تصبح القيمة t غير مستخدمة، وقد تُجمع
	// ذاكرتها كنفايات في هذه اللحظة. لذا قد يصبح
	// استخدام عنوان t.y[2] التالي غير صالح وخطيراً!
	// وهناك خطر محتمل آخر: إذا جعلت بعض العمليات
	// المكدس ينمو أو يتقلص هنا، فقد يتغير عنوان t،
	// وبذلك يصبح العنوان المحفوظ في addr غير صالح
	// (الحقيقة 3).
	ty2 := (*int16)(unsafe.Pointer(addr))
	fmt.Println(*ty2)
}

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

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

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

النمط 4: حوّل المؤشرات غير الآمنة إلى قيم uintptr كمعطيات لاستدعاءات syscall.Syscall.

من الشروح الواردة للنمط الأخير، نعلم أن الدالة التالية خطيرة.


// لنفترض أن هذه الدالة لن تُضمَّن inline.
func DoSomething(addr uintptr) {
	// اقرأ أو اكتب القيم عند العنوان الممرَّر ...
}

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

لكن نموذج تعريف الدالة Syscall في الحزمة القياسية syscall هو كما يلي


func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)

كيف تضمن هذه الدالة أن كتل الذاكرة عند العناوين الممرَّرة a1 وa2 وa3 لم تُجمع كنفايات بعد داخل الدالة؟ لا تستطيع الدالة ضمان ذلك. في الواقع، المصرِّفات هي التي تقدّم هذا الضمان. فهو امتياز خاص باستدعاءات الدوال الشبيهة بـ syscall.Syscall.

يمكننا التفكير في الأمر هكذا: ستُدرج المصرِّفات تلقائياً بعض التعليمات لكل معطاة من معطيات المؤشرات غير الآمنة المحوَّلة إلى uintptr، مثل المعطاة الثالثة في استدعاء syscall.Syscall التالي، لمنع كتلة الذاكرة المشار إليها بتلك المعطاة من الجمع كنفايات أو من النقل.

لاحظ أنه قبل Go 1.15، كان لا بأس في أن تكون تعبيرات التحويل uintptr(anUnsafePointer) تعبيرات فرعية للمعطيات المذكورة. ومنذ Go 1.15، أصبح الشرط أكثر صرامة قليلاً: يجب أن تظهر المعطيات المذكورة تماماً على الصيغة uintptr(anUnsafePointer).

الاستدعاء التالي آمن:


syscall.Syscall(SYS_READ, uintptr(fd),
			uintptr(unsafe.Pointer(p)), uintptr(n))

لكن الاستدعاءات التالية خطيرة:


u := uintptr(unsafe.Pointer(p))
// في هذه اللحظة، قد تكون القيمة المشار إليها بواسطة p
// قد أصبحت غير مستخدمة وجُمعت بالفعل، أو تغيّر
// عنوان القيمة.
syscall.Syscall(SYS_READ, uintptr(fd), u, uintptr(n))

// يجب أن تكون المعطيات على الصيغة
// "uintptr(anUnsafePointer)".
// في الواقع، كان الاستدعاء آمناً قبل Go 1.15.
// لكن Go 1.15 غيّرت القاعدة قليلاً.
syscall.Syscall(SYS_XXX, uintptr(uintptr(fd)),
			uint(uintptr(unsafe.Pointer(p))), uintptr(n))

ملاحظة: ينطبق هذا النمط أيضاً على الطريقتين syscall.Proc.Call وsyscall.LazyProc.Call على Windows.

ومرة أخرى، لا تستخدم هذا النمط أبداً عند استدعاء دوال أخرى.

النمط 5: حوّل نتيجة uintptr من استدعاء الطريقة reflect.Value.Pointer أو reflect.Value.UnsafeAddr إلى مؤشر غير آمن

تُرجع الطريقتان Pointer وUnsafeAddr للنوع Value في الحزمة القياسية reflect نتيجة من النوع uintptr بدلاً من unsafe.Pointer. وهذا تصميم متعمَّد، هدفه تجنّب تحويل نتائج الاستدعاءات (للطريقتين) إلى أي أنواع مؤشرات آمنة دون استيراد الحزمة القياسية unsafe.

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

على سبيل المثال، الاستدعاء التالي آمن.


p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer()))

ومن ناحية أخرى، الاستدعاء التالي خطير.


u := reflect.ValueOf(new(int)).Pointer()
// في هذه اللحظة، قد تكون كتلة الذاكرة عند العنوان
// المخزَّن في u قد جُمعت بالفعل.
p := (*int)(unsafe.Pointer(u))

لاحظ أن Go 1.19 يقدّم طريقة جديدة هي reflect.Value.UnsafePointer()، وتُرجع قيمة unsafe.Pointer ويُفضَّل استخدامها على الدالتين المذكورتين للتو. وهذا يعني أن التصميم المتعمَّد القديم يُعتبر الآن غير جيد.

النمط 6: حوّل الحقل reflect.SliceHeader.Data أو reflect.StringHeader.Data إلى مؤشر غير آمن، والعكس.

للسبب نفسه المذكور في القسم الفرعي الأخير، يُعلَن الحقلان Data للنوعين البنيويين SliceHeader وStringHeader في الحزمة القياسية reflect بالنوع uintptr بدلاً من unsafe.Pointer.

يمكننا تحويل مؤشر نص إلى قيمة مؤشر *reflect.StringHeader، حتى نتمكن من التلاعب بالبنية الداخلية للنص. وبالمثل، يمكننا تحويل مؤشر شريحة إلى قيمة مؤشر *reflect.SliceHeader، حتى نتمكن من التلاعب بالبنية الداخلية للشريحة.

مثال على استخدام reflect.StringHeader:


package main

import "fmt"
import "unsafe"
import "reflect"

func main() {
	a := [...]byte{'G', 'o', 'l', 'a', 'n', 'g'}
	s := "Java"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
	hdr.Data = uintptr(unsafe.Pointer(&a))
	hdr.Len = len(a)
	fmt.Println(s) // Golang
	// الآن يتشارك s وa تسلسل البايتات نفسه، ما يجعل
	// البايتات في النص s قابلة للتعديل.
	a[2], a[3], a[4], a[5] = 'o', 'g', 'l', 'e'
	fmt.Println(s) // Google
}

مثال على استخدام reflect.SliceHeader:


package main

import (
	"fmt"
	"unsafe"
	"reflect"
)

func main() {
	a := [6]byte{'G', 'o', '1', '0', '1'}
	bs := []byte("Golang")
	hdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	hdr.Data = uintptr(unsafe.Pointer(&a))

	hdr.Len = 2
	hdr.Cap = len(a)
	fmt.Printf("%s\n", bs) // Go
	bs = bs[:cap(bs)]
	fmt.Printf("%s\n", bs) // Go101
}

عموماً، ينبغي أن نحصل فقط على قيمة مؤشر *reflect.StringHeader من نص فعلي (موجود بالفعل)، أو على قيمة مؤشر *reflect.SliceHeader من شريحة فعلية (موجودة بالفعل). ولا ينبغي أن نفعل العكس، مثل إنشاء نص من StringHeader مخصَّص جديد، أو إنشاء شريحة من SliceHeader مخصَّص جديد. على سبيل المثال، الشيفرة التالية خطيرة.


var hdr reflect.StringHeader
hdr.Data = uintptr(unsafe.Pointer(new([5]byte)))
// الآن فقدت مصفوفة البايتات المخصَّصة للتو كل
// المراجع ويمكن جمعها كنفايات الآن.
hdr.Len = 5
s := *(*string)(unsafe.Pointer(&hdr)) // خطر!

فيما يلي مثال يوضّح كيفية تحويل نص إلى شريحة بايتات بالطريقة غير الآمنة. وخلافاً للتحويل الآمن من نص إلى شريحة بايتات، لا تخصّص الطريقة غير الآمنة تسلسل بايتات كامناً جديداً للشريحة الناتجة في كل تحويل.


package main

import (
	"fmt"
	"reflect"
	"strings"
	"unsafe"
)

func String2ByteSlice(str string) (bs []byte) {
	strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	sliceHdr.Data = strHdr.Data
	sliceHdr.Cap = strHdr.Len
	sliceHdr.Len = strHdr.Len
	return
}

func main() {
	// str := "Golang"
	// بالنسبة للمصرِّف القياسي الرسمي، سيجعل السطر أعلاه
	// البايتات في str مخصَّصة في منطقة ذاكرة غير قابلة
	// للتعديل. لذا نستخدم السطر التالي بدلاً منه.
	str := strings.Join([]string{"Go", "land"}, "")
	s := String2ByteSlice(str)
	fmt.Printf("%s\n", s) // Goland
	s[5] = 'g'
	fmt.Println(str) // Golang
}

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

ومن الممكن أيضاً تحويل شريحة بايتات إلى نص بطريقة مشابهة، وهي أكثر أماناً قليلاً (لكنها أبطأ قليلاً) من الطريقة المعروضة في النمط 1.


func ByteSlice2String(bs []byte) (str string) {
	sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	strHdr.Data = sliceHdr.Data
	strHdr.Len = sliceHdr.Len
	return
}

وبالمثل، يرجى التأكد من عدم تعديل البايتات في شريحة البايتات المعطاة إذا كان النص الناتج لا يزال موجوداً.

بالمناسبة، لنستعرض مثالاً سيئاً يخالف مبدأ النمط 3 (المثال مأخوذ من تعليق على Slack نشره Bryan C. Mills):


package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func Example_Bad() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Pointer(hdr.Data + 2))
	return pbyte // *pbyte == 'd'
}

func main() {
	fmt.Println(string(*Example_Bad()))
}

تطبيقان صحيحان:


func Example_Good1() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Pointer(
		uintptr(unsafe.Pointer(hdr.Data)) + 2))
	return pbyte
}

// يعمل منذ Go 1.17.
func Example_Good2() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Add(unsafe.Pointer(hdr.Data), 2))
	return pbyte
}

هل الأمر خادع؟ نعم.

يتشابه توثيق النوعين SliceHeader وStringHeader في الحزمة القياسية reflect من حيث قولهما إن تمثيلي هذين النوعين البنيويين قد يتغيران في إصدار لاحق. لذا قد تصبح الأمثلة الصالحة أعلاه التي تستخدم النوعين غير صالحة حتى لو بقيت قواعد unsafe دون تغيير. لحسن الحظ، حالياً (Go 1.25)، يتعرّف المصرِّفان الرئيسيان المتاحان لـ Go (المصرِّف القياسي لـ Go ومصرِّف gccgo) على تمثيلي النوعين المعلَنين في الحزمة القياسية reflect.

كما أدرك فريق تطوير نواة Go أن النوعين غير مريحين ومعرَّضان للخطأ، لذا لم يُعد يُوصى بالنوعين منذ Go 1.20 وصارا مهجورين منذ Go 1.21. وبدلاً منهما، ينبغي أن نحاول استخدام الدوال unsafe.String وunsafe.StringData وunsafe.Slice وunsafe.SliceData المشروحة سابقاً في هذا المقال.

كلمات أخيرة

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

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

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

وفي الختام، يجدر بالذكر أن خيار مصرِّف للتحليل الديناميكي -gcflags=all=-d=checkptr مدعوم منذ سلسلة أدوات Go 1.14 (ويُوصى باستخدام هذا الخيار على Windows مع سلسلة أدوات Go 1.15+). وعند استخدام هذا الخيار، ستُكشف بعض استخدامات المؤشرات غير الآمنة غير الصحيحة (وليس كلها) في وقت التشغيل. وبمجرد كشف استخدام غير صحيح كهذا، سيحدث panic. والشكر لـ Matthew Dempsky على تطبيق هذه الميزة الرائعة!