تعریف یک Enum

در حالی که ساختارها (Structs) روشی برای گروه‌بندی فیلدها و داده‌های مرتبط فراهم می‌کنند، Enumها به شما امکان می‌دهند که بگویید یک مقدار یکی از مجموعه مقادیر ممکن است. به عنوان مثال، ممکن است بخواهیم بگوییم که Rectangle یکی از مجموعه اشکالی است که همچنین شامل Circle و Triangle می‌شود. برای انجام این کار، زبان Rust به ما اجازه می‌دهد تا این امکان‌ها را به‌عنوان یک Enum کدگذاری کنیم.

بیایید نگاهی به یک موقعیت بیندازیم که ممکن است بخواهیم در کد بیان کنیم و ببینیم چرا Enumها مفیدتر و مناسب‌تر از Structها هستند. فرض کنید باید با آدرس‌های IP کار کنیم. در حال حاضر، دو استاندارد اصلی برای آدرس‌های IP وجود دارد: نسخه چهار و نسخه شش. از آنجا که این تنها حالت‌های ممکن برای آدرس‌های IP هستند که برنامه ما با آن‌ها مواجه خواهد شد، می‌توانیم تمام حالت‌های ممکن را شمارش کنیم، که همین موضوع اساس نام‌گذاری Enumها است.

هر آدرس IP می‌تواند یا نسخه چهار یا نسخه شش باشد، اما نمی‌تواند به‌طور همزمان هر دو باشد. این ویژگی آدرس‌های IP استفاده از ساختار داده Enum را مناسب می‌کند زیرا مقدار یک Enum می‌تواند فقط یکی از حالت‌هایش باشد. هر دو آدرس نسخه چهار و نسخه شش همچنان اساساً آدرس IP هستند، بنابراین باید هنگام کار با کدی که به هر نوع آدرس IP اعمال می‌شود، به‌عنوان یک نوع یکسان رفتار شوند.

ما می‌توانیم این مفهوم را در کد با تعریف یک Enumeration به نام IpAddrKind و فهرست کردن انواع ممکن یک آدرس IP، یعنی V4 و V6، بیان کنیم. این‌ها حالت‌های Enum هستند:

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

می‌توانیم نمونه‌هایی از هر یک از دو حالت 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 و 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) {}

استفاده از Enumها مزایای بیشتری دارد. اگر بیشتر به نوع آدرس IP خود فکر کنیم، متوجه می‌شویم که در حال حاضر راهی برای ذخیره داده‌های واقعی آدرس IP نداریم؛ فقط می‌دانیم که چه نوعی است. با توجه به اینکه به‌تازگی درباره Structها در فصل 5 یاد گرفته‌اید، ممکن است وسوسه شوید این مشکل را با Structها همانطور که در فهرست 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"),
    };
}
Listing 6-1: ذخیره داده‌ها و حالت IpAddrKind یک آدرس IP با استفاده از یک struct

در اینجا ما یک Struct به نام IpAddr تعریف کرده‌ایم که دو فیلد دارد: یک فیلد kind که از نوع IpAddrKind است (همان Enum که قبلاً تعریف کردیم) و یک فیلد address از نوع String. ما دو نمونه از این Struct داریم. اولین مورد home نام دارد و مقدار IpAddrKind::V4 به‌عنوان kind با داده‌های آدرس مرتبط 127.0.0.1 دارد. نمونه دوم loopback نام دارد. این نمونه حالت دیگر Enum یعنی V6 را به‌عنوان مقدار kind دارد و آدرس مرتبط ::1 است. ما از یک Struct برای بسته‌بندی مقادیر kind و address با هم استفاده کرده‌ایم، بنابراین اکنون حالت با مقدار مرتبط شده است.

با این حال، نمایش همان مفهوم با استفاده از فقط یک Enum مختصرتر است: به‌جای استفاده از Enum داخل یک Struct، می‌توانیم داده‌ها را مستقیماً به هر حالت Enum متصل کنیم. این تعریف جدید Enum 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"));
}

