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

معالجة الأخطاء والاستثناءات

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

ما هي الاستثناءات؟

الاستثناء هو حدث يعطل التدفق الطبيعي لبرنامجك. عندما يحدث خطأ ما -- مثل القسمة على صفر أو الوصول إلى فهرس غير صالح أو محاولة فتح ملف غير موجود -- ينشئ Dart كائن استثناء يصف المشكلة. إذا لم تتعامل مع (تلتقط) ذلك الاستثناء سيتعطل برنامجك.

فكر في الاستثناءات كأجراس إنذار. عندما يحدث شيء غير متوقع ينطلق الإنذار. يمكنك إما ترك الإنذار يوقف برنامجك بالكامل أو يمكنك اعتراضه والتعامل مع المشكلة والمتابعة.

ملاحظة: يستخدم Dart الاستثناءات (وليس رموز الخطأ) كآليته الأساسية للإبلاغ عن مشاكل وقت التشغيل. هذا مشابه للغات مثل Java و Python و C#.

Try و Catch و Finally

أهم هيكل لمعالجة الأخطاء في Dart هو كتلة try-catch-finally. يتيح لك محاولة تنفيذ كود قد يكون خطيراً والتقاط أي مشاكل وتشغيل كود تنظيف اختيارياً بغض النظر عن حدوث خطأ أم لا.

Try-Catch الأساسي

void main() {
  try {
    int result = 10 ~/ 0; // قسمة عدد صحيح على صفر
    print(result);
  } catch (e) {
    print('حدث خطأ: $e');
  }

  print('البرنامج يستمر في العمل!');
}

// المخرجات:
// حدث خطأ: IntegerDivisionByZeroException
// البرنامج يستمر في العمل!

بدون try-catch كان البرنامج سيتعطل عند سطر القسمة. معه نتعامل مع الخطأ بأناقة ونستمر في التنفيذ.

كتلة finally

كتلة finally تُنفذ دائماً سواء تم طرح استثناء أم لا. هذا مثالي لمهام التنظيف مثل إغلاق الملفات واتصالات قواعد البيانات أو تحرير الموارد.

Try-Catch-Finally

void main() {
  try {
    print('فتح المورد...');
    var result = int.parse('not_a_number');
    print('النتيجة: $result');
  } catch (e) {
    print('خطأ: $e');
  } finally {
    print('تنظيف: إغلاق المورد.');
  }
}

// المخرجات:
// فتح المورد...
// خطأ: FormatException: not_a_number
// تنظيف: إغلاق المورد.
نصيحة احترافية: كتلة finally تعمل حتى لو استخدمت return داخل try أو catch. مضمون تنفيذها مما يجعلها المكان المثالي لكود التنظيف.

التقاط أنواع استثناءات محددة

بدلاً من التقاط جميع الاستثناءات بـ catch عام يمكنك استهداف أنواع استثناءات محددة باستخدام كلمة on. هذا يتيح لك الاستجابة بشكل مختلف لأنواع مختلفة من الأخطاء.

التقاط أنواع محددة مع on

void main() {
  try {
    var value = int.parse('hello');
  } on FormatException {
    print('هذا ليس تنسيق رقم صالح!');
  } on RangeError {
    print('القيمة خارج النطاق!');
  } catch (e) {
    print('حدث خطأ آخر: $e');
  }
}

// المخرجات:
// هذا ليس تنسيق رقم صالح!

كلمة on مقابل كلمة catch

يمنحك Dart آليتين يمكن استخدامهما بشكل منفصل أو معاً:

  • on ExceptionType -- يطابق نوع استثناء محدد لكن لا يمنحك الوصول إلى كائن الاستثناء.
  • catch (e) -- يلتقط أي استثناء ويمنحك كائن الاستثناء e.
  • on ExceptionType catch (e) -- يطابق نوعاً محدداً ويمنحك الكائن أيضاً.

الجمع بين on و catch

