الأدلة التقنية

كيف تتعامل لغات البرمجة مع الأخطاء؟

لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.

كيف تتعامل لغات البرمجة مع الأخطاء؟

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

وأشهر هذه النقاشات بدأ مع Java وفكرة الاستثناءات المفحوصة Checked Exceptions، وهي استثناءات يجبرك المترجم Compiler على التعامل معها. فقد كتب بروس إيكل Bruce Eckel، صاحب كتاب Thinking in Java، أن هذه الفكرة تدفع المطورين إلى ابتلاع الأخطاء، ورد عليه إليوت رستي هارولد Elliotte Rusty Harold بمقال عنوانه صريح جداً: «Bruce Eckel is Wrong». وفي مقال أنماط البرمجة وأنظمة الأنواع رأينا كيف تكتشف اللغة أخطاء الأنواع قبل التشغيل أو أثناءه، أما هنا فنتحدث عن نوع آخر من الأخطاء: الأخطاء التي لا يمكن منعها في الكود، لأنها تأتي من العالم الخارجي، مثل ملف ناقص أو شبكة منقطعة أو مدخل خاطئ.

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

  • الجدل بين إيكل وهارولد حول الاستثناءات المفحوصة، وماذا قال كل طرف بالضبط.
  • الطريقتان الأساسيتان لإبلاغ الخطأ: الاستثناء Exception والقيمة المرجعة Error Value.
  • الاستثناءات في Java وPython وC#، والفرق بين المفحوصة Checked وغير المفحوصة Unchecked، ولماذا لم تأخذ C# وKotlin بالفكرة.
  • قيم الخطأ في Go مع if err != nil والتغليف Wrapping بـ %w والدالتين errors.Is وerrors.As.
  • النوعان Result وOption في Rust، والعلامة ?، ومتى يحدث panic.
  • رموز الخطأ Error Codes في C والمتغير errno باختصار.
  • تجربة حقيقية: الدالة نفسها بأربع لغات داخل Docker، وماذا يحدث عندما نتجاهل الخطأ في كل لغة، وقياس توضيحي لتكلفة رمي الاستثناء.
  • أين تناسب كل طريقة، والأخطاء الشائعة التي نراها في الكود الحقيقي.

الجدل الذي بدأ من Java: هل الاستثناءات المفحوصة فكرة خاطئة؟

في Java نوعان من الاستثناءات، النوع المفحوص Checked مثل IOException، وهذا يجبرك المترجم على أن تلتقطه بـ catch أو أن تعلن في توقيع الدالة Method Signature أنها ترميه بـ throws، والنوع غير المفحوص Unchecked مثل NumberFormatException، وهذا لا يطلب المترجم منك فيه شيئاً. والفكرة من الاستثناء المفحوص أن لا ينسى المطور أن العملية قد تفشل، فالمترجم يذكره في كل مرة.

وفي مقال قديم على موقعه بعنوان Does Java need Checked Exceptions? (أقدم نسخة محفوظة منه في أرشيف الإنترنت من أبريل 2002) كتب إيكل أن هذه الفكرة في رأيه تجربة فشلت «because so many people end up swallowing the exceptions in their code»، أي لأن كثيراً من المطورين ينتهي بهم الأمر إلى ابتلاع الاستثناءات في كودهم، ويقصد بذلك كتلة catch فارغة تجعل الخطأ يختفي. وكانت نظريته نفسية أكثر منها تقنية، فعندما يزعجك المترجم مرة بعد مرة وأنت تحاول إنجاز شيء آخر، فسوف تستخدم أسرع طريقة تسكته بها، وتنوي أن ترجع إليها لاحقاً ثم تنسى. واعترف بأنه هو نفسه فعل ذلك في الطبعة الأولى من كتابه، فكتب catch (SomeKindOfException e) {} في أحد الأمثلة، ثم بدأ يرى الكود نفسه عند غيره. وقارن ذلك بلغة Python التي لا تجبرك على شيء، فإذا نسيت الاستثناء فسوف يصعد حتى أعلى البرنامج ويظهر لك، ولكنه لن يختفي، واختفاء الخطأ عنده هو أسوأ ما يمكن أن يحدث ("the worst of all possible cases").

وفي الطبعة الرابعة من Thinking in Java لخص إيكل رأيه في جملة اقتبسها هارولد لاحقاً: «Checked exceptions complicate the scenario a bit, because they force you to add catch clauses in places where you may not be ready to handle an error. This results in the "harmful if swallowed" problem»، أي أن الاستثناءات المفحوصة تجبرك على كتابة catch في أماكن لست مستعداً فيها لمعالجة الخطأ، فتظهر مشكلة الخطأ الذي يضر إذا ابتلع.

وفي 20 أبريل 2010 نشر هارولد مقاله Bruce Eckel is Wrong، وقال فيه إن المشكلة ليست مسألة رأي وإنما خطأ في الحقائق، فالجملة «تجبرك على كتابة catch» غير صحيحة، والسبب كما كتب: «Checked exceptions do not require catch blocks. They require a catch block OR a throws declaration»، أي أن الاستثناء المفحوص يتطلب catch أو إعلان throws، وإذا لم تكن مستعداً لمعالجة الخطأ في مكان ما فاتركه يصعد بإضافة throws إلى الدالة. ثم أضاف الجملة التي صارت أشهر ما في المقال: «A checked exception is nothing more and nothing less than part of the return type»، أي أن الاستثناء المفحوص جزء من نوع القيمة المرجعة Return Type، فالدالة إما أن ترجع قيمة وإما أن ترمي استثناء، ومن المنطقي أن تكتب في توقيعها ما ترميه كما تكتب أنها ترجع int أو String. وكتب أيضاً أن التقاط كل استثناء مفحوص عند أول فرصة «علامة أكيدة على مطور Java مبتدئ» ("a sure mark of a novice Java developer")، وأن المعالجة الصحيحة تفرق بين أخطاء البرمجة Programming Errors التي تمثلها الاستثناءات غير المفحوصة، والمشاكل التي تأتي من البيئة Environmental Problems، مثل امتلاء القرص، وهذه هي الاستثناءات المفحوصة.

وقد يتساءل البعض: من على حق؟ والإجابة أن كل طرف يصف جزءاً حقيقياً من الصورة، فهارولد محق في القاعدة، فالمترجم لا يجبرك على catch، ولكن إيكل محق في ما يحدث فعلاً في الكود، وهذا ما قاله أيضاً أندرس هايلسبرغ مصمم C# كما سوف نرى بعد قليل. ولاحظ أن الفكرة التي صاغها هارولد، أن الخطأ جزء من نوع القيمة المرجعة، هي بالضبط الفكرة التي بنت عليها Rust النوع Result، ولكن دون آلية الاستثناءات أصلاً. وليست هذه المرة الوحيدة التي يقترح فيها إيكل تغييراً يرفضه الطرف الآخر، ففي 2008 اقترح أن تختفي self من قائمة معاملات الدوال في Python، ورد عليه جيدو فان روسم بأن ذلك يكسر اتساق اللغة، كما حكينا في قسم لماذا بقي self صريحاً في Python؟.

طريقتان لإبلاغ الخطأ: الاستثناء أم القيمة المرجعة؟

قبل أن ندخل في كل لغة، تأمل في الصورة التالية التي تبين الفرق الأساسي بين الطريقتين، فالدالة main تستدعي load التي تستدعي readPort التي تستدعي دالة قراءة الملف، والخطأ يقع في آخر السلسلة:

مساران لنفس سلسلة الاستدعاء main ثم load ثم readPort ثم readFile والخطأ في آخرها: في المسار الأعلى Exceptions لجافا وبايثون وسي شارب يقفز الاستثناء بسهم متقطع فوق الدوال حتى catch في main، وفي المسار الأسفل Error values لـ Go وRust وC يرجع الخطأ دالة بعد دالة بأسهم عليها return 0, err وif err != nil
الاستثناء يقفز فوق الدوال حتى أقرب catch، والقيمة المرجعة تمر على كل دالة في الطريق
  • الاستثناء Exception: الدالة التي فشلت ترمي Throw كائناً يصف الخطأ، فيتوقف التنفيذ الطبيعي ويصعد الاستثناء في مكدس الاستدعاءات Call Stack حتى يجد أول catch يناسبه، والدوال التي في الطريق لا تكتب أي سطر من أجله. وهذه طريقة Java وPython وC# وJavaScript وKotlin.
  • القيمة المرجعة Error Value: الخطأ قيمة عادية ترجعها الدالة مع النتيجة أو بدلاً منها، وكل دالة في الطريق تراه وتقرر ماذا تفعل به: تعالجه أو تضيف إليه سياقاً وترجعه. وهذه طريقة Go وRust، وبصورة أقدم وأضعف طريقة C برموز الخطأ.

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

المختبر: دالة واحدة بأربع لغات

سوف نكتب الدالة نفسها بلغات Java وPython وGo وRust، ونشغل كل واحدة داخل Container من Docker Image رسمية بإصدار مثبت، ثم نضيف C باختصار في آخر المقال. وحتى نحصل على حالة رفض الصلاحية Permission Denied بشكل حقيقي، نشغل كل Container بمستخدم عادي --user 1000:1000 وليس بالمستخدم root، والسبب أن root يتجاوز صلاحيات الملفات في Linux، فلو شغلنا التجربة به لقرأ الملف المقفل دون أي خطأ. والجدول التالي يبين الإصدارات التي استخدمناها:

اللغةDocker Imageالإصدار في المختبر
Javaeclipse-temurin:25.0.4.1_1-jdkOpenJDK 25.0.4.1 LTS (Temurin)
Pythonpython:3.13.16-slimPython 3.13.16
Gogolang:1.27.1-alpine3.24go1.27.1
Rust وCrust:1.99.0-alpinerustc 1.99.0 وgcc 15.2.0

ونجهز الملفات الثلاثة في كل Container بالسكربت setup.sh التالي، الذي يكتب ملفاً صحيحاً وملفاً فيه رقم خاطئ 80a0 وملفاً صحيحاً ولكن صلاحياته 000، أما الملف missing.conf فلا نكتبه أصلاً:

#!/bin/sh
# Creates the three test files in /tmp/cfg as the current (non-root) user.
mkdir -p /tmp/cfg
echo 8080 > /tmp/cfg/good.conf
echo 80a0 > /tmp/cfg/bad.conf
echo 9090 > /tmp/cfg/locked.conf
chmod 000 /tmp/cfg/locked.conf

وكل لغة لها سكربت run.sh يشغل الأوامر التي سوف نراها، ويتم تشغيله بأمر مثل التالي من مجلد المختبر، وكل Container يحذف نفسه بعد انتهاء الأمر بسبب الخيار --rm:

docker run --rm --name lab-err-java --user 1000:1000 -e HOME=/tmp \
  -v "$PWD:/lab:ro" eclipse-temurin:25.0.4.1_1-jdk sh /lab/java/run.sh

الاستثناءات في Java وPython وC#

Java: المفحوص وغير المفحوص في دالة واحدة

الصورة التالية تبين شجرة الاستثناءات في Java كما تحددها مواصفة اللغة في الفصل 11، والتي تقول إن الاستثناءات غير المفحوصة هي RuntimeException وError وما تحتهما، وكل ما عدا ذلك مفحوص:

شجرة تبدأ من Throwable وتتفرع إلى Error مثل OutOfMemoryError وإلى Exception، ومن Exception يتفرع RuntimeException باللون البرتقالي مثل NumberFormatException وIOException بالأزرق مثل NoSuchFileException وAccessDeniedException، وفي الأسفل صندوق Unchecked يقول إن المترجم لا يطلب شيئاً وصندوق Checked يقول إما catch أو throws وإلا يرفض المترجم البرنامج مع رسالة unreported exception IOException
الاستثناء المفحوص يطلب catch أو throws، وغير المفحوص يظهر عند التشغيل فقط

والملف التالي Config.java فيه الدالة readPort ومعها نسخة خاطئة عمداً تبتلع الخطأ، والمثال التالي يبين ذلك:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.AccessDeniedException;
import java.nio.file.Path;

public class Config {
    // IOException is checked: it must be caught or declared with throws.
    // NumberFormatException is unchecked: the compiler says nothing about it.
    static int readPort(String path) throws IOException {
        String text = Files.readString(Path.of(path)).trim();
        return Integer.parseInt(text);
    }

    // The mistake: an empty catch turns every failure into port 0.
    static int readPortSwallowed(String path) {
        try {
            return readPort(path);
        } catch (Exception e) {
        }
        return 0;
    }

    public static void main(String[] args) throws IOException {
        String mode = args[0];
        if (mode.equals("handle")) {
            for (String p : new String[] {"/tmp/cfg/good.conf", "/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"}) {
                try {
                    System.out.println(p + " -> port " + readPort(p));
                } catch (NoSuchFileException e) {
                    System.out.println(p + " -> not found: " + e.getMessage());
                } catch (AccessDeniedException e) {
                    System.out.println(p + " -> permission denied: " + e.getMessage());
                } catch (NumberFormatException e) {
                    System.out.println(p + " -> bad number: " + e.getMessage());
                }
            }
        } else if (mode.equals("swallow")) {
            for (String p : new String[] {"/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"}) {
                System.out.println(p + " -> port " + readPortSwallowed(p));
            }
        } else {
            System.out.println("port " + readPort(args[1]));
        }
    }
}

ونشغله بالأمر java Config.java مباشرة دون ترجمة منفصلة، وهي ميزة تشغيل ملف المصدر الواحد التي أضافها JEP 330 في Java 11. والمخرج سوف يكون كما يلي:

$ java Config.java handle
/tmp/cfg/good.conf -> port 8080
/tmp/cfg/missing.conf -> not found: /tmp/cfg/missing.conf
/tmp/cfg/bad.conf -> bad number: For input string: "80a0"
/tmp/cfg/locked.conf -> permission denied: /tmp/cfg/locked.conf

$ java Config.java swallow
/tmp/cfg/missing.conf -> port 0
/tmp/cfg/bad.conf -> port 0
/tmp/cfg/locked.conf -> port 0

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

  • الحالات الثلاث لها ثلاثة أنواع مختلفة: NoSuchFileException وAccessDeniedException وكلاهما من عائلة IOException المفحوصة، وNumberFormatException غير المفحوص، ولهذا كتبنا throws IOException فقط في توقيع readPort، ولم يطلب المترجم شيئاً عن الرقم الخاطئ.
  • النسخة التي تبتلع الخطأ أرجعت المنفذ 0 في الحالات الثلاث بصمت، فلا يوجد سطر واحد في المخرج يقول إن شيئاً فشل، وهذا هو بالضبط ما حذر منه إيكل: البرنامج يكمل بقيمة خاطئة، ولن تعرف السبب إلا بعد ساعات من البحث.

والآن نترك الاستثناء دون معالجة لنرى ال Stack Trace، وهو قائمة الدوال التي مر بها الاستثناء من مكان وقوعه حتى أعلى البرنامج:

$ java Config.java crash /tmp/cfg/missing.conf
Exception in thread "main" java.nio.file.NoSuchFileException: /tmp/cfg/missing.conf
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
	at java.base/sun.nio.fs.UnixFileSystemProvider.newFileChannel(UnixFileSystemProvider.java:213)
	at java.base/sun.nio.fs.UnixFileSystemProvider.newByteChannel(UnixFileSystemProvider.java:244)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:357)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:399)
	at java.base/java.nio.file.Files.readAllBytes(Files.java:2973)
	at java.base/java.nio.file.Files.readString(Files.java:3043)
	at java.base/java.nio.file.Files.readString(Files.java:3006)
	at Config.readPort(Config.java:11)
	at Config.main(Config.java:43)
