اذهب إلى المحتوى

التعدادات enums في لغة رست Rust


Naser Dakhel

سننظر في هذا المقال إلى التعدادات enumerations -أو اختصارًا enums- وهي تسمح لنا بتعريف نوع من خلال تعدّد متغيراته variants المحتملة. سنعرّف أولًا المعدّدات ونستخدمها حتى نعرف إمكانياتها وقدرتها على ترميز البيانات، ثم سنستعرض التعداد المهم المسمى option، والذي يدل على قيمة إذا كان يحتوي على شيء أو لا شيء، ومن ثمّ سننظر إلى مطابقة الأنماط pattern matching باستخدام التعبير match الذي يجعل من عملية تشغيل شيفرات برمجية مختلفة بحسب قيم التعداد المختلفة عملية سهلة، وأخيرًا سنتكلم عن الباني if let وهو طريقة ومصطلح مختصر آخر مُتاح لنا للتعامل مع التعدادات في برامجنا.

تعريف تعداد

التعدادات هي طريقة لتعريف أنواع بيانات مُخصصة بصورةٍ مختلفة عن الهياكل structs؛ إذ تمنحنا الهياكل طريقةً لتجميع الحقول والبيانات ذات الصلة معًا، فمثلًا يُعرّف المستطيل Rectangle من خلال طوله height وعرضه width؛ بينما تعطينا التعدادات الخيار للقول أن القيمة هي واحدة من القيم الممكنة، فقد نرغب مثلًا بالقول أن المستطيل هو أحد الخيارات من مجموعة أشكال متاحة تتضمن أيضًا الدائرة Circle والمثلث Triangle. يسمح لنا رست بترميز هذه الاحتمالات على هيئة تعداد.

دعنا ننظر إلى حالة قد تستخدم فيها التعدادات في شيفرتك البرمجية ولننظر إلى فائدتها والأشياء التي تميزها عن الهياكل؛ لنقل أن تطبيقنا سيتعامل مع عناوين بروتوكول الإنترنت IP address. المعياران المُستخدمان حاليًا في عناوين بروتوكول الإنترنت هما الإصدار الرابع والإصدار السادس، لذلك نستطيع استخدام التعداد لجميع المتغيرات variants في الحالتين، وهذا هو السبب في تسمية التعدادات بهذا الاسم.

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

يمكننا التعبير عن هذا المفهوم في شيفرتنا البرمجية عن طريق تعريف التعداد IpAddrKind وإضافة أنواع عناوين الإنترنت الممكنة ألا وهي V6 و V4 والتي هي متغيرات التعداد:

enum IpAddrKind {
    V4,
    V6,
}

أصبح لدينا الآن التعداد IpAddrKind وهو نوع بيانات مخصص يمكننا استخدامه في أي مكان ضمن شيفرتنا البرمجية.

قيم التعداد

يُمكننا إنشاء نسخةٍ من كلا المتغيرَين في التعداد IpAddrKind على النحو التالي:

    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

لاحظ أن فضاء أسماء متغيرات التعداد موجود ضمن المعرّف identifier، ويمكننا استخدام نقطتين مزدوجتين :: لفصل كل من اسم المتغيّر والمعرف، وهذا الأمر مفيد لأن كلا القيمَتين IpAddrKind::V4 و IpAddrKind::V6 من نفس النوع وهو IpAddrKind. يمكننا بعد ذلك تعريف دالة تأخذ أي قيمة من النوع IpAddrKind:

fn route(ip_kind: IpAddrKind) {}

يمكننا بعدها استدعاء الدالة باستخدام أي من متغيّرات التعداد على النحو التالي:

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);

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

    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };

[الشيفرة 1: تخزين البيانات ومتغيّر النوع IpAddrKind باستخدام الهيكل struct]

عرفنا هنا هيكلًا IpAddr يحتوي على حقلين أحدهما kind من النوع IpAddrKind (وهو التعداد الذي عرفناه سابقًا) وحقل address من النوع String، ثم أنشأنا نسختين من هذا الهيكل، وأول نسخة هي home وتحمل القيمة IpAddrKind::V4 في الحقل kind والقيمة 127.0.0.1 في الحقل address، وثاني النسخ هي loopback وتحمل القيمة IpAddrKind::V6 (المتغيّر الثاني من النوع IpAddrKind) في حقل kind ويحتوي على القيمة ‎::1 في حقل العنوان address، وبالتالي استخدمنا هنا هيكلًا لتجميع القيمتين kind و address مع بعضهما وربطنا المتغيّر مع القيمة.

يُعد تنفيذ المفهوم ذاته باستخدام التعدادات فقط أكثر بساطة من ذلك بكثير، إذ بدلًا من وجود التعداد داخل الهيكل يمكننا وضع البيانات مباشرةً داخل كل متغيّر من التعداد. إليك التعريف الجديد للتعداد IpAddr الذي يحتوي على V4 و V6 ويحمل كل منهما قيمةً من النوع String:

    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));

