لغات البرمجة عملياً الأدلة التقنية

أخطاء شائعة وأسلوب Go الصحيح Idiomatic Go

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

أخطاء شائعة وأسلوب Go الصحيح Idiomatic Go

بعد شهر من بدء خدمة الطلبات بلغة Go في الشركة الناشئة التي تبيع القهوة والتمر والهيل، صار في الفريق أربعة مطورين: واحد من Java، وواحد من Python، وواحدة من JavaScript، ورابع كتب C قبل سنوات. والكود يعمل، والاختبارات تمر في أغلب الأيام، ولكن مراجعة كل Pull Request صارت تأخذ ساعة، والتعليقات تتكرر بنفس الكلمات: «هذا ليس أسلوب Go»، «هذه الحلقة تعدل نسخة»، «هذا الخطأ يضيع»، «لماذا تنام 50 نانوثانية؟». والمشكلة ليست أن أحداً منهم مبرمج ضعيف، وإنما أن كل واحد جاء بعادات لغته، وهذه العادات صحيحة تماماً هناك، وتنتج في Go كوداً يترجم بلا أي اعتراض ويعمل بشكل خاطئ بصمت.

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

وسوف نناقش في هذا المقال ما يلي:

  • خريطة الأخطاء الثلاثة عشر: من أي لغة يأتي كل منها، وأي أداة تكشفه، وأيها لا تكشفه أي أداة.
  • أخطاء التزامن: Goroutine تطلق وتنسى، ومتغير الحلقة الذي يغير معناه السطر go في go.mod.
  • أخطاء القيم والذاكرة: append على جزء من شريحة، وإخفاء err بالعلامة :=، وتعديل نسخة في حلقة range.
  • أخطاء النص والوقت وJSON: قص النص العربي بالبايت، وtime.Sleep(50)، والحقول التي لا تظهر في JSON.
  • أخطاء الأخطاء والخرائط والموارد: مقارنة نص الخطأ، والاعتماد على ترتيب الخريطة، وdefer داخل حلقة، وقفل يمرر بالقيمة، وتجاهل الخطأ.
  • الأسلوب الصحيح Idiomatic Go كما يراه المراجع، ثم خطة قراءة من المصادر الرسمية، وقائمة مراجعة للكود.

خريطة الأخطاء Mistakes Map: من أين تأتي، ومن يكشفها؟

كل الأخطاء في هذا المقال في برنامج واحد lab/mistakes/main.go، وكل خطأ حالة تشغلها باسمها، مثل go run ./mistakes duration، ومعها مجلدان صغيران لخطأين يحتاجان وحدة مستقلة. وشغلنا كل شيء بالإصدار Go 1.27.3 داخل الـ Image الرسمية، ثم شغلنا go vet وstaticcheck على نفس الكود لنعرف أي الأخطاء تكشفها الأدوات وحدها، والصورة التالية تجمع النتيجة:

أربع مجموعات من الأخطاء مرقمة من 1 إلى 13 بحسب اللغة التي يأتي منها المطور: Java أو C# وPython وJavaScript وكل اللغات، وتحتها ثلاث خانات: ما يكشفه go vet وما يكشفه staticcheck وما لا تكشفه أي أداة
أغلب الأخطاء لا تكشفها أداة، ولذلك يجب أن تعرفها أنت

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

لماذا تقع في هذه الأخطاء؟ النموذج الذهني Mental Model للغتك القديمة

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

  • القيم Values وليست المراجع References هي الأصل: في Java وC# وPython وJavaScript كل كائن تتعامل معه عبر مرجع، فالإسناد والتمرير لا ينسخان شيئاً، ولذلك يتوقع القادم منها أن التعديل يصل دائماً إلى الأصل. أما في Go فالـ struct والمصفوفة ينسخان بالكامل، والشريحة والخريطة والنص أنواع صغيرة تحمل مؤشراً إلى بيانات مشتركة، فبعض الأشياء تنسخ وبعضها يشارك، ومن هنا تأتي الأخطاء 3 و5 و12.
  • الصراحة Explicitness بدل السحر: لا يوجد استثناء يصعد وحده، ولا تحويل بين الأنواع، ولا وحدة افتراضية للوقت، ولا انتظار تلقائي للمهام في الخلفية، وكل ذلك يجب أن تكتبه بيدك. والقادم من لغة تفعل هذه الأشياء نيابة عنه يتركها فارغة، فتأتي الأخطاء 1 و7 و13.
  • النص بايتات، والأسماء تحدد الظهور: النص في Go سلسلة بايتات بترميز UTF-8 وليس سلسلة حروف كما في Python 3 أو Java، والحرف الأول من الاسم يحدد هل تراه حزمة أخرى، ومنها مكتبة JSON، ومن هنا الخطآن 6 و8.

والباقي، أي الأخطاء 2 و4 و9 و10 و11، تفاصيل في تصميم Go نفسها يقع فيها حتى من كتب Go سنوات: متغير الحلقة الذي غيرته اللغة في 1.22، والعلامة := التي تعرف وتسند في نفس الوقت، والخريطة التي لا تعد بترتيب، وdefer المرتبط بالدالة لا بالكتلة. وهذا التقسيم مفيد عملياً: إذا كنت قادماً من Java فراقب النسخ والمراجع أولاً، وإذا كنت قادماً من Python فراقب النصوص والشرائح، وإذا كنت قادماً من JavaScript فراقب الـ Goroutines والوقت.

أخطاء التزامن Concurrency

الخطأ 1: Goroutine تطلق وتنسى Fire-and-Forget

المطورة القادمة من JavaScript تعودت أن الدالة غير المتزامنة تعمل في الخلفية، وأن Node.js لا يخرج ما دام في حلقة الأحداث عمل ينتظر. فكتبت إرسال بريد التأكيد في Goroutine دون أن تنتظرها:

	case "fire-and-forget":
		for _, id := range []int{1, 2, 3} {
			go fmt.Println("wrong: sending email for order", id)
		}
		fmt.Println("wrong: main returns now")

وشغلنا البرنامج ثلاث مرات متتالية:

wrong: main returns now
--
wrong: main returns now
wrong: sending email for order 1
wrong: sending email for order 2
--
wrong: main returns now
--