exit code 1

$ java Config.java crash /tmp/cfg/bad.conf
Exception in thread "main" java.lang.NumberFormatException: For input string: "80a0"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:565)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at Config.readPort(Config.java:12)
	at Config.main(Config.java:43)
exit code 1

ويقرأ ال Stack Trace من الأعلى إلى الأسفل: السطر الأول نوع الاستثناء ورسالته، ثم الدوال من أعمقها إلى أعلاها، وأول سطر من كودك أنت هو Config.readPort(Config.java:11)، وهو غالباً السطر الذي تبدأ منه البحث. وهذه المعلومات هي ما تخسره عندما تبتلع الاستثناء.

والآن نكسر القاعدة عمداً، فنكتب دالة تقرأ الملف دون throws ودون catch، في الملف NoThrows.java:

import java.nio.file.Files;
import java.nio.file.Path;

public class NoThrows {
    // No "throws IOException" and no catch: the compiler refuses this method.
    static int readPort(String path) {
        return Integer.parseInt(Files.readString(Path.of(path)).trim());
    }

    public static void main(String[] args) {
        System.out.println(readPort(args[0]));
    }
}

وعند ترجمته بالأمر javac سوف تجد أن المترجم يرفضه:

$ javac -d /tmp/out NoThrows.java
NoThrows.java:7: error: unreported exception IOException; must be caught or declared to be thrown
        return Integer.parseInt(Files.readString(Path.of(path)).trim());
                                                ^
1 error
exit code 1

ولاحظ نص الرسالة: «must be caught or declared to be thrown»، أي أن المترجم نفسه يعرض عليك الخيارين اللذين تحدث عنهما هارولد، وليس catch فقط. وتلخص دروس Java الرسمية من Oracle القاعدة بهذه الجملة: «If a client can reasonably be expected to recover from an exception, make it a checked exception. If a client cannot do anything to recover from the exception, make it an unchecked exception»، أي أن الاستثناء يكون مفحوصاً إذا كان من استدعى الدالة يستطيع أن يتعافى منه، وغير مفحوص إذا لم يكن يستطيع فعل شيء.

Python: كل الاستثناءات غير مفحوصة

في Python لا يوجد شيء اسمه استثناء مفحوص، فلا توقيع يعلن ما ترميه الدالة ولا مترجم يطالبك بشيء، والأسلوب الشائع في اللغة هو ما يسميه قاموس مصطلحات Python بأسلوب EAFP، اختصاراً لـ Easier to Ask for Forgiveness than Permission، أي أن تجرب العملية وتلتقط الاستثناء إذا فشلت، بدلاً من أن تفحص كل شرط قبلها. والملف config.py التالي فيه الدالة نفسها، ونسخة تبتلع الخطأ، ودالة load تغلف الخطأ بسياق جديد:

import sys


def read_port(path):
    # open() raises FileNotFoundError or PermissionError, int() raises ValueError.
    with open(path) as f:
        return int(f.read().strip())


def read_port_swallowed(path):
    # The mistake: a bare except turns every failure into port 0.
    try:
        return read_port(path)
    except Exception:
        pass
    return 0


class ConfigError(Exception):
    pass


def load(path):
    # Wrap with context and keep the cause with "from".
    try:
        return read_port(path)
    except (OSError, ValueError) as e:
        raise ConfigError(f"cannot load config {path}") from e


mode = sys.argv[1]
if mode == "handle":
    for p in ["/tmp/cfg/good.conf", "/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"]:
        try:
            print(p, "-> port", read_port(p))
        except FileNotFoundError as e:
            print(p, "-> not found:", e)
        except PermissionError as e:
            print(p, "-> permission denied:", e)
        except ValueError as e:
            print(p, "-> bad number:", e)
elif mode == "swallow":
    for p in ["/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"]:
        print(p, "-> port", read_port_swallowed(p))
elif mode == "wrap":
    load(sys.argv[2])
else:
    print("port", read_port(sys.argv[2]))

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

$ python3 config.py handle
/tmp/cfg/good.conf -> port 8080
/tmp/cfg/missing.conf -> not found: [Errno 2] No such file or directory: '/tmp/cfg/missing.conf'
/tmp/cfg/bad.conf -> bad number: invalid literal for int() with base 10: '80a0'
/tmp/cfg/locked.conf -> permission denied: [Errno 13] Permission denied: '/tmp/cfg/locked.conf'

$ python3 config.py swallow
/tmp/cfg/missing.conf -> port 0
/tmp/cfg/bad.conf -> port 0
/tmp/cfg/locked.conf -> port 0

$ python3 config.py crash /tmp/cfg/locked.conf
Traceback (most recent call last):
  File "/lab/python/config.py", line 48, in <module>
    print("port", read_port(sys.argv[2]))
                  ~~~~~~~~~^^^^^^^^^^^^^
  File "/lab/python/config.py", line 6, in read_port
    with open(path) as f:
         ~~~~^^^^^^
PermissionError: [Errno 13] Permission denied: '/tmp/cfg/locked.conf'
exit code 1

$ python3 config.py wrap /tmp/cfg/bad.conf
Traceback (most recent call last):
  File "/lab/python/config.py", line 26, in load
    return read_port(path)
  File "/lab/python/config.py", line 7, in read_port
    return int(f.read().strip())
ValueError: invalid literal for int() with base 10: '80a0'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/lab/python/config.py", line 46, in <module>
    load(sys.argv[2])
    ~~~~^^^^^^^^^^^^^
  File "/lab/python/config.py", line 28, in load
    raise ConfigError(f"cannot load config {path}") from e
ConfigError: cannot load config /tmp/cfg/bad.conf
exit code 1

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

  • الرقمان [Errno 2] و[Errno 13] هما رمزا الخطأ في نظام التشغيل، أي ENOENT وEACCES، وPython تحولهما إلى نوعين مختلفين من الاستثناءات، وتقول وثائق الاستثناءات في Python إن PermissionError يقابل EACCES وEPERM، وهذا يعني أنك تلتقط النوع ولا تقارن الأرقام بنفسك.
  • النسخة التي تبتلع الخطأ بـ except Exception: pass أعطت النتيجة نفسها التي رأيناها في Java: صفر بصمت، أي أن غياب الاستثناءات المفحوصة لم يمنع ابتلاع الخطأ، فالمشكلة في العادة وليست في اللغة.
  • عبارة raise ... from e تحفظ الاستثناء الأصلي في الخاصية __cause__، ولهذا طبع ال Traceback الاستثناءين معاً مع الجملة «The above exception was the direct cause»، فلم نخسر السبب الحقيقي ونحن نضيف رسالة أوضح.
  • العلامات ~~~^^^ تحت السطر تحدد الجزء الذي فشل بالضبط، وهي تظهر في الإصدارات الحديثة من Python.

C#: لماذا لم تأخذ بالاستثناءات المفحوصة؟