نربط البيانات إلى كل متغيّر من التعداد مباشرةً، مما يجنبنا إضافة هيكل إضافي كما يجعل من رؤية تفاصيل عمل التعداد عمليةً أكثر وضوحًا؛ إذ يصبح اسم كل متغيّر معرّف في التعداد دالةً تُنشئ نسخةً من التعداد أي أن IpAddr::V4()‎ هو استدعاء لدالة تأخذ وسيطًا من النوع String وتُعيد نسخةً من النوع IpAddr ونحصل على هذه الدالة البانية constructor function تلقائيًا عند تعريف التعداد.

هناك ميزةٌ أخرى لاستخدام التعدادات عوضًا عن الهياكل، ألا وهي أن كل متغيّر يحصل على نوع مختلف وكمية مختلفة من البيانات المرتبطة به، إذ سيحصل نوع الإصدار الرابع من عنوان الإنترنت على أربع مكونات عددية تحمل قيمة تنتمي إلى المجال من 0 إلى 255. إذا أردنا تخزين عناوين الإصدار الرابع V4 بأربع قيم من النوع u8 مع المحافظة على إمكانية تمثيل عناوين الإصدار السادس V6 مثل قيمة واحدة من النوع String، وهذا لن يكون هذا ممكنًا باستخدام الهياكل. إليك كيف تسمح لنا التعدادات بفعل ذلك:

    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));

استعرضنا عدة طرق في تعريف هياكل البيانات لتخزين الإصدار الرابع والسادس من عناوين بروتوكول الإنترنت، إلا أن تخزين عناوين الإنترنت في الحقيقة ممكن التخزين والترميز بسهولة عن طريق تعريف ضمن المكتبة القياسية. دعنا ننظر إلى كيفية تعريف المكتبة القياسية للنوع IpAddr، إذ تُعرّف المكتبة القياسية التعداد مع معدّداته بصورةٍ مماثلة لما فعلناه سابقًا إلا أنها تُضمّن عناوين الإنترنت داخل المتغيّرات ضمن هيكلين مختلفين مُعرّفَين على نحوٍ مختلف:

struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

توضح لك الشيفرة البرمجية أنه بإمكانك تخزين أي نوع من البيانات داخل متغيّر التعداد مثل السلاسل النصية والأنواع العددية أو الهياكل، كما يمكنك أيضًا تضمين تعداد آخر داخلها. أنواع المكتبة القياسية بسيطة ويمكنك فهمها بسهولة أو كتابتها من الصفر لوحدك.

لاحظ أنه بالرغم من احتواء المكتبة القياسية على تعريف النوع IpAddr، إلا أنه ما زال بإمكاننا إنشاء واستخدام تعريفنا الخاص دون أي تعارض وذلك لأننا لم نُضِف تعريف المكتبة القياسية إلى نطاقنا، وسنتحدث على نحوٍ مفصل عن إضافة الأنواع إلى النطاق لاحقًا.

دعنا ننظر إلى مثال آخر على التعدادات في الشيفرة 2، إذ يحتوي هذا المثال على أنواع متعددة ضمن متغيّرات التعداد.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

[الشيفرة 2: تعداد Message يحتوي على متغيّرات، يخزّن كل منها عدد ونوع مختلف من أنواع القيم]

يحتوي التعداد السابق على أربع متغيّرات من أنواع مختلفة:

  • المتغيّر Quit، الذي لا يحتوي على أي بيانات مرتبطة به إطلاقًا.
  • المتغيّر Move، الذي يحتوي على حقول مُسمّاة بصورةٍ مشابهة للهياكل.
  • المتغيّر Write، الذي يحتوي على نوع String وحيد.
  • المتغيّر ChangeColor، الذي يحتوي على ثلاث قيم من النوع i32.

عملية تعريف التعداد السابق في الشيفرة 2 هي عملية مشابهة لتعريف أنواع مختلفة من الهياكل، والفارق الوحيد هنا هو أن التعدادات لا تستخدم الكلمة المفتاحية struct وكل المتغيّرات هنا مجمعة مع بعضها بعضًا ضمن النوع Message. تُخزّن الهياكل التالية البيانات التي يحملها التعداد السابق:

struct QuitMessage; // unit هيكل وحدة
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // هيكل صف
struct ChangeColorMessage(i32, i32, i32); // هيكل صف

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

