مدونة

تغييرات دلالات حلقة for في Go 1.22: كن على دراية بالأثر

غيّر Go 1.22 دلالات حلقات for، بما في ذلك حلقات for-range والحلقات التقليدية ذات الشروط الثلاثة for ..; ..; .. {...} (والتي ستُختصر بـ for;; في ما تبقّى من محتوى هذا المقال).

ينبغي أن تفهم التغييرات الدلالية وأن تستوعب تبعات هذه التغييرات لتكتب شيفرة Go تتصرف كما هو مقصود. وإلا، فقد تُظهر شيفرتك سلوكًا غير متوقع.

المحتويات:

  • ما هي التغييرات؟
  • أثر التغييرات - قد يتغيّر سلوك استدعاءات الدوال المؤجلة التي تلتقط متغيرات التكرار
  • كن حذرًا عند التقاط متغيرات التكرار في الإغلاقات
  • كن حذرًا عند أخذ عناوين متغيرات التكرار
  • كن حذرًا عند نقل جمل الشرط الثالث داخل أجسام الحلقات
  • كن حذرًا عند تعريف قيم no-copy كمتغيرات تكرار
  • تحذير: قد يتدهور أداء برامج Go لديك بصمت
  • تحذير: قد تصبح الأمور أكثر دقة مما كانت عليه عندما تُستخدم متغيرات التكرار بشكل متزامن
  • نصائح واقتراحات - حدّد إصدارات لغة Go لملفات مصدر Go
  • ترقية إصدارات الوحدات
  • تجنّب استخدام متغيرات التكرار المعلنة حديثًا في حلقات for;; إذا كنت قلقًا من الوقوع في مأزق الدلالات الجديدة
  • كلمة أخيرة

ما هي التغييرات؟

بشكل محدَّد، لا تتغيّر إلا دلالات حلقات for التي تُعلَن متغيرات تكرارها داخل الحلقات (ونسمي متغيرات التكرار هذه متغيرات تكرار معلنة حديثًا في ما تبقّى من المحتوى). مثلًا، في قطعة الشيفرة التالية، لا تتغيّر دلالات الحلقتين الأوليين، لكن دلالات الحلقتين الأخيرتين تتغيّر (من Go 1.21 إلى 1.22).


	for k, v = range aContainer {...}
	for a, b, c = f(); condition; statement {...}

	for k, v := range aContainer {...}
	for a, b, c := f(); condition; statement {...}

الحلقتان الأوليان لا تُعلنان متغيري التكرار الخاصين بهما، بينما تفعل الحلقتان الأخيرتان ذلك. هذا هو الفرق هنا. لا تتغيّر دلالات الحلقتين الأوليين.

لنستعرض برنامج Go بسيطًا يخضع لتغيير دلالي (وتغيير في السلوك) من Go 1.21 إلى Go 1.22:


//demo1.go
package main

func main() {
	c, out := make(chan int), make(chan int)

	m := map[int]int{1: 2, 3: 4}
	for i, v := range m {
		go func() {
			<-c
			out <- i+v
		}()
	}

	close(c)

	println(<-out + <-out)
}

يمكننا تثبيت عدة إصدارات من سلسلة أدوات Go لفحص المخرجات. هنا، أستخدم أداة GoTV [1] لاختيار إصدارات سلسلة أدوات Go (بسهولة).

المخرجات:


$ gotv 1.21. run demo1.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo1.go
14
$ gotv 1.22. run demo1.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo1.go
10

الفرق في السلوك واضح:

  • قبل Go 1.22، كان يطبع 14 (على الأرجح، عندما لا تشارك القناة c)؛
  • منذ Go 1.22، يطبع 10 (دائمًا، حتى دون مشاركة القناة c).

سبب الفرق:

  • قبل Go 1.22، كان كل متغير تكرار معلن حديثًا يُستخدم في حلقة for مشتركًا بين جميع دورات التكرار أثناء تنفيذ الحلقة. تُنفَّذ الـ goroutineان الجديدتان بعد تنفيذ حلقة for، وفي هذه الحالة تكون القيمتان النهائيتان لمتغيري التكرار i وv هما 3 و4. و(3+4) + (3+4) يعطي 14.
  • منذ Go 1.22، سيُجسَّد كل متغير تكرار معلن حديثًا يُستخدم في حلقة for كنسخة مميزة عند بداية كل دورة تكرار. بعبارة أخرى، أصبح نطاقه لكل دورة تكرار الآن. لذا فإن قيمتي متغيري التكرار i وv المستخدمتين في الـ goroutineين الجديدتين هما 1 2 و3 4 على التوالي. و(1+2) + (3+4) يعطي 10.

قبل Go 1.22، وبدون مشاركة القناة c، توجد حالة تسابق بيانات في شيفرة البرنامج أعلاه، وينبغي أن تكون هذه حقيقة واضحة لمبرمج Go كفؤ. لتجنّب تسابق البيانات والحصول على النتيجة نفسها التي تعطيها الدلالات الجديدة، ينبغي إعادة كتابة الحلقة في البرنامج على النحو التالي:


	for i, v := range m {
		i, v := i, v // هذا السطر مضاف
		go func() {
			out <- i+v
		}()
	}