في المرة الأولى والثالثة لم يرسل أي بريد، وفي الثانية أرسل بريدين من ثلاثة، والسبب أن برنامج Go ينتهي عندما تنتهي الدالة main، مهما كانت الـ Goroutines الأخرى تعمل، ولا ينتظر أحداً، كما تنص مواصفات اللغة في قسم تنفيذ البرنامج. وفي سيرفر HTTP لا تنتهي main، فيبدو الكود صحيحاً، ثم يضيع البريد عند كل إعادة نشر، لأن الإيقاف الهادئ ينتظر الطلبات الجارية فقط، ولا يعرف شيئاً عن Goroutine أطلقتها ونسيتها. والإصلاح أن يكون لكل Goroutine من ينتظرها:

	case "fire-and-forget-fixed":
		var wg sync.WaitGroup
		for _, id := range []int{1, 2, 3} {
			wg.Go(func() { fmt.Println("fixed: sending email for order", id) })
		}
		wg.Wait()
		fmt.Println("fixed: main returns after all emails")
fixed: sending email for order 3
fixed: sending email for order 1
fixed: sending email for order 2
fixed: main returns after all emails

وفي الخدمة الحقيقية الحل الأفضل لا يكون Goroutine أصلاً، وإنما سطر في جدول Outbox داخل نفس معاملة الطلب، وعامل منفصل يرسل البريد ويعيد المحاولة، كما شرحنا في مقال تصميم الأنظمة، لأن البريد الذي يضيع عند سقوط العملية يجب أن يكون محفوظاً في مكان لا يسقط معها.

الخطأ 2: متغير الحلقة Loop Variable والسطر go في go.mod

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

// The same file is in loop121 (go 1.21 in go.mod) and loop122 (go 1.22).
package main

import (
	"fmt"
	"sync"
)