هناك تشابه آخر بين التعدادات والهياكل؛ إذ يمكننا تعريف توابع في التعدادات بطريقة مشابهة لما يحدث في الهياكل باستخدام impl. إليك تابعًا باسم call يمكننا تعريفه ضمن التعداد Message:

    impl Message {
        fn call(&self) {
            // يُعرّف محتوى التابع هنا
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();

تُستخدم self في متن التابع للحصول على القيمة المُعادة من استدعاء التابع، وفي هذا المثال نُنشئ متغيرًا m يحمل القيمة Message::Write(String::from("hello"))‎، وهذا ما ستكون قيمة self عليه داخل التابع call عند تنفيذ السطر m.call()‎.

دعنا ننظر إلى تعداد شائع مفيد آخر ضمن المكتبة القياسية ألا وهو Option.

التعداد Option وميزاته بما يخص القيم الفارغة

نستعرض في هذه الفقرة التعداد Option، وهو تعداد معرّف داخل المكتبة القياسية. يُستخدم النوع Option لترميز حالة شائعة عندما تكون القيمة شيئ أو لا شيء، فعلى سبيل المثال إذا طلبت أول عناصر القائمة ستحصل على قيمة (شيء) إلا إذا كانت القائمة فارغة فستحصل على لا شيء، والتعبير عن هذا المفهوم في سياق أنواع البيانات يعني أن المصرف قادر على معرفة إذا ما كنت تعاملت مع جميع الحالات التي يجب عليك التعامل معها، وتساعد هذه الميزة في منع حدوث الأخطاء الشائعة جدًا في باقي لغات البرمجة.

يُنظر إلى تصميم لغات البرمجة بسياق المزايا التي تتضمنها، إلا أن المزايا التي لن تُضمّن مهمةٌ أيضًا، إذ لا تحتوي رست على ميزة القيمة الفارغة null التي تمتلكها العديد من لغات البرمجة الأخرى؛ والقيمة الفارغة null تعني أنه لا يوجد أي قيمة هناك، وتكون المتغيرات في لغات البرمجة التي تحتوي على القيمة الفارغة في حالتين، إما حالة فارغة أو حالة غير فارغة not-null.

صرّح توني هواري Tony Hoare -مخترع القيمة الفارغة- في عام 2009 في عرض تقديمي بعنوان "المراجع الفارغة: خطأ بقيمة مليار دولار Null Reference: The Billion Dollar Mistake" ما يلي:

اقتباس

أدعوها بخطئي الذي كلف مليار دولار. كنت في وقتها أصمم أول نظام للأنواع للمراجع في لغة كائنية التوجه، وكان هدفي التأكد من أن جميع استخدامات المراجع هي استخدامات آمنة بالكامل وذلك عن طريق فحص المصرف تلقائيًا، إلا أنني لم أستطع مقاومة إغراء إضافة مرجع فارغ null reference وذلك لأنه كان سهل التطبيق ببساطة. أدى ذلك فيما بعد إلى أخطاء وثغرات لا تُعد ولا تُحصى وتعطُّل للنظام، مما تسبب بخسائر بمليارات الدولار في الأربعين سنة المنصرمة.

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

المشكلة ليست بالمفهوم وإنما بالحل المُطبّق، ولهذا السبب لا تحتوي رست على النوع فارغ null، وإنما تحتوي على تعداد يدل على مفهوم عدم وجود قيمة أو عدم صلاحيتها ألا وهو التعداد Option<T>‎ المعرف في المكتبة القياسية كما يلي:

enum Option<T> {
    None,
    Some(T),
}

التعداد Option<T>‎ مفيد جدًا حتى أنه مضمّن في مقدمة البرنامج وليس عليك أن تُضيفه إلى النطاق يدويًا، كما أن متغيّراته مضمّنة أيضًا ويمكنك استخدام Some و None مباشرةً دون البدء بالبادئة Option::‎، إلا أن التعداد Option<T>‎ هو تعداد مثل أي تعداد آخر و Some(T)‎ و None هي متغيّرات من النوع Option<T>‎.

طريقة كتابة <T> هي ميزة من ميزات رست التي لم نتحدث عنها بعد، وهي معامل نوع مُعمّم generic type parameter وسنتكلم عن الأنواع المعممة لاحقًا، وكل ما عليك معرفته الآن هو أن <T> يعني أن متغيّر Some من التعداد Option يستطيع تخزين جزء من أي نوع من البيانات وأي نوع يُستخدم في مكان T يجعل من النوع Option<T>‎ نوعًا مختلفًا. إليك بعض الأمثلة التي تستخدم قيم Option لتخزين عدة أنواع من البيانات:

   let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;

نوع some_number هو Option، ونوع some_string هو Option<char>‎ وهو نوع مختلف تمامًا عن سابقه، وتستطيع رست تحديد هذه الأنواع بسبب استخدامنا للقيمة داخل متغيّر Some، إلا أن رست تتطلب منا تحديد نوع Option الكلي بالنسبة للمتغير absent_number ولا يستطيع المصرّف تحديد النوع الخاص بالمتغير Some بنفسه عن طريق النظر إلى قيمة None فقط، وعلينا إخبار رست هنا أننا نقصد أن absent_number هو من النوع Option<i32>‎.

نعلم أن هناك قيمة موجودة عندما يكون لدينا قيمة في Some، أما عندما يكون لدينا قيمة في None نعلم أن هذا الأمر مكافئ للقيمة الفارغة null أي أنه لا يوجد لدينا قيمة صالحة. إذًا، لمَ وجود Option<T>‎ هو أفضل من وجود القيمة الفارغة؟

لأن Option<T>‎ و T (إذ يمكن أن تدل T على أي نوع) هي من أنواع مختلفة ولن يسمح لنا المصرف باستخدام القيمة Option<T>‎ على أنها قيمة صالحة. على سبيل المثال، لن تُصرَّف الشيفرة البرمجية التالية لأننا نحاول إضافة النوع i8 إلى نوع Option<i8>‎:

    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;

إذا نفذنا الشيفرة البرمجية السابقة، نحصل على رسالة الخطأ التالية:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            <&'a f32 as Add<f32>>
            <&'a f64 as Add<f64>>
            <&'a i128 as Add<i128>>
            <&'a i16 as Add<i16>>
            <&'a i32 as Add<i32>>
            <&'a i64 as Add<i64>>
            <&'a i8 as Add<i8>>
            <&'a isize as Add<isize>>
          and 48 others

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error

تعني رسالة الخطأ السابقة أن رست لا تعرف كيفية إضافة i8 إلى Option<i8>‎ لأنهما من نوعين مختلفين. يتأكد المصرف عندما نحصل على قيمة من نوع مشابه إلى النوع i8 في رست من أنه لدينا قيمة صالحة، ويمكننا تجاوز عملية التحقق بأمان دون الحاجة للتحقق من قيمة فارغة قبل استخدام هذه القيمة، إلا أنه يجب علينا أخذ الحيطة فقط في حال كان لدينا Option<i8>‎ (أو أي نوع من قيمة نتعامل معها) وذلك إذا كان النوع لا يحمل أي قيمة وسيتأكد المصرف حينها من تعاملنا مع هذه الحالة قبل استخدام القيمة.

بكلمات أخرى، يجب عليك تحويل النوع Option<T>‎ إلى T قبل تنفيذ عمليات النوع T على القيمة، ويساعدنا هذا عمومًا على تشخيص أحد أكثر المشاكل شيوعًا في القيم الفارغة ألا وهي افتراض أن قيمة ما ليست فارغة وهي في الحقيقة فارغة.

سيجعلك التخلص من المشكلة السابقة أكثر ثقة بشيفرتك البرمجية، إذ يتوجب عليك تحويل القيمة إلى النوع Option<T>‎ يدويًا إذا أردت الحصول على القيمة التي من الممكن أن تكون فارغة، وعليك التعامل مع حالة كون القيمة فارغة إذا استخدمت هذه الطريقة، وتستطيع الافتراض بأمان أن أي قيمة ليست من النوع Option<T>‎ ليست بقيمة فارغة، وهذا تصميم مقصود في لغة رست للحدّ من انتشار القيمة الفارغة وزيادة أمان شيفرة رست البرمجية.

كيف تُخرج القيمة T خارج متغيّر Some عندما يكون لديك قيمة من النوع Option<T>‎ وتريد استخدام القيمة؟ لدى التعداد Option<T>‎ العديد من التوابع المفيدة في حالات متعددة ويمكنك النظر إليها من التوثيق، وستكون معرفة هذه التوابع الخاصة بالنوع Option<T>‎ مفيدةً جدًا في رحلتك مع رست.

يجب عليك كتابة شيفرة برمجية تتعامل مع كل متغيّر إذا أردت استخدام القيمة الموجودة في Option<T>‎، إذ يجب على شيفرتك البرمجية أن تُنفّذ فقط في حال كان داخل Option<T>‎ قيمةً ما، والسماح لهذه الشيفرة البرمجية باستخدام القيمة T الداخلية، كما ينبغي وجود شيفرة برمجية أخرى تُنفّذ في حال كان هناك قيمة None بحيث لا تستخدم هذه الشيفرة البرمجية القيمة T. يسمح لنا تعبير match الذي يمثل باني للتحكم بسير البرنامج باستخدام التعدادات بتنفيذ هذا الأمر تمامًا، إذ سينفذ شيفرةً برمجيةً مختلفةً بحسب المتغيّر الموجود داخل التعداد، ويمكن للشيفرة البرمجية حينها استخدام هذه البيانات داخل القيمة الموافقة.

ترجمة -وبتصرف- لقسم من الفصل Enums and Pattern Matching من كتاب The Rust Programming Language.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...