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

الأنواع والواجهات Interfaces والذاكرة والأخطاء في Go

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

الأنواع والواجهات Interfaces والذاكرة والأخطاء في Go

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

وفي المقال الأول من هذه المرحلة ثبتنا Go 1.27.3 وتعرفنا على الأدوات وكتبنا حاسبة الطلب، وفي هذا المقال نتعمق في ثلاثة أسئلة يسألها كل قادم إلى اللغة: كيف تعمل الأنواع والواجهات Interfaces بلا كلاسات ولا وراثة؟ ومن يدير الذاكرة، وكيف تقيس ما يكلفه كودك منها؟ وكيف تبلغ عن الخطأ في لغة ليس فيها استثناءات؟ ولن نكرر هنا ما شرحناه في مقالين سابقين، فجامع القمامة نفسه ومقبضاه GOGC وGOMEMLIMIT قسناهما في مقال إدارة الذاكرة يدوياً أم Garbage Collector أم Ownership، وتغليف الخطأ بالفعل %w مع errors.Is وerrors.As شرحناه في مقال كيف تتعامل لغات البرمجة مع الأخطاء، وإنما نبني عليهما بما يحتاجه من يكتب خدمة حقيقية.

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

  • نظام الأنواع Type System في Go: ثابت وصارم بلا تحويل ضمني، والقيمة الصفرية، والـ struct الذي ينسخ بالقيمة.
  • المستقبل Receiver بالقيمة أو بالمؤشر، وهو سبب المخزون الذي لا ينقص.
  • الواجهات Interfaces الضمنية، والتضمين Embedding بدل الوراثة، وتبديل الأنواع Type Switch، ثم الواجهة من الداخل وسبب الخطأ الذي يطبع <nil>.
  • الأنواع العامة Generics، والجديد في Go 1.27: دوال مرتبطة بنوع تأخذ معاملات نوع.
  • الذاكرة بالأرقام: تحليل الهروب Escape Analysis، وقياس الحجوزات بالـ Benchmarks، وما يراه جامع القمامة من runtime.MemStats وgctrace، ثم pprof ليدلك على السطر.
  • الأخطاء: الخطأ الثابت ونوع الخطأ وerrors.Join، وفحص جديد في go vet، ثم panic وrecover وحدودهما.
  • الأخطاء الشائعة، وجدول قرار للقيمة والمؤشر وشكل الخطأ.

نظام الأنواع Type System في Go: ثابت وصارم

Go لغة ثابتة الأنواع Statically Typed، أي أن نوع كل متغير معروف وقت الترجمة، وصارمة Strongly Typed بمعنى أنها لا تحول بين الأنواع وحدها أبداً: لا تستطيع أن تجمع int مع int64، ولا أن تمرر int32 لدالة تنتظر int، ولا أن تضع رقماً في شرط if بدل قيمة منطقية، وكل ذلك يحتاج تحويلاً صريحاً مثل int64(x). وهذا يزعج القادم من JavaScript أو Python في الأيام الأولى، ولكنه يغلق باباً كاملاً من الأخطاء التي رأيناها في مقال TypeScript للمطورين، حيث تجمع اللغة نصاً مع رقم فتعطي نصاً دون أن يعترض أحد. وإذا أردت الصورة العامة لأنظمة الأنواع وأين تقع Go بين اللغات، فقد شرحناها في مقال أنماط البرمجة وأنظمة الأنواع.

وتستطيع أن تعرف نوعاً جديداً فوق نوع موجود، مثل type Halalas int، وهذا النوع الجديد مختلف عن int في نظر المترجم رغم أن تمثيله في الذاكرة واحد، فلا تستطيع أن تجمع Halalas مع int دون تحويل، وهذه طريقة رخيصة جداً لتمنع خلط الهللات بالكميات أو بالريالات في دالة واحدة. ولكي نرى كل ما في هذا القسم والقسمين التاليين بالمخرج، كتبنا برنامجاً واحداً اسمه types/main.go يمر على الأفكار بالترتيب، وأول جزء فيه النوع Product ودالتان لحجز المخزون:

type Product struct {
	ID    int
	Name  string
	Price int // halalas
	Stock int
}

// Value receiver: works on a copy.
func (p Product) ReserveCopy(n int) { p.Stock -= n }

// Pointer receiver: works on the original.
func (p *Product) Reserve(n int) { p.Stock -= n }

وعند تشغيل البرنامج كاملاً بالأمر go run ./types يكون المخرج كما يلي، وسوف نقرؤه قسماً قسماً في هذا المقال:

== zero values
{ID:0 Name: Price:0 Stock:0} | map nil? true len 0 | slice nil? true len 0
read from nil map: 0
== value vs pointer receiver
after ReserveCopy(4): 10
after Reserve(4):     6
== structs compare by value
a == b: false | a.Price: 1
== implicit interfaces and embedding
created by api | CreatedBy: api | total: 2490
== generics
2970 29.700000000000003 8300
== type switch
nil
int 42
string "قهوة" (len 8 bytes)
Priced main.Product = 990
other float64
== typed nil inside an interface
buggy: err == nil? false, value <nil>, type *main.StockError
fixed: err == nil? true
errors.As: true 6

القيمة الصفرية Zero Value بدل null

السطر الأول من المخرج يبين ما يحدث عندما تعرف متغيرات دون أن تعطيها قيمة: الـ struct ظهر بكل حقوله أصفاراً ونصوصاً فارغة، والخريطة والشريحة كلاهما nil وطول كل منهما صفر، والقراءة من الخريطة nil أعادت صفراً دون أي خطأ. وهذا يعني أنك نادراً ما ترى في Go خطأً مثل NullPointerException عند قراءة حقل، لأن الـ struct نفسه ليس مؤشراً، ولكن انتبه إلى أن المؤشر *Product قيمته الصفرية nil، والقراءة منه توقف البرنامج بنفس الطريقة التي تعرفها من Java، والكتابة في خريطة nil توقفه أيضاً كما سوف نرى في قسم الأخطاء. والقاعدة العملية التي يكررها دليل Effective Go أن تصمم أنواعك بحيث تكون قيمتها الصفرية صالحة للاستخدام، فالنوع Totals في حاسبة الطلب قيمته الصفرية «طلب فارغ» صحيح، ولا يحتاج دالة إنشاء.

المستقبل Receiver بالقيمة أو بالمؤشر

وهنا سبب الملاحظة الأولى من الاختبار. السطر الثاني في المخرج يقول إن المخزون بقي 10 بعد ReserveCopy(4)، ثم صار 6 بعد Reserve(4)، والدالتان متطابقتان في كل شيء إلا النجمة قبل Product. والسبب أن كل شيء في Go يمرر بالقيمة Pass by Value، بلا استثناء، فالمستقبل بدون نجمة يستلم نسخة كاملة من الـ struct، والدالة عدلت هذه النسخة ثم اختفت النسخة مع نهاية الدالة. أما المستقبل بالنجمة فيستلم نسخة من المؤشر، وهي تشير إلى نفس المتغير، فوصل التعديل إليه. والصورة التالية تبين الحالتين:

مقارنة بين مستقبل بالقيمة حيث تنسخ الدالة الـ struct فيبقى المخزون 10 في المتغير الأصلي ويصبح 6 في النسخة فقط، ومستقبل بالمؤشر حيث تستلم الدالة عنوان المتغير فيصبح المخزون 6 في الأصل، وتحتهما القاعدة العملية لاختيار المؤشر
النجمة قبل اسم النوع هي الفرق بين تعديل الأصل وتعديل نسخة تختفي