عندما أعلنت Microsoft عن C# في سنة 2000 كانت Java أمام فريقها منذ سنوات، ومع ذلك لم تضف C# الاستثناءات المفحوصة، وكل استثناء فيها يشبه الاستثناء غير المفحوص في Java. وفي 30 يوليو 2003 جلس بروس إيكل نفسه مع بيل فينرز Bill Venners مع أندرس هايلسبرغ Anders Hejlsberg، المعماري الرئيسي لـ C# الذي حكينا قصته في مقال قصص لغات البرمجة، ونشرت المقابلة على Artima في 18 أغسطس 2003 بعنوان The Trouble with Checked Exceptions. وقال هايلسبرغ إن عنده مشكلتين مع الاستثناءات المفحوصة:

  • الإصدارات Versionability: إذا أعلنت دالة أنها ترمي A وB وC، ثم احتاج الإصدار الثاني منها أن يرمي D، فإضافة D إلى throws تكسر كل كود يستدعيها، تماماً مثل إضافة دالة إلى Interface منشورة.
  • الحجم Scalability: في مثال صغير تبدو الفكرة رائعة، ولكن عندما يتحدث نظامك مع أربعة أو خمسة أنظمة فرعية، وكل واحد يرمي من أربعة إلى عشرة استثناءات، فسوف تجد في توقيع الدالة 40 استثناء، ثم 80 عندما تجمعها مع نظام آخر، وكما قال «It just balloons out of control».

وأضاف أن المطورين يلتفون على الفكرة بأن يكتبوا throws Exception في كل دالة، أو بكتلة catch فارغة ينوون الرجوع إليها ولا يرجعون، وبالتالي فإن الاستثناءات المفحوصة «degraded the quality of the system in the large»، أي أنها أضعفت جودة الأنظمة الكبيرة. وقال أيضاً إن الأهم في الاستثناءات ليس التقاطها، ففي التطبيق المكتوب جيداً يرى أن نسبة try finally إلى try catch عشرة إلى واحد، أي أن أغلب الكود يكتفي بتنظيف الموارد ويترك المعالجة لمعالج مركزي واحد. ولاحظ أن هايلسبرغ لم يقل إن الفكرة سيئة، بل قال إنها «a wonderful feature» وإن المشكلة في طريقة تطبيقها في Java، وإن C# اختارت أن تبقى صامتة ومحايدة حتى يظهر حل أفضل.

والشكل في C# قريب جداً من Java، مع فرق مهم في إعادة رمي الاستثناء، فالمثال التالي يبين ذلك:

static int ReadPort(string path)
{
    // FileNotFoundException, UnauthorizedAccessException, FormatException: nothing to declare
    return int.Parse(File.ReadAllText(path).Trim());
}

int port;
try
{
    port = ReadPort("/etc/app/port.conf");
}
catch (FileNotFoundException)
{
    port = 8080;          // a real decision: use the default
}
catch (Exception ex)
{
    logger.LogError(ex, "cannot read config");
    throw;                // not "throw ex;": that restarts the stack trace here
}

وتشرح إرشادات Microsoft للاستثناءات السبب: إذا أعدت رمي الاستثناء بـ throw e فإن ال Stack Trace يبدأ من جديد من الدالة الحالية وتضيع قائمة الدوال بين مكان الخطأ الأصلي ومكان الالتقاط، أما throw وحدها داخل catch فتحفظه، وهناك قاعدة تحليل اسمها CA2200 تكتشف هذا الخطأ. ولم نشغل مثال C# في المختبر، فهو هنا لتوضيح الشكل فقط.

Kotlin: تعمل على JVM وتركت الاستثناءات المفحوصة

القصة الأكثر دلالة هي Kotlin، لأنها تعمل على JVM نفسها وتستدعي مكتبات Java مباشرة، ومع ذلك تقول وثائق Kotlin الحالية إن «Kotlin treats all exceptions as unchecked by default»، أي أن كل الاستثناءات غير مفحوصة، فتستطيع أن تلتقطها ولكنك لست مجبراً على التقاطها أو إعلانها. وكانت النسخة السابقة من الصفحة نفسها (محفوظة في أرشيف الإنترنت من يناير 2024) تشرح السبب بمثال من مكتبة Java نفسها، فالواجهة Appendable التي ينفذها StringBuilder تعلن أن الدالة append ترمي IOException، لأن بعض من ينفذها يكتب في ملف، والنتيجة أن من يضيف نصاً إلى StringBuilder في الذاكرة يجبر على كتابة catch لا معنى لها، فيكتب كتلة فارغة مع تعليق «Must be safe». ثم أشارت الصفحة إلى مقابلة هايلسبرغ نفسها، وإلى البند 77 في كتاب Effective Java وعنوانه «Don't ignore exceptions».

ويجدر الانتباه إلى تفصيل صغير: تلك الصفحة القديمة نسبت إلى إيكل جملة عن أن البرامج الصغيرة توحي بأن إعلان الاستثناءات يحسن الإنتاجية والجودة بينما المشاريع الكبيرة تقول العكس، وفي مقال إيكل الأصلي هذه الجملة يقتبسها هو من تعليق كتبه أحد مصممي C#، وليست من كلامه. وإذا كنت تكتب بـ Kotlin كوداً تستدعيه Java وتريد أن ينبه المترجم من يستدعيه، فهناك الوسم @Throws لهذا الغرض.

القيم المرجعة في Go: if err != nil

اختارت Go الطريق الآخر بالكامل، وتشرح الأسئلة الشائعة في موقع Go السبب بأن ربط الاستثناءات ببنية تحكم مثل try-catch-finally «results in convoluted code»، أي أنه ينتج كوداً معقداً، وأنه يشجع المطورين على اعتبار أخطاء عادية، مثل فشل فتح ملف، حالات استثنائية. ففي Go ترجع الدالة قيمتين: النتيجة، وقيمة من النوع error تكون nil إذا نجحت العملية. والملف main.go التالي فيه الدالة نفسها:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"strconv"
	"strings"
)

// readPort returns the port or an error; the caller decides what to do.
func readPort(path string) (int, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return 0, fmt.Errorf("read config: %w", err)
	}
	port, err := strconv.Atoi(strings.TrimSpace(string(data)))
	if err != nil {
		return 0, fmt.Errorf("parse port in %s: %w", path, err)
	}
	return port, nil
}

func main() {
	switch os.Args[1] {
	case "handle":
		for _, p := range []string{"/tmp/cfg/good.conf", "/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"} {
			port, err := readPort(p)
			var numErr *strconv.NumError
			switch {
			case err == nil:
				fmt.Println(p, "-> port", port)
			case errors.Is(err, fs.ErrNotExist):
				fmt.Println(p, "-> not found:", err)
			case errors.Is(err, fs.ErrPermission):
				fmt.Println(p, "-> permission denied:", err)
			case errors.As(err, &numErr):
				fmt.Println(p, "-> bad number:", numErr.Num, "|", err)
			default:
				fmt.Println(p, "-> other error:", err)
			}
		}
	case "ignore":
		// The mistake: the error is thrown away with _ and port is silently 0.
		for _, p := range []string{"/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"} {
			port, _ := readPort(p)
			fmt.Println(p, "-> port", port)
		}
	case "panic":
		port, err := readPort(os.Args[2])
		if err != nil {
			panic(err)
		}
		fmt.Println("port", port)
	}
}

ونبني البرنامج داخل Container ثم نشغله، والمخرج سوف يكون كما يلي:

$ ./config handle
/tmp/cfg/good.conf -> port 8080
/tmp/cfg/missing.conf -> not found: read config: open /tmp/cfg/missing.conf: no such file or directory
/tmp/cfg/bad.conf -> bad number: 80a0 | parse port in /tmp/cfg/bad.conf: strconv.Atoi: parsing "80a0": invalid syntax
/tmp/cfg/locked.conf -> permission denied: read config: open /tmp/cfg/locked.conf: permission denied

$ ./config ignore
/tmp/cfg/missing.conf -> port 0
/tmp/cfg/bad.conf -> port 0
/tmp/cfg/locked.conf -> port 0

$ go vet .
go vet: no findings

$ ./config panic /tmp/cfg/bad.conf
panic: parse port in /tmp/cfg/bad.conf: strconv.Atoi: parsing "80a0": invalid syntax

goroutine 1 [running]:
main.main()
	/tmp/src/main.go:53 +0x21e
