انواع داده جنریک

ما از جنریک‌ها برای ایجاد تعریف‌هایی برای مواردی مانند امضای توابع یا ساختارها (struct) استفاده می‌کنیم، که سپس می‌توانیم با انواع داده مشخص مختلف از آن‌ها استفاده کنیم. بیایید ابتدا ببینیم چگونه می‌توان توابع، ساختارها، شمارش‌ها (enum)، و متدها را با استفاده از جنریک‌ها تعریف کرد. سپس درباره اینکه جنریک‌ها چگونه بر عملکرد کد تأثیر می‌گذارند صحبت خواهیم کرد.

در تعریف توابع

هنگام تعریف یک تابع که از جنریک‌ها استفاده می‌کند، جنریک‌ها را در امضای تابع قرار می‌دهیم، جایی که معمولاً نوع داده پارامترها و مقدار بازگشتی را مشخص می‌کنیم. این کار کد ما را انعطاف‌پذیرتر می‌کند و به فراخوانی‌کنندگان تابع ما عملکرد بیشتری ارائه می‌دهد، در حالی که از تکرار کد جلوگیری می‌کند.

با ادامه تابع largest، لیست ۱۰-۴ دو تابع را نشان می‌دهد که هر دو بزرگ‌ترین مقدار را در یک بخش (slice) پیدا می‌کنند. سپس این‌ها را به یک تابع واحد که از جنریک‌ها استفاده می‌کند ترکیب خواهیم کرد.

Filename: src/main.rs
fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
    assert_eq!(*result, 'y');
}
Listing 10-4: دو تابع که فقط در نام‌ها و انواع موجود در امضاهایشان متفاوت هستند

تابع largest_i32 همان تابعی است که در لیست ۱۰-۳ استخراج کردیم و بزرگ‌ترین مقدار i32 را در یک بخش پیدا می‌کند. تابع largest_char بزرگ‌ترین مقدار char را در یک بخش پیدا می‌کند. بدنه توابع دارای کد یکسانی هستند، بنابراین با معرفی یک پارامتر نوع جنریک در یک تابع واحد، تکرار را حذف می‌کنیم.

برای پارامتری کردن نوع‌ها در یک تابع جدید، باید نام پارامتر نوع را مشخص کنیم، همانطور که برای پارامترهای مقداری در یک تابع انجام می‌دهیم. می‌توانید از هر شناسه‌ای به عنوان نام پارامتر نوع استفاده کنید. اما از آنجا که طبق قرارداد، نام‌های پارامتر نوع در Rust کوتاه هستند، اغلب فقط یک حرف، و قرارداد نام‌گذاری نوع در Rust UpperCamelCase است، ما از T استفاده می‌کنیم. کوتاه برای type، T انتخاب پیش‌فرض بیشتر برنامه‌نویسان Rust است.

وقتی از یک پارامتر در بدنه تابع استفاده می‌کنیم، باید نام پارامتر را در امضا اعلام کنیم تا کامپایلر بداند آن نام به چه معناست. به طور مشابه، وقتی از نام پارامتر نوع در امضای تابع استفاده می‌کنیم، باید نام پارامتر نوع را قبل از استفاده از آن اعلام کنیم. برای تعریف تابع جنریک largest، نام نوع‌ها را داخل پرانتزهای زاویه‌ای، <>، بین نام تابع و لیست پارامتر قرار می‌دهیم، مانند زیر:

fn largest<T>(list: &[T]) -> &T {

این تعریف را به این صورت می‌خوانیم: تابع largest بر روی یک نوع T جنریک است. این تابع یک پارامتر به نام list دارد، که یک بخش از مقادیر نوع T است. تابع largest یک مرجع به مقداری از همان نوع T بازمی‌گرداند.

لیست ۱۰-۵ تعریف تابع ترکیبی largest با استفاده از نوع داده جنریک در امضای آن را نشان می‌دهد. این لیست همچنین نشان می‌دهد که چگونه می‌توان تابع را با یک بخش از مقادیر i32 یا مقادیر char فراخوانی کرد. توجه داشته باشید که این کد هنوز کامپایل نمی‌شود، اما بعداً در این فصل آن را رفع خواهیم کرد.

Filename: src/main.rs
fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}
Listing 10-5: تابع largest با استفاده از پارامترهای نوع جنریک؛ این کد هنوز کامپایل نمی‌شود

اگر همین حالا این کد را کامپایل کنیم، این خطا را دریافت می‌کنیم:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