func main() {
	ids := []int{101, 102, 103}
	var wg sync.WaitGroup
	var mu sync.Mutex
	var seen []int
	for _, id := range ids {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			seen = append(seen, id) // which id does the goroutine see?
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println("goroutines saw:", seen)
}

وبنفس المترجم Go 1.27.3، ثلاث مرات لكل وحدة:

### go 1.21 in go.mod
goroutines saw: [103 103 103]
goroutines saw: [103 103 103]
goroutines saw: [101 103 103]
### go 1.22 in go.mod
goroutines saw: [103 101 102]
goroutines saw: [103 101 102]
goroutines saw: [103 101 102]
### go vet in loop121
main.go:19:24: loop variable id captured by func literal
exit=1
مقارنة بين وحدة go 1.21 حيث يوجد متغير واحد لكل الحلقة فترى الـ Goroutines كلها 103 مع تحذير go vet، ووحدة go 1.22 حيث ينشأ متغير جديد في كل دورة فترى كل Goroutine قيمتها
نفس المترجم ونفس الكود، والسطر go في go.mod هو الذي غير النتيجة

في الوحدة الأولى رأت الـ Goroutines الثلاث القيمة 103 في كل تشغيل، لأن الحلقة قبل 1.22 تستخدم متغيراً واحداً تغير قيمته في كل دورة، والـ Goroutines تعمل بعد أن تنتهي الحلقة غالباً. وفي الثانية رأت كل واحدة قيمتها، والترتيب [103 101 102] هو ترتيب تنفيذ الـ Goroutines وليس ترتيب الحلقة. والسبب في ربط السلوك بالسطر go هو وعد التوافق: تغيير معنى الحلقة قد يغير سلوك برنامج قديم يعتمد عليه، فجعله الفريق يعمل فقط عندما تعلن الوحدة أنها مكتوبة لـ 1.22 أو أحدث، كما تشرح تدوينة تغيير متغير الحلقة. والدرس العملي: إذا ورثت مشروعاً قديماً فيه go 1.20 فأنت تكتب بالقواعد القديمة حتى لو كان مترجمك أحدث إصدار، ورفع السطر قرار يحتاج تشغيل الاختبارات مع -race، وgo vet يساعدك هنا لأنه أمسك السطر 19 بالضبط.

أخطاء القيم Values والذاكرة

الخطأ 3: append على جزء من شريحة Slice Aliasing

المطور القادم من Python تعود أن items[:2] نسخة جديدة مستقلة، فكتب:

	case "append-alias":
		all := []string{"قهوة", "تمر", "هيل"}
		firstTwo := all[:2]
		firstTwo = append(firstTwo, "زعفران")
		fmt.Println("wrong: all =", all, "| firstTwo =", firstTwo)
		all = []string{"قهوة", "تمر", "هيل"}
		firstTwo = slices.Clone(all[:2])
		firstTwo = append(firstTwo, "زعفران")
		fmt.Println("fixed: all =", all, "| firstTwo =", firstTwo)
wrong: all = [قهوة تمر زعفران] | firstTwo = [قهوة تمر زعفران]
fixed: all = [قهوة تمر هيل] | firstTwo = [قهوة تمر زعفران]

في النسخة الخاطئة اختفى «هيل» من القائمة الأصلية، وحل محله «زعفران»، دون أن نلمس all أبداً. والسبب أن all[:2] في Go ليست نسخة، وإنما نافذة على نفس المصفوفة طولها 2 وسعتها 3، فلما أضاف append عنصراً وجد مكاناً في السعة، فكتب فيه، وهذا المكان هو العنصر الثالث في all. ولو كانت السعة ممتلئة لحجز append مصفوفة جديدة، ولما ظهر الخطأ، وهذا ما يجعله خطيراً: يعتمد على السعة التي لا تراها. والإصلاح slices.Clone قبل الإضافة، أو صيغة الشريحة الكاملة all[:2:2] التي تحدد السعة بـ 2 فيضطر append أن ينسخ. والصورة التالية تبين هذا الخطأ والخطأ السادس معاً، لأن أصلهما واحد:

يمين: مصفوفة من ثلاث خانات قهوة وتمر وزعفران كانت هيل، والنافذة firstTwo بطول 2 وسعة 3 تكتب في الخانة الثالثة، ويسار: النص هيل حب في 11 بايتاً حيث كل حرف عربي بايتان، والقص عند البايت 5 يقطع اللام، ومخرج valid UTF-8 false
الشريحة نافذة على مصفوفة، والنص بايتات، وكلاهما لا ينسخ ولا يفهم ما بداخله

الخطأ 4: إخفاء err بالعلامة := Variable Shadowing

العلامة := تعرف متغيراً جديداً، وإذا كتبتها داخل كتلة if أو for عرفت متغيراً جديداً يخفي المتغير الخارجي بنفس الاسم. والدالة التالية تحاول أن تقرأ سعراً:

func parsePrice(s string) (int, error) {
	var err error
	price := 0
	if s != "" {
		price, err := strconv.Atoi(s) // := declares NEW price and err in this block
		if err != nil {
			return 0, err
		}
		_ = price
	}
	return price, err
}

func parsePriceFixed(s string) (int, error) {
	if s == "" {
		return 0, nil
	}
	return strconv.Atoi(s)
}
wrong: price=0 err=strconv.Atoi: parsing "abc": invalid syntax
wrong: price=0 err=<nil>
fixed: price=0 err=strconv.Atoi: parsing "abc": invalid syntax
fixed: price=990 err=<nil>

الحالة الخاطئة تقرأ «abc» بشكل صحيح وترجع الخطأ، ولكنها تقرأ «990» فترجع صفراً بلا أي خطأ، لأن price, err := داخل if أنشأ متغيرين جديدين، وأسند الرقم إلى price الداخلي الذي يختفي مع نهاية الكتلة، والدالة ترجع price الخارجي الذي بقي صفراً. ومنتج بسعر صفر في المتجر أسوأ بكثير من خطأ واضح. ولم يكتشف go vet ولا staticcheck هذا الخطأ بإعداداتهما الافتراضية، فالفحص shadow موجود كمحلل منفصل خارج go vet لأنه يعطي إنذارات كاذبة كثيرة. والإصلاح الحقيقي ليس أداة، وإنما أسلوب: الرجوع المبكر Early Return وعدم تعريف المتغيرات قبل الحاجة إليها، كما في parsePriceFixed التي صارت سطرين، فلا يبقى مكان للإخفاء.

ولماذا تسمح Go بهذا الإخفاء أصلاً؟ لأن العلامة := وضعت لتقلل الكتابة في أكثر سطر يتكرر في اللغة، v, err := f()، والقاعدة أنها تعيد استخدام المتغير الموجود في نفس الكتلة، وتعرف متغيراً جديداً إذا كان الموجود في كتلة خارجية. وهذه القاعدة منطقية عندما تعرفها، ولكنها تعني أن نفس السطر يتصرف بشكل مختلف بحسب مكانه، وأن نقل سطر صحيح إلى داخل if أثناء إعادة الترتيب قد يغير معناه دون أن يتغير حرف فيه. لذلك ينصح كثيرون بأن تكون الدوال قصيرة بما يكفي لترى كل تعريفاتها في شاشة واحدة، فيرى المراجع الإخفاء بعينه.

الخطأ 5: تعديل نسخة في حلقة range Range Copy

القادم من Java أو C# يكتب for (Product p : products) p.stock-- فيتعدل الكائن، لأن p مرجع. وفي Go:

	case "range-copy":
		products := []Product{{1, "قهوة", 100}, {3, "هيل", 10}}
		for _, p := range products {
			p.Stock -= 1 // p is a copy
		}
		fmt.Println("wrong:", products)
		for i := range products {
			products[i].Stock -= 1
		}
		fmt.Println("fixed:", products)
wrong: [{1 قهوة 100} {3 هيل 10}]
fixed: [{1 قهوة 99} {3 هيل 9}]

الحلقة الأولى لم تغير شيئاً، لأن p في for _, p := range products نسخة من كل عنصر، والـ struct ينسخ بالقيمة كما رأينا في المقال الثاني. والإصلاح أن تعدل العنصر نفسه بالفهرس products[i]، أو أن تكون الشريحة من مؤشرات []*Product إذا كانت العناصر كبيرة أو تعدل كثيراً. ولاحظ أن الأخذ بعنوان متغير الحلقة &p صار آمناً منذ 1.22 لأن كل دورة لها متغيرها، ولكنه عنوان النسخة وليس عنوان العنصر في الشريحة، فالتعديل عبره يضيع أيضاً.

أخطاء النص String والوقت Time وتنسيق JSON

الخطأ 6: قص النص العربي بالبايت Bytes vs Runes

أراد المطور أن يعرض أول خمسة «أحرف» من اسم المنتج في إشعار قصير، فكتب name[:5] كما يكتبها في Python:

	case "arabic-bytes":
		name := "هيل حب"
		fmt.Println("len:", len(name), "| runes:", utf8.RuneCountInString(name))
		cut := name[:5]
		fmt.Printf("wrong: name[:5] = %q valid UTF-8? %v\n", cut, utf8.ValidString(cut))
		fmt.Printf("wrong: name[0] = %v (%T)\n", name[0], name[0])
		r := []rune(name)
		fmt.Printf("fixed: string([]rune(name)[:3]) = %q\n", string(r[:3]))
		for i, ch := range "هيل" {
			fmt.Printf("fixed: range gives byte index %d rune %q\n", i, ch)
		}
len: 11 | runes: 6
wrong: name[:5] = "هي\xd9" valid UTF-8? false
wrong: name[0] = 217 (uint8)
fixed: string([]rune(name)[:3]) = "هيل"
fixed: range gives byte index 0 rune 'ه'
fixed: range gives byte index 2 rune 'ي'
fixed: range gives byte index 4 rune 'ل'

طول «هيل حب» 11 وليس 6، لأن len تعد البايتات، والنص في Go بايتات بترميز UTF-8، وكل حرف عربي فيه بايتان. والقص عند البايت 5 أخذ الهاء والياء ونصف اللام، فخرج نص غير صالح valid UTF-8? false، وسوف يظهر في الإشعار رمز استبدال غريب، أو ترفضه واجهة خارجية تتحقق من الترميز. والفهرس name[0] رجع رقماً 217 من النوع uint8، وليس الحرف «ه». والإصلاح أن تعمل على الحروف Runes: التحويل []rune(name) ثم القص، أو الحلقة range التي تعطيك الحرف وموقع أول بايت له، ولاحظ في المخرج أن المواقع 0 ثم 2 ثم 4، وهذا يؤكد أن كل حرف بايتان. ويشرح مقال Strings, bytes, runes and characters in Go هذا كله، وهو قراءة إلزامية لكل من يكتب نصوصاً عربية بلغة Go.

الخطأ 7: time.Sleep(50) بلا وحدة Duration

في JavaScript تكتب setTimeout(fn, 50) فتعني 50 مللي ثانية، وفي Python time.sleep(0.05) بالثواني. وفي Go:

	case "duration":
		t := time.Now()
		time.Sleep(50) // 50 nanoseconds, not 50 ms
		fmt.Println("wrong: time.Sleep(50) took", time.Since(t).Round(time.Microsecond))
		t = time.Now()
		time.Sleep(50 * time.Millisecond)
		fmt.Println("fixed: time.Sleep(50 * time.Millisecond) took", time.Since(t).Round(time.Millisecond))
wrong: time.Sleep(50) took 22µs
fixed: time.Sleep(50 * time.Millisecond) took 61ms

النوع time.Duration رقم صحيح بالنانوثانية، والثابت 50 بلا وحدة يصبح 50 نانوثانية، فالنوم أخذ 22 ميكروثانية في هذا التشغيل و1.3 مللي ثانية في تشغيل سابق، والفرق من دقة مؤقت النظام وليس من الكود، وكلاهما بعيد جداً عن 50 مللي ثانية. وتخيل هذا في حلقة إعادة المحاولة مع خدمة الشحن: بدل أن تنتظر 50 مللي ثانية بين كل محاولة ترسل آلاف الطلبات في ثانية. وهذا الخطأ الوحيد هنا الذي كشفه staticcheck:

mistakes/main.go:163:14: sleeping for 50 nanoseconds is probably a bug; be explicit if it isn't (SA1004)
exit=1

والإصلاح أن تكتب الوحدة دائماً، 50 * time.Millisecond، وأن يكون نوع كل معامل يمثل وقتاً في دوالك time.Duration وليس int، كما يوصي توثيق Duration، فلا يستطيع أحد أن يمرر رقماً بلا وحدة دون أن يراه المراجع.

ولماذا اختار مصممو Go النانوثانية وحدة أساسية؟ لأن Duration رقم int64 واحد، وبالنانوثانية يغطي حوالي 290 سنة، وهذا يكفي لكل مهلة معقولة دون أن تحتاج إلى أرقام عشرية، فالجمع والمقارنة بين الأزمنة عمليات على أرقام صحيحة سريعة ودقيقة، وهي نفس فكرة الهللة في المبالغ. والثمن أن الرقم بلا وحدة يقرأ كنانوثانية دون أي تحذير من المترجم، لأن الثابت 50 بلا نوع يتحول إلى Duration وحده.

الخطأ 8: حقول لا تظهر في JSON Unexported Fields

type orderJSON struct {
	id    int    // lower case: invisible to encoding/json
	total int
	Name  string `json:"name_ar"`
}

	case "json-unexported":
		b, _ := json.Marshal(orderJSON{id: 1, total: 3416, Name: "هيل"})
		fmt.Println("wrong:", string(b))
		type orderOut struct {
			ID    int    `json:"id"`
			Total int    `json:"total_halalas"`
			Name  string `json:"name_ar"`
		}
		b, _ = json.Marshal(orderOut{1, 3416, "هيل"})
		fmt.Println("fixed:", string(b))
wrong: {"name_ar":"هيل"}
fixed: {"id":1,"total_halalas":3416,"name_ar":"هيل"}

رقم الطلب والإجمالي اختفيا من JSON دون أي خطأ، لأن الحقل الذي يبدأ بحرف صغير غير مصدر، والحزمة encoding/json حزمة أخرى لا تراه، كما ذكرنا في المقال الأول. والقادم من Python لا يتوقع ذلك لأن كل الحقول عامة هناك، والقادم من Java يتوقع أن المكتبة تستخدم الانعكاس Reflection لتقرأ الحقول الخاصة كما تفعل Jackson أحياناً. والإصلاح حقول بحرف كبير مع وسم json لكل حقل، يحدد الاسم الذي يراه العميل بأسلوب snake_case المتفق عليه في الواجهة. وفي الخدمة الحقيقية عرف نوعاً خاصاً بالرد كما في orderOut، ولا تعرض أنواع قاعدة البيانات مباشرة، حتى لا يخرج حقل داخلي لم تقصد إخراجه يوم يضيفه أحد.

أخطاء الأخطاء Errors والخرائط Maps والموارد

الخطأ 9: مقارنة نص الخطأ Error String

var ErrNotFound = errors.New("not found")

func find(id int) error { return fmt.Errorf("find product %d: %w", id, ErrNotFound) }

	case "error-string":
		err := find(9)
		fmt.Println("wrong: err.Error() == \"not found\"?", err.Error() == "not found", "| text:", err)
		fmt.Println("fixed: errors.Is(err, ErrNotFound)?", errors.Is(err, ErrNotFound))
wrong: err.Error() == "not found"? false | text: find product 9: not found
fixed: errors.Is(err, ErrNotFound)? true

المقارنة بالنص فشلت لأن الدالة غلفت الخطأ بسياقها «find product 9:»، وهذا هو الأسلوب الصحيح في Go، فكل كود يقارن النصوص سوف يتعطل في اليوم الذي يضيف فيه أحد سياقاً أفضل للرسالة. والإصلاح errors.Is مع خطأ ثابت، أو errors.As مع نوع خطأ، كما فصلنا في المقال الثاني. ويأتي هذا الخطأ عادة من Java أو C# حيث يقارن البعض e.getMessage()، أو من مكتبات قديمة لم تكن تعطي نوعاً للخطأ، وإذا اضطررت لمكتبة كهذه فضع المقارنة بالنص في دالة واحدة معزولة مع اختبار، ولا تنشرها في الكود.

الخطأ 10: الاعتماد على ترتيب الخريطة Map Order

	case "map-order":
		stock := map[string]int{"قهوة": 100, "تمر": 100, "هيل": 10, "زعفران": 5}
		for run := 1; run <= 3; run++ {
			var keys []string
			for k := range stock {
				keys = append(keys, k)
			}
			fmt.Println("wrong run", run, keys)
		}
		fmt.Println("fixed:", slices.Sorted(maps.Keys(stock)))

وهنا حدث شيء لم نتوقعه. داخل عملية واحدة أعطت الحلقة نفس الترتيب ثلاث مرات:

wrong run 1 [قهوة تمر هيل زعفران]
wrong run 2 [قهوة تمر هيل زعفران]
wrong run 3 [قهوة تمر هيل زعفران]
fixed: [تمر زعفران قهوة هيل]

ثم شغلنا البرنامج ثلاث مرات كعمليات منفصلة، ومعه خريطة من عشرين مفتاحاً:

wrong run 1 [تمر هيل زعفران قهوة]
wrong run 1 [قهوة تمر هيل زعفران]
wrong run 1 [هيل زعفران قهوة تمر]
first 5 keys of a 20-key map: [9 15 20 1 5]
first 5 keys of a 20-key map: [7 8 10 11 5]
first 5 keys of a 20-key map: [6 7 17 18 19]

فالترتيب يختلف بين العمليات، ويختلف داخل الخريطة الكبيرة، ولكنه بقي ثابتاً للخريطة الصغيرة داخل عملية واحدة. ومواصفات اللغة لا تعد بأي ترتيب، وتطبيق الخريطة الحالي يختار نقطة بداية عشوائية بطرق تتغير بين الإصدارات، ولذلك لا تعتمد على الترتيب، ولا تعتمد أيضاً على أن العشوائية سوف تكشف الخطأ لك في الاختبار، فاختبار يقارن مخرج JSON من خريطة صغيرة قد ينجح على جهازك ألف مرة ثم يفشل في CI. والإصلاح أن ترتب المفاتيح صراحة عندما يهمك الترتيب، والسطر slices.Sorted(maps.Keys(stock)) يفعل ذلك في سطر واحد منذ Go 1.23 بفضل المكررات Iterators التي يشرحها مقال Range Over Function Types، ولاحظ أن الترتيب الأبجدي العربي في المخرج «تمر، زعفران، قهوة، هيل» هو ترتيب نقاط Unicode، وهو يطابق الأبجدية في هذه الكلمات ولكنه ليس ترتيباً لغوياً كاملاً.

وقصة هذا السلوك قديمة: في ملاحظات إصدار Go 1 عام 2012 قرر الفريق أن يجعل ترتيب المرور على الخريطة غير قابل للتنبؤ عن قصد، والسبب المكتوب هناك أن الكود الذي يعتمد على الترتيب سوف يتعطل مبكراً ويصلح قبل أن يصبح مشكلة، وأن ذلك يعطي تطبيق الخريطة حرية في تحسين نفسه. وقد استفاد التطبيق من هذه الحرية فعلاً، فالخريطة في Go 1.24 وما بعده مبنية على تصميم جديد بجداول Swiss Tables، وتغير معه شكل العشوائية في الخرائط الصغيرة، وهذا على الأرجح سبب أن خريطتنا ذات المفاتيح الأربعة أعطت نفس الترتيب داخل العملية، والدرس نفسه: ما لا تعد به اللغة يتغير بين الإصدارات دون إعلان.

الخطأ 11: defer داخل حلقة طويلة Defer in Loop

func readAllDeferInLoop(n int) int {
	maxOpen := 0
	for i := 0; i < n; i++ {
		f, err := os.Open("/dev/null")
		if err != nil {
			panic(err)
		}
		defer f.Close() // runs when the FUNCTION returns, not the iteration
		maxOpen = max(maxOpen, countFDs())
	}
	return maxOpen
}

func readAllFixed(n int) int {
	maxOpen := 0
	for i := 0; i < n; i++ {
		func() {
			f, err := os.Open("/dev/null")
			if err != nil {
				panic(err)
			}
			defer f.Close() // runs at the end of this small function
			maxOpen = max(maxOpen, countFDs())
		}()
	}
	return maxOpen
}
open files at start: 6
wrong: peak open files with defer in loop: 508
fixed: peak open files with a function per iteration: 9
ثلاث بطاقات: defer داخل حلقة 500 ملف أعطى 508 ملفات مفتوحة مقابل 9 بعد الإصلاح، وtime.Sleep(50) أخذ 22 ميكروثانية مقابل 61 مللي ثانية بعد الإصلاح، وترتيب خريطة من 20 مفتاحاً اختلف في ثلاث عمليات
ثلاثة أخطاء لا ترمي أي خطأ، وتظهر فقط في الأرقام

الأمر defer يؤجل التنفيذ إلى نهاية الدالة، وليس إلى نهاية دورة الحلقة، فالنسخة الخاطئة فتحت 500 ملف وأبقتها كلها مفتوحة حتى انتهت الدالة، ووصل عدد الواصفات Descriptors المفتوحة إلى 508. وفي برنامج يعالج ملفات الفواتير اليومية، أو يفتح اتصالاً لكل سطر، سوف تصل إلى حد النظام للملفات المفتوحة وتظهر رسالة too many open files في يوم يكون فيه عدد الفواتير أكبر من المعتاد. والإصلاح أن تضع جسم الدورة في دالة صغيرة، فيعمل defer في نهايتها، فبقي العدد 9 في كل لحظة، أو أن تغلق صراحة في نهاية الدورة إذا كان الكود قصيراً بلا طرق خروج متعددة.

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

الخطأ 12: قفل يمرر بالقيمة Copying a Mutex

// A struct with a mutex passed by value: each copy has its own lock.
package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c Counter) Inc() { // value receiver: locks and changes a copy
	c.mu.Lock()
	c.n++
	c.mu.Unlock()
}