والذي يجعل هذا الخطأ صعب الملاحظة أن المترجم يقبل الحالتين بنفس صيغة الاستدعاء hail.Reserve(4)، فإذا كانت الدالة بمؤشر والمتغير قيمة قابلة للعنونة كتب المترجم (&hail).Reserve(4) نيابة عنك، كما تشرح الأسئلة الشائعة عن المؤشر أو القيمة في الدوال المرتبطة. والقادم من Java أو Python تعود على أن الكائنات تمرر بالمرجع دائماً، فيكتب الدالة بدون نجمة لأنها «أبسط»، ويعمل الكود دون أي رسالة. والقاعدة التي يذكرها نفس المرجع: إذا كانت الدالة تعدل المستقبل فيجب أن يكون مؤشراً، وإذا كان النوع كبيراً فالمؤشر أرخص من نسخه في كل استدعاء، وإذا كان فيه قفل sync.Mutex فيجب أن يكون مؤشراً لأن نسخ القفل يعطيك قفلاً آخر لا يحمي شيئاً، وإذا كانت بعض دوال النوع بمؤشر فاجعلها كلها بمؤشر حتى لا يحتار القارئ.

الـ struct ينسخ بالقيمة Copy Semantics

والسطر الثالث في المخرج يكمل نفس الفكرة: نسخنا a إلى b بالسطر b := a، ثم غيرنا سعر b، فبقي سعر a 1 كما هو، والمقارنة a == b أعادت false. فالإسناد ينسخ الـ struct كاملاً، والمقارنة بالعلامة == تقارن الحقول حقلاً حقلاً، وهذا يعمل ما دامت كل الحقول قابلة للمقارنة Comparable، فإذا كان في الـ struct شريحة أو خريطة رفض المترجم المقارنة. ولاحظ الفخ هنا: الـ struct ينسخ، ولكن الشريحة داخله نافذة على مصفوفة، فنسخ struct فيه شريحة يعطيك نسختين تشيران إلى نفس البيانات، وتعديل عنصر في إحداهما يظهر في الأخرى، وهذا ما يشرحه مقال Go الرسمي عن الشرائح Slices من الداخل.

الواجهات Interfaces الضمنية

الواجهة في Go مجموعة دوال، وأي نوع له هذه الدوال يحققها تلقائياً، دون أن يكتب implements ودون أن يعرف أصلاً أن الواجهة موجودة. والجزء التالي من البرنامج يعرف واجهة صغيرة اسمها Priced، ونوعين يحققانها دون أن يذكراها:

// Priced is satisfied by any type with a Halalas method; no "implements" keyword.
type Priced interface{ Halalas() int }

func (p Product) Halalas() int { return p.Price }

type Shipping struct{ Fee int }

func (s Shipping) Halalas() int { return s.Fee }

// Embedding: Audit's fields and methods are promoted into Order.
type Audit struct{ CreatedBy string }

func (a Audit) Who() string { return "created by " + a.CreatedBy }

type Order struct {
	Audit
	ID    int
	Items []Priced
}

والسطر الرابع في المخرج يبين أن الشريحة []Priced حملت منتجاً ورسوم شحن معاً، والحلقة جمعت 990 + 1500 = 2490 دون أن تعرف نوع أي منهما. وقد يتساءل البعض: ما الفرق عن interface في Java سوى أنك لا تكتب الكلمة؟ والإجابة أن الفرق في من يعرف الواجهة: في Java يجب أن يعلن الكلاس عن الواجهات التي يحققها، فالواجهة يجب أن تكون موجودة قبله، أما في Go فتستطيع أن تكتب واجهة اليوم لنوع كتبه غيرك قبل سنوات في مكتبة لا تملكها، وسوف يحققها ما دامت دواله مطابقة. ولهذا السبب يعرف المستهلك الواجهة في Go وليس المنتج، والعرف المشهور «اقبل واجهات وارجع أنواعاً محددة» Accept Interfaces, Return Structs يأتي من هنا: الدالة في خدمة الطلبات التي تحتاج من قاعدة البيانات ثلاث عمليات فقط تعرف واجهة بهذه العمليات الثلاث، فتستطيع في الاختبار أن تمرر لها نسخة مزيفة بثلاث دوال، كما سوف نفعل في المقال الرابع.

وأشهر مثال على قوة الواجهات الصغيرة في المكتبة القياسية هو io.Reader، وهي واجهة بدالة واحدة Read(p []byte) (n int, err error)، ويحققها الملف والاتصال الشبكي وجسم طلب HTTP والنص في الذاكرة وملف مضغوط بـ gzip، فكل دالة تقبل io.Reader تعمل مع كل هذه الأشياء. ولذلك تجد أغلب الواجهات في Go بدالة أو دالتين، وكلما كبرت الواجهة قلت الأنواع التي تحققها وضعفت فائدتها.

من الكلاسات Classes إلى Go: كيف ترجم الفريق تصميم Java؟

المطور القادم من Java في فريقنا بدأ تصميم الخدمة كما تعود: كلاس مجرد AbstractRepository فيه الاتصال بقاعدة البيانات، يرثه ProductRepository وOrderRepository، ثم واجهة IOrderService يحققها كلاس واحد OrderServiceImpl، ثم مصنع Factory ينشئ كل ذلك. وعندما حاول أن ينقل هذا الشكل إلى Go حرفياً وجد نفسه يكتب struct فارغاً يضمنه في كل نوع، وواجهة لكل struct لا يحققها إلا نوع واحد، ودالة NewX لكل شيء، والكود صار ضعف حجمه دون أي فائدة. والترجمة الصحيحة أبسط من ذلك بكثير، وهي ثلاث خطوات: الأولى أن الحالة المشتركة مثل مجمع الاتصالات تصبح حقلاً عادياً في struct واحد اسمه Store، والدوال المشتركة تصبح دوالاً مرتبطة به، فلا حاجة إلى أب مجرد. والثانية أن الواجهة لا تكتب بجانب النوع الذي يحققها، وإنما بجانب من يستخدمها، وبالدوال التي يحتاجها فقط، فطبقة HTTP تعرف واجهة بثلاث دوال من Store لأنها تستخدم ثلاثاً، وهذا ما سوف تراه في الحزمة api في المقال الرابع. والثالثة أن دالة الإنشاء New تكتب فقط عندما يحتاج النوع تجهيزاً حقيقياً، وإلا فالقيمة الصفرية أو القيمة الحرفية Store{pool: p} تكفي.

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

التضمين Embedding بدل الوراثة Inheritance

في النوع Order أعلاه وضعنا Audit كحقل بلا اسم، وهذا هو التضمين Embedding، فصارت الدالة Who() والحقل CreatedBy متاحين مباشرة على الطلب، كما ظهر في المخرج: created by api | CreatedBy: api. وهذا يشبه الوراثة في شكله، ولكنه ليس وراثة في معناه، فالطلب ليس «نوعاً من» Audit، ولا تستطيع أن تمرر Order لدالة تنتظر Audit، وإنما المترجم يرفع Promote الحقول والدوال من الداخل إلى الخارج كاختصار للكتابة، وما زال o.Audit.Who() صحيحاً. ولا يوجد تجاوز Override بالمعنى الذي تعرفه، فإذا عرفت Who() على Order نفسه أخفت الدالة الداخلية، ولكن دوال Audit التي تستدعي Who() سوف تستدعي نسختها هي، وليس نسخة Order، وهذا يفاجئ كل من يحاول أن يبني نمط Template Method الذي تعرفه من Java. ويشرح قسم التضمين في Effective Go هذه الآلية، والدرس العملي أن تفكر في التركيب Composition: الطلب يحتوي على بيانات تدقيق، ولا يرثها.

النوع any وتبديل الأنواع Type Switch

النوع any اسم مختصر للواجهة الفارغة interface{}، وكل نوع يحققها لأنها لا تطلب أي دالة، فهي أقرب شيء في Go إلى Object في Java. وعندما تستلم قيمة من نوع any تستطيع أن تسأل عن نوعها الحقيقي بتبديل الأنواع Type Switch:

func describe(v any) string {
	switch x := v.(type) {
	case nil:
		return "nil"
	case int:
		return fmt.Sprintf("int %d", x)
	case string:
		return fmt.Sprintf("string %q (len %d bytes)", x, len(x))
	case Priced:
		return fmt.Sprintf("Priced %T = %d", x, x.Halalas())
	default:
		return fmt.Sprintf("other %T", x)
	}
}