exit code 2

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

  • كل رسالة خطأ تقرأ مثل جملة من اليسار إلى اليمين: «read config» من دالتنا، ثم «open /tmp/cfg/locked.conf» من مكتبة os، ثم «permission denied» من نظام التشغيل، وهذا لأن كل طبقة أضافت سياقها بـ fmt.Errorf والفعل %w.
  • الدالة errors.Is عرفت أن الخطأ «ملف غير موجود» أو «صلاحية مرفوضة» رغم أنه مغلف داخل خطأين آخرين، والدالة errors.As استخرجت النوع *strconv.NumError ومنه القيمة الخاطئة 80a0.
  • عندما تجاهلنا الخطأ بالعلامة _ رجع المنفذ 0 بصمت، كما في Java وPython تماماً، والأداة go vet لم تعترض على ذلك، لأن _ تعني أنك قررت التجاهل صراحة.
  • الدالة panic هي الأقرب إلى الاستثناء في Go، فهي توقف البرنامج وتطبع مكدس ال goroutine، ورمز الخروج 2، وهي مخصصة للأخطاء التي لا يمكن التعافي منها وليست للأخطاء العادية مثل ملف إعداد ناقص.

والصورة التالية تبين ما يحدث داخل الخطأ المغلف في حالة الملف المقفل:

رسالة الخطأ read config: open /tmp/cfg/locked.conf: permission denied في الأعلى، وتحتها ثلاثة صناديق متداخلة: الخارجي fmt.wrapError من fmt.Errorf مع %w، ثم fs.PathError فيه Op open والمسار، ثم الداخلي syscall.Errno بقيمة EACCES 13، وتربطها أسهم Unwrap، وعلى اليمين صندوق errors.Is(err, fs.ErrPermission) مع علامة صح يشير إلى الصندوق الداخلي
كل طبقة تغلف الخطأ الذي تحتها، والفحص يتم على الأنواع داخل السلسلة وليس على النص المطبوع

وتشرح تدوينة Go عن الأخطاء في Go 1.13 هذه الآلية، فعندما يحتوي fmt.Errorf على الفعل %w فإن الخطأ الناتج له دالة Unwrap ترجع الخطأ الأصلي، والدالتان errors.Is وerrors.As في الحزمة errors تمران على هذه السلسلة كلها. ولاحظ أنك لو كتبت %v بدلاً من %w لبقي النص المطبوع كما هو تماماً، ولكن السلسلة تنقطع ويرجع errors.Is القيمة false، وهذا من الأخطاء التي لا تراها بعينك في السجل.

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

package main

import (
	"fmt"
	"os"
)

func main() {
	// err is declared and never checked: the compiler refuses unused variables.
	data, err := os.ReadFile("/tmp/cfg/missing.conf")
	fmt.Println(len(data))
}
$ go build ./unused
# labconfig/unused
unused/main.go:10:8: declared and not used: err
exit code 1

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

وقد يتساءل البعض: ألم يشتك مطورو Go من تكرار if err != nil؟ والإجابة نعم، وهذه من أقدم الشكاوى على اللغة، وقد ناقش فريق Go عدة اقتراحات لتغيير الصياغة، ثم كتب روبرت غريسيمر Robert Griesemer في 3 يونيو 2025 تدوينة [ On | No ] syntactic support for error handling أعلن فيها أن الفريق «will stop pursuing syntactic language changes for error handling» في المستقبل المنظور، وأنه سوف يغلق الاقتراحات الجديدة التي تتعلق بصياغة معالجة الأخطاء، أي أن if err != nil باقية.

Result وOption في Rust، والعلامة ?

يبدأ فصل معالجة الأخطاء في كتاب Rust الرسمي بجملة واضحة: «Rust doesn't have exceptions»، فهناك النوع Result<T, E> للأخطاء التي يمكن التعافي منها، والماكرو panic! للأخطاء التي لا يمكن التعافي منها. والنوع Result إما Ok(value) وإما Err(error)، ولا تستطيع أن تصل إلى القيمة دون أن تقرر ماذا تفعل بالخطأ، وهذا هو المعنى العملي لجملة هارولد أن الخطأ جزء من نوع القيمة المرجعة. ويقابله النوع Option<T> الذي إما Some(value) وإما None، وهو للقيمة التي قد لا توجد دون أن يكون ذلك خطأ، وقد شرحناه في قسم الحماية من Null. والملف main.rs التالي فيه الدالة نفسها مع نوع خطأ خاص بها:

use std::fmt;
use std::fs;
use std::io;
use std::num::ParseIntError;

// One error type for this function, with the cause kept inside.
#[derive(Debug)]
enum ConfigError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "read config: {}", e),
            ConfigError::Parse(e) => write!(f, "parse port: {}", e),
        }
    }
}

impl From<io::Error> for ConfigError {
    fn from(e: io::Error) -> Self { ConfigError::Io(e) }
}
impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self { ConfigError::Parse(e) }
}

// The ? operator returns early with the error, converted by From.
fn read_port(path: &str) -> Result<u16, ConfigError> {
    let text = fs::read_to_string(path)?;
    let port = text.trim().parse::<u16>()?;
    Ok(port)
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    match args[1].as_str() {
        "handle" => {
            for p in ["/tmp/cfg/good.conf", "/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"] {
                match read_port(p) {
                    Ok(port) => println!("{} -> port {}", p, port),
                    Err(ConfigError::Io(e)) if e.kind() == io::ErrorKind::NotFound => println!("{} -> not found: {}", p, e),
                    Err(ConfigError::Io(e)) if e.kind() == io::ErrorKind::PermissionDenied => println!("{} -> permission denied: {}", p, e),
                    Err(e) => println!("{} -> {}", p, e),
                }
            }
        }
        "ignore" => {
            // The mistakes: unwrap_or hides the reason, and a bare call drops the Result.
            for p in ["/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"] {
                println!("{} -> port {}", p, read_port(p).unwrap_or(0));
            }
            read_port("/tmp/cfg/missing.conf");
        }
        _ => {
            let port = read_port(&args[2]).unwrap();
            println!("port {}", port);
        }
    }
}

ونترجمه بـ rustc مباشرة دون Cargo، والمخرج سوف يكون كما يلي:

$ rustc -O -o config main.rs
warning: unused `Result` that must be used
  --> /lab/rust/main.rs:54:13
   |
54 |             read_port("/tmp/cfg/missing.conf");
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
   |
54 |             let _ = read_port("/tmp/cfg/missing.conf");
   |             +++++++

warning: 1 warning emitted

exit code 0

$ ./config handle
/tmp/cfg/good.conf -> port 8080
/tmp/cfg/missing.conf -> not found: No such file or directory (os error 2)
/tmp/cfg/bad.conf -> parse port: invalid digit found in string
/tmp/cfg/locked.conf -> permission denied: Permission denied (os error 13)

$ ./config ignore
/tmp/cfg/missing.conf -> port 0
/tmp/cfg/bad.conf -> port 0
/tmp/cfg/locked.conf -> port 0

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

  • المترجم حذرنا من السطر الذي استدعى read_port وترك ال Result دون استخدام، لأن النوع Result معلم بـ #[must_use] كما تذكر وثائق المكتبة القياسية، وهذا تحذير وليس خطأ، فالبرنامج ترجم ورمز الخروج 0.
  • والتحذير نفسه يقترح let _ = ... إذا كنت تريد التجاهل فعلاً، وهي الفكرة نفسها التي رأيناها في Go: التجاهل ممكن، ولكنه يكتب صراحة فيراه من يراجع الكود.
  • الدالة unwrap_or(0) أعطتنا المنفذ 0 في الحالات الثلاث بصمت، فلا توجد لغة تمنعك من اختيار قيمة افتراضية خاطئة، والفرق أن الاختيار مكتوب في السطر نفسه.

