مقدمه

در فصل قبل دیدیم که چطور می‌توانیم از struct برای تعریف نوع‌های سفارشی استفاده کنیم و همانجا اشاره کردیم که یک نوع سفارشی دیگر با نام enum نیز در Rust وجود دارد. enum مخفف enumeration به معنای شمارشی است و همانطور که نامش نشان می‌دهد، برای تعریف نوع‌هایی کاربرد دارد که مقادیرشان می‌توانند یک مقدار از بین چند مقدار مشخص باشند. همانطور که struct ما را قادر می‌کند که داده‌ها و فیلدهای مرتبط را با هم گروه کنیم و مثلاً نوعی بسازیم به نام Rectangle با فیلدهای width و height، با استفاده از enum هم می‌توانیم نوعی بسازیم به نام Shape که اعضایش می‌توانند یک مستطیل یا Rectangle، یک دایره یا Circle و یا یک مثلث یا Triangle باشند.

تعریف و استفاده از enum

کار معرفی و بررسی نوع enum را با بررسی یک موقعیتِ نمونه شروع می‌کنیم و از این طریق نشان خواهیم داد که چرا شمارشی‌ها مفید هستند و در چه مواردی نسبت به ساختارها، انتخاب مناسب‌تری محسوب می‌شوند. فرض کنید می‌خواهیم با آدرس‌های IP کار کنیم. در حال حاضر، دو استاندارد اصلی برای آدرس‌های IP وجود دارد: ورژن 4 و ورژن 6. از آنجایی که یک آدرس IP می‌تواند فقط به یکی از این دو فرم باشد، می‌توانیم یک enum تعریف کنیم. در واقع، یک آدرس IP می‌تواند فقط به یکی از این دو فرم باشد و در هر لحظه نمی‌تواند به هر دو فرم باشد و این ویژگیِ آدرس‌های IP آن را به یک انتخاب مناسب برای تعریف به عنوان یک شمارشی، تبدیل می‌کند.

برای پیاده‌سازی این مفهوم در کد، می‌توانیم یک شمارشی با نام IpAddrKind تعریف کنیم و مقادیر یا حالت‌های (variants) ممکن برای آن را V4 و V6 بنامیم.

enum IpAddrKind {
  V4,
  V6,
}

حالا IpAddrKind یک نوع سفارشی است که می‌توانیم در هر جایی از برنامه از آن استفاده کنیم.

مقادیر enum

بعد از تعریف شمارشی IpAddrKind، می‌توانیم مانند زیر، نمونه‌هایی (instances) از هر یک از دو حالت (variant) آن ایجاد کنیم.

Copy Icon src/main.rs
fn main() {
  let four = IpAddrKind::V4;
  let six = IpAddrKind::V6;
}
          
enum IpAddrKind {
  V4,
  V6,
}

الان هر دو مقدار IpAddrKind::V4 و IpAddrKind::V6 دارای نوع یکسانی به نام IpAddrKind هستند. اگر مثلاً تابعی تعریف کنیم که یک پارامتر از نوع IpAddrKind داشته باشد، می‌توانیم هر یک از این دو مقدار را به آن پاس کنیم.

Copy Icon src/main.rs
fn main() {
  route(IpAddrKind::V4);
  route(IpAddrKind::V6);
}
          
fn route(ip_kind: IpAddrKind) {}
          
enum IpAddrKind {
  V4,
  V6,
}

نوع IpAddrKind فعلاً راهی برای ذخیره‌ی آدرس واقعی ندارد و فقط نوع آن را مشخص می‌کند. با استفاده‌ی ترکیبی از struct و enum می‌توانیم نوعی ایجاد کنیم که هم نوع آدرس و هم خود آدرس را ذخیره کند.

Copy Icon src/main.rs
fn main() {
  let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
  };
              
  let loopback = IpAddr {
    kind: IpAddrKind::V6,
    address: String::from("::1"),
  };
}
            
enum IpAddrKind {
  V4,
  V6,
}
            
struct IpAddr {
  kind: IpAddrKind,
  address: String,
}

در اینجا یک ساختار با نام IpAddr تعریف شده که دو فیلد دارد. یکی فیلد kind که از نوع IpAddrKind (یعنی شمارشی‌ای که قبلاً تعریف کردیم) است و دیگری فیلد address که از نوع String است. سپس، دو نمونه از ساختار IpAddr با تام‌های home و loopback ایجاد شده است. به این ترتیب، با استفاده از یک ساختار، مقادیر kind و address را با هم مرتبط کرده‌ایم و نوعی داریم که هم آدرس و هم نوع آدرس را نگه می‌دارد.