ما داده‌ها را مستقیماً به هر حالت Enum متصل کرده‌ایم، بنابراین نیازی به یک Struct اضافی نیست. در اینجا همچنین می‌توان جزئیات دیگری از نحوه عملکرد Enumها را مشاهده کرد: نام هر حالت Enum که تعریف می‌کنیم به‌صورت یک تابع تبدیل می‌شود که نمونه‌ای از Enum ایجاد می‌کند. یعنی IpAddr::V4() یک فراخوانی تابع است که یک آرگومان از نوع String می‌گیرد و نمونه‌ای از نوع IpAddr برمی‌گرداند. این تابع سازنده به‌طور خودکار به‌عنوان نتیجه تعریف Enum تعریف می‌شود.

یک مزیت دیگر استفاده از Enum به‌جای Struct این است که هر حالت می‌تواند انواع و مقادیر داده مرتبط متفاوتی داشته باشد. آدرس‌های IP نسخه چهار همیشه چهار مؤلفه عددی خواهند داشت که مقادیرشان بین 0 و 255 است. اگر بخواهیم آدرس‌های V4 را به‌صورت چهار مقدار u8 ذخیره کنیم اما همچنان آدرس‌های V6 را به‌صورت یک مقدار String بیان کنیم، با یک Struct نمی‌توانیم این کار را انجام دهیم. Enumها به‌راحتی این حالت را مدیریت می‌کنند:

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 در کتابخانه استاندارد بیندازیم: این کتابخانه دارای همان Enum و حالت‌هایی است که ما تعریف کرده و استفاده کرده‌ایم، اما داده‌های آدرس را به‌صورت داخلی در حالت‌ها در قالب دو Struct مختلف تعبیه کرده است، که به‌طور متفاوت برای هر حالت تعریف شده‌اند:

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

struct Ipv6Addr {
    // --snip--
}

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

این کد نشان می‌دهد که شما می‌توانید هر نوع داده‌ای مانند رشته‌ها، انواع عددی، یا Structها را داخل حالت‌های Enum قرار دهید. حتی می‌توانید یک Enum دیگر را نیز شامل کنید! همچنین، انواع استاندارد کتابخانه معمولاً خیلی پیچیده‌تر از چیزی نیستند که ممکن است خودتان ارائه دهید.

توجه داشته باشید که با وجود اینکه کتابخانه استاندارد تعریفی برای IpAddr دارد، ما همچنان می‌توانیم تعریف خودمان را ایجاد و استفاده کنیم بدون اینکه تضادی پیش بیاید زیرا تعریف کتابخانه استاندارد را به محدوده خود وارد نکرده‌ایم. ما در فصل 7 درباره وارد کردن انواع به محدوده بیشتر صحبت خواهیم کرد.

بیایید به مثال دیگری از یک Enum در فهرست 6-2 نگاه کنیم: این مورد دارای انواع متنوعی از داده‌های جاسازی‌شده در حالت‌های خود است.

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

fn main() {}
Listing 6-2: یک Enum به نام Message که هر یک از حالت‌های آن مقادیر متفاوتی ذخیره می‌کنند

این Enum دارای چهار حالت با انواع مختلف است:

  • Quit هیچ داده‌ای با آن مرتبط نیست.
  • Move دارای فیلدهای نام‌گذاری شده، شبیه به یک Struct است.
  • Write شامل یک مقدار String است.
  • ChangeColor شامل سه مقدار i32 است.

تعریف یک Enum با حالت‌هایی مانند حالت‌های فهرست 6-2 مشابه تعریف انواع مختلف ساختارها است، با این تفاوت که Enum از کلمه کلیدی struct استفاده نمی‌کند و تمام حالت‌ها تحت نوع Message گروه‌بندی شده‌اند. ساختارهای زیر می‌توانند همان داده‌هایی را نگه دارند که حالت‌های Enum قبلی نگه می‌دارند:

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

fn main() {}