في ظل الدلالات الجديدة، يصبح السطر المضاف غير ضروري. في الواقع، هذا هو السبب الرئيسي وراء إجراء التغييرات الدلالية في Go 1.22.

وبالمثل، يخضع البرنامج التالي أيضًا لتغيير دلالي/سلوكي من Go 1.21 إلى Go 1.22:


// demo2.go
package main

func main() {
	c, out := make(chan int), make(chan int)

	for i := 1; i <= 3; i++ {
		go func() {
			<-c
			out <- i
		}()
	}

	close(c)

	println(<-out + <-out + <-out)
}

مخرجات البرنامج أعلاه:


$ gotv 1.21. run demo2.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo2.go
12
$ gotv 1.22. run demo2.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo2.go
6

يركّز هذا المقال على تفاصيل التغييرات وأثر التغييرات، لا على الأسباب الكامنة وراءها. للتفاصيل حول عملية الاعتماد والأسباب الكامنة وراء التغييرات، انظر

  1. النقاش الأول: إعادة تعريف دلالات متغيرات حلقة for: https://github.com/golang/go/discussions/56010.
  2. سلسلة قضية المقترح الرسمي: https://github.com/golang/go/issues/60078. لاحظ أن التعليق المفتوح لهذه السلسلة لم يذكر تقريبًا التغيير الدلالي على حلقات for;;.
  3. ملف المقترح نفسه: https://go.googlesource.com/proposal/+/master/design/60078-loopvar.md. (إذا كنت تهتم بالأسباب فقط، فيمكنك قراءة هذا الملف وحده.)
  4. صفحة الـ wiki: https://go.dev/wiki/LoopvarExperiment، ومقال المدونة: https://go.dev/blog/loopvar-preview. أُنشئ كلاهما بعد قبول المقترح، ما يعني أن المقترح قُبِل قبل أن تبدأ مرحلة التجربة حتى! (كان ذلك غير معتاد جدًا. بدت مرحلة التجربة مجرد إجراء شكلي. وقد ظهر ذلك في قِصر مدة مرحلة التجربة وغياب دعوة عامة قوية للمشاركة. وبالتالي، لم تظهر المشكلات العديدة المذكورة في هذا المقال إلا بعد الإصدار الرسمي للتغييرات المقترحة.)
  5. ملاحظات إصدار Go 1.22: https://go.dev/doc/go1.22، التي تدّعي أن الإصدار يحافظ على وعد Go 1 بالتوافق. لكن الأمر ببساطة ليس كذلك (اقرأ أدناه لمعرفة الأسباب).

أثر التغييرات

شخصيًا، أرى أن منطق تغيير حلقات for-range مبرَّر جيدًا. أصبحت الدلالات الجديدة لحلقات for-range أكثر بداهة. لا يؤثر التغيير إلا على حلقات for k, v := range .. {...}، التي توحي فيها العلامة := بقوة بأن متغيرات التكرار ذات نطاق لكل دورة تكرار. ولا تُدخَل أي تبعات. وأثر التغيير إيجابي تقريبًا.

من ناحية أخرى، في رأيي الصريح، منطق تغيير حلقات for;; غير كافٍ. السبب الرئيسي الذي قدّمه واضعو المقترح هو تحقيق الاتساق مع حلقات for-range (فكلتاهما حلقات for). لكن ليس بديهيًا على الإطلاق اعتبار متغيرات التكرار في الحلقات المشابهة التالية ذات نطاق لكل دورة تكرار.


for a, b, c := anExpression; aCondition; postStatement {
	... // جسم الحلقة
}

تُنفَّذ الجملة a, b, c := anExpression مرة واحدة فقط أثناء تنفيذ الحلقة، لذا من البديهي أن تُجسَّد متغيرات التكرار صراحةً مرة واحدة فقط أثناء تنفيذ الحلقة. تجعل الدلالات الجديدة متغيرات التكرار تُجسَّد عند كل دورة تكرار، ما يعني أنه لا بد من وجود شيفرة ضمنية للقيام بذلك. وهذا صحيح. تقول مواصفة Go 1.22+:

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

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


{
	a_last, b_last, c_last := anExpression
	pa_last, pb_last, pc_last = &a_last, &b_last, &c_last
	first := true
	for {
		a, b, c := *pa_last, *pb_last, *pc_last
		if first {
			first = false
		} else {
			postStatement
		}
		if !(aCondition) {
			break
		}
		pa_last, pb_last, pc_last = &a, &b, &c
		... // جسم الحلقة
	}
}

واو، قدر كبير جدًا من الشيفرة الضمنية السحرية. بالنسبة للغة تروّج للصراحة، هذا محرج.

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

قد يتغيّر سلوك استدعاءات الدوال المؤجلة التي تلتقط متغيرات التكرار

مثال بسيط:


// demo-defer.go
package main

import "fmt"

