Optional وجافا الحديثة

الخطأ بمليار دولار: null

15 دقيقة الدرس 1 من 13

الخطأ بمليار دولار: null

في عام 2009، أعلن السير توني هور — مخترع المرجع الفارغ null — علنًا أنه كان "خطأً بمليار دولار". أُدخل مرجع null إلى لغة ALGOL W عام 1965 لمجرد أنه كان سهل التطبيق. وبعد ستة عقود، لا يزال NullPointerException من أكثر أعطال وقت التشغيل شيوعًا في تطبيقات Java. يشرح هذا الدرس لماذا يُعدّ null خطرًا بنيويًا، وما التكلفة الحقيقية له في كود الإنتاج، ويمهّد الطريق لواجهة برمجة Optional التي قدّمتها Java الحديثة بديلًا عنه.

ماذا يعني null في الواقع؟

في Java، يمكن لكل متغير مرجعي أن يحمل إمّا مرجعًا لكائن أو القيمة الخاصة null، التي تعني "لا يوجد كائن". المشكلة أن نظام الأنواع لا يميّز بين الحالتين. نوع المتغير يقول String، لكن القيمة الفعلية في وقت التشغيل قد تكون غائبة. المترجم لا يستطيع تحذيرك؛ لن تكتشف ذلك إلا حين تحاول الآلة الافتراضية JVM إلغاء الإشارة إلى مؤشر فارغ في وقت التشغيل.

// كلا التصريحين من نوع String — يعاملهما المترجم بالطريقة ذاتها String name = "Alice"; String missing = null; // يُترجَم بنجاح لكنه ينفجر في وقت التشغيل int len = missing.length(); // NullPointerException

كيف يحدث NullPointerException في الواقع

مساحة الخطأ المحتملة ضخمة. أيّ دالة من المفترض أن تُعيد كائنًا يمكنها إعادة null بدلًا من ذلك، وعادةً ما ينسى المستدعون التحقق. انظر إلى سلسلة استدعاءات واقعية في خدمة مستخدمين:

