أساسيات برمجة Dart

أمان القيم الفارغة (Null Safety)

40 دقيقة الدرس 11 من 13

فهم أمان القيم الفارغة في Dart

أخطاء المراجع الفارغة (Null reference errors) هي من أكثر الأخطاء شيوعًا في البرمجة. توني هور، الذي اخترع المرجع الفارغ عام 1965، وصفه بشكل مشهور بأنه "خطأ المليار دولار". نظام أمان القيم الفارغة (Sound Null Safety) في Dart يزيل هذه الفئة الكاملة من الأخطاء في وقت التصريف، مما يجعل كودك أكثر أمانًا وموثوقية.

مفهوم أساسي: مع أمان القيم الفارغة، يميز Dart بين الأنواع التي يمكن أن تحمل قيمة فارغة والأنواع التي لا يمكنها ذلك. بشكل افتراضي، جميع الأنواع غير قابلة للقيم الفارغة. يجب عليك السماح صراحةً بالقيم الفارغة.

ما هي القيمة الفارغة (Null)؟

null تمثل غياب القيمة. قبل أمان القيم الفارغة، كان يمكن لأي متغير في Dart أن يكون null، مما أدى إلى انهيارات أثناء التشغيل عند محاولة الوصول إلى خصائص أو طرق على كائن فارغ.

// قبل أمان القيم الفارغة (Dart القديم) — أي متغير يمكن أن يكون فارغًا
String name = null;  // كان هذا مسموحًا!
print(name.length);  // انهيار! NoSuchMethodError أثناء التشغيل

// مع أمان القيم الفارغة — المصرّف يكتشف هذا
String name = null;  // خطأ في وقت التصريف: لا يمكن تعيين null
print(name.length);  // آمن — مضمون أن name ليس فارغًا

الأنواع القابلة للقيم الفارغة مقابل غير القابلة

في Dart مع أمان القيم الفارغة، كل نوع غير قابل للقيم الفارغة بشكل افتراضي. لجعل نوع قابلاً للقيم الفارغة، أضف لاحقة ?.

// أنواع غير قابلة للقيم الفارغة — لا يمكنها حمل null
String name = 'Alice';
int age = 25;
double height = 5.6;
bool isActive = true;

// name = null;  // خطأ: قيمة من نوع 'Null' لا يمكن تعيينها

// أنواع قابلة للقيم الفارغة — يمكنها حمل null
String? nickname = null;    // حسنًا
int? score = null;          // حسنًا
double? weight;             // القيمة الافتراضية null
bool? hasLicense;           // القيمة الافتراضية null

print(nickname);  // null
print(score);     // null
نصيحة احترافية: فكّر في String و String? كنوعين مختلفين. String مضمون أن يحمل قيمة. String? هو اتحاد بين String و Null. لا يمكنك استخدام String? حيث يُتوقع String بدون التحقق أولاً من القيمة الفارغة.

عامل الوصول الآمن من القيم الفارغة (?.)

عامل ?. يتيح لك الوصول بأمان إلى الخصائص أو الطرق على قيمة قد تكون فارغة. إذا كانت القيمة فارغة، يُقيَّم التعبير بأكمله إلى null بدلاً من إلقاء خطأ.

String? name = 'Alice';
print(name?.length);  // 5

name = null;
print(name?.length);  // null (بدون انهيار!)

// تسلسل الوصول الآمن من القيم الفارغة
class User {
  Address? address;
  User({this.address});
}

class Address {
  String? city;
  Address({this.city});
}

User? user = User(address: Address(city: 'Dubai'));
print(user?.address?.city);        // Dubai
print(user?.address?.city?.length); // 5

user = null;
print(user?.address?.city);  // null (تمت المعالجة بأمان)

عامل الدمج مع القيم الفارغة (??)

عامل ?? يُرجع القيمة اليسرى إذا لم تكن فارغة؛ وإلا يُرجع القيمة اليمنى. هذا مثالي لتوفير قيم افتراضية.

String? input = null;

// بدون عامل ??
String result;
if (input != null) {
  result = input;
} else {
  result = 'default';
}

// مع عامل ?? — نظيف ومختصر
String result = input ?? 'default';
print(result);  // default

// أمثلة عملية
String? username = getUsernameFromDB();
String displayName = username ?? 'Anonymous';

int? savedTheme = loadThemePreference();
int theme = savedTheme ?? 0;  // الافتراضي هو السمة الفاتحة

// تسلسل ?? لعدة بدائل
String? firstName;
String? nickname;
String? email;
String display = firstName ?? nickname ?? email ?? 'Unknown User';

عامل التعيين الآمن من القيم الفارغة (??=)

عامل ??= يعيّن قيمة للمتغير فقط إذا كان المتغير حاليًا فارغًا (null).