func main() {
	for counter, n := 0, 2; n >= 0; n-- {
		defer func(v int) {
		    fmt.Print("#", counter, ": ", v, "\n")
		    counter++
		}(n)
	}
}

مخرجاتها:


$ gotv 1.21. run demo-defer.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo-defer.go
#0: 0
#1: 1
#2: 2
$ gotv 1.22. run demo-defer.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo-defer.go
#0: 0
#0: 1
#0: 2

يمكنك أن تجد أنه منذ Go 1.22، لا تزداد قيمة counter فعليًا أبدًا. لماذا؟ آسف. كما ذُكر أعلاه، من الصعب بعض الشيء شرح الدلالات الجديدة بوضوح ولا أعتقد أنني أملك القدرة على ذلك. قد تفهمها من الشيفرة المكافئة التالية:


func main() {
	counter_last, n_last := 0, 2
	p_counter_last, p_n_last := &counter_last, &n_last
	first := true
	for {
		counter, n := *p_counter_last, *p_n_last
		if (first) {
			first = false
		} else {
			n--
		}

		if !(n >= 0) {
			break
		}
		p_counter_last, p_n_last = &counter, &n
		defer func(v int) {
			fmt.Print("#", counter, ": ", v, "\n")
			counter++
		}(n)
	}
}

مثال أكثر واقعية:


// search.go
package main

import "fmt"

func demoFilter(n int) bool {
	return n & 1 == 0;
}

// ابحث عن القيم وأرجِعها دون إفساد الترتيب.
func search(start, end int)(r []int) {
	var count = 0
	for i, index := start, 0; i <= end; i++ {
		if demoFilter(i) {
			count++
			defer func(value int) {
				r[index] = value
				index++
			}(i)
		}
	}

	r = make([]int, count) // خصّص مرة واحدة فقط
	return
}

func main() {
	fmt.Println(search(0, 9))
}

مخرجات البرنامج أعلاه:


$ gotv 1.21. run search.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run search.go
[8 6 4 2 0]
$ gotv 1.22. run search.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run search.go
[0 0 0 0 0]

إذن، منذ Go 1.22، كن حذرًا فقط عند استخدام متغيرات تكرار معلنة حديثًا في استدعاءات الدوال المؤجلة.

يوحي المثال أعلاه بأن بعض متغيرات التكرار المعلنة حديثًا في الشرط الأول لحلقات for;; قد يكون من المقبول أن تكون ذات نطاق لكل دورة تكرار، لكن بعضها يُتوقع بقوة أن يكون ذا نطاق للحلقة بأكملها، مثل متغيري التكرار index وcounter المعروضين أعلاه. لقد اقترحت سابقًا السماح بجمل إعادة التعريف كـ postStatement لحلقات for;; لتحديد صراحةً أي المتغيرات ذات نطاق لكل دورة تكرار. مثلًا، في شيفرة الحلقة التالية، n ذو نطاق لكل دورة تكرار لكن counter ذو نطاق للحلقة بأكملها.


  for counter, n := 0, 2; n >= 0; n := n - 1 { ... }

لكن، للأسف، تم تجاهل الاقتراح تمامًا.

كن حذرًا عند التقاط متغيرات التكرار في الإغلاقات

مثال:


// demo-closure-1.go
package main

import "fmt"

func main() {
	var printN func()
	for n := 0; n < 9; n++ {
		if printN == nil {
			printN = func() {
				fmt.Println(n)
			}
		}
	}
	printN()
}

مخرجاتها:


$ gotv 1.21. run demo-closure-1.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo-closure-1.go
9
$ gotv 1.22. run demo-closure-1.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo-closure-1.go
0

قبل Go 1.22، ما يلتقطه إغلاق printN هو النسخة الوحيدة لمتغير التكرار، وقيمتها النهائية 9. لكن منذ Go 1.22، ما يلتقطه إغلاق printN هو النسخة الأولى لمتغير التكرار، وقيمتها النهائية 0. وهذا هو سبب اختلاف السلوك بين إصداري Go.

وهذا مثال مشابه:


// demo-closure-2.go
package main

import (
	"bytes"
	"fmt"
)

func main() {
	var printBuf func()
	for buf, i := (bytes.Buffer{}), byte('a'); i <= 'z'; i++ {
		if printBuf == nil {
			printBuf = func() {
				fmt.Printf("%s\n", buf.Bytes())
			}
		}
		buf.WriteByte(i)
	}
	printBuf()
}

مخرجاتها:


$ gotv 1.21. run demo-closure-2.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo-closure-2.go
abcdefghijklmnopqrstuvwxyz
$ gotv 1.22. run demo-closure-2.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo-closure-2.go
a

المثال الثالث:


package main

func main() {
    var next func()
    for i := 0; i < 3; next() {
        print(i)
        next = func() {
            i++
        }
    }
}

لن ينتهي أبدًا منذ Go 1.22 (قبل Go 1.22، كان يطبع 012 ثم ينتهي فورًا)

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

كن حذرًا عند أخذ عناوين متغيرات التكرار

