ميزات Dart المتقدمة

السجلات والصفوف (Dart 3)

45 دقيقة الدرس 6 من 16

ما هي السجلات؟

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

قبل السجلات، إذا أردت إرجاع عدة قيم من دالة، كان لديك ثلاثة خيارات مزعجة: إرجاع List (فقدان أمان الأنواع)، أو إرجاع Map (فقدان البنية)، أو إنشاء فئة مخصصة (الكثير من الكود المتكرر للحالات البسيطة). تحل السجلات المشاكل الثلاث بأناقة.

المشكلة التي تحلها السجلات

// قبل Dart 3: إرجاع عدة قيم كان مزعجاً

// الخيار 1: List (بدون أمان أنواع للعناصر الفردية)
List<dynamic> getMinMax(List<int> numbers) {
  return [numbers.reduce(min), numbers.reduce(max)];
}
// الاستخدام: final result = getMinMax([3, 1, 4]); // result[0]? result[1]?

// الخيار 2: Map (بدون بنية، أنواع نصية)
Map<String, int> getMinMaxMap(List<int> numbers) {
  return {'min': numbers.reduce(min), 'max': numbers.reduce(max)};
}

// الخيار 3: فئة مخصصة (الكثير من المراسم)
class MinMaxResult {
  final int min;
  final int max;
  MinMaxResult(this.min, this.max);
}

// Dart 3: السجلات -- نظيفة، آمنة الأنواع، بدون كود متكرر
(int, int) getMinMaxRecord(List<int> numbers) {
  return (numbers.reduce(min), numbers.reduce(max));
}
// الاستخدام: final (min, max) = getMinMaxRecord([3, 1, 4]);

السجلات الموضعية

أبسط شكل للسجلات يستخدم حقول موضعية. تعرف الأنواع بين أقواس، وتصل للحقول باستخدام $1، $2، إلخ. (مفهرسة من 1، وليس من 0).

إنشاء سجلات موضعية

void main() {
  // سجل بحقلين موضعيين
  (String, int) person = ('Edrees', 28);

  // الوصول للحقول بـ $1، $2، $3، إلخ.
  print(person.$1); // Edrees
  print(person.$2); // 28

  // السجلات يمكنها حمل أنواع مختلفة
  (String, double, bool) product = ('Laptop', 999.99, true);
  print('${product.$1}: \$${product.$2} (in stock: ${product.$3})');
  // Laptop: $999.99 (in stock: true)

  // سجلات العنصر الواحد تحتاج فاصلة في النهاية
  (int,) singleRecord = (42,);
  print(singleRecord.$1); // 42

  // السجلات الفارغة صالحة أيضاً (لكن نادراً ما تكون مفيدة)
  () emptyRecord = ();
}
تحذير: حقول السجلات الموضعية مفهرسة من 1، وليس من 0. الحقل الأول هو $1، والثاني هو $2، وهكذا. هذا مختلف عن القوائم والمصفوفات في معظم اللغات. لاحظ أيضاً الفاصلة في النهاية لسجلات العنصر الواحد -- (42) هو مجرد تعبير بين أقواس، بينما (42,) هو سجل.

السجلات المسماة

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

إنشاء سجلات مسماة

void main() {
  // سجل بحقول مسماة
  ({String name, int age}) person = (name: 'Edrees', age: 28);

  // الوصول بالاسم
  print(person.name); // Edrees
  print(person.age);  // 28

  // ترتيب الحقول المسماة لا يهم عند الإنشاء
  ({String city, String country}) location =
      (country: 'Saudi Arabia', city: 'Riyadh');
  print('${location.city}, ${location.country}');
  // Riyadh, Saudi Arabia

  // مزيج من الحقول الموضعية والمسماة
  (String, {int age, String role}) employee =
      ('Ahmed', age: 30, role: 'Developer');
  print(employee.$1);   // Ahmed (موضعي)
  print(employee.age);  // 30 (مسمى)
  print(employee.role); // Developer (مسمى)
}
نصيحة: فضّل السجلات المسماة على الموضعية عندما لا يكون معنى كل حقل واضحاً من السياق. (double, double) يمكن أن يعني أي شيء -- خط العرض/الطول، العرض/الارتفاع، x/y. لكن ({double latitude, double longitude}) توثق نفسها. استخدم السجلات الموضعية للأزواج البسيطة الواضحة مثل (int, int) للحد الأدنى/الأقصى أو (String, bool) للاسم/نشط.

أنواع السجلات

