البرمجة كائنية التوجه في Dart

الخصائص و Getters و Setters

40 دقيقة الدرس 3 من 8

لماذا Getters و Setters؟

في الدروس السابقة تعلمت عن خصائص الفئة واتفاقية الشرطة السفلية للخصوصية. لكن مجرد جعل حقل خاصاً وكشفه مباشرة ليس كافياً دائماً. غالباً تحتاج:

  • حساب قيم من خصائص أخرى (مثل fullName من firstName و lastName)
  • التحقق من البيانات قبل تعيين خاصية (مثل العمر يجب أن يكون موجباً)
  • الاستجابة للتغييرات عند تحديث خاصية (مثل تسجيل التغيير أو إخطار المستمعين)
  • التحكم في الوصول -- جعل خاصية للقراءة فقط أو للكتابة فقط من الخارج

هنا يأتي دور getters و setters. تبدو مثل الخصائص للعالم الخارجي لكنها تنفذ كوداً خلف الكواليس.

Getters و Setters الضمنية

كل حقل غير خاص في Dart يحصل تلقائياً على getter و setter ضمنيين. عندما تكتب person.name فأنت تستدعي getter. عندما تكتب person.name = 'أحمد' فأنت تستدعي setter.

الضمني مقابل الصريح

class Person {
  // هذان الحقلان لهما getters و setters ضمنية
  String name;
  int age;

  Person(this.name, this.age);
}

void main() {
  var p = Person('أحمد', 25);

  // هذه تستخدم getter الضمني
  print(p.name);  // أحمد
  print(p.age);   // 25

  // هذه تستخدم setter الضمني
  p.name = 'سارة';
  p.age = 30;

  print(p.name);  // سارة
  print(p.age);   // 30
}
ملاحظة: لحقول final ينشئ Dart getter فقط (لا setter) لأن الحقول النهائية لا يمكن إعادة تعيينها بعد التهيئة. هكذا تنشئ خصائص للقراءة فقط على مستوى اللغة.

Getters المخصصة

getter المخصص يُعرّف باستخدام الكلمة المفتاحية get. يبدو مثل خاصية لكنه يحسب قيمته في كل مرة يُوصل إليه. الـ getters ليس لها معاملات وتستخدم صيغة السهم (=>) أو الكتلة ({}).

خصائص محسوبة مع Getters

class Person {
  String firstName;
  String lastName;
  DateTime birthDate;

  Person({
    required this.firstName,
    required this.lastName,
    required this.birthDate,
  });

  // getter محسوب: الاسم الكامل
  String get fullName => '$firstName $lastName';

  // getter محسوب: العمر (يُحسب من تاريخ الميلاد)
  int get age {
    DateTime now = DateTime.now();
    int years = now.year - birthDate.year;
    if (now.month < birthDate.month ||
        (now.month == birthDate.month && now.day < birthDate.day)) {
      years--;
    }
    return years;
  }

  // getter محسوب: الأحرف الأولى
  String get initials => '${firstName[0]}${lastName[0]}';

  // getter محسوب: هل بالغ
  bool get isAdult => age >= 18;

  @override
  String toString() => '$fullName (العمر: $age)';
}

void main() {
  var person = Person(
    firstName: 'أحمد',
    lastName: 'حسن',
    birthDate: DateTime(1995, 6, 15),
  );

  print(person.fullName);   // أحمد حسن
  print(person.age);        // 30 (أو العمر الحالي)
  print(person.initials);   // أح
  print(person.isAdult);    // true

  // Getters تُوصل مثل الخصائص وليس الطرق
  // person.fullName    -- صحيح (getter)
  // person.fullName()  -- خطأ! ليست طريقة
}
Getter مقابل Method: استخدم getter عندما يكون الحساب رخيصاً والقيمة تمثل خاصية للكائن (اسمية: fullName و area و isEmpty). استخدم method عندما يكون الحساب مكلفاً أو له تأثيرات جانبية أو يمثل إجراءً (فعلية: calculate() و fetchData() و save()).

Setters المخصصة