وبالمثل، منذ Go 1.22، قد يكون من الخطير استخدام عنوان متغير تكرار معلن حديثًا عبر دورات التكرار.

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


// demo-pointer1.go
package main

import "fmt"

func main() {
	for i, p := 0, (*int)(nil); p == nil; fmt.Println(p == &i) {
		p = &i
	}
}

مخرجاتها:


$ gotv 1.21. run demo-pointer1.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo-pointer1.go
true
$ gotv 1.22. run demo-pointer1.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo-pointer1.go
false

يعطي Go 1.21 و1.22 إجابتين مختلفتين. لماذا؟ من الشيفرة المكافئة المعروضة أدناه، يمكننا أن نستنتج أنه في المقارنة p == &i، يشير p إلى النسخة الأولى من i، بينما يأخذ &i عنوان النسخة الثانية من i. لذا فإن نتيجة تقييم المقارنة هي false.


func main() {
	i_last, p_last := 0, (*int)(nil)
	p_i_last, p_p_last := &i_last, &p_last
	first := true
	for {
		i, p := *p_i_last, *p_p_last
		if first {
			first = false
		} else {
			fmt.Println(p == &i)
		}
		if !(p == nil) {
			break
		}
		p_i_last, p_p_last = &i, &p
		p = &i
	}
}

مثال آخر:


// demo-pointer2.go
package main

import "fmt"

func main() {
    var p *int
	for i := 0; i < 3; *p++ {
	    p = &i
	    fmt.Println(i)
	}
}

منذ Go 1.22، لن ينتهي البرنامج أعلاه أبدًا (قبل Go 1.22، كان ينتهي):


$ gotv 1.21. run demo-pointer2.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo-pointer2.go
0
1
2
$ gotv 1.22. run demo-pointer2.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo-pointer2.go
0
0
0
...

كن حذرًا عند نقل جمل الشرط الثالث داخل أجسام الحلقات

منذ Go 1.22، قد لا تكون الحلقتان التاليتان متكافئتين إحداهما مع الأخرى بعد الآن (قبل Go 1.22، كانتا متكافئتين).


for ...; ...; postStatement {
	... // لا توجد جمل continue هنا
}

for ...; ...; {
	... // لا توجد جمل continue هنا
	postStatement
}

مثلًا، إذا نقلنا جمل الشرط الثالث للحلقات في القسم الأخير إلى أجسام الحلقات، فسيتغيّر سلوكها (منذ Go 1.22).


// demo-pointer3.go
package main

import "fmt"

func pointerDemo1() {
	for i, p := 0, (*int)(nil); p == nil; {
		p = &i
		fmt.Println(p == &i) // الشرط الثالث القديم
	}
}

func pointerDemo2() {
    var p *int
	for i := 0; i < 3; {
	    p = &i
	    fmt.Println(i)
	    *p++ // الشرط الثالث القديم
	}
}

func main() {
	pointerDemo1();
	pointerDemo2();
}

المخرجات الجديدة:


$ gotv 1.22. run demo-pointer3.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo-pointer3.go
true
0
1
2

كن حذرًا عند تعريف قيم no-copy كمتغيرات تكرار

كما شُرح أعلاه، منذ Go 1.22، عند بداية كل دورة تكرار، سيُنسَخ كل متغير تكرار معلن حديثًا مرة واحدة، ضمنيًا. ويعني هذا أنه منذ Go 1.22، لا يُنصح عمومًا باستخدام قيم no-copy كمتغيرات تكرار. تشمل الأمثلة sync.Mutex وsync/atomic.Int64 وbytes.Buffer وstrings.Builder وcontainer/list.List، إلخ.

مثلًا، في إصدارات Go السابقة لـ 1.22، كانت الشيفرة التالية تُعدّ صحيحة من ناحية التزامن. لكن بدءًا من Go 1.22، تُعدّ هذه الشيفرة مصابة بمشكلة تزامن، لأن متغير التكرار wg سيُنسَخ (ضمنيًا) عند بداية كل دورة تكرار.


// demo-nocopy1.go
package main

import (
	"sync"
	"time"
)

func process() (wait func()) {
	for wg, i := (sync.WaitGroup{}), 0; i < 3; i++ {
		if (wait == nil) {
			wait = wg.Wait
		}

		wg.Add(1)
		go func(v int) {
			defer wg.Done()
			if (v > 0) {
				time.Sleep(time.Second/8)
			}
			println(v)
		}(i)
	}
	return
}

func main() {
	process()()
}

مخرجاتها:


$ gotv 1.21. run demo-nocopy1.go
[Run]: $HOME/.cache/gotv/tag_go1.21.8/bin/go run demo-nocopy1.go
0
2
1
$ gotv 1.22. run demo-nocopy1.go
[Run]: $HOME/.cache/gotv/tag_go1.22.1/bin/go run demo-nocopy1.go
0
$ gotv 1.22. vet demo-nocopy1.go
[Run]: $HOME/.cache/gotv/tag_go1.22.1/bin/go vet demo-nocopy1.go

