Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

سوالات و مباحث هسته زبان Rust

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

1. مفاهیم پایه (Rust Fundamentals)


تفاوت let و let mut چیست و چرا Rust به‌صورت پیش‌فرض immutable است؟

پاسخ بلند

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

immutability باعث می‌شود:

  • state برنامه قابل‌ردیابی‌تر باشد
  • data raceها در زمان کامپایل حذف شوند
  • کامپایلر بتواند بهینه‌سازی‌های تهاجمی‌تری انجام دهد
  • تغییرات ناخواسته‌ی داده کاهش یابد

Rust توسعه‌دهنده را مجبور می‌کند که «قصد تغییر» را به‌صورت صریح اعلام کند. این موضوع در سیستم‌های concurrent و codebaseهای بزرگ اهمیت حیاتی دارد.

پاسخ کوتاه

let متغیر immutable تعریف می‌کند و let mut mutable؛ پیش‌فرض بودن immutability برای افزایش ایمنی، خوانایی و حذف خطاهای همزمانی در زمان کامپایل است.


مفهوم Ownership را توضیح دهید

پاسخ بلند

Ownership هسته‌ی اصلی مدل مدیریت حافظه در Rust است. هر مقدار در Rust دقیقاً یک مالک (owner) دارد. زمانی که owner از scope خارج می‌شود، مقدار به‌صورت خودکار آزاد می‌شود.

قوانین اصلی ownership:

  1. هر مقدار فقط یک owner دارد
  2. با انتقال ownership (move)، owner قبلی دیگر معتبر نیست
  3. وقتی owner از scope خارج شود، drop فراخوانی می‌شود

این مدل امکان مدیریت حافظه بدون GC و بدون manual memory management را فراهم می‌کند و بسیاری از خطاهای رایج مانند use-after-free و double-free را در زمان کامپایل حذف می‌کند.

پاسخ کوتاه

Ownership مدلی است که در آن هر مقدار فقط یک مالک دارد و با خروج مالک از scope حافظه آزاد می‌شود، بدون نیاز به GC یا مدیریت دستی.


چرا Rust از Garbage Collector استفاده نمی‌کند؟

پاسخ بلند

Rust به‌جای GC از ownership + borrowing + lifetimes استفاده می‌کند تا مدیریت حافظه را به زمان کامپایل منتقل کند. GC هزینه‌هایی مانند:

  • latency غیرقابل پیش‌بینی
  • مصرف منابع اضافی
  • توقف‌های دوره‌ای (stop-the-world)

را به سیستم تحمیل می‌کند.

Rust برای سیستم‌های low-level، real-time و high-performance طراحی شده و حذف GC امکان کنترل دقیق‌تر بر زمان و منابع را فراهم می‌کند، در حالی که ایمنی حافظه حفظ می‌شود.

پاسخ کوتاه

چون Rust ایمنی حافظه را در زمان کامپایل تضمین می‌کند و GC باعث latency و overhead غیرقابل پیش‌بینی می‌شود.


تفاوت بین move و copy چیست؟ چه typeهایی Copy هستند؟

پاسخ بلند

به‌صورت پیش‌فرض، assignment یا pass کردن یک مقدار باعث move شدن ownership می‌شود. بعد از move، متغیر قبلی دیگر قابل استفاده نیست.

برخی typeها رفتار Copy دارند؛ یعنی به‌جای انتقال ownership، یک کپی بیتی از مقدار ساخته می‌شود. این typeها:

  • ساده و بدون heap allocation هستند
  • resource ownership ندارند

نمونه typeهای Copy:

  • اعداد primitive (i32, u64, f64)
  • bool
  • char
  • tupleهایی که تمام اعضایشان Copy باشند

پاسخ کوتاه

move ownership را منتقل می‌کند ولی copy یک کپی بیتی می‌سازد؛ typeهای ساده مثل اعداد و bool Copy هستند.


Borrowing و قوانین آن چیست؟ چرا همزمان چند mutable reference مجاز نیست؟

پاسخ بلند

Borrowing اجازه می‌دهد بدون انتقال ownership به داده دسترسی داشته باشیم. Rust دو نوع borrow دارد:

  • immutable borrow (&T)
  • mutable borrow (&mut T)

قوانین borrowing:

  • همزمان می‌توان چند reference immutable داشت
  • یا فقط یک reference mutable
  • mutable و immutable همزمان مجاز نیستند

این محدودیت برای جلوگیری از data race، aliasing + mutation و رفتارهای undefined است. Rust این تضمین‌ها را در زمان کامپایل enforce می‌کند.

پاسخ کوتاه