اما حقیقت ماجرا این است که ما بدون استفاده از ساختارها و تنها با استفاده از یک شمارشی هم می‌توانیم به خواسته‌ی خودمان برسیم. موضوع مهم این است که variants یا آیتم‌های یک شمارشی می‌توانند مقدار هم نگه دارند. در کد زیر، IpAddr به عنوان یک شمارشی تعریف شده که هر دو آیتمش یک مقدار String را نگه می‌دارند.

Copy Icon src/main.rs
fn main() {
  let home = IpAddr::V4(String::from("127.0.0.1"));
          
  let loopback = IpAddr::V6(String::from("::1"));
}
          
enum IpAddr {
  V4(String),
  V6(String),
}

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

استفاده از شمارشی به جای ساختار، یک مزیت ویژه‌ی دیگر هم دارد: هر variant یا آیتم شمارشی می‌تواند مقداری از یک نوع متفاوت را نگه دارد. می‌دانیم که هر آدرس IP ورژن 4 از 4 کامپوننت تشکیل می‌شود که هر یک مقداری بین صفر تا 255 دارند. اگر بخواهیم ترتیبی دهیم که آدرس‌های ورژن 4 چهار مقدار u8 را نگه دارند اما آدرس‌های ورژن 6 همچنان از نوع String باشند، ساختارها نمی‌توانند کمکی به ما بکنند اما یک شمارشی این خواسته را به راحتی برآورده می‌کند.

Copy Icon src/main.rs
fn main() {
  let home = IpAddr::V4(127, 0, 0, 1);
          
  let loopback = IpAddr::V6(String::from("::1"));
}
          
enum IpAddr {
  V4(u8, u8, u8, u8),
  V6(String),
}

تا اینجا چند روش برای تعریف ساختار داده‌ای که آدرس‌های IP ورژن 4 و 6 را ذخیره کند، ارائه شد. با این حال، کار با آدرس‌های IP به قدری رایج هست که کتابخانه‌ی استاندارد نوعی را برای کار با این آدرس‌ها فراهم کند. این نوع IpAddr نام دارد و به صورت زیر تعریف شده است.

Copy Icon src/main.rs
struct Ipv4Addr {
  // --snip--
}
          
struct Ipv6Addr {
  // --snip--
}
          
enum IpAddr {
  V4(Ipv4Addr),
  V6(Ipv6Addr),
}

شمارشی IpAddr مشابه همان چیزی است که ما تعریف کردیم اما با این تفاوت که نوع مقداری که هر variant یا آیتم نگه می‌دارد، struct است. پس، نوع مقادیری که آیتم‌های یک شمارشی نگه می‌دارند، می‌تواند هر چیزی باشد؛ عدد، رشته، ساختار یا حتی یک شمارشی دیگر.

اجازه دهید یک مثال دیگر از شمارشی‌ها ببینیم. شمارشی Message در کد زیر از نوع‌های متنوعی برای آیتم‌های خود استفاده می‌کند.

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

این شمارشی 4 آیتم یا variant با نوع‌های مختلف دارد.

  • Quit هیچ دیتایی را نگه نمی‌دارد.
  • Move مثل یک ساختار، فیلدهای دارای نام دارد.
  • Write شامل یک نوع String است.
  • ChangeColor شامل سه مقدار i32 است.

تعریف یک شمارشی مثل Message مشابه تعریف انواع مختلف ساختارهاست. ساختارهای زیر می‌توانند دیتای یکسانی با شمارشی Message را نگه دارند.

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

اما در صورنی که از ساختارهای مختلف استفاده کنیم، هر کدام از آنها نوع خودش را دارد و مثلاً نمی‌توانیم به راحتی تابعی تعریف کنیم که همه‌ی این نوع‌ها را بپذیرد.

یک شباهت دیگر بین enum و struct این است که برای یک enum هم می‌توان یک بلاک impl ایجاد کرد و متدهایی را برای آن enum تعریف کرد.

Copy Icon src/main.rs
impl Message {
  fn call(&self) {
    // method body
  }
}
      
let m = Message::Write(String::from("hello"));
m.call();

شمارشی Option

