3. Traitها، Generics و Type System
این بخش به بررسی عمیق سیستم تایپ Rust، مکانیزمهای انتزاع (Abstraction) و پلیمورفیسم (Polymorphism) میپردازد.
trait چیست و چه تفاوتی با interface دارد؟
پاسخ بلند
Trait در Rust معادل قدرتمندتری برای Interface در زبانهایی مثل Java یا C# است. Trait قراردادی است که مشخص میکند یک تایپ چه رفتارهایی (Functionality) باید داشته باشد. تفاوتهای کلیدی:
- عدم وجود State: Traitها فقط رفتار را تعریف میکنند و نمیتوانند هیچ داده یا فیلدی (Instance Data) داشته باشند (برخلاف Abstract Class).
- پیادهسازی روی تایپهای خارجی (Extension Methods): شما میتوانید یک Trait (حتی اگر خودتان ننوشته باشید) را برای یک Type (حتی اگر مال کتابخانه استاندارد باشد) پیادهسازی کنید، به شرطی که حداقل یکی از آنها متعلق به Crate شما باشد (قانون Orphan Rule).
- Associated Items: Traitها میتوانند
Associated TypesوAssociated Constantsداشته باشند که انعطافپذیری سیستم تایپ را به شدت بالا میبرد (مثلIterator::Item). - Default Implementation: میتوان برای متدها بدنهی پیشفرض نوشت تا همه پیادهسازها مجبور به بازنویسی آن نباشند.
پاسخ کوتاه
Trait در Rust مجموعهای از رفتارها (متدها) را تعریف میکند که یک Type میتواند داشته باشد. تفاوت اصلی آن با Interface در زبانهای سنتی شیگرا این است که Traitها نمیتوانند فیلد (Data) داشته باشند، اما میتوانند پیادهسازی پیشفرض (Default Implementation)، نوعهای مرتبط (Associated Types) و ثابتها (Constants) داشته باشند. همچنین امکان پیادهسازی Trait روی Typeهای خارجی وجود دارد.
تفاوت static dispatch و dynamic dispatch چیست؟
پاسخ بلند
این دو روش پیادهسازی پلیمورفیسم در Rust هستند:
-
Static Dispatch (Generics): وقتی از
fn foo<T: Trait>(arg: T)استفاده میکنید، کامپایلر برای هر نوعTمتمایزی که این تابع با آن صدا زده شود، یک نسخه جداگانه از تابع تولید میکند. این فرآیند Monomorphization نام دارد.- مزایا: سرعت اجرای بالا (Direct calls)، امکان Inline کردن کد توسط کامپایلر.
- معایب: افزایش حجم فایل اجرایی (Code Bloat) و زمان کامپایل طولانیتر.
-
Dynamic Dispatch (Trait Objects): وقتی از
fn foo(arg: &dyn Trait)استفاده میکنید، نوع دقیقargدر زمان کامپایل پاک میشود (Type Erasure). در زمان اجرا، یک اشارهگر Fat Pointer شامل دو بخش پاس داده میشود: اشارهگر به داده واقعی و اشارهگر بهvtable(جدولی از اشارهگرهای توابع آن trait).- مزایا: حجم کد کمتر (فقط یک نسخه از تابع کامپایل میشود)، انعطافپذیری بالا (امکان ذخیره انواع مختلف در یک لیست واحد).
- معایب: کندتر بودن به دلیل عدم امکان Inlining و هزینه
vtable lookupوindirect branchدر پردازنده.
پاسخ کوتاه
Static Dispatch (با Generics) در زمان کامپایل، کد مخصوص هر تایپ را تولید میکند (Monomorphization)، بنابراین سریعتر است اما حجم باینری را افزایش میدهد.
Dynamic Dispatch (با trait objects) در زمان اجرا از طریق مکانیزم vtable مشخص میکند کدام متد باید صدا زده شود؛ انعطافپذیرتر است اما کمی سربار اجرایی دارد و کامپایلر نمیتواند کد را Inline کند.
dyn Trait چه زمانی استفاده میشود؟
پاسخ بلند
از dyn Trait یا همان Trait Objects در موارد زیر استفاده میشود:
- ذخیرهسازی انواع ناهمگن: مثلاً یک
Vec<Box<dyn Animal>>که میتواند همزمان شاملCatوDogباشد. با Generics (Vec<T>) همه عناصر باید دقیقاً یک نوع باشند. - پنهانسازی نوع (Type Erasure): وقتی نمیخواهیم نوع دقیق در Signatures تابع یا در ساختار داده مشخص باشد و فقط رفتار (Trait) اهمیت دارد.
- کاهش حجم کد: اگر یک تابع Generic داریم که با دهها نوع مختلف صدا زده میشود و بدنه آن بزرگ است، استفاده از
dyn Traitجلوی کپی شدن بیهودهی کد ماشین را میگیرد. - Recursive Types: گاهی اوقات تعریف نوع بازگشتی با Generics پیچیده یا ناممکن میشود، اما با اشارهگر به
dyn Traitحل میشود.
نکته: فقط Traitهایی که Object Safe هستند میتوانند به صورت dyn استفاده شوند (مثلاً متدهایی که self برمیگردانند یا Generic parameters دارند نمیتوانند در Trait Object باشند).
پاسخ کوتاه
زمانی که نیاز به Heterogeneous Collections (لیستی از انواع مختلف که همگی یک Trait را پیادهسازی کردهاند) داریم، یا زمانی که میخواهیم حجم کد باینری (Code Bloat) ناشی از Generics را کاهش دهیم.
تفاوت impl Trait در return type و argument چیست؟
پاسخ بلند
-
Argument Position:
#![allow(unused)] fn main() { fn foo(arg: impl Display) { ... } }این کد تقریباً معادل
fn foo<T: Display>(arg: T)است. یعنی Caller تصمیم میگیرد چه نوعی پاس دهد و برای هر نوع، یک نسخه از تابع ساخته میشود (Static Dispatch). -
Return Position:
#![allow(unused)] fn main() { fn foo() -> impl Iterator<Item=i32> { ... } }در اینجا Function تعیین میکند چه نوعی برمیگرداند، اما آن را در امضا (Signature) مخفی میکند.
- بدون
impl Trait، اگر بخواهیم مثلاً یک Closure یا یک Iterator پیچیده (مثلMap<Filter<...>>) برگردانیم، نوشتن نام دقیق Type بسیار سخت یا غیرممکن است. - تضمین میکند که نوع بازگشتی در آینده قابل تغییر است بدون اینکه API عمومی تابع بشکند (مادامی که Trait را رعایت کند).
- برخلاف
dyn Trait، هیچ هزینه اجرایی (Overhead) ندارد زیرا کامپایلر نوع واقعی را میداند.
- بدون
پاسخ کوتاه
در Argument (fn foo(x: impl Trait)): صرفاً سینتکس سادهتر (Syntactic Sugar) برای Generics است وCaller تعیین میکند چه نوعی بفرستد.
در Return Type (fn foo() -> impl Trait): نوع بازگشتی یک نوع مشخص و واحد است که کامپایلر آن را میداند اما از کاربر پنهان شده است (Opaque Type).
trait bound چیست و چگونه چند trait را constrain میکنید؟
پاسخ بلند
هنگام تعریف توابع یا ساختارهای Generic، معمولاً نمیتوانیم روی T هیچ متدی صدا بزنیم مگر اینکه مشخص کنیم T چه قابلیتهایی دارد.
-
Syntax:
#![allow(unused)] fn main() { fn notify<T: Summary + Display>(item: &T) { ... } }در اینجا
Tباید همSummaryرا پیادهسازی کرده باشد و همDisplay. -
Where Clause: برای خوانایی بیشتر وقتی محدودیتها زیاد میشوند:
#![allow(unused)] fn main() { fn some_function<T, U>(t: &T, u: &U) -> i32 where T: Display + Clone, U: Clone + Debug, { ... } }
پاسخ کوتاه
Trait bound شرطی است که روی Generic Type میگذاریم تا تضمین کنیم آن Type رفتار خاصی را پیادهسازی کرده است. برای اعمال چند محدودیت از عملگر + استفاده میکنیم.
orphan rule چیست و چرا وجود دارد؟
پاسخ بلند
این قانون برای حفظ ویژگی Coherence در سیستم تایپ Rust حیاتی است.
فرض کنید Trait Display (از std) و Type Vec (از std) را داریم. اگر Crate شما اجازه داشت impl Display for Vec را بنویسد و Crate دیگری هم همین کار را میکرد، وقتی برنامه شما از هر دو Crate استفاده میکند، کامپایلر نمیداند از کدام پیادهسازی استفاده کند.
همچنین، اگر شما برای یک Type خارجی پیادهسازی بنویسید، ممکن است در آینده صاحب آن Type (مثلاً تیم استاندارد Rust) تصمیم بگیرد خودش آن Trait را پیادهسازی کند. در این صورت کد شما متوقف (Break) میشود.
استثنا (Newtype Pattern): برای دور زدن این قانون، میتوانید Type خارجی را در یک struct محلی (Tuple Struct) بپیچید:
#![allow(unused)] fn main() { struct MyVec(Vec<i32>); impl Display for MyVec { ... } }
پاسخ کوتاه
قانون Orphan Rule (یا Coherence) میگوید که شما تنها در صورتی میتوانید یک Trait را برای یک Type پیادهسازی کنید که یا Trait یا Type در همان Crate جاری (Local) تعریف شده باشند. هدف آن جلوگیری از تداخل پیادهسازیها و شکستن کد در صورت تغییر وابستگیهاست.
specialization چرا unstable است؟
پاسخ بلند
Specialization (RFC 1210) قابلیتی بسیار مورد درخواست است که اجازه میدهد چندین پیادهسازی impl همپوشانی داشته باشند و کامپایلر "خاصترین" (Specific) آنها را انتخاب کند.
مثال:
#![allow(unused)] fn main() { impl<T> MyTrait for T { ... } // پیادهسازی کلی (کندتر) impl MyTrait for String { ... } // پیادهسازی خاص برای String (سریعتر) }
دلیل Unstable بودن طولانی مدت آن، باگی عمیق در تعامل با سیستم Lifetimeهاست. تشخیص اینکه یک پیادهسازی از دیگری "خاصتر" است وقتی پای Lifetime وسط میآید (مثلاً &'static str vs &'a str) میتواند منجر به استفاده از کدهای unsafe در شرایطی شود که ایمنی حافظه نقض گردد. تیم Rust در حال کار روی نسخهی اصلاح شدهای (مثل min_specialization) است.
پاسخ کوتاه
Specialization اجازه میدهد برای یک Type خاص، پیادهسازی بهینهتری نسبت به پیادهسازی عمومی Generic ارائه دهیم. این قابلیت هنوز Unstable است چون مسائل پیچیدهای در مورد Soundness (ایمنی منطقی سیستم تایپ) مخصوصاً در تعامل با Lifetimeها وجود دارد که هنوز کامل حل نشدهاند.