لاحظ أن الأمر go vet في إصدارات سلسلة أدوات Go السابقة لـ 1.24 لا يستطيع التقاط مثل هذا النسخ الضمني لقيم no-copy، بغض النظر عما إذا كان متغير التكرار wg ملتقطًا في جسم الحلقة أم لا.

تحدث بعض فحوص no-copy أثناء وقت التشغيل. ولا يزال الأمر go vet في سلسلة أدوات Go v1.24+ عاجزًا عن التقاط مثل هذه الحالات. لنستعرض مثالًا يستخدم strings.Builder (تحتوي كل قيمة strings.Builder على حقل مؤشر ينبغي أن يشير إلى نفسها):


// demo-nocopy2.go
package main

import "strings"
import "fmt"

func foo(pb *strings.Builder) {}

var bar = foo

func a2z_foo() string {
	for b, i := (strings.Builder{}), byte('a'); ; i++ {
		b.WriteByte(i)
		foo(&b) // <- الفرق هنا
		if i == 'z' {
			return b.String()
		}
	}
}

func a2z_bar() string {
	for b, i := (strings.Builder{}), byte('a'); ; i++ {
		b.WriteByte(i)
		bar(&b) // <- الفرق هنا
		if i == 'z' {
			return b.String()
		}
	}
}

func main() {
	fmt.Println("foo:", a2z_foo())
	fmt.Println("bar:", a2z_bar())
}

بتشغيله بسلاسل أدوات Go مختلفة، نحصل على:


$ gotv 1.21. run demo-nocopy2.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo-nocopy2.go
foo: abcdefghijklmnopqrstuvwxyz
bar: abcdefghijklmnopqrstuvwxyz
$ gotv 1.22. run demo-nocopy2.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo-nocopy2.go
foo: abcdefghijklmnopqrstuvwxyz
panic: strings: illegal use of non-zero Builder copied by value

goroutine 1 [running]:
...

تُظهر المخرجات أن:

  • قبل Go 1.22، سلوك الدالتين a2z متسق. لا يحدث أي نسخ لمتغيرات التكرار، لذا لن تحدث أي حالة panic.
  • منذ Go 1.22، بما أن متغيرات التكرار المعلنة حديثًا تُنسَخ ضمنيًا عند بداية كل دورة تكرار، يعمل فحص no-copy في وقت التشغيل. لكن الفحص يعمل فقط مع الدالة a2z_bar، وليس مع الدالة a2z_foo.

لكن لماذا لا يعمل فحص no-copy في وقت التشغيل مع الدالة a2z_foo؟ بسبب تحسينين من المصرِّف. في أحد التحسينين، يحذف المصرِّف السطر foo(&b) في الدالة a2z_foo. والتحسين صالح فعليًا. والتحسين متحفظ بحيث لا يحذف المصرِّف السطر bar(&b) في الدالة a2z_bar. والنتيجة أنه في التحسين الآخر، يعتقد المصرِّف (خطأً) أن متغير التكرار المعلن حديثًا b في الدالة a2z_foo يمكن تجسيده مرة واحدة فقط للحلقة بأكملها، حتى مع أن دلالاته تشير إلى التجسيد لكل دورة تكرار.

لكن المصرِّف ذكي أكثر من اللازم هنا لدرجة اتخاذ قرار سيئ. ينفّذ المصرِّف الدلالات بشكل غير صحيح. كان ينبغي أن يظل سلوك الدالتين a2z في البرنامج أعلاه متسقًا منذ Go 1.22. كان ينبغي أن تنتجا كلتاهما حالة panic.

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

لا يزال الأمر go vet في أحدث إصدارات سلسلة أدوات Go عاجزًا عن التقاط مثل هذا النسخ الضمني لقيم no-copy.

ملاحظة: في المكتبة القياسية، توجد أنواع أخرى، مثل النوع bytes.Buffer، التي لا ينبغي نسخ قيمها أيضًا. لكن لا تُجرى لها فحوص وقت التصريف ولا فحوص وقت التشغيل.

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

تحذير: قد يتدهور أداء برامج Go لديك بصمت

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

لنستعرض مثالًا يُستخدم فيه متغير تكرار كبير الحجم في الدالة bar.


// demo-largesize.go
package main

import (
	"fmt"
	"time"
)

const N = 1 << 18

func foo(f func([]byte, int)) {
	a := [N]byte{}
	for i := 0; i < len(a); i++ {
		f(a[:], i)
	}
}

func bar(f func([]byte, int)) {
	for a, i := [N]byte{}, 0; i < len(a); i++ {
		f(a[:], i)
	}
}

func main() {
	readonly := func(x []byte, k int) {}
	bench := func(f func(func([]byte, int))) time.Duration {
		start := time.Now()
		f(readonly)
		return time.Since(start)
	}
	fmt.Println("foo time:", bench(foo))
	fmt.Println("bar time:", bench(bar))
}

مخرجاتها:


$ gotv 1.21. run aaa.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run aaa.go
foo time: 689.79µs
bar time: 690.988µs
$ gotv 1.22. run aaa.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run aaa.go
foo time: 734.85µs
bar time: 18.795043596s