public String getCity(Long userId) { User user = userRepository.findById(userId); // قد يُعيد null Address address = user.getAddress(); // NPE إذا كان user يساوي null return address.getCity(); // NPE إذا كان address يساوي null }

لجعل هذا الكود آمنًا باستخدام فحوصات null الصريحة يجب عليك كتابة:

public String getCity(Long userId) { User user = userRepository.findById(userId); if (user == null) { return "Unknown"; } Address address = user.getAddress(); if (address == null) { return "Unknown"; } return address.getCity(); }

كل طبقة تضيف كودًا دفاعيًا. في قاعدة كود كبيرة يغرق هذا الضوضاء المنطقَ التجاري الحقيقي، ولا يزال يُتجاهَل باستمرار تحت ضغط الوقت.

null ليس إشارة غياب آمنة من ناحية الأنواع. حين تُعيد دالة null، لا تقدّم أي ضمان في وقت الترجمة ولا أي توثيق يُلزم المستدعي بمعالجة حالة الغياب. العقد غير مرئي، والعقوبة على تجاهله هي عطل في وقت التشغيل.

الأسباب الجذرية الثلاثة

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

  1. غياب فحوصات null على نتائج المستودع/الخدمة. كود الوصول إلى البيانات يُعيد null عند "عدم العثور على القيمة" ويفترض المستدعون أن القيمة موجودة دائمًا.
  2. الحقول غير المُهيَّأة. يُصرَّح عن حقل لكن لا يُسنَد في كل مسار منشئ؛ وبحلول الوقت الذي تصل إليه دالة ما، لا يزال null.
  3. تمرير null كوسيط. يُمرّر المستدعي null إلى دالة لم تُصمَّم لقبوله، ويُرمى NPE عميقًا داخل الدالة المستدعاة — بعيدًا عن موضع الاستدعاء — مما يجعل تتبع المكدس صعب التفسير.
// النمط 3 — تمرير null كوسيط؛ NPE يحدث داخل formatName لا في موضع الاستدعاء public String formatName(String first, String last) { return first.trim() + " " + last.trim(); // NPE على first.trim() إذا كان first يساوي null } // المستدعي — الخطأ هنا لكن تتبع المكدس يشير إلى مكان آخر formatName(null, "Salih");

التكلفة الحقيقية في الإنتاج

رقم المليار دولار ليس مبالغة. تشمل التكاليف الملموسة لـ null في أنظمة الإنتاج:

  • أعطال التطبيق. ينتشر NPE غير المعالَج صعودًا في مكدس الاستدعاء، وما لم يُصطاد بمعالج عالمي، يُسقط الطلب الحالي (أو الخيط بأكمله في الكود القديم).
  • تلف البيانات الصامت. فحص null الذي يُعيد قيمة افتراضية (سلسلة فارغة، صفر) يمكنه إخفاء غياب سجل بصمت، مما يُفضي إلى مخرجات تجارية خاطئة أصعب تشخيصًا من العطل.
  • الكود الدفاعي الزائد. كل حدّ API يتطلّب كود حماية من null. تُظهر دراسات قواعد الكود الكبيرة أن فحوصات null قد تُشكّل 10-20% من إجمالي المنطق الشرطي — كود لا قيمة تجارية له، مجرد سقالات أمان.
  • عبء الاختبار. كل مسار قد يستقبل null يستلزم حالة اختبار منفصلة. يتفاقم التفجّر التوافقي مع عمق الدالة.
  • تصميم API رديء. حين يمكن لدالة إعادة null، يجب على كل مستدعٍ قراءة Javadoc (إن وُجد) أو المصدر ليعرف ما إذا كان يحتاج إلى حراسة. لا يعبّر الـ API عن النية.
المشكلة الجوهرية تمثيلية. يملك نظام أنواع Java نوعًا واحدًا لـ "قيمة من النوع T" وصفرًا من الأنواع لـ "قيمة من النوع T قد تكون غائبة". كلاهما يُكتَب T. لا يستطيع نظام الأنواع تمييزهما، فيقع العبء كليًا على انضباط المبرمج — وهو أمر غير موثوق به على النطاق الواسع.

null مقابل الغياب المقصود

ثمة حاجة مشروعة لتمثيل الغياب: مستخدم لم يُقدّم عنوانًا بعد، بحث لم يُعِد نتائج، قيمة إعداد اختيارية. المشكلة ليست في وجود الغياب — بل في كون null تمثيلًا رديئًا له:

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

اللغات المصممة بعد فشل null — Kotlin وSwift وRust وHaskell — كلها اختارت نهجًا مختلفًا: جعل الغياب صريحًا في النوع. String? في Kotlin نوع مختلف عن String؛ لن يسمح لك المترجم باستدعاء دوال على قيمة قابلة للإلغاء دون عامل آمن من null. قدّمت Java 8 الفئة Optional<T> كإجابتها على هذه المشكلة — نوع تغليف يُجبر المستدعين على الاعتراف بحالة الغياب. ذلك ما يتناوله الدرس التالي.

قاعدة عملية جيدة الآن: لا تُعِد null أبدًا من دالة عامة حين يمكنك إعادة مجموعة فارغة أو سلسلة فارغة أو (كما سنتعلم قريبًا) Optional. احتفظ بـ null حصرًا لتفاصيل التنفيذ الداخلية التي لا تتجاوز حدود الدالة — وحتى في ذلك، يُفضَّل استخدام قيمة افتراضية محلية.

حلفاء مفيدون قبل Optional

قبل وجود Optional، طوّرت منظومة Java تخفيفين جزئيين يستحقان المعرفة:

  • تعليمات @Nullable / @NonNull (من Checker Framework أو JetBrains أو JSR-305). تُعلّق هذه التعليمات على المعاملات وأنواع القيم المُعادة ليتمكن التحليل الثابت (IntelliJ وSpotBugs وErrorProne) من الإشارة إلى فحوصات null المفقودة في وقت الترجمة. تُحسّن الأمان لكنها اختيارية ولا تُطبّقها JVM.
  • Objects.requireNonNull() (Java 7). يُرمى NPE وصفي فوريًا إذا كانت القيمة null، مما ينقل الفشل إلى موضع الاستدعاء بدلًا من عمق الدالة المستدعاة. أفضل بكثير من السماح لـ null بالسفر في صمت.
import java.util.Objects; public void setName(String name) { // يفشل مبكرًا برسالة واضحة بدلًا من NPE غامض لاحقًا this.name = Objects.requireNonNull(name, "name must not be null"); }

الخلاصة

وُجدت مراجع null لأنها كانت سهلة الإضافة إلى أنظمة الأنواع المبكرة، لا لأنها تصميم جيد. إنها غير مرئية في توقيع النوع، وتتجاوز فحوصات المترجم، وتُنتج أعطالًا في وقت التشغيل كثيرًا ما تكون بعيدة عن الخطأ الحقيقي. التكلفة في التطبيقات الحقيقية — الأعطال والتلف الصامت وكثرة الكود الدفاعي — كبيرة جدًا. الحلّ الذي قدّمته Java 8 هو Optional<T>: حاوية آمنة من ناحية الأنواع تُجبر على الاعتراف بالغياب على مستوى الـ API. في الدرس التالي ستتعلم تحديدًا كيف يعمل.