وفي المخرج لاحظ أمرين: الأول أن النص «قهوة» طوله 8 بايت وليس 4، لأن len على النص في Go تعد البايتات وليس الحروف، والحرف العربي في UTF-8 بايتان، فإذا أردت عدد الحروف فاستخدم utf8.RuneCountInString أو مرر على النص بالحلقة range التي تعطيك الحروف Runes واحداً واحداً، وهذا خطأ يقع فيه كل من يقص أسماء المنتجات العربية بعدد ثابت. والثاني أن الحالة case Priced طابقت المنتج لأنه يحقق الواجهة، فتبديل الأنواع يعمل مع الواجهات أيضاً، والترتيب مهم، فالحالة الأولى التي تطابق تفوز. ولا تجعل any عادة في الكود، لأنك تخسر كل فحص يقوم به المترجم وتنقله إلى وقت التشغيل، ومكانها الطبيعي الحدود مثل قراءة JSON لا تعرف شكله.

الواجهة من الداخل: لماذا لا يساوي الخطأ nil؟

وهنا سبب الملاحظة الثانية من الاختبار، الطلب السليم الذي رجع بخطأ 500. والكود الذي سببها في الدالة checkStockBuggy:

type StockError struct{ ProductID, Want, Have int }

func (e *StockError) Error() string {
	return fmt.Sprintf("product %d: want %d, have %d", e.ProductID, e.Want, e.Have)
}

// checkStock returns a *StockError typed nil when everything is fine: a classic bug.
func checkStockBuggy(p Product, n int) error {
	var se *StockError
	if p.Stock < n {
		se = &StockError{p.ID, n, p.Stock}
	}
	return se
}

func checkStock(p Product, n int) error {
	if p.Stock < n {
		return &StockError{p.ID, n, p.Stock}
	}
	return nil
}

والمخرج في آخر البرنامج يقول: buggy: err == nil? false, value <nil>, type *main.StockError، أي أن الخطأ ليس nil، ولكن طباعته تعطي <nil>. ولكي تفهم ذلك يجب أن تعرف أن قيمة الواجهة في Go زوج من شيئين: النوع الحقيقي للقيمة التي بداخلها، والقيمة نفسها، كما يشرح Russ Cox في مقاله عن تمثيل الواجهات Go Data Structures: Interfaces. والواجهة تساوي nil فقط إذا كان الاثنان فارغين، والصورة التالية تبين الحالات الثلاث:

ثلاث بطاقات تمثل قيمة الواجهة كزوج من النوع والقيمة: خطأ nil حقيقي نوعه nil وقيمته nil، ومؤشر nil داخل واجهة نوعه StockError وقيمته nil فلا تساوي الواجهة nil، وخطأ له قيمة، وتحتها مخرج التجربة الذي يبين false ثم true
الواجهة لا تساوي nil إلا إذا كان النوع والقيمة كلاهما فارغين

فالمتغير se من النوع *StockError، وعندما أرجعناه من دالة نوعها المرجع error، وضع المترجم داخل الواجهة النوع *StockError والقيمة nil، فصار للواجهة نوع، ولم تعد تساوي nil. وهذا الخطأ مشهور لدرجة أن له سؤالاً خاصاً في الأسئلة الشائعة: لماذا قيمة الخطأ nil لا تساوي nil؟. والحل في الدالة checkStock: لا تحفظ الخطأ في متغير من نوع محدد ثم ترجعه، وإنما ارجع nil صريحة في الطريق السليم، وارجع &StockError{…} مباشرة في طريق الخطأ، وعندها أعاد السطر الأخير errors.As: true 6، أي أنه استخرج المؤشر ومنه المخزون الحالي 6. ولاحظ أن staticcheck لا يكتشف هذا الخطأ في كل أشكاله، فالحماية الحقيقية هي العادة: الدالة التي ترجع error ترجع nil حرفياً.

الأنواع العامة Generics: من 1.18 إلى الدوال المرتبطة في 1.27

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

// Generics: one Sum for any numeric type.
type Number interface{ ~int | ~int64 | ~float64 }

func Sum[T Number](xs []T) T {
	var s T
	for _, x := range xs {
		s += x
	}
	return s
}

type Halalas int

والعلامة ~int تعني «كل نوع تمثيله int»، ولذلك قبلت Sum النوع Halalas الذي عرفناه فوق int، وأعادت 8300. ولكن انظر إلى السطر الخاص بها في المخرج مرة أخرى: 2970 29.700000000000003 8300، فنفس الدالة مع float64 جمعت 9.9 ثلاث مرات فأعطت 29.700000000000003، وهذا ليس خطأ في الأنواع العامة، وإنما سبب آخر لتخزين المبالغ بالهللة كأرقام صحيحة كما فعلنا في المقال الأول.

وفي الإصدار الحالي Go 1.27 أضيفت ميزة طال انتظارها، وهي أن الدالة المرتبطة بنوع Method تستطيع أن تأخذ معاملات نوع خاصة بها، كما تذكر ملاحظات إصدار Go 1.27. وقبلها كان يجب أن تكتب دالة عامة على مستوى الحزمة تأخذ الكائن كمعامل، والملف generic/main.go يجرب الميزة الجديدة:

// Go 1.27: a method may declare its own type parameters.
package main

import "fmt"

type Product struct {
	ID    int
	Name  string
	Price int
}

type Catalog struct{ items []Product }

// Index builds a map from any comparable key the caller chooses.
func (c *Catalog) Index[K comparable](key func(Product) K) map[K]Product {
	m := make(map[K]Product, len(c.items))
	for _, p := range c.items {
		m[key(p)] = p
	}
	return m
}

func main() {
	c := &Catalog{items: []Product{{1, "قهوة", 4500}, {2, "تمر", 3800}, {3, "هيل", 990}}}
	byID := c.Index(func(p Product) int { return p.ID })
	byName := c.Index(func(p Product) string { return p.Name })
	fmt.Println(byID[3].Name, byName["تمر"].Price)
}

والمخرج سوف يكون كما يلي:

هيل 3800

فالدالة Index بنت فهرساً بالرقم مرة وبالاسم مرة، والمترجم استنتج K من الدالة الممررة دون أن نكتبه. ولاحظ القيد الذي تذكره ملاحظات الإصدار: دوال الواجهات لا تستطيع أن تأخذ معاملات نوع، ولا يمكن أن تحقق دالة عامة دالة في واجهة. ومتى تستخدم الأنواع العامة؟ يجيب مقال When To Use Generics من فريق Go بقاعدة بسيطة: إذا وجدت نفسك تكتب نفس الكود أكثر من مرة والفرق الوحيد هو النوع، فاستخدمها، أما إذا كان الفرق في السلوك فالواجهة هي الأداة الصحيحة. وفي خدمة الطلبات لن تحتاج أن تكتب أنواعاً عامة بنفسك إلا نادراً، ولكنك سوف تستخدمها كل يوم من المكتبات، مثل slices.Sort وmaps.Keys وpgx.CollectRows.

من يدير الذاكرة؟ جامع القمامة Garbage Collector

في Go يدير جامع قمامة Garbage Collector الذاكرة نيابة عنك، فلا يوجد free ولا delete، وقد شرحنا في مقال إدارة الذاكرة كيف يعمل الجامع المتتبع Tracing GC بمرحلتي التعليم والكنس، وأنه يعمل بالتوازي مع البرنامج ولا يوقفه إلا أجزاء من المللي ثانية، وقسنا هناك أثر المقبضين GOGC وGOMEMLIMIT على برنامج يحتفظ بثلاثة ملايين طلب. ومنذ Go 1.26 صار الجامع الافتراضي تصميماً جديداً اسمه Green Tea، ويقول الفريق في ملاحظات إصدار Go 1.26 إنه يحسن تعليم الكائنات الصغيرة وفحصها، ويتوقع انخفاضاً بين 10% و40% في عمل الجامع في البرامج الحقيقية. ولكن ما يهمك كمطور قبل أي ضبط للجامع سؤال أبسط: كم كائناً ينشئ كودك؟ لأن عمل الجامع يتناسب مع ما تحجزه، وأفضل طريقة لتقليل عمله أن تقلل الحجوزات في الكود الذي يعمل مع كل طلب، والدليل الرسمي A Guide to the Go Garbage Collector يقول ذلك صراحة.