تكشف نتائج قياس الأداء عن تراجع كبير في أداء الدالة bar بين Go 1.21 و1.22 (لاحظ أن 1s == 1,000,000us). لماذا؟ لأن متغير التكرار a في الدالة bar يُنسَخ عند كل دورة تكرار مع المصرِّف الرسمي القياسي Go 1.22. بينما في الإصدارات السابقة، كان هذا النسخ دائمًا بلا داعٍ.

لا تؤثر مشكلة تدهور الأداء على صحة منطق الشيفرة (على الأقل في المثال المحدد أعلاه). وتعتمد درجة تدهور الأداء على حجم قيمة متغير التكرار من نوع المصفوفة. لذا قد لا تُكتشف في الوقت المناسب في بعض مشاريع Go عند ترقيتها من إصدار سابق لـ 1.22 إلى إصدار 1.22+.

اقتراحات لتجنّب مشكلة تدهور الأداء هذه:

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

تحذير: قد تصبح الأمور أكثر دقة مما كانت عليه عندما تُستخدم متغيرات التكرار بشكل متزامن

أولًا، لنستعرض برنامجًا بسيطًا.


// demo-concurrency1.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(i)
		}()
	}
	wg.Wait()
}

البرنامج أعلاه يهدف إلى طباعة قيم متغير التكرار i عند كل دورة تكرار. قبل Go 1.22، توجد حالة تسابق بيانات واضحة في البرنامج، لأن متغير التكرار i يُجسَّد مرة واحدة فقط خلال الحلقة بأكملها. ستقرأ جميع الـ goroutines الجديدة النسخة الوحيدة لكن الـ goroutine الرئيسية ستعدّلها. تُثبت المخرجات التالية هذه الحقيقة:


$ CGO_ENABLED=1 gotv 1.21. run -race demo-concurrency1.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run -race demo-concurrency1.go
3
3
==================
WARNING: DATA RACE
...
==================
3

قبل Go 1.22، كان الإصلاح بسيطًا: أضف فقط سطر i := i عند بداية جسم الحلقة. يصلح Go 1.22 مشكلة تسابق البيانات المحددة بتغيير دلالات حلقات for;;، دون تعديل الشيفرة القديمة المشكلة. ويمكن التحقق من ذلك بالمخرجات التالية:


$ CGO_ENABLED=1 gotv 1.22. run -race demo-concurrency1.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run -race demo-concurrency1.go
1
2
0

في الواقع، هذا هو السبب الرئيسي وراء إجراء Go 1.22 التغيير الدلالي على حلقات for;;. لكن هل يستحق الأمر إصلاح مشكلة صغيرة كهذه بإدخال شيفرة ضمنية سحرية؟

إن أثر محاولة إصلاح المشكلة بإجراء تغيير دلالي محدود فعليًا. لنعدّل البرنامج أعلاه قليلًا:


// demo-concurrency2.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			i++ // <-- أضف هذا السطر
			fmt.Println(i)
		}()
	}
	wg.Wait()
}

هل ما زالت الشيفرة الجديدة خالية من تسابق البيانات (مع دلالات Go 1.22)؟ تبدو جيدة. فكل goroutine جديدة تستخدم مجرد نسخة حصرية من متغير التكرار i. لكن الجواب «لا»، لأن هناك إسنادًا ضمنيًا عند بداية كل دورة تكرار، ويستخدم الإسناد الضمني نسخة من متغير التكرار كقيمة مصدر (أي تقرؤها الـ goroutine الرئيسية)، غير أن النسخة تُعدَّل في goroutine جديدة.

تُثبت المخرجات التالية وجود حالة تسابق بيانات في الشيفرة الجديدة:


$ CGO_ENABLED=1 gotv 1.22. run -race demo-concurrency2.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run -race demo-concurrency2.go
==================
WARNING: DATA RACE
...
==================
2
1
3
Found 1 data race(s)

قبل Go 1.22، كان تسابق البيانات واضحًا وسهل الاكتشاف. لكن منذ Go 1.22، أصبحت الأمور أكثر دقة ولم يعد تسابق البيانات واضحًا تمامًا (بسبب الشيفرة الضمنية).

ربما لاحظت أن البرنامج المصاب بتسابق البيانات يبدو وكأنه ينتج المخرجات المقصودة. نعم، هذه مشكلة أخرى في التغيير الدلالي. فرغم أن مخرجات البرنامج المصاب بتسابق البيانات قد تبدو صحيحة في معظم الوقت، فإن هذا يخفي مشكلة أعمق: قد يبقى تسابق البيانات غير مكتشف لفترة أطول. وقد يؤخر هذا بشكل كبير تحديد المشكلة وحلها!

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


// demo-concurrency3.go
package main

import (
	"fmt"
	"sync"
)

const NumWorkers = 3

func isGold(num uint64) bool {
	return num & 0xFFFFF == 0
}