Borrowing یعنی دسترسی بدون ownership؛ فقط یک mutable reference مجاز است تا از data race و رفتار undefined جلوگیری شود.


تفاوت &T و &mut T چیست؟

پاسخ بلند

&T یک reference فقط‌خواندنی است و اجازه‌ی تغییر مقدار را نمی‌دهد.
&mut T اجازه‌ی خواندن و نوشتن را می‌دهد اما:

  • باید یکتا باشد
  • هیچ reference دیگری همزمان وجود نداشته باشد

این تمایز بخش مهمی از سیستم ایمنی Rust است.

پاسخ کوتاه

&T فقط خواندنی است، &mut T قابل تغییر است و باید یکتا باشد.


lifetime چیست و چه زمانی نیاز به annotation داریم؟

پاسخ بلند

Lifetime مشخص می‌کند یک reference تا چه مدت معتبر است. Rust معمولاً با lifetime elision آن‌ها را به‌صورت خودکار استنتاج می‌کند.

Annotation زمانی لازم است که:

  • چند reference ورودی و خروجی وجود دارد
  • رابطه‌ی بین lifetimeها برای کامپایلر مبهم است
  • در structهایی که reference نگه می‌دارند

Lifetimeها رفتار runtime ندارند و صرفاً ابزاری برای بررسی ایمنی در زمان کامپایل هستند.

پاسخ کوتاه

Lifetime طول اعتبار reference را مشخص می‌کند و فقط وقتی annotation لازم است که کامپایلر نتواند آن را استنتاج کند.


چرا Rust دارای Option و Result<T, E> است و از null استفاده نمی‌کند؟

پاسخ بلند

null منبع یکی از رایج‌ترین خطاهای runtime است. Rust به‌جای آن از type system استفاده می‌کند تا نبود مقدار یا خطا را صریح و قابل بررسی کند.

  • Option<T>: وجود یا عدم وجود مقدار
  • Result<T, E>: موفقیت یا خطا با اطلاعات دقیق

این رویکرد:

  • خطاها را به زمان کامپایل نزدیک می‌کند
  • توسعه‌دهنده را مجبور به handle کردن حالت‌ها می‌کند
  • از panicها و crashهای ناگهانی جلوگیری می‌کند

پاسخ کوتاه

چون Option و Result خطا و نبود مقدار را صریح و type-safe می‌کنند و مشکلات کلاسیک null را حذف می‌کنند.


2. کنترل خطا و ایمنی


تفاوت panic! و Result چیست؟ چه زمانی از هرکدام استفاده می‌کنید؟

پاسخ بلند

panic! بیانگر یک شکست غیرقابل‌گردش (unrecoverable) است. زمانی استفاده می‌شود که برنامه وارد وضعیتی شده که ادامهٔ اجرا منطقی نیست؛ معمولاً نقض یک invariant داخلی، باگ برنامه‌نویسی، یا شرایطی که برنامه از ابتدا فرض کرده هرگز رخ نمی‌دهد. هنگام panic، بسته به تنظیمات پروژه، یا استک unwind می‌شود یا کل فرآیند abort می‌شود.

در مقابل، Result<T, E> برای خطاهای قابل‌گردش (recoverable) طراحی شده است. این نوع خطاها بخشی از رفتار طبیعی سیستم هستند: خطاهای I/O، خطاهای شبکه، خطاهای parsing یا validation. با استفاده از Result، خطا در امضای تابع منعکس می‌شود و caller مجبور است به‌صورت صریح آن را مدیریت یا propagate کند.

قاعدهٔ طراحی:

  • Result برای خطاهای قابل انتظار و بخشی از دامنهٔ مسئله
  • panic! برای نقض فرضیات داخلی و باگ‌ها
    در کتابخانه‌ها و APIهای عمومی تقریباً همیشه Result ترجیح داده می‌شود.

پاسخ کوتاه

Result برای خطاهای قابل‌هندل است؛ panic! برای باگ‌ها و شرایطی که ادامهٔ اجرا بی‌معنی است.


مفهوم unwinding و abort در panic چیست؟

پاسخ بلند

Rust دو استراتژی برای واکنش به panic دارد:

  • Unwinding: استک به‌تدریج باز می‌شود و destructorها (Drop) اجرا می‌شوند. این رفتار امکان cleanup منابع را فراهم می‌کند و با catch_unwind می‌توان panic را در مرزهای مشخص مهار کرد. با این حال، هزینهٔ اجرایی دارد و در تعامل با FFI می‌تواند پیچیده باشد.

  • Abort: فرآیند بلافاصله خاتمه می‌یابد، بدون اجرای destructorها. این روش سریع‌تر و قابل‌پیش‌بینی‌تر است و معمولاً در سیستم‌های embedded، real-time یا سناریوهایی که unwind ناامن است، استفاده می‌شود.