void main() {
  try {
    List<int> numbers = [1, 2, 3];
    print(numbers[10]); // فهرس خارج النطاق
  } on RangeError catch (e) {
    print('تم التقاط RangeError!');
    print('الرسالة: ${e.message}');
  }
}

// المخرجات:
// تم التقاط RangeError!
// الرسالة: Index out of range

الوصول إلى تتبع المكدس

يمكنك التقاط تتبع المكدس كمعامل ثانٍ في كتلة catch. يوضح لك تتبع المكدس بالضبط أين حدث الخطأ في كودك.

الالتقاط مع تتبع المكدس

void main() {
  try {
    riskyFunction();
  } catch (e, stackTrace) {
    print('خطأ: $e');
    print('تتبع المكدس:\n$stackTrace');
  }
}

void riskyFunction() {
  throw Exception('حدث خطأ في riskyFunction');
}
ملاحظة: تتبعات المكدس لا تقدر بثمن لتصحيح الأخطاء. في الإنتاج عادةً ما تسجلها بدلاً من عرضها للمستخدمين. تُظهر تسلسل استدعاءات الدوال التي أدت إلى الخطأ.

فئة Exception

فئة Exception في Dart هي الفئة الأساسية لجميع الاستثناءات التي يجب أن تلتقطها البرامج. بعض الاستثناءات المدمجة الشائعة تشمل:

  • FormatException -- يُطرح عندما لا يمكن تحليل نص (مثل int.parse('abc'))
  • IOException -- يُطرح لإخفاقات الإدخال/الإخراج
  • HttpException -- يُطرح للأخطاء المتعلقة بـ HTTP
  • IntegerDivisionByZeroException -- يُطرح عند قسمة عدد صحيح على صفر
  • StateError -- يُطرح عندما يكون الكائن في حالة غير صالحة
  • ArgumentError -- يُطرح عندما تتلقى دالة وسيطاً غير صالح
  • RangeError -- يُطرح عندما يكون الفهرس خارج الحدود

الاستثناءات المدمجة الشائعة

void main() {
  // FormatException
  try {
    int.parse('abc');
  } on FormatException catch (e) {
    print('خطأ تنسيق: $e');
  }

  // RangeError
  try {
    var list = [1, 2, 3];
    print(list[5]);
  } on RangeError catch (e) {
    print('خطأ نطاق: $e');
  }

  // StateError
  try {
    var emptyList = <int>[];
    emptyList.first; // لا يوجد عنصر
  } on StateError catch (e) {
    print('خطأ حالة: $e');
  }
}

طرح الاستثناءات

يمكنك طرح استثناءاتك الخاصة باستخدام كلمة throw. يمكنك طرح أي كائن في Dart لكن من أفضل الممارسات طرح كائنات تنفذ Exception أو Error.

طرح استثناء

double divide(double a, double b) {
  if (b == 0) {
    throw ArgumentError('لا يمكن القسمة على صفر');
  }
  return a / b;
}

void main() {
  try {
    var result = divide(10, 0);
    print(result);
  } on ArgumentError catch (e) {
    print('خطأ: ${e.message}');
  }
}

// المخرجات:
// خطأ: لا يمكن القسمة على صفر

طرح Exception مع رسالة

void validateAge(int age) {
  if (age < 0) {
    throw Exception('العمر لا يمكن أن يكون سالباً: $age');
  }
  if (age > 150) {
    throw Exception('العمر غير واقعي: $age');
  }
  print('عمر صالح: $age');
}

void main() {
  try {
    validateAge(-5);
  } catch (e) {
    print(e); // Exception: العمر لا يمكن أن يكون سالباً: -5
  }
}

إنشاء استثناءات مخصصة

للتطبيقات المعقدة يجب عليك إنشاء فئات استثناءات خاصة بك. هذا يجعل معالجة الأخطاء أكثر دقة ومعنى.

فئات استثناءات مخصصة

class InsufficientFundsException implements Exception {
  final double amount;
  final double balance;

  InsufficientFundsException(this.amount, this.balance);