اما اگر از ساختارهای مختلفی استفاده کنیم که هر یک نوع خاص خود را دارند، نمی‌توانیم به‌راحتی یک تابع تعریف کنیم که بتواند هر یک از این انواع پیام‌ها را مانند چیزی که با Enum Message تعریف‌شده در فهرست 6-2 امکان‌پذیر است، دریافت کند.

یک شباهت دیگر بین Enumها و ساختارها این است: همان‌طور که می‌توانیم متدها را با استفاده از impl برای ساختارها تعریف کنیم، می‌توانیم متدها را برای Enumها نیز تعریف کنیم. اینجا یک متد به نام call است که می‌توانیم برای Enum 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();
}

بدنه این متد از self برای دسترسی به مقداری که متد روی آن فراخوانی شده است استفاده می‌کند. در این مثال، ما یک متغیر به نام m ایجاد کرده‌ایم که مقدار Message::Write(String::from("hello")) را دارد و این همان چیزی است که self در بدن متد call هنگام اجرای m.call() خواهد بود.

بیایید به یک Enum دیگر در کتابخانه استاندارد که بسیار متداول و مفید است نگاهی بیندازیم: Option.

Enum Option و مزایای آن نسبت به مقادیر Null

این بخش به مطالعه موردی Option می‌پردازد که یکی دیگر از Enumهای تعریف شده در کتابخانه استاندارد است. نوع Option سناریوی بسیار رایجی را نشان می‌دهد که در آن یک مقدار می‌تواند وجود داشته باشد یا هیچ مقداری وجود نداشته باشد.

به عنوان مثال، اگر اولین مورد را در یک لیست غیر خالی درخواست کنید، مقداری دریافت خواهید کرد. اگر اولین مورد را در یک لیست خالی درخواست کنید، هیچ مقداری دریافت نخواهید کرد. بیان این مفهوم در قالب سیستم نوع به کامپایلر امکان می‌دهد تا بررسی کند آیا تمام مواردی که باید مدیریت شوند را در نظر گرفته‌اید؛ این ویژگی می‌تواند از بروز باگ‌هایی که در دیگر زبان‌های برنامه‌نویسی بسیار رایج هستند جلوگیری کند.

طراحی زبان‌های برنامه‌نویسی اغلب از نظر ویژگی‌هایی که شامل می‌شوند بررسی می‌شود، اما ویژگی‌هایی که کنار گذاشته می‌شوند نیز مهم هستند. Rust ویژگی null را که بسیاری از زبان‌های دیگر دارند، ندارد. Null یک مقدار است که به معنای وجود نداشتن مقدار می‌باشد. در زبان‌هایی که دارای null هستند، متغیرها می‌توانند همیشه در یکی از دو حالت باشند: null یا not-null.

در ارائه سال 2009 خود به نام “Null References: The Billion Dollar Mistake”، تونی هور، مخترع null، چنین می‌گوید:

من آن را اشتباه میلیارد دلاری خود می‌نامم. در آن زمان، من در حال طراحی اولین سیستم نوع جامع برای مراجع در یک زبان شیءگرا بودم. هدف من اطمینان از این بود که تمام استفاده‌های از مراجع کاملاً امن باشند، با بررسی‌هایی که به‌طور خودکار توسط کامپایلر انجام می‌شوند. اما نتوانستم در برابر وسوسه قرار دادن یک مرجع null مقاومت کنم، فقط به این دلیل که پیاده‌سازی آن بسیار آسان بود. این منجر به خطاها، آسیب‌پذیری‌ها، و خرابی‌های سیستم‌های بی‌شماری شده است که احتمالاً باعث یک میلیارد دلار درد و ضرر در چهل سال گذشته شده‌اند.

مشکل مقادیر null این است که اگر بخواهید از یک مقدار null به‌عنوان یک مقدار not-null استفاده کنید، نوعی خطا دریافت خواهید کرد. از آنجا که خاصیت null یا not-null فراگیر است، بسیار آسان است که این نوع خطا را مرتکب شوید.

با این حال، مفهومی که null سعی در بیان آن دارد همچنان مفید است: null یک مقدار است که در حال حاضر به دلایلی نامعتبر یا غایب است.