func main() {
	var c = make(chan uint64)
	var m sync.Mutex
	for n, i := 0, uint64(0); n < NumWorkers; n++ {
		go func() {
			for {
				m.Lock()
				i++
				v := i
				m.Unlock()

				if isGold(v) {
					c <- v
				}
			}
		}()
	}

	for n := range c {
		fmt.Println("Found gold", n)
	}
}

بتشغيله بإصدارات مختلفة من سلسلة الأدوات، نحصل على المخرجات التالية:


$ CGO_ENABLED=1 gotv 1.21. run -race demo-concurrency3.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run -race demo-concurrency3.go
Found gold 1048576
Found gold 2097152
Found gold 3145728
...
^C
$ CGO_ENABLED=1 gotv 1.22. run -race demo-concurrency3.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run -race demo-concurrency3.go
==================
WARNING: DATA RACE
...
==================
Found gold 1048576
Found gold 1048576
Found gold 1048576
Found gold 2097152
Found gold 2097152
Found gold 2097152
Found gold 3145728
Found gold 3145728
Found gold 3145728
...
^C

😳😳😳... (بالنظر إلى أن عنوان المقترح لإجراء التغيير الدلالي هو "Proposal: Less Error-Prone Loop Variable Scoping". أليست هذه سخرية؟)

كيف تصلح الشيفرة المصابة بتسابق البيانات (منذ 1.22)؟

  • لمجرد تجنّب حالة التسابق، يمكننا الاستمرار في استخدام الحيلة القديمة: أضف فقط سطر i := i عند بداية جسم الحلقة. نعم، لا يزال هذا أفضل اقتراح لتجنّب تسابق البيانات في مثل هذه الحالات في عصر Go 1.22+. أليس هذا سخرية من قصد التغيير الدلالي (لحلقات for;;)؟
  • لتجنّب حالة التسابق وتصحيح المنطق، ينبغي تعريف المتغير i خارج كتلة الحلقة (منذ Go 1.22).

نصائح واقتراحات

حسنًا، ما سبق هو المشكلات المحتملة التي حددتها حتى الآن في الدلالات الجديدة لحلقات for;; المُدخَلة في Go 1.22. قد تكون هناك مشكلات أخرى، لست متأكدًا.

وإليك بعض التوصيات التي يمكنك اتباعها في عصر Go 1.22+.

حدّد إصدارات لغة Go لملفات مصدر Go

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

توجد عدة طرق (بترتيب الأولوية) لتحديد إصدارات لغة Go لملفات مصدر Go:

  1. أضف توجيه تعليق //go:build go1.xy عند بداية ملف مصدر لتحديد إصدار اللغة لملف المصدر. (لكن توجيه التعليق //go:build go1.xy في ملف مصدر Go قد يبقى متجاهلًا بسبب أخطاء محتملة في تنفيذ سلسلة الأدوات. مثلًا، لا يرغب فريق Go الأساسي في إصلاح هذا الخطأ المعروف في إصدارات سلسلة أدوات Go v1.22.x.)
  2. استخدم خيار المصرِّف -gcflags=-lang=go1.xy عند بناء ملف Go ثنائي لتحديد إصدار لغة Go لملفات البذرة التي تمررها إلى المصرِّف.
  3. ضع سطر التوجيه go 1.xy في ملف go.mod لوحدة Go لتحديد إصدار لغة Go الافتراضي لجميع ملفات المصدر في وحدة Go. ويُفترض أن سطر التوجيه الغائب هو go 1.16. وتُوصَف آثار سطر التوجيه هنا.

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

يسبب هذا التصميم مشكلتين:

  1. يحب بعض الناس استخدام الأمر go run لتشغيل شيفرة Go كنصوص برمجية (مجموعة ملفات مصدر Go دون ملفات go.mod). إذا لم يحتوي ملف مصدر Go نصي على توجيه تعليق //go:build go1.xy ولم يُحدَّد خيار المصرِّف -gcflags=-lang=go1.xy، فسيعتمد سلوك الشيفرة في الملف النصي على المصرِّف، تمامًا كما تُظهر الأمثلة أعلاه. ومرة أخرى، يرجى ملاحظة أنه بسبب أخطاء التنفيذ، قد تتجاهل إصدارات معينة من سلسلة الأدوات توجيهات التعليق //go:build go1.xy.
  2. إذا رقّيت، لأي سبب (مثلًا، لاستخدام ميزة موفَّرة في إصدارات Go الأحدث)، إصدار اللغة لوحدة Go تديرها إلى 1.22 أو أعلى، فقد يتغيّر سلوك وحدة Go. وإذا لم تُكتشف تغييرات السلوك في الوقت المناسب (بسبب اختبار غير كافٍ ومساوئ الدلالات الجديدة لحلقات for;;، إلخ)، فقد لا تسير الأمور على ما يرام.

على أي حال، منذ Go 1.22، ينبغي أن تحاول تحديد إصدار لغة Go لكل ملف مصدر Go، بأي من الطرق المقدَّمة أعلاه، لتقليل السلوكيات المعتمدة على إصدار المصرِّف. وهذا هو الحد الأدنى لمعيار أن تكون مبرمج Go محترفًا في عصر Go 1.22+.