func main() {
	var c Counter
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Go(func() { c.Inc() })
	}
	wg.Wait()
	fmt.Println("count:", c.n)
}
### go run .
count: 0
### go vet .
main.go:14:9: Inc passes lock by value: example.com/copylock.Counter contains sync.Mutex
exit=1

ألف زيادة، والعداد صفر. والسبب الخطأ الخامس نفسه في ثوب آخر: الدالة Inc بمستقبل بالقيمة، فكل استدعاء ينسخ الـ struct كاملاً، ومعه القفل، ويقفل النسخة ويزيد عدادها ثم يرميها. ولو كان القفل يعمل لما نفع أيضاً، لأن كل نسخة لها قفلها. وهذا من الأخطاء القليلة التي يمسكها go vet بفحص copylocks، والرسالة واضحة: Inc passes lock by value. والإصلاح نجمة واحدة: func (c *Counter) Inc()، والقاعدة التي ذكرناها في المقال الثاني: النوع الذي فيه قفل يمرر دائماً بمؤشر.

الخطأ 13: تجاهل الخطأ بالشرطة السفلية Ignored Error

	case "ignored-error":
		p, _ := strconv.Atoi("9,90")
		fmt.Println("wrong: price =", p, "halalas")
		if _, err := strconv.Atoi("9,90"); err != nil {
			fmt.Println("fixed:", err)
		}