متن کمکی به std::cmp::PartialOrd اشاره می‌کند که یک ویژگی (trait) است، و ما در بخش بعدی درباره ویژگی‌ها صحبت خواهیم کرد. در حال حاضر، بدانید که این خطا بیان می‌کند که بدنه تابع largest برای همه نوع‌های ممکن که T می‌تواند باشد، کار نمی‌کند. از آنجا که می‌خواهیم مقادیر نوع T را در بدنه مقایسه کنیم، فقط می‌توانیم از نوع‌هایی استفاده کنیم که مقادیرشان قابل مرتب‌سازی باشد. برای فعال کردن مقایسه‌ها، کتابخانه استاندارد ویژگی std::cmp::PartialOrd را ارائه می‌دهد که می‌توانید روی نوع‌ها پیاده‌سازی کنید (برای اطلاعات بیشتر درباره این ویژگی به ضمیمه ج مراجعه کنید). با دنبال کردن پیشنهاد متن کمکی، نوع‌های معتبر برای T را به آن‌هایی که PartialOrd را پیاده‌سازی می‌کنند محدود می‌کنیم و این مثال کامپایل خواهد شد، زیرا کتابخانه استاندارد ویژگی PartialOrd را برای هر دو نوع i32 و char پیاده‌سازی کرده است.

در تعریف ساختارها (Struct)

ما می‌توانیم ساختارها را نیز به گونه‌ای تعریف کنیم که از یک پارامتر نوع جنریک در یک یا چند فیلد استفاده کنند، با استفاده از نحو <>. لیست ۱۰-۶ ساختار Point<T> را تعریف می‌کند که مقادیر مختصات x و y از هر نوعی را نگه می‌دارد.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}
Listing 10-6: ساختار Point<T> که مقادیر x و y از نوع T را نگه می‌دارد

نحو استفاده از جنریک‌ها در تعریف ساختارها مشابه استفاده آن‌ها در تعریف توابع است. ابتدا نام پارامتر نوع را در داخل پرانتزهای زاویه‌ای بلافاصله پس از نام ساختار اعلام می‌کنیم. سپس نوع جنریک را در تعریف ساختار استفاده می‌کنیم، جایی که در غیر این صورت نوع داده مشخص را مشخص می‌کردیم.

توجه داشته باشید که از آنجا که فقط یک نوع جنریک برای تعریف Point<T> استفاده کرده‌ایم، این تعریف بیان می‌کند که ساختار Point<T> برای یک نوع T جنریک است و فیلدهای x و y هر دو از همان نوع هستند، هرچه که آن نوع باشد. اگر نمونه‌ای از Point<T> ایجاد کنیم که مقادیر آن انواع مختلف داشته باشند، همانطور که در لیست ۱۰-۷ آمده است، کد ما کامپایل نخواهد شد.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}
Listing 10-7: فیلدهای x و y باید از همان نوع باشند زیرا هر دو دارای نوع داده جنریک T هستند.

در این مثال، وقتی مقدار عدد صحیح 5 را به x اختصاص می‌دهیم، به کامپایلر اطلاع می‌دهیم که نوع جنریک T برای این نمونه از Point<T> یک عدد صحیح خواهد بود. سپس وقتی 4.0 را برای y مشخص می‌کنیم، که تعریف کرده‌ایم همان نوع x را داشته باشد، یک خطای عدم تطابق نوع دریافت می‌کنیم، مانند این:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

برای تعریف یک ساختار Point که در آن x و y هر دو جنریک هستند اما می‌توانند انواع مختلفی داشته باشند، می‌توانیم از پارامترهای نوع جنریک چندگانه استفاده کنیم. برای مثال، در لیست ۱۰-۸، تعریف Point را تغییر می‌دهیم تا برای نوع‌های T و U جنریک باشد، جایی که x از نوع T و y از نوع U است.

Filename: src/main.rs
struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}
Listing 10-8: یک ساختار Point<T, U> جنریک بر روی دو نوع، به طوری که x و y می‌توانند مقادیری از انواع مختلف باشند

حالا تمام نمونه‌های Point نشان داده شده معتبر هستند! شما می‌توانید به تعداد دلخواه پارامترهای نوع جنریک در یک تعریف استفاده کنید، اما استفاده از تعداد زیاد خوانایی کد شما را دشوار می‌کند. اگر می‌بینید که نیاز به انواع جنریک زیادی در کد خود دارید، ممکن است نشان‌دهنده این باشد که کد شما نیاز به ساختاربندی مجدد به بخش‌های کوچک‌تر دارد.

در تعریف شمارش‌ها (Enum)

همانطور که با ساختارها انجام دادیم، می‌توانیم شمارش‌ها را به گونه‌ای تعریف کنیم که نوع داده‌های جنریک را در حالت‌های خود نگه دارند. بیایید دوباره به شمارش Option<T> که کتابخانه استاندارد ارائه می‌دهد و در فصل ۶ از آن استفاده کردیم نگاه کنیم:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

