الأخطاء
على دوال المكتبات أن تُرجع في أغلب الأحيان نوعًا ما من إشارة الخطأ إلى المنادي. وقد ذكرنا سابقًا أن الإرجاع متعدّد القيم في Go يجعل من السهل أن تُرجع وصفًا تفصيليًا للخطأ إلى جانب قيمة الإرجاع المعتادة. ومن الأسلوب الجيد أن تستعمل هذه الميزة لتوفير معلومات دقيقة عن الخطأ. فمثلًا، كما سنرى، لا تُرجع os.Open مؤشرًا nil فحسب عند الفشل، بل تُرجع أيضًا قيمة خطأ تصف ما الذي حدث.
وبالعرف، تكون الأخطاء من النوع error، وهي واجهة مدمجة بسيطة:
type error interface {
Error() string
}
ومَن يكتب مكتبة حرّ في تنفيذ هذه الواجهة بنموذج أغنى في باطنها، فيصبح من الممكن ألّا ترى الخطأ فحسب بل أن تقدّم بعض السياق. وقد ذكرنا أن os.Open تُرجع، إلى جانب قيمة الإرجاع المعتادة *os.File، قيمة خطأ أيضًا. فإذا فُتح الملف بنجاح كان الخطأ nil، أما عند وجود مشكلة فيحمل قيمة os.PathError:
// PathError records an error and the operation and
// file path that caused it.
type PathError struct {
Op string // "open", "unlink", etc.
Path string // The associated file.
Err error // Returned by the system call.
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
وتولّد دالة Error في PathError نصًّا من هذا النوع:
open /etc/passwx: no such file or directory
وهذا الخطأ — الذي يتضمّن اسم الملف المشكلة، والعملية، وخطأ نظام التشغيل الذي سبّبه — مفيد حتى لو طُبع بعيدًا عن النداء الذي سبّبه؛ فهو أكثر إفادة بكثير من عبارة «no such file or directory» المجرّدة.
وحيثما أمكن، ينبغي لنصوص الأخطاء أن تحدّد مصدرها، مثل أن تحمل بادئة تسمّي العملية أو الحزمة التي أنتجت الخطأ. فمثلًا، في الحزمة image، يكون التمثيل النصّي لخطأ في فك ترميز بسبب تنسيق غير معروف هو «image: unknown format».
والمنادون الذين يهمّهم تفصيل الأخطاء يستطيعون استعمال مُبدِّل أنواع أو تأكيد نوع للبحث عن أخطاء بعينها واستخراج تفاصيلها. وبالنسبة إلى PathErrors قد يشمل ذلك فحص الحقل الداخلي Err للكشف عن الأعطال القابلة للاسترداد:
for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // Recover some space.
continue
}
return
}
وجملة if الثانية هنا هي تأكيد نوع آخر. فإن أخفقت، سيكون ok خاطئًا وستكون e هي nil. وإن نجحت، سيكون ok صحيحًا، أي إن الخطأ كان من نوع *os.PathError، ومن ثمّ e كذلك، فيمكننا فحصه لمعرفة المزيد عن الخطأ.
panic
الطريقة المعتادة للإبلاغ عن خطأ إلى المنادي هي أن تُرجع error كقيمة إرجاع إضافية. وطريقة Read المعتادة مثال معروف؛ فهي تُرجع عدد بايتات وerror. لكن ماذا إن كان الخطأ غير قابل للاسترداد؟ أحيانًا لا يستطيع البرنامج أن يُكمل ببساطة.
ولهذا الغرض توجد دالة مدمجة اسمها panic تنشئ خطأ وقت تشغيل يوقف البرنامج (لكن انظر القسم التالي). وهي تأخذ وسيطًا واحدًا من نوع اعتباطي — غالبًا نصًّا — يُطبع عند موت البرنامج. وهي أيضًا طريقة للإشارة إلى حدوث شيء مستحيل، مثل الخروج من حلقة لا نهائية.
// A toy implementation of cube root using Newton's method.
func CubeRoot(x float64) float64 {
z := x/3 // Arbitrary initial value
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// A million iterations has not converged; something is wrong.
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}
هذا مثال فحسب، لكن دوال المكتبات الحقيقية ينبغي أن تتجنّب panic. فإن أمكن إخفاء المشكلة أو الالتفاف حولها، فالأفضل دائمًا ترك الأشياء تعمل بدلًا من إسقاط البرنامج كله. ومن الأمثلة الممكنة على الاستثناء التهيئة: فإذا عجزت المكتبة حقًا عن.الإعداد، فقد يكون من المعقول أن «تُpanic» — على حدّ التعبير.
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}
recover
حين تُنادى panic — بما في ذلك ضمنيًا لأخطاء وقت التشغيل مثل الفهرسة خارج حدود شريحة أو فشل تأكيد نوع — فإنها توقف تنفيذ الدالة الحالية فورًا وتبدأ في فكّ مكدّس الـ goroutine، مُنفِّذة أي دوال مؤجَّلة في الطريق. فإن بلغ هذا الفكّ قمة مكدّس الـ goroutine، مات البرنامج. غير أنه من الممكن استعمال الدالة المدمجة recover لاستعادة التحكّم في الـ goroutine واستئناف التنفيذ الطبيعي.
ونداءُ recover يوقف عملية الفكّ ويُرجع الوسيط الذي مُرِّر إلى panic. ولأن الشيفرة الوحيدة التي تعمل أثناء الفكّ هي ما بداخل الدوال المؤجَّلة، فإن recover لا تنفع إلا داخل دوال مؤجَّلة.
ومن تطبيقات recover إيقاف goroutine فاشلة داخل خادم دون قتل الـ goroutines الأخرى العاملة. وإليك مثالًا:
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work)
}
}
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
في هذا المثال، إن أُطلق panic في do(work)، فسيُسجَّل الناتج وتخرج الـ goroutine بنظافة دون إزعاج غيرها. ولا حاجة إلى عمل أي شيء آخر داخل الإغلاق المؤجَّل؛ فنداء recover يعالج الحالة تمامًا.
ولأن recover تُرجع دائمًا nil ما لم تُنَادَ مباشرةً من دالة مؤجَّلة، فإن الشيفرة المؤجَّلة تستطيع أن تنادي دوال مكتبات تستعمل panic وrecover هي نفسها دون أن تفشل. فمثلًا، قد تنادي الدالة المؤجَّلة في safelyDo دالة تسجيل قبل نداء recover، وستعمل شيفرة التسجيل تلك دون أن يؤثّر فيها حالة الانهيار.
ومع وجود نمط الاسترداد هذا، تستطيع دالة do — وأي شيء تناديه — أن تخرج من أي موقف سيّئ بأمان عبر نداء panic. ويمكننا أن نستثمر هذه الفكرة لتبسيط معالجة الأخطاء في البرمجيات المعقّدة. فلنرَ نسخة مثالية من حزمة regexp، وهي تبلّغ عن أخطاء التحليل بنداء panic مع نوع خطأ محلي. وإليك تعريف Error، ودالة error، ودالة Compile:
// Error is the type of a parse error; it satisfies the error interface.
type Error string
func (e Error) Error() string {
return string(e)
}
// error is a method of *Regexp that reports parsing errors by
// panicking with an Error.
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// Compile returns a parsed representation of the regular expression.
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
// doParse will panic if there is a parse error.
defer func() {
if e := recover(); e != nil {
regexp = nil // Clear return value.
err = e.(Error) // Will re-panic if not a parse error.
}
}()
return regexp.doParse(str), nil
}
وإن أُطلق panic في doParse، فإن كتلة الاسترداد ستضبط قيمة الإرجاع على nil — فالدوال المؤجَّلة تستطيع تعديل قيم الإرجاع المسمّاة. ثم ستفحص، في الإسناد إلى err، أن المشكلة كانت خطأ تحليل بتأكيد أنها من النوع المحلي Error. وإن لم تكن كذلك، فإن تأكيد النوع سيفشل، فيسبب خطأ وقت تشغيل يُكمل فكّ المكدّس وكأن شيئًا لم يقطعه. يعني هذا الفحص أن إذا حدث شيء غير متوقّع، مثل الخروج عن حدود فهرس، فستفشل الشيفرة رغم أننا نستعمل panic وrecover في معالجة أخطاء التحليل.
ومع وجود معالجة الأخطاء، تصبح دالة error — ولأنها دالة مرتبطة بنوع، فلا بأس ولا غرابة في أن تحمل الاسم نفسه الذي تحمله نوع error المدمج — تجعل الإبلاغ عن أخطاء التحليل سهلًا دون التفكير في فكّ مكدّس التحليل يدويًا:
if pos == 0 {
re.error("'*' illegal at start of expression")
}
ورغم نفع هذا النمط، فينبغي ألا يُستعمل إلا داخل الحزمة الواحدة. فدالة Parse تحوّل نداءاتها الداخلية لـ panic إلى قيم error؛ فهي لا تكشف panics لعملائها. وهذه قاعدة جيّدة نمضي على النهج.
وبالمناسبة، يغيّر هذا عرف إعادة الانهيار قيمة الانهيار إذا وقع خطأ فعلي. غير أن الفشل الأصلي والجديد سيُعرضان معًا في تقرير الانهيار، فيبقى سبب المشكلة الجذري ظاهرًا. ومن ثمّ يكفي هذا النهج البسيط لإعادة الانهيار عادةً — بعد كل شيء، إنّه انهيار — أما إن أردت عرض القيمة الأصلية وحدها، فبإمكانك كتابة شيفرة أطول قليلًا تُصفّي المشكلات غير المتوقّعة وتعيد الانهيار بالخطأ الأصلي. وذاك تُرك كتمرين للقارئ.