wrong: price = 0 halalas
fixed: strconv.Atoi: parsing "9,90": invalid syntax

السعر جاء من ملف استيراد كتبه موظف بالفاصلة «9,90»، والدالة Atoi فشلت، والشرطة السفلية رمت الخطأ، فصار السعر صفراً هللة دون أي رسالة. والمطور القادم من Java أو Python يفعل ذلك لأنه تعود أن الخطأ الذي لا تعالجه يرمي استثناء ويوقف البرنامج، فهو «مرئي» حتى لو تجاهلته، أما في Go فالخطأ الذي تتجاهله يختفي تماماً. والقاعدة: لا تكتب _ مكان خطأ إلا مع تعليق يشرح لماذا هو مستحيل أو غير مهم، وأدوات مثل errcheck ضمن golangci-lint تبحث عن الأخطاء المهملة، ولكنها تتجاوز الشرطة السفلية الصريحة افتراضياً، لأنها تعتبرها قراراً مقصوداً.

وهناك أعراف أخرى على مستوى المشروع لا على مستوى السطر: قلل الاعتماديات، فالمكتبة القياسية تكفي لكثير مما تبحث له عن مكتبة في لغات أخرى، وكل اعتمادية إضافية كود يجب أن تراجعه وتحدثه وتفحص ثغراته، وفي Go مقولة مشهورة بين المطورين أن قليلاً من النسخ أفضل من قليل من الاعتماد A little copying is better than a little dependency. وابدأ بحزمة واحدة ثم قسم عندما تظهر الحدود، كما ذكرنا في المقال الأول، ولا تكتب واجهة حتى يوجد لها مستخدمان، ولا تكتب Goroutine في مكتبة يستخدمها غيرك إلا إذا كان المستدعي يتحكم في عمرها، فالمكتبة الجيدة في Go تقدم دوال متزامنة عادية، ويقرر المستدعي هل يشغلها في Goroutine أم لا.