والصورة التالية تبين ما تفعله العلامة ? في الدالة read_port:

ملف يدخل إلى fs::read_to_string(path)? ثم يمر بسهم أخضر Ok(text) إلى text.trim().parse::<u16>()? ثم بسهم أخضر Ok(port) إلى Ok(8080)، ومن كل خطوة سهم أحمر للأسفل Err(io::Error) وErr(ParseIntError) يمر على From ليصبح ConfigError::Io أو ConfigError::Parse ثم يتجه إلى return Err
مع Ok تكمل الدالة، ومع Err يتحول الخطأ بـ From إلى نوع الدالة ويخرج منها فوراً

ويشرح الكتاب الرسمي أن ? تعمل تقريباً مثل match، فإذا كانت القيمة Ok أخذت ما بداخلها وأكملت، وإذا كانت Err أرجعت الخطأ من الدالة كلها، بعد أن تحوله إلى نوع الخطأ الذي تعلنه الدالة عبر From. وبهذا تحصل على قصر كود الاستثناءات مع وضوح القيمة المرجعة: كل نقطة قد تفشل عليها علامة ? تراها بعينك.

أما panic! فهي للحالة التي لا معنى للاستمرار بعدها، والدالة unwrap تحول أي Err إلى panic، والمثال التالي يبين ذلك:

$ ./config unwrap /tmp/cfg/locked.conf

thread 'main' (31) panicked at /lab/rust/main.rs:57:44:
called `Result::unwrap()` on an `Err` value: Io(Os { code: 13, kind: PermissionDenied, message: "Permission denied" })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit code 101

$ RUST_BACKTRACE=1 ./config unwrap /tmp/cfg/bad.conf

thread 'main' (32) panicked at /lab/rust/main.rs:57:44:
called `Result::unwrap()` on an `Err` value: Parse(ParseIntError { kind: InvalidDigit })
stack backtrace:
   0: __rustc::rust_begin_unwind
             at /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/std/src/panicking.rs:679:5
   1: core::panicking::panic_fmt
             at /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/core/src/panicking.rs:80:14
   2: core::result::unwrap_failed
             at /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/core/src/result.rs:1871:5
   3: main::main
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
exit code 101

ولاحظ أن Rust لا تطبع المكدس Backtrace افتراضياً، وإنما تطلب منك المتغير RUST_BACKTRACE=1، ورمز الخروج عند panic هو 101. ويناقش فصل To panic! or Not to panic! متى يكون panic مقبولاً: في الأمثلة والنماذج الأولية والاختبارات، أو عندما تعرف أن الحالة مستحيلة منطقياً. أما ملف إعداد ناقص في برنامج حقيقي فليس حالة مستحيلة، ولهذا فإن unwrap هنا خطأ ينتظر أن يقع على السيرفر.

رموز الخطأ في C والمتغير errno

قبل كل ذلك كانت C، وفيها لا توجد استثناءات ولا نوع للخطأ، فالدالة ترجع قيمة خاصة مثل -1 أو NULL لتقول إنها فشلت، ثم تضع سبب الفشل في متغير عام اسمه errno، كما تصفه مواصفة POSIX. والمثال التالي يبين ذلك بالدالة نفسها:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Returns 0 on success and writes the port; returns -1 and leaves errno set on failure. */
int read_port(const char *path, long *port) {
    FILE *f = fopen(path, "r");
    if (f == NULL) return -1;                 /* errno: ENOENT or EACCES */
    char buf[32] = {0};
    fgets(buf, sizeof buf, f);
    fclose(f);
    char *end;
    errno = 0;
    *port = strtol(buf, &end, 10);
    if (end == buf || (*end != '\n' && *end != '\0')) { errno = EINVAL; return -1; }
    return 0;
}

int main(void) {
    const char *paths[] = {"/tmp/cfg/good.conf", "/tmp/cfg/missing.conf", "/tmp/cfg/bad.conf", "/tmp/cfg/locked.conf"};
    for (int i = 0; i < 4; i++) {
        long port = 0;
        if (read_port(paths[i], &port) != 0)
            printf("%s -> error %d: %s\n", paths[i], errno, strerror(errno));
        else
            printf("%s -> port %ld\n", paths[i], port);
    }
    /* The mistake: the return value is ignored and port keeps whatever it had. */
    long port = 0;
    read_port("/tmp/cfg/missing.conf", &port);
    printf("ignored -> port %ld\n", port);
    return 0;
}

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

$ gcc -Wall -o config config.c
exit code 0

$ ./config
/tmp/cfg/good.conf -> port 8080
/tmp/cfg/missing.conf -> error 2: No such file or directory
/tmp/cfg/bad.conf -> error 22: Invalid argument
/tmp/cfg/locked.conf -> error 13: Permission denied
ignored -> port 0

ولاحظ أن الرموز 2 و13 هي نفسها التي رأيناها في Python داخل [Errno 2] و[Errno 13]، لأن كل هذه اللغات تأخذ الخطأ في النهاية من نظام التشغيل نفسه. ولاحظ أيضاً أن المترجم مع الخيار -Wall لم يعترض على السطر الذي تجاهل القيمة المرجعة، وأن errno لا تعني شيئاً إلا إذا فحصتها مباشرة بعد الدالة التي فشلت، لأن أي استدعاء بعدها قد يغيرها. وهذا الضعف هو بالضبط ما وصفه إيكل في مقاله عندما قال إن أحد أهم أسباب ظهور الاستثناءات في C++ هو نموذج الأخطاء الضعيف الذي جاءت به C، والذي يتجاهله أغلب الناس.

ماذا يحدث إذا تجاهلت الخطأ؟

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

اللغةطريقة التجاهل في المختبرهل نبه المترجم أو الأداة؟النتيجة
Javacatch (Exception e) {}لا، والمترجم يقبلهاالمنفذ 0 بصمت
Javaلا catch ولا throwsنعم، خطأ ترجمة للاستثناء المفحوص فقطلا يترجم البرنامج
Pythonexcept Exception: passلاالمنفذ 0 بصمت
Pythonلا يوجد tryلا، ولكن الاستثناء يظهر عند التشغيلTraceback ورمز خروج 1
Goport, _ := readPort(p)لا، وgo vet لم يعترضالمنفذ 0 بصمت
Goمتغير err غير مستخدمنعم، خطأ ترجمةلا يترجم البرنامج
Rustاستدعاء دون استخدام ال Resultنعم، تحذير unused_must_useيترجم مع تحذير
Rustunwrap_or(0)لا، فهو اختيار صريحالمنفذ 0 بصمت
Cتجاهل القيمة المرجعةلا، حتى مع -Wallالمنفذ 0 بصمت

والخلاصة من الجدول أن كل اللغات تسمح لك بابتلاع الخطأ إذا أردت، والفرق الحقيقي بينها في أمرين: هل النسيان ممكن دون أن تكتب شيئاً، وهل يظهر التجاهل في الكود لمن يراجعه. ففي C تنسى دون أن تكتب أي شيء، وفي Java المفحوصة وGo وRust يجب أن تكتب شيئاً لتتجاهل، مثل {} أو _ أو let _، وهذا الشيء الصغير هو ما يمسكه المراجع في Code Review.

كم يكلف رمي الاستثناء؟ قياس توضيحي

يقال كثيراً إن الاستثناءات بطيئة، والقياس التالي يبين لماذا يقال ذلك ومتى يهم. وفي كل لغة كتبنا دالة تنزل عشرة مستويات Recursion ثم تفشل، مرة برمي استثناء ومرة بإرجاع قيمة خطأ، وقسنا متوسط الزمن للعملية الواحدة. وهذه أرقام توضيحية Illustrative فقط، على جهاز واحد بمعالج Intel Core i7-7700K داخل Docker Desktop على WSL2، دون أدوات قياس دقيقة مثل JMH، وجهاز التطوير نفسه كانت عليه أعمال أخرى، ولذلك تجد في الجدول تشغيلين متتاليين، وتغيرت الأرقام بينهما حتى الضعف، ولكن الترتيب بقي كما هو.