انتخاب بین این دو از طریق تنظیم panic = "unwind" یا panic = "abort" انجام می‌شود و یک تصمیم معماری محسوب می‌شود.

پاسخ کوتاه

unwinding استک را باز کرده و destructorها را اجرا می‌کند؛ abort فوراً برنامه را متوقف می‌کند بدون cleanup.


الگوی ? چگونه کار می‌کند؟

پاسخ بلند

عملگر ? مکانیزمی برای propagate کردن خطا به‌شکل صریح و مختصر است. اگر مقدار از نوع Result باشد:

  • در صورت Ok، مقدار unwrap شده و ادامهٔ اجرا انجام می‌شود
  • در صورت Err، تابع بلافاصله با همان خطا (یا خطای تبدیل‌شده با From) برمی‌گردد

به‌صورت مفهومی، ? جایگزین یک match صریح است و از traitهای مربوط به error propagation در زبان استفاده می‌کند. این الگو خوانایی را افزایش داده و کد را composable می‌کند. همین رفتار برای Option نیز صادق است.

پاسخ کوتاه

? در صورت خطا مقدار را زودهنگام برمی‌گرداند و در صورت موفقیت مقدار را unwrap می‌کند.


چرا Rust خطاها را در type system مدل می‌کند؟

پاسخ بلند

Rust خطاها را بخشی از مدل نوع می‌داند تا مدیریت آن‌ها:

  • صریح باشد و امکان نادیده‌گرفتن وجود نداشته باشد
  • در زمان کامپایل بررسی شود
  • به‌صورت ترکیب‌پذیر بین لایه‌های مختلف سیستم propagate شود
  • مستندسازی طبیعی API را شکل دهد

این رویکرد مانع بروز خطاهای کلاسیک runtime (مانند null dereference) می‌شود و باعث افزایش reliability، testability و maintainability سیستم می‌گردد؛ به‌ویژه در سیستم‌های concurrent و توزیع‌شده.

پاسخ کوتاه

برای صریح‌بودن، بررسی در زمان کامپایل و افزایش قابلیت اطمینان؛ خطا بخشی از قرارداد type می‌شود.


چه زمانی استفاده از unwrap() قابل قبول است؟

پاسخ بلند

unwrap() در صورت نبود مقدار باعث panic! می‌شود و به همین دلیل در کد production به‌طور کلی نامطلوب است. با این حال، استفاده از آن در شرایط زیر قابل قبول است:

  • تست‌ها و benchmarkها
  • نمونه‌کدها و مستندات
  • prototypeهای کوتاه‌عمر
  • جاهایی که invariantها به‌طور قطعی برقرار شده‌اند و failure نشان‌دهندهٔ باگ است

در این موارد، expect() با پیام توضیحی معمولاً انتخاب بهتری نسبت به unwrap() خام است. در APIهای عمومی و کتابخانه‌ها، استفاده از unwrap() توصیه نمی‌شود.

پاسخ کوتاه

در تست‌ها، نمونه‌کدها یا زمانی که invariantها تضمین شده‌اند؛ در کد تولیدی بهتر است از Result یا دست‌کم expect استفاده شود.


3. Traitها، Generics و Type System

این بخش به بررسی عمیق سیستم تایپ Rust، مکانیزم‌های انتزاع (Abstraction) و پلی‌مورفیسم (Polymorphism) می‌پردازد.


trait چیست و چه تفاوتی با interface دارد؟

پاسخ بلند

Trait در Rust معادل قدرت‌مندتری برای Interface در زبان‌هایی مثل Java یا C# است. Trait قراردادی است که مشخص می‌کند یک تایپ چه رفتارهایی (Functionality) باید داشته باشد. تفاوت‌های کلیدی:

  1. عدم وجود State: Traitها فقط رفتار را تعریف می‌کنند و نمی‌توانند هیچ داده یا فیلدی (Instance Data) داشته باشند (برخلاف Abstract Class).
  2. پیاده‌سازی روی تایپ‌های خارجی (Extension Methods): شما می‌توانید یک Trait (حتی اگر خودتان ننوشته باشید) را برای یک Type (حتی اگر مال کتابخانه استاندارد باشد) پیاده‌سازی کنید، به شرطی که حداقل یکی از آن‌ها متعلق به Crate شما باشد (قانون Orphan Rule).
  3. Associated Items: Traitها می‌توانند Associated Types و Associated Constants داشته باشند که انعطاف‌پذیری سیستم تایپ را به شدت بالا می‌برد (مثل Iterator::Item).
  4. Default Implementation: می‌توان برای متدها بدنه‌ی پیش‌فرض نوشت تا همه پیاده‌سازها مجبور به بازنویسی آن نباشند.