كل سجل له نوع يحدده أنواع الحقول وأسماؤها ومواضعها. سجلان لهما نفس النوع إذا وفقط إذا كان لهما نفس أنواع الحقول في نفس المواضع بنفس الأسماء.

هوية نوع السجل

void main() {
  // هذه لها نفس النوع: (String, int)
  (String, int) a = ('Hello', 42);
  (String, int) b = ('World', 99);

  // هذه لها أنواع مختلفة
  (String, int) c = ('Hello', 42);     // موضعي
  (int, String) d = (42, 'Hello');     // الترتيب مهم!
  ({String name, int value}) e = (name: 'Hello', value: 42); // مسمى

  // ترتيب الحقول المسماة لا يهم لهوية النوع
  ({int age, String name}) f = (name: 'Test', age: 25);
  ({String name, int age}) g = (name: 'Test', age: 25);
  // f و g لهما نفس النوع -- ترتيب الحقول المسماة غير ذي صلة

  // يمكنك استخدام أسماء مستعارة للأنواع لأنواع السجلات المعقدة
  typedef Coordinate = ({double x, double y});
  typedef UserInfo = (String name, int age, {String email});

  Coordinate point = (x: 10.5, y: 20.3);
  UserInfo user = ('Edrees', 28, email: 'edrees@example.com');
}

مساواة السجلات

السجلات لها مساواة هيكلية مدمجة. سجلان متساويان إذا كان لهما نفس النوع وجميع الحقول المقابلة متساوية. لا تحتاج لتجاوز == أو hashCode -- تعمل تلقائياً.

المساواة الهيكلية

void main() {
  // السجلات الموضعية: متساوية إذا نفس النوع ونفس القيم
  final a = ('Edrees', 28);
  final b = ('Edrees', 28);
  final c = ('Ahmed', 28);

  print(a == b); // true  (نفس القيم)
  print(a == c); // false (اسم مختلف)

  // السجلات المسماة: متساوية إذا نفس النوع ونفس قيم الحقول
  final p1 = (x: 10.0, y: 20.0);
  final p2 = (x: 10.0, y: 20.0);
  final p3 = (x: 10.0, y: 30.0);

  print(p1 == p2); // true
  print(p1 == p3); // false

  // hashCode متسق أيضاً
  print(a.hashCode == b.hashCode); // true

  // هذا يعني أن السجلات تعمل بشكل صحيح في Sets وكمفاتيح Map
  final pointSet = <({double x, double y})>{};
  pointSet.add((x: 1.0, y: 2.0));
  pointSet.add((x: 1.0, y: 2.0)); // مكرر -- لم يُضاف
  pointSet.add((x: 3.0, y: 4.0));
  print(pointSet.length); // 2
}
ملاحظة: مساواة السجلات عميقة، مما يعني أن السجلات المتداخلة تُقارن بشكل تكراري. ((1, 2), (3, 4)) == ((1, 2), (3, 4)) هي true. هذا مختلف عن الفئات، حيث == الافتراضي هو مساواة المرجع.

تفكيك السجلات

التفكيك (يسمى أيضاً فك التغليف) يتيح لك استخراج حقول السجل في متغيرات فردية في عبارة واحدة. هذه واحدة من أقوى ميزات السجلات.

تفكيك السجلات الموضعية

void main() {
  // تفكيك سجل موضعي
  final (name, age) = ('Edrees', 28);
  print('$name is $age years old'); // Edrees is 28 years old

  // تفكيك مع تعليقات الأنواع
  final (String city, int population) = ('Riyadh', 7500000);
  print('$city has $population people');

  // تفكيك في حلقة for
  final points = [(1, 2), (3, 4), (5, 6)];
  for (final (x, y) in points) {
    print('Point: ($x, $y)');
  }

  // استخدم _ لتجاهل الحقول التي لا تحتاجها
  final (_, secondValue) = ('ignored', 42);
  print(secondValue); // 42
}

تفكيك السجلات المسماة

void main() {
  // تفكيك الحقول المسماة
  final (:name, :age) = (name: 'Edrees', age: 28);
  print('$name is $age'); // Edrees is 28

  // إعادة التسمية أثناء التفكيك
  final (name: userName, age: userAge) = (name: 'Ahmed', age: 25);
  print('$userName is $userAge'); // Ahmed is 25

  // مزيج موضعي ومسمى
  final (title, :year, :rating) =
      ('Inception', year: 2010, rating: 8.8);
  print('$title ($year) -- $rating/10');
  // Inception (2010) -- 8.8/10
}