این تعریف اکنون باید برای شما بیشتر معنا پیدا کند. همانطور که می‌بینید، شمارش Option<T> بر روی نوع T جنریک است و دو حالت دارد: Some که یک مقدار از نوع T را نگه می‌دارد و حالت None که هیچ مقداری را نگه نمی‌دارد. با استفاده از شمارش Option<T>، می‌توانیم مفهوم انتزاعی یک مقدار اختیاری را بیان کنیم، و از آنجا که Option<T> جنریک است، می‌توانیم از این انتزاع بدون توجه به نوع مقدار اختیاری استفاده کنیم.

شمارش‌ها نیز می‌توانند از انواع جنریک چندگانه استفاده کنند. تعریف شمارش Result که در فصل ۹ استفاده کردیم یک مثال است:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

شمارش Result بر روی دو نوع جنریک T و E است و دو حالت دارد: Ok که یک مقدار از نوع T نگه می‌دارد و Err که یک مقدار از نوع E نگه می‌دارد. این تعریف استفاده از شمارش Result را در هر جایی که یک عملیات ممکن است موفق شود (یک مقدار از نوع T بازگرداند) یا شکست بخورد (یک خطا از نوع E بازگرداند) آسان می‌کند. در واقع، این همان چیزی است که برای باز کردن یک فایل در لیست ۹-۳ استفاده کردیم، جایی که T با نوع std::fs::File پر شده بود وقتی فایل با موفقیت باز شد و E با نوع std::io::Error پر شده بود وقتی مشکلاتی در باز کردن فایل وجود داشت.

وقتی وضعیت‌هایی در کد خود را شناسایی کردید که چندین تعریف ساختار یا شمارش وجود دارد که فقط در نوع مقادیر نگهداری شده متفاوت هستند، می‌توانید با استفاده از نوع‌های جنریک از تکرار جلوگیری کنید.

در تعریف متدها

ما می‌توانیم متدهایی را روی ساختارها و شمارش‌ها پیاده‌سازی کنیم (همانطور که در فصل ۵ انجام دادیم) و از انواع جنریک در تعریف آن‌ها نیز استفاده کنیم. لیست ۱۰-۹ ساختار Point<T> که در لیست ۱۰-۶ تعریف کردیم را نشان می‌دهد، با متدی به نام x که روی آن پیاده‌سازی شده است.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listing 10-9: پیاده‌سازی متدی به نام x روی ساختار Point<T> که یک مرجع به فیلد x از نوع T بازمی‌گرداند

در اینجا، یک متد به نام x روی Point<T> تعریف کرده‌ایم که یک مرجع به داده موجود در فیلد x بازمی‌گرداند.

توجه داشته باشید که باید T را بلافاصله بعد از impl اعلام کنیم تا بتوانیم از T برای مشخص کردن اینکه داریم متدها را روی نوع Point<T> پیاده‌سازی می‌کنیم، استفاده کنیم. با اعلام T به عنوان یک نوع جنریک بعد از impl، Rust می‌تواند تشخیص دهد که نوع موجود در پرانتزهای زاویه‌ای در Point یک نوع جنریک است، نه یک نوع مشخص. می‌توانستیم نامی متفاوت از پارامتر جنریک اعلام‌شده در تعریف ساختار برای این پارامتر جنریک انتخاب کنیم، اما استفاده از همان نام یک عرف است. اگر یک متد را درون یک impl که یک نوع جنریک اعلام می‌کند بنویسید، آن متد روی هر نمونه‌ای از آن نوع تعریف می‌شود، بدون توجه به اینکه چه نوع مشخصی جایگزین نوع جنریک می‌شود.

همچنین می‌توانیم محدودیت‌هایی بر روی نوع‌های جنریک هنگام تعریف متدها روی یک نوع مشخص کنیم. می‌توانیم، برای مثال، متدهایی را فقط روی نمونه‌های Point<f32> پیاده‌سازی کنیم، نه روی نمونه‌های Point<T> با هر نوع جنریک. در لیست ۱۰-۱۰ از نوع مشخص f32 استفاده کرده‌ایم، به این معنی که هیچ نوعی را بعد از impl اعلام نمی‌کنیم.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listing 10-10: یک بلوک impl که فقط برای یک ساختار با یک نوع مشخص برای پارامتر نوع جنریک T اعمال می‌شود

این کد به این معنی است که نوع Point<f32> دارای یک متد distance_from_origin خواهد بود؛ سایر نمونه‌های Point<T> که T از نوع f32 نیستند، این متد را تعریف نخواهند کرد. این متد فاصله نقطه ما از نقطه‌ای با مختصات (0.0, 0.0) را اندازه‌گیری می‌کند و از عملیات ریاضی استفاده می‌کند که فقط برای نوع‌های اعداد اعشاری در دسترس هستند.