پاسخ کوتاه

Trait در Rust مجموعه‌ای از رفتارها (متدها) را تعریف می‌کند که یک Type می‌تواند داشته باشد. تفاوت اصلی آن با Interface در زبان‌های سنتی شی‌گرا این است که Traitها نمی‌توانند فیلد (Data) داشته باشند، اما می‌توانند پیاده‌سازی پیش‌فرض (Default Implementation)، نوع‌های مرتبط (Associated Types) و ثابت‌ها (Constants) داشته باشند. همچنین امکان پیاده‌سازی Trait روی Typeهای خارجی وجود دارد.


تفاوت static dispatch و dynamic dispatch چیست؟

پاسخ بلند

این دو روش پیاده‌سازی پلی‌مورفیسم در Rust هستند:

  1. Static Dispatch (Generics): وقتی از fn foo<T: Trait>(arg: T) استفاده می‌کنید، کامپایلر برای هر نوع T متمایزی که این تابع با آن صدا زده شود، یک نسخه جداگانه از تابع تولید می‌کند. این فرآیند Monomorphization نام دارد.

    • مزایا: سرعت اجرای بالا (Direct calls)، امکان Inline کردن کد توسط کامپایلر.
    • معایب: افزایش حجم فایل اجرایی (Code Bloat) و زمان کامپایل طولانی‌تر.
  2. 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 در موارد زیر استفاده می‌شود:

  1. ذخیره‌سازی انواع ناهمگن: مثلاً یک Vec<Box<dyn Animal>> که می‌تواند همزمان شامل Cat و Dog باشد. با Generics (Vec<T>) همه عناصر باید دقیقاً یک نوع باشند.
  2. پنهان‌سازی نوع (Type Erasure): وقتی نمی‌خواهیم نوع دقیق در Signatures تابع یا در ساختار داده مشخص باشد و فقط رفتار (Trait) اهمیت دارد.
  3. کاهش حجم کد: اگر یک تابع Generic داریم که با ده‌ها نوع مختلف صدا زده می‌شود و بدنه آن بزرگ است، استفاده از dyn Trait جلوی کپی شدن بیهوده‌ی کد ماشین را می‌گیرد.
  4. 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 چیست؟

پاسخ بلند

  1. Argument Position:

    #![allow(unused)]
    fn main() {
    fn foo(arg: impl Display) { ... }
    }

    این کد تقریباً معادل fn foo<T: Display>(arg: T) است. یعنی Caller تصمیم می‌گیرد چه نوعی پاس دهد و برای هر نوع، یک نسخه از تابع ساخته می‌شود (Static Dispatch).

  2. 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ها وجود دارد که هنوز کامل حل نشده‌اند.

سوالات مصاحبه فریم‌ورک Axum

این بخش به مباحث تخصصی و سوالات مصاحبه پیرامون Axum، یکی از محبوب‌ترین و ارگونومیک‌ترین فریم‌ورک‌های وب در اکوسیستم Rust می‌پردازد. Axum بر پایه tokio و tower ساخته شده و برای توسعه سرویس‌های وب با کارایی بالا طراحی شده است.

1. مبانی Axum


Axum چیست و چه تفاوتی با Actix / Warp دارد؟

پاسخ بلند

Axum یک وب‌فریم‌ورک مدرن برای Rust است که روی Tokio و Hyper ساخته شده و به‌طور مستقیم از اکوسیستم Tower استفاده می‌کند. تمرکز اصلی Axum بر type-safety، composability و استفاده‌ی حداکثری از type system Rust است.

در مقایسه:

  • Actix Web معماری و runtime اختصاصی خود را دارد و middlewareها و مدل اجرایی آن tightly-coupled هستند.
  • Warp از مدل filter-based استفاده می‌کند که اگرچه type-safe است، اما در APIهای بزرگ و پیچیده به‌سرعت خوانایی و maintainability خود را از دست می‌دهد.
  • Axum با تکیه بر Router، extractors صریح و Tower middlewareها، ساختار واضح‌تر و قابل‌گسترش‌تری برای APIهای production-grade فراهم می‌کند.

Axum عمداً یک abstraction نازک است و تلاش نمی‌کند همه‌چیز را خودش پیاده‌سازی کند.

پاسخ کوتاه