وفي Go مكانان للذاكرة: المكدس Stack لكل Goroutine، وهو رخيص جداً لأن كل ما فيه يختفي عند خروج الدالة دون أن يراه الجامع، والكومة Heap المشتركة التي يجمعها الجامع. والفرق المهم عن C أنك لا تختار المكان بنفسك، فالمترجم هو الذي يقرر، وهذا ما سوف نقيسه الآن على كود من خدمة الطلبات.

ولاحظ أن مكدس كل Goroutine ليس ثابت الحجم كما في خيوط نظام التشغيل، وإنما يبدأ صغيراً بضعة كيلوبايتات، ويكبر الـ Runtime حجمه وينسخه إلى مكان أكبر عندما تحتاج الدالة مساحة أكثر، وهذا سبب أن تشغيل مئة ألف Goroutine ممكن على جهاز عادي، كما سوف نقيس في المقال التالي. ومن نتائج هذا التصميم أن عنوان متغير على المكدس قد يتغير عندما ينسخ المكدس، والـ Runtime يحدث كل المؤشرات إليه، ولذلك لا يسمح لك Go بأن تحتفظ بعنوان المكدس خارج الدالة، والمترجم يحل المشكلة بأن ينقل أي كائن قد يعيش بعد دالته إلى الـ Heap. وكل ذلك يحدث دون أن تراه، ولكنه يفسر لماذا لا تجد في Go كلمة مثل new تعني «في الـ Heap» كما في C++ أو Java، فحتى الدالة new(T) قد تضع الكائن على المكدس إذا أثبت المترجم أنه لا يهرب.

تحليل الهروب Escape Analysis: قرار المترجم

كتبنا في الحزمة alloc دوالاً تمثل ما يحدث في الخدمة: إنشاء سطر طلب بقيمة أو بمؤشر، وبناء شريحة أسطر مع حجز مسبق أو بدونه، وبناء نص إيصال بالدمج أو بـ strings.Builder:

// Package alloc compares allocation patterns that show up in an orders service.
package alloc

import (
	"fmt"
	"strconv"
	"strings"
)

type Line struct{ ProductID, Qty, Price int }

//go:noinline
func NewLinePtr(id, qty, price int) *Line { return &Line{id, qty, price} }

//go:noinline
func NewLineVal(id, qty, price int) Line { return Line{id, qty, price} }

func BuildAppend(n int) []Line {
	var out []Line
	for i := 0; i < n; i++ {
		out = append(out, Line{i, 1, 990})
	}
	return out
}

func BuildPrealloc(n int) []Line {
	out := make([]Line, 0, n)
	for i := 0; i < n; i++ {
		out = append(out, Line{i, 1, 990})
	}
	return out
}

func ReceiptConcat(lines []Line) string {
	s := ""
	for _, l := range lines {
		s += fmt.Sprintf("%d x %d = %d\n", l.ProductID, l.Qty, l.Qty*l.Price)
	}
	return s
}

func ReceiptBuilder(lines []Line) string {
	var b strings.Builder
	b.Grow(len(lines) * 16)
	for _, l := range lines {
		b.WriteString(strconv.Itoa(l.ProductID))
		b.WriteString(" x ")
		b.WriteString(strconv.Itoa(l.Qty))
		b.WriteString(" = ")
		b.WriteString(strconv.Itoa(l.Qty * l.Price))
		b.WriteByte('\n')
	}
	return b.String()
}

ثم طلبنا من المترجم أن يشرح قراره لكل حجز بالخيار -gcflags=-m، وأخذنا الأسطر التي تخص هذا الملف:

go build -gcflags=-m ./alloc 2>&1 | grep "alloc.go"

والمخرج سوف يكون كما يلي:

alloc/alloc.go:18:6: can inline BuildAppend
alloc/alloc.go:26:6: can inline BuildPrealloc
alloc/alloc.go:46:29: inlining call to strconv.Itoa
alloc/alloc.go:46:16: inlining call to strings.(*Builder).WriteString
alloc/alloc.go:47:16: inlining call to strings.(*Builder).WriteString
alloc/alloc.go:48:29: inlining call to strconv.Itoa
alloc/alloc.go:48:16: inlining call to strings.(*Builder).WriteString
alloc/alloc.go:49:16: inlining call to strings.(*Builder).WriteString
alloc/alloc.go:46:29: inlining call to strconv.Itoa
alloc/alloc.go:46:16: inlining call to strings.(*Builder).copyCheck
alloc/alloc.go:47:16: inlining call to strings.(*Builder).copyCheck
alloc/alloc.go:48:29: inlining call to strconv.Itoa
alloc/alloc.go:48:16: inlining call to strings.(*Builder).copyCheck
alloc/alloc.go:49:16: inlining call to strings.(*Builder).copyCheck
alloc/alloc.go:46:16: inlining call to abi.NoEscape
alloc/alloc.go:47:16: inlining call to abi.NoEscape
alloc/alloc.go:48:16: inlining call to abi.NoEscape
alloc/alloc.go:49:16: inlining call to abi.NoEscape
alloc/alloc.go:13:52: &Line{...} escapes to heap
alloc/alloc.go:21:15: append escapes to heap
alloc/alloc.go:27:13: make([]Line, 0, n) escapes to heap
alloc/alloc.go:29:15: append escapes to heap
alloc/alloc.go:34:20: lines does not escape
alloc/alloc.go:37:19: ... argument does not escape
alloc/alloc.go:37:39: l.ProductID escapes to heap
alloc/alloc.go:37:52: l.Qty escapes to heap
alloc/alloc.go:37:63: l.Qty * l.Price escapes to heap
alloc/alloc.go:42:21: lines does not escape
alloc/alloc.go:46:16: "strings: illegal use of non-zero Builder copied by value" escapes to heap
alloc/alloc.go:46:16: append escapes to heap

في المخرج أعلاه لاحظ التالي:

  • الأسطر التي تبدأ بـ can inline وinlining call to قرارات الدمج Inlining، أي أن المترجم يضع جسم الدالة الصغيرة في مكان استدعائها بدل استدعائها، وهذا شرط لكثير من قرارات الهروب، لأن المترجم لا يرى ما بعد حدود الدالة غير المدمجة. ولذلك وضعنا التعليق //go:noinline على الدالتين الأوليين، حتى يبقى القياس صادقاً.
  • alloc.go:13:52: &Line{...} escapes to heap: الدالة NewLinePtr ترجع عنوان الكائن، فالكائن يجب أن يعيش بعدها، فذهب إلى الـ Heap. أما NewLineVal فلا يوجد لها سطر أصلاً، لأن القيمة تنسخ إلى من استدعاها ولا شيء يهرب.
  • append escapes to heap وmake([]Line, 0, n) escapes to heap: الشريحة ترجع من الدالة، فمصفوفتها في الـ Heap في الحالتين، والفرق كما سوف نرى في عدد المرات.
  • lines does not escape: الدالة تقرأ الشريحة ولا تحفظها، فلا يحتاج المترجم أن ينقل شيئاً بسببها.
  • l.ProductID escapes to heap: هذا مفاجئ، فالرقم يهرب لأن fmt.Sprintf تأخذ معاملاتها من النوع any، ووضع رقم داخل واجهة يحتاج غالباً حجزاً، وهذا سبب أن fmt في الكود الساخن مكلفة.
مقارنة بين دالة ترجع قيمة يبقى كائنها على الـ Stack بزمن 5.975 نانوثانية وصفر بايت وصفر حجز، ودالة ترجع مؤشراً يهرب كائنها إلى الـ Heap بزمن 262 نانوثانية و24 بايت وحجز واحد، مع سطر escapes to heap من المترجم
نفس البيانات، والفرق الوحيد هو ما ترجعه الدالة

