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

البيانات غير القابلة للتغيير وكائنات القيمة

45 دقيقة الدرس 23 من 24

لماذا تهم عدم القابلية للتغيير

الكائن غير القابل للتغيير هو كائن لا يمكن تغيير حالته بعد إنشائه. بمجرد بنائه، يبقى كما هو إلى الأبد. هذه الفكرة البسيطة ظاهرياً لها فوائد عميقة لجودة البرمجيات:

  • أمان الخيوط -- الكائنات غير القابلة للتغيير يمكن مشاركتها عبر العزلات والكود غير المتزامن بدون أقفال أو حالات سباق.
  • القابلية للتنبؤ -- إذا تلقت دالة كائناً غير قابل للتغيير، لا يمكنها تعديله عن طريق الخطأ. لا آثار جانبية.
  • سهولة التخزين المؤقت -- الكائنات غير القابلة للتغيير بنفس القيم قابلة للتبادل، لذا يمكن تخزينها مؤقتاً وإعادة استخدامها.
  • إدارة الحالة -- setState في Flutter و BLoC و Riverpod وأنماط أخرى تعتمد بشدة على كائنات حالة غير قابلة للتغيير. اكتشاف التغيير يعني مقارنة الكائنات غير القابلة للتغيير القديمة بالجديدة.

في هذا الدرس، ستتقن final مقابل const، وتعليق @immutable، ونمط copyWith، وكائنات القيمة -- جميعها أنماط أساسية لكود Dart و Flutter الإنتاجي.

Final مقابل Const

لدى Dart كلمتان مفتاحيتان للقيم التي لا تتغير: final و const. هما مرتبطتان لكن مختلفتان.

شرح Final مقابل Const

void main() {
  // final -- يُعيَّن مرة واحدة وقت التشغيل، لا يمكن إعادة تعيينه
  final String name = 'Alice';
  // name = 'Bob';  // خطأ: المتغير final يمكن تعيينه مرة واحدة فقط

  final DateTime now = DateTime.now();  // جيد: القيمة تُحدد وقت التشغيل
  final List<int> numbers = [1, 2, 3];
  numbers.add(4);  // جيد! final يعني أن المتغير لا يمكن إعادة تعيينه
                    // لكن القائمة نفسها لا تزال قابلة للتغيير

  // const -- ثابت وقت الترجمة، غير قابل للتغيير بعمق
  const double pi = 3.14159;
  const List<int> primes = [2, 3, 5, 7, 11];
  // primes.add(13);  // خطأ: لا يمكن تعديل قائمة const

  // const يتطلب قيم وقت الترجمة
  // const DateTime t = DateTime.now();  // خطأ: ليس ثابت وقت ترجمة

  // كائنات const بنفس القيم متطابقة (نفس النسخة)
  const a = Point(1, 2);
  const b = Point(1, 2);
  print(identical(a, b));  // true -- نفس الكائن في الذاكرة!
}

class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);
}
تمييز أساسي: final يعني “هذا المتغير يمكن تعيينه مرة واحدة فقط” لكن الكائن الذي يحمله لا يزال قابلاً للتغيير. const يعني “هذه القيمة ثابت وقت الترجمة وغير قابلة للتغيير بعمق.” للكائنات غير القابلة للتغيير حقاً، يجب أن تكون جميع الحقول final ويجب أن يكون المُنشئ const.

بناء فئات غير قابلة للتغيير

الفئة غير القابلة للتغيير لديها حقول final فقط، ومُنشئ const، ولا طرق تُعدّل الحالة. تعليق @immutable في Dart (من package:meta أو package:flutter) يساعد في فرض هذا.

نمط الفئة غير القابلة للتغيير

// في Flutter استورد 'package:flutter/foundation.dart' لـ @immutable
// في Dart النقي استورد 'package:meta/meta.dart'

class Money {
  final double amount;
  final String currency;

  const Money(this.amount, this.currency);

  // العمليات ترجع كائنات جديدة -- لا تعدل هذا الكائن أبداً
  Money add(Money other) {
    if (currency != other.currency) {
      throw ArgumentError(
        'لا يمكن جمع $currency و ${other.currency}',
      );
    }
    return Money(amount + other.amount, currency);
  }

  Money subtract(Money other) {
    if (currency != other.currency) {
      throw ArgumentError(
        'لا يمكن طرح $currency و ${other.currency}',
      );
    }
    return Money(amount - other.amount, currency);
  }

  Money multiply(double factor) {
    return Money(amount * factor, currency);
  }

  @override
  bool operator ==(Object other) =>
      other is Money &&
      other.amount == amount &&
      other.currency == currency;

  @override
  int get hashCode => Object.hash(amount, currency);

  @override
  String toString() => '${amount.toStringAsFixed(2)} $currency';
}

void main() {
  const price = Money(29.99, 'USD');
  const tax = Money(2.40, 'USD');

  // add() ترجع كائن Money جديد
  final total = price.add(tax);
  print(total);   // 32.39 USD
  print(price);   // 29.99 USD -- لم يتغير!

  // كائنات const بنفس القيم متطابقة
  const a = Money(10.00, 'USD');
  const b = Money(10.00, 'USD');
  print(a == b);           // true (متساويان)
  print(identical(a, b));  // true (نفس النسخة)
}

