انواع داده جنریک
ما از جنریکها برای ایجاد تعریفهایی برای مواردی مانند امضای توابع یا ساختارها (struct) استفاده میکنیم، که سپس میتوانیم با انواع داده مشخص مختلف از آنها استفاده کنیم. بیایید ابتدا ببینیم چگونه میتوان توابع، ساختارها، شمارشها (enum)، و متدها را با استفاده از جنریکها تعریف کرد. سپس درباره اینکه جنریکها چگونه بر عملکرد کد تأثیر میگذارند صحبت خواهیم کرد.
در تعریف توابع
هنگام تعریف یک تابع که از جنریکها استفاده میکند، جنریکها را در امضای تابع قرار میدهیم، جایی که معمولاً نوع داده پارامترها و مقدار بازگشتی را مشخص میکنیم. این کار کد ما را انعطافپذیرتر میکند و به فراخوانیکنندگان تابع ما عملکرد بیشتری ارائه میدهد، در حالی که از تکرار کد جلوگیری میکند.
با ادامه تابع largest
، لیست ۱۰-۴ دو تابع را نشان میدهد که هر دو بزرگترین مقدار را در یک بخش (slice) پیدا میکنند. سپس اینها را به یک تابع واحد که از جنریکها استفاده میکند ترکیب خواهیم کرد.
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'); }
تابع 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
فراخوانی کرد. توجه داشته باشید که این کد هنوز کامپایل نمیشود، اما بعداً در این فصل آن را رفع خواهیم کرد.
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}");
}
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
از هر نوعی را نگه میدارد.
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 }; }
Point<T>
که مقادیر x
و y
از نوع T
را نگه میداردنحو استفاده از جنریکها در تعریف ساختارها مشابه استفاده آنها در تعریف توابع است. ابتدا نام پارامتر نوع را در داخل پرانتزهای زاویهای بلافاصله پس از نام ساختار اعلام میکنیم. سپس نوع جنریک را در تعریف ساختار استفاده میکنیم، جایی که در غیر این صورت نوع داده مشخص را مشخص میکردیم.
توجه داشته باشید که از آنجا که فقط یک نوع جنریک برای تعریف Point<T>
استفاده کردهایم، این تعریف بیان میکند که ساختار Point<T>
برای یک نوع T
جنریک است و فیلدهای x
و y
هر دو از همان نوع هستند، هرچه که آن نوع باشد. اگر نمونهای از Point<T>
ایجاد کنیم که مقادیر آن انواع مختلف داشته باشند، همانطور که در لیست ۱۰-۷ آمده است، کد ما کامپایل نخواهد شد.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
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
است.
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 }; }
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
که روی آن پیادهسازی شده است.
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()); }
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
اعلام نمیکنیم.
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()); }
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
).
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); }
در تابع 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
گسترش میدهد و بنابراین تعریف جنریک را با تعریفهای مشخص جایگزین میکند.
نسخه تکریختسازی شده کد شبیه به چیزی به نظر میرسد (کامپایلر از نامهای متفاوتی استفاده میکند، اما برای توضیح از این نامها استفاده کردهایم):
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 را در زمان اجرا بسیار کارآمد میکند.