ترقية إصدارات الوحدات

من الحقائق المحزنة أن واضعي المقترح (والمتاعب) لا ينوون أبدًا توفير طرق تلقائية لتحديد المشكلات المحتملة عند ترقية إصدارات الوحدات. لذا يجب عليك تحديد المشكلات المحتملة يدويًا.

  • إذا كنت تدير وحدة Go عامة تعتمد عليها مشاريع Go أخرى، فيرجى فحص جميع استخدامات حلقات for;; في شيفرة الوحدة بعناية قبل رفع إصدار اللغة إلى Go 1.22+ في ملف go.mod. وانتبه بشكل خاص إلى متغيرات التكرار المعلنة حديثًا التي لا تُعدَّل في postStatement، لتجنّب كسر المشاريع التي تعتمد على هذه الوحدة.
  • إذا رقّيت الوحدات التابعة لمشاريع Go لديك، فانتبه إلى تلك التي رُقّي إصدار لغتها إلى Go 1.22 أو أعلى من إصدار ذي الدلالات القديمة قبل Go 1.22، لتجنّب كسر مشاريعك. ويرجى ملاحظة أن الأمر go mod tidy الموفَّر في سلسلة أدوات Go الرسمية سيرقّي أحيانًا إصدارات اللغة (المحددة في ملفات go.mod) لمشاريعك تلقائيًا، دون اكتشاف المشكلات المحتملة التي ستكسر سلوك مشاريعك، ودون إشعارك بالترقية. وقد تكتشف تغييرات السلوك في الوقت المناسب أو لا تكتشفها. فقط كن حذرًا!

تجنّب استخدام متغيرات التكرار المعلنة حديثًا في حلقات for;; إذا كنت قلقًا من الوقوع في مأزق الدلالات الجديدة

لا تكن متوترًا جدًا، :D. في الواقع، تتصرف معظم حلقات for;; بالطريقة نفسها مع الدلالات القديمة أو الدلالات الجديدة. لكن إذا لم تكن متأكدًا من ذلك، فيمكنك دائمًا إعادة كتابة الحلقات المشابهة التالية


for a, b, c := anExpression; aCondition; postStatement {
	... // جسم الحلقة
}

على النحو التالي


{
	a, b, c := anExpression
	for ; aCondition; postStatement {
		... // جسم الحلقة
	}
}

لتجنّب إطلاق الدلالات الجديدة. بل يمكنك حتى تحديد متغيرات التكرار التي تُجسَّد عند كل دورة تكرار وتلك التي لا تُجسَّد. مثلًا، في الشيفرة التالية، يُجسَّد a وc عند كل دورة تكرار، لكن b سيُجسَّد مرة واحدة فقط خلال الحلقة بأكملها.


{
	a, b, c := anExpression
	for ; aCondition; postStatement {
		a, c := a, c
		... // جسم الحلقة
	}
}

هذا غريب قليلًا، لكنه أكثر أمانًا بكثير.

كلمة أخيرة

إجمالًا، أجد أن أثر الدلالات الجديدة لحلقات for-range إيجابي، بينما أثر الدلالات الجديدة لحلقات for;; سلبي. وفي رأيي الصريح، فوائد الدلالات الجديدة لحلقات for;; نادرة وضئيلة، بينما عيوبها أكثر بروزًا وخطورة.

أضرّ التغيير الدلالي بسمعة Go في الترويج للصراحة والحفاظ على توافق عكسي قوي.

بسبب الضمنية السحرية المُدخَلة، قد تتطلب الدلالات الجديدة لحلقات for;; وقت تصحيح إضافي أثناء كتابة الشيفرة وجهدًا معرفيًا إضافيًا أثناء مراجعة الشيفرة وفهمها في بعض الحالات.

قد تُدخِل الدلالات الجديدة لحلقات for;; تدهورًا محتملًا في الأداء ومشكلات تسابق بيانات في الشيفرة الموجودة، ما يستلزم مراجعة دقيقة وتعديلات محتملة. واعتمادًا على الحالات المحددة، قد تُكتشف هذه المشكلات في الوقت المناسب أو لا.

تخفض التغييرات الدلالية المُدخَلة في Go 1.22 معيار الحفاظ على التوافق العكسي بشكل كبير. وهذه بداية سيئة.

لقد عبّرت عن آرائي في التعليقات التالية:

لكن واضعي المقترح (بعض أعضاء فريق Go الأساسي) تجاهلوها تمامًا وقرروا المضي قدمًا في التغيير الدلالي لحلقات for;; على أي حال.

ما حدث قد حدث. وفي الختام، آمل أن يساعدك هذا المقال في كتابة شيفرة Go احترافية واستخدام مكتبات Go بشكل صحيح في عصر Go 1.22+.

  1. GoTV (https://go101.org/apps-and-libs/gotv.html) أداة تُستخدم لتثبيت إصدارات متعايشة من سلسلة أدوات Go واستخدامها بانسجام. ↩︎