انواع (Typeهای) پیشرفته
سیستم نوعبندی Rust شامل ویژگیهایی است که تاکنون فقط به آنها اشاره کردهایم و هنوز بهطور کامل مورد بحث قرار نگرفتهاند. ابتدا به بررسی الگوی newtype میپردازیم تا بفهمیم چرا این الگو بهعنوان انواع مفید است. سپس به aliasهای نوع میپردازیم، که ویژگی مشابهی با newtype دارند اما با تفاوتهایی در معناشناسی. همچنین، نوع !
و انواع پویا (dynamically sized types) را نیز بررسی خواهیم کرد.
استفاده از الگوی Newtype برای ایمنی نوع و انتزاع
توجه: این بخش فرض میکند که قبلاً بخش “استفاده از الگوی Newtype برای پیادهسازی Traits خارجی روی انواع خارجی” را مطالعه کردهاید.
الگوی newtype علاوه بر مواردی که تاکنون بحث کردیم، برای وظایف دیگری مانند اعمال محدودیتهای استاتیک برای جلوگیری از اشتباه و نمایش واحدهای یک مقدار نیز مفید است. شما یک مثال از استفاده از newtype برای نمایش واحدها را در مثال 20-16 دیدید: در آنجا، ساختارهای Millimeters
و Meters
مقادیر نوع u32
را در یک newtype بستهبندی میکردند. اگر تابعی با پارامتری از نوع Millimeters
بنویسیم، برنامهای که بهطور اشتباه بخواهد این تابع را با مقدار نوع Meters
یا یک u32
ساده فراخوانی کند، کامپایل نخواهد شد.
ما همچنین میتوانیم از الگوی newtype برای انتزاع جزئیات پیادهسازی یک نوع استفاده کنیم: نوع جدید میتواند یک API عمومی ارائه دهد که با API نوع داخلی خصوصی متفاوت است.
الگوی newtype همچنین میتواند پیادهسازی داخلی را مخفی کند. بهعنوان مثال، میتوانیم نوعی به نام People
ارائه دهیم که یک HashMap<i32, String>
را برای ذخیره ID افراد با نام آنها بستهبندی کند. کدی که از People
استفاده میکند، فقط با API عمومی که ارائه میدهیم تعامل خواهد داشت، مانند متدی برای افزودن یک رشته نام به مجموعه People
. این کد نیازی ندارد که بداند ما بهصورت داخلی یک ID نوع 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); }
اکنون، alias 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
جایگزین کنیم.
<فهرست شماره=“20-26” عنوان=“معرفی نوع مستعار 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
هرگز نمیتواند بازگردد.
اما استفاده از نوعی که هرگز نمیتوان مقداری برای آن ایجاد کرد، چیست؟ کد مربوط به Listing 2-5 را به خاطر بیاورید که بخشی از بازی حدس عدد بود. ما بخشی از آن را اینجا در Listing 20-27 بازتولید کردهایم.
use rand::Rng;
use std::cmp::Ordering;
use std::io;
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
زبان Rust نیاز دارد تا جزئیاتی درباره انواع خود بداند، مانند اینکه چقدر فضا برای ذخیرهسازی یک مقدار از یک نوع خاص تخصیص دهد. این امر یکی از گوشههای سیستم انواع این زبان را کمی گیجکننده میکند: مفهوم انواع با اندازه دایتانیک (پویا) (dynamically sized types). گاهی اوقات به این نوعها DST یا انواع بدون اندازه (unsized types) نیز گفته میشود. این نوعها به ما اجازه میدهند تا کدی بنویسیم که با مقادیری کار کند که اندازه آنها تنها در زمان اجرا مشخص میشود.
بیایید به جزئیات یک نوع با اندازه دایتانیک به نام 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
میسازیم. به یاد بیاورید که در بخش “برشهای رشتهای” از فصل ۴ گفته شد که ساختار داده برش تنها موقعیت شروع و طول برش را ذخیره میکند. بنابراین، اگرچه یک &T
تنها یک مقدار است که آدرس حافظهای که T
در آن قرار دارد را ذخیره میکند، یک &str
دو مقدار دارد: آدرس str
و طول آن. بنابراین، ما میتوانیم اندازه یک مقدار &str
را در زمان کامپایل بدانیم: اندازه آن دو برابر طول یک usize
است. به عبارت دیگر، ما همیشه اندازه یک &str
را میدانیم، بدون توجه به اینکه رشتهای که به آن اشاره میکند چقدر طولانی است. به طور کلی، این روش استفاده از انواع با اندازه دایتانیک در Rust است: آنها یک بخش اضافی از متادیتا دارند که اندازه اطلاعات دایتانیک را ذخیره میکند. قانون طلایی انواع با اندازه دایتانیک این است که باید همیشه مقادیر این نوعها را پشت یک نوع اشارهگر (Pointer) قرار دهیم.
ما میتوانیم str
را با انواع مختلف اشارهگر (Pointer) ترکیب کنیم: به عنوان مثال، Box<str>
یا Rc<str>
. در واقع، قبلاً این مورد را دیدهاید اما با یک نوع با اندازه دایتانیک متفاوت: ویژگیها (Traits). هر ویژگی یک نوع با اندازه دایتانیک است که میتوانیم با استفاده از نام ویژگی به آن ارجاع دهیم. در فصل ۱۸ در بخش “استفاده از اشیاء ویژگی که امکان مقادیر با تایپهای مختلف را فراهم میکنند” اشاره کردیم که برای استفاده از ویژگیها به عنوان اشیاء ویژگی، باید آنها را پشت یک اشارهگر (Pointer) قرار دهیم، مانند &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ها صحبت خواهیم کرد!