پارامترهای نوع جنریک در تعریف یک ساختار همیشه با آن‌هایی که در امضاهای متد همان ساختار استفاده می‌شوند یکسان نیستند. لیست ۱۰-۱۱ از نوع‌های جنریک X1 و Y1 برای ساختار Point و X2 و Y2 برای امضای متد mixup استفاده می‌کند تا مثال را واضح‌تر کند. این متد یک نمونه جدید از Point ایجاد می‌کند با مقدار x از Point self (از نوع X1) و مقدار y از Point پاس‌داده‌شده (از نوع Y2).

Filename: src/main.rs
struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Listing 10-11: یک متد که از نوع‌های جنریک متفاوت از تعریف ساختار خود استفاده می‌کند

در تابع main، یک Point تعریف کرده‌ایم که x آن یک i32 (با مقدار 5) و y آن یک f64 (با مقدار 10.4) است. متغیر p2 یک ساختار Point است که x آن یک قطعه رشته (با مقدار "Hello") و y آن یک char (با مقدار c) است. فراخوانی mixup روی p1 با آرگومان p2 به ما p3 را می‌دهد، که x آن یک i32 خواهد بود زیرا x از p1 آمده است. متغیر p3 یک char برای y خواهد داشت زیرا y از p2 آمده است. فراخوانی ماکرو println! مقدار p3.x = 5, p3.y = c را چاپ می‌کند.

هدف این مثال این است که وضعیتی را نشان دهد که در آن برخی پارامترهای جنریک با impl اعلام می‌شوند و برخی دیگر با تعریف متد اعلام می‌شوند. در اینجا، پارامترهای جنریک X1 و Y1 بعد از impl اعلام شده‌اند زیرا با تعریف ساختار همراه هستند. پارامترهای جنریک X2 و Y2 بعد از fn mixup اعلام شده‌اند زیرا فقط به متد مربوط هستند.

عملکرد کدی که از جنریک‌ها استفاده می‌کند

ممکن است این سوال برای شما پیش بیاید که آیا هنگام استفاده از پارامترهای نوع جنریک، هزینه‌ای در زمان اجرا وجود دارد یا خیر. خبر خوب این است که استفاده از انواع جنریک برنامه شما را کندتر از حالتی که از انواع مشخص استفاده می‌کردید، نمی‌کند.

Rust این کار را با انجام فرآیندی به نام تک‌ریخت‌سازی (monomorphization) روی کدی که از جنریک‌ها استفاده می‌کند در زمان کامپایل انجام می‌دهد. تک‌ریخت‌سازی فرآیند تبدیل کد جنریک به کد مشخص است با پر کردن انواع مشخصی که هنگام کامپایل استفاده می‌شوند. در این فرآیند، کامپایلر برعکس مراحلی که برای ایجاد تابع جنریک در لیست ۱۰-۵ استفاده کردیم را انجام می‌دهد: کامپایلر به تمام جاهایی که کد جنریک فراخوانی شده نگاه می‌کند و کدی را برای انواع مشخصی که کد جنریک با آن‌ها فراخوانی شده ایجاد می‌کند.

بیایید ببینیم این کار چگونه انجام می‌شود با استفاده از شمارش جنریک Option<T> در کتابخانه استاندارد:

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

وقتی Rust این کد را کامپایل می‌کند، فرآیند تک‌ریخت‌سازی را انجام می‌دهد. در طول این فرآیند، کامپایلر مقادیر استفاده شده در نمونه‌های Option<T> را می‌خواند و دو نوع Option<T> را شناسایی می‌کند: یکی i32 و دیگری f64. به این ترتیب، تعریف جنریک Option<T> را به دو تعریف ویژه برای i32 و f64 گسترش می‌دهد و بنابراین تعریف جنریک را با تعریف‌های مشخص جایگزین می‌کند.

نسخه تک‌ریخت‌سازی شده کد شبیه به چیزی به نظر می‌رسد (کامپایلر از نام‌های متفاوتی استفاده می‌کند، اما برای توضیح از این نام‌ها استفاده کرده‌ایم):

Filename: src/main.rs
enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

شمارش جنریک Option<T> با تعریف‌های مشخص ایجاد شده توسط کامپایلر جایگزین شده است. از آنجا که Rust کد جنریک را به کدی که نوع را در هر نمونه مشخص می‌کند کامپایل می‌کند، هیچ هزینه‌ای در زمان اجرا برای استفاده از جنریک‌ها پرداخت نمی‌کنیم. وقتی کد اجرا می‌شود، دقیقاً همان‌طور عمل می‌کند که اگر هر تعریف را به صورت دستی تکرار کرده بودیم. فرآیند تک‌ریخت‌سازی جنریک‌های Rust را در زمان اجرا بسیار کارآمد می‌کند.