تعریف و استفاده از enum
کار معرفی و بررسی نوع enum را با بررسی یک موقعیتِ نمونه شروع میکنیم و از این طریق نشان
خواهیم داد که چرا شمارشیها مفید هستند و در چه مواردی نسبت به ساختارها،
انتخاب مناسبتری محسوب میشوند. فرض کنید میخواهیم با آدرسهای IP کار کنیم.
در حال حاضر، دو استاندارد اصلی برای آدرسهای IP وجود دارد: ورژن 4 و ورژن 6.
از آنجایی که یک آدرس IP میتواند فقط به یکی از این دو فرم باشد، میتوانیم یک enum تعریف
کنیم.
در واقع، یک آدرس IP میتواند فقط به یکی از این دو فرم باشد و در هر لحظه نمیتواند به هر دو فرم باشد و این
ویژگیِ
آدرسهای IP آن را به یک انتخاب مناسب برای تعریف به عنوان یک شمارشی، تبدیل میکند.
برای پیادهسازی این مفهوم در کد، میتوانیم یک شمارشی با نام IpAddrKind تعریف کنیم و
مقادیر یا حالتهای (variants) ممکن برای آن را V4 و V6 بنامیم.
enum IpAddrKind {
V4,
V6,
}
حالا IpAddrKind یک نوع سفارشی است که میتوانیم در هر جایی از برنامه از آن استفاده کنیم.
مقادیر enum
بعد از تعریف شمارشی IpAddrKind، میتوانیم مانند زیر، نمونههایی (instances) از هر یک از دو حالت
(variant)
آن ایجاد کنیم.
src/main.rs
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
}
enum IpAddrKind {
V4,
V6,
}
الان هر دو مقدار IpAddrKind::V4 و IpAddrKind::V6 دارای نوع یکسانی به نام IpAddrKind
هستند.
اگر مثلاً تابعی تعریف کنیم که یک پارامتر از نوع IpAddrKind داشته باشد،
میتوانیم هر یک از این دو مقدار را به آن پاس کنیم.
src/main.rs
fn main() {
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
enum IpAddrKind {
V4,
V6,
}
نوع IpAddrKind فعلاً راهی برای ذخیرهی آدرس واقعی ندارد و فقط نوع آن را مشخص میکند.
با استفادهی ترکیبی از struct و enum میتوانیم نوعی ایجاد کنیم که هم
نوع آدرس و هم
خود آدرس را ذخیره کند.
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 را نگه میدارند.
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 باشند، ساختارها نمیتوانند کمکی به ما بکنند اما
یک شمارشی این خواسته را به راحتی برآورده میکند.
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 نام دارد و به صورت زیر تعریف شده است.
src/main.rs
struct Ipv4Addr {
}
struct Ipv6Addr {
}
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;
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String);
struct ChangeColorMessage(i32, i32, i32);
اما در صورنی که از ساختارهای مختلف استفاده کنیم، هر کدام از آنها
نوع خودش را دارد و مثلاً نمیتوانیم به راحتی تابعی تعریف کنیم که
همهی این نوعها را بپذیرد.
یک شباهت دیگر بین enum و struct این است که برای یک enum هم میتوان
یک بلاک impl ایجاد کرد و متدهایی را برای آن enum تعریف کرد.
src/main.rs
impl Message {
fn call(&self) {
}
}
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 است که دادههایی از نوعهای مختلف را نگه
میدارند.
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> جمع کند.
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> استفاده
کنیم.