انواع (Typeهای) پیشرفته
سیستم نوعبندی Rust شامل ویژگیهایی است که تاکنون فقط به آنها اشاره کردهایم و هنوز بهطور کامل مورد بحث قرار نگرفتهاند. ابتدا به بررسی الگوی newtype میپردازیم تا بفهمیم چرا این الگو بهعنوان انواع مفید است. سپس به aliasهای نوع میپردازیم، که ویژگی مشابهی با newtype دارند اما با تفاوتهایی در معناشناسی. همچنین، نوع ! و انواع پویا (dynamically sized types) را نیز بررسی خواهیم کرد.
استفاده از الگوی Newtype برای ایمنی نوع و انتزاع
این بخش فرض میکند که پیشتر بخش «استفاده از الگوی Newtype برای پیادهسازی Traitهای خارجی» را خواندهاید. الگوی newtype برای کارهایی فراتر از آنچه تاکنون بحث کردیم نیز مفید است، از جمله اعمال محدودیتهای ایستا (statically) برای جلوگیری از اشتباه گرفتن مقادیر و مشخصکردن واحد یک مقدار. مثالی از استفادهی newtype برای مشخصکردن واحدها را در لیستینگ 20-16 مشاهده کردید: به خاطر بیاورید که ساختارهای Millimeters و Meters مقادیر u32 را درون یک newtype میپیچیدند. اگر تابعی با پارامتری از نوع Millimeters بنویسیم، برنامهای که بهاشتباه سعی کند آن تابع را با مقداری از نوع Meters یا یک u32 معمولی فراخوانی کند، کامپایل نخواهد شد.
ما همچنین میتوانیم از الگوی newtype برای انتزاع جزئیات پیادهسازی یک نوع استفاده کنیم: نوع جدید میتواند یک API عمومی ارائه دهد که با API نوع داخلی خصوصی متفاوت است.
الگوی newtype همچنین میتواند پیادهسازی داخلی را پنهان کند. برای مثال، میتوانیم یک نوع People ارائه دهیم که یک HashMap<i32, String> را در خود بپیچد؛ این ساختار شناسهی هر فرد را با نام او نگه میدارد. کدی که از People استفاده میکند، تنها با API عمومیای که ما ارائه میدهیم تعامل خواهد داشت، مانند متدی برای افزودن یک رشتهی نام به مجموعهی People؛ این کد نیازی ندارد بداند که ما بهصورت داخلی برای نامها یک شناسهی i32 اختصاص میدهیم. الگوی newtype روشی سبکوزن برای رسیدن به کپسولهسازی و پنهانسازی جزئیات پیادهسازی است؛ موضوعی که در فصل ۱۸، در بخش «کپسولهسازی برای پنهانسازی جزئیات پیادهسازی» بررسی کردیم.
ایجاد مترادفهای نوع با استفاده از Type Aliases
Rust قابلیت تعریف alias نوع را برای ارائه یک نام دیگر برای یک نوع موجود فراهم میکند. برای این کار از کلمهکلیدی type استفاده میکنیم. بهعنوان مثال، میتوانیم aliasای به نام Kilometers برای نوع i32 ایجاد کنیم:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
اکنون نام مستعار Kilometers یک مترادف برای نوع i32 است؛ برخلاف انواع Millimeters و Meters که در لیستینگ 20-16 ایجاد کردیم، Kilometers یک نوع جداگانه و جدید نیست. مقادیری که نوع آنها Kilometers باشد، همانند مقادیر نوع i32 در نظر گرفته میشوند:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
از آنجا که Kilometers و i32 از نظر نوع یکسان هستند، میتوانیم مقادیری از هر دو نوع را با هم جمع کنیم و همچنین میتوانیم مقادیر Kilometers را به توابعی بدهیم که پارامترهایی از نوع i32 دارند.
با این حال، با استفاده از این روش، مزایای بررسی نوع را که از الگوی newtype (الگوی نوع جدید) بهدست میآید، نخواهیم داشت.
بهعبارت دیگر، اگر در جایی Kilometers و i32 را با هم اشتباه بگیریم، کامپایلر به ما خطا نخواهد داد.
استفاده اصلی از مترادفهای نوع برای کاهش تکرار است. بهعنوان مثال، ممکن است یک نوع طولانی مانند این داشته باشیم:
Box<dyn Fn() + Send + 'static>
نوشتن این نوع طولانی در امضاهای توابع و بهعنوان توضیحات نوع در سراسر کد میتواند خستهکننده و مستعد خطا باشد. تصور کنید پروژهای پر از کدی مانند آنچه در فهرست 20-25 نشان داده شده است.
<فهرست شماره=“20-25” عنوان=“استفاده از یک نوع طولانی در مکانهای متعدد”>
fn main() { let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi")); fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) { // --snip-- } fn returns_long_type() -> Box<dyn Fn() + Send + 'static> { // --snip-- Box::new(|| ()) } }
</فهرست>
یک نوع مستعار (type alias) این کد را با کاهش تکرار خواناتر و مدیریتپذیرتر میکند. در فهرست 20-26، ما یک مستعار به نام Thunk برای نوع طولانی معرفی کردهایم و میتوانیم همه استفادهها از این نوع را با مستعار کوتاهتر Thunk جایگزین کنیم.
fn main() { type Thunk = Box<dyn Fn() + Send + 'static>; let f: Thunk = Box::new(|| println!("hi")); fn takes_long_type(f: Thunk) { // --snip-- } fn returns_long_type() -> Thunk { // --snip-- Box::new(|| ()) } }
</فهرست>
این کد بسیار خواناتر و نوشتن آن آسانتر است! انتخاب یک نام معنادار برای نوع مستعار میتواند به انتقال مقصود شما کمک کند. (برای مثال، thunk کلمهای است که به کدی اشاره دارد که قرار است در آینده اجرا شود، بنابراین برای اشاره به یک closure که ذخیره میشود، مناسب است).
نوعهای مستعار همچنین معمولاً با نوع Result<T, E> برای کاهش تکرار استفاده میشوند. بهعنوان نمونه، ماژول std::io در کتابخانه استاندارد را در نظر بگیرید. عملیات I/O اغلب یک Result<T, E> برمیگرداند تا مواقعی که عملیات با شکست مواجه میشود مدیریت شود. این کتابخانه یک ساختار std::io::Error دارد که تمامی خطاهای ممکن در I/O را نمایش میدهد. بسیاری از توابع در std::io Result<T, E> را برمیگردانند که در آن E برابر با std::io::Error است، مانند این توابع در trait Write:
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
عبارت Result<..., Error> به دفعات تکرار شده است. به همین دلیل، در ماژول std::io یک نوع مستعار (type alias) به این شکل تعریف شده است:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
از آنجا که این تعریف در ماژول std::io قرار دارد، میتوانیم از نوع مستعار std::io::Result<T> استفاده کنیم؛ به این معنی که Result<T, E> با مقدار E برابر با std::io::Error است. امضای توابع موجود در trait Write به این شکل خواهد بود:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
این نوع مستعار از دو جنبه کمککننده است: نوشتن کد را سادهتر میکند و یک رابط کاربری یکپارچه در تمام بخشهای std::io فراهم میآورد. از آنجا که این یک مستعار است، همچنان یک Result<T, E> معمولی است؛ به این معنی که میتوانیم از تمام متدهایی که روی Result<T, E> کار میکنند استفاده کنیم، همچنین از نحو خاص مانند عملگر ?.
The Never Type That Never Returns
Rust دارای یک نوع ویژه به نام ! است که در نظریه نوعها به عنوان نوع خالی (empty type) شناخته میشود، زیرا هیچ مقداری ندارد. ما ترجیح میدهیم آن را نوعی که هرگز بازنمیگردد (never type) بنامیم، زیرا بهجای نوع بازگشتی قرار میگیرد زمانی که یک تابع هرگز بازنمیگردد. به مثال زیر توجه کنید:
fn bar() -> ! {
// --snip--
panic!();
}
این کد به این صورت خوانده میشود: «تابع bar هرگز باز نمیگردد.»
تابعهایی که هرگز باز نمیگردند، تابعهای واگرا (diverging functions) نام دارند.
ما نمیتوانیم مقداری از نوع ! بسازیم، بنابراین bar نمیتواند هیچگاه مقداری بازگرداند.
اما چه فایدهای دارد نوعی که نمیتوانید هیچ مقداری از آن بسازید؟ کدی را به یاد آورید که در لیست ۲-۵، بخشی از بازی حدس عدد بود؛ ما بخشی از آن را اینجا در لیست 20-27 بازتولید کردهایم.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
match با بازوی پایانی که به continue ختم میشوددر آن زمان، از برخی جزئیات در این کد عبور کردیم.
در بخش «ساختار کنترلی match» در فصل ۶،
بحث کردیم که تمام بازوهای match باید یک نوع بازگشتی یکسان داشته باشند.
برای مثال، کد زیر کار نمیکند:
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
نوع guess در این کد باید هم عدد صحیح (integer) و هم رشته (string) باشد، و Rust نیاز دارد که guess تنها یک نوع داده داشته باشد. بنابراین، دستور continue چه مقداری را برمیگرداند؟ چگونه توانستیم از یک بازو مقدار u32 بازگردانیم و در بازوی دیگر continue را قرار دهیم که در لیست ۲۰-۲۷ آورده شده است؟
همانطور که احتمالاً حدس زدهاید، دستور continue دارای نوع ! است. یعنی، وقتی Rust نوع guess را محاسبه میکند، به هر دو بازوی match نگاه میکند: بازوی اول مقداری از نوع u32 دارد و بازوی دوم مقداری از نوع !. از آنجا که ! نمیتواند هیچ مقداری داشته باشد، Rust نتیجهگیری میکند که نوع guess برابر با u32 است.
روش رسمی برای توصیف این رفتار این است که عبارتهای نوع ! میتوانند به هر نوع دیگری تبدیل شوند (coerce). ما میتوانیم بازوی match را با دستور continue پایان دهیم زیرا continue مقداری باز نمیگرداند؛ بلکه کنترل را به بالای حلقه بازمیگرداند، بنابراین در حالت Err، هیچ مقداری به guess اختصاص داده نمیشود.
نوع ! در ماکرو panic! نیز مفید است. به یاد بیاورید تابع unwrap که روی مقادیر Option<T> فراخوانی میکنیم تا مقداری را تولید کند یا با استفاده از این تعریف متوقف شود:
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
در این کد، همان چیزی که در match لیست ۲۰-۲۷ رخ داد اتفاق میافتد: Rust میبیند که val از نوع T است و panic! از نوع ! است، بنابراین نتیجه کلی عبارت match برابر با T است. این کد کار میکند زیرا panic! هیچ مقداری تولید نمیکند؛ بلکه برنامه را متوقف میکند. در حالت None، ما مقداری از unwrap بازنمیگردانیم، بنابراین این کد معتبر است.
یک عبارت نهایی که نوع ! دارد، حلقه loop است:
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
اینجا، حلقه هیچگاه متوقف نمیشود، بنابراین نوع ! مقدار عبارت خواهد بود. با این حال، اگر break درون حلقه باشد، این موضوع درست نخواهد بود، زیرا حلقه وقتی به break میرسد، متوقف میشود.
فایل تکمیل شد.
typeها با اندازه پویا (dynamic) و ویژگی Sized
بیایید وارد جزئیات نوعی با اندازهی پویا (Dynamically Sized Type یا DST) به نام str شویم
که در طول این کتاب بارها از آن استفاده کردهایم.
بله درست است، منظورمان &str نیست، بلکه خود str بهتنهایی یک DST است.
در بسیاری از موارد، مانند زمانی که متنی توسط کاربر وارد میشود،
ما نمیدانیم طول رشته چقدر است تا زمانی که برنامه اجرا شود.
این بدان معناست که نمیتوانیم یک متغیر از نوع str ایجاد کنیم
و نه میتوانیم آرگومانی از نوع str دریافت کنیم.
به مثال زیر توجه کنید که کار نمیکند:
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
Rust نیاز دارد که بداند چقدر حافظه برای هر مقدار از یک نوع خاص تخصیص دهد، و تمام مقادیر یک نوع باید از همان مقدار حافظه استفاده کنند. اگر Rust اجازه میداد این کد را بنویسیم، این دو مقدار str باید از یک مقدار فضا استفاده میکردند. اما آنها طولهای متفاوتی دارند: s1 به ۱۲ بایت فضای ذخیرهسازی نیاز دارد و s2 به ۱۵ بایت. به همین دلیل است که ایجاد یک متغیر که یک نوع با اندازه دایتانیک داشته باشد ممکن نیست.
پس چه کار باید بکنیم؟ در این حالت، شما از قبل پاسخ را میدانید:
باید نوعهای s1 و s2 را به جای str، به &str تغییر دهیم.
به یاد دارید از بخش “برشهای رشتهای” در فصل ۴
که ساختار دادهی slice فقط موقعیت شروع و طول برش را ذخیره میکند.
بنابراین، اگرچه یک &T یک مقدار منفرد است که آدرس حافظهای که T در آن قرار دارد را ذخیره میکند،
یک &str دو مقدار دارد: آدرس str و طول آن.
از این رو، میتوانیم اندازهی مقدار &str را در زمان کامپایل بدانیم:
این اندازه دو برابر طول یک usize است.
یعنی، همیشه اندازهی &str را میدانیم، فارغ از اینکه طول رشتهای که به آن اشاره میکند چقدر باشد.
به طور کلی، این همان روشی است که نوعهای با اندازهی پویا در Rust استفاده میشوند:
آنها یک قطعه اضافی از فراداده (metadata) دارند که اندازهی اطلاعات پویا را ذخیره میکند.
قانون طلایی نوعهای با اندازهی پویا این است که
همیشه باید مقادیر این نوعها را پشت یک نوع اشارهگر (pointer) قرار دهیم.
ما میتوانیم str را با انواع مختلفی از اشارهگرها ترکیب کنیم:
برای مثال، Box<str> یا Rc<str>.
در واقع، قبلاً نیز این را دیدهاید اما با نوعی دیگر از نوعهای با اندازهی پویا: traits.
هر trait یک نوع با اندازهی پویا است که میتوانیم با استفاده از نام trait به آن ارجاع دهیم.
در بخش “استفاده از trait objectها برای انتزاعسازی روی رفتار مشترک”
در فصل ۱۸، گفتیم که برای استفاده از traits به عنوان trait object،
باید آنها را پشت یک اشارهگر قرار دهیم، مانند &dyn Trait یا Box<dyn Trait> (Rc<dyn Trait> نیز کار میکند).
برای کار با تایپهای دایتانیک، Rust ویژگی Sized را فراهم میکند تا تعیین کند که آیا اندازه یک نوع در زمان کامپایل مشخص است یا خیر. این ویژگی به طور خودکار برای هر چیزی که اندازه آن در زمان کامپایل مشخص باشد پیادهسازی میشود. علاوه بر این، Rust به طور ضمنی یک محدودیت روی Sized را به هر تابع جنریک اضافه میکند. یعنی یک تعریف تابع جنریک به این صورت:
fn generic<T>(t: T) {
// --snip--
}
در واقع، به گونهای رفتار میشود که گویی این را نوشتهایم:
fn generic<T: Sized>(t: T) {
// --snip--
}
به طور پیشفرض، توابع جنریک فقط روی نوعهایی کار خواهند کرد که اندازه آنها در زمان کامپایل مشخص باشد. با این حال، میتوانید از سینتکس خاص زیر برای کاهش این محدودیت استفاده کنید:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
یک محدودیت ویژگی روی ?Sized به این معنی است که “T ممکن است Sized باشد یا نباشد” و این یادداشت، پیشفرضی که نوعهای جنریک باید اندازه مشخصی در زمان کامپایل داشته باشند را لغو میکند. سینتکس ?Trait با این معنا تنها برای Sized در دسترس است، نه برای هیچ ویژگی دیگری.
همچنین توجه داشته باشید که نوع پارامتر t را از T به &T تغییر دادیم. از آنجایی که نوع ممکن است Sized نباشد، باید از آن پشت یک نوع اشارهگر (Pointer) استفاده کنیم. در این مورد، یک ارجاع انتخاب کردهایم.
در ادامه، درباره توابع و closureها صحبت خواهیم کرد!