نمط copyWith

عندما لديك كائن غير قابل للتغيير وتحتاج نسخة مختلفة قليلاً، استخدم نمط copyWith. هذا ينشئ كائناً جديداً مع تغيير بعض الحقول ونسخ الباقي من الأصل. هذا هو أهم نمط في إدارة حالة Flutter.

نمط copyWith

class UserProfile {
  final String name;
  final String email;
  final int age;
  final String? avatarUrl;
  final bool isVerified;

  const UserProfile({
    required this.name,
    required this.email,
    required this.age,
    this.avatarUrl,
    this.isVerified = false,
  });

  // copyWith -- ترجع كائناً جديداً مع تغيير الحقول المحددة
  UserProfile copyWith({
    String? name,
    String? email,
    int? age,
    String? avatarUrl,
    bool? isVerified,
  }) {
    return UserProfile(
      name: name ?? this.name,
      email: email ?? this.email,
      age: age ?? this.age,
      avatarUrl: avatarUrl ?? this.avatarUrl,
      isVerified: isVerified ?? this.isVerified,
    );
  }

  @override
  String toString() =>
      'UserProfile(name: $name, email: $email, age: $age, '
      'avatar: $avatarUrl, verified: $isVerified)';

  @override
  bool operator ==(Object other) =>
      other is UserProfile &&
      other.name == name &&
      other.email == email &&
      other.age == age &&
      other.avatarUrl == avatarUrl &&
      other.isVerified == isVerified;

  @override
  int get hashCode => Object.hash(name, email, age, avatarUrl, isVerified);
}

void main() {
  const user = UserProfile(
    name: 'أليس',
    email: 'alice@example.com',
    age: 30,
  );

  // تغيير البريد الإلكتروني فقط -- كل شيء آخر يبقى كما هو
  final updatedUser = user.copyWith(email: 'alice@newdomain.com');
  print(updatedUser);
  // UserProfile(name: أليس, email: alice@newdomain.com, age: 30, ...)

  // الأصل لم يتغير
  print(user.email);  // alice@example.com

  // سلسلة تغييرات متعددة
  final verifiedUser = user
      .copyWith(isVerified: true)
      .copyWith(avatarUrl: 'https://example.com/alice.jpg');
  print(verifiedUser.isVerified);  // true
  print(verifiedUser.avatarUrl);   // https://example.com/alice.jpg
}
نصيحة: نمط copyWith يستخدم معاملات nullable مع ?? (دمج null) لتحديد الحقول التي يجب تغييرها. إذا لم يُقدّم معامل (null)، يُحتفظ بالقيمة الأصلية. هذا هو النمط الذي يستخدمه Flutter لـ ThemeData.copyWith() و TextStyle.copyWith() والعديد من فئات الإطار الأخرى.

كائنات القيمة مقابل الكيانات

في التصميم الموجه بالنطاق، تنقسم الكائنات إلى فئتين:

  • كائنات القيمة -- تُعرّف بـسماتها. كائنا قيمة بنفس البيانات يُعتبران متساويين. أمثلة: Money(10, 'USD')، Color(255, 0, 0)، Address('123 Main St').
  • الكيانات -- تُعرّف بـهويتها. كيانان بنفس البيانات ليسا متساويين إذا كانت لهما معرفات مختلفة. أمثلة: User(id: 1)، Order(id: 'ORD-123').

كائن القيمة مقابل الكيان

// كائن قيمة -- المساواة مبنية على السمات
class Address {
  final String street;
  final String city;
  final String zipCode;
  final String country;

  const Address({
    required this.street,
    required this.city,
    required this.zipCode,
    required this.country,
  });

  @override
  bool operator ==(Object other) =>
      other is Address &&
      other.street == street &&
      other.city == city &&
      other.zipCode == zipCode &&
      other.country == country;

  @override
  int get hashCode => Object.hash(street, city, zipCode, country);

  Address copyWith({
    String? street,
    String? city,
    String? zipCode,
    String? country,
  }) => Address(
    street: street ?? this.street,
    city: city ?? this.city,
    zipCode: zipCode ?? this.zipCode,
    country: country ?? this.country,
  );

  @override
  String toString() => '$street, $city $zipCode, $country';
}

// كيان -- المساواة مبنية على الهوية (id)
class Customer {
  final String id;
  final String name;
  final Address address;

  const Customer({
    required this.id,
    required this.name,
    required this.address,
  });

  // المقارنة بـ id فقط -- ليس بالاسم أو العنوان
  @override
  bool operator ==(Object other) =>
      other is Customer && other.id == id;

  @override
  int get hashCode => id.hashCode;

  Customer copyWith({
    String? name,
    Address? address,
  }) => Customer(
    id: id,  // id لا يتغير أبداً
    name: name ?? this.name,
    address: address ?? this.address,
  );
}