قياس الحجوزات بالـ Benchmarks

قرار المترجم وحده لا يكفي، والسؤال الحقيقي كم يكلف كل نمط، والإجابة من قياس الأداء Benchmark في الحزمة testing نفسها، بملف alloc_test.go:

package alloc

import "testing"

var sinkP *Line
var sinkV Line
var sinkS []Line
var sinkStr string

func BenchmarkNewLinePtr(b *testing.B) {
	for b.Loop() {
		sinkP = NewLinePtr(1, 2, 990)
	}
}
func BenchmarkNewLineVal(b *testing.B) {
	for b.Loop() {
		sinkV = NewLineVal(1, 2, 990)
	}
}
func BenchmarkBuildAppend(b *testing.B) {
	for b.Loop() {
		sinkS = BuildAppend(1000)
	}
}
func BenchmarkBuildPrealloc(b *testing.B) {

ولاحظ هنا أمرين: الحلقة for b.Loop() هي الطريقة الحديثة منذ Go 1.24 كما تشرح تدوينة b.Loop، وهي تحدد عدد الدورات وحدها وتمنع المترجم من حذف الكود الذي لا تستخدم نتيجته. والمتغيرات العامة sink تحفظ النتيجة لنفس السبب، فقد رأينا في مقال إدارة الذاكرة كيف حذف المترجم الحجز كله في تجربة لم تحفظ نتيجتها. ثم شغلنا القياس مع خيار الذاكرة:

go test -run '^$' -bench . -benchmem ./alloc

والمخرج سوف يكون كما يلي:

goos: linux
goarch: amd64
pkg: example.com/typeslab/alloc
cpu: Intel(R) Core(TM) i7-7700K CPU @ 4.20GHz
BenchmarkNewLinePtr-8       	 5282126	       262.0 ns/op	      24 B/op	       1 allocs/op
BenchmarkNewLineVal-8       	198585574	         5.975 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuildAppend-8      	    6336	    224808 ns/op	   77802 B/op	      12 allocs/op
BenchmarkBuildPrealloc-8    	   16550	     89876 ns/op	   24576 B/op	       1 allocs/op
BenchmarkReceiptConcat-8    	    1098	   1007418 ns/op	  286648 B/op	     599 allocs/op
BenchmarkReceiptBuilder-8   	   22227	     48725 ns/op	    4160 B/op	     301 allocs/op
PASS
ok  	example.com/typeslab/alloc	7.871s

ولنقرأ المخرج سطراً سطراً:

  • الرقم -8 بعد اسم كل قياس هو GOMAXPROCS، أي عدد الأنوية التي استخدمها البرنامج، ثم عدد الدورات التي اختارها الإطار، ثم الزمن لكل دورة، ثم البايتات والحجوزات لكل دورة.
  • القيمة مقابل المؤشر: صفر حجز وأقل من 6 نانوثانية للقيمة، وحجز واحد من 24 بايت للمؤشر. والزمن 262 نانوثانية للمؤشر أكبر مما يجب لحجز واحد، والسبب أن الجهاز كان يشغل أعمالاً أخرى أثناء هذا القياس بالذات، لذلك اعتمد على عمودي الحجوزات والبايتات فهما ثابتان، وكرر القياس بالخيار -count ثم قارن بأداة benchstat قبل أن تثق بأي زمن.
  • بناء ألف سطر: بدون حجز مسبق 12 حجزاً و77,802 بايت، لأن append ضاعفت السعة عدة مرات ونسخت البيانات في كل مرة، ومع make(…, 0, n) حجز واحد بالضبط بحجم 24,576 بايت، أي ألف سطر في 24 بايت مقربة لأقرب حجم يستخدمه المخصص، والزمن نزل إلى أقل من النصف.
  • الإيصال بمئتي سطر: الدمج بالعلامة += مع fmt.Sprintf أخذ 599 حجزاً و286 كيلوبايت لكل إيصال، لأن النص في Go ثابت، فكل += تنشئ نصاً جديداً وتنسخ فيه كل ما سبق، وSprintf تحجز للمعاملات. أما strings.Builder مع Grow فأخذ 4,160 بايت فقط، وأسرع بعشرين ضعفاً تقريباً. وبقيت 301 حجزاً، وهي من strconv.Itoa التي تنشئ نصاً لكل رقم، ويمكن إزالتها بـ strconv.AppendInt إذا كان هذا الكود ساخناً فعلاً.

ما يراه جامع القمامة: runtime.MemStats

الـ Benchmark يقيس دالة واحدة، أما ما يهم الخدمة فهو أثر هذه الحجوزات على الجامع خلال دقائق من العمل. لذلك كتبنا البرنامج memstats الذي يبني مئتي ألف إيصال بعشرين سطراً، ويقرأ إحصاءات الـ Runtime قبل الحلقة وبعدها بالدالة runtime.ReadMemStats، وحقولها موثقة في توثيق MemStats:

// memstats builds 200,000 receipts two ways and prints what the GC saw.
//   go run ./memstats concat|builder
package main

import (
	"fmt"
	"os"
	"runtime"
	"time"

	"example.com/typeslab/alloc"
)

var sink string

func main() {
	mode := os.Args[1]
	lines := alloc.BuildPrealloc(20)
	var before, after runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&before)
	start := time.Now()
	for i := 0; i < 200_000; i++ {
		if mode == "concat" {
			sink = alloc.ReceiptConcat(lines)
		} else {
			sink = alloc.ReceiptBuilder(lines)
		}
	}
	elapsed := time.Since(start)
	runtime.ReadMemStats(&after)
	fmt.Printf("mode=%s time=%v\n", mode, elapsed.Round(time.Millisecond))
	fmt.Printf("  allocated total: %d MiB in %d objects\n", (after.TotalAlloc-before.TotalAlloc)>>20, after.Mallocs-before.Mallocs)
	fmt.Printf("  gc cycles: %d, total pause: %v\n", after.NumGC-before.NumGC, time.Duration(after.PauseTotalNs-before.PauseTotalNs).Round(time.Microsecond))
	fmt.Printf("  heap in use now: %d KiB, from OS (Sys): %d MiB\n", after.HeapInuse>>10, after.Sys>>20)
}

والمخرج بالطريقتين سوف يكون كما يلي:

mode=concat time=5.83s
  allocated total: 603 MiB in 11800973 objects
  gc cycles: 199, total pause: 674.126ms
  heap in use now: 2632 KiB, from OS (Sys): 21 MiB

mode=builder time=921ms
  allocated total: 73 MiB in 4200052 objects
  gc cycles: 22, total pause: 82.472ms
  heap in use now: 3328 KiB, from OS (Sys): 16 MiB
أربعة رسوم بأعمدة أفقية تقارن الدمج بالعلامة += مع strings.Builder: الذاكرة المحجوزة 603 مقابل 73 ميجابايت، والكائنات 11.8 مقابل 4.2 مليون، ودورات الجامع 199 مقابل 22، ومجموع التوقف 674 مقابل 82 مللي ثانية والزمن الكلي 5.83 مقابل 0.92 ثانية
تقليل الحجوزات قلل عمل الجامع بنفس النسبة تقريباً، دون أي ضبط له

في المخرج أعلاه لاحظ التالي:

  • allocated total هو مجموع ما حجزه البرنامج طوال الحلقة، 603 ميجابايت مقابل 73، وهذا الرقم لا يعني أن البرنامج استهلك 603 ميجابايت في لحظة واحدة، فالجامع كان يحرر وراءه باستمرار.
  • gc cycles نزل من 199 إلى 22، أي تسع مرات أقل، وهو نفس نسبة الذاكرة المحجوزة تقريباً، لأن الجامع يبدأ دورة جديدة كلما تضاعف حجم الـ Heap الحي بالإعداد الافتراضي GOGC=100.
  • total pause مجموع التوقفات القصيرة لكل الدورات، 674 مللي ثانية مقابل 82، وهذا وقت لم يخدم فيه البرنامج أي طلب.
  • Sys هو ما أخذه البرنامج من نظام التشغيل، 21 و16 ميجابايت فقط، رغم مئات الميجابايتات المحجوزة، وهذا هو الجامع وهو يعمل: الذاكرة نفسها تستخدم مرة بعد مرة.