  @override
  String toString() =>
      'InsufficientFundsException: حاول سحب \$$amount '
      'لكن \$$balance فقط متاح.';
}

class AccountLockedException implements Exception {
  final String reason;
  AccountLockedException(this.reason);

  @override
  String toString() => 'AccountLockedException: $reason';
}

class BankAccount {
  double balance;
  bool isLocked;

  BankAccount(this.balance, {this.isLocked = false});

  void withdraw(double amount) {
    if (isLocked) {
      throw AccountLockedException('الحساب مقفل لأسباب أمنية.');
    }
    if (amount > balance) {
      throw InsufficientFundsException(amount, balance);
    }
    balance -= amount;
    print('تم سحب \$$amount. الرصيد الجديد: \$$balance');
  }
}

void main() {
  var account = BankAccount(100.0);

  try {
    account.withdraw(150.0);
  } on InsufficientFundsException catch (e) {
    print(e);
  } on AccountLockedException catch (e) {
    print(e);
  }
}

// المخرجات:
// InsufficientFundsException: حاول سحب $150.0
// لكن $100.0 فقط متاح.
نصيحة احترافية: استخدم دائماً implements Exception (وليس extends) للاستثناءات المخصصة. أعد تعريف toString() لتوفير رسائل خطأ ذات معنى. أضف حقول بيانات ذات صلة حتى يتمكن الملتقط من فهم ما حدث.

Error مقابل Exception

يفرق Dart بين Error و Exception. فهم الفرق أمر حاسم للتعامل السليم مع الأخطاء:

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

Error مقابل Exception

void main() {
  // هذا Exception -- قابل للاسترداد ويجب التقاطه
  try {
    int.parse('hello');
  } on FormatException {
    print('تم المعالجة: تنسيق إدخال سيئ');
  }

  // هذا Error -- عادةً خطأ برمجي
  // يمكنك التقاطه لكن عموماً لا يجب ذلك
  try {
    List<int> empty = [];
    print(empty[0]); // RangeError (فئة فرعية من Error)
  } on RangeError {
    print('تم التقاط RangeError -- لكن أصلح الكود بدلاً من ذلك!');
  }
}
تحذير: لا تلتقط أنواع Error في كود الإنتاج إلا إذا كان لديك سبب وجيه جداً. أخطاء مثل StackOverflowError و OutOfMemoryError و TypeError تشير إلى أخطاء في كودك يجب إصلاحها وليس إخفاؤها خلف كتل catch.

إعادة طرح الاستثناءات

أحياناً تريد التقاط استثناء لتسجيله أو إجراء معالجة جزئية ثم تمريره لشخص آخر للتعامل معه. استخدم كلمة rethrow لإعادة طرح الاستثناء الأصلي مع تتبع المكدس الأصلي.

استخدام rethrow

void processData(String data) {
  try {
    var number = int.parse(data);
    print('تمت المعالجة: $number');
  } catch (e) {
    print('تسجيل الخطأ: $e');
    rethrow; // تمرير الاستثناء إلى المستدعي
  }
}

void main() {
  try {
    processData('not_a_number');
  } catch (e) {
    print('Main التقط: $e');
  }
}

// المخرجات:
// تسجيل الخطأ: FormatException: not_a_number
// Main التقط: FormatException: not_a_number
ملاحظة: استخدم rethrow بدلاً من throw e. كلمة rethrow تحافظ على تتبع المكدس الأصلي بينما throw e تنشئ واحداً جديداً مما يجعل تصحيح الأخطاء أصعب.

عبارات Assert (وضع التطوير)

عبارة assert هي أداة تطوير تتحقق مما إذا كان الشرط صحيحاً. إذا كان الشرط خاطئاً تطرح AssertionError. التأكيدات تنشط فقط في وضع التصحيح وتُزال تماماً في بنيات الإنتاج.

استخدام assert

void setTemperature(double celsius) {
  // هذا الفحص يعمل فقط في وضع التصحيح
  assert(celsius >= -273.15, 'الحرارة لا يمكن أن تكون أقل من الصفر المطلق!');
  print('تم ضبط الحرارة على $celsius°C');
}