Axum فریم‌ورکی type-safe و مدولار روی Hyper/Tokio و Tower است؛ برخلاف Actix runtime اختصاصی ندارد و برخلاف Warp از مدل filter استفاده نمی‌کند.


چرا Axum بر پایه‌ی Tower ساخته شده است؟

پاسخ بلند

Tower یک abstraction استاندارد برای سرویس‌های async در Rust است که حول trait اصلی Service و مفهوم Layer (middleware) شکل گرفته است. Axum با ساختن خود بر پایه‌ی Tower:

  • از middlewareهای بالغ و battle-tested استفاده می‌کند
  • نیاز به reinvent کردن middleware stack را حذف می‌کند
  • امکان ترکیب ساده‌ی concerns مثل timeout، retry، rate limit، tracing و auth را فراهم می‌کند

در واقع Axum یک adapter انسانی‌پسند برای HTTP routing روی Tower است.

پاسخ کوتاه

برای استفاده از abstraction استاندارد Service و اکوسیستم آماده‌ی middlewareهای Tower.


نقش Router در Axum چیست؟

پاسخ بلند

Router هسته‌ی اصلی تعریف API در Axum است. این ساختار مسئول:

  • نگاشت مسیر و HTTP method به handler
  • ترکیب چند Router به‌صورت سلسله‌مراتبی
  • اعمال middleware در سطوح مختلف
  • مدیریت state اشتراکی بین handlerها

Router نه‌تنها مسیرها را نگه می‌دارد، بلکه boundary اصلی طراحی API و modularization در Axum محسوب می‌شود.

پاسخ کوتاه

Router وظیفه‌ی اتصال مسیرها به handlerها، ترکیب sub-routerها و اعمال middleware و state را دارد.


تفاوت route و nest چیست؟

پاسخ بلند

  • route برای ثبت مستقیم یک مسیر مشخص و اتصال آن به handler یا service استفاده می‌شود.
  • nest برای mount کردن یک Router دیگر زیر یک prefix مشخص به کار می‌رود.

در nest، prefix از مسیر حذف می‌شود و Router داخلی بدون آگاهی از مسیر بالادستی کار می‌کند. این ویژگی برای ماژولار کردن API (مثلاً /api/v1/users) حیاتی است.

پاسخ کوتاه

route یک مسیر منفرد را ثبت می‌کند؛ nest یک Router کامل را زیر یک prefix قرار می‌دهد.


چرا handlerها در Axum async هستند؟

پاسخ بلند

Axum بر مبنای I/O غیرهمزمان طراحی شده و handlerها مستقیماً در مسیر اجرای async runtime (Tokio) قرار می‌گیرند. async بودن handlerها امکان:

  • انجام I/O بدون بلوکه کردن thread
  • مقیاس‌پذیری بالا با تعداد thread کم
  • استفاده‌ی مستقیم از async database clients و HTTP clients

را فراهم می‌کند. این طراحی برای سرویس‌های high-concurrency ضروری است.

پاسخ کوتاه

چون Axum بر پایه‌ی async I/O ساخته شده و handlerها باید non-blocking و scalable باشند.


چرا امضای handler باید type-safe باشد؟

پاسخ بلند

Axum از extractors مبتنی بر type system استفاده می‌کند؛ یعنی ورودی‌های handler (path params، query، headers، body، state) همگی type-safe هستند و در زمان کامپایل بررسی می‌شوند.

این رویکرد:

  • خطاهای parsing و missing data را به compile-time نزدیک می‌کند
  • قرارداد ورودی handler را شفاف و self-documenting می‌سازد
  • تست‌پذیری و refactorپذیری را افزایش می‌دهد

Handler در Axum عملاً یک function با وابستگی‌های صریح است، نه یک تابع با دسترسی مبهم به request.

پاسخ کوتاه

برای اینکه استخراج داده از request در زمان کامپایل بررسی شود و خطاهای runtime حذف شوند.


2. Handlerها و Extractorها


extractor چیست و چگونه کار می‌کند؟

پاسخ بلند

در Axum، extractor نوعی است که از یک درخواست HTTP مقدار/اطلاعات خاصی را استخراج می‌کند و آن را به‌عنوان آرگومان به handler تحویل می‌دهد. یک extractor با یکی از traitهای FromRequestParts (برای بخش‌های درخواست مثل headers, path, query, extensions) یا FromRequest (برای استخراج‌هایی که باید body را مصرف کنند) پیاده‌سازی می‌شود. قبل از فراخوانی handler، Axum هر extractor را اجرا می‌کند و نتیجهٔ موفق را به عنوان آرگومان به handler می‌دهد؛ اگر extraction شکست بخورد، handler اجرا نخواهد شد و یک rejection که قابل تبدیل به Response است بازگردانده می‌شود. :contentReference[oaicite:0]{index=0}

