Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

تعريف التعداد (Defining an Enum)

بينما تمنحك الهياكل (structs) طريقة لتجميع الحقول والبيانات ذات الصلة معاً، مثل Rectangle مع width و height الخاصين به، تمنحك التعدادات (enums) طريقة لقول أن القيمة هي واحدة من مجموعة محتملة من القيم. على سبيل المثال، قد نرغب في القول أن Rectangle هو واحد من مجموعة من الأشكال الممكنة التي تتضمن أيضاً Circle و Triangle. للقيام بذلك، يسمح لنا Rust بترميز هذه الاحتمالات كـ enum.

دعونا نلقي نظرة على موقف قد نرغب في التعبير عنه في الكود ونرى لماذا تعد enums مفيدة وأكثر ملاءمة من structs في هذه الحالة. لنفترض أننا بحاجة إلى العمل مع عناوين IP. حالياً، يتم استخدام معيارين رئيسيين لعناوين IP: الإصدار الرابع والإصدار السادس. نظراً لأن هذه هي الاحتمالات الوحيدة لعنوان IP التي سيواجهها برنامجنا، يمكننا تعداد (enumerate) جميع المتغيرات (variants) الممكنة، ومن هنا حصل التعداد على اسمه.

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

يمكننا التعبير عن هذا المفهوم في الكود من خلال تعريف تعداد IpAddrKind وإدراج الأنواع الممكنة التي يمكن أن يكون عليها عنوان IP، وهي V4 و V6. هذه هي variants الخاصة بالتعداد:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

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

fn route(ip_kind: IpAddrKind) {}

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

قيم التعداد (Enum Values)

يمكننا إنشاء مثيلات (instances) لكل من المتغيرين في IpAddrKind كالتالي:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

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

fn route(ip_kind: IpAddrKind) {}

لاحظ أن variants الخاصة بالتعداد تقع تحت مساحة الاسم (namespaced) الخاصة بمعرفه، ونستخدم نقطتين مزدوجتين للفصل بينهما. هذا مفيد لأن كلا القيمتين IpAddrKind::V4 و IpAddrKind::V6 هما الآن من نفس النوع: IpAddrKind. يمكننا بعد ذلك، على سبيل المثال، تعريف دالة تأخذ أي IpAddrKind:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

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

fn route(ip_kind: IpAddrKind) {}

ويمكننا استدعاء هذه الدالة بأي من المتغيرين:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

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

fn route(ip_kind: IpAddrKind) {}

استخدام enums له مزايا أكثر. بالتفكير أكثر في نوع عنوان IP الخاص بنا، في الوقت الحالي ليس لدينا طريقة لتخزين بيانات (data) عنوان IP الفعلية؛ نحن نعرف فقط نوعه. بالنظر إلى أنك تعلمت للتو عن structs في الفصل الخامس، فقد تميل إلى معالجة هذه المشكلة باستخدام structs كما هو موضح في القائمة 6-1.

fn main() {
    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"),
    };
}

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

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

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

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

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

نحن نرفق البيانات بكل variant من التعداد مباشرة، لذا لا داعي لـ struct إضافي. هنا، من الأسهل أيضاً رؤية تفصيل آخر لكيفية عمل enums: يصبح اسم كل enum variant نقوم بتعريفه أيضاً دالة تنشئ instance من التعداد. أي أن IpAddr::V4() هو استدعاء دالة يأخذ وسيطاً (argument) من نوع String ويعيد instance من نوع IpAddr. نحصل تلقائياً على دالة البناء (constructor function) هذه نتيجة لتعريف التعداد.

هناك ميزة أخرى لاستخدام enum بدلاً من struct: يمكن أن يكون لكل variant أنواع وكميات مختلفة من البيانات المرتبطة. ستتكون عناوين IP من الإصدار الرابع دائماً من أربعة مكونات رقمية ستكون قيمها بين 0 و 255. إذا أردنا تخزين عناوين V4 كأربع قيم u8 ولكننا لا نزال نريد التعبير عن عناوين V6 كقيمة String واحدة، فلن نتمكن من ذلك باستخدام struct. تتعامل التعدادات مع هذه الحالة بسهولة:

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

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

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