كيف تلاحظ هذه الأخطاء في الإنتاج؟ Production Signals

أغلب هذه الأخطاء لا يرمي خطأً ولا يكتب سطراً في السجل، فكيف تعرف أنها وصلت إلى الإنتاج؟ الإجابة أن كل واحد منها يترك أثراً في مكان آخر، وإذا عرفت الأثر عرفت أين تبحث:

  • عملاء يشتكون أن بريد التأكيد لا يصل أحياناً، وخصوصاً بعد كل نشر جديد: Goroutine تطلق وتنسى، أي الخطأ 1.
  • عدد الـ Goroutines في لوحة المراقبة يرتفع ولا ينزل، والذاكرة معه: تسرب، كما رأينا في مقال التزامن، وملف goroutineleak يدلك على السطر.
  • طلبات بإجمالي صفر أو منتجات بسعر صفر في قاعدة البيانات: خطأ متجاهل أو مخفي بالعلامة :=، أي الخطآن 4 و13. وضع قيداً CHECK (price_halalas > 0) حيث يكون الصفر مستحيلاً في عملك، فيتحول الخطأ الصامت إلى خطأ صاخب.
  • رموز غريبة في أسماء المنتجات في الإشعارات أو الرسائل، أو رفض من بوابة الرسائل بسبب الترميز: قص بالبايت، أي الخطأ 6.
  • الرسالة too many open files أو accept: too many open files في السجل في يوم ضغط: ملفات أو اتصالات لا تغلق، وأولها defer في حلقة، أو جسم رد HTTP لم يغلق.
  • شركة الشحن تحظر حسابك بسبب عدد الطلبات: إعادة محاولة بلا وحدة وقت، أي الخطأ 7.
  • تطبيق المتجر يعرض حقولاً فارغة بعد تحديث في الخدمة: حقل صار بحرف صغير أو نقل إلى نوع داخلي، أي الخطأ 8.
  • أرقام 404 تتحول فجأة إلى 500 بعد تعديل لا علاقة له بها: مقارنة نص خطأ تعطلت لأن أحداً غير الرسالة، أي الخطأ 9.
  • اختبار ينجح على جهازك ويفشل في CI مرة كل عدة أيام: ترتيب خريطة، أي الخطأ 10، أو سباق لم يشغل عليه كاشف السباقات.
  • عداد أو مخزون في الذاكرة لا يتغير رغم أن العمليات تنجح: نسخة تعدل بدل الأصل، أي الخطآن 5 و12.

ولاحظ أن نصف هذه الآثار لا يظهر إلا إذا كانت لديك مراقبة حقيقية: عدد الـ Goroutines والملفات المفتوحة من مقاييس الـ Runtime، ونسب الردود 4xx و5xx لكل طريق، وتنبيه على القيم المستحيلة في قاعدة البيانات. وهذه كلها جزء من قائمة «ما الذي ينقص الخدمة قبل الإنتاج» في المقال الرابع، وهي التي تحول الخطأ الصامت إلى إنذار في نفس اليوم بدل شكوى عميل بعد أسبوع.

إعداد الأدوات Tooling لتكشف أكثر

رأينا أن الإعداد الافتراضي للأدوات كشف ثلاثة أخطاء فقط، ولكنك تستطيع أن ترفع هذا العدد بإعداد مدروس، مع العلم أن كل فحص إضافي يأتي بإنذارات كاذبة يجب أن يتحملها الفريق. والطريقة الشائعة أن تستخدم golangci-lint بملف إعداد في المستودع يفعل مجموعة محددة من الفاحصات، ويثبت إصدارها في CI. ومن الفاحصات التي تخص أخطاء هذا المقال: govet مع تفعيل فحص shadow للخطأ الرابع، وerrcheck للأخطاء المهملة دون الشرطة السفلية، وstaticcheck بكل فحوصاته، وbodyclose لأجسام ردود HTTP التي لا تغلق. وابدأ بعدد قليل، وشغله على الكود الحالي، وأصلح ما يجده أو اكتب سبب الاستثناء بجانب السطر، ثم اجعل الخط يفشل عند أي ملاحظة جديدة. ولا تفعل كل الفاحصات المتاحة في يوم واحد، لأن مئات الملاحظات على كود قديم تجعل الفريق يتجاهلها كلها، وهذا أسوأ من عدم وجودها. وأضف إلى ذلك ثلاثة أوامر لا تحتاج أي إعداد: go test -race ./... في كل Pull Request، وgo test -count=20 -run Concurrent في الليل، وgovulncheck ./... كل أسبوع.

أسلوب Go الصحيح Idiomatic Go: ما الذي يبحث عنه المراجع؟