والدرس هنا أن مشكلة الملاحظة الثالثة من الاختبار، ست ثوان ومئتا دورة جمع، لم تكن مشكلة في الجامع، وإنما في الكود الذي يطعمه، وأن تغيير ثلاثة أسطر أنزل الزمن من 5.83 إلى 0.92 ثانية.

السجل GODEBUG=gctrace=1 سطراً سطراً

وإذا أردت أن ترى كل دورة وحدها، فالمتغير GODEBUG=gctrace=1 يجعل الـ Runtime يطبع سطراً عن كل دورة على الخطأ القياسي، وشغلنا به نفس البرنامج:

GODEBUG=gctrace=1 go run ./memstats concat 2> gc.txt
head -5 gc.txt; grep -c '^gc ' gc.txt

والمخرج سوف يكون كما يلي:

mode=concat time=24.365s
  allocated total: 602 MiB in 11800776 objects
  gc cycles: 189, total pause: 214.247ms
  heap in use now: 672 KiB, from OS (Sys): 17 MiB
gc 1 @1.318s 0%: 0.19+14+0.026 ms clock, 1.5+14/10/0+0.20 ms cpu, 3->4->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 2 @1.555s 0%: 11+1.7+0.005 ms clock, 94+2.0/1.3/0+0.041 ms cpu, 3->3->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 3 @1.640s 1%: 7.9+17+0.006 ms clock, 63+0.87/0.84/14+0.053 ms cpu, 3->4->2 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 4 @1.679s 1%: 1.9+4.4+0.012 ms clock, 15+1.0/2.1/0.11+0.10 ms cpu, 4->5->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 5 @1.697s 1%: 1.4+14+0.072 ms clock, 11+0/3.5/1.3+0.58 ms cpu, 4->8->5 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P
lines: 198

وقد شرحنا حقول هذا السطر واحداً واحداً في قسم جامع القمامة من مقال إدارة الذاكرة، والمهم هنا ثلاثة أشياء: الأول أن الهدف 4 MB goal صغير جداً، لأن البيانات الحية بين الإيصالات قليلة، فالجامع يبدأ دورة كلما حجز البرنامج 4 ميجابايت جديدة، وهذا سبب العدد الكبير للدورات. والثاني أن الدورة الثانية فيها توقف أول 11 مللي ثانية، وهو رقم كبير لجامع Go، والدورات الأخرى أقل من 2 مللي ثانية، فالتوقف الطويل الوحيد جاء في بداية البرنامج والجهاز مشغول. والثالث أن البرنامج نفسه أخذ في هذا التشغيل 24 ثانية بدل 5.8، مع نفس عدد الدورات تقريباً، 189 مقابل 199، وهذه من المشكلات التي سوف نذكرها بعد قليل.

أين تحجز الذاكرة؟ pprof يدلك على السطر

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

go test -run '^$' -bench ReceiptConcat -benchmem -memprofile mem.out -o alloc.test ./alloc
go tool pprof -sample_index=alloc_space -top -nodecount=8 alloc.test mem.out

والمخرج سوف يكون كما يلي:

File: alloc.test
Build ID: 22c472088bfbbe4f26ab288403949b9267715d31
Type: alloc_space
Time: 2026-10-31 07:25:40 UTC
Showing nodes accounting for 782.41MB, 99.62% of 785.41MB total
Dropped 28 nodes (cum <= 3.93MB)
      flat  flat%   sum%        cum   cum%
  776.41MB 98.85% 98.85%   782.91MB 99.68%  example.com/typeslab/alloc.ReceiptConcat
       6MB  0.76% 99.62%     6.50MB  0.83%  fmt.Sprintf
         0     0% 99.62%   782.91MB 99.68%  example.com/typeslab/alloc.BenchmarkReceiptConcat
         0     0% 99.62%   782.91MB 99.68%  testing.(*B).run1.func1
         0     0% 99.62%   783.41MB 99.74%  testing.(*B).runN

العمود flat هو ما حجزته الدالة نفسها، وcum ما حجزته مع كل ما تستدعيه، والدالة ReceiptConcat حجزت بنفسها 98.85% من كل البايتات، وهذه حجوزات += نفسها، بينما fmt.Sprintf حجزت 6 ميجابايت فقط. فلو بدأنا بإزالة Sprintf لأننا سمعنا أنها بطيئة لأصلحنا أقل من 1% من المشكلة، وهذا هو سبب أن تقيس قبل أن تغير. وفي الخدمة الحقيقية لا تحتاج Benchmark، وإنما تضيف الحزمة net/http/pprof على منفذ داخلي، وتسحب الملف من السيرفر وهو يعمل، كما يشرح مقال Profiling Go Programs.

الأخطاء Errors في Go: قيم وليست استثناءات

شرحنا في مقال الأخطاء عبر اللغات لماذا اختارت Go أن يكون الخطأ قيمة ترجعها الدالة، وكيف يغلف كل مستوى الخطأ بسياقه بالفعل %w، وكيف تمر errors.Is وerrors.As على السلسلة كلها. وما نضيفه هنا هو كيف تختار شكل الخطأ في خدمة حقيقية، لأن كل شكل يتحول في النهاية إلى رد HTTP مختلف. ويلخص Rob Pike الفلسفة في عنوان مقاله Errors are values: ما دام الخطأ قيمة فتستطيع أن تبرمج به كما تبرمج بأي قيمة، تجمعه وتغلفه وتقارنه.

الخطأ الثابت Sentinel ونوع الخطأ Custom Type

البرنامج errs/main.go يمثل حجز المخزون في الخدمة، وفيه شكلان للخطأ:

// Errors lab: sentinel errors, custom types, errors.Join, panic and recover.
package main

import (
	"errors"
	"fmt"
	"os"
	"strconv"
)

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

type StockError struct{ ProductID, Want, Have int }

func (e *StockError) Error() string {
	return fmt.Sprintf("product %d: want %d, have %d", e.ProductID, e.Want, e.Have)
}

var stock = map[int]int{1: 100, 2: 100, 3: 10}

func reserve(id, qty int) error {
	have, ok := stock[id]
	if !ok {
		return fmt.Errorf("reserve %d: %w", id, ErrNotFound)
	}
	if have < qty {
		return fmt.Errorf("reserve: %w", &StockError{id, qty, have})
	}
	stock[id] = have - qty
	return nil
}

// in main():
	for _, c := range [][2]int{{3, 4}, {9, 1}, {3, 50}} {
		err := reserve(c[0], c[1])
		var se *StockError
		switch {
		case err == nil:
			fmt.Printf("reserve(%d,%d): ok, stock now %d\n", c[0], c[1], stock[c[0]])
		case errors.Is(err, ErrNotFound):
			fmt.Printf("reserve(%d,%d): 404 -> %v\n", c[0], c[1], err)
		case errors.As(err, &se):
			fmt.Printf("reserve(%d,%d): 409 -> %v (short by %d)\n", c[0], c[1], err, se.Want-se.Have)
		}
	}

والجزء الأول من المخرج:

== sentinel and custom error types
reserve(3,4): ok, stock now 6
reserve(9,1): 404 -> reserve 9: product not found
reserve(3,50): 409 -> reserve: product 3: want 50, have 6 (short by 44)