int? count;
print(count);   // null

count ??= 0;    // count فارغ، لذا عيّن 0
print(count);   // 0

count ??= 10;   // count ليس فارغًا (هو 0)، لذا لا تفعل شيئًا
print(count);   // 0

// استخدام عملي: التهيئة الكسولة
class Config {
  Map<String, String>? _cache;

  Map<String, String> get cache {
    _cache ??= _loadFromDisk();  // حمّل مرة واحدة فقط
    return _cache!;
  }

  Map<String, String> _loadFromDisk() {
    print('جاري تحميل الإعدادات...');
    return {'theme': 'dark', 'lang': 'en'};
  }
}

عامل تأكيد عدم الفراغ (!)

عامل ! (يُسمى عامل "البانغ") يُخبر المصرّف: "أعرف أن هذه القيمة ليست فارغة." إذا كانت القيمة فارغة فعلاً أثناء التشغيل، فإنه يُلقي استثناءً.

String? name = 'Alice';
String nonNullName = name!;  // حسنًا — name ليس فارغًا
print(nonNullName.length);   // 5

// خطر: استخدام ! على قيمة فارغة
String? nullName = null;
// String crash = nullName!;  // خطأ أثناء التشغيل: Null check operator used on a null value
تحذير: استخدم ! باعتدال! كل عامل ! هو انهيار محتمل أثناء التشغيل. استخدمه فقط عندما تكون متأكدًا 100% أن القيمة ليست فارغة ونظام الأنواع لا يستطيع إثبات ذلك. يُفضّل استخدام فحوصات القيم الفارغة أو ?? أو ?. بدلاً من ذلك.
// سيء: الإفراط في استخدام !
String? name = getName();
print(name!.length);        // قد ينهار!
print(name!.toUpperCase()); // قد ينهار!

// جيد: تحقق مرة واحدة، ثم استخدم بأمان
String? name = getName();
if (name != null) {
  print(name.length);        // آمن — Dart يعرف أن name ليس فارغًا هنا
  print(name.toUpperCase()); // آمن
}

// جيد: وفّر قيمة افتراضية
String name = getName() ?? 'Unknown';
print(name.length);  // آمن

الكلمة المفتاحية late

الكلمة المفتاحية late تُخبر Dart أن متغيرًا غير قابل للقيم الفارغة سيتم تهيئته قبل استخدامه، حتى لو لم تتم تهيئته عند التصريح.

// بدون late — يجب التهيئة فورًا
String name = 'Alice';

// مع late — وعد بالتهيئة قبل الاستخدام
late String name;

void init() {
  name = 'Alice';  // تهيئة لاحقًا
}

void greet() {
  init();
  print('Hello, $name');  // حسنًا — تمت التهيئة قبل الاستخدام
}

// late للحسابات المكلفة (التهيئة الكسولة)
class DataProcessor {
  // لا يتم حسابه حتى الوصول الأول
  late final List<int> processedData = _heavyComputation();

  List<int> _heavyComputation() {
    print('جاري الحساب...');
    return List.generate(1000000, (i) => i * 2);
  }
}

// الحساب يعمل فقط عند الوصول إلى processedData
var processor = DataProcessor();
print('تم إنشاء المعالج');  // لا حساب بعد
print(processor.processedData.length);  // الآن يتم الحساب
مهم: إذا وصلت إلى متغير late قبل تهيئته، يُلقي Dart خطأ LateInitializationError. الكلمة المفتاحية late هي وعد للمصرّف — يجب أن تفي بهذا الوعد.

الكلمة المفتاحية required للمعاملات المسماة

بشكل افتراضي، المعاملات المسماة اختيارية وقابلة للقيم الفارغة. الكلمة المفتاحية required تُجبر المستدعي على تقديم قيمة، مما يجعل المعامل غير قابل للقيم الفارغة.

// بدون required — المعامل اختياري وقابل للقيم الفارغة
void greet({String? name}) {
  print('Hello, ${name ?? "stranger"}');
}
greet();            // Hello, stranger
greet(name: 'Ali'); // Hello, Ali

// مع required — يجب على المستدعي تقديم القيمة
void createUser({
  required String name,
  required String email,
  int age = 0,         // له قيمة افتراضية، لذا ليس مطلوبًا
}) {
  print('User: $name ($email), age: $age');
}

createUser(name: 'Ali', email: 'ali@mail.com');        // حسنًا
// createUser(name: 'Ali');  // خطأ: المعامل المطلوب 'email' مفقود

// في الأصناف — نمط شائع جدًا
class Product {
  final String name;
  final double price;
  final String? description;  // اختياري

  Product({
    required this.name,
    required this.price,
    this.description,
  });
}