setter المخصص يُعرّف باستخدام الكلمة المفتاحية set. يأخذ معاملاً واحداً بالضبط وينفذ كوداً عند إسناد قيمة. Setters تُستخدم عادة للتحقق أو التحويل أو إطلاق تأثيرات جانبية.

Setters مع التحقق

class Temperature {
  double _celsius;

  Temperature(this._celsius);

  // Getter: قراءة المئوي
  double get celsius => _celsius;

  // Setter: التحقق قبل تعيين المئوي
  set celsius(double value) {
    if (value < -273.15) {
      throw ArgumentError('الحرارة لا يمكن أن تكون أقل من الصفر المطلق (-273.15°C)');
    }
    _celsius = value;
  }

  // Getter: تحويل إلى فهرنهايت
  double get fahrenheit => (_celsius * 9 / 5) + 32;

  // Setter: تعيين عبر فهرنهايت ويُخزن كمئوي
  set fahrenheit(double value) {
    celsius = (value - 32) * 5 / 9;  // يستخدم setter المئوي (مع التحقق!)
  }

  // Getter: تحويل إلى كلفن
  double get kelvin => _celsius + 273.15;

  // Setter: تعيين عبر كلفن
  set kelvin(double value) {
    celsius = value - 273.15;  // يستخدم setter المئوي
  }

  @override
  String toString() =>
      '${celsius.toStringAsFixed(1)}°C / ${fahrenheit.toStringAsFixed(1)}°F / ${kelvin.toStringAsFixed(1)}K';
}

void main() {
  var temp = Temperature(100);
  print(temp);  // 100.0°C / 212.0°F / 373.1K

  // تعيين عبر فهرنهايت
  temp.fahrenheit = 32;
  print(temp);  // 0.0°C / 32.0°F / 273.1K

  // تعيين عبر كلفن
  temp.kelvin = 0;
  print(temp);  // -273.1°C / -459.7°F / 0.0K

  // التحقق يعمل
  try {
    temp.celsius = -300;  // أقل من الصفر المطلق!
  } catch (e) {
    print(e);  // الحرارة لا يمكن أن تكون أقل من الصفر المطلق
  }
}

حقول خاصة مع Getters/Setters عامة

أكثر نمط OOP شيوعاً هو جعل الحقول خاصة (_) وكشفها من خلال getters و setters. هذا يمنحك تحكماً كاملاً في كيفية قراءة وكتابة البيانات.

نمط التغليف

class BankAccount {
  String _owner;
  double _balance;
  List<String> _transactions = [];

  BankAccount({required String owner, double initialBalance = 0})
      : _owner = owner,
        _balance = initialBalance;

  // للقراءة فقط: المالك (getter فقط بدون setter)
  String get owner => _owner;

  // للقراءة فقط: الرصيد
  double get balance => _balance;

  // للقراءة فقط: نسخة من المعاملات (تمنع التعديل الخارجي)
  List<String> get transactions => List.unmodifiable(_transactions);

  // للقراءة فقط: محسوبة
  int get transactionCount => _transactions.length;
  bool get isEmpty => _balance == 0;

  // وصول كتابة متحكم فيه عبر الطرق (ليس setters)
  void deposit(double amount) {
    if (amount <= 0) throw ArgumentError('المبلغ يجب أن يكون موجباً');
    _balance += amount;
    _transactions.add('+\$${amount.toStringAsFixed(2)}');
  }

  void withdraw(double amount) {
    if (amount <= 0) throw ArgumentError('المبلغ يجب أن يكون موجباً');
    if (amount > _balance) throw StateError('رصيد غير كافٍ');
    _balance -= amount;
    _transactions.add('-\$${amount.toStringAsFixed(2)}');
  }

  @override
  String toString() =>
      'حساب($owner, \$${_balance.toStringAsFixed(2)}, ${_transactions.length} معاملات)';
}