مشکل واقعاً با مفهوم نیست، بلکه با پیاده‌سازی خاص است. به این ترتیب، Rust مقادیر null ندارد، اما یک Enum دارد که می‌تواند مفهوم وجود داشتن یا نداشتن یک مقدار را کدگذاری کند. این Enum Option<T> است که به صورت زیر توسط کتابخانه استاندارد تعریف شده است:

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

Enum Option<T> آن‌قدر مفید است که حتی در بخش پیش‌فرض (Prelude) گنجانده شده است؛ نیازی نیست که به‌طور صریح آن را به محدوده بیاورید. حالت‌های آن نیز در بخش پیش‌فرض هستند: می‌توانید مستقیماً از Some و None بدون پیشوند Option:: استفاده کنید. Enum Option<T> همچنان یک Enum معمولی است، و Some(T) و None همچنان حالت‌هایی از نوع Option<T> هستند.

سینتکس <T> یک ویژگی از Rust است که هنوز درباره آن صحبت نکرده‌ایم. این یک پارامتر نوع عمومی (Generic) است و ما در فصل 10 به جزئیات بیشتری درباره آن خواهیم پرداخت. برای حالا، تنها چیزی که باید بدانید این است که <T> به این معنا است که حالت Some از Enum Option می‌تواند یک قطعه داده از هر نوعی را نگه دارد، و هر نوع مشخصی که به جای T استفاده شود، کل نوع Option<T> را به یک نوع متفاوت تبدیل می‌کند. در اینجا چند مثال از استفاده از مقادیر Option برای نگه‌داری انواع عددی و کاراکتری آورده شده است:

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

نوع some_number برابر با Option<i32> است. نوع some_char برابر با Option<char> است که یک نوع متفاوت است. Rust می‌تواند این انواع را تشخیص دهد زیرا ما مقداری را در حالت 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>`:
            `&'a i8` implements `Add<i8>`
            `&i8` implements `Add<&i8>`
            `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 است.

از بین بردن خطر فرض نادرست درباره یک مقدار not-null به شما کمک می‌کند تا در کد خود اطمینان بیشتری داشته باشید. برای داشتن مقداری که ممکن است null باشد، باید صریحاً با تعیین نوع آن مقدار به‌عنوان Option<T> به آن رضایت دهید. سپس، هنگامی که از آن مقدار استفاده می‌کنید، موظف هستید که به‌طور صریح حالتی را که مقدار null است مدیریت کنید. هر جا که مقداری از نوعی است که Option<T> نیست، می‌توانید با خیال راحت فرض کنید که مقدار null نیست. این تصمیم طراحی برای محدود کردن شیوع null و افزایش ایمنی کدهای Rust بود.

پس چگونه مقدار T را از حالت Some وقتی که یک مقدار از نوع Option<T> دارید استخراج می‌کنید تا بتوانید از آن مقدار استفاده کنید؟ Enum Option<T> تعداد زیادی متد دارد که در موقعیت‌های مختلف مفید هستند؛ می‌توانید آن‌ها را در مستندات آن بررسی کنید. آشنایی با متدهای موجود در Option<T> در مسیر یادگیری Rust بسیار مفید خواهد بود.

به طور کلی، برای استفاده از یک مقدار Option<T>، می‌خواهید کدی داشته باشید که هر حالت را مدیریت کند. می‌خواهید کدی داشته باشید که تنها زمانی اجرا شود که یک مقدار Some(T) دارید، و این کد اجازه دارد از مقدار داخلی T استفاده کند. همچنین، می‌خواهید کدی داشته باشید که فقط در صورت وجود مقدار None اجرا شود، و این کد به هیچ مقدار T دسترسی ندارد. عبارت match یک سازه جریان کنترلی است که وقتی با Enumها استفاده می‌شود دقیقاً این کار را انجام می‌دهد: این عبارت کد متفاوتی را بسته به اینکه کدام حالت از Enum موجود است اجرا می‌کند، و آن کد می‌تواند از داده داخل مقدار منطبق شده استفاده کند.