وهذا كود Java المستخدم، وفيه ثلاث حالات: استثناء جديد في كل مرة، واستثناء واحد معاد الاستخدام دون بناء Stack Trace جديد، وقيمة مرجعة:

// Illustrative only: cost of failing with an exception versus returning an error value,
// 10 frames deep, on one machine. Not a rigorous benchmark (no JMH).
public class Bench {
    static final int DEPTH = 10;
    static final int N = 2_000_000;

    static int failByThrow(int d) {
        if (d == 0) throw new IllegalStateException("bad number");
        return failByThrow(d - 1) + 1;
    }

    static int failByReturn(int d) {
        if (d == 0) return -1;
        int r = failByReturn(d - 1);
        return r < 0 ? r : r + 1;
    }

    static final IllegalStateException SHARED = new IllegalStateException("bad number");
    static int failByThrowNoTrace(int d) {
        if (d == 0) throw SHARED;
        return failByThrowNoTrace(d - 1) + 1;
    }

    public static void main(String[] args) {
        long sink = 0;
        for (int round = 0; round < 3; round++) {
            long t0 = System.nanoTime();
            for (int i = 0; i < N; i++) { try { sink += failByThrow(DEPTH); } catch (IllegalStateException e) { sink--; } }
            long t1 = System.nanoTime();
            for (int i = 0; i < N; i++) { try { sink += failByThrowNoTrace(DEPTH); } catch (IllegalStateException e) { sink--; } }
            long t2 = System.nanoTime();
            for (int i = 0; i < N; i++) { sink += failByReturn(DEPTH); }
            long t3 = System.nanoTime();
            if (round == 2) {
                System.out.printf("throw (new exception, stack trace): %6.1f ns/op%n", (t1 - t0) / (double) N);
                System.out.printf("throw (reused exception, no trace): %6.1f ns/op%n", (t2 - t1) / (double) N);
                System.out.printf("return error value:                 %6.1f ns/op%n", (t3 - t2) / (double) N);
            }
        }
        System.out.println("sink " + sink);
    }
}

وفي Go استخدمنا أداة القياس المدمجة go test -bench، بثلاث حالات: إرجاع الخطأ نفسه، وإرجاعه مع التغليف بـ fmt.Errorf في كل مستوى، وpanic مع recover:

package main

// Illustrative only: returning an error versus panic and recover, 10 frames deep.
// Run with: go test -bench . -benchmem

import (
	"errors"
	"fmt"
	"testing"
)

const depth = 10

var errBad = errors.New("bad number")

func failByReturn(d int) (int, error) {
	if d == 0 {
		return 0, errBad
	}
	r, err := failByReturn(d - 1)
	if err != nil {
		return 0, err
	}
	return r + 1, nil
}

func failByWrap(d int) (int, error) {
	if d == 0 {
		return 0, errBad
	}
	r, err := failByWrap(d - 1)
	if err != nil {
		return 0, fmt.Errorf("level %d: %w", d, err)
	}
	return r + 1, nil
}

func failByPanic(d int) int {
	if d == 0 {
		panic(errBad)
	}
	return failByPanic(d-1) + 1
}

func callWithRecover() (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = r.(error)
		}
	}()
	failByPanic(depth)
	return nil
}

var sink error

func BenchmarkReturnError(b *testing.B) {
	for b.Loop() {
		_, sink = failByReturn(depth)
	}
}

func BenchmarkReturnWrappedError(b *testing.B) {
	for b.Loop() {
		_, sink = failByWrap(depth)
	}
}

func BenchmarkPanicRecover(b *testing.B) {
	for b.Loop() {
		sink = callWithRecover()
	}
}

أما كود Python فهو الفكرة نفسها بـ raise مقابل إرجاع None، ومن المخرجات التالية بنينا الجدول:

$ java Bench.java                       (run 2)
throw (new exception, stack trace): 2070.8 ns/op
throw (reused exception, no trace):  620.8 ns/op
return error value:                   16.6 ns/op

$ python3 bench.py                      (run 2)
raise exception:      3205.8 ns/op
return error value:   1257.3 ns/op

$ go test -run xxx -bench . -benchmem   (run 2)
BenchmarkReturnError-8          	56303096	        21.78 ns/op	       0 B/op	       0 allocs/op
BenchmarkReturnWrappedError-8   	  387386	      2996 ns/op	     984 B/op	      20 allocs/op
BenchmarkPanicRecover-8         	 1683826	       714.8 ns/op	       0 B/op	       0 allocs/op
اللغةالحالةالتشغيل 1 (ns لكل فشل)التشغيل 2 (ns لكل فشل)
Javaاستثناء جديد مع Stack Trace40222071
Javaاستثناء معاد الاستخدام865621
Javaقيمة مرجعة12.316.6
Pythonraise57893206
Pythonإرجاع None19991257
Goإرجاع error30.221.8
Goإرجاع مع fmt.Errorf في كل مستوى100182996
Gopanic وrecover736715

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

  • في Java كان رمي استثناء جديد أبطأ من القيمة المرجعة بأكثر من مئة ضعف، وأغلب التكلفة في بناء ال Stack Trace، والدليل أن الاستثناء المعاد استخدامه كان أسرع بكثير، ولكن لا تقم بإعادة استخدام الاستثناءات في كودك لهذا السبب، لأنك تخسر ال Stack Trace الذي يخبرك أين وقع الخطأ.
  • في Python الفرق أصغر نسبياً، أقل من ثلاثة أضعاف، والسبب أن استدعاء الدوال نفسه مكلف في Python، فالإرجاع العادي عبر عشرة مستويات أخذ أكثر من ألف نانوثانية.
  • في Go كان التغليف بـ fmt.Errorf في كل مستوى أبطأ من panic نفسه، مع 20 عملية حجز ذاكرة Allocation، لأن كل مستوى يبني نصاً جديداً، أي أن القيمة المرجعة ليست مجانية عندما تضيف السياق، والتكلفة الحقيقية في النص والحجز وليست في الآلية.
  • والأهم أن كل هذه الأرقام بالنانوثانية وتحدث فقط عندما يقع الخطأ، فقراءة ملف الإعداد نفسها من القرص أبطأ من أي رقم في الجدول، ولذلك لا تختر طريقة معالجة الأخطاء بسبب الأداء، إلا إذا كان الخطأ يحدث في مسار ساخن Hot Path آلاف المرات في الثانية، مثل التحقق من مدخلات في حلقة كبيرة، وهنا تكون القيمة المرجعة أنسب.

أين تناسب كل طريقة؟