void main() {
  setTemperature(25.0);   // يعمل بشكل جيد
  setTemperature(-300.0); // AssertionError في وضع التصحيح!
}

// في وضع التصحيح:
// تم ضبط الحرارة على 25.0°C
// AssertionError: الحرارة لا يمكن أن تكون أقل من الصفر المطلق!

Assert مع المنشئات

class Rectangle {
  final double width;
  final double height;

  Rectangle(this.width, this.height)
      : assert(width > 0, 'العرض يجب أن يكون موجباً'),
        assert(height > 0, 'الارتفاع يجب أن يكون موجباً');

  double get area => width * height;
}

void main() {
  var rect = Rectangle(5, 10);
  print('المساحة: ${rect.area}'); // المساحة: 50.0

  // في وضع التصحيح هذا سيطرح AssertionError:
  // var invalid = Rectangle(-1, 10);
}
نصيحة احترافية: استخدم assert لالتقاط أخطاء البرمجة أثناء التطوير. للشروط التي يجب فحصها في الإنتاج (مثل مدخلات المستخدم) استخدم عبارات if العادية واطرح استثناءات بدلاً من ذلك.

أنماط معالجة الأخطاء العملية

دعنا نلقي نظرة على بعض الأنماط الواقعية التي ستواجهها كثيراً في تطوير Dart و Flutter.

النمط 1: تحليل مدخلات المستخدم بأمان

التحليل الآمن مع tryParse

void main() {
  String userInput = 'abc';

  // سيئ: يطرح استثناء
  // int number = int.parse(userInput);

  // جيد: يعيد null إذا فشل التحليل
  int? number = int.tryParse(userInput);

  if (number != null) {
    print('تم التحليل: $number');
  } else {
    print('رقم غير صالح: $userInput');
  }
}

// المخرجات:
// رقم غير صالح: abc

النمط 2: معالجة أخطاء العمليات المتعددة

معالجة الأخطاء في العمليات المتسلسلة

class ApiException implements Exception {
  final int statusCode;
  final String message;
  ApiException(this.statusCode, this.message);

  @override
  String toString() => 'ApiException [$statusCode]: $message';
}

class UserService {
  Map<String, dynamic> fetchUser(int id) {
    if (id < 0) {
      throw ArgumentError('معرف المستخدم يجب أن يكون موجباً');
    }
    if (id == 0) {
      throw ApiException(404, 'المستخدم غير موجود');
    }
    return {'id': id, 'name': 'مستخدم $id'};
  }

  void deleteUser(int id) {
    if (id == 1) {
      throw ApiException(403, 'لا يمكن حذف المستخدم المسؤول');
    }
    print('تم حذف المستخدم $id بنجاح');
  }
}

void main() {
  var service = UserService();

  // معالجة أنواع أخطاء مختلفة
  for (var id in [-1, 0, 1, 42]) {
    try {
      var user = service.fetchUser(id);
      print('وُجد: ${user['name']}');
      service.deleteUser(id);
    } on ArgumentError catch (e) {
      print('وسيط غير صالح: ${e.message}');
    } on ApiException catch (e) {
      print('خطأ API: $e');
    } catch (e) {
      print('خطأ غير متوقع: $e');
    }
    print('---');
  }
}

النمط 3: نمط نوع النتيجة (تجنب الاستثناءات)

استخدام فئة Result

class Result<T> {
  final T? value;
  final String? error;
  final bool isSuccess;

  Result.success(this.value)
      : error = null,
        isSuccess = true;

  Result.failure(this.error)
      : value = null,
        isSuccess = false;
}

Result<int> safeDivide(int a, int b) {
  if (b == 0) {
    return Result.failure('لا يمكن القسمة على صفر');
  }
  return Result.success(a ~/ b);
}