پاسخ کوتاه

Extractor نوعی است که بخش‌هایی از Request را می‌خواند/پارسه می‌کند و به handler به‌صورت پارامتر تحویل می‌دهد؛ پیاده‌سازی آن معمولاً از FromRequestParts یا FromRequest است. :contentReference[oaicite:1]{index=1}


تفاوت Json<T>، Form<T>، Path<T>، Query<T> چیست؟

پاسخ بلند

  • Json<T>: بدنهٔ درخواست را به‌عنوان JSON خوانده و با serde::Deserialize به T تبدیل می‌کند — مصرف‌کنندهٔ body است (پس باید آخرین extractor باشد اگر body مصرفی دیگری نیست).
  • Form<T>: بدنهٔ فرم x-www-form-urlencoded را پارس می‌کند و به T تبدیل می‌کند (نیز body-consuming).
  • Path<T>: پارامترهای مسیر (path params) را از URI استخراج و با serde دی‌سریالایز می‌کند؛ معمولاً برای مقادیر شناسه/پارامتر مسیر استفاده می‌شود.
  • Query<T>: query string را پارس کرده و به T تبدیل می‌کند (برای پارامترهای optional یا pagination و غیره مناسب است).
    هر یک از این‌ها نوعی extractor هستند و رفتار خاص خود را در صورت خطا (مثلاً بدفرمت بودن JSON یا فقدان پارامتر لازم) با یک rejection بازمی‌گردانند. :contentReference[oaicite:2]{index=2}

پاسخ کوتاه

Json/Form بدنه را مصرف و پارس می‌کنند؛ Path پارامترهای مسیر و Query پارامترهای query string را استخراج می‌کند. :contentReference[oaicite:3]{index=3}


ترتیب اجرای extractorها چگونه است؟

پاسخ بلند

Extractorها همیشه به ترتیب پارامترهای تابع (از چپ به راست) اجرا می‌شوند. دلیل مهم این قاعده این است که بدنهٔ درخواست یک استریم async است که فقط یک‌بار قابل مصرف است؛ بنابراین extractorهایی که body را مصرف می‌کنند باید آخرین پارامتر باشند و Axum این محدودیت را از طریق ترتیب اجرا و نوع traitها اعمال می‌کند. به‌طور خلاصه: ترتیب پارامترها اهمیت دارد و extractorهای مصرف‌کنندهٔ body را آخر قرار دهید. :contentReference[oaicite:4]{index=4}

پاسخ کوتاه

Axum extractors را از چپ به راست (ترتیب پارامترهای handler) اجرا می‌کند؛ extractorهایی که body را مصرف می‌کنند باید آخر باشند. :contentReference[oaicite:5]{index=5}


اگر یک extractor fail شود چه اتفاقی می‌افتد؟

پاسخ بلند

وقتی یک extractor شکست می‌خورد، آن extractor یک نوع rejection برمی‌گرداند (نوع مرتبط با extractor که IntoResponse را پیاده‌سازی می‌کند). در این حالت:

  • اجرای سایر extractorها/handler متوقف می‌شود.
  • Router پاسخ HTTP را با تبدیل rejection به Response ارسال می‌کند (مثلاً 400 برای JSON نامعتبر یا 404 برای مسیر نامناسب، بسته به نوع rejection).
    برای دسترسی به خطای درون یک extractor یا سفارشی‌سازی پاسخ خطا می‌توان extractor را به صورت Result<T, T::Rejection> در آرگومان handler گرفت یا از ابزارهایی مانند WithRejection / تعریف extractor سفارشی استفاده کرد. :contentReference[oaicite:6]{index=6}

پاسخ کوتاه

در صورت شکست، extractor یک rejection بازمی‌گرداند و handler اجرا نمی‌شود — آن rejection به Response تبدیل و به کاربر برگردانده می‌شود. :contentReference[oaicite:7]{index=7}


چگونه extractor سفارشی می‌نویسید؟

پاسخ بلند

دو مسیر اصلی وجود دارد:

  1. اگر نیاز فقط به خواندن بخش‌های request (headers, path, query, extensions) دارید، FromRequestParts<S> را پیاده‌سازی کنید.
  2. اگر باید body را مصرف کنید، FromRequest<S> را پیاده‌سازی کنید.
    هر پیاده‌سازی باید یک نوع Rejection تعیین کند (که IntoResponse را پیاده‌سازی کند). معمول‌ترین الگو این است که در from_request_parts/from_request منطق استخراج را انجام دهید، در صورت موفقیت مقدار موردنظر را بسازید و در صورت شکست یک rejection مناسب برگردانید. برای سفارشی‌سازی خطاها می‌توانید از WithRejection یا از آرگومان Result<T, T::Rejection> در handler استفاده کنید. مثال کوتاه (اسکلت):