در این بخش، یک شمارشی دیگر از کتابخانه‌ی استاندارد را معرفی و بررسی می‌کنیم که خیلی مفید و پرکاربرد است. این شمارشی Option نام دارد و برای پیاده‌سازی یک سناریوی بسیار رایج و متداول به کار می‌رود: نمایش مقداری که می‌تواند وجود داشته باشد یا وجود نداشته باشد. برای مثال، اگر اولین آیتم یک لیست غیر تهی را درخواست کنیم، مقداری را دریافت خواهیم کرد اما اگر اولین آیتم یک لیست تهی را درخواست کنیم، مقداری در کار نخواهد بود. وقتی با لیستی کار می‌کنیم که می‌تواند تهی باشد، باید این موضوع را در نظر بگیریم. از منظر سیستم نوع (type system) این یعنی اینکه کامپایلر باید بتواند بررسی کند که آیا ما همه‌ی حالاتی را که می‌تواند رخ دهد، هندل کرده‌ایم یا خیر.

کامپایلر Rust بر خلاف خیلی از زبان‌های دیگر، این توانایی را دارد؛ چون بر خلاف زبان‌هایی که متکی به مفهومی به نام Null هستند، Rust روش دیگری را دنبال می‌کند. null یک مقدار است که می‌تواند مقدار معتبری نباشد. در زبان‌هایی که از این مفهوم پشتیبانی می‌کنند، یک متغیر همیشه در یکی از دو وضعیت null یا not-null قرار دارد.

اشتباه میلیارد دلاری

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

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

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

به خاطر استفاده‌ی زیادی که از Option می‌شود، این شمارشی به عنوان بخشی از prelude (زیرمجموعه‌ای از کتابخانه‌ی استاندارد که به طور خودکار در دسترس همه‌ی پروژه‌ها قرار دارد) ارائه می‌شود و نیازی نیست که آن را به طور صریح به پروژه اضافه کنیم. حتی variants یا آیتم‌های این شمارشی هم در prelude قرار دارند و بنابراین، می‌توانیم از Some و None بدون پیشوند Option:: استفاده کنیم.

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

Copy Icon src/main.rs
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 به type annotaion یا اعلان نوع صریح نیاز دارد، چون Rust نمی‌تواند با دیدن None بفهمد که آیتم Some متناظرش چه مقداری را نگه می‌دارد. بنابراین، در اینجا ما Option<i32> را صراحتاً به عنوان نوع این متغیر تعیین کرده‌ایم.

وقتی ما یک مقدار Some داشته باشیم، می‌دانیم که مقداری وجود دارد و این مقدار توسط Some نگه داشته می‌شود. اما وقتی یک مقدار None داریم، به این معناست که مقدار معتبری نداریم؛ یعنی چیزی شبیه null. اما برتری Option به null چیست؟

به طور خلاصه می‌توان گفت از آنجایی که T و Option<T> نوع‌های متفاوتی هستند، کامپایلر به ما اجازه نمی‌دهد از یک مقدار Option<T> طوری استفاده کنیم که انگار یک مقدار قطعی و معتبر است. برای مثال، کد زیر کامپایل نخواهد شد چون سعی دارد یک مقدار i8 را با یک مقدار Option<i8> جمع کند.

Copy Icon src/main.rs
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 as Add<i8>>
            <&i8 as Add<&i8>>
            <i8 as Add<&i8>>
            <i8 as Add>

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

این پیغام خطا می‌گوید که i8 و Option<i8> دو نوع متفاوت هستند و نمی‌توان آنها را با هم جمع کرد. وقتی ما مقداری از از نوعی مثل i8 داشته باشیم، کامپایلر تضمین می‌کند که ما همیشه یک مقدار معتبر داریم. فقط وقتی مقداری از نوعی مثل مثل Option<i8> داشته باشیم، باید در مورد وجود یا عدم وجود یک مقدار معتبر نگران باشیم و کامپایلر چک می‌کند تا مطمئن شود که ما همه‌ی حالات ممکن را هندل کرده باشیم.

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

بنابراین، وقتی مقداری داریم که می‌تواند null باشد، باید نوع آن را Option تعیین کنیم و قبل از استفاده از آن باید الزاماً هر دو حالت null و not-null را هندل کنیم. یعنی باید کدی داشته باشیم که فقط وقتی اجرا شود که ما یک مقدار Some(T) داشته باشیم و کدی که فقط وقتی اجرا شود که ما یک مقدار None داشته باشیم.

برای استحراج یک مقدار T از یک مقدار Option<T> می‌توانیم از متدهای مربوط به نوع Option استفاده کنیم اما در درس بعد، پس از آشنایی با عبارات match، خواهیم دید که چطور می‌توانیم از این عبارات برای استخراج مقدار T از Option<T> استفاده کنیم.