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