السجلات كأنواع إرجاع

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

دوال تُرجع سجلات

import 'dart:math';

// إرجاع الحد الأدنى والأقصى كسجل موضعي
(int min, int max) findMinMax(List<int> numbers) {
  int minVal = numbers[0];
  int maxVal = numbers[0];
  for (final n in numbers) {
    if (n < minVal) minVal = n;
    if (n > maxVal) maxVal = n;
  }
  return (minVal, maxVal);
}

// إرجاع نتيجة بحث المستخدم بحقول مسماة
({bool found, String? name, int? age}) findUser(int id) {
  // محاكاة بحث في قاعدة البيانات
  if (id == 1) {
    return (found: true, name: 'Edrees', age: 28);
  }
  return (found: false, name: null, age: null);
}

// إرجاع إحداثيات محللة
({double latitude, double longitude}) parseCoordinate(String input) {
  final parts = input.split(',');
  return (
    latitude: double.parse(parts[0].trim()),
    longitude: double.parse(parts[1].trim()),
  );
}

void main() {
  // تفكيك قيمة الإرجاع مباشرة
  final (min, max) = findMinMax([5, 2, 8, 1, 9, 3]);
  print('Min: $min, Max: $max'); // Min: 1, Max: 9

  // استخدام تفكيك مسمى
  final (:found, :name, :age) = findUser(1);
  if (found) {
    print('Found: $name, age $age'); // Found: Edrees, age 28
  }

  // تفكيك الإحداثيات
  final (:latitude, :longitude) = parseCoordinate('24.7136, 46.6753');
  print('Lat: $latitude, Lng: $longitude');
  // Lat: 24.7136, Lng: 46.6753
}

السجلات مقابل الفئات

تخدم السجلات والفئات أغراضاً مختلفة. إليك دليل واضح لمتى تستخدم كل منهما:

استخدم السجلات عندما:

  • تحتاج لإرجاع عدة قيم من دالة
  • البيانات تجميع بسيط قصير العمر
  • تريد مساواة هيكلية بدون كتابة ==/hashCode
  • البيانات ليس لها سلوك (طرق)

استخدم الفئات عندما:

  • البيانات لها سلوك (طرق) مرتبط بها
  • تحتاج الوراثة أو تنفيذ واجهات
  • البيانات تمثل كياناً طويل العمر في النطاق
  • تحتاج التغليف (حقول خاصة)
  • النوع يحتاج اسماً ذا معنى في نموذج نطاقك

قرار السجلات مقابل الفئات

// استخدام جيد للسجل: إرجاع متعدد بسيط
(bool success, String message) validate(String email) {
  if (email.contains('@')) {
    return (true, 'Valid email');
  }
  return (false, 'Invalid email format');
}

// استخدام جيد للفئة: لها سلوك وهوية
class EmailValidator {
  final List<String> _blockedDomains;
  final int _maxLength;

  EmailValidator({
    List<String> blockedDomains = const [],
    int maxLength = 254,
  })  : _blockedDomains = blockedDomains,
        _maxLength = maxLength;

  bool isValid(String email) {
    if (!email.contains('@')) return false;
    if (email.length > _maxLength) return false;
    final domain = email.split('@').last;
    return !_blockedDomains.contains(domain);
  }
}

// استخدام جيد للسجل: زوج إحداثيات
({double x, double y}) translate(
  ({double x, double y}) point,
  double dx,
  double dy,
) {
  return (x: point.x + dx, y: point.y + dy);
}

// استخدام جيد للفئة: إحداثيات مع سلوك
class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);

  double distanceTo(Point other) {
    return sqrt(pow(x - other.x, 2) + pow(y - other.y, 2));
  }

  Point operator +(Point other) => Point(x + other.x, y + other.y);
}
نصيحة: إذا وجدت نفسك تنشئ typedef لنوع سجل وتستخدمه في أكثر من 3-4 أماكن، فقد حان الوقت لترقيته إلى فئة مناسبة. السجلات أفضل للتجميعات المحلية والمؤقتة للبيانات، وليس لأنواع النطاق الأساسية التي تظهر في كل أنحاء قاعدة الكود.

مثال عملي: معالجة استجابة API

السجلات مثالية لمعالجة استجابات API حيث تحتاج لإرجاع كل من البيانات والبيانات الوصفية.

استجابة API مع السجلات

import 'dart:convert';