void main() {
  var result1 = safeDivide(10, 3);
  if (result1.isSuccess) {
    print('النتيجة: ${result1.value}'); // النتيجة: 3
  }

  var result2 = safeDivide(10, 0);
  if (!result2.isSuccess) {
    print('خطأ: ${result2.error}'); // خطأ: لا يمكن القسمة على صفر
  }
}

متى لا تستخدم الاستثناءات

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

  • الحالات المتوقعة: إذا كانت الحالة طبيعية ومتوقعة (مثل عدم إدخال المستخدم لنص) استخدم فحص if بدلاً من التقاط استثناء.
  • التحكم في التدفق: لا تستخدم الاستثناءات أبداً للتحكم في التدفق الطبيعي لبرنامجك. يجب أن تشير فقط إلى حالات استثنائية حقيقية.
  • الكود الحساس للأداء: طرح والتقاط الاستثناءات له تكلفة أداء. في الحلقات الضيقة استخدم القيم المرجعة أو فحوصات null بدلاً من ذلك.
  • عندما يتوفر tryParse: استخدم int.tryParse() و double.tryParse() والطرق المشابهة بدلاً من التقاط FormatException.

استخدام جيد مقابل سيئ للاستثناءات

// سيئ: استخدام الاستثناءات للتدفق العادي
bool isValidEmailBad(String email) {
  try {
    if (!email.contains('@')) throw FormatException();
    return true;
  } on FormatException {
    return false;
  }
}

// جيد: منطق شرطي بسيط
bool isValidEmailGood(String email) {
  return email.contains('@') && email.contains('.');
}

// سيئ: التقاط استثناءات لـ null المتوقع
String getUserNameBad(Map<String, dynamic> data) {
  try {
    return data['name'] as String;
  } catch (e) {
    return 'غير معروف';
  }
}

// جيد: فحص null-aware
String getUserNameGood(Map<String, dynamic> data) {
  return (data['name'] as String?) ?? 'غير معروف';
}
تحذير: التقاط جميع الاستثناءات بـ catch (e) عام في المستوى الأعلى لتطبيقك يمكن أن يخفي أخطاء حقيقية. كن محدداً قدر الإمكان في عبارات catch واستخدم التقاطاً عاماً فقط كملاذ أخير للأخطاء غير المتوقعة.

ملخص

إليك مرجعاً سريعاً لما غطيناه:

  • try-catch-finally -- الهيكل الأساسي لمعالجة الاستثناءات
  • on -- يلتقط نوع استثناء محدد
  • catch (e, stackTrace) -- يلتقط كائن الاستثناء وتتبع المكدس
  • throw -- يطرح استثناء
  • rethrow -- يعيد طرح الاستثناء الحالي مع الحفاظ على تتبع المكدس
  • الاستثناءات المخصصة -- نفذ Exception لأنواع أخطاء ذات معنى
  • Error مقابل Exception -- الأخطاء هي عيوب برمجية والاستثناءات حالات قابلة للاسترداد
  • assert -- فحوصات شروط للتصحيح فقط تُزال في الإنتاج
  • استخدم tryParse وفحوصات null بدلاً من الاستثناءات عندما يكون ذلك ممكناً
  • لا تستخدم الاستثناءات أبداً للتحكم في التدفق العادي

تمرين عملي

افتح DartPad وابنِ مدقق تسجيل مستخدم بسيط. أنشئ استثناءات مخصصة: InvalidEmailException و WeakPasswordException و UsernameTakenException. اكتب دالة registerUser() تتحقق من البريد الإلكتروني (يجب أن يحتوي على @) وكلمة المرور (يجب أن تكون 8 أحرف على الأقل) واسم المستخدم (لا يمكن أن يكون "admin" أو "root"). استخدم try-catch مع عبارات on محددة للتعامل مع كل نوع استثناء بشكل مختلف وطباعة رسائل خطأ مفهومة للمستخدم. أضف عبارات assert للتحقق من أن أياً من المدخلات ليست نصوصاً فارغة.

اكتمل الدرس!

تهانينا! لقد أكملت جميع الدروس في هذا البرنامج التعليمي.