لقد عرضنا عدة طرق مختلفة لتعريف هياكل البيانات لتخزين عناوين IP من الإصدار الرابع والسادس. ومع ذلك، كما اتضح، فإن الرغبة في تخزين عناوين IP وترميز نوعها أمر شائع جداً لدرجة أن المكتبة القياسية لديها تعريف يمكننا استخدامه! دعونا نلقي نظرة على كيفية تعريف المكتبة القياسية لـ IpAddr. لديها نفس التعداد والمتغيرات التي عرفناها واستخدمناها، ولكنها تدمج بيانات العنوان داخل variants في شكل اثنين من structs المختلفين، واللذين يتم تعريفهما بشكل مختلف لكل variant:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

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

يوضح هذا الكود أنه يمكنك وضع أي نوع من البيانات داخل enum variant: سلاسل نصية، أو أنواع رقمية، أو structs، على سبيل المثال. يمكنك حتى تضمين enum آخر! أيضاً، غالباً ما لا تكون أنواع المكتبة القياسية أكثر تعقيداً بكثير مما قد تبتكره أنت.

لاحظ أنه على الرغم من أن المكتبة القياسية تحتوي على تعريف لـ IpAddr ، إلا أنه لا يزال بإمكاننا إنشاء واستخدام تعريفنا الخاص دون تعارض لأننا لم نجلب تعريف المكتبة القياسية إلى نطاقنا (scope). سنتحدث أكثر عن جلب الأنواع إلى النطاق في الفصل السابع.

دعونا نلقي نظرة على مثال آخر لـ enum في القائمة 6-2: هذا المثال يحتوي على مجموعة واسعة من الأنواع المدمجة في variants الخاصة به.

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

fn main() {}

يحتوي هذا التعداد على أربعة variants بأنواع مختلفة:

  • Quit: ليس لديه أي بيانات مرتبطة به على الإطلاق
  • Move: يحتوي على حقول مسمى، تماماً كما يفعل struct
  • Write: يتضمن String واحدة
  • ChangeColor: يتضمن ثلاث قيم i32

تعريف enum مع variants مثل تلك الموجودة في القائمة 6-2 يشبه تعريف أنواع مختلفة من تعريفات struct، باستثناء أن التعداد لا يستخدم الكلمة المفتاحية struct ويتم تجميع جميع variants معاً تحت نوع Message. يمكن لـ structs التالية الاحتفاظ بنفس البيانات التي تحتفظ بها enum variants السابقة:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

ولكن إذا استخدمنا structs المختلفة، والتي لكل منها نوعها الخاص، فلن نتمكن بسهولة من تعريف دالة لتأخذ أي نوع من هذه الرسائل كما فعلنا مع تعداد Message المعرف في القائمة 6-2، والذي هو نوع واحد.

هناك تشابه آخر بين enums و structs: تماماً كما يمكننا تعريف دوال (methods) على structs باستخدام impl ، يمكننا أيضاً تعريف methods على enums. إليك method يسمى call يمكننا تعريفه على تعداد Message الخاص بنا:

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

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

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

سيستخدم جسم الـ method الكلمة self للحصول على القيمة التي استدعينا الـ method عليها. في هذا المثال، أنشأنا متغيراً m له القيمة Message::Write(String::from("hello")) ، وهذا ما ستكون عليه self في جسم الـ method call عندما يتم تشغيل m.call().

دعونا نلقي نظرة على enum آخر في المكتبة القياسية شائع جداً ومفيد: Option.

تعداد Option (The Option Enum)

يستكشف هذا القسم دراسة حالة لـ Option ، وهو enum آخر معرف بواسطة المكتبة القياسية. يرمز النوع Option للسيناريو الشائع جداً الذي يمكن أن تكون فيه القيمة شيئاً ما، أو قد لا تكون شيئاً (nothing).

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