void main() {
  var account = BankAccount(owner: 'أحمد', initialBalance: 1000);

  account.deposit(500);
  account.withdraw(200);

  print(account.balance);          // 1300.0
  print(account.owner);            // أحمد
  print(account.transactions);     // [+\$500.00, -\$200.00]
  print(account.transactionCount); // 2

  // لا يمكن التعديل مباشرة:
  // account.balance = 9999;     // خطأ! لا يوجد setter
  // account._balance = 9999;    // خطأ من ملفات أخرى! (خاص)
}
مهم: عند إرجاع قائمة أو خريطة من getter أرجع List.unmodifiable() أو نسخة. إذا أرجعت القائمة الأصلية مباشرة يمكن للكود الخارجي تعديلها وكسر التغليف.

خصائص للقراءة فقط

أحياناً تريد خاصية يمكن قراءتها فقط ولا يمكن تعيينها من الخارج. هناك عدة طرق لتحقيق هذا:

ثلاث طرق لصنع خصائص للقراءة فقط

class Config {
  // الطريقة 1: حقل final (يُعيّن مرة واحدة في المُنشئ، getter ضمني فقط)
  final String appName;

  // الطريقة 2: حقل خاص + getter عام
  double _version;
  double get version => _version;

  // الطريقة 3: getter محسوب (بدون حقل خلفي)
  String get displayName => '$appName v${_version}';

  Config({required this.appName, required double version})
      : _version = version;

  // الطريقة الداخلية لا تزال تستطيع تعديل _version
  void updateVersion(double newVersion) {
    if (newVersion > _version) {
      _version = newVersion;
    }
  }
}

void main() {
  var config = Config(appName: 'تطبيقي', version: 1.0);

  print(config.appName);      // تطبيقي
  print(config.version);      // 1.0
  print(config.displayName);  // تطبيقي v1.0

  // config.appName = 'آخر';   // خطأ! final
  // config.version = 2.0;       // خطأ! لا يوجد setter

  config.updateVersion(2.0);    // حسناً -- طريقة داخلية
  print(config.displayName);    // تطبيقي v2.0
}

الخصائص الثابتة (Static)

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

خصائص وطرق ثابتة

class Counter {
  // خاصية ثابتة: مشتركة بين جميع النسخ
  static int _totalCount = 0;

  // خاصية النسخة: فريدة لكل كائن
  final String name;
  int _count = 0;

  Counter(this.name) {
    _totalCount++;  // زيادة عند إنشاء عداد جديد
  }

  // getter النسخة
  int get count => _count;

  // getter ثابت: الوصول بدون نسخة
  static int get totalCounters => _totalCount;

  void increment() => _count++;

  @override
  String toString() => '$name: $_count';
}

void main() {
  print(Counter.totalCounters);  // 0

  var likes = Counter('الإعجابات');
  var views = Counter('المشاهدات');
  var shares = Counter('المشاركات');

  print(Counter.totalCounters);  // 3

  likes.increment();
  likes.increment();
  views.increment();

  print(likes);   // الإعجابات: 2
  print(views);   // المشاهدات: 1
  print(shares);  // المشاركات: 0
}
ارتباط Flutter: الخصائص الثابتة تُستخدم في كل مكان في Flutter. مثلاً Colors.blue و Icons.home و TextStyle.lerp(). توفر وصولاً مريحاً لقيم محددة مسبقاً دون إنشاء كائنات.

خصائص Late

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

خصائص Late

class UserProfile {
  final String username;

  // late: ستُهيّأ لاحقاً
  late String _bio;
  late DateTime _lastLogin;

  // late + تهيئة كسولة: تُحسب فقط عند الوصول الأول
  late final String greeting = 'مرحباً بعودتك $username!';

  UserProfile(this.username);

  void setBio(String bio) {
    if (bio.length > 500) {
      throw ArgumentError('السيرة يجب أن تكون 500 حرف أو أقل');
    }
    _bio = bio;
  }

  void recordLogin() {
    _lastLogin = DateTime.now();
  }

  String get bio => _bio;
  DateTime get lastLogin => _lastLogin;
}