ترقية النوع (تحليل التدفق)

تحليل التدفق في Dart ذكي. عندما تتحقق من متغير قابل للقيم الفارغة، يقوم Dart تلقائيًا بـ "ترقية" النوع إلى نوع غير قابل للقيم الفارغة ضمن نطاق ذلك التحقق.

void printLength(String? text) {
  // text هنا من نوع String?

  if (text == null) {
    print('لم يتم تقديم نص');
    return;
  }

  // text يُرقّى تلقائيًا إلى String هنا!
  print(text.length);        // لا حاجة لـ text?.length أو text!.length
  print(text.toUpperCase()); // Dart يعرف أن text ليس فارغًا
}

// يعمل مع أنماط تحقق مختلفة
void processValue(int? value) {
  // النمط 1: تحقق من null مع return
  if (value == null) return;
  print(value + 10);  // value يُرقّى إلى int

  // النمط 2: تحقق != null
  if (value != null) {
    print(value * 2);  // value يُرقّى إلى int داخل الكتلة
  }
}

// ترقية النوع مع فحوصات is
void handleData(Object? data) {
  if (data is String) {
    print(data.length);       // data يُرقّى إلى String
    print(data.toUpperCase());
  } else if (data is int) {
    print(data.isEven);       // data يُرقّى إلى int
  }
}
نصيحة احترافية: ترقية النوع تعمل فقط مع المتغيرات المحلية والمعاملات. لا تعمل مع حقول الأصناف أو المتغيرات العلوية، لأن تلك يمكن تغييرها بواسطة كود آخر بين التحقق من القيمة الفارغة والاستخدام. للحقول، عيّن إلى متغير محلي أولاً.
class MyClass {
  String? name;

  void doSomething() {
    // سيء: الترقية لا تعمل على الحقول
    // if (name != null) {
    //   print(name.length);  // خطأ: لا يزال String?
    // }

    // جيد: عيّن إلى متغير محلي
    final localName = name;
    if (localName != null) {
      print(localName.length);  // حسنًا: localName يُرقّى إلى String
    }
  }
}

المجموعات القابلة للقيم الفارغة

فهم الفرق بين مجموعة قابلة للقيم الفارغة ومجموعة من عناصر قابلة للقيم الفارغة أمر أساسي.

// قائمة قابلة للقيم الفارغة — القائمة نفسها يمكن أن تكون فارغة
List<String>? nullableList = null;
nullableList = ['a', 'b', 'c'];

// قائمة من عناصر قابلة للقيم الفارغة — القائمة موجودة دائمًا، لكن العناصر يمكن أن تكون فارغة
List<String?> listOfNullable = ['a', null, 'c', null];

// كلاهما قابل للقيم الفارغة — القائمة يمكن أن تكون فارغة والعناصر أيضًا
List<String?>? bothNullable = null;
bothNullable = ['a', null, 'c'];

// العمل مع القوائم القابلة للقيم الفارغة
List<String?> names = ['Alice', null, 'Bob', null, 'Charlie'];

// تصفية القيم الفارغة
List<String> validNames = names.whereType<String>().toList();
print(validNames);  // [Alice, Bob, Charlie]

// المعالجة مع التعامل مع القيم الفارغة
for (var name in names) {
  print(name?.toUpperCase() ?? 'UNKNOWN');
}
// ALICE, UNKNOWN, BOB, UNKNOWN, CHARLIE

// خريطة قابلة للقيم الفارغة
Map<String, int?> scores = {
  'Alice': 95,
  'Bob': null,  // Bob لم يخض الاختبار
  'Charlie': 87,
};

scores.forEach((name, score) {
  print('$name: ${score ?? "N/A"}');
});

أنماط عملية للتعامل مع القيم الفارغة

إليك أنماط من العالم الحقيقي ستستخدمها بشكل متكرر عند العمل مع أمان القيم الفارغة في Dart.

// النمط 1: التحليل الآمن
int? tryParseAge(String? input) {
  if (input == null) return null;
  return int.tryParse(input);
}

String ageText = '25';
int age = tryParseAge(ageText) ?? 0;

// النمط 2: استدعاء الطرق المشروطة
List<String>? items;
int count = items?.length ?? 0;
bool isEmpty = items?.isEmpty ?? true;

// النمط 3: التحويل الآمن
Object? data = getDataFromApi();
String? text = data as String?;  // يُرجع null إذا لم يكن data من نوع String

// النمط 4: الرجوع المبكر عند القيمة الفارغة
String formatUser(Map<String, dynamic>? json) {
  if (json == null) return 'لا توجد بيانات';

  String name = json['name'] as String? ?? 'غير معروف';
  int age = json['age'] as int? ?? 0;
  return '$name (العمر $age)';
}