غالباً ما يتم التفكير في تصميم لغة البرمجة من حيث الميزات التي تضمنها، ولكن الميزات التي تستبعدها مهمة أيضاً. لا يحتوي Rust على ميزة القيمة الفارغة (null) الموجودة في العديد من اللغات الأخرى. Null هي قيمة تعني عدم وجود قيمة هناك. في اللغات التي تحتوي على null، يمكن أن تكون المتغيرات دائماً في واحدة من حالتين: null أو ليست null.

في عرضه التقديمي لعام 2009 بعنوان “مراجع Null: خطأ المليار دولار” ، قال توني هور، مخترع null، ما يلي:

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

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

ومع ذلك، فإن المفهوم الذي تحاول null التعبير عنه لا يزال مفهوماً مفيداً: null هي قيمة غير صالحة حالياً أو غائبة لسبب ما.

المشكلة ليست حقاً في المفهوم ولكن في التنفيذ المحدد. على هذا النحو، لا يحتوي Rust على nulls، ولكنه يحتوي على enum يمكنه ترميز مفهوم وجود القيمة أو غيابها. هذا التعداد هو Option<T> ، وهو معرف بواسطة المكتبة القياسية كما يلي:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

تعداد Option<T> مفيد جداً لدرجة أنه مضمن في التمهيد (prelude)؛ لست بحاجة إلى جلبه إلى النطاق صراحة. كما أن variants الخاصة به مضمنة في prelude: يمكنك استخدام Some و None مباشرة بدون بادئة Option::. لا يزال تعداد Option<T> مجرد enum عادي، ولا تزال Some(T) و None عبارة عن variants من نوع Option<T>.

بناء الجملة <T> هو ميزة في Rust لم نتحدث عنها بعد. إنه معلمة نوع عام (generic type parameter)، وسنغطي الأنواع العامة (generics) بمزيد من التفصيل في الفصل العاشر. في الوقت الحالي، كل ما تحتاج إلى معرفته هو أن <T> تعني أن المتغير Some من تعداد Option يمكنه الاحتفاظ بقطعة واحدة من البيانات من أي نوع، وأن كل نوع ملموس يتم استخدامه بدلاً من T يجعل نوع Option<T> الإجمالي نوعاً مختلفاً. إليك بعض الأمثلة على استخدام قيم Option للاحتفاظ بأنواع الأرقام وأنواع الأحرف:

#![allow(unused)]
fn main() {
(Content truncated due to size limit. Use line ranges to read remaining content)
}

نوع some_number هو Option<i32>. ونوع some_char هو Option<char> ، وهو نوع مختلف. يمكن لـ Rust استنتاج (infer) هذه الأنواع لأننا حددنا قيمة داخل المتغير Some. بالنسبة لـ absent_number ، يتطلب Rust منا توضيح نوع Option الإجمالي: لا يمكن للمصرف استنتاج النوع الذي سيحتفظ به المتغير Some المقابل من خلال النظر فقط إلى قيمة None. هنا، نخبر Rust أننا نقصد أن يكون absent_number من نوع Option<i32>.

عندما يكون لدينا قيمة Some ، فإننا نعلم أن القيمة موجودة، وأن القيمة محتفظ بها داخل Some. عندما يكون لدينا قيمة None ، فإنها تعني بمعنى ما نفس الشيء مثل null: ليس لدينا قيمة صالحة. إذاً، لماذا يعد وجود Option<T> أفضل من وجود null؟

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

fn main() {
    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>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

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

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

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

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

إذاً، كيف تحصل على قيمة T من متغير Some عندما يكون لديك قيمة من نوع Option<T> حتى تتمكن من استخدام تلك القيمة؟ يحتوي تعداد Option<T> على عدد كبير من الدوال (methods) المفيدة في مجموعة متنوعة من المواقف؛ يمكنك الاطلاع عليها في توثيقها. سيصبح التعرف على methods الموجودة في Option<T> مفيداً للغاية في رحلتك مع Rust.

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