الأخطاء السابقة تنتج سلوكاً خاطئاً، ولكن هناك طبقة ثانية في المراجعة لا تتعلق بالصحة، وإنما بأن يقرأ الكود كما يقرأ كود Go في كل مكان، وهذا ما يقصده الناس بكلمة Idiomatic. ومرجع هذه الطبقة صفحة Go Code Review Comments التي يجمع فيها فريق Go التعليقات المتكررة في مراجعات الكود، ودليل أسلوب Go من Google. وأهم ما فيهما مما يخالف عادات اللغات الأخرى:

  • الأسماء: MixedCaps دائماً وليس snake_case، والاختصارات بحالة واحدة مثل ID وURL وHTTP وليس Id. والأسماء قصيرة كلما قصر نطاقها، فمتغير حلقة من ثلاثة أسطر اسمه p وليس currentProductItem، والمستقبل حرف أو حرفان مثل s *Store وليس this أو self.
  • لا يوجد Get قبل اسم دالة القراءة: الدالة التي ترجع المالك اسمها Owner()، والتي تغيره SetOwner()، كما يقول Effective Go.
  • اسم الحزمة جزء من الاسم: store.New وليس store.NewStore، وhttp.Client وليس http.HTTPClient، ويشرح مقال أسماء الحزم ذلك.
  • الواجهات صغيرة، ويعرفها المستهلك، وأسماء واجهات الدالة الواحدة تنتهي بـ er مثل Reader وStringer، ولا يوجد I قبل الاسم كما في C#.
  • رسائل الأخطاء تبدأ بحرف صغير ولا تنتهي بنقطة، لأنها تغلف داخل رسائل أخرى، فيقرأ السطر «reserve: find product 9: not found» كجملة واحدة.
  • الطريق السعيد Happy Path على اليسار: افحص الخطأ وارجع مبكراً، ولا تضع الكود الطبيعي داخل else، فيبقى الكود الذي يقرؤه الناس أولاً بلا إزاحة.
  • context.Context أول معامل في كل دالة تنتظر شيئاً، ولا يحفظ في struct.
  • القيمة الصفرية مفيدة: صمم أنواعك بحيث تعمل دون دالة إنشاء إن أمكن.
  • تعليق التوثيق يبدأ باسم الشيء، كما في Go Doc Comments.
  • الاختبارات جدولية Table-Driven مع t.Run، ورسالة الفشل تقول ما ظهر وما كان متوقعاً.

وهناك جملة تلخص روح هذه القواعد كلها، وتسمعها كثيراً في مجتمع Go: الوضوح أفضل من الذكاء Clear is better than clever. فإذا كنت بين حل من سطر واحد يحتاج تفكيراً ليفهم، وحل من خمسة أسطر يقرأ فوراً، فاختر الخمسة. وهذا ما يجعل الكود في Go يبدو «مملاً» للقادم من Python أو Kotlin، وهو ملل مقصود، لأن الكود يقرأ أكثر بكثير مما يكتب، والفريق الذي يكبر يحتاج كوداً يفهمه المطور الجديد في أسبوعه الأول.

مثال كامل Refactoring: من أسلوب Java إلى أسلوب Go

لكي ترى الفرق بين الكود الذي يعمل والكود الذي يقرأ كأنه Go، لنفرض أن الزميل القادم من Java كتب في أسبوعه الأول دالة لحجز المخزون بالشكل الذي تعود عليه: اسمها GetStockServiceInstance().ReserveProductStock(productIdStr string)، وتبدأ بإنشاء كائن خدمة من مصنع، ثم تحول المعرف من نص برقم وتتجاهل الخطأ، ثم تفتح كتلة if err == nil { … } else { … } فيها كل المنطق بإزاحتين، ثم ترجع bool وتكتب الخطأ في السجل بدل أن ترجعه، وكان النوع StockService يحقق واجهة IStockService معرفة بجانبه لا يستخدمها أحد غير الاختبار. والمراجعة لم تطلب تغيير المنطق، وإنما الشكل، وكانت التعديلات كما يلي:

  • حذف المصنع، والاكتفاء بنوع Store فيه مجمع الاتصالات، ينشأ مرة واحدة في main ويمرر لمن يحتاجه.
  • تغيير الاسم إلى Reserve(ctx context.Context, productID int64, qty int64) error: السياق أول معامل، والمعرف رقم بنوعه الصحيح، وتحويل النص إلى رقم مسؤولية طبقة HTTP التي تستقبل المدخلات، والاسم بلا تكرار لكلمة Stock لأن النوع اسمه Store والحزمة store.
  • إرجاع error بدل bool مع سجل، لأن من يستدعي الدالة هو من يقرر هل الخطأ 409 أم 500، والدالة التي تكتب في السجل وترجع false تجعل الخطأ يسجل مرتين أو لا يسجل أبداً.
  • الرجوع المبكر بعد كل خطأ، فصار الطريق السعيد على اليسار بلا إزاحة، وصار يقرأ من الأعلى إلى الأسفل كقائمة خطوات.
  • نقل الواجهة إلى الحزمة التي تستخدمها، بالدوال التي تحتاجها فقط، كما في الواجهة Store في طبقة HTTP في المقال الرابع.

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

خطة القراءة Reading Plan من المصادر الرسمية

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

أربع مراحل للقراءة: الأسبوع الأول A Tour of Go وHow to Write Go Code والدروس، والثاني Effective Go وCode Review Comments وGo Doc Comments ودليل Google، والثالث مواصفات اللغة ونموذج الذاكرة ودليل الجامع والتشخيص وكاشف السباقات، وبعدها ملاحظات الإصدارات والمدونة وpkg.go.dev والأسئلة الشائعة
من الجولة إلى المواصفات، ثم متابعة كل إصدار

وأفضل طريقة لتثبيت كل ذلك أن تقرأ كوداً جيداً، والمكتبة القياسية نفسها أفضل كود Go تستطيع أن تقرأه، فافتح مثلاً net/http/server.go أو strings/builder.go في GOROOT على جهازك، وانظر كيف كتب فريق اللغة نفسه ما شرحناه هنا.

وكلمة أخيرة عن ملاحظات الإصدار، لأنها أكثر مصدر يهمله المطورون: كل ستة أشهر يصدر إصدار جديد، وملاحظاته ليست قائمة إصلاحات فقط، وإنما فيها تغييرات تمس الكود الذي كتبته في هذه المقالات مباشرة. ففي آخر ثلاثة إصدارات وحدها صار GOMAXPROCS يقرأ حد الـ Container، وجاءت WaitGroup.Go، وصار Green Tea هو جامع القمامة الافتراضي، وجاءت errors.AsType، وصار ملف goroutineleak متاحاً، وأضاف go vet فحص تغليف المؤشر بالفعل %w، وصارت الدوال المرتبطة بنوع تأخذ معاملات نوع. وكل واحد من هذه رأيناه في مقالات هذه المرحلة، ومن لم يقرأ الملاحظات ما زال يكتب الكود بطريقة الإصدار الذي تعلم عليه. لذلك اجعل قراءة ملاحظات كل إصدار عادة في فريقك، في فبراير وأغسطس، وناقشوا ما يغير كودكم منها قبل أن ترفعوا السطر go في go.mod.

