انواع (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ها صحبت خواهیم کرد!