#![allow(unused)]
fn main() {
use axum::extract::{FromRequestParts, RequestParts};
use http::request::Parts;

struct MyExtractor(/* fields */);

#[axum::async_trait]
impl<S> FromRequestParts<S> for MyExtractor
where
    S: Send + Sync,
{
    type Rejection = MyRejection;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        // خواندن header/extension و ساخت extractor یا بازگرداندن MyRejection
    }
}
}

3. Response و Error Handling


چه typeهایی می‌توانند response باشند؟

پاسخ بلند

هر نوعی که IntoResponse را پیاده‌سازی کرده باشد می‌تواند از یک handler برگردد. Axum برای انواع رایج (String, &'static str, StatusCode, http::Response<Body>, Json<T>، tupleهایی مثل (StatusCode, HeaderMap, body) و غیره) پیاده‌سازی آماده دارد، بنابراین معمولاً می‌توانید مستقیم یک مقدار ساده یا یک tuple برگردانید و Axum آن را به Response تبدیل می‌کند. اگر نوع دلخواه شما این trait را نداشته باشد، می‌توانید آن را خودتان پیاده‌سازی کنید تا مستقیماً از handler قابل برگشت باشد. :contentReference[oaicite:0]{index=0}

پاسخ کوتاه

هر چیزی که IntoResponse را پیاده‌سازی کند — Axum برای بسیاری از انواع رایج پیاده‌سازی آماده دارد. :contentReference[oaicite:1]{index=1}


trait IntoResponse چگونه کار می‌کند؟

پاسخ بلند

IntoResponse متدی into_response(self) -> Response فراهم می‌کند که نوع حامل را به یک http::Response<Body> تبدیل می‌کند. وقتی handler impl IntoResponse یا نوعی که IntoResponse را پیاده‌سازی کرده برمی‌گرداند، Axum آن را فراخوانی می‌کند تا پاسخ HTTP واقعی ساخته شود. این سازوکار امکان انعطاف‌پذیری بالا (مثلاً بازگرداندن tuple برای تعیین status/headers/body) و یکپارچگی با انواع کاربردی مانند Json<T> را فراهم می‌سازد. اغلب لازم نیست خودتان IntoResponse را بنویسید مگر برای error typeهای سفارشی یا رفتارهای ویژه. :contentReference[oaicite:2]{index=2}

پاسخ کوتاه

این trait مقدار را به یک http::Response تبدیل می‌کند؛ Axum این تبدیل را برای هر خروجی handler انجام می‌دهد. :contentReference[oaicite:3]{index=3}


چگونه error مرکزی (global error handling) پیاده می‌کنید؟

پاسخ بلند

الگوهای معمول برای هندلینگ سراسری در Axum:

  1. Middleware / Tower layers — از tower::ServiceBuilder و لِیِرهایی مثل HandleErrorLayer برای wrap کردن سرویس استفاده کنید تا خطاهای لایه‌ها (مثل timeout یا fallible middleware) را بگیرید و به IntoResponse تبدیل کنید. HandleErrorLayer برای تبدیل BoxError یا خطاهای لایه به پاسخ HTTP کاربردی است. :contentReference[oaicite:4]{index=4}

  2. خطاهای handler به‌صورت نوعی که IntoResponse را پیاده‌سازی می‌کند — هر error type سفارشی را طوری طراحی کنید که خودش IntoResponse را پیاده‌سازی کند؛ سپس هر کجا Result<T, E> برگردانید، Axum در صورت Err(e) آن را به پاسخ مناسب تبدیل می‌کند. این الگو خطاها را در لایه application متحد می‌سازد (مثلاً تبدیل enum خطاها به JSON+status). :contentReference[oaicite:5]{index=5}

  3. ترکیب هر دو — middleware برای موارد کلی (logging، تبدیل errorهای لایه‌ای، timeouts) و IntoResponse برای mapping دقیق انواع خطای دامنه به status/body.

نکته عملی: middlewareها معمولاً خطاها را به شکل Result یا BoxError برمی‌گردانند؛ از HandleErrorLayer یا یک layer سفارشی استفاده کنید تا log/metric بگیرید و یک Response یکنواخت بسازید. :contentReference[oaicite:6]{index=6}

پاسخ کوتاه

از ترکیب Tower layers (مثلاً HandleErrorLayer) برای خطاهای لایه‌ای و پیاده‌سازی IntoResponse روی error typeهای دامنه برای mapping دقیق خطا به status/body استفاده کنید. :contentReference[oaicite:7]{index=7}


تفاوت برگرداندن Result<T, E> و impl IntoResponse چیست؟

پاسخ بلند

  • impl IntoResponse: تابع فقط می‌تواند یک نوع بازگشتی را داشته باشد؛ اگر بخواهید در شاخه‌های مختلف انواع متفاوتی برگردانید، impl Trait محدودیت دارد و گاهی لازم است به صورت صریح یک Response یا یک common enum برگردانید. علاوه بر این، impl IntoResponse مناسب وقتی است که موفقیت و خطا در یک نوع واحد نمایش داده شوند. :contentReference[oaicite:8]{index=8}

  • Result<T, E>: بیان صریحِ احتمال خطاست. Axum برای Result نیز پیاده‌سازی دارد، اما هر دو شاخه (T و E) باید IntoResponse را پیاده‌سازی کنند تا تبدیل خودکار به Response انجام شود. بنابراین Result به شما اجازه می‌دهد خطا را جدا نگه دارید و آن را با یک IntoResponse مناسب هندل کنید. اگر E انواع مختلفی داشته باشد، معمولاً یک enum خطا می‌سازید و برای آن IntoResponse پیاده‌سازی می‌کنید. :contentReference[oaicite:9]{index=9}

پاسخ کوتاه

impl IntoResponse برمی‌گرداند یک نوع قابل تبدیل به پاسخ؛ Result<T, E> صراحتاً خطا را نشان می‌دهد — ولی برای تبدیل خودکار، هم T و هم E باید IntoResponse را پیاده‌سازی کنند. :contentReference[oaicite:10]{index=10}


چگونه HTTP status code و body را همزمان کنترل می‌کنید؟

پاسخ بلند

چند راه معمول:

  • برگرداندن یک tuple مثل (StatusCode, impl IntoResponse) یا (StatusCode, HeaderMap, body) — Axum این ترکیب‌ها را می‌شناسد و آن‌ها را به Response تبدیل می‌کند. این ساده‌ترین راه برای تعیین هم‌زمان status و body است. :contentReference[oaicite:11]{index=11}

  • ساختن و برگرداندن یک http::Response<Body> کامل وقتی نیاز به کنترل دقیق headers، extensions یا body streaming دارید. :contentReference[oaicite:12]{index=12}

  • در صورت استفاده از Result<T, E>، نوع E را طوری پیاده‌سازی کنید که هنگام Err یک status مناسب و body توضیحی (مثلاً JSON با فیلدهای خطا) تولید کند (یعنی IntoResponse برای E). :contentReference[oaicite:13]{index=13}

پاسخ کوتاه

برای حالت‌های ساده از tuple مثل (StatusCode, body) استفاده کنید؛ برای کنترل کامل از http::Response؛ یا error type را طوری IntoResponse کنید که status+body مناسب تولید کند. :contentReference[oaicite:14]{index=14}


بهترین الگو برای error type در Axum چیست؟

پاسخ بلند

الگوی رایج و مؤثر:

  1. یک enum خطا در سطح application که تمام خطاهای منطقی را پوشش می‌دهد (مثلاً enum AppError { NotFound, Validation(ValidationError), Auth(AuthError), Internal(anyhow::Error) }).
  2. از crates مثل thiserror برای derive کردن Error و متدهای مفید استفاده کنید.
  3. برای AppError یک impl IntoResponse بنویسید که status مناسب را تعیین و body (معمولاً Json<{ code, message, details }> ) را برمی‌گرداند. این کار موجب می‌شود هر handler بتواند Result<T, AppError> برگرداند و mapping سراسری خطاها ساده و یکنواخت باشد.
  4. از logging/observability در نقطهٔ central (مثلاً در IntoResponse برای Internal یا در یک middleware) استفاده کنید تا stack/context لاگ شود اما پاسخ به کاربر امن بماند.

این الگو ترکیب type-safety، خوانایی و امکان unit/integration testing را فراهم می‌کند و با اکوسیستم Axum/Tower به‌خوبی کار می‌کند. :contentReference[oaicite:15]{index=15}

پاسخ کوتاه

یک enum مرکزی برای خطاها + thiserror برای derive + یک impl IntoResponse برای mapping به StatusCode و JSON body. لاگینگ و تبدیل خطاهای داخلی در middleware یا داخل IntoResponse انجام شود. :contentReference[oaicite:16]{index=16}