// محاكاة نوع استجابة API
typedef ApiResponse<T> = ({T data, int statusCode, String? error});

// تحليل استجابة API مقسمة الصفحات
({List<Map<String, dynamic>> items, int total, int page, int perPage})
    parsePaginatedResponse(String jsonString) {
  final json = jsonDecode(jsonString) as Map<String, dynamic>;
  return (
    items: (json['data'] as List).cast<Map<String, dynamic>>(),
    total: json['total'] as int,
    page: json['page'] as int,
    perPage: json['per_page'] as int,
  );
}

// محاكاة الجلب والتحليل
Future<({bool success, List<String> names, String? error})>
    fetchUserNames() async {
  try {
    // محاكاة استدعاء API
    await Future.delayed(Duration(milliseconds: 100));
    final mockResponse = '{"users": ["Edrees", "Ahmed", "Sara"]}';
    final json = jsonDecode(mockResponse) as Map<String, dynamic>;
    final names = (json['users'] as List).cast<String>();
    return (success: true, names: names, error: null);
  } catch (e) {
    return (success: false, names: <String>[], error: e.toString());
  }
}

Future<void> main() async {
  final (:success, :names, :error) = await fetchUserNames();

  if (success) {
    print('Users: ${names.join(", ")}');
    // Users: Edrees, Ahmed, Sara
  } else {
    print('Error: $error');
  }
}

مثال عملي: نظام الإحداثيات

تعمل السجلات بشكل جميل لتحويلات الإحداثيات والحسابات الهندسية.

عمليات الإحداثيات مع السجلات

import 'dart:math';

typedef Point2D = ({double x, double y});
typedef Point3D = ({double x, double y, double z});

// تحويل الإحداثيات القطبية إلى ديكارتية
Point2D polarToCartesian(double radius, double angleRadians) {
  return (
    x: radius * cos(angleRadians),
    y: radius * sin(angleRadians),
  );
}

// حساب المسافة بين نقطتين
double distance(Point2D a, Point2D b) {
  return sqrt(pow(a.x - b.x, 2) + pow(a.y - b.y, 2));
}

// نقطة المنتصف بين نقطتين
Point2D midpoint(Point2D a, Point2D b) {
  return (x: (a.x + b.x) / 2, y: (a.y + b.y) / 2);
}

// صندوق الإحاطة لقائمة نقاط
({Point2D topLeft, Point2D bottomRight}) boundingBox(List<Point2D> points) {
  double minX = points.first.x, maxX = points.first.x;
  double minY = points.first.y, maxY = points.first.y;

  for (final p in points) {
    if (p.x < minX) minX = p.x;
    if (p.x > maxX) maxX = p.x;
    if (p.y < minY) minY = p.y;
    if (p.y > maxY) maxY = p.y;
  }

  return (
    topLeft: (x: minX, y: minY),
    bottomRight: (x: maxX, y: maxY),
  );
}

void main() {
  final origin = (x: 0.0, y: 0.0);
  final point = polarToCartesian(5.0, pi / 4);
  print('Polar (5, 45°) → Cartesian (${point.x.toStringAsFixed(2)}, ${point.y.toStringAsFixed(2)})');
  // Polar (5, 45°) → Cartesian (3.54, 3.54)

  final d = distance(origin, point);
  print('Distance from origin: ${d.toStringAsFixed(2)}'); // 5.00

  final mid = midpoint(origin, point);
  print('Midpoint: (${mid.x.toStringAsFixed(2)}, ${mid.y.toStringAsFixed(2)})');
  // Midpoint: (1.77, 1.77)

  final points = [(x: 1.0, y: 3.0), (x: 5.0, y: 1.0), (x: 3.0, y: 7.0)];
  final (:topLeft, :bottomRight) = boundingBox(points);
  print('Bounding box: ($topLeft) to ($bottomRight)');
}

الملخص

السجلات في Dart 3 إضافة قوية تلغي الحاجة لفئات تغليف متكررة عند تجميع عدة قيم. تدعم الحقول الموضعية والمسماة، وتوفر مساواة هيكلية جاهزة، وتتكامل بسلاسة مع التفكيك. استخدم السجلات للتجميعات البسيطة للبيانات وأنواع إرجاع الدوال، والفئات للأنواع التي تحتاج سلوكاً أو تغليفاً أو هوية. مع مطابقة الأنماط (مُغطاة في الدرس التالي)، تصبح السجلات أداة أساسية لكتابة كود Dart نظيف ومعبر.

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