الطريقةهل يستطيع المستدعي تجاهل الخطأ؟القراءة Readabilityالأداء Performance عند الفشلأين تناسب
استثناء غير مفحوص (Python وC# وKotlin)نعم، بالنسيان، ولكنه يظهر عند التشغيلالكود الأساسي نظيف، ونقاط الفشل مخفيةمكلف نسبياً بسبب ال Stack Traceالتطبيقات التي لها معالج مركزي، مثل تطبيقات الويب
استثناء مفحوص (Java)لا بالنسيان، ولكن بكتلة فارغة أو throws Exceptionتوقيع الدالة يقول ما قد يفشل، والكود أطولمثل غير المفحوصالمكتبات التي يجب أن يعرف مستخدمها أخطاء البيئة
قيمة خطأ (Go)نعم بالعلامة _، وهي ظاهرةكل نقطة فشل مكتوبة، وتكرار if err != nilرخيص، إلا مع تغليف كثيرالخدمات والأدوات التي تريد التحكم في كل خطأ
Result (Rust)بتحذير من المترجم أو باختيار صريحقصير مع ? وواضحرخيصالأنظمة والمكتبات التي تريد أن يفرض النوع المعالجة
رمز خطأ وerrno (C)نعم، بالنسيان ودون أي أثرتفاصيل كثيرة يدويةالأرخصواجهات نظام التشغيل والكود القديم

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

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

  • ابتلاع الخطأ Swallowing: كتلة catch فارغة، أو except: pass، أو _ مكان الخطأ في Go، كما رأينا في المختبر، والنتيجة برنامج يعمل بمنفذ 0 ولا أحد يعرف السبب. لا تقم بذلك أبداً، وإذا كان التجاهل مقصوداً فعلاً فاكتب تعليقاً يشرح لماذا.
  • الالتقاط العام Catch-All: catch (Exception e) في منتصف الكود يلتقط أخطاء لم تتوقعها، مثل NullPointerException من خطأ برمجي، ويعاملها كأنها «الملف غير موجود». التقط النوع المحدد الذي تعرف ماذا تفعل به، واترك الباقي يصعد إلى المعالج المركزي.
  • ضياع السبب أو ال Stack Trace: في Java عندما تكتب throw new ConfigException("bad config") دون أن تمرر الاستثناء الأصلي كسبب Cause، وفي C# عندما تكتب throw ex بدلاً من throw، وفي Go عندما تكتب %v بدلاً من %w، وفي Python عندما تكتب log.error(str(e)). في كل هذه الحالات تبقى رسالة ويضيع ما يخبرك أين وقع الخطأ ولماذا.
  • التسجيل ثم إعادة الرمي في كل مستوى: كل طبقة تسجل الخطأ ثم ترميه، فتجد الخطأ نفسه ثلاث مرات في السجل وتظن أن عندك ثلاث مشاكل. سجل الخطأ مرة واحدة في المكان الذي يقرر ماذا يفعل به، كما شرحنا في قسم تسجيل الاستثناءات مرة واحدة في مقال السجلات.
  • الاستثناءات للتحكم في سير البرنامج Control Flow: مثل أن ترمي استثناء لتخرج من حلقة، أو لتعيد نتيجة عادية مثل «المستخدم غير موجود» آلاف المرات في الثانية. وتنصح إرشادات Microsoft بأن تتعامل مع الحالات الشائعة دون استثناءات، مثل نمط TryParse، والسبب أن الاستثناء أبطأ كما رأينا في القياس، وأنه يخفي المسار الطبيعي للكود. ولاحظ أن Python استثناء من هذه القاعدة بأسلوب EAFP، فاستخدام try للحالات المتوقعة فيها أمر عادي.
  • تسريب الأخطاء الداخلية لمستخدم ال API: أن يصل ال Stack Trace أو رسالة قاعدة البيانات إلى الرد، فيعرف المهاجم أسماء الكلاسات والمكتبات وإصداراتها. أرجع رسالة عامة بمعرف Error ID، والتفاصيل في السجل، كما في قسم معالجة الأخطاء في مقال البرمجة الآمنة، واستخدم صيغة موحدة للأخطاء مثل Problem Details في مقال تصميم واجهات API، وراجع أخطاء الإعداد الأمني في مقال أمن واجهات API.
  • panic أو unwrap للأخطاء العادية: في Go وRust الخطأ المتوقع، مثل ملف إعداد ناقص، يرجع كقيمة، وpanic للحالات المستحيلة فقط، لأن panic في سيرفر يعني توقف الخدمة كلها بسبب ملف واحد.

تدريب عملي

  • غير %w إلى %v في السطر الأول من الدالة readPort بلغة Go، ثم شغل ./config handle مرة أخرى، ولاحظ أن الرسالة المطبوعة لم تتغير ولكن الحالتين «not found» و«permission denied» صارتا «other error».
  • في Java أضف إلى readPort فحصاً يرمي استثناء إذا كان المنفذ خارج المدى 1 إلى 65535، وقرر: هل يكون مفحوصاً أم غير مفحوص؟ وطبق قاعدة Oracle التي ذكرناها لتبرر اختيارك.

الخلاصة

  • كتب بروس إيكل أن الاستثناءات المفحوصة في Java تدفع المطورين إلى ابتلاع الأخطاء، ورد إليوت رستي هارولد في 20 أبريل 2010 بأن المترجم يقبل throws بدلاً من catch، وأن الاستثناء المفحوص «part of the return type».
  • في Java الاستثناءات المفحوصة هي كل ما عدا RuntimeException وError، والمترجم يرفض البرنامج إذا لم تلتقطها أو تعلنها، أما Python وC# وKotlin فكل الاستثناءات فيها غير مفحوصة.
  • لم تأخذ C# بالفكرة بسبب مشكلتي الإصدارات والحجم كما شرح هايلسبرغ في 2003، وKotlin تعاملها كلها كغير مفحوصة رغم أنها تعمل على JVM.
  • في Go الخطأ قيمة ترجعها الدالة، والتغليف بـ %w يحفظ السلسلة لـ errors.Is وerrors.As، وأعلن فريق Go في يونيو 2025 أنه لن يغير صياغة if err != nil.
  • في Rust لا توجد استثناءات، فهناك Result وOption والعلامة ?، والمترجم يحذرك من Result غير مستخدم، وpanic للحالات التي لا يمكن التعافي منها.
  • كل اللغات في المختبر سمحت بابتلاع الخطأ والحصول على منفذ 0 بصمت، والفرق أن بعضها يجبرك على كتابة التجاهل صراحة.
  • رمي الاستثناء أبطأ من إرجاع قيمة بفرق كبير في Java في قياسنا التوضيحي، ولكن التكلفة بالنانوثانية ولا تهم إلا في مسار يتكرر فيه الفشل كثيراً.

وإذا أردت أن تكمل من هنا، فهذه مقالات مرتبطة بالموضوع:

دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.دليلقصص لغات البرمجة ومن صنعها، من Fortran إلى Rustمن صنع اللغات التي نكتب بها كل يوم، وما المشكلة التي دفعته إلى ذلك، وكيف تشرح قصة كل لغة قراراتها.دليلاللغة شيء والمترجم شيء آخر: من يقرر مستقبل لغات البرمجة؟لماذا لا تصح جمل مثل «Python بطيئة»، وما الفرق بين مواصفة اللغة والبرامج التي تطبقها، ومن يقرر مستقبلها.دليلقصة Java من Sun إلى Oracle، وكيف بقيت مفتوحةكيف فتحت Sun كود Java ثم باعتها، وماذا حدث مع Oracle وGoogle في المحاكم، وكيف أصبحت Java تصدر كل ستة أشهر.دليلالبرمجة الآمنة Secure Coding بأمثلة من أربع لغاتكيف تكتب الكود الذي يتعامل مع مدخلات المستخدم والملفات والأخطاء بحيث لا يحتاج إصلاحاً أمنياً لاحقاً، بأمثلة في أربع لغات.دليلالسجلات Logging للمطورين: ماذا تسجل وماذا لا تسجلكيف تكتب سجلات Logs تساعدك على فهم ما حدث في الإنتاج، وما الذي لا يدخل السجل أبداً، وكيف تجمعها في مكان واحد.

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

  • أكتوبر 2026: كتابة المقال اعتماداً على مقال إيكل في أرشيف الإنترنت ورد هارولد ومقابلة هايلسبرغ على Artima ووثائق Java وPython وKotlin وGo وRust وMicrosoft، مع مختبر حقيقي بأربع لغات وC داخل Docker بإصدارات مثبتة، وقياس توضيحي لتكلفة الاستثناءات في Java وPython وGo.
نشرة عرب رووت | ArabRoot

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

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

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

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