مشكلات Problems واجهتنا أثناء التجربة

  • في أول تشغيل لمثال Goroutine التي تطلق وتنسى، وضعنا النسخة الخاطئة والمصححة في نفس الحالة، فطبعت النسخة الخاطئة رسائلها كلها، لأن main لم تنته وقتها، وإنما انتظرت النسخة المصححة، فأعطت الـ Goroutines الخاطئة وقتاً لتعمل. والخطأ لم يظهر لأن تجربتنا نفسها أخفته، وهذا بالضبط ما يحدث في السيرفر الحقيقي. ففصلنا الحالتين، فظهر الضياع في تشغيلين من ثلاثة.
  • كنا نتوقع أن تعطي الخريطة الصغيرة ترتيباً مختلفاً في كل حلقة داخل نفس العملية، فأعطت نفس الترتيب ثلاث مرات. فبدل أن نكتب ما توقعناه، شغلنا البرنامج كعمليات منفصلة وأضفنا خريطة أكبر، فظهر الاختلاف، وصار الدرس أدق مما خططنا له.
  • زمن time.Sleep(50) كان 1.3 مللي ثانية في التشغيل الأول و22 ميكروثانية في الثاني، والسبب أن أقل زمن نوم فعلي يعتمد على مؤقت النظام وانشغال الجهاز. لذلك لا تكتب أبداً اختباراً يعتمد على أن النوم القصير يأخذ زمناً محدداً.

قائمة مراجعة الكود Code Review Checklist

السؤالالخطأ الذي يمنعه
هل لكل Goroutine من ينتظرها أو يلغيها؟1، وتسرب الـ Goroutines
ما هو السطر go في go.mod؟2
هل append على جزء من شريحة يملكها غيرك؟3
هل في الدالة := داخل كتلة لمتغير معرف خارجها؟4
هل الحلقة تعدل متغير range؟5
هل يقص نص أو يعد بالبايت وهو قد يحوي عربياً؟6
هل كل رقم يمثل وقتاً معه وحدته ونوعه time.Duration؟7
هل كل حقل في الرد مصدر وله وسم json؟8
هل يوجد err.Error() في شرط؟9
هل يعتمد مخرج أو اختبار على ترتيب خريطة؟10
هل يوجد defer داخل حلقة؟11
هل يمرر نوع فيه قفل بالقيمة؟12، ويكشفه go vet
هل توجد _ مكان خطأ بلا تعليق؟13

تدريب عملي

  • شغل go run golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest ./mistakes على البرنامج، وانظر هل يكشف الخطأ الرابع، وكم إنذاراً كاذباً يعطي.
  • أضف إلى الحالة arabic-bytes دالة truncate(s string, n int) string تقص النص إلى n حرفاً وتضيف «…» إذا قصت، واكتب لها اختباراً جدولياً فيه نص عربي ونص إنجليزي ونص فيه رموز تعبيرية.
  • غير السطر في loop121/go.mod إلى go 1.22، وشغل go vet مرة ثانية، ولاحظ اختفاء التحذير.
  • شغل golangci-lint بإعداداته الافتراضية على البرنامج، وقارن ما يكشفه بالخريطة في أول المقال.

الخلاصة

وصلنا لنهاية الموضوع، ولنهاية مرحلة Go، وأهم ما فيه:

  • الأخطاء الشائعة في Go تأتي من عادات صحيحة في لغات أخرى، وأغلبها يترجم ويعمل بصمت، ومن ثلاثة عشر خطأً كشف go vet اثنين وstaticcheck واحداً فقط.
  • Goroutine بلا انتظار ضاعت رسائلها في تشغيلين من ثلاثة، ومتغير الحلقة يتبع السطر go في go.mod: نفس المترجم أعطى [103 103 103] مع 1.21 والقيم الصحيحة مع 1.22.
  • الشريحة نافذة والنص بايتات: append مسح «هيل» من قائمة لم نلمسها، والقص عند البايت 5 أنتج نصاً عربياً غير صالح.
  • := داخل كتلة أرجع سعراً صفراً بلا خطأ، وtime.Sleep(50) نام 22 ميكروثانية، والحقول الصغيرة اختفت من JSON، والخطأ المتجاهل جعل السعر صفراً.
  • defer في حلقة أبقى 508 ملفات مفتوحة بدل 9، والقفل الممرر بالقيمة جعل ألف زيادة صفراً، وترتيب الخريطة ثبت داخل عملية واحدة ثم تغير بين العمليات.
  • الأسلوب الصحيح وضوح قبل الذكاء، ومراجعه الأولى Effective Go وCode Review Comments، وخطة القراءة تبدأ من الجولة وتنتهي بالمواصفات ونموذج الذاكرة.

وبهذا نكون قد أكملنا مرحلة لغة Go للمطورين، والمقالات الخمس معاً تغطي ما يحتاجه مطور قادم من لغة أخرى ليعمل في فريق Go، وإذا أردت أن تقارن ما بنيناه بنفس الخدمة في لغات أخرى فتجدها في بقية مراحل مسار لغات البرمجة عملياً:

دليلGo لمن يعرف البرمجة: الأدوات Toolchain وأول مشروعتثبيت Go 1.27 والفرق في صيغتها عما تعرفه، ثم أدواتها في أمر واحد وشكل المشروع، مع تجربة كاملة تبني حاسبة طلب باختباراتها وفحصها وملف تنفيذي واحد.دليلالأنواع والواجهات Interfaces والذاكرة والأخطاء في Goلماذا لا ينقص المخزون رغم أن الدالة تعمل، ولماذا لا يساوي الخطأ nil وهو يطبع nil، وكيف تقيس ما يحجزه كودك من الذاكرة وتختار شكل الخطأ في خدمة حقيقية بلغة Go.دليلالتزامن في Go: الـ Goroutines والقنوات Channels بالأرقاممئة طلب شبكة ومهمة SHA-256 بالتسلسل ثم بالـ Goroutines، بحد للمعالج وبدونه، مع حالة سباق باعت عشرين قطعة من عشر، وجمود وتسرب نكشفهما بأدوات Go 1.27.دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.دليلالبرمجة بعدة لغات Polyglot: متى تفيد ومتى تعقد مشروعك؟نستدعي نفس الدالة من Python بخمس طرق، عبر FFI إلى C وGo وعبر HTTP وgRPC، ونقيس كلفة كل حد بين لغتين وتعقيد البناء وما يحدث عندما يتعطل الطرف الآخر.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتشغيل كل الأخطاء ونسخها المصححة بالإصدار Go 1.27.3 داخل الـ Image الرسمية golang:1.27.3-trixie، مع staticcheck 2026.2.2.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

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

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.