void main() {
  var profile = UserProfile('ahmed_dev');

  // late final مع تهيئة كسولة
  print(profile.greeting);  // مرحباً بعودتك ahmed_dev!

  // يجب تعيين حقول late قبل قراءتها
  profile.setBio('مطور Flutter من القاهرة');
  profile.recordLogin();

  print(profile.bio);        // مطور Flutter من القاهرة
  print(profile.lastLogin);  // الوقت والتاريخ الحالي

  // قراءة حقل late غير مُهيّأ تطرح LateInitializationError
  var profile2 = UserProfile('sara');
  // print(profile2.bio);  // خطأ! LateInitializationError
}
حذر مع late: إذا قرأت متغير late قبل أن يُسند تطرح Dart LateInitializationError وقت التشغيل. استخدم late فقط عندما تكون متأكداً أن الحقل سيُهيّأ قبل الوصول إليه. إذا كان هناك أي شك استخدم نوعاً nullable (Type?) بدلاً من ذلك.

مثال عملي: منتج تجارة إلكترونية

مثال كامل مع جميع أنواع الخصائص

class Product {
  // حقول خاصة
  String _name;
  double _price;
  int _stock;
  double _discountPercent;

  // خاصية ثابتة
  static int _totalProducts = 0;
  static double _taxRate = 0.15;  // 15% ضريبة

  // حقل final (يُعيّن مرة واحدة)
  final String sku;
  final DateTime createdAt;

  Product({
    required String name,
    required double price,
    required this.sku,
    int stock = 0,
    double discountPercent = 0,
  })  : _name = name,
        _price = price,
        _stock = stock,
        _discountPercent = discountPercent,
        createdAt = DateTime.now() {
    _totalProducts++;
  }

  // --- Getters (وصول القراءة) ---
  String get name => _name;
  double get price => _price;
  int get stock => _stock;

  // getters محسوبة
  double get discountAmount => _price * (_discountPercent / 100);
  double get discountedPrice => _price - discountAmount;
  double get tax => discountedPrice * _taxRate;
  double get finalPrice => discountedPrice + tax;
  bool get inStock => _stock > 0;
  bool get isOnSale => _discountPercent > 0;

  // --- Setters (وصول الكتابة مع التحقق) ---
  set name(String value) {
    if (value.trim().isEmpty) throw ArgumentError('الاسم لا يمكن أن يكون فارغاً');
    _name = value.trim();
  }

  set price(double value) {
    if (value < 0) throw ArgumentError('السعر لا يمكن أن يكون سالباً');
    _price = value;
  }

  set stock(int value) {
    if (value < 0) throw ArgumentError('المخزون لا يمكن أن يكون سالباً');
    _stock = value;
  }

  @override
  String toString() => '$name (SKU: $sku) - \$${finalPrice.toStringAsFixed(2)} | المخزون: $_stock';
}

void main() {
  var laptop = Product(name: 'ماك بوك برو', price: 1999.99, sku: 'MBP-001', stock: 50);
  var phone = Product(name: 'آيفون 15', price: 999.99, sku: 'IPH-015', stock: 100, discountPercent: 10);

  print(laptop);
  print(phone);

  print('إجمالي المنتجات: ${Product.totalProducts}');  // 2

  // التحقق يمنع البيانات السيئة
  try {
    phone.price = -50;  // ArgumentError!
  } catch (e) {
    print(e);  // السعر لا يمكن أن يكون سالباً
  }
}

تمرين عملي

افتح DartPad وابنِ فئة UserSettings: (1) حقول خاصة: _theme (String افتراضي “light”) و _fontSize (double افتراضي 16.0) و _notificationsEnabled (bool افتراضي true) و _language (String افتراضي “en”). (2) أضف getters لجميع الحقول. (3) أضف setter لـ theme يقبل فقط “light” أو “dark” أو “system”. (4) أضف setter لـ fontSize يقبل فقط قيماً بين 10.0 و 32.0. (5) أضف setter لـ language يقبل فقط “en” أو “ar” أو “fr” أو “es”. (6) أضف getter محسوب isRTL يرجع true إذا كانت اللغة “ar”. (7) أضف خاصية ثابتة defaultSettings تُرجع UserSettings جديدة بجميع الافتراضيات. (8) تجاوز toString() واختبر جميع getters و setters والتحقق.