// النمط 5: نمط البناء مع التسلسل الآمن من القيم الفارغة
class QueryBuilder {
  String? _table;
  String? _where;
  int? _limit;

  QueryBuilder table(String t) { _table = t; return this; }
  QueryBuilder where(String w) { _where = w; return this; }
  QueryBuilder limit(int l) { _limit = l; return this; }

  String build() {
    var query = 'SELECT * FROM ${_table ?? "unknown"}';
    if (_where != null) query += ' WHERE $_where';
    if (_limit != null) query += ' LIMIT $_limit';
    return query;
  }
}

// النمط 6: طرق التوسيع للتعامل مع القيم الفارغة
extension NullSafeString on String? {
  bool get isNullOrEmpty => this == null || this!.isEmpty;
  String orDefault(String fallback) => this ?? fallback;
}

String? name;
print(name.isNullOrEmpty);       // true
print(name.orDefault('Guest'));  // Guest

تغيير طريقة تفكيرك نحو أمان القيم الفارغة

الانتقال إلى كود آمن من القيم الفارغة يتطلب تحولًا في كيفية تصميم برامجك.

// التفكير القديم: "أي متغير قد يكون فارغًا، تحقق في كل مكان"
// ignore: avoid_init_to_null
String? name = null;
if (name != null) {
  print(name.length);
}

// التفكير الجديد: "المتغيرات غير فارغة بشكل افتراضي. استخدم ? فقط عندما يكون الفراغ ذا معنى."

// اسأل: هل هذا يحتاج أن يكون فارغًا؟
// نعم → استخدم نوع قابل للقيم الفارغة وتعامل معه
// لا  → استخدم نوع غير قابل للقيم الفارغة

// جيد: المستخدم دائمًا لديه اسم
class User {
  final String name;      // غير فارغ: كل مستخدم يجب أن يكون لديه اسم
  final String? bio;      // قابل للفراغ: السيرة اختيارية
  final DateTime createdAt;
  final DateTime? deletedAt;  // قابل للفراغ: null تعني لم يُحذف

  User({
    required this.name,
    this.bio,
    DateTime? createdAt,
    this.deletedAt,
  }) : createdAt = createdAt ?? DateTime.now();
}

// جيد: معاملات الدالة تعكس النية
double calculateDiscount({
  required double price,           // يجب أن يكون هناك سعر
  required int quantity,           // يجب أن تكون هناك كمية
  String? couponCode,              // قسيمة اختيارية
}) {
  double discount = 0;
  if (couponCode != null) {
    discount = _lookupCoupon(couponCode);  // couponCode يُرقّى
  }
  return price * quantity * (1 - discount);
}

double _lookupCoupon(String code) => code == 'SAVE10' ? 0.1 : 0.0;
نصيحة احترافية: قاعدة جيدة: ابدأ بأنواع غير قابلة للقيم الفارغة. أضف ? فقط عندما يحمل الفراغ معلومة ذات معنى (مثل "لم يُعيَّن بعد"، "غير قابل للتطبيق"، "محذوف"). إذا وجدت نفسك تضيف ? فقط لأنه ليس لديك قيمة بعد، فكّر في استخدام late أو قيمة افتراضية بدلاً من ذلك.

الملخص

الميزةالصيغةالغرض
نوع قابل للفراغType?السماح بالقيم الفارغة
الوصول الآمن?.وصول آمن للخصائص/الطرق
دمج القيم الفارغة??توفير قيمة افتراضية للفراغ
التعيين الآمن??=التعيين فقط إذا كان فارغًا
تأكيد عدم الفراغ!تأكيد أن القيمة ليست فارغة
التهيئة المتأخرةlateتأجيل التهيئة
معامل مطلوبrequiredإجبار المستدعي على تقديم قيمة
ترقية النوعتحليل التدفقترقية تلقائية بعد التحقق من الفراغ

تمرين تطبيقي

أنشئ صنف UserProfile يحتوي على التالي:

  • name (مطلوب، غير قابل للقيم الفارغة)
  • email (مطلوب، غير قابل للقيم الفارغة)
  • phone (اختياري، قابل للقيم الفارغة)
  • bio (اختياري، قابل للقيم الفارغة)

ثم اكتب دالة String formatProfile(UserProfile? profile) تقوم بـ:

  • إرجاع "لا يوجد ملف شخصي متاح" إذا كان profile فارغًا
  • إرجاع نص منسق يحتوي على جميع الحقول المتاحة
  • استخدام ?? لعرض "غير متوفر" للحقول الفارغة
  • استخدام ?. للوصول الآمن إلى الملف الشخصي

اختبر مع ملف شخصي كامل وملف شخصي فارغ.