فالخطأ الثابت ErrNotFound قيمة واحدة معروفة لكل البرنامج، تناسب حالة لا تحتاج تفاصيل: المنتج غير موجود، وتفحصه بـ errors.Is حتى بعد أن غلفته الدالة بالنص reserve 9:. أما نوع الخطأ *StockError فيحمل أرقاماً يحتاجها المستدعي ليبني رداً مفيداً للعميل، مثل الكمية المتوفرة، ويستخرجها بـ errors.As، وحسبنا منه النقص 44. ولاحظ أننا لم نقارن نص الرسالة في أي مكان، فالنص للإنسان الذي يقرأ السجل، والنوع للكود الذي يتخذ القرار، ولو قارنت النص لتعطل الكود عندما يغير أحد صياغة الرسالة. ومنذ Go 1.26 توجد الدالة errors.AsType، وهي نسخة عامة من As تقول ملاحظات الإصدار إنها أسرع وأسلم في النوع، فتكتب se, ok := errors.AsType[*StockError](err) بدل تعريف المتغير ثم تمرير عنوانه.

عدة أخطاء معاً: errors.Join

عندما يرسل العميل طلباً فيه ثلاثة أسطر خاطئة، فمن الأفضل أن ترد بالأخطاء الثلاثة مرة واحدة، بدل أن يصلح الأول ويرسل فيأتيه الثاني. والدالة errors.Join التي أضيفت في Go 1.20 تجمع عدة أخطاء في خطأ واحد:

type ItemInput struct{ ProductID, Qty string }

// validate returns every problem at once with errors.Join.
func validate(items []ItemInput) error {
	var errs []error
	if len(items) == 0 {
		errs = append(errs, errors.New("items: must not be empty"))
	}
	for i, it := range items {
		if _, err := strconv.Atoi(it.ProductID); err != nil {
			errs = append(errs, fmt.Errorf("items[%d].product_id: %w", i, err))
		}
		if q, err := strconv.Atoi(it.Qty); err != nil || q <= 0 {
			errs = append(errs, fmt.Errorf("items[%d].qty: must be a positive integer, got %q", i, it.Qty))
		}
	}
	return errors.Join(errs...)
}

والمخرج سوف يكون كما يلي:

== errors.Join collects all validation errors
items[1].product_id: strconv.Atoi: parsing "abc": invalid syntax
items[1].qty: must be a positive integer, got "0"
items[2].qty: must be a positive integer, got "-1"
is strconv.ErrSyntax inside? true

الخطأ المجمع يطبع كل خطأ في سطر، وerrors.Is وجدت strconv.ErrSyntax داخل أحد الأخطاء المجمعة، لأنها تمر على كل الأخطاء وليس على الأول فقط، كما يشرح توثيق errors.Join. ولاحظ أن errors.Join ترجع nil إذا كانت القائمة فارغة، فلا تحتاج شرطاً قبلها، وهذا يتجنب فخ الـ nil داخل الواجهة الذي رأيناه في قسم الواجهات.

ماذا يحدث إذا غلفت مؤشراً Pointer بالفعل %w؟

وهنا خطأ جديد يكتشفه go vet في Go 1.27 لأول مرة، كما تذكر ملاحظات الإصدار. لنفرض أن زميلك عرف نوع خطأ دالته Error() على القيمة وليس على المؤشر، ثم غلف عنوانه:

// Go 1.27 vet: wrapping a pointer to a type whose VALUE implements error.
package main

import (
	"errors"
	"fmt"
)

type NotFound struct{ ID int }

func (e NotFound) Error() string { return fmt.Sprintf("product %d not found", e.ID) }

func find(id int) error {
	e := NotFound{id}
	return fmt.Errorf("find: %w", &e)
}

func main() {
	err := find(9)
	var nf NotFound
	fmt.Println(err, "| errors.As NotFound:", errors.As(err, &nf))
}

والمخرج عند التشغيل ثم الفحص:

find: product 9 not found | errors.As NotFound: false
vetwrap/main.go:15:27: %w wants operand of error type NotFound, not pointer type *NotFound (defeats errors.Is)
exit=1

فالرسالة المطبوعة صحيحة تماماً، ولكن errors.As أعادت false، لأن السلسلة تحمل *NotFound والمستدعي يبحث عن NotFound، وهما نوعان مختلفان. وفي الخدمة يعني ذلك أن المنتج غير الموجود يرجع 500 بدل 404، ولا ترى السبب بعينك في السجل. والفحص الجديد في go vet أمسك الخطأ بالسطر والعمود وقال السبب صراحة: defeats errors.Is. والقاعدة لتتجنب الفخ من البداية: قرر لكل نوع خطأ هل دالة Error() على القيمة أم على المؤشر، ثم استخدم نفس الشكل في كل مكان تنشئه فيه وتبحث عنه، والأكثر شيوعاً المؤشر كما في *StockError.

متى تستخدم panic، وأين يعمل recover؟

الدالة panic هي الأقرب إلى الاستثناء في Go، فهي توقف الدالة الحالية وتنفذ كل defer في طريق الصعود، فإذا لم يلتقطها أحد أوقفت البرنامج كله وطبعت مكدس الـ Goroutine. والـ Runtime نفسه يستخدمها للأخطاء البرمجية: الكتابة في خريطة nil، والفهرس خارج حدود الشريحة، والقراءة من مؤشر nil. والقاعدة في Go أن panic للأخطاء التي تعني أن الكود نفسه خطأ، وليست لملف ناقص أو مدخلات سيئة من العميل، كما يشرح مقال Defer, Panic, and Recover. ولكن سيرفر HTTP لا يجب أن يتوقف كله بسبب خطأ في معالج طلب واحد، لذلك تلتقط الـ panic في طبقة واحدة حول كل طلب بالدالة recover، والدالة safe في نفس البرنامج تمثل هذه الطبقة:

// safe turns a panic inside handler into an error, like an HTTP middleware.
func safe(name string, handler func()) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("%s panicked: %v", name, r)
		}
	}()
	handler()
	return nil
}

والجزء الأخير من المخرج:

== recover turns a panic into an error
write to nil map panicked: assignment to entry in nil map
index out of range panicked: runtime error: index out of range [3] with length 1
program still running

فالخطآن تحولا إلى قيمتين عاديتين من النوع error، واستمر البرنامج، ولاحظ أن رسالة الفهرس تقول بالضبط ما حدث: الفهرس 3 والطول 1. ولاحظ أيضاً أن recover تعمل فقط داخل دالة مؤجلة بـ defer، وأن المتغير err اسم في توقيع الدالة Named Result، وهذا ما يسمح للدالة المؤجلة أن تغير القيمة المرجعة بعد الـ panic.

ولكن هناك حد مهم جداً لهذه الطبقة، والبرنامج errs/panicgo يبينه: فيه recover في main، ثم Goroutine أخرى تكتب في خريطة nil:

// A panic in another goroutine is not caught by recover in main: the whole process exits.
package main

import (
	"fmt"
	"time"
)

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered in main:", r)
		}
	}()
	go func() {
		var orders map[int]string
		orders[1] = "paid" // panics inside the goroutine
	}()
	time.Sleep(100 * time.Millisecond)
	fmt.Println("this line never prints")
}

والمخرج سوف يكون كما يلي:

panic: assignment to entry in nil map

goroutine 7 [running]:
main.main.func2()
	/lab/errs/panicgo/main.go:17 +0x25
created by main.main in goroutine 1
	/lab/errs/panicgo/main.go:15 +0x3b
exit status 2
exit=1

فالبرنامج توقف كله برمز الخروج 2، ولم تعمل recover في main، ولم يطبع السطر الأخير، والسبب أن recover تلتقط الـ panic في نفس الـ Goroutine فقط، والـ panic في Goroutine أخرى لا يصعد إلى من أنشأها. ولاحظ أن المكدس المطبوع يقول بوضوح goroutine 7 وcreated by main.main in goroutine 1، أي أنه يدلك على السطر الذي أنشأها. والدرس للخدمة: سيرفر net/http يلتقط الـ panic في معالج الطلب وحده، ولكن أي Goroutine تنشئها أنت داخل المعالج، لإرسال بريد مثلاً، يجب أن تحمل recover خاصاً بها، وإلا فإن خطأً واحداً فيها يوقف الخدمة كلها مع كل الطلبات التي كانت تعالجها. والصورة التالية تجمع أشكال الخطأ الأربعة والرد المناسب لكل منها:

أربع بطاقات: الخطأ الثابت Sentinel مع errors.Is ويقابله 404، ونوع الخطأ مع errors.As ويقابله 409، وerrors.Join للتحقق من المدخلات ويقابله 400، وpanic مع recover للأخطاء البرمجية ويقابله 500، وتحتها تنبيه بأن recover يعمل في نفس الـ Goroutine فقط
كل شكل للخطأ يتحول إلى رد HTTP مختلف في خدمة الطلبات

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

  • رقم المؤشر في الـ Benchmark، 262 نانوثانية لحجز 24 بايت، أكبر بكثير مما يتوقعه أي شخص يعرف مخصص Go، والسبب أن الجهاز كان يشغل حاويات أخرى في نفس اللحظة. ولم نعد القياس حتى «يطلع الرقم أحسن»، وإنما اعتمدنا على عمودي البايتات والحجوزات لأنهما لا يتأثران بانشغال الجهاز، وهذا ما ننصحك به: الزمن من تشغيل واحد على جهاز مشترك لا يعتمد عليه، وأما عدد الحجوزات فثابت.
  • تشغيل نفس البرنامج مع gctrace أخذ 24.4 ثانية بدل 5.83، مع نفس عدد الدورات تقريباً. وجزء من الفرق كتابة مئتي سطر إلى الخطأ القياسي، ولكن أغلبه انشغال الجهاز في تلك الدقيقة، ولذلك قارنا الدورات والحجوزات بين التشغيلين، ولم نقارن الأزمنة.
  • مخرج -gcflags=-m الكامل مئات الأسطر، أغلبها قرارات دمج لدوال المكتبة القياسية، لذلك فلترناه بالملف ثم بأرقام الأسطر. وإذا أردت قراءة القرار لسطر واحد فابحث عن رقم السطر نفسه، ولا تقرأ المخرج من أوله.

أخطاء شائعة Common Mistakes

  • دالة تعدل الـ struct بمستقبل بالقيمة: لا خطأ ولا تحذير، والتعديل يضيع. اجعل المستقبل مؤشراً لكل دالة تعدل.
  • نسخ struct فيه sync.Mutex بالقيمة، أو تمريره بالقيمة لدالة: صار لكل نسخة قفل مختلف. والأداة go vet تكتشف أغلب هذه الحالات بفحص copylocks.
  • إرجاع مؤشر من نوع محدد قيمته nil كقيمة error: الخطأ لا يساوي nil. ارجع nil حرفياً.
  • قص النصوص العربية بالبايت مثل name[:10]: قد تقطع حرفاً في منتصفه فيظهر رمز غريب. اعمل على الحروف بـ []rune أو بالحلقة range.
  • مقارنة نص رسالة الخطأ بـ err.Error() == "…" أو strings.Contains: يتعطل الكود عندما تتغير الصياغة. استخدم خطأً ثابتاً مع errors.Is أو نوعاً مع errors.As.
  • تغليف الخطأ بالفعل %v بدل %w: النص المطبوع نفسه، والسلسلة مقطوعة، وerrors.Is ترجع false.
  • استخدام panic للمدخلات الخاطئة أو للملف الناقص: هذه أخطاء عادية ترجعها قيمة. احتفظ بـ panic للأخطاء البرمجية.
  • Goroutine بلا recover داخل خدمة: panic واحد فيها يوقف العملية كلها.
  • ضبط GOGC قبل قياس الحجوزات: أغلب مشكلات الجامع تحل بتقليل الحجوزات في الكود الساخن، وابدأ بـ pprof.
  • any في كل مكان لتشبه الكود الديناميكي: تخسر فحص المترجم. استخدمها عند الحدود فقط، والأنواع العامة عندما يتكرر الكود لأنواع مختلفة.

جدول قرار Decision Table: قيمة أم مؤشر، وأي شكل للخطأ؟

الحالةالاختيارالسبب
دالة تعدل النوعمستقبل بمؤشر *Tبالقيمة تعدل نسخة تختفي
نوع فيه قفل أو كبير الحجممؤشر في كل الدوالنسخ القفل يلغي الحماية، والنسخ الكبير مكلف
نوع صغير ثابت مثل Totalsقيمةلا يحتاج حجزاً في الـ Heap، وآمن للمشاركة
دالة تنشئ كائناً صغيراً كثيراًارجع قيمةصفر حجز بدل حجز في كل استدعاء
شريحة تعرف حجمها مسبقاًmake([]T, 0, n)حجز واحد بدل 12 في تجربتنا
بناء نص في حلقةstrings.Builder مع Growمن 599 حجزاً إلى أقل بكثير
حالة معروفة بلا تفاصيلخطأ ثابت مع errors.Isمقارنة بسيطة تصمد مع التغليف
المستدعي يحتاج أرقاماً من الخطأنوع خطأ مع errors.As أو errors.AsTypeالبيانات في حقول، لا في نص
عدة أخطاء في المدخلاتerrors.Joinالعميل يصلح كل شيء مرة واحدة
خطأ برمجي لا يجب أن يحدثpanic مع recover في طبقة واحدةطلب واحد يفشل بـ 500 والخدمة تستمر

تدريب عملي

  • أضف إلى النوع Product حقلاً sync.Mutex واجعل ReserveCopy تقفله، ثم شغل go vet ./types واقرأ الرسالة.
  • غير ReceiptBuilder لتستخدم strconv.AppendInt على شريحة بايتات بدل strconv.Itoa، وأعد القياس، وانظر كم حجزاً بقي من الـ 301.
  • شغل memstats concat مرة مع GOGC=400 ومرة مع GOMEMLIMIT=8MiB، وقارن عدد الدورات وقيمة Sys بالجدول في مقال إدارة الذاكرة.
  • أعد كتابة الجزء الذي يستخدم errors.As في errs/main.go بالدالة errors.AsType، وقارن عدد الأسطر.

الخلاصة

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

  • كل شيء في Go يمرر بالقيمة، فالمستقبل بدون نجمة يعدل نسخة، وهذا كان سبب المخزون الذي بقي 10 بعد الحجز، والإصلاح نجمة واحدة.
  • الواجهات ضمنية ويعرفها المستهلك، والواجهة زوج من نوع وقيمة، ولذلك المؤشر nil داخلها لا يساوي nil، وهذا كان سبب الطلب السليم الذي رجع بخطأ 500.
  • الأنواع العامة منذ 1.18 تقلل التكرار، وفي 1.27 صارت الدوال المرتبطة بنوع تأخذ معاملات نوع، وfloat64 في الأنواع العامة أعطتنا 29.700000000000003 كتذكير بالهللات.
  • المترجم يقرر مكان الكائن بتحليل الهروب، والمؤشر المرجع يكلف حجزاً، والحجز المسبق أنزل الحجوزات من 12 إلى 1، وstrings.Builder أنزل الإيصالات من 603 ميجابايت و199 دورة جمع إلى 73 ميجابايت و22 دورة، والزمن من 5.83 إلى 0.92 ثانية.
  • الخطأ الثابت للحالات المعروفة، ونوع الخطأ عندما تحتاج بياناته، وerrors.Join للتحقق، وgo vet في 1.27 يكتشف تغليف المؤشر الخاطئ.
  • recover يعمل في نفس الـ Goroutine فقط، فكل Goroutine في الخدمة تحتاج حمايتها، وإلا أوقف خطأ واحد فيها العملية كلها برمز 2.

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

دليلGo لمن يعرف البرمجة: الأدوات Toolchain وأول مشروعتثبيت Go 1.27 والفرق في صيغتها عما تعرفه، ثم أدواتها في أمر واحد وشكل المشروع، مع تجربة كاملة تبني حاسبة طلب باختباراتها وفحصها وملف تنفيذي واحد.دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.

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

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب بالإصدار Go 1.27.3 داخل الـ Image الرسمية golang:1.27.3-trixie على Windows 11.
Could not load content
نشرة عرب رووت | ArabRoot

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

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

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

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