void main() {
  // كائنات القيمة: نفس البيانات = متساوية
  const addr1 = Address(street: '123 الرئيسي', city: 'نيويورك', zipCode: '10001', country: 'US');
  const addr2 = Address(street: '123 الرئيسي', city: 'نيويورك', zipCode: '10001', country: 'US');
  print(addr1 == addr2);  // true -- نفس العنوان

  // الكيانات: نفس البيانات، id مختلف = غير متساوية
  final cust1 = Customer(id: 'C001', name: 'أليس', address: addr1);
  final cust2 = Customer(id: 'C002', name: 'أليس', address: addr1);
  print(cust1 == cust2);  // false -- عملاء مختلفون

  // تحديث الكيان يحافظ على الهوية
  final moved = cust1.copyWith(
    address: addr1.copyWith(city: 'بوسطن', zipCode: '02101'),
  );
  print(cust1 == moved);  // true -- نفس العميل (نفس id)
}

مثال عملي: كائنات التكوين

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

نظام تكوين غير قابل للتغيير

class DatabaseConfig {
  final String host;
  final int port;
  final String database;
  final String? username;
  final String? password;
  final int maxConnections;
  final Duration connectionTimeout;

  const DatabaseConfig({
    required this.host,
    this.port = 5432,
    required this.database,
    this.username,
    this.password,
    this.maxConnections = 10,
    this.connectionTimeout = const Duration(seconds: 30),
  });

  // مُنشئات مسماة لتكوينات شائعة
  const DatabaseConfig.localhost(String database)
      : this(host: 'localhost', database: database);

  factory DatabaseConfig.fromMap(Map<String, dynamic> map) {
    return DatabaseConfig(
      host: map['host'] as String,
      port: map['port'] as int? ?? 5432,
      database: map['database'] as String,
      username: map['username'] as String?,
      password: map['password'] as String?,
      maxConnections: map['maxConnections'] as int? ?? 10,
    );
  }

  String get connectionString =>
      'postgresql://${username != null ? "$username@" : ""}$host:$port/$database';

  DatabaseConfig copyWith({
    String? host,
    int? port,
    String? database,
    String? username,
    String? password,
    int? maxConnections,
    Duration? connectionTimeout,
  }) => DatabaseConfig(
    host: host ?? this.host,
    port: port ?? this.port,
    database: database ?? this.database,
    username: username ?? this.username,
    password: password ?? this.password,
    maxConnections: maxConnections ?? this.maxConnections,
    connectionTimeout: connectionTimeout ?? this.connectionTimeout,
  );
}

class AppConfig {
  final String appName;
  final String version;
  final bool debugMode;
  final DatabaseConfig database;
  final Duration sessionTimeout;

  const AppConfig({
    required this.appName,
    required this.version,
    this.debugMode = false,
    required this.database,
    this.sessionTimeout = const Duration(hours: 1),
  });

  // راحة: إنشاء تكوين تطوير
  factory AppConfig.development() => AppConfig(
    appName: 'تطبيقي (تطوير)',
    version: '0.0.1-dev',
    debugMode: true,
    database: DatabaseConfig.localhost('myapp_dev'),
  );

  AppConfig copyWith({
    String? appName,
    String? version,
    bool? debugMode,
    DatabaseConfig? database,
    Duration? sessionTimeout,
  }) => AppConfig(
    appName: appName ?? this.appName,
    version: version ?? this.version,
    debugMode: debugMode ?? this.debugMode,
    database: database ?? this.database,
    sessionTimeout: sessionTimeout ?? this.sessionTimeout,
  );
}

void main() {
  // إنشاء تكوين تطوير
  final devConfig = AppConfig.development();
  print(devConfig.database.connectionString);
  // postgresql://localhost:5432/myapp_dev

  // اشتقاق تكوين إنتاج من التطوير
  final prodConfig = devConfig.copyWith(
    appName: 'تطبيقي',
    version: '1.0.0',
    debugMode: false,
    database: devConfig.database.copyWith(
      host: 'db.production.com',
      database: 'myapp_prod',
      username: 'app_user',
      password: 'secret',
      maxConnections: 50,
    ),
  );
  print(prodConfig.database.connectionString);
  // postgresql://app_user@db.production.com:5432/myapp_prod

  // الأصل لم يتغير
  print(devConfig.debugMode);   // true
  print(prodConfig.debugMode);  // false
}
تحذير: نمط copyWith لديه قيد: لا يمكنك تعيين حقل nullable إلى null لأن null يعني “احتفظ بالأصل.” إذا كنت تحتاج لمسح حقل nullable، فكّر في استخدام قيمة حارسة أو غلاف مثل Optional<T>.
صلة Flutter: تقريباً كل خاصية ودجت في Flutter غير قابلة للتغيير. TextStyle و EdgeInsets و BoxDecoration و ThemeData -- جميعها تستخدم نمط copyWith. إتقان الكائنات غير القابلة للتغيير في Dart النقي يحضرك مباشرة لتطوير Flutter.