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 نسخه 1.85.0 (منتشرشده در تاریخ ۲۰۲۵/۰۲/۱۷) یا جدیدتر استفاده می‌کنید و در فایل Cargo.toml تمامی پروژه‌ها مقدار edition = “2024” را برای پیکربندی به‌کار برده‌اید تا از نگارش ۲۰۲۴ Rust و شیوه‌های مرسوم آن بهره‌مند شوید. برای نصب یا به‌روزرسانی Rust به بخش «نصب» در فصل ۱ مراجعه کنید.

فرمت HTML به‌صورت آنلاین در دسترس است در https://doc.rust-lang.org/stable/book/ و به‌صورت آفلاین با نصب‌های راست که با rustup انجام شده‌اند؛ دستور rustup doc --book را اجرا کنید تا باز شود.

چندین [ترجمه] جامعه نیز در دسترس است.

این متن در فرمت کاغذی و الکترونیکی از انتشارات No Starch Press نیز موجود است.

🚨 می‌خواهید تجربه یادگیری تعاملی‌تری داشته باشید؟ نسخه دیگری از کتاب راست را امتحان کنید که شامل: آزمون‌ها، برجسته‌سازی‌ها، تجسم‌ها، و موارد دیگر است: https://rust-book.cs.brown.edu

پیش‌گفتار

همیشه این‌قدر واضح نبود، اما زبان برنامه‌نویسی راست اساساً درباره توانمندسازی است: فرقی نمی‌کند چه نوع کدی اکنون می‌نویسید، راست به شما این قدرت را می‌دهد که فراتر بروید، با اعتمادبه‌نفس در طیف وسیع‌تری از حوزه‌ها برنامه‌نویسی کنید.

به‌عنوان مثال، کارهای “در سطح سیستم” که با جزئیات سطح پایین مدیریت حافظه، نمایش داده‌ها و همروندی سر و کار دارند. به طور سنتی، این حوزه از برنامه‌نویسی پیچیده و فقط برای عده معدودی قابل دسترسی است که سال‌های لازم را برای اجتناب از مشکلات معروف آن صرف کرده‌اند. حتی کسانی که در این زمینه فعالیت می‌کنند نیز با احتیاط عمل می‌کنند تا کد آن‌ها در معرض بهره‌برداری، خرابی یا خرابی داده‌ها قرار نگیرد.

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

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

اما راست محدود به برنامه‌نویسی سیستم‌های سطح پایین نیست. این زبان به قدری بیانگر و راحت است که نوشتن برنامه‌های خط فرمان (CLI)، سرورهای وب و بسیاری از انواع دیگر کدها را دلپذیر می‌کند — نمونه‌های ساده‌ای از هر دو را در بخش‌های بعدی کتاب خواهید یافت. کار با راست به شما این امکان را می‌دهد که مهارت‌هایی بسازید که از یک حوزه به حوزه دیگر قابل‌انتقال باشند؛ می‌توانید راست را با نوشتن یک برنامه وب یاد بگیرید و سپس همان مهارت‌ها را برای هدف قرار دادن رزبری پای خود به کار ببرید.

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

— نیکولاس ماتساکیس و آرون تورون

مقدمه

توجه: این نسخه از کتاب همان The Rust Programming Language است که به صورت چاپی و الکترونیکی از No Starch Press در دسترس است.

به زبان برنامه‌نویسی راست خوش آمدید، یک کتاب مقدماتی درباره راست. زبان برنامه‌نویسی راست به شما کمک می‌کند نرم‌افزاری سریع‌تر و قابل‌اعتمادتر بنویسید. در طراحی زبان‌های برنامه‌نویسی، راحتی در سطح بالا و کنترل در سطح پایین اغلب در تضاد هستند؛ راست این تناقض را به چالش می‌کشد. با ایجاد تعادل بین توانایی‌های فنی قدرتمند و تجربه عالی برنامه‌نویسی، راست به شما این امکان را می‌دهد که جزئیات سطح پایین (مانند استفاده از حافظه) را بدون دردسرهای سنتی مرتبط با چنین کنترلی مدیریت کنید.

راست برای چه کسانی است

راست برای افراد مختلف با دلایل متنوع ایده‌آل است. بیایید به برخی از مهم‌ترین گروه‌ها نگاهی بیندازیم.

تیم‌های برنامه‌نویسی

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

راست همچنین ابزارهای مدرن برنامه‌نویسی را به دنیای برنامه‌نویسی سیستم‌ها می‌آورد:

  • Cargo، مدیر وابستگی و ابزار ساخت، اضافه کردن، کامپایل کردن، و مدیریت وابستگی‌ها را در سراسر اکوسیستم راست ساده و یکپارچه می‌کند.
  • ابزار قالب‌بندی Rustfmt، یک سبک کدنویسی ثابت را در بین برنامه‌نویسان تضمین می‌کند.
  • rust-analyzer یکپارچگی محیط توسعه یکپارچه (IDE) را برای تکمیل کد و پیام‌های خطای درون‌خطی فراهم می‌کند.

با استفاده از این ابزارها و دیگر ابزارهای اکوسیستم راست، برنامه‌نویسان می‌توانند در هنگام نوشتن کد سطح سیستم‌ها بهره‌ور باشند.

دانشجویان

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

شرکت‌ها

صدها شرکت، بزرگ و کوچک، از راست در تولید برای وظایف متنوعی استفاده می‌کنند، از جمله ابزارهای خط فرمان، خدمات وب، ابزارهای DevOps، دستگاه‌های تعبیه‌شده، تحلیل و رمزگذاری صدا و تصویر، ارزهای دیجیتال، زیست‌اطلاعات، موتورهای جستجو، برنامه‌های اینترنت اشیاء، یادگیری ماشین و حتی بخش‌های اصلی مرورگر وب فایرفاکس.

توسعه‌دهندگان متن‌باز

راست برای کسانی است که می‌خواهند زبان برنامه‌نویسی راست، جامعه، ابزارهای توسعه‌دهنده و کتابخانه‌ها را بسازند. ما دوست داریم شما در توسعه زبان راست مشارکت کنید.

افرادی که سرعت و پایداری را ارزشمند می‌دانند

Rust برای کسانی است که به‌دنبال سرعت و پایداری در یک زبان برنامه‌نویسی هستند. منظور از سرعت، هم سرعت اجرای کدهای Rust و هم سرعت توسعه برنامه با استفاده از Rust است. بررسی‌های کامپایلر Rust پایداری را حتی هنگام افزودن ویژگی‌های جدید یا بازسازی کد (refactoring) تضمین می‌کنند. این در تضاد با کدهای قدیمی و شکننده در زبان‌هایی است که چنین بررسی‌هایی ندارند و توسعه‌دهندگان اغلب از تغییر آن‌ها واهمه دارند. Rust با تمرکز بر مفهوم انتزاع‌های بدون‌هزینه (zero-cost abstractions)— یعنی ویژگی‌های سطح بالا که پس از کامپایل به کدی در سطح پایین و سریع مانند کد دستی تبدیل می‌شوند—تلاش می‌کند تا کد امن، کدی سریع نیز باشد.

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

این کتاب برای چه کسانی است

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

نحوه استفاده از این کتاب

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

در این کتاب، دو نوع فصل وجود دارد: فصل‌های مفهومی و فصل‌های پروژه‌ای. در فصل‌های مفهومی، درباره یک جنبه از راست یاد خواهید گرفت. در فصل‌های پروژه‌ای، برنامه‌های کوچکی را با هم می‌سازیم و آنچه را که تاکنون آموخته‌اید به کار می‌گیریم. فصل‌های ۲، ۱۲ و ۲۱ فصل‌های پروژه‌ای هستند؛ بقیه فصل‌ها مفهومی هستند.

فصل ۱ نحوه نصب راست، نوشتن یک برنامه “سلام دنیا!” و استفاده از Cargo، مدیر بسته و ابزار ساخت راست را توضیح می‌دهد. فصل ۲ مقدمه‌ای عملی برای نوشتن برنامه‌ای در راست است و شما را به ساخت یک بازی حدس عدد می‌برد. در اینجا مفاهیم را به طور کلی پوشش می‌دهیم و جزئیات بیشتری را در فصول بعدی ارائه خواهیم کرد. اگر می‌خواهید بلافاصله کار عملی انجام دهید، فصل ۲ مناسب شماست. فصل ۳ ویژگی‌های راست را که مشابه ویژگی‌های سایر زبان‌های برنامه‌نویسی است پوشش می‌دهد و در فصل ۴ درباره سیستم مالکیت راست یاد خواهید گرفت. اگر شما یک یادگیرنده دقیق هستید که ترجیح می‌دهید قبل از ادامه، همه جزئیات را بیاموزید، ممکن است بخواهید فصل ۲ را رد کنید و مستقیماً به فصل ۳ بروید و پس از یادگیری جزئیات به فصل ۲ بازگردید تا روی پروژه‌ای کار کنید.

فصل ۵ به ساختارها (structs) و متدها می‌پردازد و فصل ۶ شامل enumerations (enums)، عبارات match و سازه کنترلی if let است. از ساختارها و enum‌ها برای ایجاد انواع سفارشی در راست استفاده خواهید کرد.

در فصل ۷، درباره سیستم ماژول راست و قوانین حریم خصوصی برای سازمان‌دهی کد و رابط برنامه‌نویسی عمومی (API) آن یاد خواهید گرفت. فصل ۸ به بررسی برخی از ساختارهای داده مجموعه رایج که کتابخانه استاندارد ارائه می‌دهد، مانند vectors، strings و hash maps می‌پردازد. فصل ۹ فلسفه و تکنیک‌های مدیریت خطا در راست را بررسی می‌کند.

فصل ۱۰ به مفاهیم جنریک‌ها، traits و lifetimes می‌پردازد که به شما این قدرت را می‌دهد تا کدی بنویسید که به انواع مختلف اعمال شود. فصل ۱۱ کاملاً درباره تست است که حتی با تضمین‌های ایمنی راست، برای اطمینان از درستی منطق برنامه شما ضروری است. در فصل ۱۲، پیاده‌سازی بخشی از ابزار خط فرمان grep که متن را در فایل‌ها جستجو می‌کند، خواهیم ساخت. برای این کار، از بسیاری از مفاهیمی که در فصل‌های قبلی مورد بحث قرار گرفتند استفاده خواهیم کرد.

فصل ۱۳ به بررسی closures و iterators می‌پردازد: ویژگی‌هایی از راست که از زبان‌های برنامه‌نویسی تابعی آمده‌اند. در فصل ۱۴، Cargo را به طور عمیق‌تری بررسی خواهیم کرد و درباره بهترین روش‌ها برای اشتراک‌گذاری کتابخانه‌های خود با دیگران صحبت خواهیم کرد. فصل ۱۵ اشاره‌گر (Pointer)های هوشمند (smart pointers) ارائه‌شده توسط کتابخانه استاندارد و traitsی که قابلیت‌های آن‌ها را امکان‌پذیر می‌سازد بررسی می‌کند.

در فصل ۱۶، با مدل‌های مختلف برنامه‌نویسی همروند (concurrent) آشنا خواهیم شد و درباره‌ی اینکه چگونه Rust به شما کمک می‌کند تا بدون ترس در چند thread برنامه‌نویسی کنید صحبت می‌کنیم. در فصل ۱۷، بر پایه‌ی آن مفاهیم، نگارش async و await را در Rust بررسی می‌کنیم و همچنین به سراغ taskها، futureها، و streamها می‌رویم که مدل همروندی سبک‌وزن را فراهم می‌کنند.

فصل ۱۸ به مقایسه‌ی شیوه‌های رایج در Rust با اصول برنامه‌نویسی شی‌گرا می‌پردازد که ممکن است پیش‌تر با آن‌ها آشنا باشید. فصل ۱۹ مرجعی است برای الگوها (patterns) و pattern matching، که راهکارهایی قدرتمند برای بیان مفاهیم در سراسر برنامه‌های Rust هستند. فصل ۲۰ مجموعه‌ای متنوع از موضوعات پیشرفته را در بر می‌گیرد، از جمله Rust ناایمن (unsafe)، ماکروها، و مباحث بیشتری درباره‌ی lifetimeها، traitها، نوع‌ها، تابع‌ها و closureها.

در فصل ۲۱، پروژه‌ای را تکمیل می‌کنیم که در آن یک سرور وب چندرشته‌ای سطح پایین پیاده‌سازی خواهیم کرد!

در نهایت، برخی ضمیمه‌ها شامل اطلاعات مفیدی درباره‌ی زبان Rust هستند که به‌صورت مرجع‌گونه ارائه شده‌اند. ضمیمه‌ی الف به کلمات کلیدی Rust می‌پردازد، ضمیمه‌ی ب عملگرها و نمادهای Rust را پوشش می‌دهد، ضمیمه‌ی ج traitهای قابل‌مشتق موجود در کتابخانه‌ی استاندارد را بررسی می‌کند، ضمیمه‌ی د به برخی ابزارهای مفید توسعه می‌پردازد، و ضمیمه‌ی ه نگارش‌های مختلف Rust را توضیح می‌دهد. در ضمیمه‌ی و می‌توانید ترجمه‌های این کتاب را بیابید، و در ضمیمه‌ی ی با روند توسعه‌ی Rust و مفهوم Rust شبانه (nightly) آشنا خواهید شد.

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

بخش مهمی از فرآیند یادگیری راست، یادگیری نحوه خواندن پیام‌های خطای کامپایلر است: این پیام‌ها شما را به سمت کدی که کار می‌کند هدایت می‌کنند. به همین دلیل، مثال‌های زیادی را ارائه می‌دهیم که کامپایل نمی‌شوند، همراه با پیام خطایی که کامپایلر در هر وضعیت نمایش می‌دهد. بدانید که اگر یک مثال تصادفی را وارد کنید و اجرا کنید، ممکن است کامپایل نشود! مطمئن شوید که متن اطراف را بخوانید تا ببینید آیا مثالی که می‌خواهید اجرا کنید قرار است خطا بدهد یا خیر. Ferris همچنین به شما کمک می‌کند کدی که قرار نیست کار کند را تشخیص دهید:

Ferrisمعنی
Ferris with a question markاین کد کامپایل نمی‌شود!
Ferris throwing up their handsاین کد وحشت می‌کند!
Ferris with one claw up, shruggingاین کد رفتار مورد انتظار را تولید نمی‌کند.

در بیشتر موارد، شما را به نسخه صحیح هر کدی که کامپایل نمی‌شود هدایت خواهیم کرد.

کد منبع

فایل‌های منبعی که این کتاب از آن‌ها تولید می‌شود را می‌توانید در GitHub پیدا کنید.

شروع به کار

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

  • نصب راست بر روی لینوکس، macOS، و ویندوز
  • نوشتن برنامه‌ای که سلام دنیا! را چاپ می‌کند
  • استفاده از cargo، مدیر بسته و سیستم ساخت راست

نصب

اولین قدم نصب راست است. ما راست را از طریق rustup دانلود می‌کنیم، ابزاری خط فرمان برای مدیریت نسخه‌های راست و ابزارهای مربوطه. برای دانلود به اتصال اینترنتی نیاز دارید.

توجه: اگر به هر دلیلی ترجیح می‌دهید از rustup استفاده نکنید، لطفاً صفحه روش‌های نصب دیگر راست را برای گزینه‌های بیشتر مشاهده کنید.

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

یادداشت دستورات خط فرمان

در این فصل و throughout the book، ما برخی از دستورات استفاده شده در ترمینال را نمایش خواهیم داد. خطوطی که باید در ترمینال وارد کنید، همگی با $ شروع می‌شوند. شما نیازی به وارد کردن نماد $ ندارید؛ این نماد نشان‌دهنده شروع هر دستور است. خطوطی که با $ شروع نمی‌شوند معمولاً خروجی دستور قبلی را نشان می‌دهند. علاوه بر این، مثال‌های خاص PowerShell از > به جای $ استفاده می‌کنند.

نصب rustup در لینوکس یا macOS

اگر از لینوکس یا macOS استفاده می‌کنید، یک ترمینال باز کرده و دستور زیر را وارد کنید:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

این دستور یک اسکریپت دانلود کرده و نصب ابزار rustup را آغاز می‌کند که نسخه پایدار جدید راست را نصب می‌کند. ممکن است از شما خواسته شود تا رمز عبور خود را وارد کنید. اگر نصب موفقیت‌آمیز بود، خط زیر ظاهر می‌شود:

Rust is installed now. Great!

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

برای نصب کامپایلر C در macOS، دستور زیر را اجرا کنید:

$ xcode-select --install

کاربران لینوکس معمولاً باید GCC یا Clang را طبق مستندات توزیع خود نصب کنند. برای مثال، اگر از اوبونتو استفاده می‌کنید، می‌توانید پکیج build-essential را نصب کنید.

نصب rustup در ویندوز

در ویندوز، به https://www.rust-lang.org/tools/install بروید و دستورالعمل‌های نصب راست را دنبال کنید. در یک مرحله از نصب، از شما خواسته می‌شود تا Visual Studio را نصب کنید. این ابزار یک لینکر و کتابخانه‌های بومی لازم برای کامپایل برنامه‌ها را فراهم می‌کند. اگر به کمک بیشتری نیاز دارید، این صفحه را مشاهده کنید https://rust-lang.github.io/rustup/installation/windows-msvc.html

بقیه کتاب از دستورات استفاده شده در cmd.exe و PowerShell استفاده می‌کند. اگر تفاوت‌های خاصی وجود داشته باشد، توضیح خواهیم داد که کدام را باید استفاده کنید.

عیب‌یابی

برای بررسی اینکه راست به درستی نصب شده است یا خیر، یک شل باز کرده و این دستور را وارد کنید:

$ rustc --version

باید شماره نسخه، هش کمیّت و تاریخ کمیّت برای جدیدترین نسخه پایدار منتشر شده را به صورت زیر ببینید:

rustc x.y.z (abcabcabc yyyy-mm-dd)

اگر این اطلاعات را مشاهده کردید، راست به درستی نصب شده است! اگر این اطلاعات را مشاهده نکردید، بررسی کنید که راست در متغیر سیستم %PATH% شما قرار دارد.

در CMD ویندوز، از دستور زیر استفاده کنید:

> echo %PATH%

در PowerShell، از دستور زیر استفاده کنید:

> echo $env:Path

در لینوکس و macOS، از دستور زیر استفاده کنید:

$ echo $PATH

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

بروزرسانی و حذف نصب

بعد از نصب راست از طریق rustup، بروزرسانی به نسخه جدید بسیار آسان است. از شل خود دستور زیر را اجرا کنید:

$ rustup update

برای حذف نصب راست و rustup، اسکریپت حذف زیر را از شل خود اجرا کنید:

$ rustup self uninstall

مستندات محلی

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

هر زمان که از یک نوع یا تابع ارائه‌شده توسط کتابخانه استاندارد استفاده می‌کنید و مطمئن نیستید که چه کار می‌کند یا چگونه از آن استفاده کنید، از مستندات رابط برنامه‌نویسی (API) برای یافتن آن استفاده کنید!

ویرایشگرهای متن و محیط‌های توسعه یکپارچه

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

کار با این کتاب به‌صورت آفلاین

در چندین مثال، از پکیج‌هایی در Rust استفاده خواهیم کرد که فراتر از کتابخانه استاندارد هستند. برای اجرای این مثال‌ها، یا باید به اینترنت متصل باشید یا اینکه پیشاپیش این وابستگی‌ها را دانلود کرده باشید. برای دانلود پیشاپیش این وابستگی‌ها، می‌توانید دستورات زیر را اجرا کنید. (در ادامه، cargo و عملکرد هرکدام از این دستورات را به‌طور کامل توضیح خواهیم داد.)

$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add [email protected] [email protected]

این کار نسخه‌های دانلودشده‌ی این پکیج‌ها را در کش ذخیره می‌کند تا در آینده نیازی به دانلود مجدد نباشد. پس از اجرای این دستورات، نیازی به نگه‌داشتن پوشه‌ی get-dependencies ندارید. اگر این دستورات را اجرا کرده باشید، می‌توانید در باقی قسمت‌های این کتاب از فلگ --offline همراه با تمام دستورات cargo استفاده کنید تا به‌جای اتصال به شبکه، از نسخه‌های کش‌شده بهره ببرید.

سلام، دنیا!

حالا که Rust را نصب کرده‌اید، وقت آن است که اولین برنامه‌ی Rust خود را بنویسید. وقتی زبان جدیدی را یاد می‌گیرید، معمولاً یک برنامه کوچک می‌نویسید که متن Hello, world! را به صفحه نمایش چاپ کند، پس ما هم همین کار را خواهیم کرد!

نکته: این کتاب فرض می‌کند که شما با خط فرمان آشنایی پایه‌ای دارید. Rust هیچ‌گونه الزامی در مورد ویرایش یا ابزارهای شما یا جایی که کد شما قرار دارد ندارد، بنابراین اگر ترجیح می‌دهید از یک محیط توسعه یکپارچه (IDE) به جای خط فرمان استفاده کنید، می‌توانید از IDE مورد علاقه خود استفاده کنید. بسیاری از IDE‌ها اکنون از Rust پشتیبانی می‌کنند؛ برای جزئیات، مستندات IDE خود را بررسی کنید. تیم Rust تمرکز خود را بر enabling پشتیبانی خوب از IDE از طریق rust-analyzer گذاشته است. برای جزئیات بیشتر، به ضمیمه د مراجعه کنید.

ایجاد یک دایرکتوری پروژه

شما با ایجاد یک دایرکتوری برای ذخیره کدهای Rust خود شروع خواهید کرد. برای Rust مهم نیست که کد شما کجا قرار دارد، اما برای تمرین‌ها و پروژه‌های این کتاب، پیشنهاد می‌کنیم یک دایرکتوری projects در دایرکتوری خانه‌تان بسازید و تمام پروژه‌هایتان را در آن نگهدارید.

یک ترمینال باز کنید و دستورات زیر را وارد کنید تا یک دایرکتوری projects و یک دایرکتوری برای پروژه‌ی “Hello, world!” در داخل دایرکتوری projects ایجاد کنید.

برای لینوکس، macOS، و PowerShell در ویندوز، این دستورات را وارد کنید:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

برای CMD ویندوز، این دستورات را وارد کنید:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

نوشتن و اجرای یک برنامه Rust

حالا یک فایل سورس جدید بسازید و آن را main.rs نام‌گذاری کنید. فایل‌های Rust همیشه با پسوند .rs تمام می‌شوند. اگر از بیش از یک کلمه در نام فایل استفاده می‌کنید، سنت معمول این است که از خط تیره زیر برای جدا کردن آنها استفاده کنید. به عنوان مثال، از hello_world.rs به جای helloworld.rs استفاده کنید.

حالا فایل main.rs که تازه ایجاد کرده‌اید را باز کنید و کد موجود در فهرست 1-1 را وارد کنید.

Filename: main.rs
fn main() {
    println!("Hello, world!");
}
Listing 1-1: یک برنامه که Hello, world! را چاپ می‌کند

فایل را ذخیره کنید و به پنجره ترمینال خود در دایرکتوری ~/projects/hello_world برگردید. در لینوکس یا macOS، دستورات زیر را وارد کنید تا فایل را کامپایل کرده و اجرا کنید:

$ rustc main.rs
$ ./main
Hello, world!

در ویندوز، به جای ./main دستور .\main.exe را وارد کنید:

> rustc main.rs
> .\main
Hello, world!

صرف‌نظر از سیستم‌عامل شما، رشته Hello, world! باید در ترمینال چاپ شود. اگر این خروجی را مشاهده نکردید، به بخش “رفع مشکلات” در قسمت نصب مراجعه کنید تا روش‌های دریافت کمک را بیابید.

اگر Hello, world! چاپ شد، تبریک می‌گوییم! شما به طور رسمی یک برنامه نویس Rust شده‌اید—خوش آمدید!

آناتومی یک برنامه Rust

بیایید این برنامه “Hello, world!” را به طور دقیق بررسی کنیم. این اولین بخش معما است:

fn main() {

}

این خطوط یک تابع به نام main تعریف می‌کنند. تابع main خاص است: همیشه اولین کدی است که در هر برنامه Rust اجرایی اجرا می‌شود. در اینجا، خط اول یک تابع به نام main اعلام می‌کند که هیچ پارامتر ندارد و هیچ چیزی را برنمی‌گرداند. اگر پارامترهایی وجود داشتند، آن‌ها داخل پرانتزهای () قرار می‌گرفتند.

بدن تابع در {} قرار دارد. Rust از آکولادها برای احاطه کردن تمام بدنه‌های توابع استفاده می‌کند. این یک سبک خوب است که آکولاد باز را در همان خط اعلام تابع قرار دهید و یک فضای خالی بین آن‌ها اضافه کنید.

نکته: اگر می‌خواهید در پروژه‌های Rust خود از یک سبک استاندارد پیروی کنید، می‌توانید از ابزاری به نام rustfmt برای فرمت کردن کد خود در یک سبک خاص استفاده کنید (بیشتر در مورد rustfmt در ضمیمه د). تیم Rust این ابزار را همراه با توزیع استاندارد Rust شامل کرده است، همانطور که rustc است، بنابراین باید قبلاً روی کامپیوتر شما نصب شده باشد!

بدن تابع main شامل کد زیر است:

#![allow(unused)]
fn main() {
println!("Hello, world!");
}

این خط، تمام کار این برنامه‌ی کوچک را انجام می‌دهد: متنی را روی صفحه چاپ می‌کند. در اینجا سه نکته‌ی مهم وجود دارد که باید به آن‌ها توجه کنید.

نخست، println! یک ماکرو در Rust را فراخوانی می‌کند. اگر به‌جای ماکرو، یک تابع فراخوانی شده بود، نگارش آن به‌صورت println (بدون !) می‌بود. ماکروهای Rust روشی برای نوشتن کدی هستند که کد دیگری تولید می‌کنند و به گسترش نگارش Rust کمک می‌کنند. در فصل بیستم Chapter 20 آن‌ها را با جزئیات بیشتری بررسی خواهیم کرد. در حال حاضر تنها کافی‌ست بدانید که استفاده از ! به این معناست که در حال فراخوانی یک ماکرو هستید و ماکروها همیشه از همان قواعدی که توابع پیروی می‌کنند، تبعیت نمی‌کنند.

دوم، شما رشته "Hello, world!" را مشاهده می‌کنید. این رشته را به عنوان آرگومان به println! می‌دهیم و این رشته به صفحه نمایش چاپ می‌شود.

سوم، خط را با یک نقطه‌ویرگول (;) تمام می‌کنیم که نشان می‌دهد این عبارت تمام شده و عبارت بعدی آماده شروع است. بیشتر خطوط کد Rust با نقطه‌ویرگول تمام می‌شوند.

کامپایل کردن و اجرا کردن مراحل جداگانه هستند

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

قبل از اجرای یک برنامه Rust، باید آن را با استفاده از کامپایلر Rust کامپایل کنید. برای این کار باید دستور rustc را وارد کرده و نام فایل سورس خود را به آن بدهید، مانند این:

$ rustc main.rs

اگر پیش‌زمینه‌ای از C یا C++ دارید، متوجه خواهید شد که این مشابه دستور gcc یا clang است. پس از کامپایل موفق، Rust یک فایل اجرایی باینری تولید می‌کند.

در لینوکس، macOS و PowerShell در ویندوز، می‌توانید فایل اجرایی را با وارد کردن دستور ls در شل خود مشاهده کنید:

$ ls
main  main.rs

در لینوکس و macOS، شما دو فایل خواهید دید. در PowerShell در ویندوز، همان سه فایلی را که با CMD می‌بینید مشاهده خواهید کرد. در CMD در ویندوز، باید دستور زیر را وارد کنید:

> dir /B %= گزینه /B می‌گوید که فقط نام فایل‌ها نمایش داده شود =%
main.exe
main.pdb
main.rs

این لیست فایل سورس با پسوند .rs، فایل اجرایی (main.exe در ویندوز، اما main در سایر پلتفرم‌ها)، و در صورت استفاده از ویندوز، یک فایل شامل اطلاعات دیباگ با پسوند .pdb را نشان می‌دهد. از اینجا، شما فایل main یا main.exe را اجرا می‌کنید، مانند این:

$ ./main # یا .\main.exe در ویندوز

اگر فایل main.rs شما برنامه “Hello, world!” باشد، این خط Hello, world! را در ترمینال شما چاپ می‌کند.

اگر با زبان‌های داینامیک مانند Ruby، Python یا JavaScript آشنایی بیشتری دارید، ممکن است عادت نداشته باشید که کامپایل و اجرای یک برنامه را به عنوان مراحل جداگانه انجام دهید. Rust یک زبان کامپایل شده پیش از زمان است، به این معنی که شما می‌توانید یک برنامه را کامپایل کرده و فایل اجرایی را به شخص دیگری بدهید تا آن را اجرا کند، حتی بدون اینکه Rust روی سیستم آن شخص نصب شده باشد. اگر به کسی فایل .rb، .py یا .js بدهید، آن‌ها نیاز به نصب پیاده‌سازی Ruby، Python یا JavaScript (به ترتیب) دارند. اما در این زبان‌ها، شما فقط به یک دستور نیاز دارید تا برنامه خود را کامپایل و اجرا کنید. همه چیز در طراحی زبان‌ها یک تعادل است.

فقط با کامپایل کردن با rustc برای برنامه‌های ساده کافی است، اما با رشد پروژه شما، می‌خواهید تمام گزینه‌ها را مدیریت کرده و اشتراک‌گذاری کد خود را آسان کنید. در ادامه، ما ابزار Cargo را معرفی خواهیم کرد که به شما کمک می‌کند برنامه‌های واقعی Rust بنویسید.

سلام، Cargo!

Cargo سیستم ساخت و مدیر بسته‌های Rust است. بیشتر Rustacean ها از این ابزار برای مدیریت پروژه‌های Rust خود استفاده می‌کنند زیرا Cargo بسیاری از وظایف را برای شما انجام می‌دهد، مانند ساختن کد شما، دانلود کتابخانه‌هایی که کد شما به آن‌ها وابسته است، و ساختن آن کتابخانه‌ها. (ما به کتابخانه‌هایی که کد شما به آن‌ها نیاز دارد وابستگی‌ها می‌گوییم.)

ساده‌ترین برنامه‌های Rust، مانند برنامه‌ای که تا کنون نوشته‌ایم، هیچ وابستگی‌ای ندارند. اگر پروژه “Hello, world!” را با Cargo می‌ساختیم، فقط از بخشی از Cargo استفاده می‌کرد که مسئول ساختن کد شما است. هنگامی که برنامه‌های پیچیده‌تری در Rust بنویسید، وابستگی‌ها را اضافه خواهید کرد و اگر پروژه‌ای را با استفاده از Cargo شروع کنید، اضافه کردن وابستگی‌ها بسیار راحت‌تر خواهد بود.

به دلیل اینکه اکثریت عظیم پروژه‌های Rust از Cargo استفاده می‌کنند، بقیه این کتاب فرض می‌کند که شما نیز از Cargo استفاده می‌کنید. Cargo با Rust نصب می‌شود اگر از نصب‌کننده‌های رسمی که در بخش [“نصب”][installation] بحث شده‌اند استفاده کرده باشید. اگر Rust را از طریق روش‌های دیگری نصب کرده‌اید، بررسی کنید که آیا Cargo نصب شده است یا نه با وارد کردن دستور زیر در ترمینال خود:

$ cargo --version

اگر شماره نسخه‌ای مشاهده کردید، آن را دارید! اگر خطای command not found را دیدید، به مستندات روش نصب خود مراجعه کنید تا نحوه نصب جداگانه Cargo را پیدا کنید.

ایجاد یک پروژه با Cargo

بیایید یک پروژه جدید با استفاده از Cargo بسازیم و ببینیم چگونه از پروژه اولیه “Hello, world!” ما متفاوت است. به دایرکتوری projects خود بروید (یا هر جایی که تصمیم گرفته‌اید کد خود را ذخیره کنید). سپس، در هر سیستم‌عاملی، دستور زیر را وارد کنید:

$ cargo new hello_cargo
$ cd hello_cargo

دستور اول یک دایرکتوری جدید به نام hello_cargo ایجاد می‌کند و پروژه‌ای به همین نام ایجاد می‌کند. ما پروژه خود را hello_cargo نام‌گذاری کرده‌ایم و Cargo فایل‌های خود را در دایرکتوری به همین نام ایجاد می‌کند.

به دایرکتوری hello_cargo بروید و فایل‌ها را لیست کنید. خواهید دید که Cargo دو فایل و یک دایرکتوری برای ما ایجاد کرده است: یک فایل Cargo.toml و یک دایرکتوری src که داخل آن یک فایل main.rs است.

همچنین یک مخزن Git جدید به همراه یک فایل .gitignore ایجاد شده است. فایل‌های Git در صورتی که دستور cargo new را در یک مخزن Git موجود اجرا کنید، ایجاد نمی‌شوند؛ می‌توانید این رفتار را با استفاده از cargo new --vcs=git لغو کنید.

نکته: Git یک سیستم کنترل نسخه رایج است. شما می‌توانید دستور cargo new را تغییر دهید تا از سیستم کنترل نسخه‌ای متفاوت یا هیچ سیستم کنترل نسخه‌ای استفاده کند با استفاده از پرچم --vcs. برای دیدن گزینه‌های موجود، دستور cargo new --help را اجرا کنید.

فایل Cargo.toml را در ویرایشگر متن دلخواه خود باز کنید. این فایل باید مشابه کدی باشد که در فهرست 1-2 آمده است.

Filename: Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]
Listing 1-2: محتویات Cargo.toml که توسط cargo new ایجاد شده است

این فایل در فرمت [TOML][toml] (زبان ساده و آشکار تام) است که فرمت پیکربندی Cargo است.

خط اول، [package]، یک عنوان بخش است که نشان می‌دهد بیانیه‌های بعدی در حال پیکربندی یک بسته هستند. همانطور که اطلاعات بیشتری به این فایل اضافه می‌کنیم، بخش‌های دیگری را اضافه خواهیم کرد.

سه خط بعدی اطلاعات پیکربندی‌ای را تنظیم می‌کنند که Cargo برای کامپایل برنامه شما به آن‌ها نیاز دارد: نام، نسخه و نسخه‌ای از Rust که باید استفاده شود. در مورد کلید edition در [ضمیمه ه][appendix-e] صحبت خواهیم کرد.

آخرین خط، [dependencies]، شروع یک بخش است که شما باید وابستگی‌های پروژه خود را در آن ذکر کنید. در Rust، بسته‌های کد به نام کرِیت‌ها شناخته می‌شوند. برای این پروژه نیازی به کرِیت‌های دیگر نداریم، اما در پروژه اول فصل 2 به آن‌ها نیاز خواهیم داشت، بنابراین در آن زمان از این بخش وابستگی‌ها استفاده خواهیم کرد.

حالا فایل src/main.rs را باز کنید و نگاهی بیندازید:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo یک برنامه “Hello, world!” برای شما ایجاد کرده است، درست مانند برنامه‌ای که در فهرست 1-1 نوشتیم! تا کنون، تفاوت‌های بین پروژه ما و پروژه‌ای که Cargo ایجاد کرده این است که Cargo کد را در دایرکتوری src قرار داده و ما یک فایل پیکربندی Cargo.toml در دایرکتوری بالای پروژه داریم.

Cargo انتظار دارد که فایل‌های منبع شما داخل دایرکتوری src قرار داشته باشند. دایرکتوری بالای پروژه فقط برای فایل‌های README، اطلاعات مجوز، فایل‌های پیکربندی و هر چیز دیگری که مربوط به کد شما نباشد، استفاده می‌شود. استفاده از Cargo به شما کمک می‌کند پروژه‌هایتان را سازماندهی کنید. برای هر چیز جایی وجود دارد و همه چیز در جای خود قرار دارد.

اگر پروژه‌ای شروع کرده‌اید که از Cargo استفاده نمی‌کند، همانطور که در پروژه “Hello, world!” انجام دادیم، می‌توانید آن را به پروژه‌ای که از Cargo استفاده می‌کند تبدیل کنید. کد پروژه را به دایرکتوری src منتقل کرده و یک فایل Cargo.toml مناسب ایجاد کنید. یکی از راه‌های آسان برای به‌دست آوردن آن فایل Cargo.toml این است که دستور cargo init را اجرا کنید که به‌طور خودکار آن را برای شما ایجاد می‌کند.

ساخت و اجرای پروژه با Cargo

حالا بیایید ببینیم که چه تفاوتی در زمانی که برنامه “Hello, world!” را با Cargo می‌سازیم و اجرا می‌کنیم وجود دارد! از دایرکتوری hello_cargo خود، پروژه را با وارد کردن دستور زیر بسازید:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

این دستور یک فایل اجرایی در target/debug/hello_cargo (یا target\debug\hello_cargo.exe در ویندوز) ایجاد می‌کند به جای این که آن را در دایرکتوری فعلی شما قرار دهد. زیرا ساخت پیش‌فرض یک ساخت دیباگ است، Cargo فایل باینری را در دایرکتوری به نام debug قرار می‌دهد. شما می‌توانید فایل اجرایی را با این دستور اجرا کنید:

$ ./target/debug/hello_cargo # یا .\target\debug\hello_cargo.exe در ویندوز
Hello, world!

اگر همه چیز درست پیش رفته باشد، Hello, world! باید در ترمینال چاپ شود. اجرای cargo build برای اولین بار همچنین باعث می‌شود که Cargo یک فایل جدید در بالای دایرکتوری ایجاد کند: Cargo.lock. این فایل نسخه‌های دقیق وابستگی‌های پروژه شما را پیگیری می‌کند. چون این پروژه وابستگی ندارد، این فایل کمی خالی است. شما هیچ‌گاه نیازی به تغییر دستی این فایل نخواهید داشت؛ Cargo محتویات آن را برای شما مدیریت می‌کند.

ما همین حالا پروژه را با دستور cargo build ساختیم و با ./target/debug/hello_cargo اجرا کردیم، اما همچنین می‌توانیم از cargo run برای کامپایل کردن کد و سپس اجرای باینری حاصل در یک دستور استفاده کنیم:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

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

توجه کنید که این بار، خروجی‌ای که نشان دهد Cargo در حال کامپایل کردن hello_cargo است، مشاهده نکردیم. Cargo متوجه شد که فایل‌ها تغییر نکرده‌اند، بنابراین بازسازی نکرد و فقط باینری را اجرا کرد. اگر کد منبع خود را تغییر داده بودید، Cargo ابتدا پروژه را بازسازی می‌کرد و سپس آن را اجرا می‌کرد، و شما این خروجی را می‌دیدید:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo همچنین یک دستور به نام cargo check را فراهم می‌کند. این دستور کد شما را به سرعت بررسی می‌کند تا مطمئن شود که کامپایل می‌شود اما هیچ اجرایی تولید نمی‌کند:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

چرا شما به یک فایل اجرایی نیاز ندارید؟ اغلب، cargo check بسیار سریع‌تر از cargo build است زیرا مرحله تولید یک فایل اجرایی را رد می‌کند. اگر شما به طور مداوم در حال بررسی کد خود هستید، استفاده از cargo check سرعت فرایند اطلاع دادن به شما از این که پروژه هنوز کامپایل می‌شود را افزایش می‌دهد! به همین دلیل، بسیاری از Rustaceans به طور دوره‌ای cargo check را در حین نوشتن کد خود اجرا می‌کنند تا مطمئن شوند که پروژه‌شان کامپایل می‌شود. سپس زمانی که آماده استفاده از باینری شدند، از دستور cargo build استفاده می‌کنند.

بیایید خلاصه‌ای از آنچه که تا به حال در مورد Cargo آموخته‌ایم مرور کنیم:

  • ما می‌توانیم یک پروژه با استفاده از cargo new بسازیم.
  • ما می‌توانیم یک پروژه را با استفاده از cargo build بسازیم.
  • ما می‌توانیم یک پروژه را با یک مرحله از ساخت و اجرا با استفاده از cargo run بسازیم و اجرا کنیم.
  • ما می‌توانیم یک پروژه را بدون تولید باینری برای بررسی خطاها با استفاده از cargo check بسازیم.
  • به جای ذخیره نتیجه ساخت در همان دایرکتوری که کد ما قرار دارد، Cargo آن را در دایرکتوری target/debug ذخیره می‌کند.

یک مزیت اضافی استفاده از Cargo این است که دستورات آن در همه سیستم‌عامل‌ها یکسان است. بنابراین، از این پس، دیگر دستورالعمل‌های خاصی برای لینوکس و macOS در مقابل ویندوز ارائه نخواهیم کرد.

ساخت برای انتشار

وقتی پروژه شما آماده انتشار است، می‌توانید از دستور cargo build --release برای کامپایل کردن آن با بهینه‌سازی‌ها استفاده کنید. این دستور یک فایل اجرایی در دایرکتوری target/release به جای target/debug ایجاد می‌کند. بهینه‌سازی‌ها باعث می‌شوند که کد Rust شما سریع‌تر اجرا شود، اما فعال کردن آن‌ها زمان کامپایل برنامه را طولانی‌تر می‌کند. به همین دلیل، دو پروفایل مختلف وجود دارد: یکی برای توسعه که شما می‌خواهید سریعاً و به دفعات پروژه را بازسازی کنید، و دیگری برای ساختن برنامه نهایی که به کاربر تحویل خواهید داد، که به دفعات بازسازی نمی‌شود و باید سریع‌ترین اجرا را داشته باشد. اگر در حال اندازه‌گیری زمان اجرای کد خود هستید، حتماً از دستور cargo build --release استفاده کنید و با فایل اجرایی در target/release اندازه‌گیری کنید.

Cargo به عنوان یک کنوانسیون

در پروژه‌های ساده، Cargo نسبت به استفاده از rustc مزیت زیادی ندارد، اما با پیچیده‌تر شدن برنامه‌ها، ارزش خود را نشان می‌دهد. زمانی که برنامه‌ها به چندین فایل نیاز پیدا می‌کنند یا وابستگی دارند، استفاده از Cargo برای هماهنگ کردن فرایند ساخت بسیار راحت‌تر می‌شود.

حتی اگر پروژه hello_cargo ساده باشد، اکنون از بسیاری از ابزارهای واقعی استفاده می‌کند که در طول مسیر Rust خود به آن‌ها نیاز خواهید داشت. در واقع، برای کار بر روی هر پروژه موجود، می‌توانید از دستورات زیر برای بررسی کد با استفاده از Git، تغییر به دایرکتوری آن پروژه و ساخت آن استفاده کنید:

$ git clone example.org/someproject
$ cd someproject
$ cargo build

برای اطلاعات بیشتر در مورد Cargo، می‌توانید به مستندات آن مراجعه کنید.

خلاصه

شما در حال حاضر شروع بسیار خوبی برای سفر خود در Rust دارید! در این فصل، شما یاد گرفته‌اید که چگونه:

  • آخرین نسخه پایدار Rust را با استفاده از rustup نصب کنید.
  • به نسخه جدیدتر Rust بروزرسانی کنید.
  • مستندات محلی نصب‌شده را باز کنید.
  • یک برنامه “Hello, world!” را با استفاده از rustc مستقیماً بنویسید و اجرا کنید.
  • یک پروژه جدید را با استفاده از کنوانسیون‌های Cargo بسازید و اجرا کنید.

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

برنامه‌نویسی یک بازی حدس زدن

بیایید با کار روی یک پروژه عملی با هم به دنیای Rust وارد شویم! این فصل با نشان دادن نحوه استفاده از مفاهیم رایج Rust در یک برنامه واقعی، شما را با آن‌ها آشنا می‌کند. درباره let، match، متدها، توابع مرتبط (associated functions)، جعبه‌ها (crates)ی خارجی و موارد دیگر خواهید آموخت! در فصل‌های بعدی، این ایده‌ها را به طور مفصل بررسی خواهیم کرد. در این فصل، فقط اصول اولیه را تمرین می‌کنید.

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

راه‌اندازی یک پروژه جدید

برای راه‌اندازی یک پروژه جدید، به دایرکتوری projects که در فصل 1 ایجاد کردید بروید و یک پروژه جدید با استفاده از Cargo ایجاد کنید، به این صورت:

$ cargo new guessing_game
$ cd guessing_game

دستور اول، cargo new، نام پروژه (guessing_game) را به عنوان آرگومان اول می‌گیرد. دستور دوم به دایرکتوری پروژه جدید منتقل می‌شود.

فایل Cargo.toml تولیدشده را مشاهده کنید:

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"

[dependencies]

همان‌طور که در فصل 1 دیدید، cargo new یک برنامه “Hello, world!” برای شما تولید می‌کند. فایل src/main.rs را بررسی کنید:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

حالا این برنامه “Hello, world!” را کامپایل کرده و در همان مرحله با استفاده از دستور cargo run اجرا کنید:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/guessing_game`
Hello, world!

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

فایل src/main.rs را دوباره باز کنید. شما تمام کد را در این فایل خواهید نوشت.

پردازش یک حدس

اولین بخش از برنامه بازی حدس زدن از کاربر درخواست ورودی می‌کند، آن ورودی را پردازش می‌کند و بررسی می‌کند که ورودی در قالب مورد انتظار باشد. برای شروع، به بازیکن اجازه می‌دهیم یک حدس وارد کند. کد موجود در لیستینگ 2-1 را در فایل src/main.rs وارد کنید.

Filename: src/main.rs
use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}
Listing 2-1: کدی که یک حدس از کاربر دریافت کرده و آن را چاپ می‌کند

این کد اطلاعات زیادی دارد، پس بیایید خط به خط آن را بررسی کنیم. برای گرفتن ورودی کاربر و سپس چاپ نتیجه به‌عنوان خروجی، نیاز داریم که کتابخانه ورودی/خروجی io را به دامنه بیاوریم. کتابخانه io از کتابخانه استاندارد که با نام std شناخته می‌شود، می‌آید:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

به‌طور پیش‌فرض، Rust مجموعه‌ای از آیتم‌ها را که در کتابخانه استاندارد تعریف شده‌اند به دامنه هر برنامه وارد می‌کند. این مجموعه prelude نامیده می‌شود و می‌توانید همه چیز در آن را در مستندات کتابخانه استاندارد ببینید.

اگر نوعی که می‌خواهید استفاده کنید در prelude نباشد، باید آن نوع را به‌طور صریح با یک دستور use به دامنه بیاورید. استفاده از کتابخانه std::io به شما ویژگی‌های مفیدی مانند امکان پذیرش ورودی کاربر می‌دهد.

همان‌طور که در فصل 1 دیدید، تابع main نقطه ورود به برنامه است:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

نحو fn یک تابع جدید را اعلام می‌کند؛ پرانتزها () نشان می‌دهند که هیچ پارامتری وجود ندارد و کروشه باز { بدنه تابع را شروع می‌کند.

همچنین در فصل 1 آموختید که println! یک ماکرو است که یک رشته را به صفحه چاپ می‌کند:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

این کد یک پیغام اعلام می‌کند که بازی چیست و از کاربر درخواست ورودی می‌کند.

ذخیره مقادیر با متغیرها

سپس، یک متغیر ایجاد می‌کنیم تا ورودی کاربر را ذخیره کند، مانند این:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

حالا برنامه جالب‌تر می‌شود! در این خط کوچک چیزهای زیادی در حال اتفاق است. ما از دستور let برای ایجاد متغیر استفاده می‌کنیم. در اینجا یک مثال دیگر آورده شده است:

let apples = 5;

این خط یک متغیر جدید به نام apples ایجاد می‌کند و آن را به مقدار 5 متصل می‌کند. در Rust، متغیرها به‌طور پیش‌فرض غیرقابل‌تغییر هستند، به این معنا که پس از اختصاص مقدار به متغیر، مقدار تغییر نخواهد کرد. این مفهوم را به‌طور مفصل در بخش “متغیرها و تغییرپذیری” در فصل 3 بررسی خواهیم کرد. برای متغیری که تغییرپذیر باشد، mut را قبل از نام متغیر اضافه می‌کنیم:

let apples = 5; // immutable
let mut bananas = 5; // mutable

نکته: نحو // یک نظر (comment) را آغاز می‌کند که تا انتهای خط ادامه دارد. Rust همه چیز در نظرات را نادیده می‌گیرد. نظرات را در فصل 3 با جزئیات بیشتری بررسی خواهیم کرد.

بازگشت به برنامه بازی حدس زدن: اکنون می‌دانید که let mut guess یک متغیر تغییرپذیر به نام guess معرفی می‌کند. علامت مساوی (=) به Rust می‌گوید که می‌خواهیم چیزی را به این متغیر متصل کنیم. در سمت راست علامت مساوی، مقداری قرار دارد که guess به آن متصل می‌شود، که نتیجه فراخوانی String::new است، یک تابع که یک نمونه جدید از نوع String بازمی‌گرداند. String یک نوع رشته‌ای ارائه‌شده توسط کتابخانه استاندارد است که بخشی از متن قابل رشد و با رمزگذاری UTF-8 است.

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

در مجموع، خط let mut guess = String::new(); یک متغیر تغییرپذیر ایجاد کرده است که در حال حاضر به یک نمونه جدید و خالی از String متصل شده است. خوب!

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

به یاد آورید که با use std::io; در اولین خط برنامه، قابلیت ورودی/خروجی را از کتابخانه استاندارد اضافه کردیم. اکنون تابع stdin را از ماژول io فراخوانی می‌کنیم که به ما امکان مدیریت ورودی کاربر را می‌دهد:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

اگر ماژول io را با دستور use std::io; در ابتدای برنامه وارد نکرده بودیم، همچنان می‌توانستیم از این تابع استفاده کنیم، به این شکل که آن را به‌صورت std::io::stdin فراخوانی کنیم. تابع stdin نمونه‌ای از std::io::Stdin بازمی‌گرداند، که نوعی است برای نمایش یک دسته (handle) به ورودی استاندارد ترمینال شما.

در خط بعدی، متد .read_line(&mut guess) را روی handle ورودی استاندارد فراخوانی می‌کنیم تا ورودی کاربر را دریافت کنیم. همچنین &mut guess را به‌عنوان آرگومان به read_line ارسال می‌کنیم تا به آن بگوییم ورودی کاربر را در چه رشته‌ای ذخیره کند. وظیفه کامل read_line این است که هر چیزی را که کاربر در ورودی استاندارد تایپ می‌کند به رشته‌ای اضافه کند (بدون بازنویسی محتوای آن)، بنابراین این رشته را به‌عنوان آرگومان ارسال می‌کنیم. آرگومان رشته باید تغییرپذیر باشد تا متد بتواند محتوای رشته را تغییر دهد.

علامت & نشان می‌دهد که این آرگومان یک ارجاع است، که به شما راهی می‌دهد تا به چندین بخش از کد اجازه دهید به یک قطعه داده دسترسی داشته باشند بدون اینکه نیاز به کپی کردن آن داده در حافظه چندین بار داشته باشید. ارجاعات یک ویژگی پیچیده هستند و یکی از مزایای اصلی Rust این است که استفاده از ارجاعات ایمن و آسان است. نیازی نیست جزئیات زیادی درباره آن بدانید تا این برنامه را کامل کنید. فعلاً، تنها چیزی که باید بدانید این است که، مانند متغیرها، ارجاعات به‌طور پیش‌فرض غیرقابل تغییر هستند. بنابراین، باید &mut guess بنویسید به‌جای &guess تا آن را تغییرپذیر کنید. (فصل 4 ارجاعات را به‌طور کامل توضیح خواهد داد.)

مدیریت خطای احتمالی با Result

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

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

ما می‌توانستیم این کد را به این صورت بنویسیم:

io::stdin().read_line(&mut guess).expect("Failed to read line");

با این حال، یک خط طولانی خواندن آن را دشوار می‌کند، بنابراین بهتر است آن را تقسیم کنیم. اغلب توصیه می‌شود یک خط جدید و فضای سفید معرفی کنید تا خطوط طولانی را هنگام فراخوانی متدی با نحو .method_name() تقسیم کنید. حالا بیایید ببینیم این خط چه می‌کند.

همان‌طور که قبلاً ذکر شد، read_line هر چیزی که کاربر وارد می‌کند را در رشته‌ای که به آن ارسال می‌کنیم قرار می‌دهد، اما همچنین یک مقدار Result بازمی‌گرداند. Result یک enumeration است که اغلب به عنوان enum نامیده می‌شود و نوعی است که می‌تواند در یکی از چندین حالت ممکن باشد. ما هر حالت ممکن را یک متغیر (variant) می‌نامیم.

فصل 6 به جزئیات بیشتری در مورد enumها خواهد پرداخت. هدف از انواع Result رمزگذاری اطلاعات مدیریت خطا است.

متغیرهای Result شامل Ok و Err هستند. متغیر Ok نشان می‌دهد که عملیات موفقیت‌آمیز بوده و مقداری که با موفقیت تولید شده است را در خود دارد. متغیر Err به معنای این است که عملیات شکست خورده و اطلاعاتی درباره چگونگی یا دلیل شکست عملیات در خود دارد.

مقادیر نوع Result، مانند مقادیر هر نوع دیگری، متدهایی تعریف‌شده بر روی خود دارند. یک نمونه از Result یک متد expect دارد که می‌توانید آن را فراخوانی کنید. اگر این نمونه از Result یک مقدار Err باشد، expect باعث می‌شود برنامه متوقف شده و پیغام خطایی که به‌عنوان آرگومان به expect پاس داده‌اید را نمایش دهد. اگر متد read_line یک Err بازگرداند، احتمالاً به دلیل خطایی از سیستم‌عامل زیربنایی است. اگر این نمونه از Result یک مقدار Ok باشد، expect مقدار بازگشتی که Ok در خود دارد را می‌گیرد و فقط آن مقدار را بازمی‌گرداند تا بتوانید از آن استفاده کنید. در این مورد، آن مقدار تعداد بایت‌های ورودی کاربر است.

اگر expect را فراخوانی نکنید، برنامه کامپایل می‌شود، اما هشداری دریافت خواهید کرد:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
10 |     let _ = io::stdin().read_line(&mut guess);
   |     +++++++

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s

Rust هشدار می‌دهد که از مقدار Result بازگشتی از read_line استفاده نکرده‌اید، که نشان می‌دهد برنامه یک خطای ممکن را مدیریت نکرده است.

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

چاپ مقادیر با جای‌نگهدارهای println!

علاوه بر کروشه بسته، فقط یک خط دیگر برای بحث در کدی که تاکنون نوشته‌ایم باقی مانده است:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

این خط رشته‌ای را که اکنون ورودی کاربر را در خود دارد چاپ می‌کند. مجموعه {} از کروشه‌های باز و بسته یک جای‌نگهدار است: به {} به‌عنوان پنجه‌های کوچک خرچنگی فکر کنید که یک مقدار را در جای خود نگه می‌دارند. هنگام چاپ مقدار یک متغیر، نام متغیر می‌تواند داخل کروشه‌ها قرار گیرد. هنگام چاپ نتیجه ارزیابی یک عبارت، کروشه‌های باز و بسته خالی را در رشته فرمت قرار دهید، سپس رشته فرمت را با لیستی از عبارات جداشده با کاما دنبال کنید تا در هر جای‌نگهدار خالی به همان ترتیب چاپ شوند. چاپ یک متغیر و نتیجه یک عبارت در یک فراخوانی println! به این صورت خواهد بود:

#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {x} and y + 2 = {}", y + 2);
}

این کد x = 5 and y + 2 = 12 را چاپ می‌کند.

آزمایش بخش اول

بیایید بخش اول بازی حدس زدن را آزمایش کنیم. با استفاده از دستور cargo run آن را اجرا کنید:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

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

تولید یک عدد مخفی

در مرحله بعد، باید یک عدد مخفی تولید کنیم که کاربر سعی خواهد کرد آن را حدس بزند. عدد مخفی باید هر بار متفاوت باشد تا بازی بارها قابل بازی و لذت‌بخش باشد. از یک عدد تصادفی بین 1 تا 100 استفاده می‌کنیم تا بازی خیلی سخت نباشد. Rust هنوز قابلیت تولید اعداد تصادفی را در کتابخانه استاندارد خود ندارد. با این حال، تیم Rust یک crate rand با این قابلیت ارائه می‌دهد.

استفاده از یک crate برای دسترسی به قابلیت‌های بیشتر

به یاد داشته باشید که یک crate مجموعه‌ای از فایل‌های کد منبع Rust است. پروژه‌ای که ما در حال ساخت آن هستیم یک crate دودویی است که یک فایل اجرایی است. crate rand یک crate کتابخانه‌ای است که حاوی کدی است که قرار است در برنامه‌های دیگر استفاده شود و به تنهایی قابل اجرا نیست.

هماهنگی Cargo با جعبه‌ها (crates)ی خارجی یکی از نقاط قوت آن است. قبل از اینکه بتوانیم کدی بنویسیم که از rand استفاده کند، باید فایل Cargo.toml را تغییر دهیم تا crate rand را به عنوان وابستگی اضافه کنیم. اکنون آن فایل را باز کنید و خط زیر را به انتهای آن، زیر بخش [dependencies] که Cargo برای شما ایجاد کرده است، اضافه کنید. مطمئن شوید که rand را دقیقاً همان‌طور که در اینجا آمده است با این شماره نسخه مشخص کنید، وگرنه مثال‌های کد در این آموزش ممکن است کار نکنند:

Filename: Cargo.toml

[dependencies]
rand = "0.8.5"

در فایل Cargo.toml، هر چیزی که بعد از یک سرآیند بیاید بخشی از آن بخش است و تا زمانی که بخش دیگری شروع نشود ادامه می‌یابد. در [dependencies] به Cargo می‌گویید پروژه شما به کدام جعبه‌ها (crates)ی خارجی وابسته است و کدام نسخه از آن جعبه‌ها (crates) را نیاز دارید. در این مورد، ما crate rand را با مشخص‌کننده نسخه 0.8.5 مشخص می‌کنیم. Cargo نسخه‌بندی معنایی (گاهی اوقات SemVer نامیده می‌شود) را درک می‌کند، که یک استاندارد برای نوشتن شماره نسخه‌ها است. مشخص‌کننده 0.8.5 در واقع مخفف ^0.8.5 است که به این معناست که هر نسخه‌ای که حداقل 0.8.5 باشد ولی کمتر از 0.9.0 باشد.

Cargo این نسخه‌ها را دارای API عمومی سازگار با نسخه 0.8.5 در نظر می‌گیرد و این مشخصه تضمین می‌کند که آخرین نسخه patch را دریافت خواهید کرد که همچنان با کد موجود در این فصل کامپایل می‌شود. هیچ تضمینی وجود ندارد که نسخه 0.9.0 یا بالاتر همان API را داشته باشد که مثال‌های زیر استفاده می‌کنند.

اکنون، بدون تغییر هیچ کدی، بیایید پروژه را بسازیم، همان‌طور که در لیستینگ 2-2 نشان داده شده است.

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s
Listing 2-2: خروجی اجرای cargo build پس از افزودن crate rand به عنوان وابستگی

ممکن است نسخه‌های متفاوتی را ببینید (اما همه آن‌ها با کد سازگار خواهند بود، به لطف SemVer!) و خطوط متفاوتی (بسته به سیستم‌عامل) داشته باشید، و این خطوط ممکن است به ترتیب متفاوتی ظاهر شوند.

وقتی یک وابستگی خارجی اضافه می‌کنیم، Cargo جدیدترین نسخه‌های هر چیزی که آن وابستگی نیاز دارد را از رجیستری دریافت می‌کند، که یک کپی از داده‌های Crates.io است. Crates.io جایی است که افراد در اکوسیستم Rust پروژه‌های منبع‌باز Rust خود را برای استفاده دیگران ارسال می‌کنند.

پس از به‌روزرسانی رجیستری، Cargo بخش [dependencies] را بررسی می‌کند و هر crateی را که در لیست نیست و هنوز دانلود نشده است دانلود می‌کند. در این مورد، اگرچه ما فقط rand را به‌عنوان یک وابستگی لیست کرده‌ایم، Cargo سایر جعبه‌ها (crates)یی را که rand برای کارکردن به آن‌ها وابسته است نیز دریافت کرده است. پس از دانلود جعبه‌ها (crates)، Rust آن‌ها را کامپایل می‌کند و سپس پروژه را با وابستگی‌های موجود کامپایل می‌کند.

اگر بلافاصله دوباره دستور cargo build را اجرا کنید بدون اینکه هیچ تغییری ایجاد کرده باشید، خروجی‌ای به‌جز خط Finished دریافت نخواهید کرد. Cargo می‌داند که قبلاً وابستگی‌ها را دانلود و کامپایل کرده است، و شما هیچ تغییری در فایل Cargo.toml خود نداده‌اید. Cargo همچنین می‌داند که شما هیچ تغییری در کد خود نداده‌اید، بنابراین آن را هم دوباره کامپایل نمی‌کند. وقتی کاری برای انجام دادن وجود ندارد، فقط خارج می‌شود.

اگر فایل src/main.rs را باز کنید، یک تغییر جزئی در آن ایجاد کنید، و سپس آن را ذخیره کرده و دوباره بسازید، فقط دو خط خروجی خواهید دید:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s

این خطوط نشان می‌دهند که Cargo فقط با تغییر کوچک شما در فایل src/main.rs بیلد را به‌روزرسانی کرده است. وابستگی‌های شما تغییری نکرده‌اند، بنابراین Cargo می‌داند که می‌تواند از آنچه قبلاً دانلود و کامپایل کرده است استفاده مجدد کند.

اطمینان از بیلدهای قابل بازتولید با فایل Cargo.lock

Cargo مکانیزمی دارد که اطمینان می‌دهد شما یا هر کس دیگری بتوانید هر بار که کد خود را بیلد می‌کنید، همان نتیجه را دریافت کنید: Cargo تنها از نسخه‌هایی از وابستگی‌ها که مشخص کرده‌اید استفاده می‌کند، مگر اینکه خلاف آن را اعلام کنید. برای مثال، فرض کنید هفته آینده نسخه 0.8.6 از crate rand منتشر می‌شود و آن نسخه شامل یک رفع باگ مهم است، اما همچنین شامل یک برگشت (regression) است که کد شما را خراب می‌کند. برای مدیریت این موضوع، Rust فایل Cargo.lock را در اولین باری که cargo build را اجرا می‌کنید ایجاد می‌کند، بنابراین اکنون این فایل در دایرکتوری guessing_game وجود دارد.

وقتی برای اولین بار پروژه‌ای را بیلد می‌کنید، Cargo همه نسخه‌های وابستگی‌هایی که با معیارها تطابق دارند را پیدا می‌کند و سپس آن‌ها را به فایل Cargo.lock می‌نویسد. وقتی در آینده پروژه خود را بیلد می‌کنید، Cargo می‌بیند که فایل Cargo.lock وجود دارد و از نسخه‌های مشخص‌شده در آن استفاده می‌کند، به جای اینکه تمام کار پیدا کردن نسخه‌ها را دوباره انجام دهد. این کار به شما اجازه می‌دهد که به‌طور خودکار یک بیلد قابل بازتولید داشته باشید. به عبارت دیگر، پروژه شما در نسخه 0.8.5 باقی خواهد ماند تا زمانی که به صورت صریح آن را به‌روزرسانی کنید، به لطف فایل Cargo.lock. چون فایل Cargo.lock برای بیلدهای قابل بازتولید مهم است، معمولاً همراه با بقیه کد پروژه در سیستم کنترل نسخه (source control) ذخیره می‌شود.

به‌روزرسانی یک crate برای دریافت نسخه جدید

وقتی می‌خواهید یک crate را به‌روزرسانی کنید، Cargo دستور update را فراهم می‌کند که فایل Cargo.lock را نادیده می‌گیرد و تمام نسخه‌های جدیدی که با مشخصات شما در فایل Cargo.toml سازگار هستند را پیدا می‌کند. سپس Cargo آن نسخه‌ها را به فایل Cargo.lock می‌نویسد. در این مورد، Cargo تنها به دنبال نسخه‌هایی می‌گردد که بالاتر از 0.8.5 و کمتر از 0.9.0 باشند. اگر crate rand دو نسخه جدید 0.8.6 و 0.9.0 را منتشر کرده باشد، با اجرای cargo update چنین چیزی را خواهید دید:

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)

Cargo نسخه 0.9.0 را نادیده می‌گیرد. در این مرحله، شما همچنین تغییری در فایل Cargo.lock مشاهده می‌کنید که نشان می‌دهد نسخه crate rand که اکنون استفاده می‌کنید 0.8.6 است. برای استفاده از نسخه 0.9.0 rand یا هر نسخه‌ای در سری 0.9.x، باید فایل Cargo.toml را به این شکل تغییر دهید:

[dependencies]
rand = "0.9.0"

دفعه بعد که cargo build را اجرا کنید، Cargo رجیستری جعبه‌ها (crates)ی موجود را به‌روزرسانی می‌کند و نیازمندی‌های شما برای rand را بر اساس نسخه جدیدی که مشخص کرده‌اید ارزیابی می‌کند.

چیزهای بیشتری درباره Cargo و اکوسیستم آن وجود دارد که در فصل 14 بحث خواهیم کرد، اما فعلاً این تمام چیزی است که باید بدانید. Cargo استفاده از کتابخانه‌ها را بسیار آسان می‌کند، بنابراین Rustaceans می‌توانند پروژه‌های کوچک‌تری بنویسند که از تعدادی بسته تشکیل شده‌اند.

تولید یک عدد تصادفی

بیایید استفاده از rand را برای تولید یک عدد برای حدس زدن شروع کنیم. مرحله بعد به‌روزرسانی فایل src/main.rs است، همان‌طور که در لیستینگ 2-3 نشان داده شده است.

Filename: src/main.rs
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}
Listing 2-3: اضافه کردن کدی برای تولید یک عدد تصادفی

ابتدا خط use rand::Rng; را اضافه می‌کنیم. صفت (trait) Rng متدهایی را تعریف می‌کند که تولیدکنندگان اعداد تصادفی پیاده‌سازی می‌کنند، و این صفت باید در دامنه باشد تا بتوانیم از آن متدها استفاده کنیم. فصل 10 به‌طور مفصل به بررسی صفت‌ها خواهد پرداخت.

سپس دو خط در وسط اضافه می‌کنیم. در خط اول، تابع rand::thread_rng را فراخوانی می‌کنیم که تولیدکننده اعداد تصادفی خاصی را که می‌خواهیم استفاده کنیم به ما می‌دهد: تولیدکننده‌ای که محلی برای نخ فعلی اجرا است و توسط سیستم‌عامل seed می‌شود. سپس متد gen_range را روی تولیدکننده اعداد تصادفی فراخوانی می‌کنیم. این متد توسط صفت Rng که با دستور use rand::Rng; وارد دامنه کردیم، تعریف شده است. متد gen_range یک عبارت بازه‌ای را به‌عنوان آرگومان می‌گیرد و یک عدد تصادفی در آن بازه تولید می‌کند. نوع عبارت بازه‌ای که در اینجا استفاده می‌کنیم به صورت start..=end است و شامل حد پایین و بالا می‌شود، بنابراین باید 1..=100 را مشخص کنیم تا عددی بین 1 تا 100 درخواست کنیم.

نکته: شما نمی‌توانید به‌طور پیش‌فرض بدانید که کدام صفت‌ها را باید استفاده کنید و کدام متدها و توابع را از یک crate فراخوانی کنید، بنابراین هر crate دارای مستنداتی با دستورالعمل‌هایی برای استفاده از آن است. ویژگی جالب دیگر Cargo این است که اجرای دستور cargo doc --open مستندات ارائه‌شده توسط تمام وابستگی‌های شما را به‌صورت محلی می‌سازد و در مرورگر شما باز می‌کند. اگر به دیگر قابلیت‌های crate rand علاقه‌مند هستید، برای مثال دستور cargo doc --open را اجرا کنید و روی rand در نوار کناری سمت چپ کلیک کنید.

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

برنامه را چند بار اجرا کنید:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

شما باید اعداد تصادفی متفاوتی دریافت کنید و تمام آن‌ها باید بین 1 تا 100 باشند. عالی!

مقایسه حدس با عدد مخفی

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

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    // --snip--
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}
Listing 2-4: مدیریت مقادیر بازگشتی ممکن از مقایسه دو عدد

ابتدا یک دستور use دیگر اضافه می‌کنیم تا نوعی به نام std::cmp::Ordering را از کتابخانه استاندارد وارد دامنه کنیم. نوع Ordering یک enum دیگر است و دارای متغیرهای Less، Greater و Equal است. این‌ها سه نتیجه ممکن هنگام مقایسه دو مقدار هستند.

سپس پنج خط جدید در انتهای کد اضافه می‌کنیم که از نوع Ordering استفاده می‌کنند. متد cmp دو مقدار را مقایسه می‌کند و می‌تواند روی هر چیزی که قابل مقایسه باشد فراخوانی شود. این متد یک ارجاع به مقداری که می‌خواهید مقایسه کنید می‌گیرد: در اینجا مقایسه بین guess و secret_number است. سپس یکی از متغیرهای enum Ordering که با دستور use به دامنه آوردیم را بازمی‌گرداند. از یک عبارت match برای تصمیم‌گیری در مورد اقدام بعدی بر اساس اینکه کدام متغیر Ordering از فراخوانی cmp با مقادیر guess و secret_number بازگشته است استفاده می‌کنیم.

یک عبارت match از شاخه‌ها (arms) تشکیل شده است. یک شاخه شامل یک الگو برای مطابقت است و کدی که باید اجرا شود اگر مقدار داده‌شده به match با الگوی آن شاخه تطابق داشته باشد. Rust مقدار داده‌شده به match را گرفته و به ترتیب هر الگوی شاخه را بررسی می‌کند. الگوها و سازه match از ویژگی‌های قدرتمند Rust هستند: آن‌ها به شما اجازه می‌دهند موقعیت‌های مختلفی که کد شما ممکن است با آن‌ها روبرو شود را بیان کنید و اطمینان حاصل کنید که همه آن‌ها را مدیریت می‌کنید. این ویژگی‌ها به‌طور مفصل در فصل 6 و فصل 19 پوشش داده خواهند شد.

بیایید با یک مثال از عبارت match که در اینجا استفاده کرده‌ایم، آن را بررسی کنیم. فرض کنید کاربر 50 را حدس زده و عدد مخفی که این بار به‌طور تصادفی تولید شده 38 است.

وقتی کد 50 را با 38 مقایسه می‌کند، متد cmp مقدار Ordering::Greater را بازمی‌گرداند زیرا 50 بزرگ‌تر از 38 است. عبارت match مقدار Ordering::Greater را گرفته و شروع به بررسی هر الگوی شاخه می‌کند. به الگوی شاخه اول، Ordering::Less نگاه می‌کند و می‌بیند که مقدار Ordering::Greater با Ordering::Less تطابق ندارد، بنابراین کد موجود در آن شاخه را نادیده می‌گیرد و به شاخه بعدی می‌رود. الگوی شاخه بعدی Ordering::Greater است که با Ordering::Greater تطابق دارد! کد مرتبط با آن شاخه اجرا شده و عبارت Too big! را روی صفحه چاپ می‌کند. عبارت match پس از اولین تطابق موفقیت‌آمیز پایان می‌یابد، بنابراین در این سناریو به شاخه آخر نگاه نمی‌کند.

با این حال، کد موجود در لیستینگ 2-4 هنوز کامپایل نخواهد شد. بیایید آن را امتحان کنیم:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/cmp.rs:964:8

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

هسته خطا بیان می‌کند که انواع ناسازگار وجود دارند. Rust دارای یک سیستم نوع قوی و ایستا است. با این حال، همچنین دارای استنباط نوع است. وقتی let mut guess = String::new() نوشتیم، Rust توانست استنباط کند که guess باید یک String باشد و نیازی نبود که نوع را به‌صورت صریح بنویسیم. از طرف دیگر، secret_number یک نوع عددی است. چند نوع عددی در Rust می‌توانند مقداری بین 1 و 100 داشته باشند: i32، یک عدد 32 بیتی؛ u32، یک عدد بدون علامت 32 بیتی؛ i64، یک عدد 64 بیتی؛ و دیگران. مگر اینکه خلاف آن مشخص شده باشد، Rust به‌طور پیش‌فرض از i32 استفاده می‌کند، که نوع secret_number است مگر اینکه اطلاعات نوع دیگری اضافه کنید که باعث شود Rust نوع عددی دیگری را استنباط کند. دلیل خطا این است که Rust نمی‌تواند یک رشته و یک نوع عددی را مقایسه کند.

در نهایت، می‌خواهیم String که برنامه به‌عنوان ورودی می‌خواند را به یک نوع عددی تبدیل کنیم تا بتوانیم آن را به‌صورت عددی با عدد مخفی مقایسه کنیم. این کار را با اضافه کردن این خط به بدنه تابع main انجام می‌دهیم:

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

خط موردنظر این است:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

ما یک متغیر به نام guess ایجاد می‌کنیم. اما صبر کنید، آیا برنامه قبلاً یک متغیر به نام guess ندارد؟ دارد، اما Rust به‌طور مفیدی به ما اجازه می‌دهد مقدار قبلی guess را با یک مقدار جدید پوشش دهیم. پوشش‌دهی به ما اجازه می‌دهد که از نام متغیر guess دوباره استفاده کنیم، به‌جای اینکه مجبور شویم دو متغیر منحصربه‌فرد مانند guess_str و guess ایجاد کنیم. این موضوع را در فصل 3 با جزئیات بیشتری بررسی خواهیم کرد، اما فعلاً بدانید که این ویژگی اغلب زمانی استفاده می‌شود که بخواهید مقدار را از یک نوع به نوع دیگری تبدیل کنید.

ما این متغیر جدید را به عبارت guess.trim().parse() متصل می‌کنیم. guess در این عبارت به متغیر اصلی guess که ورودی به‌صورت رشته‌ای بود اشاره دارد. متد trim روی یک نمونه String تمام فضای سفید در ابتدا و انتهای رشته را حذف می‌کند، که قبل از تبدیل رشته به u32 که فقط می‌تواند داده‌های عددی داشته باشد، باید این کار را انجام دهیم. کاربر باید کلید enter را فشار دهد تا read_line مقدار ورودی را دریافت کند، که یک کاراکتر newline به رشته اضافه می‌کند. برای مثال، اگر کاربر کلید 5 را تایپ کند و enter را فشار دهد، guess به این شکل خواهد بود: 5\n. \n نشان‌دهنده “خط جدید” است. (در ویندوز، فشار دادن enter منجر به carriage return و newline، یعنی \r\n می‌شود.) متد trim \n یا \r\n را حذف می‌کند و نتیجه فقط 5 است.

متد parse روی رشته‌ها یک رشته را به نوع دیگری تبدیل می‌کند. اینجا از آن برای تبدیل یک رشته به عدد استفاده می‌کنیم. باید به Rust نوع عدد دقیق موردنظرمان را با استفاده از let guess: u32 بگوییم. علامت : بعد از guess به Rust می‌گوید که نوع متغیر را مشخص خواهیم کرد. Rust چند نوع عدد داخلی دارد؛ u32 که اینجا دیده می‌شود، یک عدد صحیح 32 بیتی بدون علامت است. این یک انتخاب پیش‌فرض خوب برای یک عدد مثبت کوچک است. درباره دیگر انواع عددی در فصل 3 خواهید آموخت.

علاوه بر این، حاشیه‌نویسی u32 در این برنامه نمونه و مقایسه با secret_number به این معناست که Rust استنباط خواهد کرد که secret_number نیز باید یک u32 باشد. بنابراین اکنون مقایسه بین دو مقدار از یک نوع خواهد بود!

متد parse فقط روی کاراکترهایی کار می‌کند که منطقی بتوان آن‌ها را به اعداد تبدیل کرد و بنابراین به‌راحتی می‌تواند باعث خطا شود. برای مثال، اگر رشته‌ای شامل A👍% باشد، هیچ راهی برای تبدیل آن به عدد وجود ندارد. چون ممکن است این عملیات شکست بخورد، متد parse نوع Result را برمی‌گرداند، دقیقاً مانند متد read_line (که قبلاً در “مدیریت خطای احتمالی با Result بحث کردیم). ما این Result را همان‌طور که قبلاً انجام دادیم با استفاده مجدد از متد expect مدیریت خواهیم کرد. اگر parse متغیر Err از نوع Result را برگرداند زیرا نتوانست یک عدد از رشته ایجاد کند، فراخوانی expect بازی را متوقف کرده و پیام مشخص‌شده را چاپ می‌کند. اگر parse بتواند با موفقیت رشته را به عدد تبدیل کند، متغیر Ok از نوع Result را برمی‌گرداند و expect عدد مورد نظر را از مقدار Ok بازمی‌گرداند.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

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

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

اجازه دادن به چندین حدس با استفاده از حلقه

کلمه کلیدی loop یک حلقه بی‌نهایت ایجاد می‌کند. ما یک حلقه اضافه می‌کنیم تا به کاربران فرصت‌های بیشتری برای حدس زدن عدد بدهیم:

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    // --snip--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

همان‌طور که می‌بینید، ما همه چیز از درخواست ورودی حدس به بعد را داخل یک حلقه قرار داده‌ایم. مطمئن شوید که خطوط داخل حلقه را چهار فاصله دیگر تورفتگی (indentation) بدهید و برنامه را دوباره اجرا کنید. اکنون برنامه به‌طور بی‌پایان از شما حدس می‌خواهد، که در واقع یک مشکل جدید ایجاد می‌کند. به نظر می‌رسد که کاربر نمی‌تواند از برنامه خارج شود!

کاربر همیشه می‌تواند برنامه را با استفاده از میانبر صفحه‌کلید ctrl-c متوقف کند. اما راه دیگری برای فرار از این هیولای سیری‌ناپذیر وجود دارد، همان‌طور که در بحث parse در “مقایسه حدس با عدد مخفی” ذکر شد: اگر کاربر پاسخی غیرعددی وارد کند، برنامه متوقف می‌شود. می‌توانیم از این موضوع استفاده کنیم تا به کاربر اجازه دهیم خارج شود، همان‌طور که در اینجا نشان داده شده است:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit

thread 'main' panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

تایپ کردن quit باعث خروج از بازی می‌شود، اما همان‌طور که متوجه خواهید شد، وارد کردن هر ورودی غیرعددی دیگر نیز همین کار را انجام می‌دهد. این رفتار چندان بهینه نیست؛ ما می‌خواهیم بازی همچنین وقتی عدد درست حدس زده شد متوقف شود.

خروج پس از حدس درست

بیایید برنامه را طوری تنظیم کنیم که وقتی کاربر برنده می‌شود، با افزودن یک دستور break از بازی خارج شود:

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

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();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        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;
            }
        }
    }
}

اضافه کردن خط break بعد از You win! باعث می‌شود که برنامه وقتی کاربر عدد مخفی را به‌درستی حدس می‌زند، از حلقه خارج شود. خروج از حلقه همچنین به معنای خروج از برنامه است، زیرا حلقه آخرین بخش از main است.

مدیریت ورودی نامعتبر

برای بهبود بیشتر رفتار بازی، به جای اینکه برنامه هنگام ورود ورودی غیرعددی توسط کاربر متوقف شود، بیایید بازی را طوری تنظیم کنیم که ورودی غیرعددی را نادیده بگیرد تا کاربر بتواند به حدس زدن ادامه دهد. این کار را می‌توان با تغییر خطی که در آن guess از یک String به یک u32 تبدیل می‌شود انجام داد، همان‌طور که در لیستینگ 2-5 نشان داده شده است.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

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 2-5: نادیده گرفتن یک حدس غیرعددی و درخواست یک حدس دیگر به جای متوقف کردن برنامه

ما از یک فراخوانی expect به یک عبارت match تغییر می‌دهیم تا به جای متوقف کردن برنامه در صورت خطا، خطا را مدیریت کنیم. به یاد داشته باشید که parse یک نوع Result بازمی‌گرداند و Result یک enum است که دارای متغیرهای Ok و Err است. ما در اینجا از یک عبارت match استفاده می‌کنیم، همان‌طور که با نتیجه Ordering از متد cmp انجام دادیم.

اگر parse بتواند رشته را با موفقیت به یک عدد تبدیل کند، یک مقدار Ok بازمی‌گرداند که عدد تولیدشده را در خود دارد. مقدار Ok با الگوی شاخه اول مطابقت خواهد داشت و عبارت match فقط مقدار num که parse تولید کرده و در داخل مقدار Ok قرار داده است را بازمی‌گرداند. آن عدد در همان جایی که می‌خواهیم، در متغیر جدید guess که ایجاد می‌کنیم، قرار می‌گیرد.

اگر parse نتواند رشته را به عدد تبدیل کند، یک مقدار Err بازمی‌گرداند که اطلاعات بیشتری درباره‌ی خطا در خود دارد. مقدار Err با الگوی Ok(num) در شاخه‌ی اول match تطابق ندارد، اما با الگوی Err(_) در شاخه‌ی دوم مطابقت دارد. کاراکتر زیرخط، یعنی _، یک مقدار عمومی است که همه‌چیز را شامل می‌شود؛ در این مثال، ما می‌گوییم که می‌خواهیم تمام مقادیر Err را صرف‌نظر از محتوای داخلی آن‌ها، تطبیق دهیم. در نتیجه، برنامه کد شاخه‌ی دوم یعنی continue را اجرا می‌کند، که به برنامه می‌گوید به دور بعدی حلقه‌ی loop برود و یک حدس دیگر بگیرد. در عمل، برنامه تمام خطاهایی را که parse ممکن است با آن مواجه شود نادیده می‌گیرد!

حالا همه چیز در برنامه باید طبق انتظار کار کند. بیایید آن را امتحان کنیم:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

عالی! با یک تغییر کوچک نهایی، بازی حدس زدن را کامل خواهیم کرد. به یاد داشته باشید که برنامه همچنان عدد مخفی را چاپ می‌کند. این کار برای آزمایش خوب بود، اما بازی را خراب می‌کند. بیایید دستور println! که عدد مخفی را خروجی می‌دهد حذف کنیم. لیستینگ 2-6 کد نهایی را نشان می‌دهد.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        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}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 2-6: کد کامل بازی حدس زدن

در این مرحله، شما با موفقیت بازی حدس زدن را ساخته‌اید. تبریک می‌گویم!

خلاصه

این پروژه یک روش عملی برای معرفی بسیاری از مفاهیم جدید Rust به شما بود: let، match، توابع، استفاده از جعبه‌ها (crates)ی خارجی، و موارد دیگر. در چند فصل بعدی، این مفاهیم را با جزئیات بیشتری یاد خواهید گرفت. فصل 3 مفاهیمی را که بیشتر زبان‌های برنامه‌نویسی دارند، مانند متغیرها، انواع داده و توابع را پوشش می‌دهد و نشان می‌دهد چگونه از آن‌ها در Rust استفاده کنید. فصل 4 مالکیت را بررسی می‌کند، ویژگی‌ای که Rust را از زبان‌های دیگر متمایز می‌کند. فصل 5 ساختارها و نحو متدها را مورد بحث قرار می‌دهد و فصل 6 توضیح می‌دهد که enumها چگونه کار می‌کنند.

مفاهیم رایج برنامه‌نویسی

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

به طور خاص، شما با متغیرها، انواع پایه، توابع، نظرات و جریان کنترل آشنا خواهید شد. این مبانی در هر برنامه Rust وجود خواهند داشت و یادگیری آن‌ها در اوایل کار، پایه قوی‌ای برای شروع به شما می‌دهد.

کلمات کلیدی

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

متغیرها و تغییرپذیری

همان‌طور که در بخش [“ذخیره مقادیر با استفاده از متغیرها”][storing-values-with-variables] ذکر شد، به طور پیش‌فرض متغیرها در Rust غیرقابل‌تغییر هستند. این یکی از راه‌هایی است که Rust شما را به نوشتن کدی که از ایمنی و همزمانی آسان ارائه‌شده توسط این زبان بهره می‌برد، تشویق می‌کند. با این حال، شما همچنان گزینه‌ای دارید تا متغیرهای خود را قابل‌تغییر کنید. بیایید بررسی کنیم که چگونه و چرا Rust شما را تشویق به استفاده از غیرقابل‌تغییر بودن می‌کند و چرا ممکن است گاهی بخواهید این حالت را تغییر دهید.

وقتی یک متغیر غیرقابل‌تغییر است، وقتی مقداری به یک نام متصل شد، نمی‌توانید آن مقدار را تغییر دهید. برای نشان دادن این موضوع، یک پروژه جدید به نام variables در دایرکتوری projects خود ایجاد کنید با استفاده از دستور cargo new variables.

سپس، در دایرکتوری جدید variables خود، فایل src/main.rs را باز کنید و کد آن را با کد زیر جایگزین کنید، که هنوز کامپایل نخواهد شد:

تام فایل: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

فایل را ذخیره کنید و برنامه را با استفاده از cargo run اجرا کنید. باید یک پیام خطا در مورد غیرقابل‌تغییر بودن دریافت کنید، همان‌طور که در این خروجی نشان داده شده است:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

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

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

شما پیام خطای cannot assign twice to immutable variable `x` را دریافت کردید زیرا سعی کردید مقدار دوم را به متغیر غیرقابل‌تغییر x تخصیص دهید.

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

اما قابلیت تغییر می‌تواند بسیار مفید باشد و نوشتن کد را راحت‌تر کند. اگرچه متغیرها به طور پیش‌فرض غیرقابل‌تغییر هستند، می‌توانید با اضافه کردن mut قبل از نام متغیر آنها را قابل‌تغییر کنید، همان‌طور که در [فصل ۲][storing-values-with-variables] انجام دادید. اضافه کردن mut همچنین به خوانندگان آینده کد نیت شما را نشان می‌دهد که قسمت‌های دیگر کد مقدار این متغیر را تغییر خواهند داد.

برای مثال، بیایید فایل src/main.rs را به کد زیر تغییر دهیم:

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

وقتی اکنون برنامه را اجرا می‌کنیم، این خروجی را دریافت می‌کنیم:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

ما اجازه داریم مقدار مرتبط با x را از 5 به 6 تغییر دهیم وقتی که از mut استفاده شود. در نهایت، تصمیم‌گیری در مورد استفاده یا عدم استفاده از قابلیت تغییر به عهده شما است و به این بستگی دارد که در آن موقعیت خاص چه چیزی واضح‌تر به نظر می‌رسد.

ثابت ها

مانند متغیرهای غیرقابل‌تغییر، ثابت‌ها مقادیری هستند که به یک نام متصل می‌شوند و اجازه تغییر ندارند، اما چند تفاوت بین ثابت‌ها و متغیرها وجود دارد.

اول، شما نمی‌توانید از mut با ثابت‌ها استفاده کنید. ثابت‌ها نه تنها به طور پیش‌فرض غیرقابل‌تغییر هستند، بلکه همیشه غیرقابل‌تغییر هستند. شما ثابت‌ها را با استفاده از کلیدواژه const به جای کلیدواژه let تعریف می‌کنید و نوع مقدار باید مشخص شود. ما در بخش بعدی [“انواع داده”][data-types] درباره انواع و حاشیه‌نویسی نوع صحبت خواهیم کرد، بنابراین نگران جزئیات آن در حال حاضر نباشید. فقط بدانید که همیشه باید نوع را مشخص کنید.

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

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

در اینجا یک مثال از تعریف ثابت آورده شده است:

#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}

نام ثابت THREE_HOURS_IN_SECONDS است و مقدار آن برابر با نتیجه ضرب ۶۰ (تعداد ثانیه‌ها در یک دقیقه) در ۶۰ (تعداد دقیقه‌ها در یک ساعت) در ۳ (تعداد ساعت‌هایی که می‌خواهیم در این برنامه شمارش کنیم) تنظیم شده است. قانون نام‌گذاری ثابت‌ها در Rust استفاده از حروف بزرگ با خط زیر (_) بین کلمات است. کامپایلر قادر است مجموعه محدودی از عملیات را در زمان کامپایل ارزیابی کند، که به ما این امکان را می‌دهد تا این مقدار را به صورتی بنویسیم که آسان‌تر قابل‌درک و بررسی باشد، به جای تنظیم این ثابت به مقدار ۱۰،۸۰۰. برای اطلاعات بیشتر در مورد اینکه چه عملیات‌هایی می‌توانند در زمان تعریف ثابت‌ها استفاده شوند، به [بخش ارزیابی ثابت‌ها در مرجع Rust][const-eval] مراجعه کنید.

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

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

Shadowing

همان‌طور که در آموزش بازی حدس زدن در [فصل ۲][comparing-the-guess-to-the-secret-number] دیدید، شما می‌توانید یک متغیر جدید با همان نام متغیر قبلی تعریف کنید. Rustaceanها می‌گویند که متغیر اول توسط متغیر دوم سایه انداخته شده است، به این معنا که متغیر دوم چیزی است که کامپایلر وقتی از نام متغیر استفاده می‌کنید می‌بیند. در واقع، متغیر دوم متغیر اول را تحت‌الشعاع قرار می‌دهد، استفاده‌های مربوط به نام متغیر را به خود اختصاص می‌دهد تا زمانی که یا خودش تحت‌الشعاع قرار بگیرد یا دامنه تمام شود. ما می‌توانیم یک متغیر را با استفاده از همان نام متغیر و تکرار استفاده از کلیدواژه let به شرح زیر سایه‌اندازی کنیم:

Filename: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }

    println!("The value of x is: {x}");
}

این برنامه ابتدا x را به مقدار ۵ متصل می‌کند. سپس یک متغیر جدید x با تکرار let x = ایجاد می‌کند و مقدار اصلی را می‌گیرد و ۱ اضافه می‌کند، بنابراین مقدار x به ۶ تغییر می‌کند. سپس، در یک دامنه داخلی که با آکولادها ایجاد شده است، عبارت سوم let نیز x را سایه‌اندازی می‌کند و یک متغیر جدید ایجاد می‌کند که مقدار قبلی را در ۲ ضرب می‌کند و به x مقدار ۱۲ می‌دهد. وقتی آن دامنه تمام می‌شود، سایه‌اندازی داخلی پایان می‌یابد و x به مقدار ۶ بازمی‌گردد. وقتی این برنامه را اجرا می‌کنیم، خروجی زیر را دریافت می‌کنیم:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

سایه‌اندازی با علامت‌گذاری متغیر به‌عنوان mut متفاوت است، زیرا اگر به طور تصادفی سعی کنید به این متغیر بدون استفاده از کلیدواژه let مقدار جدیدی تخصیص دهید، یک خطای زمان کامپایل دریافت می‌کنید. با استفاده از let، ما می‌توانیم چند تبدیل روی یک مقدار انجام دهیم، اما متغیر بعد از اتمام این تبدیل‌ها غیرقابل تغییر باقی می‌ماند.

تفاوت دیگر بین mut و سایه‌اندازی این است که به دلیل اینکه ما عملاً یک متغیر جدید ایجاد می‌کنیم وقتی دوباره از کلیدواژه let استفاده می‌کنیم، می‌توانیم نوع مقدار را تغییر دهیم اما همان نام را دوباره استفاده کنیم. برای مثال، فرض کنید برنامه ما از یک کاربر می‌خواهد تا نشان دهد که چند فاصله می‌خواهد بین متن‌های خاص داشته باشد با وارد کردن کاراکترهای فاصله، و سپس می‌خواهیم آن ورودی را به‌عنوان یک عدد ذخیره کنیم:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

اولین متغیر spaces یک نوع رشته است و دومین متغیر spaces یک نوع عدد است. سایه‌اندازی در نتیجه ما را از نیاز به یافتن نام‌های مختلف، مانند spaces_str و spaces_num نجات می‌دهد. به جای آن، می‌توانیم از نام ساده‌تر spaces استفاده کنیم. با این حال، اگر سعی کنیم برای این کار از mut استفاده کنیم، همان‌طور که در اینجا نشان داده شده است، یک خطای زمان کامپایل دریافت می‌کنیم:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

خطا می‌گوید که مجاز نیستیم نوع متغیر را تغییر دهیم:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

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

حال که بررسی کردیم متغیرها چگونه کار می‌کنند، بیایید نگاهی به انواع داده‌های بیشتری بیندازیم که متغیرها می‌توانند داشته باشند.

انواع داده‌ها

هر مقدار در زبان راست نوع خاصی از داده را دارد که به راست می‌گوید چه نوع داده‌ای مشخص شده است تا بداند چگونه با آن داده کار کند. ما به دو زیرمجموعه از انواع داده نگاه خواهیم کرد: انواع ساده و ترکیبی.

به خاطر داشته باشید که راست یک زبان ایستا-تایپ است، به این معنا که باید نوع تمام متغیرها در زمان کامپایل مشخص باشد. کامپایلر معمولاً می‌تواند بر اساس مقدار و نحوه استفاده از آن، نوع مورد نظر ما را حدس بزند. در مواردی که انواع متعددی ممکن است، مانند زمانی که یک String را به نوع عددی تبدیل کردیم در بخش “مقایسه حدس با عدد مخفی” در فصل 2، باید یک تعریف نوع اضافه کنیم، مانند این:

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

اگر تعریف نوع : u32 را که در کد بالا آمده است اضافه نکنیم، راست خطای زیر را نمایش می‌دهد، که به معنای این است که کامپایلر به اطلاعات بیشتری از ما نیاز دارد تا بداند کدام نوع را می‌خواهیم استفاده کنیم:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

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

شما تعریف‌های نوع مختلفی برای انواع داده‌های دیگر خواهید دید.

انواع ساده

یک نوع ساده نمایانگر یک مقدار واحد است. راست چهار نوع ساده اصلی دارد: اعداد صحیح، اعداد اعشاری، بولین‌ها و کاراکترها. ممکن است این‌ها را از زبان‌های برنامه‌نویسی دیگر بشناسید. بیایید ببینیم چگونه در راست کار می‌کنند.

انواع اعداد صحیح

یک عدد صحیح عددی بدون جزء اعشاری است. ما در فصل 2 از یک نوع عدد صحیح به نام u32 استفاده کردیم. این تعریف نوع نشان می‌دهد که مقدار مرتبط باید یک عدد صحیح بدون علامت (انواع اعداد صحیح با علامت با i به جای u شروع می‌شوند) باشد که 32 بیت فضا اشغال می‌کند. جدول 3-1 انواع اعداد صحیح ساخته شده در راست را نشان می‌دهد. ما می‌توانیم از هر یک از این حالت‌ها برای تعریف نوع یک مقدار عدد صحیح استفاده کنیم.

جدول 3-1: انواع اعداد صحیح در راست

طولعلامت‌داربدون‌علامت
۸-بیتیi8u8
۱۶-بیتیi16u16
۳۲-بیتیi32u32
۶۴-بیتیi64u64
۱۲۸-بیتیi128u128
وابسته به معماریisizeusize

هر حالت می‌تواند یا با علامت یا بدون علامت باشد و اندازه صریحی دارد. با علامت و بدون علامت به این اشاره دارند که آیا ممکن است عدد منفی باشد یا خیر؛ به عبارت دیگر، آیا عدد نیاز به علامت دارد (با علامت) یا اینکه فقط مثبت خواهد بود و بنابراین می‌توان آن را بدون علامت نشان داد (بدون علامت). این شبیه به نوشتن اعداد روی کاغذ است: وقتی علامت مهم باشد، عدد با علامت مثبت یا منفی نشان داده می‌شود؛ اما وقتی فرض مثبت بودن عدد ایمن باشد، بدون علامت نشان داده می‌شود. اعداد با علامت با استفاده از نمایش دو مکمل ذخیره می‌شوند.

هر نوع عدد صحیح علامت‌دار می‌تواند مقادیری از −(2n − 1) تا 2n − 1 − 1 را در بر بگیرد، که در آن n تعداد بیت‌های استفاده‌شده توسط آن نوع است. بنابراین، یک i8 می‌تواند مقادیر بین −(27) تا 27 − 1 را نگه دارد، یعنی از −۱۲۸ تا ۱۲۷.

انواع بدون‌علامت (unsigned) می‌توانند مقادیر بین ۰ تا 2n − 1 را نگهداری کنند؛ مثلاً یک u8 می‌تواند مقادیری از ۰ تا 28 − 1، یعنی از ۰ تا ۲۵۵ را ذخیره کند.

علاوه بر این، نوع‌های isize و usize به معماری سیستمی بستگی دارند که برنامه روی آن اجرا می‌شود: اگر معماری ۶۴ بیتی باشد، این نوع‌ها ۶۴ بیتی هستند، و اگر ۳۲ بیتی باشد، ۳۲ بیتی خواهند بود.

شما می‌توانید اعداد صحیح را به هر یک از اشکال نشان داده شده در جدول 3-2 بنویسید. توجه داشته باشید که عددهایی که می‌توانند به چندین نوع عددی تبدیل شوند، یک پسوند نوع دارند، مانند 57u8، برای تعیین نوع. اعداد همچنین می‌توانند از _ به عنوان جداکننده بصری برای خواناتر کردن استفاده کنند، مانند 1_000، که همان مقدار 1000 را دارد.

جدول 3-2: نمایش اعداد صحیح در راست

نوع اعدادمثال
دهدهی98_222
هگزادسیمال0xff
اکتال0o77
باینری0b1111_0000
بایت (فقط u8)b'A'

حال چگونه می‌دانید که از کدام نوع عدد صحیح استفاده کنید؟ اگر مطمئن نیستید، مقادیر پیش‌فرض راست معمولاً مکان خوبی برای شروع هستند: نوع‌های عدد صحیح پیش‌فرض به i32 تبدیل می‌شوند. وضعیت اصلی که در آن ممکن است از isize یا usize استفاده کنید زمانی است که می‌خواهید به یک نوع مجموعه اشاره کنید.

سرریز عدد صحیح

فرض کنید یک متغیر از نوع u8 دارید که می‌تواند مقادیر بین 0 و 255 را نگه دارد. اگر تلاش کنید مقدار متغیر را به عددی خارج از این بازه، مانند 256، تغییر دهید، سرریز عدد صحیح رخ خواهد داد که می‌تواند منجر به یکی از دو رفتار شود. وقتی برنامه خود را در حالت دیباگ کامپایل می‌کنید، راست شامل بررسی‌هایی برای سرریز عدد صحیح است که باعث می‌شود برنامه شما در زمان اجرا پانیک کند اگر این رفتار رخ دهد. راست از اصطلاح پانیک کردن زمانی استفاده می‌کند که برنامه با یک خطا خارج شود؛ ما در بخش “خطاهای غیرقابل بازیابی با panic! در فصل 9 به طور عمیق‌تر درباره پانیک‌ها بحث خواهیم کرد.

وقتی برنامه خود را در حالت انتشار با پرچم --release کامپایل می‌کنید، راست این بررسی‌ها را برای سرریز عدد صحیح شامل نمی‌شود. در عوض، اگر سرریز رخ دهد، راست از دو مکمل بسته‌بندی استفاده می‌کند. به طور خلاصه، مقادیر بزرگتر از حداکثر مقداری که نوع می‌تواند نگه دارد به “حداقل مقادیر” بازه نوع بسته‌بندی می‌شوند. در مورد یک u8، مقدار 256 به 0 تبدیل می‌شود، مقدار 257 به 1 و غیره. برنامه پانیک نخواهد کرد، اما متغیر مقدار متفاوتی نسبت به آنچه انتظار می‌رفت خواهد داشت. اعتماد به رفتار بسته‌بندی سرریز عدد صحیح یک خطا محسوب می‌شود.

برای مدیریت صریح امکان سرریز، می‌توانید از این خانواده‌های روش‌ها استفاده کنید که توسط کتابخانه استاندارد برای نوع‌های عددی اولیه ارائه شده‌اند:

  • در همه حالت‌ها با استفاده از متدهای wrapping_* مانند wrapping_add، مقدار را wrap می‌کند.
  • در صورت بروز overflow، مقدار None را با متدهای checked_* بازمی‌گرداند.
  • مقدار و یک مقدار Boolean که نشان می‌دهد overflow رخ داده یا نه، با متدهای overflowing_* بازمی‌گردد.
  • در مقدار حداقل یا حداکثر نوع متوقف می‌شود (saturate) با استفاده از متدهای saturating_*.

انواع اعداد اعشاری

راست همچنین دو نوع اولیه برای اعداد اعشاری دارد، که اعدادی با نقطه اعشار هستند. نوع‌های اعشاری راست f32 و f64 هستند که به ترتیب 32 بیت و 64 بیت اندازه دارند. نوع پیش‌فرض f64 است زیرا روی CPUهای مدرن، سرعت آن تقریباً مشابه f32 است اما دقت بیشتری دارد. همه نوع‌های اعشاری علامت‌دار هستند.

در اینجا مثالی که اعداد اعشاری را در عمل نشان می‌دهد آورده شده است:

Filename: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

اعداد اعشاری طبق استاندارد IEEE-754 نمایش داده می‌شوند.

عملیات عددی

راست از عملیات ریاضی پایه‌ای که برای تمام انواع عددی انتظار دارید پشتیبانی می‌کند: جمع، تفریق، ضرب، تقسیم و باقی‌مانده. تقسیم اعداد صحیح به نزدیک‌ترین عدد صحیح به سمت صفر گرد می‌شود. کد زیر نشان می‌دهد چگونه می‌توانید از هر عملیات عددی در یک عبارت let استفاده کنید:

Filename: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Results in -1

    // remainder
    let remainder = 43 % 5;
}

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

نوع بولین

مانند اکثر زبان‌های برنامه‌نویسی دیگر، نوع بولین در راست دو مقدار ممکن دارد: true و false. نوع بولین در راست یک بایت اندازه دارد. نوع بولین در راست با استفاده از bool مشخص می‌شود. برای مثال:

Filename: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

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

نوع کاراکتر

نوع char در راست ابتدایی‌ترین نوع الفبایی زبان است. در اینجا برخی از مثال‌های اعلام مقادیر char آورده شده است:

Filename: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

توجه داشته باشید که literals نوع char با نقل‌قول‌های تکی مشخص می‌شوند، در حالی که literals رشته‌ای (string) از نقل‌قول‌های دوتایی استفاده می‌کنند. نوع char در Rust اندازه‌ای برابر با چهار بایت دارد و نمایانگر یک مقدار اسکالر یونیکد است، به این معنا که می‌تواند بسیار بیشتر از فقط کاراکترهای ASCII را نمایش دهد. حروف دارای اعراب، کاراکترهای چینی، ژاپنی و کره‌ای، ایموجی‌ها و فضاهای بدون عرض همگی مقادیر معتبر char در Rust هستند. مقدارهای اسکالر یونیکد در بازه‌ی U+0000 تا U+D7FF و U+E000 تا U+10FFFF شامل می‌شوند. با این حال، مفهوم “کاراکتر” در یونیکد واقعاً وجود ندارد، بنابراین تصور انسانی شما از “کاراکتر” ممکن است با آنچه char در Rust است تفاوت داشته باشد. این موضوع را در بخش «ذخیره متن کدگذاری‌شده UTF-8 با رشته‌ها» در فصل ۸ به‌طور مفصل بررسی خواهیم کرد.

انواع ترکیبی

انواع ترکیبی می‌توانند چندین مقدار را در یک نوع گروه‌بندی کنند. راست دو نوع ترکیبی اولیه دارد: تاپل‌ها و آرایه‌ها.

نوع تاپل

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

ما یک تاپل را با نوشتن یک لیست جدا شده با کاما از مقادیر در داخل پرانتز ایجاد می‌کنیم. هر موقعیت در تاپل یک نوع دارد، و انواع مقادیر مختلف در تاپل نیازی به یکسان بودن ندارند. ما در این مثال حاشیه‌نویسی نوع اختیاری اضافه کرده‌ایم:

Filename: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

متغیر tup به کل تاپل متصل می‌شود زیرا یک تاپل به عنوان یک عنصر ترکیبی واحد در نظر گرفته می‌شود. برای استخراج مقادیر جداگانه از یک تاپل، می‌توانیم از تطابق الگو برای تجزیه مقدار تاپل استفاده کنیم، مانند این:

Filename: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

این برنامه ابتدا یک تاپل ایجاد کرده و آن را به متغیر tup متصل می‌کند. سپس از یک الگو با let برای گرفتن tup و تبدیل آن به سه متغیر جداگانه، x، y، و z استفاده می‌کند. این فرآیند تجزیه نامیده می‌شود زیرا تاپل واحد را به سه قسمت تقسیم می‌کند. در نهایت، برنامه مقدار y را که 6.4 است، چاپ می‌کند.

ما همچنین می‌توانیم یک عنصر از تاپل را مستقیماً با استفاده از یک نقطه (.) به دنبال شماره شاخص مقدار مورد نظر دسترسی داشته باشیم. برای مثال:

Filename: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

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

تاپل بدون هیچ مقداری یک نام خاص دارد، واحد. این مقدار و نوع مربوط به آن هر دو با () نوشته می‌شوند و یک مقدار خالی یا یک نوع بازگشت خالی را نشان می‌دهند. عبارات به طور ضمنی مقدار واحد را بازمی‌گردانند اگر هیچ مقدار دیگری بازنگردانند.

نوع آرایه

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

ما مقادیر یک آرایه را به صورت یک لیست جدا شده با کاما در داخل کروشه می‌نویسیم:

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

آرایه‌ها زمانی کاربردی هستند که بخواهید داده‌هایتان روی stack تخصیص یابند، مشابه سایر نوع‌هایی که تاکنون دیدیم، نه روی heap (که در فصل ۴ بیشتر درباره‌ی stack و heap صحبت خواهیم کرد) یا زمانی که می‌خواهید همیشه تعداد ثابتی از عناصر داشته باشید. با این حال، آرایه به اندازه‌ی نوع vector انعطاف‌پذیر نیست. وکتور نوعی مجموعه مشابه است که توسط کتابخانه استاندارد ارائه شده و اجازه دارد اندازه‌اش تغییر کند، چون محتوای آن روی heap ذخیره می‌شود. اگر مطمئن نیستید که از آرایه استفاده کنید یا وکتور، احتمالاً بهتر است وکتور را انتخاب کنید. فصل ۸ به‌طور دقیق‌تر درباره‌ی وکتورها بحث می‌کند.

با این حال، آرایه‌ها زمانی مفیدتر هستند که بدانید تعداد عناصر نیاز به تغییر ندارد. برای مثال، اگر از نام‌های ماه در یک برنامه استفاده می‌کردید، احتمالاً از یک آرایه به جای یک وکتور استفاده می‌کردید زیرا می‌دانید همیشه ۱۲ عنصر خواهد داشت:

#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

شما نوع یک آرایه را با استفاده از کروشه‌ها به همراه نوع هر عنصر، یک نقطه ویرگول، و سپس تعداد عناصر در آرایه می‌نویسید، مانند این:

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

در اینجا، i32 نوع هر عنصر است. پس از نقطه ویرگول، عدد ۵ نشان می‌دهد که آرایه شامل پنج عنصر است.

شما همچنین می‌توانید یک آرایه را طوری مقداردهی اولیه کنید که هر عنصر مقدار یکسانی داشته باشد، با مشخص کردن مقدار اولیه، یک نقطه ویرگول، و سپس طول آرایه در کروشه‌ها، مانند این:

#![allow(unused)]
fn main() {
let a = [3; 5];
}

آرایه‌ای با نام a شامل ۵ عنصر خواهد بود که همه ابتدا مقدار ۳ دارند. این همان نوشتن let a = [3, 3, 3, 3, 3]; است، اما به شیوه‌ای مختصرتر.

دسترسی به عناصر آرایه

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

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

در این مثال، متغیری با نام first مقدار 1 را می‌گیرد زیرا این مقدار در ایندکس [0] در آرایه قرار دارد. متغیری با نام second مقدار 2 را از ایندکس [1] در آرایه می‌گیرد.

دسترسی نامعتبر به عنصر آرایه

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

Filename: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

این کد به درستی کامپایل می‌شود. اگر این کد را با استفاده از cargo run اجرا کنید و مقادیری مانند 0، 1، 2، 3 یا 4 را وارد کنید، برنامه مقدار متناظر در آن ایندکس از آرایه را چاپ می‌کند. اما اگر به جای آن عددی خارج از محدوده آرایه، مانند 10، وارد کنید، خروجی چیزی شبیه به این خواهد بود:

thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

این برنامه در نقطه استفاده از مقدار نامعتبر در عملیات ایندکس‌گذاری دچار خطای زمان اجرا شد. برنامه با یک پیام خطا خاتمه یافت و دستور نهایی println! را اجرا نکرد. زمانی که شما تلاش می‌کنید به یک عنصر با استفاده از ایندکس دسترسی پیدا کنید، راست بررسی می‌کند که ایندکسی که مشخص کرده‌اید کمتر از طول آرایه باشد. اگر ایندکس بزرگ‌تر یا برابر با طول باشد، راست متوقف می‌شود (پانیک می‌کند). این بررسی باید در زمان اجرا انجام شود، به‌ویژه در این مورد، زیرا کامپایلر نمی‌تواند بداند که کاربر چه مقداری را هنگام اجرای کد وارد خواهد کرد.

این یک مثال از اصول ایمنی حافظه راست در عمل است. در بسیاری از زبان‌های سطح پایین، این نوع بررسی انجام نمی‌شود و زمانی که شما یک ایندکس اشتباه ارائه می‌کنید، می‌توان به حافظه نامعتبر دسترسی پیدا کرد. راست شما را از این نوع خطا با متوقف کردن فوری برنامه به جای اجازه دسترسی به حافظه و ادامه برنامه محافظت می‌کند. فصل ۹ خطایابی در راست و نحوه نوشتن کد خوانا و ایمن که نه دچار پانیک شود و نه اجازه دسترسی نامعتبر به حافظه را بدهد، بیشتر بررسی می‌کند.

توابع

توابع در کدهای راست بسیار رایج هستند. شما تاکنون یکی از مهم‌ترین توابع در این زبان را دیده‌اید: تابع main، که نقطه ورود بسیاری از برنامه‌ها است. همچنین با کلمه کلیدی fn آشنا شدید که به شما امکان تعریف توابع جدید را می‌دهد.

کدهای راست از حالت snake case به عنوان سبک متعارف برای نام‌گذاری توابع و متغیرها استفاده می‌کنند، که در آن تمام حروف کوچک هستند و کلمات با زیرخط از یکدیگر جدا می‌شوند. این یک برنامه است که شامل یک مثال از تعریف تابع می‌باشد:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

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

ما می‌توانیم هر تابعی را که تعریف کرده‌ایم با وارد کردن نام آن به همراه یک مجموعه پرانتز فراخوانی کنیم. از آنجا که another_function در برنامه تعریف شده است، می‌توان آن را از داخل تابع main فراخوانی کرد. توجه داشته باشید که ما another_function را بعد از تابع main در کد منبع تعریف کردیم؛ همچنین می‌توانستیم آن را قبل از آن تعریف کنیم. راست اهمیتی نمی‌دهد که توابع شما کجا تعریف شده‌اند، فقط باید در محدوده‌ای باشند که توسط فراخوانی کننده قابل مشاهده باشد.

بیایید یک پروژه باینری جدید به نام functions ایجاد کنیم تا توابع را بیشتر بررسی کنیم. مثال another_function را در فایل src/main.rs قرار دهید و آن را اجرا کنید. باید خروجی زیر را مشاهده کنید:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

دستورات به ترتیبی که در تابع main ظاهر شده‌اند اجرا می‌شوند. ابتدا پیام “Hello, world!” چاپ می‌شود و سپس another_function فراخوانی شده و پیام آن چاپ می‌شود.

پارامترها

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

در این نسخه از another_function، ما یک پارامتر اضافه می‌کنیم:

Filename: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {x}");
}

این برنامه را اجرا کنید؛ باید خروجی زیر را ببینید:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

اعلان another_function دارای یک پارامتر به نام x است. نوع x به عنوان i32 مشخص شده است. وقتی ما مقدار 5 را به another_function می‌دهیم، ماکروی println! مقدار 5 را در جایی که جفت کروشه حاوی x در رشته فرمت بود، قرار می‌دهد.

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

هنگام تعریف چندین پارامتر، اعلام پارامترها را با کاما جدا کنید، مانند این:

Filename: src/main.rs

fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}

این مثال یک تابع به نام print_labeled_measurement با دو پارامتر ایجاد می‌کند. پارامتر اول به نام value و از نوع i32 است. پارامتر دوم به نام unit_label و از نوع char است. سپس تابع متنی حاوی هر دو value و unit_label را چاپ می‌کند.

بیایید این کد را اجرا کنیم. برنامه‌ای که در حال حاضر در فایل src/main.rs پروژه functions شما است را با مثال بالا جایگزین کنید و آن را با استفاده از cargo run اجرا کنید:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The measurement is: 5h

از آنجا که ما تابع را با 5 به عنوان مقدار برای value و 'h' به عنوان مقدار برای unit_label فراخوانی کردیم، خروجی برنامه شامل این مقادیر است.

اظهارات و عبارات

بدنه توابع از یک سری اظهارات تشکیل شده است که به طور اختیاری با یک عبارت پایان می‌یابند. تاکنون، توابعی که پوشش داده‌ایم شامل یک عبارت پایانی نبوده‌اند، اما شما یک عبارت را به عنوان بخشی از یک اظهار دیده‌اید. از آنجا که راست یک زبان مبتنی بر عبارات است، این تمایز بسیار مهم است که درک شود. زبان‌های دیگر این تمایز را ندارند، بنابراین بیایید نگاهی به اظهارات و عبارات بیندازیم و ببینیم چگونه تفاوت‌های آنها بر بدن توابع تأثیر می‌گذارد.

  • دستورات (Statements) دستورالعمل‌هایی هستند که عملی را انجام می‌دهند و مقداری بازنمی‌گردانند.
  • عبارت‌ها (Expressions) مقداری را ارزیابی و بازمی‌گردانند.

بیایید چند مثال بررسی کنیم.

ما در واقع قبلاً از اظهارات و عبارات استفاده کرده‌ایم. ایجاد یک متغیر و اختصاص یک مقدار به آن با کلمه کلیدی let یک اظهار است. در لیستینگ ۳-۱، let y = 6; یک اظهار است.

Filename: src/main.rs
fn main() {
    let y = 6;
}
Listing 3-1: تعریف تابع main که شامل یک اظهار است

تعریف توابع نیز از نوع دستورات (statements) است؛ کل مثال قبلی خود یک دستور محسوب می‌شود. (همان‌طور که در ادامه خواهیم دید، فراخوانی تابع اما یک دستور نیست.)

اظهارات هیچ مقداری باز نمی‌گردانند. بنابراین، نمی‌توانید یک اظهار let را به یک متغیر دیگر اختصاص دهید، همانطور که کد زیر سعی دارد انجام دهد؛ شما با یک خطا روبرو خواهید شد:

Filename: src/main.rs

fn main() {
    let x = (let y = 6);
}

وقتی این برنامه را اجرا کنید، خطایی که دریافت خواهید کرد به شکل زیر خواهد بود:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  |

warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 1 warning emitted

اظهار let y = 6 هیچ مقداری باز نمی‌گرداند، بنابراین چیزی برای اتصال به x وجود ندارد. این با آنچه در زبان‌های دیگر، مانند C و Ruby رخ می‌دهد، متفاوت است، جایی که تخصیص مقدار باز می‌گرداند. در آن زبان‌ها، می‌توانید x = y = 6 بنویسید و هر دو x و y مقدار 6 را داشته باشند؛ این حالت در راست وجود ندارد.

عبارات به یک مقدار ارزیابی می‌شوند و بیشتر بقیه کدی که در راست می‌نویسید را تشکیل می‌دهند. به عنوان مثال یک عملیات ریاضی، مانند 5 + 6، که یک عبارت است که به مقدار 11 ارزیابی می‌شود. عبارات می‌توانند بخشی از اظهارات باشند: در لیستینگ ۳-۱، مقدار 6 در اظهار let y = 6; یک عبارت است که به مقدار 6 ارزیابی می‌شود. فراخوانی یک تابع یک عبارت است. فراخوانی یک ماکرو یک عبارت است. یک بلوک جدید از دامنه که با کروشه‌های باز و بسته ایجاد شده است نیز یک عبارت است، برای مثال:

Filename: src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {y}");
}

این عبارت:

{
    let x = 3;
    x + 1
}

یک بلوک است که در این مورد به مقدار 4 ارزیابی می‌شود. آن مقدار به عنوان بخشی از اظهار let به y متصل می‌شود. توجه داشته باشید که خط x + 1 در انتها یک نقطه ویرگول ندارد، که برخلاف اکثر خطوطی است که تاکنون دیده‌اید. عبارات شامل نقطه ویرگول انتهایی نمی‌شوند. اگر به انتهای یک عبارت یک نقطه ویرگول اضافه کنید، آن را به یک اظهار تبدیل می‌کنید و دیگر مقداری باز نمی‌گرداند. این نکته را در ذهن داشته باشید زیرا در ادامه به بررسی مقادیر بازگشتی توابع و عبارات می‌پردازیم.

توابع با مقادیر بازگشتی

توابع می‌توانند مقادیری را به کدی که آنها را فراخوانی کرده است بازگردانند. ما به مقادیر بازگشتی نام نمی‌دهیم، اما باید نوع آنها را بعد از یک فلش (->) اعلام کنیم. در راست، مقدار بازگشتی تابع معادل مقدار عبارت نهایی در بلوک بدنه تابع است. شما می‌توانید با استفاده از کلمه کلیدی return و مشخص کردن یک مقدار، زودتر از یک تابع بازگردید، اما بیشتر توابع به طور ضمنی مقدار آخرین عبارت را بازمی‌گردانند. در اینجا یک مثال از یک تابع که مقدار بازمی‌گرداند آورده شده است:

Filename: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {x}");
}

هیچ فراخوانی تابع، ماکرو یا حتی اظهار let در تابع five وجود ندارد—فقط عدد 5 به تنهایی. این یک تابع کاملاً معتبر در راست است. توجه کنید که نوع مقدار بازگشتی تابع نیز به صورت -> i32 مشخص شده است. این کد را اجرا کنید؛ خروجی باید به صورت زیر باشد:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

عدد 5 در five مقدار بازگشتی تابع است، به همین دلیل نوع بازگشتی i32 است. بیایید این موضوع را با جزئیات بیشتری بررسی کنیم. دو نکته مهم وجود دارد: اول، خط let x = five(); نشان می‌دهد که ما از مقدار بازگشتی یک تابع برای مقداردهی یک متغیر استفاده می‌کنیم. چون تابع five مقدار 5 را بازمی‌گرداند، این خط مشابه خط زیر است:

#![allow(unused)]
fn main() {
let x = 5;
}

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

بیایید به مثال دیگری نگاه کنیم:

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

اجرای این کد مقدار The value of x is: 6 را چاپ خواهد کرد. اما اگر یک نقطه ویرگول به انتهای خط حاوی x + 1 اضافه کنیم و آن را از یک عبارت به یک اظهار تبدیل کنیم، با خطا مواجه خواهیم شد:

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

کامپایل این کد خطایی به شرح زیر تولید می‌کند:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

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

پیام خطای اصلی، mismatched types، مسئله اصلی این کد را آشکار می‌کند. تعریف تابع plus_one می‌گوید که این تابع یک i32 بازمی‌گرداند، اما اظهارات به یک مقدار ارزیابی نمی‌شوند، که با ()، نوع واحد، نشان داده می‌شود. بنابراین، چیزی بازگردانده نمی‌شود که با تعریف تابع تناقض دارد و باعث ایجاد خطا می‌شود. در این خروجی، راست پیامی ارائه می‌دهد تا شاید به حل این مشکل کمک کند: پیشنهاد حذف نقطه ویرگول، که این خطا را برطرف می‌کند.

کامنت‌ها

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

در اینجا یک کامنت ساده آورده شده است:

#![allow(unused)]
fn main() {
// hello, world
}

در راست، سبک متعارف کامنت‌گذاری با دو اسلش شروع می‌شود و کامنت تا پایان خط ادامه دارد. برای کامنت‌هایی که بیشتر از یک خط هستند، باید روی هر خط از // استفاده کنید، به این صورت:

#![allow(unused)]
fn main() {
// پس ما در اینجا کاری پیچیده انجام می‌دهیم، به‌قدری طولانی
// که نیاز به چندین خط توضیح داریم! هُف! امیدوارم این کامنت
// آنچه در جریان است را به خوبی توضیح دهد.
}

کامنت‌ها همچنین می‌توانند در انتهای خطوطی که حاوی کد هستند قرار گیرند:

Filename: src/main.rs

fn main() {
    let lucky_number = 7; // I'm feeling lucky today
}

اما بیشتر مواقع کامنت‌ها را در این قالب خواهید دید، با کامنتی که در خط جداگانه‌ای بالای کدی که توضیح می‌دهد قرار دارد:

Filename: src/main.rs

fn main() {
    // I'm feeling lucky today
    let lucky_number = 7;
}

راست همچنین نوع دیگری از کامنت‌ها، کامنت‌های مستندات (documentation comments) دارد که آنها را در بخش “انتشار یک کرات در Crates.io” از فصل 14 بررسی خواهیم کرد.

کنترل جریان

توانایی اجرای کدی که وابسته به درست بودن یا نبودن یک شرط است و اجرای مکرر کدی در حالی که یک شرط درست است، از ساختارهای اساسی در بیشتر زبان‌های برنامه‌نویسی محسوب می‌شود. رایج‌ترین ساختارهایی که به شما امکان کنترل جریان اجرای کد در راست را می‌دهند، عبارتند از عبارات if و حلقه‌ها.

عبارات if

یک عبارت if به شما امکان می‌دهد کد خود را بسته به شرایطی شاخه‌بندی کنید. شما یک شرط مشخص می‌کنید و سپس می‌گویید: «اگر این شرط برقرار بود، این بلوک کد اجرا شود. اگر شرط برقرار نبود، این بلوک کد اجرا نشود.»

یک پروژه جدید به نام branches در دایرکتوری projects خود ایجاد کنید تا عبارت if را بررسی کنید. در فایل src/main.rs کد زیر را وارد کنید:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

تمام عبارات if با کلمه کلیدی if شروع می‌شوند و سپس یک شرط دنبال می‌شود. در این مثال، شرط بررسی می‌کند که آیا مقدار متغیر number کمتر از 5 است یا خیر. بلوک کدی که در صورت درست بودن شرط باید اجرا شود، بلافاصله بعد از شرط و داخل کروشه‌ها قرار می‌گیرد. بلوک‌های کدی که با شرایط در عبارات if مرتبط هستند، گاهی بازو (arm) نامیده می‌شوند، همانند بازوهای موجود در عبارات match که در بخش “مقایسه حدس با عدد مخفی” از فصل 2 مورد بحث قرار گرفت.

به‌صورت اختیاری، می‌توانیم یک عبارت else نیز اضافه کنیم، همان‌طور که اینجا انتخاب کردیم، تا به برنامه یک بلوک کد جایگزین برای اجرا ارائه دهیم، در صورتی که شرط به false ارزیابی شود. اگر عبارت else ارائه ندهید و شرط false باشد، برنامه بلوک if را نادیده گرفته و به بخش بعدی کد می‌رود.

این کد را اجرا کنید؛ باید خروجی زیر را مشاهده کنید:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

بیایید مقدار number را به مقداری تغییر دهیم که شرط false شود تا ببینیم چه اتفاقی می‌افتد:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

برنامه را دوباره اجرا کنید و خروجی را مشاهده کنید:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

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

Filename: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

این بار شرط if به مقدار 3 ارزیابی می‌شود و راست خطا می‌دهد:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

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

خطا نشان می‌دهد که راست انتظار یک bool داشت اما یک عدد صحیح دریافت کرد. برخلاف زبان‌هایی مانند Ruby و JavaScript، راست به‌صورت خودکار تلاش نمی‌کند انواع غیر bool را به یک bool تبدیل کند. شما باید صریح باشید و همیشه یک bool را به‌عنوان شرط به if بدهید. اگر می‌خواهید بلوک کد if فقط زمانی اجرا شود که یک عدد برابر 0 نباشد، می‌توانید عبارت if را به این صورت تغییر دهید:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

اجرای این کد number was something other than zero را چاپ خواهد کرد.

مدیریت شرایط متعدد با else if

شما می‌توانید با ترکیب if و else در یک عبارت else if، شرایط متعددی را مدیریت کنید. به عنوان مثال:

Filename: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

این برنامه چهار مسیر ممکن برای اجرا دارد. پس از اجرای آن، باید خروجی زیر را مشاهده کنید:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

هنگامی که این برنامه اجرا می‌شود، هر عبارت if را به ترتیب بررسی کرده و اولین بلوکی که شرط آن به true ارزیابی شود، اجرا می‌کند. توجه داشته باشید که حتی با وجود اینکه 6 بر 2 بخش‌پذیر است، خروجی number is divisible by 2 را نمی‌بینیم و همچنین متن number is not divisible by 4, 3, or 2 از بلوک else را نیز نمی‌بینیم. این به این دلیل است که راست فقط بلوک مربوط به اولین شرط درست را اجرا می‌کند و پس از یافتن آن، بقیه را بررسی نمی‌کند.

استفاده از تعداد زیادی عبارت else if می‌تواند کد شما را شلوغ کند، بنابراین اگر بیش از یک مورد دارید، ممکن است بخواهید کد خود را بازنویسی کنید. فصل 6 یک ساختار شاخه‌بندی قدرتمند در راست به نام match را برای این موارد توضیح می‌دهد.

استفاده از if در یک عبارت let

از آنجایی که if یک عبارت است، می‌توانیم از آن در سمت راست یک عبارت let برای تخصیص نتیجه به یک متغیر استفاده کنیم، همان‌طور که در لیست 3-2 نشان داده شده است.

Filename: src/main.rs
fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}
Listing 3-2: تخصیص نتیجه یک عبارت if به یک متغیر

متغیر number به مقداری بر اساس نتیجه عبارت if متصل خواهد شد. این کد را اجرا کنید تا ببینید چه اتفاقی می‌افتد:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

به خاطر داشته باشید که بلوک‌های کد به آخرین عبارت در آن‌ها ارزیابی می‌شوند و اعداد به تنهایی نیز عبارات محسوب می‌شوند. در این حالت، مقدار کل عبارت if بستگی به این دارد که کدام بلوک کد اجرا شود. این بدان معناست که مقادیری که می‌توانند نتایج هر بازوی if باشند، باید از یک نوع باشند. در لیست 3-2، نتایج بازوی if و بازوی else هر دو اعداد صحیح i32 بودند. اگر انواع ناسازگار باشند، مانند مثال زیر، خطایی دریافت خواهیم کرد:

Filename: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("The value of number is: {number}");
}

هنگامی که تلاش می‌کنیم این کد را کامپایل کنیم، خطایی دریافت می‌کنیم. بازوهای if و else دارای انواع مقداری ناسازگار هستند و راست دقیقاً نشان می‌دهد که مشکل در برنامه کجاست:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

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

عبارت موجود در بلوک if به یک عدد صحیح ارزیابی می‌شود و عبارت موجود در بلوک else به یک رشته ارزیابی می‌شود. این کار نمی‌کند زیرا متغیرها باید یک نوع مشخص داشته باشند و راست باید در زمان کامپایل بداند که نوع متغیر number چیست. دانستن نوع number به کامپایلر این امکان را می‌دهد که بررسی کند نوع آن در هر جایی که از number استفاده می‌کنیم معتبر است. راست نمی‌توانست این کار را انجام دهد اگر نوع number تنها در زمان اجرا مشخص می‌شد. کامپایلر پیچیده‌تر می‌شد و تضمین‌های کمتری درباره کد ارائه می‌داد اگر مجبور بود انواع فرضی مختلفی را برای هر متغیر پیگیری کند.

تکرار با حلقه‌ها

اغلب مفید است که یک بلوک کد بیش از یک بار اجرا شود. برای این کار، Rust چندین حلقه ارائه می‌دهد که کد داخل بدنه حلقه را اجرا کرده و سپس بلافاصله به ابتدای حلقه بازمی‌گردند. برای آزمایش با حلقه‌ها، یک پروژه جدید به نام loops ایجاد کنید.

Rust سه نوع حلقه دارد: loop، while و for. بیایید هر کدام را امتحان کنیم.

تکرار کد با loop

کلمه کلیدی loop به Rust می‌گوید که یک بلوک کد را بارها و بارها اجرا کند، تا زمانی که شما به طور صریح به آن بگویید متوقف شود.

به عنوان مثال، فایل src/main.rs را در دایرکتوری loops خود به شکل زیر تغییر دهید:

Filename: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

وقتی این برنامه را اجرا کنیم، again! بارها و بارها به طور مداوم چاپ می‌شود تا زمانی که برنامه را به صورت دستی متوقف کنیم. اکثر ترمینال‌ها از میانبر صفحه کلید ctrl-c برای متوقف کردن برنامه‌ای که در یک حلقه بی‌پایان گیر کرده است، پشتیبانی می‌کنند. آن را امتحان کنید:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

نماد ^C نشان‌دهنده جایی است که کلیدهای ctrl-c را فشار داده‌اید.

ممکن است پس از ^C کلمه‌ی again! چاپ شود یا نشود، بسته به این که کد در کدام قسمت از حلقه هنگام دریافت سیگنال قطع اجرا قرار داشته باشد.

خوشبختانه، Rust همچنین روشی برای خروج از یک حلقه با استفاده از کد ارائه می‌دهد. شما می‌توانید کلمه کلیدی break را درون حلقه قرار دهید تا به برنامه بگویید که چه زمانی اجرای حلقه را متوقف کند. به یاد داشته باشید که این کار را در بازی حدس عدد در بخش “خروج پس از یک حدس درست” در فصل 2 انجام دادیم تا زمانی که کاربر با حدس درست بازی را برنده شد، برنامه خاتمه یابد.

ما همچنین از continue در بازی حدس عدد استفاده کردیم که در یک حلقه به برنامه می‌گوید هر کد باقی‌مانده در این تکرار حلقه را نادیده بگیرد و به تکرار بعدی برود.

بازگرداندن مقادیر از حلقه‌ها

یکی از کاربردهای loop این است که یک عملیات را که ممکن است شکست بخورد دوباره امتحان کنید، مثلاً بررسی کنید که آیا یک نخ (thread) کار خود را تمام کرده است یا نه. همچنین ممکن است نیاز داشته باشید نتیجه این عملیات را از حلقه به بقیه کد خود منتقل کنید. برای انجام این کار، می‌توانید مقداری که می‌خواهید برگردانده شود را پس از عبارت break اضافه کنید. این مقدار از حلقه بازگردانده می‌شود تا بتوانید از آن استفاده کنید، همان‌طور که در اینجا نشان داده شده است:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

قبل از حلقه، یک متغیر به نام counter اعلام می‌کنیم و مقدار آن را 0 مقداردهی اولیه می‌کنیم. سپس یک متغیر به نام result اعلام می‌کنیم تا مقدار بازگشتی از حلقه را نگه دارد. در هر تکرار حلقه، مقدار 1 را به متغیر counter اضافه می‌کنیم و سپس بررسی می‌کنیم که آیا مقدار counter برابر با 10 است یا نه. زمانی که این شرط برقرار باشد، از کلمه کلیدی break با مقدار counter * 2 استفاده می‌کنیم. پس از حلقه، با استفاده از یک سمی‌کالن، مقدار به result تخصیص داده می‌شود. در نهایت، مقدار result را چاپ می‌کنیم که در این مثال برابر با 20 است.

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

برچسب حلقه‌ها برای رفع ابهام بین چندین حلقه

اگر حلقه‌هایی تو در تو داشته باشید، break و continue به حلقه داخلی‌ترین حلقه در آن نقطه اعمال می‌شوند. به طور اختیاری می‌توانید یک برچسب حلقه روی یک حلقه مشخص کنید که سپس می‌توانید از آن برچسب با break یا continue استفاده کنید تا مشخص کنید که این کلمات کلیدی به حلقه برچسب‌دار اعمال می‌شوند نه حلقه داخلی‌ترین. برچسب‌های حلقه باید با یک آپاستروف شروع شوند. در اینجا یک مثال با دو حلقه تو در تو آمده است:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

حلقه بیرونی دارای برچسب 'counting_up است و از 0 تا 2 شمارش می‌کند. حلقه داخلی بدون برچسب از 10 تا 9 شمارش معکوس می‌کند. اولین break که برچسبی مشخص نمی‌کند فقط از حلقه داخلی خارج می‌شود. عبارت break 'counting_up; از حلقه بیرونی خارج می‌شود. این کد موارد زیر را چاپ می‌کند:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

حلقه‌های شرطی با while

یک برنامه اغلب نیاز دارد که یک شرط را درون یک حلقه ارزیابی کند. تا زمانی که شرط true باشد، حلقه اجرا می‌شود. زمانی که شرط دیگر true نباشد، برنامه با فراخوانی break، حلقه را متوقف می‌کند. امکان پیاده‌سازی چنین رفتاری با استفاده از ترکیب loop، if، else و break وجود دارد. می‌توانید این را اکنون در یک برنامه امتحان کنید، اگر مایل هستید. با این حال، این الگو آن‌قدر رایج است که Rust یک سازه زبان داخلی برای آن دارد که به آن حلقه while گفته می‌شود. در Listing 3-3، از while برای اجرای برنامه سه بار، شمارش معکوس در هر بار، و سپس چاپ یک پیام و خروج از حلقه استفاده می‌کنیم.

Filename: src/main.rs
fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}
Listing 3-3: استفاده از حلقه‌ی while برای اجرای کد تا زمانی که شرط مقدار true داشته باشد

این سازه مقدار زیادی از تو در تویی که در صورت استفاده از loop، if، else و break لازم بود را حذف می‌کند و واضح‌تر است. تا زمانی که یک شرط به مقدار true ارزیابی شود، کد اجرا می‌شود؛ در غیر این صورت، حلقه متوقف می‌شود.

تکرار از طریق یک مجموعه با for

می‌توانید از ساختار while برای تکرار روی عناصر یک مجموعه، مانند آرایه، استفاده کنید. به‌عنوان مثال، حلقه‌ی موجود در فهرست 3-4، هر عنصر موجود در آرایه a را چاپ می‌کند.

Filename: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}
Listing 3-4: تکرار در هر عنصر یک مجموعه با استفاده از حلقه while

در اینجا، کد از طریق عناصر آرایه شمارش می‌کند. از اندیس (index)0 شروع می‌کند و سپس تا زمانی که به آخرین اندیس (index)در آرایه برسد (یعنی وقتی که index < 5 دیگر true نباشد) حلقه می‌زند. اجرای این کد هر عنصر در آرایه را چاپ می‌کند:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

همه پنج مقدار آرایه همانطور که انتظار می‌رود در ترمینال ظاهر می‌شوند. حتی اگر index در نهایت به مقدار 5 برسد، حلقه قبل از تلاش برای گرفتن مقدار ششم از آرایه متوقف می‌شود.

با این حال، این روش مستعد خطاست؛ ما می‌توانیم باعث شویم برنامه در صورت اشتباه بودن مقدار اندیس (index)یا شرط آزمایشی متوقف شود. به عنوان مثال، اگر تعریف آرایه a را به چهار عنصر تغییر دهید اما فراموش کنید شرط را به while index < 4 به‌روزرسانی کنید، کد متوقف خواهد شد. همچنین این روش کند است، زیرا کامپایلر کد زمان اجرا را برای انجام بررسی شرطی در مورد اینکه آیا اندیس (index)در محدوده آرایه است یا نه در هر تکرار حلقه اضافه می‌کند.

به عنوان یک جایگزین مختصرتر، می‌توانید از حلقه for استفاده کنید و برای هر مورد در یک مجموعه، کدی اجرا کنید. یک حلقه for شبیه کدی در Listing 3-5 است.

Filename: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}
Listing 3-5: تکرار در هر عنصر یک مجموعه با استفاده از حلقه for

وقتی این کد را اجرا کنیم، خروجی مشابه فهرست 3-4 را خواهیم دید. مهم‌تر اینکه، اکنون ایمنی کد افزایش یافته و احتمال بروز خطاهایی که ممکن است از دسترسی فراتر از انتهای آرایه یا عدم پیمایش کامل عناصر نشأت بگیرند، حذف شده است. همچنین، کد ماشینی که از حلقه‌های for تولید می‌شود می‌تواند کارآمدتر باشد، زیرا در هر تکرار نیازی به مقایسه‌ی اندیس با طول آرایه نیست.

با استفاده از حلقه for، نیازی به به خاطر سپردن تغییر کد دیگری ندارید اگر تعداد مقادیر در آرایه را تغییر دهید، همانطور که با روش استفاده شده در Listing 3-4 باید انجام می‌دادید.

ایمنی و مختصر بودن حلقه‌های for آنها را به رایج‌ترین سازه حلقه‌ای در Rust تبدیل کرده است. حتی در موقعیت‌هایی که می‌خواهید کدی را تعداد مشخصی از دفعات اجرا کنید، مانند مثال شمارش معکوس که از حلقه while در Listing 3-3 استفاده می‌کرد، اکثر برنامه‌نویسان Rust از حلقه for استفاده می‌کنند. روش انجام این کار استفاده از Range، که توسط کتابخانه استاندارد ارائه می‌شود، است که تمام اعداد را به ترتیب از یک عدد شروع کرده و قبل از عدد دیگری به پایان می‌رساند.

این چیزی است که شمارش معکوس با استفاده از یک حلقه for و روش دیگری که هنوز در مورد آن صحبت نکرده‌ایم، یعنی rev برای معکوس کردن محدوده، به نظر می‌رسد:

Filename: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

این کد کمی بهتر نیست؟

خلاصه

شما موفق شدید! این یک فصل بزرگ بود: شما درباره متغیرها، انواع داده اسکالر و مرکب، توابع، نظرات، عبارات if و حلقه‌ها یاد گرفتید! برای تمرین با مفاهیم مطرح‌شده در این فصل، سعی کنید برنامه‌هایی برای انجام موارد زیر بسازید:

  • تبدیل دما بین فارنهایت و سلسیوس.
  • تولید عدد nام دنباله فیبوناچی.
  • چاپ متن سرود کریسمس “The Twelve Days of Christmas”، با استفاده از تکرار موجود در این آهنگ.

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

درک مالکیت

مالکیت یکی از ویژگی‌های منحصر به فرد Rust است و تأثیرات عمیقی بر سایر بخش‌های زبان دارد. این ویژگی به Rust اجازه می‌دهد تا بدون نیاز به یک جمع‌آوری زباله (garbage collector)، تضمین‌های ایمنی حافظه را فراهم کند، بنابراین درک چگونگی کارکرد مالکیت بسیار مهم است. در این فصل، ما درباره مالکیت و چند ویژگی مرتبط دیگر صحبت خواهیم کرد: قرض گرفتن (borrowing)، برش‌ها (slices) و نحوه چیدمان داده‌ها در حافظه توسط Rust.

مالکیت چیست؟

مالکیت مجموعه‌ای از قوانین است که نحوه مدیریت حافظه را در برنامه‌های Rust تعیین می‌کند. همه برنامه‌ها باید نحوه استفاده از حافظه کامپیوتر را در هنگام اجرا مدیریت کنند. برخی زبان‌ها از جمع‌آوری زباله استفاده می‌کنند که به طور منظم حافظه‌ای را که دیگر استفاده نمی‌شود بررسی می‌کند؛ در دیگر زبان‌ها، برنامه‌نویس باید حافظه را به صورت صریح تخصیص داده و آزاد کند. Rust از یک روش سوم استفاده می‌کند: حافظه از طریق سیستمی از مالکیت با مجموعه‌ای از قوانین مدیریت می‌شود که کامپایلر آن‌ها را بررسی می‌کند. اگر هر یک از این قوانین نقض شود، برنامه کامپایل نخواهد شد. هیچ‌یک از ویژگی‌های مالکیت برنامه شما را در هنگام اجرا کند نمی‌کند.

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

وقتی مالکیت را درک کنید، پایه‌ای محکم برای درک ویژگی‌هایی که Rust را منحصر به فرد می‌کنند خواهید داشت. در این فصل، مالکیت را با کار بر روی چند مثال که بر یک ساختار داده بسیار رایج تمرکز دارند یاد خواهید گرفت: رشته‌ها.

استک (Stack) و هیپ (Heap)

بسیاری از زبان‌های برنامه‌نویسی نیاز ندارند که شما زیاد درباره‌ی استک و هیپ فکر کنید. اما در زبان‌های برنامه‌نویسی سیستمی مانند Rust، محل قرارگیری مقدار روی استک یا هیپ روی رفتار زبان و تصمیماتی که باید بگیرید تأثیر می‌گذارد. بخش‌هایی از مالکیت (ownership) در ارتباط با استک و هیپ در ادامه‌ی این فصل شرح داده خواهند شد، بنابراین در اینجا توضیح کوتاهی در آماده‌سازی برای آن ارائه می‌دهیم.

استک و هیپ هر دو بخش‌هایی از حافظه هستند که در زمان اجرا برای کد شما در دسترس‌اند، اما ساختار متفاوتی دارند. استک مقادیر را به ترتیبی که دریافت می‌کند ذخیره کرده و آن‌ها را به ترتیب معکوس حذف می‌کند. به این مدل آخرین وارد شده، اولین خارج شده (last in, first out) گفته می‌شود. تصور کنید یک دسته بشقاب: زمانی که بشقاب جدیدی اضافه می‌کنید، آن را روی بالای دسته قرار می‌دهید و وقتی بخواهید بشقابی بردارید، از بالای دسته برمی‌دارید. اضافه یا حذف کردن بشقاب از وسط یا پایین دسته به خوبی کار نخواهد کرد! افزودن داده به استک را push کردن روی استک و حذف داده را pop کردن از استک می‌نامند. تمامی داده‌های ذخیره‌شده روی استک باید اندازه‌ای مشخص و ثابت داشته باشند. داده‌هایی که اندازه آن‌ها هنگام کامپایل مشخص نیست یا ممکن است تغییر کند، باید روی هیپ ذخیره شوند.

هیپ ساختار کمتری دارد: وقتی داده‌ای را روی هیپ می‌گذارید، فضایی مشخص درخواست می‌کنید. تخصیص‌دهنده‌ی حافظه (memory allocator) محلی خالی در هیپ پیدا می‌کند که به‌اندازه کافی بزرگ باشد، آن را به‌عنوان فضای استفاده‌شده علامت‌گذاری می‌کند و یک اشاره‌گر که آدرس آن مکان است بازمی‌گرداند. این فرایند را تخصیص در هیپ می‌نامند و گاهی به‌سادگی تخصیص خوانده می‌شود (push کردن روی استک به‌عنوان تخصیص محسوب نمی‌شود). چون اندازه اشاره‌گر روی هیپ ثابت و مشخص است، می‌توانید اشاره‌گر را روی استک ذخیره کنید، اما وقتی به داده‌ی واقعی نیاز دارید، باید از طریق آن اشاره‌گر مراجعه کنید. این موضوع را می‌توان به نشستن در رستوران تشبیه کرد: وقتی وارد می‌شوید، تعداد افراد گروه را می‌گویید، میزبان میز خالی‌ای پیدا می‌کند که همه را در خود جای دهد و شما را به آن‌جا هدایت می‌کند. اگر کسی دیر برسد، می‌تواند بپرسد شما کجا نشسته‌اید تا شما را پیدا کند.

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

دسترسی به داده‌های روی هیپ معمولاً کندتر از داده‌های روی استک است، چون باید اشاره‌گر را دنبال کنید. پردازنده‌های امروزی زمانی سریع‌تر کار می‌کنند که پرش کمتری در حافظه داشته باشند. با ادامه‌ی تشبیه، فرض کنید یک پیشخدمت در رستوران سفارش‌های میزهای مختلف را می‌گیرد. کارآمدترین روش این است که همه سفارش‌های یک میز را کامل دریافت کند و سپس به میز بعدی برود. گرفتن سفارش از میز A، سپس میز B، دوباره میز A و سپس میز B، روندی بسیار کندتر خواهد بود. به همین ترتیب، پردازنده معمولاً بهتر کار می‌کند اگر روی داده‌هایی کار کند که به داده‌های دیگر نزدیک باشند (مثل داده‌های روی استک) نه داده‌هایی که دورتر هستند (مثل داده‌های روی هیپ).

وقتی کد شما تابعی را فراخوانی می‌کند، مقادیری که به تابع داده می‌شوند (شامل اشاره‌گرهایی به داده‌های روی هیپ) و متغیرهای محلی تابع روی استک قرار می‌گیرند. وقتی تابع به پایان رسید، این داده‌ها از استک حذف می‌شوند.

پیگیری اینکه کدام بخش‌های کد از کدام داده‌ها روی هیپ استفاده می‌کنند، کمینه‌سازی داده‌های تکراری روی هیپ، و پاک‌سازی داده‌های استفاده‌نشده روی هیپ تا فضای کافی باقی بماند، همه مسائلی هستند که مالکیت (ownership) به آن‌ها می‌پردازد. وقتی مالکیت را درک کنید، نیاز نیست زیاد درباره‌ی استک و هیپ فکر کنید، اما دانستن اینکه هدف اصلی مالکیت مدیریت داده‌های روی هیپ است، می‌تواند توضیح دهد چرا مالکیت این‌گونه عمل می‌کند.

قوانین مالکیت

ابتدا، بیایید نگاهی به قوانین مالکیت بیندازیم. این قوانین را در ذهن داشته باشید زیرا با مثال‌هایی که آن‌ها را نشان می‌دهند کار می‌کنیم:

  • هر مقدار در Rust یک مالک دارد.
  • در یک زمان فقط می‌تواند یک مالک وجود داشته باشد.
  • زمانی که مالک از دامنه خارج شود، مقدار حذف خواهد شد.

دامنه متغیر

حال که از سینتکس پایه Rust گذشته‌ایم، در مثال‌ها کد کامل fn main() { را نخواهیم آورد. بنابراین، اگر دنبال می‌کنید، مطمئن شوید که مثال‌های زیر را به صورت دستی داخل یک تابع main قرار دهید. در نتیجه، مثال‌های ما کمی مختصرتر خواهند بود و می‌توانیم بر روی جزئیات واقعی به جای کد ابتدایی تمرکز کنیم.

به عنوان اولین مثال از مالکیت، به دامنه برخی متغیرها نگاه می‌کنیم. دامنه محدوده‌ای است که در آن یک آیتم در یک برنامه معتبر است. به متغیر زیر توجه کنید:

#![allow(unused)]
fn main() {
let s = "hello";
}

متغیر s به یک رشته‌ی ثابت اشاره دارد، جایی که مقدار رشته به صورت ثابت در متن برنامه ما کدنویسی شده است. این متغیر از نقطه‌ای که اعلام شده معتبر است تا انتهای دامنه جاری. لیست 4-1 برنامه‌ای را با توضیحاتی که نشان می‌دهند متغیر s در کجا معتبر است، نمایش می‌دهد.

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}
Listing 4-1: یک متغیر و دامنه‌ای که در آن معتبر است

به عبارت دیگر، در اینجا دو نقطه‌ی مهم زمانی وجود دارد:

  • وقتی s وارد دامنه می‌شود، معتبر است.
  • تا زمانی که از دامنه خارج شود معتبر باقی می‌ماند.

در این نقطه، رابطه بین دامنه‌ها و زمان‌هایی که متغیرها معتبر هستند مشابه با زبان‌های برنامه‌نویسی دیگر است. حالا بر اساس این درک، نوع String را معرفی می‌کنیم.

نوع String

برای نشان دادن قوانین مالکیت، به نوع داده‌ای نیاز داریم که پیچیده‌تر از آن‌هایی باشد که در بخش “انواع داده” فصل ۳ بررسی کردیم. انواعی که قبلاً پوشش داده شد، اندازه‌ی مشخصی دارند، می‌توانند در استک ذخیره شوند و وقتی دامنه‌شان تمام شد از استک برداشته شوند و می‌توانند به سرعت و به سادگی برای ساختن یک نمونه‌ی جدید و مستقل کپی شوند اگر قسمت دیگری از کد بخواهد همان مقدار را در دامنه‌ی دیگری استفاده کند. اما ما می‌خواهیم به داده‌هایی نگاه کنیم که در هیپ ذخیره شده‌اند و بررسی کنیم چگونه Rust می‌داند چه زمانی باید این داده‌ها را پاکسازی کند، و نوع String یک مثال عالی است.

ما روی بخش‌هایی از String تمرکز خواهیم کرد که به مالکیت مربوط می‌شوند. این جنبه‌ها همچنین به سایر انواع داده‌های پیچیده اعمال می‌شوند، چه آن‌هایی که توسط کتابخانه استاندارد ارائه شده‌اند و چه آن‌هایی که خودتان ایجاد کرده‌اید. ما String را در فصل ۸ با جزئیات بیشتری بررسی خواهیم کرد.

قبلاً رشته‌های ثابت را دیده‌ایم، جایی که مقدار رشته در کد ما به صورت ثابت قرار گرفته است. رشته‌های ثابت راحت هستند، اما برای هر موقعیتی که ممکن است بخواهیم از متن استفاده کنیم مناسب نیستند. یکی از دلایل این است که آن‌ها تغییرناپذیر هستند. دلیل دیگر این است که نمی‌توان هر مقدار رشته را هنگام نوشتن کد خود دانست: به عنوان مثال، اگر بخواهیم ورودی کاربر را بگیریم و ذخیره کنیم چه؟ برای این شرایط، Rust یک نوع رشته‌ی دیگر به نام String دارد. این نوع داده‌های تخصیص‌یافته در هیپ را مدیریت می‌کند و به همین دلیل می‌تواند مقدار متنی را ذخیره کند که اندازه‌ی آن در زمان کامپایل برای ما ناشناخته است. شما می‌توانید یک String را از یک رشته‌ی ثابت با استفاده از تابع from ایجاد کنید، به این صورت:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

عملگر :: به ما اجازه می‌دهد این تابع from خاص را تحت نوع String نام‌گذاری کنیم به جای استفاده از نوعی نام مانند string_from. این سینتکس را بیشتر در بخش “سینتکس متد” فصل ۵ و هنگامی که در مورد نام‌گذاری با ماژول‌ها صحبت می‌کنیم در بخش “مسیرها برای ارجاع به یک آیتم در درخت ماژول” فصل ۷ بررسی خواهیم کرد.

این نوع رشته می‌تواند تغییر کند:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

پس، تفاوت اینجا چیست؟ چرا String می‌تواند تغییر کند اما رشته‌های ثابت نمی‌توانند؟ تفاوت در نحوه‌ی مدیریت حافظه توسط این دو نوع است.

حافظه و تخصیص

در مورد یک رشته‌ی ثابت، ما محتوا را در زمان کامپایل می‌دانیم، بنابراین متن به طور مستقیم در فایل اجرایی نهایی کدنویسی شده است. به همین دلیل رشته‌های ثابت سریع و کارآمد هستند. اما این ویژگی‌ها فقط از تغییرناپذیری رشته‌ی ثابت ناشی می‌شوند. متأسفانه، نمی‌توانیم یک تکه حافظه را برای هر قطعه متنی که اندازه‌ی آن در زمان کامپایل ناشناخته است و ممکن است در حین اجرای برنامه تغییر کند، در فایل باینری قرار دهیم.

با نوع String، برای پشتیبانی از یک متن قابل تغییر و قابل رشد، ما نیاز داریم مقداری حافظه را در هیپ تخصیص دهیم که در زمان کامپایل ناشناخته است تا محتوا را نگه داریم. این به این معناست که:

  • حافظه باید در زمان اجرا از تخصیص‌دهنده حافظه درخواست شود.
  • ما نیاز داریم راهی برای بازگرداندن این حافظه به تخصیص‌دهنده زمانی که کارمان با String تمام شد، داشته باشیم.

قسمت اول توسط ما انجام می‌شود: وقتی که String::from را فراخوانی می‌کنیم، پیاده‌سازی آن حافظه‌ای را که نیاز دارد درخواست می‌کند. این تقریباً در تمام زبان‌های برنامه‌نویسی رایج است.

اما قسمت دوم متفاوت است. در زبان‌هایی که دارای جمع‌کننده زباله (GC) هستند، GC حافظه‌ای را که دیگر استفاده نمی‌شود پیگیری و پاک‌سازی می‌کند و ما نیازی به فکر کردن در مورد آن نداریم. در بیشتر زبان‌هایی که GC ندارند، این مسئولیت بر عهده ماست که مشخص کنیم چه زمانی حافظه دیگر استفاده نمی‌شود و کدی را برای آزادسازی صریح آن فراخوانی کنیم، دقیقاً همان‌طور که آن را درخواست کرده‌ایم. انجام درست این کار در تاریخ برنامه‌نویسی یک مشکل دشوار بوده است. اگر فراموش کنیم، حافظه هدر می‌رود. اگر خیلی زود این کار را انجام دهیم، یک متغیر نامعتبر خواهیم داشت. اگر دو بار این کار را انجام دهیم، این هم یک باگ است. ما نیاز داریم دقیقاً یک allocate را با دقیقاً یک free جفت کنیم.

Rust مسیر متفاوتی را طی می‌کند: حافظه به طور خودکار وقتی که متغیری که مالک آن است از دامنه خارج می‌شود بازگردانده می‌شود. در اینجا نسخه‌ای از مثال دامنه ما از فهرست ۴-۱ وجود دارد که از یک String به جای یک رشته‌ی ثابت استفاده می‌کند:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

یک نقطه طبیعی وجود دارد که می‌توانیم حافظه‌ای را که String ما نیاز دارد به تخصیص‌دهنده بازگردانیم: وقتی s از دامنه خارج می‌شود. وقتی یک متغیر از دامنه خارج می‌شود، Rust یک تابع خاص را برای ما فراخوانی می‌کند. این تابع drop نامیده می‌شود، و اینجا جایی است که نویسنده String می‌تواند کدی برای بازگرداندن حافظه قرار دهد. Rust به طور خودکار drop را در زمان بستن آکولاد فراخوانی می‌کند.

نکته: در C++، این الگو که منابع در انتهای دوره عمر یک آیتم آزاد می‌شوند گاهی اوقات Resource Acquisition Is Initialization (RAII) نامیده می‌شود. تابع drop در Rust برای کسانی که از الگوهای RAII استفاده کرده‌اند آشنا خواهد بود.

این الگو تأثیر عمیقی بر نحوه نوشتن کد در Rust دارد. ممکن است اکنون ساده به نظر برسد، اما رفتار کد می‌تواند در موقعیت‌های پیچیده‌تر که می‌خواهیم متغیرهای متعددی از داده‌هایی که در هیپ تخصیص داده‌ایم استفاده کنند، غیرمنتظره باشد. اکنون به بررسی برخی از این موقعیت‌ها می‌پردازیم.

تعامل متغیرها و داده‌ها با انتقال (Move)

متغیرهای مختلف می‌توانند در Rust به روش‌های مختلفی با داده‌ها تعامل داشته باشند. بیایید به مثالی با استفاده از یک عدد صحیح در فهرست ۴-۲ نگاه کنیم.

fn main() {
    let x = 5;
    let y = x;
}
Listing 4-2: اختصاص مقدار عدد صحیح متغیر x به y

ما احتمالاً می‌توانیم حدس بزنیم این کد چه می‌کند: “مقدار 5 را به x اختصاص بده؛ سپس یک کپی از مقدار x بگیر و آن را به y اختصاص بده.” اکنون دو متغیر داریم، x و y، و هر دو برابر 5 هستند. این دقیقاً همان چیزی است که اتفاق می‌افتد، زیرا اعداد صحیح مقادیر ساده‌ای با اندازه‌ی مشخص هستند، و این دو مقدار 5 به استک اضافه می‌شوند.

اکنون بیایید به نسخه String نگاه کنیم:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

این بسیار مشابه به نظر می‌رسد، بنابراین ممکن است فرض کنیم که نحوه عملکرد آن نیز مشابه است: یعنی، خط دوم یک کپی از مقدار موجود در s1 می‌گیرد و آن را به s2 اختصاص می‌دهد. اما این دقیقاً چیزی نیست که اتفاق می‌افتد.

به شکل ۴-۱ نگاه کنید تا ببینید که در پشت صحنه با String چه اتفاقی می‌افتد. یک String از سه بخش تشکیل شده است که در سمت چپ نشان داده شده‌اند: یک اشاره‌گر (Pointer) به حافظه‌ای که محتوای رشته را نگه می‌دارد، یک طول، و یک ظرفیت. این گروه داده‌ها روی استک ذخیره می‌شوند. در سمت راست، حافظه روی هیپ قرار دارد که محتوای رشته را نگه می‌دارد.

دو جدول: جدول اول نمایش s1 روی استک را نشان می‌دهد که شامل طول (۵)، ظرفیت (۵)، و اشاره‌گر (Pointer)ی به اولین مقدار در جدول دوم است. جدول دوم نمایش داده‌های رشته روی هیپ را بایت به بایت نشان می‌دهد.

شکل ۴-۱: نمایش در حافظه یک String که مقدار "hello" به s1 متصل است

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

وقتی s1 را به s2 اختصاص می‌دهیم، داده‌های String کپی می‌شوند، به این معنی که اشاره‌گر (Pointer)، طول، و ظرفیت موجود روی استک را کپی می‌کنیم. ما داده‌های روی هیپ را که اشاره‌گر (Pointer) به آن اشاره می‌کند، کپی نمی‌کنیم. به عبارت دیگر، نمایش داده‌ها در حافظه به شکل ۴-۲ به نظر می‌رسد.

سه جدول: جدول‌های s1 و s2 به ترتیب نمایش‌دهنده رشته‌ها روی استک هستند و هر دو به داده‌های رشته یکسان روی هیپ اشاره می‌کنند.

شکل ۴-۲: نمایش در حافظه متغیر s2 که یک کپی از اشاره‌گر (Pointer)، طول، و ظرفیت s1 دارد

نمایش داده‌ها به این شکل نیست که در شکل ۴-۳ آمده است، که نشان می‌دهد حافظه به گونه‌ای باشد که Rust همچنین داده‌های هیپ را کپی کند. اگر Rust این کار را انجام می‌داد، عملیات s2 = s1 می‌توانست از نظر عملکرد زمان اجرا بسیار گران باشد اگر داده‌های روی هیپ بزرگ بودند.

چهار جدول: دو جدول نمایانگر داده‌های استک برای s1 و s2 هستند و هر کدام به نسخه خود از داده‌های رشته روی هیپ اشاره می‌کنند.

شکل ۴-۳: یک امکان دیگر برای آنچه که s2 = s1 ممکن است انجام دهد اگر Rust داده‌های هیپ را نیز کپی کند

قبلاً گفتیم که وقتی یک متغیر از دامنه خارج می‌شود، Rust به طور خودکار تابع drop را فراخوانی می‌کند و حافظه هیپ را برای آن متغیر پاک‌سازی می‌کند. اما شکل ۴-۲ نشان می‌دهد که هر دو اشاره‌گر (Pointer) داده‌ها به یک مکان اشاره می‌کنند. این یک مشکل است: وقتی s2 و s1 از دامنه خارج می‌شوند، هر دو سعی می‌کنند همان حافظه را آزاد کنند. این به عنوان یک خطای آزادسازی دوباره شناخته می‌شود و یکی از مشکلات ایمنی حافظه است که قبلاً ذکر کردیم. آزادسازی حافظه دو بار می‌تواند منجر به خراب شدن حافظه شود، که به طور بالقوه می‌تواند منجر به آسیب‌پذیری‌های امنیتی شود.

برای اطمینان از ایمنی حافظه، پس از خط let s2 = s1;، Rust متغیر s1 را دیگر معتبر نمی‌داند. بنابراین، Rust نیازی به آزادسازی هیچ چیزی ندارد وقتی s1 از دامنه خارج می‌شود. بررسی کنید که وقتی سعی می‌کنید s1 را پس از ایجاد s2 استفاده کنید، چه اتفاقی می‌افتد؛ این کار جواب نمی‌دهد:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

شما خطایی مشابه این دریافت خواهید کرد زیرا Rust از استفاده از مرجع نامعتبر جلوگیری می‌کند:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:15
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |               ^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

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

اگر اصطلاحات کپی سطحی و کپی عمیق را هنگام کار با زبان‌های دیگر شنیده‌اید، مفهوم کپی کردن اشاره‌گر (Pointer)، طول، و ظرفیت بدون کپی کردن داده احتمالاً شبیه به انجام یک کپی سطحی است. اما به دلیل اینکه Rust همچنین متغیر اول را نامعتبر می‌کند، به جای اینکه آن را کپی سطحی بنامند، به عنوان یک انتقال شناخته می‌شود. در این مثال، می‌توانیم بگوییم که s1 به s2 منتقل شده است. بنابراین، آنچه در واقع اتفاق می‌افتد در شکل ۴-۴ نشان داده شده است.

سه جدول: جدول‌های s1 و s2 که به ترتیب نمایش‌دهنده رشته‌ها روی استک هستند و هر دو به داده‌های رشته یکسان روی هیپ اشاره می‌کنند. جدول s1 خاکستری شده زیرا s1 دیگر معتبر نیست؛ تنها s2 می‌تواند برای دسترسی به داده‌های هیپ استفاده شود.

شکل ۴-۴: نمایش در حافظه پس از اینکه s1 نامعتبر شده است

این مشکل ما را حل می‌کند! با تنها s2 که معتبر است، وقتی از دامنه خارج می‌شود، تنها آن حافظه را آزاد خواهد کرد و کار ما تمام است.

علاوه بر این، یک انتخاب طراحی وجود دارد که از این نتیجه‌گیری می‌شود: Rust هرگز به طور خودکار “کپی عمیق” داده‌های شما را ایجاد نمی‌کند. بنابراین، هر گونه کپی خودکار می‌تواند به‌عنوان عملی ارزان از نظر عملکرد زمان اجرا در نظر گرفته شود.

دامنه و انتساب

عکس این رابطه بین دامنه‌بندی، مالکیت، و آزادسازی حافظه از طریق تابع drop نیز صحیح است. وقتی یک مقدار کاملاً جدید به یک متغیر موجود اختصاص می‌دهید، Rust تابع drop را فراخوانی می‌کند و حافظه مقدار اصلی را بلافاصله آزاد می‌کند. به این کد توجه کنید:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

ابتدا یک متغیر s را اعلان می‌کنیم و آن را به یک String با مقدار "hello" اختصاص می‌دهیم. سپس بلافاصله یک String جدید با مقدار "ahoy" ایجاد می‌کنیم و آن را به s اختصاص می‌دهیم. در این نقطه، هیچ چیزی به مقدار اصلی روی هیپ اشاره نمی‌کند.

یک جدول s که نمایانگر مقدار رشته روی استک است و به بخش دوم داده‌های رشته (ahoy) روی هیپ اشاره می‌کند، با داده‌های رشته اصلی (hello) که خاکستری شده زیرا دیگر نمی‌توان به آن دسترسی داشت.

شکل ۴-۵: نمایش در حافظه پس از اینکه مقدار اولیه به طور کامل جایگزین شده است.

رشته اصلی بلافاصله از دامنه خارج می‌شود. Rust تابع drop را روی آن اجرا می‌کند و حافظه آن بلافاصله آزاد می‌شود. وقتی مقدار را در انتها چاپ می‌کنیم، مقدار "ahoy, world!" خواهد بود.

تعامل متغیرها و داده‌ها با Clone

اگر بخواهیم داده‌های هیپ String را عمیقاً کپی کنیم، نه فقط داده‌های استک، می‌توانیم از یک متد معمول به نام clone استفاده کنیم. ما نحو متدها را در فصل ۵ بررسی خواهیم کرد، اما از آنجا که متدها یک ویژگی رایج در بسیاری از زبان‌های برنامه‌نویسی هستند، احتمالاً قبلاً آنها را دیده‌اید.

در اینجا یک مثال از روش clone در عمل آورده شده است:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

این کد به خوبی کار می‌کند و به وضوح رفتار نشان داده شده در شکل ۴-۳ را تولید می‌کند، جایی که داده‌های هیپ کپی می‌شوند.

وقتی یک فراخوانی به clone می‌بینید، می‌دانید که کدی دلخواه اجرا می‌شود و ممکن است این کد هزینه‌بر باشد. این یک شاخص بصری است که نشان می‌دهد چیزی متفاوت در حال رخ دادن است.

داده‌های فقط استک: Copy

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

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

اما این کد به نظر می‌رسد با آنچه که به تازگی یاد گرفتیم تناقض دارد: ما یک فراخوانی به clone نداریم، اما x همچنان معتبر است و به y منتقل نشده است.

دلیل این است که انواعی مانند اعداد صحیح که اندازه مشخصی در زمان کامپایل دارند، به طور کامل روی استک ذخیره می‌شوند، بنابراین کپی کردن مقادیر واقعی سریع است. این به این معناست که هیچ دلیلی وجود ندارد که بخواهیم x پس از ایجاد متغیر y نامعتبر شود. به عبارت دیگر، در اینجا تفاوتی بین کپی عمیق و کپی سطحی وجود ندارد، بنابراین فراخوانی clone کاری متفاوت از کپی سطحی معمول انجام نمی‌دهد و می‌توانیم آن را حذف کنیم.

Rust دارای یک نشانه‌گذاری ویژه به نام ویژگی Copy است که می‌توانیم روی انواعی که روی استک ذخیره می‌شوند (مانند اعداد صحیح) اعمال کنیم (ما در فصل ۱۰ بیشتر درباره ویژگی‌ها صحبت خواهیم کرد). اگر یک نوع ویژگی Copy را پیاده‌سازی کند، متغیرهایی که از آن استفاده می‌کنند جابه‌جا نمی‌شوند، بلکه به سادگی کپی می‌شوند و پس از اختصاص به متغیر دیگری همچنان معتبر باقی می‌مانند.

Rust به ما اجازه نمی‌دهد یک نوع را با Copy نشانه‌گذاری کنیم اگر نوع یا هر یک از اجزای آن ویژگی Drop را پیاده‌سازی کرده باشند. اگر نوع به چیزی خاص نیاز داشته باشد تا زمانی که مقدار از دامنه خارج شود و ما ویژگی Copy را به آن نوع اضافه کنیم، یک خطای زمان کامپایل دریافت خواهیم کرد. برای یادگیری نحوه افزودن ویژگی Copy به نوع خود برای پیاده‌سازی این ویژگی، به “ویژگی‌های قابل اشتقاق” در ضمیمه ج مراجعه کنید.

پس، چه نوع‌هایی ویژگی Copy را پیاده‌سازی می‌کنند؟ می‌توانید برای اطمینان، مستندات نوع داده شده را بررسی کنید، اما به عنوان یک قانون کلی، هر گروه از مقادیر ساده و اسکالر می‌توانند ویژگی Copy را پیاده‌سازی کنند و هیچ چیزی که نیاز به تخصیص یا نوعی منبع داشته باشد نمی‌تواند ویژگی Copy را پیاده‌سازی کند. در اینجا تعدادی از انواعی که ویژگی Copy را پیاده‌سازی می‌کنند آورده شده است:

  • تمام انواع اعداد صحیح، مانند u32.
  • نوع بولی، bool، با مقادیر true و false.
  • تمام انواع اعشاری، مانند f64.
  • نوع کاراکتر، char.
  • تاپل‌ها، اگر تنها شامل انواعی باشند که ویژگی Copy را نیز پیاده‌سازی می‌کنند. برای مثال، (i32, i32) ویژگی Copy را پیاده‌سازی می‌کند، اما (i32, String) این کار را نمی‌کند.

مالکیت و توابع

مکانیزم‌های انتقال یک مقدار به یک تابع مشابه زمانی است که مقداری را به یک متغیر اختصاص می‌دهیم. انتقال یک متغیر به یک تابع به همان صورت که تخصیص انجام می‌شود، جابه‌جا یا کپی می‌شود. لیستینگ ۴-۳ مثالی با برخی حاشیه‌نویسی‌ها دارد که نشان می‌دهد متغیرها کجا وارد و از دامنه خارج می‌شوند.

Filename: src/main.rs
fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
Listing 4-3: توابع با مالکیت و دامنه حاشیه‌نویسی شده

اگر بخواهیم از s پس از فراخوانی به takes_ownership استفاده کنیم، Rust یک خطای زمان کامپایل صادر می‌کند. این بررسی‌های استاتیک ما را از اشتباهات محافظت می‌کنند. سعی کنید کدی به main اضافه کنید که از s و x استفاده کند تا ببینید کجا می‌توانید از آن‌ها استفاده کنید و کجا قوانین مالکیت مانع شما می‌شوند.

مقادیر بازگشتی و دامنه

بازگرداندن مقادیر نیز می‌تواند مالکیت را منتقل کند. لیستینگ ۴-۴ مثالی از یک تابع که مقداری را بازمی‌گرداند نشان می‌دهد، با حاشیه‌نویسی‌هایی مشابه آنچه در لیستینگ ۴-۳ وجود داشت.

Filename: src/main.rs
fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}
Listing 4-4: انتقال مالکیت مقادیر بازگشتی

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

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

Rust به ما اجازه می‌دهد مقادیر متعددی را با استفاده از یک tuple بازگردانیم، همانطور که در لیستینگ ۴-۵ نشان داده شده است.

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}
Listing 4-5: بازگرداندن مالکیت پارامترها

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

ارجاعات و قرض گرفتن (References and Borrowing)

مشکل کدی که در لیستینگ 4-5 با استفاده از تاپل وجود دارد این است که باید String را به تابع فراخوانی‌کننده بازگردانیم تا بعد از فراخوانی calculate_length بتوانیم همچنان از String استفاده کنیم، زیرا String به calculate_length منتقل شده است. در عوض، می‌توانیم یک ارجاع به مقدار String ارائه دهیم. یک ارجاع مشابه یک اشاره‌گر (Pointer) است، به این معنا که یک آدرس است که می‌توانیم از آن پیروی کنیم تا به داده‌هایی که در آن آدرس ذخیره شده‌اند دسترسی پیدا کنیم؛ این داده‌ها متعلق به متغیر دیگری هستند. برخلاف اشاره‌گر (Pointer)، یک ارجاع تضمین می‌کند که به یک مقدار معتبر از نوع خاصی در طول عمر آن ارجاع اشاره می‌کند.

در اینجا نحوه تعریف و استفاده از یک تابع calculate_length آورده شده است که به جای گرفتن مالکیت مقدار، یک ارجاع به یک شی به عنوان پارامتر دارد:

Filename: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

اول، توجه کنید که تمام کد مربوط به تاپل در اعلام متغیر و مقدار بازگشتی تابع حذف شده است. دوم، دقت کنید که ما &s1 را به calculate_length می‌دهیم و در تعریف آن، &String می‌گیریم به جای String. این علامت‌های & نماینده‌ی ارجاعات هستند و به شما اجازه می‌دهند تا به مقداری اشاره کنید بدون اینکه مالکیت آن را بگیرید. شکل 4-6 این مفهوم را نشان می‌دهد.

سه جدول: جدول s فقط یک اشاره‌گر (Pointer) به جدول s1 دارد. جدول s1 شامل داده‌های استک برای s1 است و به داده‌های رشته‌ای در هیپ اشاره می‌کند.

شکل 4-6: نمودار &String s که به String s1 اشاره می‌کند

توجه: متضاد ارجاع دادن با استفاده از &، عدم ارجاع است که با عملگر عدم ارجاع، یعنی *، انجام می‌شود. برخی از موارد استفاده از عملگر عدم ارجاع را در فصل 8 خواهیم دید و جزئیات مربوط به عدم ارجاع را در فصل 15 بحث خواهیم کرد.

بیایید نگاهی دقیق‌تر به فراخوانی تابع بیندازیم:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

سینتکس &s1 به ما اجازه می‌دهد یک ارجاع ایجاد کنیم که به مقدار s1 اشاره می‌کند اما مالک آن نیست. از آنجایی که ارجاع مالک آن نیست، مقداری که به آن اشاره می‌کند زمانی که ارجاع استفاده نمی‌شود حذف نخواهد شد.

به همین ترتیب، امضای تابع از & استفاده می‌کند تا نشان دهد که نوع پارامتر s یک ارجاع است. بیایید برخی توضیحات اضافه کنیم:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because s does not have ownership of what
  // it refers to, the String is not dropped.

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

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

پس چه اتفاقی می‌افتد اگر بخواهیم چیزی که قرض گرفته‌ایم را تغییر دهیم؟ کد موجود در لیستینگ 4-6 را امتحان کنید. هشدار: این کار نمی‌کند!

Filename: src/main.rs
fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}
Listing 4-6: تلاش برای تغییر مقدار قرض گرفته شده

در اینجا خطا آورده شده است:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

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

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

ارجاعات متغیر

ما می‌توانیم کد موجود در لیستینگ 4-6 را طوری اصلاح کنیم که به ما اجازه دهد یک مقدار قرض گرفته شده را تغییر دهیم، با چند تغییر کوچک که به جای آن از ارجاع متغیر استفاده کنیم:

Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

ابتدا s را به mut تغییر می‌دهیم. سپس یک ارجاع متغیر با &mut s ایجاد می‌کنیم، جایی که تابع change را فراخوانی می‌کنیم، و امضای تابع را به‌روزرسانی می‌کنیم تا یک ارجاع متغیر با some_string: &mut String بپذیرد. این بسیار واضح می‌کند که تابع change مقدار قرض گرفته شده را تغییر خواهد داد.

ارجاعات متغیر یک محدودیت بزرگ دارند: اگر یک ارجاع متغیر به یک مقدار داشته باشید، نمی‌توانید هیچ ارجاع دیگری به آن مقدار داشته باشید. این کد که تلاش می‌کند دو ارجاع متغیر به s ایجاد کند، ناموفق خواهد بود:

Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{r1}, {r2}");
}

در اینجا خطا آورده شده است:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{r1}, {r2}");
  |               ---- first borrow later used here

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

این خطا می‌گوید که این کد نامعتبر است زیرا نمی‌توانیم s را به طور همزمان بیش از یک بار به صورت متغیر قرض بگیریم. اولین قرض متغیر در r1 است و باید تا زمانی که در println! استفاده شود باقی بماند، اما بین ایجاد آن ارجاع متغیر و استفاده از آن، ما سعی کردیم یک ارجاع متغیر دیگر در r2 ایجاد کنیم که همان داده‌ای را قرض می‌گیرد که r1 نیز قرض گرفته است.

محدودیتی که از ایجاد چند ارجاع متغیر به داده‌های یکسان به طور همزمان جلوگیری می‌کند، امکان تغییر داده‌ها را فراهم می‌کند اما به صورت بسیار کنترل شده. این چیزی است که تازه‌کاران زبان Rust ممکن است با آن مشکل داشته باشند زیرا اکثر زبان‌ها به شما اجازه می‌دهند هر زمان که بخواهید داده‌ها را تغییر دهید. مزیت این محدودیت این است که Rust می‌تواند از مسابقات داده (data race) در زمان کامپایل جلوگیری کند. یک مسابقه داده مشابه یک شرایط مسابقه (race condition) است و زمانی رخ می‌دهد که این سه رفتار اتفاق بیفتند:

  • دو یا چند اشاره‌گر (Pointer) به طور همزمان به داده‌های یکسان دسترسی پیدا می‌کنند.
  • حداقل یکی از اشاره‌گر (Pointer)ها برای نوشتن در داده‌ها استفاده می‌شود.
  • هیچ مکانیزمی برای هماهنگ کردن دسترسی به داده‌ها استفاده نمی‌شود.

مسابقات داده باعث رفتار نامشخص می‌شوند و در زمان اجرای برنامه ممکن است یافتن و رفع آن‌ها دشوار باشد؛ Rust با عدم کامپایل کدهای دارای مسابقات داده از این مشکل جلوگیری می‌کند!

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

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

Rust یک قانون مشابه برای ترکیب ارجاعات متغیر و غیرمتغیر اعمال می‌کند. این کد منجر به خطا می‌شود:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

    println!("{r1}, {r2}, and {r3}");
}

در اینجا خطا آورده شده است:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{r1}, {r2}, and {r3}");
  |               ---- immutable borrow later used here

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

ای وای! ما همچنین نمی‌توانیم یک ارجاع متغیر داشته باشیم در حالی که یک ارجاع غیرمتغیر به همان مقدار داریم.

کاربرانی که از یک ارجاع غیرمتغیر استفاده می‌کنند، انتظار ندارند که مقدار به طور ناگهانی تغییر کند! با این حال، چندین ارجاع غیرمتغیر مجاز هستند زیرا هیچ‌کسی که فقط داده‌ها را می‌خواند، نمی‌تواند خواندن دیگران را تحت تأثیر قرار دهد.

توجه داشته باشید که اسکوپ یک ارجاع از جایی که معرفی می‌شود شروع شده و تا آخرین باری که از آن استفاده می‌شود ادامه دارد. به عنوان مثال، این کد کامپایل می‌شود زیرا آخرین استفاده از ارجاعات غیرمتغیر در println! است، قبل از اینکه ارجاع متغیر معرفی شود:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{r1} and {r2}");
    // Variables r1 and r2 will not be used after this point.

    let r3 = &mut s; // no problem
    println!("{r3}");
}

اسکوپ‌های ارجاعات غیرمتغیر r1 و r2 بعد از println! که در آنجا آخرین بار استفاده شده‌اند به پایان می‌رسند، که این قبل از ایجاد ارجاع متغیر r3 است. این اسکوپ‌ها همپوشانی ندارند، بنابراین این کد مجاز است: کامپایلر می‌تواند تشخیص دهد که ارجاع دیگر در نقطه‌ای قبل از پایان اسکوپ استفاده نمی‌شود.

حتی اگر خطاهای قرض گرفتن ممکن است گاهی اوقات ناامیدکننده باشند، به یاد داشته باشید که این کامپایلر Rust است که به شما نشان می‌دهد یک باگ بالقوه در اوایل (در زمان کامپایل به جای زمان اجرا) وجود دارد و دقیقا به شما می‌گوید مشکل کجاست. سپس نیازی نیست که پیگیری کنید چرا داده‌های شما آن چیزی نیست که فکر می‌کردید.

ارجاعات آویزان

در زبان‌هایی که از اشاره‌گر (Pointer)ها استفاده می‌کنند، ایجاد اشتباه یک اشاره‌گر (Pointer) آویزان آسان است—اشاره‌گر (Pointer)ی که به مکانی در حافظه اشاره می‌کند که ممکن است به شخص دیگری داده شده باشد—با آزاد کردن مقداری حافظه در حالی که اشاره‌گر (Pointer) به آن حافظه را حفظ می‌کنید. در Rust، برعکس، کامپایلر تضمین می‌کند که ارجاعات هرگز ارجاعات آویزان نخواهند بود: اگر به داده‌هایی ارجاع دارید، کامپایلر اطمینان می‌دهد که داده‌ها قبل از ارجاع به داده‌ها از محدوده خارج نمی‌شوند.

بیایید سعی کنیم یک ارجاع آویزان ایجاد کنیم تا ببینیم چگونه Rust با یک خطای زمان کامپایل از این اتفاق جلوگیری می‌کند:

Filename: src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

در اینجا خطا آورده شده است:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
5 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
  |

error[E0515]: cannot return reference to local variable `s`
 --> src/main.rs:8:5
  |
8 |     &s
  |     ^^ returns a reference to data owned by the current function

Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous errors

این پیام خطا به ویژگی‌ای اشاره دارد که هنوز پوشش نداده‌ایم: طول عمرها (lifetimes). ما طول عمرها را به طور مفصل در فصل 10 مورد بحث قرار خواهیم داد. اما، اگر بخش‌های مربوط به طول عمرها را نادیده بگیرید، پیام کلید مشکل این کد را بیان می‌کند:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

بیایید نگاهی دقیق‌تر به آنچه که در هر مرحله از کد dangle اتفاق می‌افتد بیندازیم:

Filename: src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope and is dropped, so its memory goes away.
  // Danger!

از آنجا که s داخل dangle ایجاد می‌شود، زمانی که کد dangle تمام می‌شود، s از محدوده خارج می‌شود و آزاد می‌گردد. اما ما سعی کردیم یک ارجاع به آن برگردانیم. این بدان معناست که این ارجاع به یک String نامعتبر اشاره می‌کند. این خوب نیست! Rust اجازه نمی‌دهد این کار را انجام دهیم.

راه‌حل در اینجا این است که به جای آن String را به طور مستقیم برگردانید:

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

این بدون هیچ مشکلی کار می‌کند. مالکیت به بیرون منتقل می‌شود و هیچ چیزی آزاد نمی‌شود.

قوانین ارجاعات

بیایید آنچه درباره ارجاعات بحث کردیم را مرور کنیم:

  • در هر زمان مشخص، می‌توانید یا یک ارجاع متغیر داشته باشید یا هر تعداد ارجاع غیرمتغیر.
  • ارجاعات باید همیشه معتبر باشند.

در مرحله بعد، به نوع دیگری از ارجاع خواهیم پرداخت: بخش‌ها (slices).

نوع Slice

Slices به شما اجازه می‌دهند که به یک دنباله‌ی متوالی از عناصر در یک مجموعه ارجاع دهید. اسلایس نوعی ارجاع است، بنابراین مالکیت ندارد.

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

نکته: برای معرفی اسلایس‌های رشته‌ای در این بخش، فرض بر این است که تنها با ASCII سروکار داریم؛ بحث جامع‌تر درباره‌ی مدیریت UTF-8 در بخش «ذخیره متن کدگذاری‌شده UTF-8 با رشته‌ها» در فصل ۸ ارائه شده است.

بیایید بررسی کنیم چگونه امضای این تابع را بدون استفاده از اسلایس‌ها می‌نویسیم تا مشکل‌هایی که اسلایس‌ها حل می‌کنند را درک کنیم:

fn first_word(s: &String) -> ?

تابع first_word یک پارامتر از نوع &String دریافت می‌کند.
ما نیازی به مالکیت نداریم، پس این کار درست است.
(در Rust ایدئومیک، توابع معمولاً مالکیت آرگومان‌های
خود را نمی‌گیرند مگر اینکه واقعاً لازم باشد، و دلایل
این موضوع با پیش رفتن توضیح داده خواهد شد.) اما
چه چیزی باید بازگردانیم؟ در واقع راهی برای اشاره به
بخشی از رشته نداریم. با این حال، می‌توانیم اندیس
پایان کلمه را که با یک فاصله مشخص می‌شود، بازگردانیم.
بیایید این روش را امتحان کنیم، همان‌طور که در فهرست 4-7
نشان داده شده است.

Filename: src/main.rs
fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}
Listing 4-7: تابع first_word که یک مقدار شاخص بایت در String پارامتر را برمی‌گرداند

زیرا ما نیاز داریم عنصر به عنصر از String عبور کنیم و بررسی کنیم که آیا یک مقدار فاصله است یا خیر، رشته خود را به یک آرایه از بایت‌ها با استفاده از متد as_bytes تبدیل می‌کنیم.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

در مرحله بعد، یک iterator روی آرایه بایت‌ها با استفاده از متد iter ایجاد می‌کنیم:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

ما در فصل 13 بیشتر درباره iterators بحث خواهیم کرد. فعلاً بدانید که iter یک متد است که هر عنصر در یک مجموعه را برمی‌گرداند و enumerate نتیجه iter را می‌پیچد و هر عنصر را به عنوان بخشی از یک tuple برمی‌گرداند. اولین عنصر tuple برگردانده شده از enumerate شاخص است و دومین عنصر ارجاع به عنصر است. این کار کمی راحت‌تر از محاسبه شاخص به صورت دستی است.

زیرا متد enumerate یک tuple برمی‌گرداند، می‌توانیم از الگوها برای جدا کردن این tuple استفاده کنیم. ما در فصل 6 بیشتر درباره الگوها صحبت خواهیم کرد. در حلقه for، الگویی مشخص می‌کنیم که i برای شاخص در tuple و &item برای بایت منفرد در tuple باشد. زیرا ما یک ارجاع به عنصر از .iter().enumerate() دریافت می‌کنیم، از & در الگو استفاده می‌کنیم.

داخل حلقه for، به دنبال بایتی که نماینده فاصله باشد می‌گردیم با استفاده از نحوه نوشتن بایت به صورت literale. اگر یک فاصله پیدا کردیم، موقعیت را برمی‌گردانیم. در غیر این صورت، طول رشته را با استفاده از s.len() برمی‌گردانیم.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

اکنون راهی برای یافتن شاخص انتهای اولین کلمه در رشته داریم، اما مشکلی وجود دارد. ما یک usize به تنهایی برمی‌گردانیم، اما این تنها یک عدد معنادار در زمینه &String است. به عبارت دیگر، زیرا این مقدار از String جدا است، هیچ تضمینی وجود ندارد که در آینده همچنان معتبر باشد. برنامه‌ای که در لیستینگ 4-8 استفاده می‌شود و از تابع first_word از لیستینگ 4-7 استفاده می‌کند را در نظر بگیرید.

Filename: src/main.rs
fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // word still has the value 5 here, but s no longer has any content that we
    // could meaningfully use with the value 5, so word is now totally invalid!
}
Listing 4-8: ذخیره نتیجه از فراخوانی تابع first_word و سپس تغییر محتوای String

این برنامه بدون هیچ خطایی کامپایل می‌شود و حتی اگر word را بعد از فراخوانی s.clear() استفاده کنیم، همچنان درست کار خواهد کرد. زیرا word اصلاً به حالت s متصل نیست، word همچنان مقدار 5 را دارد. ما می‌توانیم از مقدار 5 همراه با متغیر s استفاده کنیم تا تلاش کنیم اولین کلمه را استخراج کنیم، اما این یک باگ خواهد بود زیرا محتوای s از زمانی که 5 را در word ذخیره کردیم، تغییر کرده است.

نگران هماهنگ نگه داشتن شاخص در word با داده‌های موجود در s بودن، خسته‌کننده و مستعد خطاست! مدیریت این شاخص‌ها حتی شکننده‌تر می‌شود اگر بخواهیم یک تابع second_word بنویسیم. امضای آن باید به این صورت باشد:

fn second_word(s: &String) -> (usize, usize) {

حالا ما یک شاخص شروع و یک شاخص پایان را دنبال می‌کنیم و مقادیر بیشتری داریم که از داده‌ها در یک وضعیت خاص محاسبه شده‌اند اما اصلاً به آن وضعیت مرتبط نیستند. ما سه متغیر نامرتبط داریم که باید همگام نگه داشته شوند.

خوشبختانه، Rust یک راه‌حل برای این مشکل دارد: برش‌های رشته‌ای.

برش‌های رشته‌ای

string slice یک ارجاع به دنباله‌ای متوالی از عناصر یک String است و به این صورت نمایش داده می‌شود:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

به‌جای یک رفرنس به کل String، مقدار hello یک رفرنس به بخشی از String است که در بخش اضافی [0..5] مشخص شده است.

برای ساختن slice، از یک بازه در داخل براکت‌ها استفاده می‌کنیم و آن را به صورت [starting_index..ending_index] می‌نویسیم؛ که در آن، starting_index اولین موقعیت در slice است و ending_index یکی بیشتر از آخرین موقعیت در slice است.

درونی‌سازی ساختار داده‌ی slice، موقعیت شروع و طول slice را ذخیره می‌کند که این طول برابر است با ending_index منهای starting_index.

پس در مورد دستور let world = &s[6..11];، متغیر world یک slice خواهد بود که اشاره‌گری به بایت در اندیس ۶ از s دارد، به همراه یک مقدار طول برابر با 5.

شکل 4-7 این موضوع را در یک نمودار نشان می‌دهد.

سه جدول: جدولی که داده‌های پشته‌ای s را نشان می‌دهد، که به بایت در شاخص 0 در یک جدول از داده‌های رشته "hello world" در heap اشاره می‌کند. جدول سوم داده‌های پشته‌ای برش world را نشان می‌دهد که دارای مقدار طول 5 است و به بایت 6 از جدول داده‌های heap اشاره می‌کند.

شکل 4-7: برش رشته‌ای اشاره به بخشی از یک String

با استفاده از نحوی محدوده .. در Rust، اگر می‌خواهید از شاخص 0 شروع کنید، می‌توانید مقدار قبل از دو نقطه را حذف کنید. به عبارت دیگر، این دو معادل هستند:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

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

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

شما همچنین می‌توانید هر دو مقدار را حذف کنید تا یک برش از کل رشته بگیرید. بنابراین این دو معادل هستند:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

توجه: اندیس‌های بازه‌ی slice برای String باید در مرزهای معتبر کاراکترهای UTF-8 قرار داشته باشند. اگر سعی کنید یک slice از رشته را در میانه‌ی یک کاراکتر چندبایتی ایجاد کنید، برنامه‌ی شما با خطا متوقف خواهد شد.

با در نظر گرفتن این اطلاعات، بیایید first_word را بازنویسی کنیم تا یک برش برگرداند. نوعی که نشان‌دهنده “برش رشته‌ای” است به صورت &str نوشته می‌شود:

Filename: src/main.rs
fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

ما شاخص پایان کلمه را به همان روشی که در لیستینگ 4-7 انجام دادیم، پیدا می‌کنیم، یعنی با جستجوی اولین فضای خالی. وقتی یک فضای خالی پیدا می‌کنیم، یک برش رشته‌ای با استفاده از شروع رشته و شاخص فضای خالی به‌عنوان شاخص‌های شروع و پایان برمی‌گردانیم.

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

بازگرداندن یک برش برای یک تابع second_word نیز کار می‌کند:

fn second_word(s: &String) -> &str {

اکنون یک API ساده داریم که بسیار سخت‌تر است اشتباه شود زیرا کامپایلر اطمینان حاصل می‌کند که ارجاع‌ها به داخل String معتبر باقی می‌مانند. به یاد دارید خطای منطقی برنامه در لیستینگ 4-8، وقتی شاخص انتهای اولین کلمه را به دست آوردیم اما سپس رشته را پاک کردیم، بنابراین شاخص ما نامعتبر شد؟ آن کد منطقی نادرست بود اما هیچ خطای فوری نشان نمی‌داد. مشکلات بعداً وقتی تلاش می‌کردیم از شاخص اولین کلمه با یک رشته خالی استفاده کنیم، ظاهر می‌شد. برش‌ها این خطا را غیرممکن می‌کنند و به ما اطلاع می‌دهند که مشکلی در کد ما وجود دارد خیلی زودتر. استفاده از نسخه برش first_word یک خطای زمان کامپایل ایجاد می‌کند:

Filename: src/main.rs
fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {word}");
}

این هم خطای کامپایلر:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {word}");
   |                                  ------ immutable borrow later used here

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

به یاد بیاورید از قوانین وام گرفتن که اگر ما یک ارجاع غیرقابل تغییر به چیزی داشته باشیم، نمی‌توانیم یک ارجاع قابل تغییر نیز بگیریم. از آنجایی که clear نیاز دارد که String را کوتاه کند، نیاز دارد یک ارجاع قابل تغییر بگیرد. println! بعد از فراخوانی به clear از ارجاع در word استفاده می‌کند، بنابراین ارجاع غیرقابل تغییر باید هنوز در آن نقطه فعال باشد. Rust ارجاع قابل تغییر در clear و ارجاع غیرقابل تغییر در word را از همزمان وجود داشتن ممنوع می‌کند و کامپایل شکست می‌خورد. نه تنها Rust API ما را آسان‌تر کرده، بلکه یک دسته کامل از خطاها را در زمان کامپایل حذف کرده است!

رشته‌های متنی به عنوان برش

به یاد بیاورید که ما درباره ذخیره رشته‌های متنی در داخل باینری صحبت کردیم. اکنون که درباره برش‌ها می‌دانیم، می‌توانیم رشته‌های متنی را به درستی درک کنیم:

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

نوع s در اینجا &str است: این یک برش است که به یک نقطه خاص از باینری اشاره می‌کند. این همچنین دلیل غیرقابل تغییر بودن رشته‌های متنی است؛ &str یک ارجاع غیرقابل تغییر است.

برش‌های رشته‌ای به عنوان پارامترها

دانستن اینکه می‌توانید برش‌هایی از رشته‌های متنی و مقادیر String بگیرید ما را به یک بهبود دیگر در first_word می‌رساند، و آن امضای آن است:

fn first_word(s: &String) -> &str {

یک برنامه‌نویس باتجربه‌تر Rust امضای نشان داده شده در لیستینگ 4-9 را می‌نویسد زیرا این اجازه را می‌دهد که از همان تابع برای مقادیر &String و &str استفاده کنیم.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}
Listing 4-9: بهبود تابع first_word با استفاده از برش رشته‌ای برای نوع پارامتر s

اگر ما یک برش رشته‌ای داشته باشیم، می‌توانیم آن را مستقیماً ارسال کنیم. اگر یک String داشته باشیم، می‌توانیم یک برش از String یا یک ارجاع به String ارسال کنیم. این انعطاف‌پذیری از ویژگی دریف کوئرسین استفاده می‌کند، که در بخش “Implicit Deref Coercions with Functions and Methods” در فصل 15 به آن خواهیم پرداخت.

تعریف یک تابع برای گرفتن یک برش رشته‌ای به جای یک ارجاع به String، API ما را عمومی‌تر و مفیدتر می‌کند بدون اینکه هیچ کاربردی از دست برود:

Filename: src/main.rs
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

برش‌های دیگر

برش‌های رشته‌ای، همانطور که تصور می‌کنید، مختص رشته‌ها هستند. اما یک نوع برش عمومی‌تر نیز وجود دارد. این آرایه را در نظر بگیرید:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

همانطور که ممکن است بخواهیم به بخشی از یک رشته ارجاع دهیم، ممکن است بخواهیم به بخشی از یک آرایه نیز ارجاع دهیم. این کار را می‌توانیم به این شکل انجام دهیم:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

این برش دارای نوع &[i32] است. این دقیقاً همانطور که برش‌های رشته‌ای کار می‌کنند، با ذخیره یک ارجاع به اولین عنصر و یک طول عمل می‌کند. شما از این نوع برش برای انواع دیگر مجموعه‌ها نیز استفاده خواهید کرد. ما این مجموعه‌ها را به تفصیل وقتی درباره وکتورها در فصل 8 صحبت کنیم، بررسی خواهیم کرد.

خلاصه

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

مالکیت بر نحوه عملکرد بسیاری از بخش‌های دیگر Rust تأثیر می‌گذارد، بنابراین در طول بقیه کتاب این مفاهیم را بیشتر بررسی خواهیم کرد. بیایید به فصل 5 برویم و نگاهی به گروه‌بندی قطعات داده در یک struct بیندازیم.

استفاده از Structها برای سازماندهی داده‌های مرتبط

یک struct یا ساختار، نوع داده‌ای سفارشی است که به شما اجازه می‌دهد چندین مقدار مرتبط را به صورت گروهی در کنار هم بسته‌بندی و نام‌گذاری کنید. اگر با یک زبان برنامه‌نویسی شیءگرا آشنا باشید، یک struct شبیه به ویژگی‌های داده‌ای یک شیء است. در این فصل، ما ساختارها را با تاپل‌ها مقایسه و مقایسه خواهیم کرد تا نشان دهیم چه زمانی ساختارها روش بهتری برای گروه‌بندی داده‌ها هستند.

ما نحوه تعریف و نمونه‌سازی ساختارها را نشان خواهیم داد. همچنین بحث خواهیم کرد که چگونه توابع مرتبط، به‌ویژه نوعی از توابع مرتبط به نام متدها را تعریف کنیم تا رفتار مرتبط با یک نوع ساختار را مشخص کنیم. ساختارها و Enumها (که در فصل ۶ مورد بحث قرار گرفته‌اند) بلوک‌های سازنده‌ای برای ایجاد انواع جدید در حوزه برنامه شما هستند که از بررسی نوع در زمان کامپایل در Rust به طور کامل استفاده می‌کنند.

تعریف و نمونه‌سازی Structها

ساختارها مشابه تاپل‌ها هستند که در بخش «نوع Tuple» مورد بحث قرار گرفتند، به این معنا که هر دو شامل مقادیر مرتبط متعددی هستند. مانند تاپل‌ها، اجزای یک ساختار می‌توانند از انواع مختلفی باشند. اما برخلاف تاپل‌ها، در یک ساختار شما برای هر جزء داده نام تعیین می‌کنید تا معنای مقادیر روشن‌تر شود. افزودن این نام‌ها باعث می‌شود که ساختارها از تاپل‌ها انعطاف‌پذیرتر باشند: شما مجبور نیستید برای مشخص کردن یا دسترسی به مقادیر یک نمونه به ترتیب داده‌ها تکیه کنید.

برای تعریف یک ساختار، کلمه کلیدی struct را وارد کرده و نام کل ساختار را تعیین می‌کنیم. نام یک ساختار باید توصیف‌کننده اهمیت اجزای داده‌ای باشد که با هم گروه‌بندی می‌شوند. سپس، داخل آکولادها، نام‌ها و انواع اجزای داده‌ای را که به آن‌ها فیلد می‌گوییم، تعریف می‌کنیم. برای مثال، لیست ۵-۱ یک ساختار را نشان می‌دهد که اطلاعات مربوط به یک حساب کاربری را ذخیره می‌کند.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}
Listing 5-1: تعریف یک ساختار User

برای استفاده از یک struct پس از تعریف آن، باید یک instance از آن ایجاد کنیم با مشخص کردن مقادیر مشخص برای هر یک از فیلدها.

برای ساختن یک instance، نام struct را می‌نویسیم و سپس داخل کروشه‌ها جفت‌های کلید: مقدار قرار می‌دهیم؛ که در آن‌ها، کلیدها نام فیلدها هستند و مقادیر، داده‌هایی هستند که می‌خواهیم در آن فیلدها ذخیره کنیم.

لازم نیست فیلدها را به همان ترتیبی بنویسیم که در تعریف struct آمده‌اند.

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

برای نمونه، می‌توانیم یک کاربر خاص را همان‌طور که در لیست ۵-۲ نشان داده شده تعریف کنیم.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("[email protected]"),
        sign_in_count: 1,
    };
}
Listing 5-2: ایجاد یک نمونه از ساختار User

برای به‌دست‌آوردن مقدار خاصی از یک ساختار، از نشانه‌گذاری نقطه استفاده می‌کنیم. به عنوان مثال، برای دسترسی به آدرس ایمیل این کاربر، از user1.email استفاده می‌کنیم. اگر نمونه قابل تغییر باشد، می‌توانیم مقدار را با استفاده از نشانه‌گذاری نقطه تغییر داده و در یک فیلد خاص مقداردهی کنیم. لیست ۵-۳ نشان می‌دهد که چگونه مقدار در فیلد email یک نمونه قابل تغییر User را تغییر دهیم.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("[email protected]"),
        sign_in_count: 1,
    };

    user1.email = String::from("[email protected]");
}
Listing 5-3: تغییر مقدار در فیلد email یک نمونه User

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

لیست ۵-۴ یک تابع build_user را نشان می‌دهد که یک نمونه از User را با ایمیل و نام کاربری مشخص برمی‌گرداند. فیلد active مقدار true می‌گیرد و sign_in_count مقدار 1 دریافت می‌کند.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("[email protected]"),
        String::from("someusername123"),
    );
}
Listing 5-4: یک تابع build_user که یک ایمیل و نام کاربری می‌گیرد و یک نمونه User را بازمی‌گرداند

نوشتن نام پارامترهای تابع با همان نام فیلدهای ساختار منطقی است، اما تکرار نام‌های email و username برای هر دو فیلد و متغیرها کمی خسته‌کننده است. اگر ساختار فیلدهای بیشتری داشت، تکرار هر نام حتی آزاردهنده‌تر می‌شد. خوشبختانه، یک راه میانبر راحت وجود دارد!

استفاده از میانبر مقداردهی فیلد

از آنجا که نام پارامترها و نام فیلدهای ساختار دقیقاً یکسان هستند، می‌توانیم از نحو میانبر مقداردهی فیلد برای بازنویسی build_user استفاده کنیم تا همان رفتار را داشته باشد اما تکرار username و email را نداشته باشد، همان‌طور که در لیست ۵-۵ نشان داده شده است.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("[email protected]"),
        String::from("someusername123"),
    );
}
Listing 5-5: یک تابع build_user که از میانبر مقداردهی فیلد استفاده می‌کند زیرا پارامترهای username و email همان نام فیلدهای ساختار را دارند

اینجا، ما یک نمونه جدید از ساختار User می‌سازیم که فیلدی به نام email دارد. ما می‌خواهیم مقدار فیلد email را به مقداری که در پارامتر email تابع build_user وجود دارد تنظیم کنیم. از آنجا که فیلد email و پارامتر email نام یکسانی دارند، فقط نیاز داریم email بنویسیم، نه email: email.

ایجاد نمونه‌ها از نمونه‌های دیگر با استفاده از نحو به‌روزرسانی Struct

اغلب مفید است که یک instance جدید از یک struct ایجاد کنیم که بیشتر مقادیر آن از یک instance دیگر با همان نوع گرفته شده باشد، اما برخی از مقادیر آن تغییر کرده باشند. برای انجام این کار می‌توانید از syntax به‌روزرسانی struct استفاده کنید.

ابتدا، در لیست ۵-۶ نشان داده شده است که چگونه می‌توان یک نمونه جدید User در user2 ایجاد کرد، بدون استفاده از نحو به‌روزرسانی. ما یک مقدار جدید برای email تنظیم می‌کنیم اما در غیر این صورت از همان مقادیر در user1 که قبلاً در لیست ۵-۲ ایجاد شده است، استفاده می‌کنیم.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("[email protected]"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        active: user1.active,
        username: user1.username,
        email: String::from("[email protected]"),
        sign_in_count: user1.sign_in_count,
    };
}
Listing 5-6: ایجاد یک نمونه جدید User با استفاده از تمام مقادیر به جز یکی از user1

با استفاده از نحو به‌روزرسانی Struct، می‌توانیم همان نتیجه را با کد کمتری به دست آوریم، همان‌طور که در لیست ۵-۷ نشان داده شده است. نحو .. مشخص می‌کند که فیلدهای باقی‌مانده‌ای که به صورت صریح تنظیم نشده‌اند باید همان مقادیری را داشته باشند که در نمونه داده شده هستند.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("[email protected]"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("[email protected]"),
        ..user1
    };
}
Listing 5-7: استفاده از نحو به‌روزرسانی Struct برای تنظیم یک مقدار جدید email برای یک نمونه User اما استفاده از مقادیر باقی‌مانده از user1

کد در لیست ۵-۷ همچنین نمونه‌ای در user2 ایجاد می‌کند که مقدار متفاوتی برای email دارد اما دارای مقادیر مشابهی برای فیلدهای username، active و sign_in_count از user1 است. ..user1 باید در انتها بیاید تا مشخص کند که فیلدهای باقی‌مانده باید مقادیر خود را از فیلدهای مربوطه در user1 دریافت کنند، اما می‌توانیم مقادیر را برای هر تعداد فیلدی که می‌خواهیم به هر ترتیبی مشخص کنیم، بدون توجه به ترتیب فیلدها در تعریف ساختار.

توجه داشته باشید که نحو به‌روزرسانی struct از = مانند عمل انتساب استفاده می‌کند؛ زیرا داده را منتقل می‌کند، همان‌طور که در بخش «تعامل متغیرها و داده‌ها با Move» دیدیم. در این مثال، پس از ایجاد user2 دیگر نمی‌توانیم از user1 استفاده کنیم چون String موجود در فیلد username از user1 به user2 منتقل شده است. اگر برای user2 مقادیر جدیدی از نوع String برای هر دو فیلد email و username مشخص کرده بودیم و تنها از مقادیر active و sign_in_count از user1 استفاده کرده بودیم، آنگاه user1 پس از ساختن user2 همچنان معتبر باقی می‌ماند. زیرا active و sign_in_count از نوع‌هایی هستند که Copy trait را پیاده‌سازی می‌کنند، و بنابراین رفتاری که در بخش «داده‌های فقط-پشته: Copy» توضیح دادیم، اعمال می‌شود. در این مثال، همچنان می‌توانیم از user1.email استفاده کنیم، چون مقدار آن از user1 خارج نشده است.

استفاده از ساختارهای Tuple بدون فیلدهای نام‌گذاری‌شده برای ایجاد انواع مختلف

Rust همچنین از ساختارهایی که شبیه تاپل‌ها هستند پشتیبانی می‌کند که به آن‌ها ساختارهای Tuple می‌گویند. ساختارهای Tuple به دلیل نام ساختار معنای بیشتری دارند اما نام‌هایی برای فیلدهای خود ندارند؛ بلکه فقط نوع فیلدها را دارند. ساختارهای Tuple زمانی مفید هستند که بخواهید به کل تاپل یک نام بدهید و آن را به عنوان نوعی متفاوت از تاپل‌های دیگر مشخص کنید، و وقتی نام‌گذاری هر فیلد مانند یک ساختار معمولی طولانی یا زائد باشد.

برای تعریف یک ساختار Tuple، با کلمه کلیدی struct و نام ساختار شروع کنید و سپس نوع‌های موجود در تاپل را مشخص کنید. به عنوان مثال، در اینجا ما دو ساختار Tuple به نام‌های Color و Point تعریف و استفاده کرده‌ایم:

Filename: src/main.rs
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

توجه داشته باشید که مقادیر black و origin از انواع متفاوتی هستند چون آن‌ها instanceهای دو tuple struct مختلف‌اند. هر structای که تعریف می‌کنید، نوع خاص خود را دارد، حتی اگر فیلدهای داخل آن struct نوع‌های یکسانی داشته باشند. برای مثال، یک تابع که پارامتری از نوع Color می‌گیرد، نمی‌تواند یک Point را به عنوان آرگومان دریافت کند، حتی اگر هر دو نوع از سه مقدار i32 تشکیل شده باشند. به جز این مورد، tuple structها شبیه به tupleها هستند از این جهت که می‌توانید آن‌ها را به اجزای منفردشان destructure کنید، و با استفاده از . و اندیس، به مقدار خاصی دسترسی پیدا کنید. برخلاف tupleها، tuple structها نیاز دارند که هنگام destructure کردن، نام نوع struct را مشخص کنید. برای مثال، برای destructure کردن مقادیر موجود در origin به متغیرهای x، y و z، باید بنویسیم: let Point(x, y, z) = origin;

ساختارهای شبیه به Unit بدون هیچ فیلدی

شما همچنین می‌توانید ساختارهایی تعریف کنید که هیچ فیلدی ندارند! این‌ها به عنوان ساختارهای شبیه Unit شناخته می‌شوند زیرا شبیه به نوع ()، نوع Unit، رفتار می‌کنند که در بخش «نوع Tuple» مورد اشاره قرار گرفت. ساختارهای شبیه Unit زمانی مفید هستند که نیاز به پیاده‌سازی یک ویژگی بر روی یک نوع داشته باشید اما هیچ داده‌ای برای ذخیره در خود نوع نداشته باشید. ما ویژگی‌ها را در فصل ۱۰ بحث خواهیم کرد. در اینجا مثالی از اعلام و نمونه‌سازی یک ساختار شبیه Unit به نام AlwaysEqual آورده شده است:

Filename: src/main.rs
struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

برای تعریف AlwaysEqual، از کلمه کلیدی struct، نام دلخواه و سپس یک نقطه ویرگول استفاده می‌کنیم. نیازی به آکولاد یا پرانتز نیست! سپس می‌توانیم یک نمونه از AlwaysEqual را در متغیر subject با استفاده از همان نامی که تعریف کرده‌ایم، بدون هیچ آکولاد یا پرانتزی دریافت کنیم. تصور کنید که در آینده رفتاری را برای این نوع پیاده‌سازی خواهیم کرد که همه نمونه‌های AlwaysEqual همیشه با تمام نمونه‌های دیگر برابر باشند، شاید برای داشتن نتیجه‌ای مشخص برای اهداف آزمایشی. برای پیاده‌سازی آن رفتار نیازی به هیچ داده‌ای نداریم! شما در فصل ۱۰ خواهید دید که چگونه می‌توانید ویژگی‌ها را تعریف و آن‌ها را بر روی هر نوعی، از جمله ساختارهای شبیه به Unit، پیاده‌سازی کنید.

مالکیت داده‌های Struct

در تعریف ساختار User در لیست ۵-۱، ما از نوع مالک String به جای نوع برش رشته &str استفاده کردیم. این یک انتخاب عمدی است زیرا ما می‌خواهیم هر نمونه از این ساختار همه داده‌های خود را مالک باشد و این داده‌ها به مدت زمانی که کل ساختار معتبر است، معتبر باقی بمانند.

همچنین ممکن است ساختارهایی وجود داشته باشند که به داده‌های متعلق به چیز دیگری ارجاع می‌دهند، اما برای انجام این کار نیاز به استفاده از طول عمر‌ها داریم، یک ویژگی از Rust که ما در فصل ۱۰ مورد بحث قرار خواهیم داد. طول عمرها اطمینان حاصل می‌کنند که داده‌هایی که توسط یک ساختار ارجاع داده شده‌اند تا زمانی که ساختار معتبر است، معتبر باقی می‌مانند. بیایید بگوییم شما سعی دارید یک ارجاع را در یک ساختار ذخیره کنید بدون اینکه طول عمرها را مشخص کنید، مانند مثال زیر؛ این کار نخواهد کرد:

Filename: src/main.rs
struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: "someusername123",
        email: "[email protected]",
        sign_in_count: 1,
    };
}

کامپایلر شکایت خواهد کرد که به مشخص‌کننده‌های طول عمر نیاز دارد:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:4:12
  |
4 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 |     username: &str,
4 ~     email: &'a str,
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` (bin "structs") due to 2 previous errors

در فصل ۱۰، ما بحث خواهیم کرد که چگونه این خطاها را برطرف کنید تا بتوانید ارجاع‌ها را در ساختارها ذخیره کنید، اما در حال حاضر، ما این خطاها را با استفاده از انواع مالک مانند String به جای ارجاع‌ها مانند &str برطرف خواهیم کرد.

یک برنامه نمونه با استفاده از Structها

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

بیایید یک پروژه باینری جدید با Cargo به نام rectangles ایجاد کنیم که عرض و ارتفاع یک مستطیل را بر حسب پیکسل مشخص کرده و مساحت آن را محاسبه کند. لیست ۵-۸ یک برنامه کوتاه نشان می‌دهد که دقیقاً همین کار را در فایل src/main.rs پروژه ما انجام می‌دهد.

Filename: src/main.rs
fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}
Listing 5-8: محاسبه مساحت یک مستطیل که با متغیرهای عرض و ارتفاع جداگانه مشخص شده است

اکنون، این برنامه را با استفاده از دستور cargo run اجرا کنید:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

این کد با فراخوانی تابع area با هر یک از ابعاد موفق به محاسبه مساحت مستطیل می‌شود، اما می‌توانیم این کد را خواناتر و قابل درک‌تر کنیم.

مشکل این کد در امضای تابع area مشخص است:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

تابع area قرار است مساحت یک مستطیل را محاسبه کند، اما تابعی که نوشتیم دو پارامتر دارد و هیچ‌کجا در برنامه مشخص نیست که این پارامترها به هم مرتبط هستند. بهتر است عرض و ارتفاع را به صورت گروهی تعریف کنیم تا خوانایی و مدیریت کد بهتر شود. یکی از روش‌هایی که قبلاً در بخش «نوع Tuple» فصل ۳ بحث کردیم این است که از تاپل‌ها استفاده کنیم.

بازنویسی با استفاده از Tupleها

لیست ۵-۹ نسخه دیگری از برنامه ما را نشان می‌دهد که از تاپل‌ها استفاده می‌کند.

Filename: src/main.rs
fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}
Listing 5-9: مشخص کردن عرض و ارتفاع مستطیل با یک Tuple

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

اگر بخواهیم مستطیل را روی صفحه نمایش بکشیم، جابه‌جایی عرض و ارتفاع اهمیتی ندارد، اما برای رسم آن اهمیت پیدا می‌کند! ما باید به خاطر داشته باشیم که width ایندکس 0 تاپل و height ایندکس 1 تاپل است. این کار حتی برای کسی که از کد ما استفاده می‌کند سخت‌تر خواهد بود و به اشتباهات بیشتری منجر می‌شود. چون معنای داده‌های ما در کد مشخص نشده است، احتمال خطا بیشتر می‌شود.

بازنویسی با استفاده از Structها: افزودن معنای بیشتر

ما از ساختارها استفاده می‌کنیم تا با نام‌گذاری داده‌ها، معنای بیشتری به آن‌ها بدهیم. می‌توانیم تاپلی که استفاده می‌کنیم را به یک ساختار تبدیل کنیم که برای کل داده‌ها یک نام و همچنین برای بخش‌های مختلف آن نام‌هایی مشخص کنیم، همان‌طور که در لیست ۵-۱۰ نشان داده شده است.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}
Listing 5-10: تعریف یک ساختار Rectangle

در این‌جا یک struct تعریف کرده‌ایم و نام آن را Rectangle گذاشته‌ایم.
درون آکولادها، فیلدهایی با نام‌های width و height تعریف کرده‌ایم
که هر دو دارای نوع u32 هستند. سپس، در تابع main، یک نمونه خاص از Rectangle ایجاد کرده‌ایم
که width آن برابر با 30 و height آن برابر با 50 است.

تابع area ما اکنون با یک پارامتر تعریف شده است که آن را rectangle نامیده‌ایم و نوع آن یک ارجاع غیرقابل تغییر به یک نمونه از ساختار Rectangle است. همان‌طور که در فصل ۴ اشاره شد، ما می‌خواهیم ساختار را قرض بگیریم نه اینکه مالکیت آن را بگیریم. به این ترتیب، main مالکیت خود را حفظ می‌کند و می‌تواند همچنان از rect1 استفاده کند. به همین دلیل است که از & در امضای تابع و در جایی که تابع را فراخوانی می‌کنیم استفاده می‌کنیم.

تابع area به فیلدهای width و height در نمونه Rectangle دسترسی پیدا می‌کند (توجه داشته باشید که دسترسی به فیلدهای یک نمونه قرض‌گرفته‌شده باعث انتقال مقادیر فیلدها نمی‌شود، به همین دلیل است که اغلب قرض‌گیری ساختارها را مشاهده می‌کنید). امضای تابع area ما اکنون دقیقاً همان چیزی را می‌گوید که منظور ماست: مساحت Rectangle را با استفاده از فیلدهای width و height آن محاسبه کن. این کار نشان می‌دهد که عرض و ارتفاع به یکدیگر مرتبط هستند و نام‌های توصیفی به مقادیر می‌دهد، به جای استفاده از مقادیر ایندکس تاپل‌ها مانند 0 و 1. این یک پیروزی برای شفافیت است.

افزودن قابلیت‌های مفید با Traits مشتق‌شده

زمانی که در حال اشکال‌زدایی برنامه خود هستیم، مفید است که بتوانیم نمونه‌ای از Rectangle را چاپ کرده و مقادیر تمام فیلدهای آن را ببینیم. لیست ۵-۱۱ تلاش می‌کند با استفاده از ماکروی println! که در فصل‌های قبلی استفاده کرده‌ایم، این کار را انجام دهد. با این حال، این کار موفق نخواهد بود.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1}");
}
Listing 5-11: تلاش برای چاپ یک نمونه از Rectangle

وقتی این کد را کامپایل می‌کنیم، با خطایی مواجه می‌شویم که پیام اصلی آن به این صورت است:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

ماکروی println! می‌تواند بسیاری از انواع فرمت‌بندی را انجام دهد، و به صورت پیش‌فرض، آکولادها به println! می‌گویند که از فرمت‌بندی‌ای که به نام Display شناخته می‌شود استفاده کند: خروجی‌ای که برای مصرف مستقیم کاربر نهایی در نظر گرفته شده است. انواع ابتدایی که تاکنون دیده‌ایم به صورت پیش‌فرض ویژگی Display را پیاده‌سازی می‌کنند زیرا تنها یک روش برای نمایش یک مقدار مانند 1 یا هر نوع ابتدایی دیگری به کاربر وجود دارد. اما با ساختارها، روش فرمت‌بندی خروجی کمتر واضح است زیرا امکانات بیشتری برای نمایش وجود دارد: آیا می‌خواهید از ویرگول استفاده شود یا خیر؟ آیا می‌خواهید آکولادها چاپ شوند؟ آیا تمام فیلدها باید نشان داده شوند؟ به دلیل این ابهام، Rust سعی نمی‌کند حدس بزند که ما چه می‌خواهیم، و ساختارها پیاده‌سازی‌ای برای Display ندارند که بتوان با println! و جایگزین {} استفاده کرد.

اگر به خواندن خطاها ادامه دهیم، به این یادداشت مفید خواهیم رسید:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

بیایید آن را امتحان کنیم! اکنون فراخوانی ماکروی println! به صورت println!("rect1 is {rect1:?}"); خواهد بود. قرار دادن مشخص‌کننده :? داخل آکولادها به println! می‌گوید که می‌خواهیم از یک فرمت خروجی به نام Debug استفاده کنیم. ویژگی Debug به ما اجازه می‌دهد تا ساختار خود را به روشی که برای توسعه‌دهندگان مفید است چاپ کنیم تا مقدار آن را هنگام اشکال‌زدایی کد خود ببینیم.

کد را با این تغییر کامپایل کنید. خب، باز هم یک خطا دریافت می‌کنیم:

error[E0277]: `Rectangle` doesn't implement `Debug`

اما باز هم کامپایلر یادداشتی مفید به ما می‌دهد:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

Rust در واقع قابلیت چاپ اطلاعات اشکال‌زدایی را دارد، اما باید به صورت صریح این قابلیت را برای ساختار خود فعال کنیم. برای انجام این کار، ویژگی بیرونی #[derive(Debug)] را دقیقاً قبل از تعریف ساختار اضافه می‌کنیم، همان‌طور که در لیست ۵-۱۲ نشان داده شده است.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1:?}");
}
Listing 5-12: افزودن ویژگی برای مشتق کردن Debug و چاپ نمونه Rectangle با استفاده از فرمت اشکال‌زدایی

اکنون وقتی برنامه را اجرا می‌کنیم، هیچ خطایی دریافت نخواهیم کرد و خروجی زیر را خواهیم دید:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

عالی! این خروجی ممکن است زیباترین نباشد، اما مقادیر تمام فیلدها را برای این نمونه نشان می‌دهد که قطعاً در هنگام اشکال‌زدایی کمک می‌کند. زمانی که ساختارهای بزرگ‌تری داریم، مفید است که خروجی کمی آسان‌تر خوانده شود؛ در چنین مواردی می‌توانیم به جای {:?} از {:#?} در رشته println! استفاده کنیم. در این مثال، استفاده از سبک {:#?} خروجی زیر را ایجاد خواهد کرد:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

روش دیگر برای چاپ مقدار با استفاده از فرمت Debug، استفاده از ماکروی dbg! است که مالکیت یک عبارت را می‌گیرد (برخلاف println!، که ارجاع می‌گیرد)، فایل و شماره خطی که فراخوانی dbg! در آن اتفاق می‌افتد همراه با مقدار حاصل از آن عبارت را چاپ می‌کند و مالکیت مقدار را بازمی‌گرداند.

Here is the continuation of the translation for “ch05-02-example-structs.md” into Persian:

در اینجا مثالی آورده شده است که در آن ما به مقدار اختصاص داده شده به فیلد width و همچنین مقدار کل ساختار در rect1 علاقه‌مند هستیم:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

ما می‌توانیم dbg! را در اطراف عبارت 30 * scale قرار دهیم و چون dbg! مالکیت مقدار عبارت را بازمی‌گرداند، فیلد width همان مقداری را خواهد داشت که اگر فراخوانی dbg! در آنجا وجود نداشت. ما نمی‌خواهیم dbg! مالکیت rect1 را بگیرد، بنابراین از یک ارجاع به rect1 در فراخوانی بعدی استفاده می‌کنیم. در اینجا خروجی این مثال آورده شده است:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

می‌توانیم ببینیم که اولین بخش خروجی از خط ۱۰ در src/main.rs آمده است، جایی که ما در حال اشکال‌زدایی عبارت 30 * scale هستیم، و مقدار حاصل آن 60 است (فرمت‌بندی Debug که برای اعداد صحیح پیاده‌سازی شده است فقط مقدار آن‌ها را چاپ می‌کند). فراخوانی dbg! در خط ۱۴ از src/main.rs مقدار &rect1 را چاپ می‌کند که ساختار Rectangle است. این خروجی از فرمت‌بندی زیبا و مفید Debug برای نوع Rectangle استفاده می‌کند. ماکروی dbg! می‌تواند در هنگام تلاش برای درک رفتار کدتان بسیار مفید باشد!

علاوه بر ویژگی Debug، Rust تعدادی ویژگی برای ما فراهم کرده است که می‌توانیم با استفاده از ویژگی derive آن‌ها را به نوع‌های سفارشی خود اضافه کنیم و رفتار مفیدی ارائه دهند. این ویژگی‌ها و رفتار آن‌ها در ضمیمه ج فهرست شده‌اند. ما در فصل ۱۰ به نحوه پیاده‌سازی این ویژگی‌ها با رفتار سفارشی و همچنین نحوه ایجاد ویژگی‌های خود می‌پردازیم. همچنین بسیاری از ویژگی‌های دیگر به غیر از derive وجود دارند؛ برای اطلاعات بیشتر، به بخش «ویژگی‌ها» در مرجع Rust مراجعه کنید.

تابع area ما بسیار خاص است: فقط مساحت مستطیل‌ها را محاسبه می‌کند. مفید خواهد بود اگر این رفتار را به صورت نزدیک‌تر با ساختار Rectangle مرتبط کنیم، زیرا این تابع با هیچ نوع دیگری کار نخواهد کرد. بیایید ببینیم که چگونه می‌توانیم با تبدیل تابع area به یک متد که برای نوع Rectangle تعریف شده است، این کد را بازنویسی کنیم.

متد

متدها شبیه به توابع هستند: آن‌ها را با کلیدواژه‌ی fn و یک نام تعریف می‌کنیم،
می‌توانند پارامتر و مقدار بازگشتی داشته باشند، و شامل مقداری کد هستند
که هنگام فراخوانی متد از جایی دیگر اجرا می‌شود. برخلاف توابع،
متدها در بستر یک struct (یا یک enum یا یک trait object که به ترتیب در فصل ۶ و فصل ۱۸ بررسی می‌شوند) تعریف می‌شوند،
و اولین پارامتر آن‌ها همیشه self است، که نشان‌دهنده‌ی نمونه‌ای از struct است
که متد روی آن فراخوانی شده است.

تعریف متدها

بیایید تابع area که یک نمونه از Rectangle را به عنوان پارامتر می‌گیرد، تغییر دهیم و به جای آن، یک متد area تعریف کنیم که روی ساختار Rectangle تعریف شده است، همان‌طور که در لیست ۵-۱۳ نشان داده شده است.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}
Listing 5-13: تعریف یک متد area روی ساختار Rectangle

برای تعریف تابع در زمینه Rectangle، یک بلوک impl (پیاده‌سازی) برای Rectangle شروع می‌کنیم. هر چیزی در این بلوک impl با نوع Rectangle مرتبط خواهد بود. سپس، تابع area را به درون آکولادهای impl منتقل کرده و اولین (و در اینجا تنها) پارامتر آن را در امضا و در هر جایی در بدنه به self تغییر می‌دهیم. در main، جایی که تابع area را فراخوانی می‌کردیم و rect1 را به عنوان آرگومان ارسال می‌کردیم، اکنون می‌توانیم از نحو متد برای فراخوانی متد area روی نمونه Rectangle خود استفاده کنیم. نحو متد بعد از یک نمونه قرار می‌گیرد: نقطه‌ای اضافه می‌کنیم و به دنبال آن نام متد، پرانتزها و هر آرگومان دیگری قرار می‌دهیم.

در امضای area، از &self به جای rectangle: &Rectangle استفاده می‌کنیم. &self در واقع معادل کوتاه‌شده‌ای از self: &Self است. درون یک بلوک impl، نوع Self نام مستعاری برای نوعی است که بلوک impl برای آن تعریف شده است. متدها باید به عنوان پارامتر اول خود یک پارامتری به نام self از نوع Self داشته باشند، بنابراین Rust به شما اجازه می‌دهد این عبارت را با فقط نوشتن self در محل اولین پارامتر کوتاه کنید. توجه داشته باشید که همچنان باید از & در مقابل اختصار self استفاده کنیم تا نشان دهیم که این متد نمونه Self را قرض می‌گیرد، دقیقاً همان‌طور که در rectangle: &Rectangle استفاده می‌کردیم. متدها می‌توانند مالکیت self را بگیرند، self را به صورت غیرقابل تغییر قرض بگیرند، همان‌طور که در اینجا انجام داده‌ایم، یا self را به صورت قابل تغییر قرض بگیرند، دقیقاً مانند هر پارامتر دیگری.

ما در اینجا &self را انتخاب کرده‌ایم به همان دلیلی که در نسخه تابع از &Rectangle استفاده کردیم: ما نمی‌خواهیم مالکیت را بگیریم و فقط می‌خواهیم داده‌ها را در ساختار بخوانیم، نه اینکه آن‌ها را تغییر دهیم. اگر بخواهیم نمونه‌ای که متد روی آن فراخوانی شده است را به عنوان بخشی از کاری که متد انجام می‌دهد تغییر دهیم، به عنوان پارامتر اول از &mut self استفاده می‌کنیم. داشتن متدی که مالکیت نمونه را می‌گیرد با استفاده از فقط self به عنوان پارامتر اول به ندرت اتفاق می‌افتد؛ این تکنیک معمولاً زمانی استفاده می‌شود که متد self را به چیز دیگری تبدیل کند و شما بخواهید از استفاده از نمونه اصلی پس از تبدیل جلوگیری کنید.

دلیل اصلی استفاده از متدها به جای توابع، علاوه بر ارائه نحو متد و عدم نیاز به تکرار نوع self در امضای هر متد، سازمان‌دهی است. ما تمام کارهایی که می‌توانیم با یک نمونه از یک نوع انجام دهیم را در یک بلوک impl قرار داده‌ایم، به جای اینکه کاربران آینده کد ما به دنبال قابلیت‌های Rectangle در مکان‌های مختلف در کتابخانه‌ای که ارائه می‌دهیم بگردند.

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

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}

Here is the continuation of the translation for “ch05-03-method-syntax.md” into Persian:

در اینجا ما تصمیم گرفته‌ایم متد width را طوری تعریف کنیم که اگر مقدار در فیلد width نمونه بزرگ‌تر از 0 باشد مقدار true و در غیر این صورت مقدار false برگرداند: ما می‌توانیم از یک فیلد درون یک متد با همان نام برای هر منظوری استفاده کنیم. در main، وقتی که ما rect1.width را با پرانتز دنبال می‌کنیم، Rust می‌داند که منظور ما متد width است. وقتی از پرانتز استفاده نمی‌کنیم، Rust می‌داند که منظور ما فیلد width است.

اغلب، اما نه همیشه، زمانی که به یک متد نامی مشابه یک فیلد می‌دهیم، می‌خواهیم که این متد تنها مقدار موجود در فیلد را بازگرداند و هیچ کار دیگری انجام ندهد. متدهایی مانند این‌ها getter نامیده می‌شوند، و Rust آن‌ها را به صورت خودکار برای فیلدهای ساختار پیاده‌سازی نمی‌کند، همان‌طور که برخی از زبان‌های دیگر انجام می‌دهند. Getterها مفید هستند زیرا می‌توانید فیلد را خصوصی کنید اما متد را عمومی کنید و به این ترتیب دسترسی فقط-خواندنی به آن فیلد را به عنوان بخشی از API عمومی نوع فعال کنید. ما در فصل ۷ در مورد عمومی و خصوصی بودن و چگونگی تعیین عمومی یا خصوصی بودن یک فیلد یا متد بحث خواهیم کرد.

کجاست عملگر ->؟

در C و C++، دو عملگر مختلف برای فراخوانی متدها استفاده می‌شود: شما از . استفاده می‌کنید اگر متد را روی خود شیء فراخوانی می‌کنید و از -> اگر متد را روی یک اشاره‌گر (Pointer) به شیء فراخوانی می‌کنید و نیاز دارید ابتدا اشاره‌گر (Pointer) را اشاره‌برداری کنید. به عبارت دیگر، اگر object یک اشاره‌گر (Pointer) باشد، object->something() شبیه به (*object).something() است.

Rust معادل عملگر -> را ندارد؛ به جای آن، Rust یک ویژگی به نام ارجاع‌دهی و اشاره‌برداری خودکار دارد. فراخوانی متدها یکی از معدود مکان‌هایی در Rust است که این رفتار را دارد.

این‌گونه کار می‌کند: وقتی یک متد را با object.something() فراخوانی می‌کنید، Rust به طور خودکار &، &mut یا * را اضافه می‌کند تا object با امضای متد مطابقت داشته باشد. به عبارت دیگر، موارد زیر یکسان هستند:

#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

اولین مورد خیلی تمیزتر به نظر می‌رسد. این رفتار ارجاع‌دهی خودکار کار می‌کند زیرا متدها یک گیرنده واضح دارند—نوع self. با توجه به گیرنده و نام یک متد، Rust می‌تواند به طور قطعی تعیین کند که آیا متد در حال خواندن (&self)، تغییر (&mut self) یا مصرف (self) است. این واقعیت که Rust قرض‌گیری را برای گیرنده‌های متد ضمنی می‌کند، بخش بزرگی از راحتی کار با مالکیت در عمل است.

متدهایی با پارامترهای بیشتر

بیایید با تعریف یک متد دیگر روی ساختار Rectangle تمرین کنیم. این بار می‌خواهیم یک نمونه از Rectangle نمونه دیگری از Rectangle را بگیرد و مقدار true برگرداند اگر Rectangle دوم کاملاً در self (اولین Rectangle) جای گیرد؛ در غیر این صورت مقدار false برگرداند. به عبارت دیگر، پس از تعریف متد can_hold، می‌خواهیم بتوانیم برنامه‌ای بنویسیم که در لیست ۵-۱۴ نشان داده شده است.

Filename: src/main.rs
fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-14: استفاده از متد can_hold که هنوز نوشته نشده است

خروجی مورد انتظار به صورت زیر خواهد بود زیرا هر دو بُعد rect2 کوچکتر از ابعاد rect1 هستند، اما rect3 از rect1 عریض‌تر است:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

ما می‌دانیم که می‌خواهیم یک متد تعریف کنیم، بنابراین این متد در بلوک impl Rectangle خواهد بود. نام متد can_hold خواهد بود و یک قرض غیرقابل تغییر از یک Rectangle دیگر به عنوان پارامتر خواهد گرفت. می‌توانیم نوع پارامتر را با نگاه به کدی که متد را فراخوانی می‌کند تشخیص دهیم: rect1.can_hold(&rect2) مقدار &rect2 را ارسال می‌کند، که یک قرض غیرقابل تغییر به rect2، یک نمونه از Rectangle است. این منطقی است زیرا ما فقط نیاز به خواندن rect2 داریم (نه نوشتن، که به یک قرض قابل تغییر نیاز داشت) و می‌خواهیم مالکیت rect2 در main باقی بماند تا بتوانیم پس از فراخوانی متد can_hold دوباره از آن استفاده کنیم. مقدار بازگشتی can_hold یک مقدار بولی خواهد بود و پیاده‌سازی بررسی می‌کند که آیا عرض و ارتفاع self به ترتیب بزرگ‌تر از عرض و ارتفاع Rectangle دیگر هستند. بیایید متد جدید can_hold را به بلوک impl از لیست ۵-۱۳ اضافه کنیم، همان‌طور که در لیست ۵-۱۵ نشان داده شده است.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-15: پیاده‌سازی متد can_hold روی Rectangle که یک نمونه دیگر از Rectangle را به عنوان پارامتر می‌گیرد

Here is the continuation of the translation for “ch05-03-method-syntax.md” into Persian:

وقتی این کد را با تابع main موجود در لیست ۵-۱۴ اجرا می‌کنیم، خروجی دلخواه را دریافت خواهیم کرد. متدها می‌توانند چندین پارامتر بگیرند که ما آن‌ها را پس از پارامتر self به امضا اضافه می‌کنیم، و این پارامترها همانند پارامترهای توابع عمل می‌کنند.

توابع مرتبط

تمام توابعی که در یک بلوک impl تعریف شده‌اند توابع مرتبط نامیده می‌شوند، زیرا با نوعی که بعد از impl نام‌گذاری شده است، مرتبط هستند. ما می‌توانیم توابع مرتبطی را تعریف کنیم که self را به عنوان اولین پارامتر خود ندارند (و بنابراین متد نیستند) زیرا نیازی به کار با یک نمونه از نوع ندارند. ما قبلاً از یک تابع مشابه استفاده کرده‌ایم: تابع String::from که روی نوع String تعریف شده است.

توابع مرتبطی که متد نیستند اغلب برای سازنده‌ها استفاده می‌شوند که نمونه جدیدی از ساختار را بازمی‌گردانند. این توابع معمولاً new نامیده می‌شوند، اما new یک نام خاص نیست و در زبان به صورت داخلی تعریف نشده است. برای مثال، ما می‌توانیم تصمیم بگیریم تابع مرتبطی به نام square ارائه دهیم که یک پارامتر برای ابعاد بگیرد و از آن به عنوان عرض و ارتفاع استفاده کند، بنابراین ایجاد یک Rectangle مربعی را آسان‌تر می‌کند به جای اینکه مجبور باشیم مقدار یکسان را دو بار مشخص کنیم:

Filename: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

کلمات کلیدی Self در نوع بازگشتی و در بدنه تابع، نام مستعاری برای نوعی هستند که بعد از کلمه کلیدی impl ظاهر می‌شود، که در اینجا Rectangle است.

برای فراخوانی این تابع مرتبط، از نحو :: همراه با نام ساختار استفاده می‌کنیم؛ برای مثال: let sq = Rectangle::square(3);. این تابع با ساختار فضای نام‌گذاری شده است: نحو :: برای توابع مرتبط و فضای نام‌های ایجاد شده توسط ماژول‌ها استفاده می‌شود. ما ماژول‌ها را در فصل ۷ بررسی خواهیم کرد.

بلوک‌های متعدد impl

هر ساختار اجازه دارد چندین بلوک impl داشته باشد. برای مثال، لیست ۵-۱۵ معادل کدی است که در لیست ۵-۱۶ نشان داده شده است، که هر متد در بلوک impl خود قرار دارد.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-16: بازنویسی لیست ۵-۱۵ با استفاده از بلوک‌های متعدد impl

هیچ دلیلی برای جدا کردن این متدها به بلوک‌های متعدد impl در اینجا وجود ندارد، اما این یک نحو معتبر است. ما در فصل ۱۰ موردی را خواهیم دید که در آن بلوک‌های متعدد impl مفید هستند، جایی که ما نوع‌های عمومی و ویژگی‌ها را بررسی خواهیم کرد.

خلاصه

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

اما ساختارها تنها راه ایجاد نوع‌های سفارشی نیستند: بیایید به ویژگی Enum در Rust بپردازیم تا ابزار دیگری به جعبه ابزار شما اضافه کنیم.

شمارنده‌ها و تطابق الگو

در این فصل، به شمارنده‌ها که همچنین به عنوان enums شناخته می‌شوند، می‌پردازیم. شمارنده‌ها به شما اجازه می‌دهند یک نوع را با شمردن مقادیر ممکن آن تعریف کنید. ابتدا، یک شمارنده تعریف کرده و از آن استفاده می‌کنیم تا نشان دهیم چگونه شمارنده می‌تواند معنی را همراه با داده کدگذاری کند. سپس، به شمارنده‌ای بسیار مفید به نام Option خواهیم پرداخت که بیان می‌کند یک مقدار می‌تواند چیزی باشد یا هیچ چیز. بعد، بررسی خواهیم کرد که چگونه تطابق الگو در عبارت match باعث می‌شود اجرای کد مختلف برای مقادیر مختلف یک شمارنده آسان شود. در نهایت، پوشش خواهیم داد که ساختار if let چگونه ایده‌آل و مختصر برای مدیریت شمارنده‌ها در کد شما است.

تعریف یک Enum

در حالی که ساختارها (Structs) روشی برای گروه‌بندی فیلدها و داده‌های مرتبط فراهم می‌کنند، Enumها به شما امکان می‌دهند که بگویید یک مقدار یکی از مجموعه مقادیر ممکن است. به عنوان مثال، ممکن است بخواهیم بگوییم که Rectangle یکی از مجموعه اشکالی است که همچنین شامل Circle و Triangle می‌شود. برای انجام این کار، زبان Rust به ما اجازه می‌دهد تا این امکان‌ها را به‌عنوان یک Enum کدگذاری کنیم.

بیایید نگاهی به یک موقعیت بیندازیم که ممکن است بخواهیم در کد بیان کنیم و ببینیم چرا Enumها مفیدتر و مناسب‌تر از Structها هستند. فرض کنید باید با آدرس‌های IP کار کنیم. در حال حاضر، دو استاندارد اصلی برای آدرس‌های IP وجود دارد: نسخه چهار و نسخه شش. از آنجا که این تنها حالت‌های ممکن برای آدرس‌های IP هستند که برنامه ما با آن‌ها مواجه خواهد شد، می‌توانیم تمام حالت‌های ممکن را شمارش کنیم، که همین موضوع اساس نام‌گذاری Enumها است.

هر آدرس IP می‌تواند یا نسخه چهار یا نسخه شش باشد، اما نمی‌تواند به‌طور همزمان هر دو باشد. این ویژگی آدرس‌های IP استفاده از ساختار داده Enum را مناسب می‌کند زیرا مقدار یک Enum می‌تواند فقط یکی از حالت‌هایش باشد. هر دو آدرس نسخه چهار و نسخه شش همچنان اساساً آدرس IP هستند، بنابراین باید هنگام کار با کدی که به هر نوع آدرس IP اعمال می‌شود، به‌عنوان یک نوع یکسان رفتار شوند.

ما می‌توانیم این مفهوم را در کد با تعریف یک Enumeration به نام IpAddrKind و فهرست کردن انواع ممکن یک آدرس IP، یعنی V4 و V6، بیان کنیم. این‌ها حالت‌های Enum هستند:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

اکنون IpAddrKind یک نوع داده سفارشی است که می‌توانیم در قسمت‌های دیگر کد خود استفاده کنیم.

مقادیر Enum

می‌توانیم نمونه‌هایی از هر یک از دو حالت IpAddrKind را به این صورت ایجاد کنیم:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

توجه داشته باشید که حالت‌های Enum تحت شناسه آن نام‌گذاری شده‌اند و برای جدا کردن دو حالت از یکدیگر از دو نقطه استفاده می‌کنیم. این ویژگی مفید است زیرا اکنون هر دو مقدار IpAddrKind::V4 و IpAddrKind::V6 از نوع یکسان IpAddrKind هستند. سپس می‌توانیم به عنوان مثال یک تابع تعریف کنیم که هر نوع IpAddrKind را به عنوان ورودی بپذیرد:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

و می‌توانیم این تابع را با هر دو حالت فراخوانی کنیم:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

استفاده از Enumها مزایای بیشتری دارد. اگر بیشتر به نوع آدرس IP خود فکر کنیم، متوجه می‌شویم که در حال حاضر راهی برای ذخیره داده‌های واقعی آدرس IP نداریم؛ فقط می‌دانیم که چه نوعی است. با توجه به اینکه به‌تازگی درباره Structها در فصل 5 یاد گرفته‌اید، ممکن است وسوسه شوید این مشکل را با Structها همانطور که در فهرست 6-1 نشان داده شده است، حل کنید.

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}
Listing 6-1: ذخیره داده‌ها و حالت IpAddrKind یک آدرس IP با استفاده از یک struct

در اینجا ما یک Struct به نام IpAddr تعریف کرده‌ایم که دو فیلد دارد: یک فیلد kind که از نوع IpAddrKind است (همان Enum که قبلاً تعریف کردیم) و یک فیلد address از نوع String. ما دو نمونه از این Struct داریم. اولین مورد home نام دارد و مقدار IpAddrKind::V4 به‌عنوان kind با داده‌های آدرس مرتبط 127.0.0.1 دارد. نمونه دوم loopback نام دارد. این نمونه حالت دیگر Enum یعنی V6 را به‌عنوان مقدار kind دارد و آدرس مرتبط ::1 است. ما از یک Struct برای بسته‌بندی مقادیر kind و address با هم استفاده کرده‌ایم، بنابراین اکنون حالت با مقدار مرتبط شده است.

با این حال، نمایش همان مفهوم با استفاده از فقط یک Enum مختصرتر است: به‌جای استفاده از Enum داخل یک Struct، می‌توانیم داده‌ها را مستقیماً به هر حالت Enum متصل کنیم. این تعریف جدید Enum IpAddr نشان می‌دهد که هر دو حالت V4 و V6 مقادیر String مرتبط دارند:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

ما داده‌ها را مستقیماً به هر حالت Enum متصل کرده‌ایم، بنابراین نیازی به یک Struct اضافی نیست. در اینجا همچنین می‌توان جزئیات دیگری از نحوه عملکرد Enumها را مشاهده کرد: نام هر حالت Enum که تعریف می‌کنیم به‌صورت یک تابع تبدیل می‌شود که نمونه‌ای از Enum ایجاد می‌کند. یعنی IpAddr::V4() یک فراخوانی تابع است که یک آرگومان از نوع String می‌گیرد و نمونه‌ای از نوع IpAddr برمی‌گرداند. این تابع سازنده به‌طور خودکار به‌عنوان نتیجه تعریف Enum تعریف می‌شود.

یک مزیت دیگر استفاده از Enum به‌جای Struct این است که هر حالت می‌تواند انواع و مقادیر داده مرتبط متفاوتی داشته باشد. آدرس‌های IP نسخه چهار همیشه چهار مؤلفه عددی خواهند داشت که مقادیرشان بین 0 و 255 است. اگر بخواهیم آدرس‌های V4 را به‌صورت چهار مقدار u8 ذخیره کنیم اما همچنان آدرس‌های V6 را به‌صورت یک مقدار String بیان کنیم، با یک Struct نمی‌توانیم این کار را انجام دهیم. Enumها به‌راحتی این حالت را مدیریت می‌کنند:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

ما چندین روش مختلف برای تعریف ساختارهای داده برای ذخیره آدرس‌های IP نسخه چهار و نسخه شش نشان داده‌ایم. با این حال، همان‌طور که مشخص است، ذخیره آدرس‌های IP و کدگذاری نوع آن‌ها به‌قدری رایج است که کتابخانه استاندارد تعریفی برای این کار ارائه می‌دهد! بیایید نگاهی به نحوه تعریف IpAddr در کتابخانه استاندارد بیندازیم: این کتابخانه دارای همان Enum و حالت‌هایی است که ما تعریف کرده و استفاده کرده‌ایم، اما داده‌های آدرس را به‌صورت داخلی در حالت‌ها در قالب دو Struct مختلف تعبیه کرده است، که به‌طور متفاوت برای هر حالت تعریف شده‌اند:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

این کد نشان می‌دهد که شما می‌توانید هر نوع داده‌ای مانند رشته‌ها، انواع عددی، یا Structها را داخل حالت‌های Enum قرار دهید. حتی می‌توانید یک Enum دیگر را نیز شامل کنید! همچنین، انواع استاندارد کتابخانه معمولاً خیلی پیچیده‌تر از چیزی نیستند که ممکن است خودتان ارائه دهید.

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

بیایید به مثال دیگری از یک Enum در فهرست 6-2 نگاه کنیم: این مورد دارای انواع متنوعی از داده‌های جاسازی‌شده در حالت‌های خود است.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}
Listing 6-2: یک Enum به نام Message که هر یک از حالت‌های آن مقادیر متفاوتی ذخیره می‌کنند

این Enum دارای چهار حالت با انواع مختلف است:

  • Quit: هیچ داده‌ای همراه خود ندارد
  • Move: دارای فیلدهای نام‌گذاری‌شده است، مانند یک struct
  • Write: شامل یک String واحد است
  • ChangeColor: شامل سه مقدار از نوع i32 است

تعریف یک Enum با حالت‌هایی مانند حالت‌های فهرست 6-2 مشابه تعریف انواع مختلف ساختارها است، با این تفاوت که Enum از کلمه کلیدی struct استفاده نمی‌کند و تمام حالت‌ها تحت نوع Message گروه‌بندی شده‌اند. ساختارهای زیر می‌توانند همان داده‌هایی را نگه دارند که حالت‌های Enum قبلی نگه می‌دارند:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

اما اگر از ساختارهای مختلفی استفاده کنیم که هر یک نوع خاص خود را دارند، نمی‌توانیم به‌راحتی یک تابع تعریف کنیم که بتواند هر یک از این انواع پیام‌ها را مانند چیزی که با Enum Message تعریف‌شده در فهرست 6-2 امکان‌پذیر است، دریافت کند.

یک شباهت دیگر بین Enumها و ساختارها این است: همان‌طور که می‌توانیم متدها را با استفاده از impl برای ساختارها تعریف کنیم، می‌توانیم متدها را برای Enumها نیز تعریف کنیم. اینجا یک متد به نام call است که می‌توانیم برای Enum Message خود تعریف کنیم:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

بدنه این متد از self برای دسترسی به مقداری که متد روی آن فراخوانی شده است استفاده می‌کند. در این مثال، ما یک متغیر به نام m ایجاد کرده‌ایم که مقدار Message::Write(String::from("hello")) را دارد و این همان چیزی است که self در بدن متد call هنگام اجرای m.call() خواهد بود.

بیایید به یک Enum دیگر در کتابخانه استاندارد که بسیار متداول و مفید است نگاهی بیندازیم: Option.

Enum Option و مزایای آن نسبت به مقادیر Null

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

به عنوان مثال، اگر اولین مورد را در یک لیست غیر خالی درخواست کنید، مقداری دریافت خواهید کرد. اگر اولین مورد را در یک لیست خالی درخواست کنید، هیچ مقداری دریافت نخواهید کرد. بیان این مفهوم در قالب سیستم نوع به کامپایلر امکان می‌دهد تا بررسی کند آیا تمام مواردی که باید مدیریت شوند را در نظر گرفته‌اید؛ این ویژگی می‌تواند از بروز باگ‌هایی که در دیگر زبان‌های برنامه‌نویسی بسیار رایج هستند جلوگیری کند.

طراحی زبان‌های برنامه‌نویسی اغلب از نظر ویژگی‌هایی که شامل می‌شوند بررسی می‌شود، اما ویژگی‌هایی که کنار گذاشته می‌شوند نیز مهم هستند. Rust ویژگی null را که بسیاری از زبان‌های دیگر دارند، ندارد. Null یک مقدار است که به معنای وجود نداشتن مقدار می‌باشد. در زبان‌هایی که دارای null هستند، متغیرها می‌توانند همیشه در یکی از دو حالت باشند: null یا not-null.

در ارائه‌ی سال ۲۰۰۹ خود با عنوان «ارجاعات تهی: اشتباه میلیارد دلاری»(Null References: The Billion Dollar Mistake,”)، تونی هوار، مخترع null، این سخن را بیان کرد:

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

مشکل مقادیر null این است که اگر بخواهید از یک مقدار null به‌عنوان یک مقدار not-null استفاده کنید، نوعی خطا دریافت خواهید کرد. از آنجا که خاصیت null یا not-null فراگیر است، بسیار آسان است که این نوع خطا را مرتکب شوید.

با این حال، مفهومی که null سعی در بیان آن دارد همچنان مفید است: null یک مقدار است که در حال حاضر به دلایلی نامعتبر یا غایب است.

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

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

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

سینتکس <T> یک ویژگی از Rust است که هنوز درباره آن صحبت نکرده‌ایم. این یک پارامتر نوع عمومی (Generic) است و ما در فصل 10 به جزئیات بیشتری درباره آن خواهیم پرداخت. برای حالا، تنها چیزی که باید بدانید این است که <T> به این معنا است که حالت Some از Enum Option می‌تواند یک قطعه داده از هر نوعی را نگه دارد، و هر نوع مشخصی که به جای T استفاده شود، کل نوع Option<T> را به یک نوع متفاوت تبدیل می‌کند. در اینجا چند مثال از استفاده از مقادیر Option برای نگه‌داری انواع عددی و کاراکتری آورده شده است:

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

نوع some_number برابر با Option<i32> است. نوع some_char برابر با Option<char> است که یک نوع متفاوت است. Rust می‌تواند این انواع را تشخیص دهد زیرا ما مقداری را در حالت Some مشخص کرده‌ایم. برای absent_number، Rust از ما می‌خواهد که نوع کلی Option را مشخص کنیم: کامپایلر نمی‌تواند نوعی را که حالت Some مرتبط نگه خواهد داشت فقط با نگاه کردن به یک مقدار None تشخیص دهد. در اینجا، ما به Rust می‌گوییم که منظور ما این است که absent_number از نوع Option<i32> باشد.

هنگامی که ما یک مقدار Some داریم، می‌دانیم که یک مقدار وجود دارد و این مقدار درون Some نگه‌داری می‌شود. هنگامی که ما یک مقدار None داریم، از یک نظر، این همان معنای null را دارد: ما یک مقدار معتبر نداریم. پس چرا داشتن Option<T> بهتر از داشتن null است؟

به طور خلاصه، به این دلیل که Option<T> و T (جایی که T می‌تواند هر نوعی باشد) انواع متفاوتی هستند، کامپایلر به ما اجازه نمی‌دهد که یک مقدار Option<T> را به‌عنوان یک مقدار قطعاً معتبر استفاده کنیم. به عنوان مثال، این کد کامپایل نخواهد شد، زیرا سعی در جمع یک i8 با یک Option<i8> دارد:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

اگر این کد را اجرا کنیم، پیام خطایی شبیه به این دریافت می‌کنیم:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

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

شدید است! در واقع، این پیام خطا به این معنا است که Rust نمی‌داند چگونه یک i8 و یک Option<i8> را جمع کند، زیرا آن‌ها انواع مختلفی هستند. هنگامی که یک مقدار از نوعی مانند i8 در Rust داریم، کامپایلر اطمینان می‌دهد که همیشه یک مقدار معتبر داریم. می‌توانیم با اطمینان ادامه دهیم بدون اینکه مجبور باشیم قبل از استفاده از آن مقدار، null را بررسی کنیم. فقط زمانی که یک Option<i8> (یا هر نوع مقداری که با آن کار می‌کنیم) داریم باید نگران احتمال عدم وجود مقدار باشیم، و کامپایلر اطمینان می‌دهد که ما آن حالت را قبل از استفاده از مقدار مدیریت کرده‌ایم.

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

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

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

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

سازه جریان کنترلی match

زبان Rust دارای یک سازه جریان کنترلی بسیار قدرتمند به نام match است که به شما اجازه می‌دهد تا یک مقدار را با یک سری الگوها مقایسه کنید و سپس بر اساس الگویی که مطابقت دارد، کد مربوطه را اجرا کنید. الگوها می‌توانند شامل مقادیر ثابت، نام متغیرها، wildcardها و چیزهای دیگر باشند. فصل 19 انواع مختلف الگوها و عملکرد آن‌ها را پوشش می‌دهد. قدرت match از بیان‌پذیری الگوها و این واقعیت ناشی می‌شود که کامپایلر تأیید می‌کند که همه حالت‌های ممکن مدیریت شده‌اند.

می‌توانید یک عبارت match را مانند یک دستگاه مرتب‌کننده سکه تصور کنید: سکه‌ها در یک مسیر با سوراخ‌هایی با اندازه‌های مختلف قرار می‌گیرند و هر سکه از اولین سوراخی که در آن جا می‌شود عبور می‌کند. به همین ترتیب، مقادیر از هر الگو در یک match عبور می‌کنند و در اولین الگویی که مقدار “جا می‌شود”، مقدار به بلوک کد مرتبط می‌افتد و برای اجرا استفاده می‌شود.

حال بیایید از یک مثال واقعی با سکه‌ها استفاده کنیم! می‌توانیم تابعی بنویسیم که یک سکه ناشناخته از ایالات متحده را بگیرد و به شیوه‌ای مشابه دستگاه شمارنده سکه‌ها، تعیین کند که آن سکه کدام نوع است و ارزش آن را به سنت برگرداند، همانطور که در فهرست 6-3 نشان داده شده است.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}
Listing 6-3: یک enum و یک عبارت match که حالت‌های enum را به عنوان الگوهای خود دارد

بازبینی تابع value_in_cents

ابتدا کلمه کلیدی match و سپس یک عبارت را فهرست می‌کنیم که در این مورد مقدار coin است. این کار بسیار مشابه یک عبارت شرطی که با if استفاده می‌شود به نظر می‌رسد، اما تفاوت بزرگی دارد: با if، شرط باید به یک مقدار بولین ارزیابی شود، اما اینجا می‌تواند هر نوعی باشد. نوع coin در این مثال enum Coin است که در اولین خط تعریف کردیم.

بازوهای match دو قسمت دارند: یک الگو و مقداری کد. اولین بازو در اینجا دارای الگویی است که مقدار Coin::Penny است و سپس اپراتور => که الگو و کد اجرایی را از هم جدا می‌کند. کد در اینجا فقط مقدار 1 است. هر بازو با یک کاما از بازوی بعدی جدا می‌شود.

هنگامی که عبارت match اجرا می‌شود، مقدار حاصل را با الگوی هر بازو به ترتیب مقایسه می‌کند. اگر الگویی با مقدار مطابقت داشته باشد، کدی که با آن الگو مرتبط است اجرا می‌شود. اگر آن الگو با مقدار مطابقت نداشته باشد، اجرا به بازوی بعدی ادامه می‌یابد، همانطور که در یک دستگاه مرتب‌کننده سکه‌ها عمل می‌کند. ما می‌توانیم به هر تعداد بازو که نیاز داریم داشته باشیم: در فهرست 6-3، match ما چهار بازو دارد.

کد مرتبط با هر بازو یک عبارت است و مقدار حاصل از عبارت در بازوی منطبق شده، مقداری است که برای کل عبارت match بازگردانده می‌شود.

معمولاً اگر کد بازوی match کوتاه باشد، از آکولاد استفاده نمی‌کنیم، همانطور که در فهرست 6-3 که هر بازو فقط یک مقدار را برمی‌گرداند. اگر بخواهید چندین خط کد را در یک بازو اجرا کنید، باید از آکولاد استفاده کنید، و در این صورت کاما پس از بازو اختیاری است. به عنوان مثال، کد زیر هر بار که متد با یک Coin::Penny فراخوانی می‌شود، “Lucky penny!” را چاپ می‌کند، اما همچنان آخرین مقدار بلوک یعنی 1 را بازمی‌گرداند:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

الگوهایی که به مقادیر متصل می‌شوند

یکی دیگر از ویژگی‌های مفید بازوهای match این است که می‌توانند به بخش‌هایی از مقادیر که با الگو مطابقت دارند متصل شوند. این همان روشی است که می‌توانیم مقادیر را از حالت‌های enum استخراج کنیم.

به عنوان مثال، بیایید یکی از حالت‌های enum خود را تغییر دهیم تا داده‌هایی را درون خود نگه دارد. از سال 1999 تا 2008، ایالات متحده ربع‌هایی با طرح‌های مختلف برای هر یک از 50 ایالت در یک طرف ضرب کرد. هیچ سکه دیگری طرح ایالتی نداشت، بنابراین فقط ربع‌ها این مقدار اضافی را دارند. می‌توانیم این اطلاعات را به enum خود با تغییر حالت Quarter به گونه‌ای که یک مقدار UsState درون آن ذخیره شود اضافه کنیم، همانطور که در فهرست 6-4 انجام دادیم.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}
Listing 6-4: یک enum به نام Coin که حالت Quarter آن همچنین یک مقدار UsState را نگه می‌دارد

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

در عبارت match برای این کد، یک متغیر به نام state به الگو اضافه می‌کنیم که مقادیری از حالت Coin::Quarter را تطبیق می‌دهد. وقتی که یک مقدار Coin::Quarter منطبق می‌شود، متغیر state به مقدار ایالت آن ربع متصل خواهد شد. سپس می‌توانیم از state در کد بازوی آن استفاده کنیم، به این صورت:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {state:?}!");
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

اگر ما value_in_cents(Coin::Quarter(UsState::Alaska)) را فراخوانی کنیم، مقدار coin برابر با Coin::Quarter(UsState::Alaska) خواهد بود. هنگامی که آن مقدار را با هر بازوی match مقایسه می‌کنیم، هیچ‌کدام از آن‌ها مطابقت ندارند تا اینکه به Coin::Quarter(state) برسیم. در این نقطه، اتصال برای state مقدار UsState::Alaska خواهد بود. سپس می‌توانیم از آن اتصال در عبارت println! استفاده کنیم و به این ترتیب مقدار داخلی ایالت را از حالت Quarter enum Coin استخراج کنیم.

تطبیق با Option<T>

در بخش قبلی، ما می‌خواستیم مقدار داخلی T را از حالت Some استخراج کنیم زمانی که از Option<T> استفاده می‌کردیم؛ همچنین می‌توانیم با استفاده از match حالت‌های Option<T> را مدیریت کنیم، همانطور که با enum Coin انجام دادیم! به جای مقایسه سکه‌ها، حالت‌های Option<T> را مقایسه می‌کنیم، اما روش کار عبارت match همان باقی می‌ماند.

بیایید فرض کنیم که می‌خواهیم تابعی بنویسیم که یک Option<i32> بگیرد و اگر یک مقدار درون آن باشد، مقدار 1 را به آن اضافه کند. اگر هیچ مقداری درون آن نباشد، تابع باید مقدار None را بازگرداند و هیچ عملیاتی را انجام ندهد.

نوشتن این تابع با استفاده از match بسیار آسان است و به این صورت خواهد بود:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

اجازه دهید اولین اجرای plus_one را با جزئیات بیشتری بررسی کنیم. وقتی که plus_one(five) را فراخوانی می‌کنیم، متغیر x در بدنه plus_one مقدار Some(5) خواهد داشت. سپس آن را با هر بازوی match مقایسه می‌کنیم:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

مقدار Some(5) با الگوی None مطابقت ندارد، بنابراین به بازوی بعدی می‌رویم:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

آیا Some(5) با Some(i) مطابقت دارد؟ بله! ما همان حالت را داریم. مقدار i به مقدار داخل Some متصل می‌شود، بنابراین i مقدار 5 می‌گیرد. سپس کد موجود در بازوی match اجرا می‌شود، بنابراین مقدار 1 به مقدار i اضافه می‌کنیم و یک مقدار جدید Some با مقدار کل 6 ایجاد می‌کنیم.

حالا اجازه دهید اجرای دوم plus_one را در فهرست 6-5 در نظر بگیریم، جایی که مقدار x برابر با None است. ما وارد match می‌شویم و آن را با اولین بازو مقایسه می‌کنیم:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

این بار مطابقت دارد! هیچ مقداری برای اضافه کردن وجود ندارد، بنابراین برنامه متوقف می‌شود و مقدار None در سمت راست => را بازمی‌گرداند. از آنجا که اولین بازو مطابقت داشت، بازوهای دیگر بررسی نمی‌شوند.

ترکیب match و enumها در بسیاری از موقعیت‌ها مفید است. این الگو را در کد Rust زیاد خواهید دید: match روی یک enum، اتصال یک متغیر به داده داخل، و سپس اجرای کد بر اساس آن. ممکن است در ابتدا کمی سخت باشد، اما وقتی به آن عادت کنید، آرزو خواهید کرد که در همه زبان‌ها وجود داشته باشد. این سازه همواره یکی از ویژگی‌های مورد علاقه کاربران است.

تطابق‌ها Exhaustive هستند

یکی دیگر از جنبه‌های عبارت match این است که الگوهای بازوها باید تمام حالت‌های ممکن را پوشش دهند. به این نسخه از تابع plus_one که یک باگ دارد و کامپایل نمی‌شود توجه کنید:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

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

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:3:15
  |
3 |         match x {
  |               ^ pattern `None` not covered
  |
note: `Option<i32>` defined here
 --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:572:1
 ::: /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:576:5
  |
  = note: not covered
  = note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
  |
4 ~             Some(i) => Some(i + 1),
5 ~             None => todo!(),
  |

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

Rust می‌داند که ما هر حالت ممکن را پوشش نداده‌ایم و حتی می‌داند که کدام الگو را فراموش کرده‌ایم! تطابق‌ها در Rust exhaustive هستند: ما باید هر حالت ممکن را مدیریت کنیم تا کد معتبر باشد. به ویژه در مورد Option<T>، وقتی که Rust از فراموش کردن مدیریت صریح حالت None جلوگیری می‌کند، از فرض نادرست وجود مقدار زمانی که ممکن است null باشد محافظت می‌کند و به این ترتیب اشتباه میلیارد دلاری که قبلاً بحث شد را غیرممکن می‌سازد.

الگوهای Catch-all و Placeholder _

با استفاده از Enumها، می‌توانیم اقدامات ویژه‌ای برای چند مقدار خاص انجام دهیم، اما برای تمام مقادیر دیگر یک عمل پیش‌فرض داشته باشیم. تصور کنید که در حال پیاده‌سازی یک بازی هستید که اگر بازیکن عدد 3 روی تاس بیاورد، حرکت نمی‌کند اما یک کلاه زیبا جدید می‌گیرد. اگر عدد 7 بیاورد، بازیکن یک کلاه زیبا از دست می‌دهد. برای تمام مقادیر دیگر، بازیکن به اندازه عدد روی تخته بازی حرکت می‌کند. در اینجا یک عبارت match آورده شده است که این منطق را پیاده‌سازی می‌کند. نتیجه‌ی پرتاب تاس به جای مقدار تصادفی، به صورت هاردکد شده قرار داده شده است، و تمام منطق دیگر با توابعی بدون بدنه نشان داده شده‌اند زیرا پیاده‌سازی آن‌ها خارج از محدوده این مثال است:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn move_player(num_spaces: u8) {}
}

برای دو بازوی اول، الگوها مقادیر ثابت 3 و 7 هستند. برای بازوی آخر که تمام مقادیر ممکن دیگر را پوشش می‌دهد، الگو یک متغیر است که ما آن را other نامیده‌ایم. کدی که برای بازوی other اجرا می‌شود، متغیر را با استفاده از تابع move_player می‌فرستد.

این کد کامپایل می‌شود، حتی اگر تمام مقادیر ممکن یک u8 را فهرست نکرده باشیم، زیرا بازوی آخر همه مقادیر ذکر نشده را تطبیق می‌دهد. این الگوی catch-all نیاز تطابق exhaustive را برآورده می‌کند. توجه داشته باشید که باید بازوی catch-all را در آخر قرار دهیم زیرا الگوها به ترتیب ارزیابی می‌شوند. اگر بازوی catch-all را زودتر قرار دهیم، بازوهای دیگر هرگز اجرا نخواهند شد، بنابراین Rust به ما هشدار می‌دهد اگر بعد از یک بازوی catch-all بازوهای دیگری اضافه کنیم!

Rust همچنین یک الگو به نام _ دارد که می‌توانیم از آن استفاده کنیم وقتی که می‌خواهیم یک catch-all داشته باشیم اما نمی‌خواهیم مقدار در الگوی catch-all را استفاده کنیم. این به Rust می‌گوید که ما قصد نداریم مقدار را استفاده کنیم، بنابراین Rust درباره یک متغیر استفاده نشده به ما هشدار نمی‌دهد.

بیایید قوانین بازی را تغییر دهیم: حالا اگر بازیکن هر چیزی به غیر از 3 یا 7 بیاورد، باید دوباره تاس بیندازد. دیگر نیازی به استفاده از مقدار catch-all نیست، بنابراین می‌توانیم کد خود را به‌جای متغیری به نام other از _ استفاده کنیم:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn reroll() {}
}

این مثال نیز نیاز تطابق exhaustive را برآورده می‌کند زیرا ما صریحاً تمام مقادیر دیگر را در بازوی آخر نادیده گرفته‌ایم و چیزی را فراموش نکرده‌ایم.

در نهایت، قوانین بازی را یک بار دیگر تغییر می‌دهیم، بنابراین اگر بازیکن هر چیزی غیر از 3 یا 7 بیاورد، هیچ کار دیگری در نوبت او انجام نمی‌شود. می‌توانیم این موضوع را با استفاده از مقدار واحد (نوع tuple خالی که قبلاً در بخش “نوع Tuple” ذکر شد) به عنوان کدی که با بازوی _ همراه است بیان کنیم:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
}

اینجا، ما به Rust صریحاً می‌گوییم که قصد نداریم هیچ مقدار دیگری را که با هیچ الگویی در بازوهای قبلی مطابقت ندارد استفاده کنیم و نمی‌خواهیم در این حالت کدی اجرا کنیم.

درباره الگوها و تطبیق آن‌ها مطالب بیشتری در فصل 19 پوشش خواهیم داد. فعلاً به سینتکس if let می‌پردازیم که می‌تواند در مواقعی که عبارت match کمی طولانی به نظر می‌رسد، مفید باشد.

جریان کنترلی مختصر با if let و let else

دستور if let به شما اجازه می‌دهد که if و let را ترکیب کنید و به شکلی کمتر پرحجم، مقادیر مطابق با یک الگو را مدیریت کنید و سایر مقادیر را نادیده بگیرید. برنامه‌ای که در لیستینگ 6-6 نشان داده شده است، بر روی یک مقدار Option<u8> در متغیر config_max مطابقت دارد، اما تنها زمانی که مقدار Some باشد کد را اجرا می‌کند.

fn main() {
    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("The maximum is configured to be {max}"),
        _ => (),
    }
}
Listing 6-6: یک match که تنها به اجرای کد زمانی که مقدار Some است اهمیت می‌دهد

اگر مقدار Some باشد، مقدار موجود در متغیر Some را با اتصال به متغیر max در الگو چاپ می‌کنیم. ما نمی‌خواهیم با مقدار None کاری انجام دهیم. برای برآورده کردن دستور match، باید _ => () را بعد از پردازش تنها یک مورد اضافه کنیم، که کد اضافی آزاردهنده‌ای است.

در عوض، می‌توانیم این کد را به شکلی کوتاه‌تر با استفاده از if let بنویسیم. کد زیر به همان شکل match در لیستینگ 6-6 عمل می‌کند:

fn main() {
    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {max}");
    }
}

دستور if let یک الگو و یک عبارت را می‌گیرد که با یک علامت مساوی جدا شده‌اند. این دستور همانند match عمل می‌کند، جایی که عبارت به match داده می‌شود و الگو بازوی اول آن است. در این مورد، الگو Some(max) است و متغیر max مقدار داخل Some را می‌گیرد. سپس می‌توانیم از max در بدنه بلوک if let همان‌طور که در بازوی متناظر match استفاده کردیم، استفاده کنیم. کد در بلوک if let تنها در صورتی اجرا می‌شود که مقدار با الگو مطابقت داشته باشد.

استفاده از if let به معنای تایپ کمتر، تورفتگی کمتر، و کدنویسی قالبی (boilerplate) کمتر است.
با این حال، بررسی جامع‌ای که match تحمیل می‌کند را از دست می‌دهید؛ این بررسی تضمین می‌کند
که هیچ حالتی را فراموش نکرده باشید. انتخاب بین match و if let بستگی به کاری دارد که
در موقعیت خاص خود انجام می‌دهید و این‌که آیا دستیابی به اختصار، مبادله‌ای مناسب در برابر از دست دادن بررسی جامع است یا خیر.

به عبارت دیگر، می‌توانید if let را به عنوان یک قند سینتکس برای match تصور کنید که کد را زمانی که مقدار با یک الگو مطابقت دارد اجرا می‌کند و سپس تمام مقادیر دیگر را نادیده می‌گیرد.

ما می‌توانیم یک else با یک if let اضافه کنیم. بلوک کدی که با else همراه می‌شود همان بلوک کدی است که با مورد _ در دستور match که معادل if let و else است همراه می‌شود. دستور Coin را در لیستینگ 6-4 به یاد بیاورید، جایی که نوع Quarter یک مقدار UsState را نیز در خود جای داده بود. اگر می‌خواستیم تمام سکه‌های غیر Quarter را که می‌بینیم بشماریم، هم‌زمان ایالت‌های سکه‌های Quarter را اعلام کنیم، می‌توانستیم این کار را با یک دستور match انجام دهیم، مانند این:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {state:?}!"),
        _ => count += 1,
    }
}

یا می‌توانستیم از یک عبارت if let و else استفاده کنیم، مانند این:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {state:?}!");
    } else {
        count += 1;
    }
}

ماندن در «مسیر خوشحال» با let...else

الگوی رایج این است که وقتی یک مقدار موجود است، عملیاتی را انجام دهیم و در غیر این صورت، یک مقدار پیش‌فرض را بازگردانیم. با ادامه دادن مثال سکه‌ها با یک مقدار UsState، اگر بخواهیم بر اساس قدمت ایالتی که روی سکه‌ی ۲۵ سنتی (quarter) است، چیزی خنده‌دار بگوییم، می‌توانیم یک متد روی UsState تعریف کنیم تا سن ایالت را بررسی کند، مانند مثال زیر:

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

سپس ممکن است از if let برای مطابقت با نوع سکه استفاده کنیم، متغیری به نام state را در بدنه شرط معرفی کنیم، همان‌طور که در لیستینگ 6-7 نشان داده شده است.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-7: بررسی این‌که آیا یک ایالت در سال ۱۹۰۰ وجود داشته است، با استفاده از شرط‌هایی که درون یک if let تو در تو قرار دارند.

این کار انجام می‌شود، اما منطق اجرا را به درون بدنه‌ی عبارت if let منتقل کرده‌ایم، و اگر کار مورد نظر پیچیده‌تر باشد، ممکن است دنبال کردن ارتباط شاخه‌های سطح بالا دشوار شود. همچنین می‌توانیم از این واقعیت بهره ببریم که عبارات یک مقدار تولید می‌کنند— یا برای تولید state از if let استفاده کنیم یا زودتر بازگردیم، همان‌طور که در لیستینگ 6-8 نشان داده شده است. (می‌توانید کار مشابهی را با match نیز انجام دهید.)

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-8: استفاده از if let برای تولید یک مقدار یا بازگشت زودهنگام.

این تا حدی آزاردهنده است! یک شاخه if let یک مقدار تولید می‌کند و دیگری کاملاً از تابع بازمی‌گردد.

برای بیان ساده‌تر این الگوی رایج، Rust ساختار let...else را ارائه داده است. سینتکس let...else یک الگو در سمت چپ و یک عبارت در سمت راست می‌گیرد، که بسیار شبیه به if let است، اما شاخه‌ی if ندارد و تنها یک شاخه‌ی else دارد. اگر الگو با مقدار مطابقت داشته باشد، مقدار از درون الگو در حوزه‌ی بیرونی (outer scope) بایند خواهد شد. اگر الگو مطابقت نداشته باشد، برنامه وارد شاخه‌ی else خواهد شد، که باید از تابع بازگردد.

در لیستینگ 6-9 می‌توانید ببینید که چگونه لیستینگ 6-8 با استفاده از let...else به جای if let بازنویسی شده است.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-9: استفاده از let...else برای شفاف‌سازی جریان اجرای تابع.

توجه داشته باشید که با این روش، بدنه‌ی اصلی تابع در «مسیر خوشحال» باقی می‌ماند، بدون آن‌که مانند if let جریان کنترل متفاوت و قابل‌توجهی بین دو شاخه ایجاد کند.

اگر در موقعیتی هستید که منطق برنامه‌تان برای بیان با استفاده از match بیش از حد مفصل است، به یاد داشته باشید که if let و let...else نیز در جعبه‌ابزار Rust شما قرار دارند.

خلاصه

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

برنامه‌های Rust شما اکنون می‌توانند مفاهیمی را در حوزه خود بیان کنند و از ساختارها و enumها استفاده کنند. ایجاد انواع سفارشی برای استفاده در API شما ایمنی نوع را تضمین می‌کند: کامپایلر مطمئن می‌شود که توابع شما فقط مقادیری از نوعی که هر تابع انتظار دارد دریافت می‌کنند.

برای ارائه یک API سازمان‌یافته به کاربران خود که استفاده از آن ساده باشد و فقط دقیقاً آنچه کاربران شما نیاز دارند را آشکار کند، حالا به ماژول‌های Rust می‌پردازیم.

مدیریت پروژه‌های بزرگ با بسته‌ها، جعبه‌ها (crates) و ماژول‌ها

با نوشتن برنامه‌های بزرگ‌تر، سازماندهی کد شما اهمیت بیشتری پیدا می‌کند. با گروه‌بندی قابلیت‌های مرتبط و جدا کردن کدی که ویژگی‌های متمایزی دارد، می‌توانید مشخص کنید که کد یک ویژگی خاص در کجا پیاده‌سازی شده و کجا می‌توان آن را تغییر داد.

برنامه‌هایی که تا این‌جا نوشته‌ایم، همگی در یک ماژول و در یک فایل بوده‌اند. با رشد یک پروژه، باید کد را با تقسیم آن به چند ماژول و سپس چند فایل، سازمان‌دهی کنید. یک پکیج می‌تواند شامل چندین crate دودویی باشد و به‌صورت اختیاری یک crate کتابخانه‌ای نیز داشته باشد. با گسترش یک پکیج، می‌توانید بخش‌هایی از آن را به crateهای جداگانه استخراج کنید که به وابستگی‌های خارجی تبدیل می‌شوند. این فصل تمام این تکنیک‌ها را پوشش می‌دهد. برای پروژه‌های بسیار بزرگی که از مجموعه‌ای از پکیج‌های مرتبط به‌هم تشکیل شده‌اند و با هم رشد می‌کنند، Cargo قابلیتی به نام workspaces ارائه می‌دهد که آن را در فصل ۱۴ با عنوان “Cargo Workspaces” بررسی خواهیم کرد.

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

یک مفهوم مرتبط، محدوده (scope) است: زمینه‌ای که در آن کد نوشته شده است و مجموعه‌ای از نام‌ها که به عنوان «در محدوده» تعریف می‌شوند. هنگام خواندن، نوشتن و کامپایل کد، برنامه‌نویسان و کامپایلرها باید بدانند که آیا یک نام خاص در یک مکان خاص به متغیر، تابع، ساختار، enum، ماژول، ثابت یا مورد دیگری اشاره دارد و معنای آن مورد چیست. شما می‌توانید محدوده‌ها ایجاد کنید و مشخص کنید که کدام نام‌ها در محدوده هستند یا خارج از آن. نمی‌توانید دو مورد با نام یکسان در یک محدوده داشته باشید؛ ابزارهایی برای رفع تعارض نام‌ها در دسترس هستند.

Rust مجموعه‌ای از ویژگی‌ها دارد که به شما امکان می‌دهد سازماندهی کد خود را مدیریت کنید، از جمله جزئیاتی که آشکار می‌شوند، جزئیاتی که خصوصی هستند، و نام‌هایی که در هر محدوده در برنامه‌های شما قرار دارند. این ویژگی‌ها که گاهی به صورت جمعی سیستم ماژول نامیده می‌شوند شامل موارد زیر هستند:

  • Packages: یکی از قابلیت‌های Cargo که به شما اجازه می‌دهد crateها را بسازید، تست کنید و به اشتراک بگذارید
  • Crates: یک درخت از ماژول‌ها که یک کتابخانه یا فایل اجرایی تولید می‌کند
  • Modules و use: به شما امکان می‌دهد سازمان‌دهی، حوزه (scope)، و سطح دسترسی مسیرها را کنترل کنید
  • Paths: روشی برای نام‌گذاری یک آیتم، مانند یک struct، تابع، یا ماژول

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

بسته‌ها و جعبه‌ها (crates)

اولین بخش‌هایی که در سیستم ماژول بررسی خواهیم کرد، بسته‌ها و جعبه‌ها (crates) هستند.

یک crate کوچک‌ترین واحدی از کد است که کامپایلر Rust در هر لحظه به آن توجه می‌کند. حتی اگر به جای استفاده از cargo، مستقیماً rustc را اجرا کنید و تنها یک فایل کد منبع را (همان‌طور که در فصل اول در بخش «نوشتن و اجرای یک برنامه Rust» انجام دادیم) به آن بدهید، کامپایلر آن فایل را به عنوان یک crate در نظر می‌گیرد. crateها می‌توانند شامل ماژول‌هایی باشند، و این ماژول‌ها ممکن است در فایل‌های دیگری تعریف شده باشند که هنگام کامپایل، همراه با crate پردازش می‌شوند، همان‌طور که در بخش‌های بعدی خواهیم دید.

یک crate می‌تواند یکی از دو نوع زیر باشد: crate دودویی (binary) یا crate کتابخانه‌ای (library). crateهای دودویی برنامه‌هایی هستند که می‌توانید آن‌ها را به فایل اجرایی کامپایل کرده و اجرا کنید، مانند یک برنامه‌ی خط فرمان یا یک سرور. هر crate دودویی باید تابعی به نام main داشته باشد که مشخص می‌کند هنگام اجرای فایل اجرایی، چه اتفاقی می‌افتد. تمام crateهایی که تا این‌جا ایجاد کرده‌ایم، crateهای دودویی بوده‌اند.

crateهای کتابخانه‌ای تابع main ندارند و به فایل اجرایی کامپایل نمی‌شوند. در عوض، آن‌ها قابلیت‌هایی را تعریف می‌کنند که برای اشتراک‌گذاری میان پروژه‌های مختلف در نظر گرفته شده‌اند. برای مثال، crate rand که در فصل ۲ از آن استفاده کردیم، قابلیت‌هایی برای تولید اعداد تصادفی فراهم می‌کند. در اغلب موارد، زمانی که Rustaceanها از واژه‌ی “crate” استفاده می‌کنند، منظورشان crate کتابخانه‌ای است و این واژه را به‌طور معادل با مفهوم عمومی «کتابخانه» در برنامه‌نویسی به کار می‌برند.

ریشه‌ی crate (crate root) فایلی از کد منبع است که کامپایلر Rust از آن شروع می‌کند و ماژول ریشه‌ی crate را تشکیل می‌دهد (ماژول‌ها را در بخش “تعریف ماژول‌ها برای کنترل حوزه و سطح دسترسی” با جزئیات توضیح خواهیم داد).

یک package مجموعه‌ای از یک یا چند crate است که مجموعه‌ای از قابلیت‌ها را ارائه می‌دهد. یک package شامل یک فایل Cargo.toml است که مشخص می‌کند چگونه crateها باید ساخته شوند. خود Cargo در واقع یک package است که شامل یک crate دودویی برای ابزار خط فرمانی است که تاکنون از آن برای ساخت کد خود استفاده کرده‌اید. پکیج Cargo همچنین شامل یک crate کتابخانه‌ای است که crate دودویی به آن وابسته است. سایر پروژه‌ها می‌توانند به crate کتابخانه‌ای Cargo وابسته شوند تا از همان منطق استفاده کنند که ابزار خط فرمان Cargo از آن بهره می‌برد.

یک package می‌تواند هر تعداد crate دودویی داشته باشد، اما در بیشترین حالت، تنها یک crate کتابخانه‌ای می‌تواند داشته باشد. هر package باید دست‌کم شامل یک crate باشد، چه crate کتابخانه‌ای و چه crate دودویی.

بیایید ببینیم وقتی یک بسته ایجاد می‌کنیم چه اتفاقی می‌افتد. ابتدا دستور cargo new my-project را وارد می‌کنیم:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

بعد از اجرای cargo new my-project، از دستور ls استفاده می‌کنیم تا ببینیم Cargo چه چیزی ایجاد کرده است. در دایرکتوری پروژه، یک فایل Cargo.toml وجود دارد که به ما یک بسته می‌دهد. همچنین یک دایرکتوری src وجود دارد که شامل فایل main.rs است. فایل Cargo.toml را در ویرایشگر متن خود باز کنید و توجه کنید که هیچ اشاره‌ای به src/main.rs نشده است. Cargo از یک قرارداد پیروی می‌کند که src/main.rs ریشه جعبه (crate) یک جعبه (crate) باینری با همان نام بسته است. به همین ترتیب، Cargo می‌داند که اگر دایرکتوری بسته شامل src/lib.rs باشد، بسته شامل یک جعبه (crate) کتابخانه‌ای با همان نام بسته است و src/lib.rs ریشه جعبه (crate) آن است. Cargo فایل‌های ریشه جعبه (crate) را به rustc ارسال می‌کند تا کتابخانه یا فایل اجرایی ساخته شود.

در اینجا، ما یک بسته داریم که تنها شامل src/main.rs است، به این معنی که تنها یک جعبه (crate) باینری به نام my-project دارد. اگر یک بسته شامل src/main.rs و src/lib.rs باشد، آن بسته دو جعبه (crate) خواهد داشت: یک جعبه (crate) باینری و یک کتابخانه، هر دو با همان نام بسته. یک بسته می‌تواند چندین جعبه (crate) باینری داشته باشد با قرار دادن فایل‌ها در دایرکتوری src/bin: هر فایل یک جعبه (crate) باینری جداگانه خواهد بود.

تعریف ماژول‌ها برای کنترل محدوده و حریم خصوصی

در این بخش، ما درباره ماژول‌ها و سایر بخش‌های سیستم ماژول صحبت خواهیم کرد، یعنی مسیرها که به شما امکان می‌دهند آیتم‌ها را نام‌گذاری کنید؛ کلمه کلیدی use که مسیر را به محدوده وارد می‌کند؛ و کلمه کلیدی pub برای عمومی کردن آیتم‌ها. همچنین درباره کلمه کلیدی as، بسته‌های خارجی، و عملگر glob صحبت خواهیم کرد.

خلاصه‌ای از ماژول‌ها

قبل از اینکه به جزئیات ماژول‌ها و مسیرها بپردازیم، اینجا یک مرجع سریع در مورد نحوه عملکرد ماژول‌ها، مسیرها، کلمه کلیدی use و کلمه کلیدی pub در کامپایلر ارائه می‌دهیم و همچنین نحوه سازماندهی کد توسط اکثر توسعه‌دهندگان را نشان می‌دهیم. ما در طول این فصل به مثال‌هایی از هر یک از این قواعد خواهیم پرداخت، اما این یک مکان عالی برای یادآوری نحوه عملکرد ماژول‌ها است.

  • شروع از ریشه جعبه (crate): هنگام کامپایل یک جعبه (crate)، کامپایلر ابتدا در فایل ریشه جعبه (crate) (معمولاً src/lib.rs برای یک جعبه (crate) کتابخانه‌ای یا src/main.rs برای یک جعبه (crate) باینری) به دنبال کد برای کامپایل می‌گردد.
  • تعریف ماژول‌ها: در فایل ریشه جعبه (crate)، می‌توانید ماژول‌های جدید تعریف کنید؛ مثلاً می‌توانید یک ماژول “garden” با mod garden; تعریف کنید. کامپایلر کد ماژول را در مکان‌های زیر جستجو می‌کند:
    • به صورت درون‌خطی، داخل براکت‌های موج‌دار که به جای علامت نقطه‌ویرگول بعد از mod garden قرار می‌گیرند.
    • در فایل src/garden.rs
    • در فایل src/garden/mod.rs
  • تعریف زیرماژول‌ها: در هر فایلی به جز فایل ریشه جعبه (crate)، می‌توانید زیرماژول‌ها تعریف کنید. برای مثال، ممکن است mod vegetables; را در فایل src/garden.rs تعریف کنید. کامپایلر کد زیرماژول را در دایرکتوری‌ای که به نام ماژول والد است، در مکان‌های زیر جستجو می‌کند:
    • به صورت درون‌خطی، مستقیماً بعد از mod vegetables، داخل براکت‌های موج‌دار به جای نقطه‌ویرگول
    • در فایل src/garden/vegetables.rs
    • در فایل src/garden/vegetables/mod.rs
  • مسیرها به کد در ماژول‌ها: وقتی یک ماژول بخشی از جعبه (crate) شما باشد، می‌توانید از هر جای دیگر در همان جعبه (crate) (تا زمانی که قواعد حریم خصوصی اجازه دهند) با استفاده از مسیر به کد آن ارجاع دهید. برای مثال، یک نوع Asparagus در ماژول vegetables در garden به این صورت پیدا می‌شود: crate::garden::vegetables::Asparagus.
  • خصوصی در مقابل عمومی: کد درون یک ماژول به صورت پیش‌فرض برای ماژول‌های والد خصوصی است. برای عمومی کردن یک ماژول، آن را با pub mod به جای mod تعریف کنید. برای عمومی کردن آیتم‌های داخل یک ماژول عمومی، از pub قبل از اعلان آن‌ها استفاده کنید.
  • کلمه کلیدی use: در یک محدوده، کلمه کلیدی use میانبری به آیتم‌ها ایجاد می‌کند تا تکرار مسیرهای طولانی کاهش یابد. در هر محدوده‌ای که می‌تواند به crate::garden::vegetables::Asparagus ارجاع دهد، می‌توانید یک میانبر با use crate::garden::vegetables::Asparagus; ایجاد کنید و از آن به بعد فقط کافی است Asparagus را در آن محدوده استفاده کنید.

اینجا، ما یک جعبه (crate) باینری به نام backyard ایجاد می‌کنیم که این قواعد را نشان می‌دهد. دایرکتوری جعبه (crate) که آن هم backyard نامیده می‌شود شامل این فایل‌ها و دایرکتوری‌ها است:

backyard
├── Cargo.lock
├── Cargo.toml
└── src
    ├── garden
    │   └── vegetables.rs
    ├── garden.rs
    └── main.rs

فایل ریشه جعبه (crate) در اینجا src/main.rs است و حاوی موارد زیر است:

Filename: src/main.rs
use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
    let plant = Asparagus {};
    println!("I'm growing {plant:?}!");
}

خط pub mod garden; به کامپایلر می‌گوید که کدی را که در src/garden.rs پیدا می‌کند وارد کند، که شامل موارد زیر است:

Filename: src/garden.rs
pub mod vegetables;

اینجا، pub mod vegetables; به این معنا است که کد موجود در src/garden/vegetables.rs نیز وارد می‌شود. آن کد به صورت زیر است:

#[derive(Debug)]
pub struct Asparagus {}

حالا بیایید به جزئیات این قواعد بپردازیم و آن‌ها را در عمل نشان دهیم!

گروه‌بندی کدهای مرتبط در ماژول‌ها

ماژول‌ها به ما امکان می‌دهند کد را در یک جعبه (crate) برای خوانایی و بازاستفاده آسان سازماندهی کنیم. ماژول‌ها همچنین به ما امکان کنترل حریم خصوصی آیتم‌ها را می‌دهند زیرا کد درون یک ماژول به صورت پیش‌فرض خصوصی است. آیتم‌های خصوصی جزئیات پیاده‌سازی داخلی هستند که برای استفاده خارجی در دسترس نیستند. ما می‌توانیم انتخاب کنیم که ماژول‌ها و آیتم‌های درون آن‌ها عمومی باشند، که این موارد را برای استفاده خارجی آشکار می‌کند.

برای مثال، بیایید یک جعبه (crate) کتابخانه‌ای بنویسیم که عملکرد یک رستوران را ارائه دهد. امضای توابع را تعریف می‌کنیم اما بدنه آن‌ها را خالی می‌گذاریم تا بیشتر بر سازماندهی کد تمرکز کنیم تا پیاده‌سازی عملکرد یک رستوران.

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

برای ساختاردهی جعبه (crate) خود به این روش، می‌توانیم عملکردها را در ماژول‌های تو در تو سازماندهی کنیم. یک کتابخانه جدید به نام restaurant با اجرای دستور cargo new restaurant --lib ایجاد کنید. سپس کد لیستینگ 7-1 را در src/lib.rs وارد کنید تا برخی ماژول‌ها و امضای توابع تعریف شود. این کد بخش جلوی خانه را تعریف می‌کند.

Filename: src/lib.rs
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}
Listing 7-1: یک ماژول front_of_house که شامل ماژول‌های دیگر است که سپس شامل توابع می‌شوند

ماژولی را با استفاده از کلیدواژه‌ی mod و به‌دنبال آن نام ماژول تعریف می‌کنیم
(در این مثال، front_of_house). بدنه‌ی ماژول درون آکولاد قرار می‌گیرد.
درون ماژول‌ها می‌توان ماژول‌های دیگری نیز قرار داد، همان‌طور که در این مثال،
ماژول‌های hosting و serving درون front_of_house قرار گرفته‌اند.
ماژول‌ها همچنین می‌توانند شامل تعریف آیتم‌های دیگر نیز باشند،
مانند structها، enumها، ثابت‌ها (constants)، traitها،
و همان‌طور که در لیستینگ 7-1 می‌بینید، توابع.

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

درخت ماژول

قبلاً اشاره کردیم که src/main.rs و src/lib.rs به نام ریشه جعبه (crate) شناخته می‌شوند. دلیل نام‌گذاری آن‌ها این است که محتوای هر یک از این دو فایل یک ماژول به نام crate را در ریشه ساختار ماژول جعبه (crate) تشکیل می‌دهند، که به عنوان درخت ماژول شناخته می‌شود.

لیستینگ 7-2 درخت ماژول را برای ساختار موجود در لیستینگ 7-1 نشان می‌دهد.

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment
Listing 7-2: درخت ماژول برای کد موجود در لیستینگ 7-1

این درخت نشان می‌دهد که برخی از ماژول‌ها در داخل ماژول‌های دیگر قرار دارند؛ برای مثال، hosting در داخل front_of_house قرار دارد. درخت همچنین نشان می‌دهد که برخی از ماژول‌ها هم‌سطح هستند، به این معنی که در همان ماژول تعریف شده‌اند؛ hosting و serving هم‌سطح هستند و درون front_of_house تعریف شده‌اند. اگر ماژول A درون ماژول B قرار گیرد، می‌گوییم ماژول A فرزند ماژول B است و ماژول B والد ماژول A است. توجه کنید که کل درخت ماژول در زیر ماژول ضمنی به نام crate ریشه دارد.

درخت ماژول ممکن است شما را به یاد درخت دایرکتوری‌های فایل‌سیستم کامپیوتر بیندازد؛ این مقایسه بسیار مناسبی است! درست همان‌طور که دایرکتوری‌ها در فایل‌سیستم کد را سازماندهی می‌کنند، شما می‌توانید از ماژول‌ها برای سازماندهی کد خود استفاده کنید. و درست مانند فایل‌ها در یک دایرکتوری، ما نیاز به روشی برای پیدا کردن ماژول‌ها داریم.

مسیرها برای اشاره به یک آیتم در درخت ماژول

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

یک مسیر می‌تواند به دو شکل باشد:

  • یک مسیر مطلق مسیری کامل است که از ریشه جعبه (crate) شروع می‌شود؛ برای کدی که از یک جعبه (crate) خارجی می‌آید، مسیر مطلق با نام جعبه (crate) شروع می‌شود، و برای کدی که از جعبه (crate) فعلی می‌آید، با کلمه کلیدی crate شروع می‌شود.
  • یک مسیر نسبی از ماژول فعلی شروع می‌شود و از self، super یا یک شناسه در ماژول فعلی استفاده می‌کند.

هر دو مسیر مطلق و نسبی با یک یا چند شناسه که با دو نقطه دوبل (::) جدا شده‌اند دنبال می‌شوند.

با بازگشت به لیستینگ 7-1، فرض کنید که می‌خواهیم تابع add_to_waitlist را فراخوانی کنیم. این کار مشابه پرسیدن این است: مسیر تابع add_to_waitlist چیست؟ لیستینگ 7-3 شامل لیستینگ 7-1 با حذف برخی از ماژول‌ها و توابع است.

ما دو روش برای فراخوانی تابع add_to_waitlist از یک تابع جدید، eat_at_restaurant، که در ریشه جعبه (crate) تعریف شده است، نشان خواهیم داد. این مسیرها درست هستند، اما یک مشکل دیگر وجود دارد که مانع کامپایل این مثال به شکل فعلی می‌شود. بعداً توضیح خواهیم داد که چرا.

تابع eat_at_restaurant بخشی از API عمومی جعبه (crate) کتابخانه‌ای ما است، بنابراین آن را با کلمه کلیدی pub علامت می‌زنیم. در بخش «آشکار کردن مسیرها با کلمه کلیدی pub»، به جزئیات بیشتری درباره pub خواهیم پرداخت.

Filename: src/lib.rs
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-3: فراخوانی تابع add_to_waitlist با استفاده از مسیرهای مطلق و نسبی

بار اولی که تابع add_to_waitlist را در eat_at_restaurant فراخوانی می‌کنیم، از یک مسیر مطلق استفاده می‌کنیم. تابع add_to_waitlist در همان جعبه (crate) تعریف شده است که eat_at_restaurant در آن قرار دارد، که به این معنی است که می‌توانیم از کلمه کلیدی crate برای شروع مسیر مطلق استفاده کنیم. سپس هر یک از ماژول‌های متوالی را شامل می‌کنیم تا به add_to_waitlist برسیم. می‌توانید یک فایل‌سیستم با ساختار مشابه تصور کنید: ما مسیر /front_of_house/hosting/add_to_waitlist را برای اجرای برنامه add_to_waitlist مشخص می‌کنیم؛ استفاده از نام crate برای شروع از ریشه جعبه (crate) مانند استفاده از / برای شروع از ریشه فایل‌سیستم در شل است.

بار دوم که تابع add_to_waitlist را در eat_at_restaurant فراخوانی می‌کنیم، از یک مسیر نسبی استفاده می‌کنیم. مسیر با front_of_house شروع می‌شود، که نام ماژولی است که در همان سطح از درخت ماژول به عنوان eat_at_restaurant تعریف شده است. اینجا معادل فایل‌سیستم استفاده از مسیر front_of_house/hosting/add_to_waitlist است. شروع با نام ماژول به این معنی است که مسیر نسبی است.

انتخاب بین مسیرهای مطلق و نسبی

انتخاب بین استفاده از مسیر نسبی یا مطلق یک تصمیم است که بر اساس پروژه شما گرفته می‌شود، و به این بستگی دارد که آیا احتمال بیشتری دارد کد تعریف آیتم را به طور مستقل از یا همراه با کدی که از آیتم استفاده می‌کند جابجا کنید. برای مثال، اگر ماژول front_of_house و تابع eat_at_restaurant را به یک ماژول به نام customer_experience منتقل کنیم، باید مسیر مطلق به add_to_waitlist را به‌روزرسانی کنیم، اما مسیر نسبی همچنان معتبر خواهد بود. با این حال، اگر تابع eat_at_restaurant را به طور مستقل به یک ماژول به نام dining منتقل کنیم، مسیر مطلق به فراخوانی add_to_waitlist تغییر نمی‌کند، اما مسیر نسبی باید به‌روزرسانی شود. ترجیح ما به طور کلی این است که مسیرهای مطلق را مشخص کنیم زیرا احتمال بیشتری دارد که بخواهیم تعریف کد و فراخوانی آیتم‌ها را مستقل از یکدیگر جابجا کنیم.

بیایید سعی کنیم کد لیستینگ 7-3 را کامپایل کنیم و ببینیم چرا هنوز کامپایل نمی‌شود! خطاهایی که دریافت می‌کنیم در لیستینگ 7-4 نشان داده شده‌اند.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
  |                            |
  |                            private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
   |                     |
   |                     private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
2  |     mod hosting {
   |     ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors
Listing 7-4: خطاهای کامپایلر هنگام ساخت کد در لیستینگ 7-3

پیام‌های خطا می‌گویند که ماژول hosting خصوصی است. به عبارت دیگر، ما مسیرهای صحیح برای ماژول hosting و تابع add_to_waitlist داریم، اما Rust به ما اجازه نمی‌دهد از آن‌ها استفاده کنیم زیرا به بخش‌های خصوصی دسترسی ندارد. در Rust، تمام آیتم‌ها (توابع، متدها، ساختارها، enumها، ماژول‌ها و ثابت‌ها) به صورت پیش‌فرض برای ماژول‌های والد خصوصی هستند. اگر بخواهید آیتمی مانند یک تابع یا ساختار را خصوصی کنید، آن را در یک ماژول قرار می‌دهید.

آیتم‌های موجود در یک ماژول والد نمی‌توانند از آیتم‌های خصوصی درون ماژول‌های فرزند استفاده کنند، اما آیتم‌های درون ماژول‌های فرزند می‌توانند از آیتم‌های ماژول‌های اجداد خود استفاده کنند. این به این دلیل است که ماژول‌های فرزند جزئیات پیاده‌سازی خود را بسته‌بندی و پنهان می‌کنند، اما ماژول‌های فرزند می‌توانند زمینه‌ای که در آن تعریف شده‌اند را ببینند. برای ادامه مثال، قواعد حریم خصوصی را مانند دفتر پشتی یک رستوران تصور کنید: آنچه در آنجا می‌گذرد برای مشتریان رستوران خصوصی است، اما مدیران دفتر می‌توانند همه چیز را در رستوران ببینند و انجام دهند.

Rust تصمیم گرفته است که سیستم ماژول به این صورت کار کند تا پنهان کردن جزئیات پیاده‌سازی داخلی به صورت پیش‌فرض باشد. به این ترتیب، می‌دانید کدام بخش‌های کد داخلی را می‌توانید تغییر دهید بدون اینکه کد بیرونی را خراب کنید. با این حال، Rust به شما این امکان را می‌دهد که بخش‌های داخلی کد ماژول‌های فرزند را به ماژول‌های اجداد بیرونی با استفاده از کلمه کلیدی pub عمومی کنید.

آشکار کردن مسیرها با کلمه کلیدی pub

بیایید به خطای لیستینگ 7-4 برگردیم که به ما گفت ماژول hosting خصوصی است. ما می‌خواهیم تابع eat_at_restaurant در ماژول والد به تابع add_to_waitlist در ماژول فرزند دسترسی داشته باشد، بنابراین ماژول hosting را با کلمه کلیدی pub علامت می‌زنیم، همان‌طور که در لیستینگ 7-5 نشان داده شده است.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-5: اعلان ماژول hosting به عنوان pub برای استفاده از آن در eat_at_restaurant

متأسفانه، کد در لیستینگ 7-5 همچنان به خطاهای کامپایلر منجر می‌شود، همان‌طور که در لیستینگ 7-6 نشان داده شده است.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:10:37
   |
10 |     crate::front_of_house::hosting::add_to_waitlist();
   |                                     ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:13:30
   |
13 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors
Listing 7-6: خطاهای کامپایلر هنگام ساخت کد در لیستینگ 7-5

چه اتفاقی افتاد؟ اضافه کردن کلمه کلیدی pub در جلوی mod hosting ماژول را عمومی می‌کند. با این تغییر، اگر به front_of_house دسترسی داشته باشیم، می‌توانیم به hosting نیز دسترسی داشته باشیم. اما محتویات hosting همچنان خصوصی است؛ عمومی کردن ماژول به معنای عمومی کردن محتوای آن نیست. کلمه کلیدی pub روی یک ماژول فقط به کدهای موجود در ماژول‌های اجداد اجازه می‌دهد به آن ارجاع دهند، نه اینکه به کد داخلی آن دسترسی داشته باشند. از آنجایی که ماژول‌ها به عنوان ظرف عمل می‌کنند، تنها عمومی کردن ماژول کافی نیست؛ باید فراتر رفته و یک یا چند مورد از آیتم‌های درون ماژول را نیز عمومی کنیم.

خطاهای موجود در لیستینگ 7-6 نشان می‌دهند که تابع add_to_waitlist خصوصی است. قواعد حریم خصوصی برای ساختارها، enumها، توابع، متدها و همچنین ماژول‌ها اعمال می‌شوند.

بیایید تابع add_to_waitlist را نیز با اضافه کردن کلمه کلیدی pub قبل از تعریف آن عمومی کنیم، همان‌طور که در لیستینگ 7-7 نشان داده شده است.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-7: اضافه کردن کلمه کلیدی pub به mod hosting و fn add_to_waitlist به ما اجازه می‌دهد تابع را از eat_at_restaurant فراخوانی کنیم

Now the code will compile! To see why adding the pub keyword lets us use these paths in eat_at_restaurant with respect to the privacy rules, let’s look at the absolute and the relative paths.

In the absolute path, we start with crate, the root of our crate’s module tree. The front_of_house module is defined in the crate root. While front_of_house isn’t public, because the eat_at_restaurant function is defined in the same module as front_of_house (that is, eat_at_restaurant and front_of_house are siblings), we can refer to front_of_house from eat_at_restaurant. Next is the hosting module marked with pub. We can access the parent module of hosting, so we can access hosting. Finally, the add_to_waitlist function is marked with pub and we can access its parent module, so this function call works!

In the relative path, the logic is the same as the absolute path except for the first step: rather than starting from the crate root, the path starts from front_of_house. The front_of_house module is defined within the same module as eat_at_restaurant, so the relative path starting from the module in which eat_at_restaurant is defined works. Then, because hosting and add_to_waitlist are marked with pub, the rest of the path works, and this function call is valid!

If you plan on sharing your library crate so other projects can use your code, your public API is your contract with users of your crate that determines how they can interact with your code. There are many considerations around managing changes to your public API to make it easier for people to depend on your crate. These considerations are out of the scope of this book; if you’re interested in this topic, see The Rust API Guidelines.

بهترین شیوه‌ها برای بسته‌هایی که یک جعبه (crate) باینری و یک جعبه (crate) کتابخانه‌ای دارند

We mentioned that a package can contain both a src/main.rs binary crate root as well as a src/lib.rs library crate root, and both crates will have the package name by default. Typically, packages with this pattern of containing both a library and a binary crate will have just enough code in the binary crate to start an executable that calls code within the library crate. This lets other projects benefit from most of the functionality that the package provides because the library crate’s code can be shared.

درخت ماژول باید در src/lib.rs تعریف شود. سپس، هر آیتم عمومی را می‌توان در جعبه (crate) باینری با شروع مسیرها با نام بسته استفاده کرد. جعبه (crate) باینری به یک کاربر از جعبه (crate) کتابخانه‌ای تبدیل می‌شود، درست مثل اینکه یک جعبه (crate) کاملاً خارجی از جعبه (crate) کتابخانه‌ای استفاده می‌کند: تنها می‌تواند از API عمومی استفاده کند. این کار به شما کمک می‌کند یک API خوب طراحی کنید؛ نه تنها نویسنده آن هستید، بلکه یک کاربر نیز هستید!

In Chapter 12, we’ll demonstrate this organizational practice with a command-line program that will contain both a binary crate and a library crate.

Starting Relative Paths with super

We can construct relative paths that begin in the parent module, rather than the current module or the crate root, by using super at the start of the path. This is like starting a filesystem path with the .. syntax. Using super allows us to reference an item that we know is in the parent module, which can make rearranging the module tree easier when the module is closely related to the parent but the parent might be moved elsewhere in the module tree someday.

Consider the code in Listing 7-8 that models the situation in which a chef fixes an incorrect order and personally brings it out to the customer. The function fix_incorrect_order defined in the back_of_house module calls the function deliver_order defined in the parent module by specifying the path to deliver_order, starting with super.

Filename: src/lib.rs
fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}
Listing 7-8: فراخوانی یک تابع با استفاده از یک مسیر نسبی که با super شروع می‌شود

تابع fix_incorrect_order در ماژول back_of_house است، بنابراین می‌توانیم از super برای رفتن به ماژول والد back_of_house استفاده کنیم، که در این مورد crate، یعنی ریشه است. از آنجا به دنبال deliver_order می‌گردیم و آن را پیدا می‌کنیم. موفقیت! ما فکر می‌کنیم که ماژول back_of_house و تابع deliver_order احتمالاً در همان رابطه با یکدیگر باقی می‌مانند و اگر بخواهیم درخت ماژول جعبه (crate) را سازماندهی مجدد کنیم، با هم جابجا می‌شوند. بنابراین، از super استفاده کردیم تا در آینده، اگر این کد به ماژول دیگری منتقل شد، تغییرات کمتری در کد لازم باشد.

عمومی کردن ساختارها و enumها

ما همچنین می‌توانیم از pub برای مشخص کردن ساختارها و enumها به عنوان عمومی استفاده کنیم، اما چند جزئیات اضافی در مورد استفاده از pub با ساختارها و enumها وجود دارد. اگر از pub قبل از تعریف یک ساختار استفاده کنیم، ساختار عمومی می‌شود، اما فیلدهای ساختار همچنان خصوصی خواهند بود. ما می‌توانیم هر فیلد را به صورت موردی عمومی یا خصوصی کنیم. در لیستینگ 7-9، یک ساختار عمومی به نام back_of_house::Breakfast تعریف کرده‌ایم که یک فیلد عمومی به نام toast دارد اما فیلد seasonal_fruit خصوصی است. این مدل‌سازی حالتی است که در آن مشتری می‌تواند نوع نان همراه با وعده غذایی را انتخاب کند، اما سرآشپز تصمیم می‌گیرد که کدام میوه همراه وعده غذایی باشد بر اساس آنچه در فصل و موجودی است. میوه‌های موجود به سرعت تغییر می‌کنند، بنابراین مشتریان نمی‌توانند میوه را انتخاب کنند یا حتی ببینند که چه میوه‌ای دریافت خواهند کرد.

Filename: src/lib.rs
mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast.
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like.
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal.
    // meal.seasonal_fruit = String::from("blueberries");
}
Listing 7-9: یک ساختار با برخی فیلدهای عمومی و برخی خصوصی

از آنجا که فیلد toast در ساختار back_of_house::Breakfast عمومی است، می‌توانیم در eat_at_restaurant به این فیلد با استفاده از نقطه‌گذاری مقدار بدهیم یا مقدار آن را بخوانیم. توجه کنید که نمی‌توانیم از فیلد seasonal_fruit در eat_at_restaurant استفاده کنیم، زیرا seasonal_fruit خصوصی است. خطی که مقدار فیلد seasonal_fruit را تغییر می‌دهد را لغو کامنت کنید تا ببینید چه خطایی دریافت می‌کنید!

همچنین توجه کنید که چون back_of_house::Breakfast یک فیلد خصوصی دارد، ساختار باید یک تابع وابسته عمومی ارائه دهد که یک نمونه از Breakfast بسازد (ما آن را اینجا summer نامیده‌ایم). اگر Breakfast چنین تابعی نداشت، نمی‌توانستیم یک نمونه از Breakfast را در eat_at_restaurant ایجاد کنیم، زیرا نمی‌توانستیم مقدار فیلد خصوصی seasonal_fruit را در eat_at_restaurant تنظیم کنیم.

در مقابل، اگر یک enum را عمومی کنیم، تمام متغیرهای آن نیز عمومی می‌شوند. ما فقط به pub قبل از کلمه کلیدی enum نیاز داریم، همان‌طور که در لیستینگ 7-10 نشان داده شده است.

Filename: src/lib.rs
mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}
Listing 7-10: Designating an enum as public makes all its variants public

از آنجایی که enum Appetizer را عمومی کردیم، می‌توانیم از متغیرهای Soup و Salad در eat_at_restaurant استفاده کنیم.

Enums خیلی مفید نیستند مگر اینکه متغیرهای آن‌ها عمومی باشند؛ اضافه کردن pub به تمام متغیرهای enum در هر مورد کار خسته‌کننده‌ای خواهد بود، بنابراین به طور پیش‌فرض متغیرهای enum عمومی هستند. ساختارها اغلب بدون عمومی بودن فیلدهایشان مفید هستند، بنابراین فیلدهای ساختار از قانون کلی پیروی می‌کنند که همه چیز به صورت پیش‌فرض خصوصی است مگر اینکه با pub مشخص شود.

یک وضعیت دیگر مرتبط با pub وجود دارد که هنوز آن را پوشش نداده‌ایم، و آن آخرین ویژگی سیستم ماژول ما است: کلمه کلیدی use. ابتدا use را به تنهایی بررسی خواهیم کرد، و سپس نشان خواهیم داد چگونه pub و use را ترکیب کنیم.

وارد کردن مسیرها به محدوده با کلمه کلیدی use

نوشتن مسیرهای کامل برای فراخوانی توابع می‌تواند خسته‌کننده و تکراری باشد. در لیستینگ 7-7، چه مسیر مطلق یا نسبی را برای تابع add_to_waitlist انتخاب کنیم، هر بار که بخواهیم این تابع را فراخوانی کنیم باید front_of_house و hosting را نیز مشخص کنیم. خوشبختانه، راهی برای ساده‌تر کردن این فرآیند وجود دارد: می‌توانیم یک میانبر به یک مسیر با استفاده از کلمه کلیدی use ایجاد کنیم و سپس در هر جای دیگر محدوده، از نام کوتاه‌تر استفاده کنیم.

در لیستینگ 7-11، ماژول crate::front_of_house::hosting را به محدوده تابع eat_at_restaurant می‌آوریم تا فقط نیاز به مشخص کردن hosting::add_to_waitlist برای فراخوانی تابع add_to_waitlist در eat_at_restaurant داشته باشیم.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-11: وارد کردن یک ماژول به محدوده با use

اضافه کردن use و یک مسیر در یک محدوده مشابه ایجاد یک لینک نمادین در فایل‌سیستم است. با اضافه کردن use crate::front_of_house::hosting در ریشه جعبه (crate)، hosting اکنون یک نام معتبر در آن محدوده است، درست مانند اینکه ماژول hosting در ریشه جعبه (crate) تعریف شده باشد. مسیرهایی که با use به محدوده آورده می‌شوند مانند هر مسیر دیگری حریم خصوصی را بررسی می‌کنند.

توجه کنید که use فقط میانبر را برای محدوده خاصی که در آن use استفاده شده ایجاد می‌کند. لیستینگ 7-12 تابع eat_at_restaurant را به یک زیرماژول جدید به نام customer منتقل می‌کند که سپس یک محدوده متفاوت از دستور use است، بنابراین بدنه تابع کامپایل نمی‌شود.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

mod customer {
    pub fn eat_at_restaurant() {
        hosting::add_to_waitlist();
    }
}
Listing 7-12: دستور use تنها در همان حوزه‌ای (scope) اعمال می‌شود که در آن قرار دارد

خطای کامپایلر نشان می‌دهد که میانبر دیگر در ماژول customer اعمال نمی‌شود:

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: failed to resolve: use of undeclared crate or module `hosting`
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of undeclared crate or module `hosting`
   |
help: consider importing this module through its public re-export
   |
10 +     use crate::hosting;
   |

warning: unused import: `crate::front_of_house::hosting`
 --> src/lib.rs:7:5
  |
7 | use crate::front_of_house::hosting;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

For more information about this error, try `rustc --explain E0433`.
warning: `restaurant` (lib) generated 1 warning
error: could not compile `restaurant` (lib) due to 1 previous error; 1 warning emitted

توجه کنید که همچنین یک هشدار وجود دارد که use دیگر در محدوده خود استفاده نمی‌شود! برای رفع این مشکل، دستور use را نیز به داخل ماژول customer منتقل کنید، یا میانبر را در ماژول والد با super::hosting در داخل ماژول customer ارجاع دهید.

ایجاد مسیرهای use به صورت ایدیوماتیک

در لیستینگ 7-11، ممکن است این سوال پیش بیاید که چرا ما use crate::front_of_house::hosting را مشخص کرده‌ایم و سپس hosting::add_to_waitlist را در eat_at_restaurant فراخوانی کرده‌ایم، به جای اینکه مسیر use را تا تابع add_to_waitlist مشخص کنیم تا همان نتیجه را به دست آوریم، همان‌طور که در لیستینگ 7-13 نشان داده شده است.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
}
Listing 7-13: وارد کردن تابع add_to_waitlist به محدوده با use که غیر ایدیوماتیک است

اگرچه هم لیستینگ 7-11 و هم لیستینگ 7-13 کار مشابهی انجام می‌دهند، لیستینگ 7-11 روش ایدیوماتیک برای وارد کردن یک تابع به محدوده با use است. وارد کردن ماژول والد تابع با use به این معنا است که باید ماژول والد را هنگام فراخوانی تابع مشخص کنیم. مشخص کردن ماژول والد هنگام فراخوانی تابع نشان می‌دهد که تابع به صورت محلی تعریف نشده است، در حالی که همچنان تکرار مسیر کامل را به حداقل می‌رساند. کد موجود در لیستینگ 7-13 مشخص نمی‌کند که add_to_waitlist کجا تعریف شده است.

از طرف دیگر، وقتی ساختارها، enumها، و سایر آیتم‌ها را با use وارد می‌کنیم، ایدیوماتیک است که مسیر کامل را مشخص کنیم. لیستینگ 7-14 روش ایدیوماتیک برای وارد کردن ساختار HashMap از کتابخانه استاندارد به محدوده جعبه (crate) باینری را نشان می‌دهد.

Filename: src/main.rs
use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}
Listing 7-14: وارد کردن HashMap به محدوده به روش ایدیوماتیک

هیچ دلیل قوی پشت این عرف نیست: این فقط کنوانسیونی است که در جامعه Rust به وجود آمده و افراد به خواندن و نوشتن کد Rust به این روش عادت کرده‌اند.

استثنای این عرف زمانی است که دو آیتم با نام یکسان را با دستورات use وارد محدوده می‌کنیم، زیرا Rust این اجازه را نمی‌دهد. لیستینگ 7-15 نشان می‌دهد که چگونه دو نوع Result را که نام یکسانی دارند اما از ماژول‌های والد متفاوتی می‌آیند وارد محدوده کنیم و چگونه به آن‌ها ارجاع دهیم.

Filename: src/lib.rs
use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}
Listing 7-15: وارد کردن دو نوع با نام یکسان به یک محدوده نیازمند استفاده از ماژول‌های والد آن‌ها است.

همان‌طور که می‌بینید، استفاده از ماژول‌های والد دو نوع Result را از هم متمایز می‌کند. اگر به جای آن use std::fmt::Result و use std::io::Result مشخص کنیم، دو نوع Result در یک محدوده خواهیم داشت و Rust نمی‌تواند بفهمد منظور ما از Result کدام است.

ارائه نام‌های جدید با کلمه کلیدی as

یک راه‌حل دیگر برای مشکل وارد کردن دو نوع با نام یکسان به یک محدوده با use این است که پس از مسیر، با استفاده از as یک نام محلی جدید یا نام مستعار برای نوع مشخص کنیم. لیستینگ 7-16 راه دیگری برای نوشتن کد در لیستینگ 7-15 را نشان می‌دهد که در آن یکی از دو نوع Result را با استفاده از as تغییر نام داده‌ایم.

Filename: src/lib.rs
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}
Listing 7-16: تغییر نام یک نوع هنگام وارد کردن به محدوده با کلمه کلیدی as

در دستور دوم use، ما نام جدید IoResult را برای نوع std::io::Result انتخاب کردیم، که با نوع Result از std::fmt که آن را نیز وارد محدوده کرده‌ایم، تضاد نخواهد داشت. هر دو لیستینگ 7-15 و 7-16 ایدیوماتیک در نظر گرفته می‌شوند، بنابراین انتخاب با شماست!

دوباره صادر کردن نام‌ها با pub use

وقتی با استفاده از کلیدواژه‌ی use یک نام را وارد حوزه‌ای می‌کنیم،
آن نام تنها در همان حوزه خصوصی است که در آن وارد شده است.
برای این‌که کد خارج از آن حوزه نیز بتواند به آن نام دسترسی داشته باشد،
انگار که در همان حوزه تعریف شده است، می‌توانیم pub و use را با هم ترکیب کنیم.
این تکنیک re-exporting نامیده می‌شود، زیرا در حالی که یک آیتم را وارد حوزه می‌کنیم،
همزمان آن را برای دیگران نیز قابل دسترس می‌کنیم تا بتوانند آن را وارد حوزه‌ی خود کنند.

لیستینگ 7-17 کد موجود در لیستینگ 7-11 را با تغییر دستور use در ماژول ریشه به pub use نشان می‌دهد.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-17: در دسترس قرار دادن یک نام برای هر کدی که از محدوده جدید استفاده می‌کند با pub use

قبل از این تغییر، کد خارجی باید تابع add_to_waitlist را با استفاده از مسیر restaurant::front_of_house::hosting::add_to_waitlist() فراخوانی می‌کرد، که همچنین نیاز داشت ماژول front_of_house به عنوان pub علامت‌گذاری شود. حالا که این pub use ماژول hosting را از ماژول ریشه دوباره صادر کرده است، کد خارجی می‌تواند از مسیر restaurant::hosting::add_to_waitlist() استفاده کند.

Re-exporting زمانی مفید است که ساختار داخلی کد شما با نحوه‌ی تفکر برنامه‌نویسانی که از کد شما استفاده می‌کنند درباره‌ی دامنه، متفاوت باشد. برای مثال، در این تمثیل رستوران، کسانی که رستوران را اداره می‌کنند درباره‌ی «بخش جلویی» (front of house) و «بخش پشتی» (back of house) فکر می‌کنند. اما مشتریانی که به رستوران می‌آیند احتمالاً درباره‌ی قسمت‌های رستوران با چنین اصطلاحاتی فکر نمی‌کنند. با استفاده از pub use می‌توانیم کد خود را با یک ساختار بنویسیم ولی ساختاری متفاوت را در معرض استفاده قرار دهیم. این کار باعث می‌شود کتابخانه‌ی ما هم برای برنامه‌نویسانی که روی کتابخانه کار می‌کنند و هم برای برنامه‌نویسانی که از آن استفاده می‌کنند، به‌خوبی سازمان‌دهی شده باشد. در فصل ۱۴، در بخش “صادرات یک API عمومی راحت با استفاده از pub use، مثال دیگری از pub use و تأثیر آن بر مستندات crate شما را بررسی خواهیم کرد.

استفاده از بسته‌های خارجی

در فصل ۲، ما یک پروژه بازی حدس‌زنی برنامه‌ریزی کردیم که از یک بسته خارجی به نام rand برای تولید اعداد تصادفی استفاده می‌کرد. برای استفاده از rand در پروژه خود، این خط را به Cargo.toml اضافه کردیم:

Filename: Cargo.toml
rand = "0.8.5"

اضافه کردن rand به عنوان یک وابستگی در Cargo.toml به Cargo می‌گوید که بسته rand و هرگونه وابستگی را از crates.io دانلود کرده و rand را در پروژه ما در دسترس قرار دهد.

سپس، برای وارد کردن تعاریف crate rand به حوزه‌ی پکیج خود، یک خط use اضافه کردیم که با نام crate، یعنی rand، آغاز شد و آیتم‌هایی را که می‌خواستیم وارد حوزه کنیم، فهرست کردیم. به یاد داشته باشید که در بخش “تولید یک عدد تصادفی” در فصل ۲، trait مربوط به Rng را وارد حوزه کردیم و تابع rand::thread_rng را فراخوانی نمودیم:

use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

اعضای جامعه Rust بسیاری از بسته‌ها را در crates.io به اشتراک گذاشته‌اند، و وارد کردن هر یک از آن‌ها به بسته شما شامل این مراحل است: فهرست کردن آن‌ها در فایل Cargo.toml بسته شما و استفاده از use برای وارد کردن آیتم‌ها از جعبه (crate) آن‌ها به محدوده.

توجه داشته باشید که کتابخانه استاندارد std نیز یک جعبه (crate) خارجی برای بسته ما است. از آنجا که کتابخانه استاندارد همراه با زبان Rust ارائه می‌شود، نیازی به تغییر Cargo.toml برای گنجاندن std نداریم. اما برای وارد کردن آیتم‌ها از آن به محدوده بسته خود، باید به آن با use ارجاع دهیم. برای مثال، با HashMap از این خط استفاده می‌کردیم:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

این یک مسیر مطلق است که با std، نام جعبه (crate) کتابخانه استاندارد، شروع می‌شود.

استفاده از مسیرهای تو در تو برای ساده‌سازی لیست‌های بزرگ use

اگر از چندین آیتم تعریف‌شده در یک جعبه (crate) یا ماژول استفاده کنیم، فهرست کردن هر آیتم در خط خود می‌تواند فضای عمودی زیادی در فایل‌های ما اشغال کند. برای مثال، این دو دستور use که در بازی حدس‌زنی در لیستینگ ۲-۴ استفاده کردیم آیتم‌هایی از std را به محدوده می‌آورند:

Filename: src/main.rs
use rand::Rng;
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

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

Filename: src/main.rs
use rand::Rng;
// --snip--
use std::{cmp::Ordering, io};
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}
Listing 7-18: مشخص کردن یک مسیر تو در تو برای وارد کردن چندین آیتم با پیشوند مشابه به محدوده

در برنامه‌های بزرگ‌تر، وارد کردن بسیاری از آیتم‌ها از یک جعبه (crate) یا ماژول مشابه با استفاده از مسیرهای تو در تو می‌تواند تعداد دستورات use جداگانه مورد نیاز را به طور قابل‌توجهی کاهش دهد.

ما می‌توانیم در هر سطحی از یک مسیر، از یک مسیر تو در تو استفاده کنیم، که این کار در مواقعی که دو دستور use دارای یک زیرمسیر مشترک هستند، مفید است. برای مثال، لیستینگ 7-19 دو دستور use را نشان می‌دهد: یکی که std::io را به محدوده وارد می‌کند و دیگری که std::io::Write را به محدوده وارد می‌کند.

Filename: src/lib.rs
use std::io;
use std::io::Write;
Listing 7-19: دو دستور use که یکی زیرمسیر دیگری است

بخش مشترک این دو مسیر، std::io است که مسیر کامل اولین دستور use را تشکیل می‌دهد. برای ترکیب این دو مسیر به یک دستور use، می‌توانیم از self در مسیر تو در تو استفاده کنیم، همان‌طور که در لیستینگ 7-20 نشان داده شده است.

Filename: src/lib.rs
use std::io::{self, Write};
Listing 7-20: ترکیب مسیرهای موجود در لیستینگ 7-19 به یک دستور use

این خط، std::io و std::io::Write را به محدوده وارد می‌کند.

عملگر Glob

اگر بخواهیم تمام آیتم‌های عمومی تعریف‌شده در یک مسیر را به محدوده وارد کنیم، می‌توانیم آن مسیر را به همراه عملگر * مشخص کنیم:

#![allow(unused)]
fn main() {
use std::collections::*;
}

این دستور use تمام آیتم‌های عمومی تعریف‌شده در std::collections را وارد حوزه‌ی فعلی می‌کند. در استفاده از عملگر glob دقت کنید! استفاده از glob می‌تواند باعث شود تشخیص این‌که چه نام‌هایی در حوزه هستند و یک نام استفاده‌شده در برنامه از کجا آمده، دشوارتر شود. علاوه بر این، اگر وابستگی تغییراتی در تعاریف خود ایجاد کند، آن‌چه شما وارد کرده‌اید نیز تغییر می‌کند، که ممکن است هنگام به‌روزرسانی وابستگی، باعث بروز خطای کامپایلر شود— برای مثال، اگر وابستگی تعریفی با همان نامی اضافه کند که شما نیز در همان حوزه تعریف کرده‌اید.

عملگر glob اغلب هنگام تست برای وارد کردن تمام آیتم‌های تحت تست به ماژول tests استفاده می‌شود؛ در فصل ۱۱ در بخش “چگونه تست بنویسیم” درباره‌ی آن صحبت خواهیم کرد. همچنین، عملگر glob گاهی در قالب الگوی prelude نیز به‌کار می‌رود؛ برای اطلاعات بیشتر درباره‌ی این الگو، به مستندات کتابخانه‌ی استاندارد مراجعه کنید.

جدا کردن ماژول‌ها به فایل‌های مختلف

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

برای مثال، بیایید از کد موجود در لیستینگ 7-17 شروع کنیم که شامل چندین ماژول مرتبط با رستوران بود. ما این ماژول‌ها را به جای تعریف در فایل ریشه جعبه (crate)، به فایل‌های جداگانه منتقل می‌کنیم. در این مثال، فایل ریشه جعبه (crate) src/lib.rs است، اما این روش برای جعبه‌ها (crates)ی باینری که فایل ریشه آن‌ها src/main.rs است نیز کار می‌کند.

ابتدا ماژول front_of_house را به فایل خودش منتقل می‌کنیم. کدی که داخل آکولادهای ماژول front_of_house است را حذف کرده و فقط اعلان mod front_of_house; را باقی می‌گذاریم. نتیجه کد در src/lib.rs مانند لیستینگ 7-21 خواهد بود. توجه داشته باشید که این کد تا زمانی که فایل src/front_of_house.rs مطابق لیستینگ 7-22 ایجاد نشود کامپایل نخواهد شد.

Filename: src/lib.rs
mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-21: اعلان ماژول front_of_house که بدنه آن در src/front_of_house.rs خواهد بود

سپس، کدی که داخل آکولادهای ماژول front_of_house بود را به یک فایل جدید به نام src/front_of_house.rs منتقل می‌کنیم، همان‌طور که در لیستینگ 7-22 نشان داده شده است. کامپایلر می‌داند که باید این فایل را بررسی کند زیرا در فایل ریشه جعبه (crate) با نام front_of_house اعلان ماژول را دیده است.

Filename: src/front_of_house.rs
pub mod hosting {
    pub fn add_to_waitlist() {}
}
Listing 7-22: تعریف‌های داخل ماژول front_of_house در src/front_of_house.rs

توجه داشته باشید که شما فقط یک بار نیاز دارید تا یک فایل را با استفاده از دستور mod در درخت ماژول خود بارگذاری کنید. وقتی کامپایلر می‌فهمد که فایل بخشی از پروژه است (و می‌فهمد که کد در کجای درخت ماژول قرار دارد به خاطر جایی که دستور mod را قرار داده‌اید)، سایر فایل‌های پروژه شما باید با استفاده از مسیری که به محل اعلان فایل اشاره می‌کند به کد بارگذاری شده ارجاع دهند، همان‌طور که در بخش «مسیرها برای اشاره به یک آیتم در درخت ماژول» توضیح داده شد. به عبارت دیگر، mod یک عملیات “شامل کردن” (include) نیست که ممکن است در زبان‌های برنامه‌نویسی دیگر دیده باشید.

در مرحله بعد، ماژول hosting را به فایل خودش منتقل می‌کنیم. این فرآیند کمی متفاوت است زیرا hosting یک زیرماژول از front_of_house است، نه از ماژول ریشه. فایل مربوط به hosting را در یک دایرکتوری جدید قرار می‌دهیم که به نام والدین آن در درخت ماژول نام‌گذاری شده است، که در اینجا src/front_of_house است.

برای شروع انتقال hosting، فایل src/front_of_house.rs را تغییر می‌دهیم تا فقط شامل اعلان ماژول hosting باشد:

Filename: src/front_of_house.rs
pub mod hosting;

سپس یک دایرکتوری به نام src/front_of_house و یک فایل hosting.rs ایجاد می‌کنیم تا تعریف‌هایی که در ماژول hosting انجام شده‌اند را در آن قرار دهیم:

Filename: src/front_of_house/hosting.rs
pub fn add_to_waitlist() {}

اگر به جای آن فایل hosting.rs را در دایرکتوری src قرار دهیم، کامپایلر انتظار خواهد داشت که کد hosting.rs در یک ماژول hosting که در ریشه جعبه (crate) اعلان شده باشد قرار داشته باشد، نه به عنوان یک زیرماژول از ماژول front_of_house. قوانین کامپایلر برای مشخص کردن این که کدام فایل‌ها برای کدام ماژول‌ها بررسی شوند، به این معناست که دایرکتوری‌ها و فایل‌ها با درخت ماژول مطابقت بیشتری دارند.

مسیرهای فایل جایگزین

تاکنون مسیرهای فایل ایدیوماتیک را که کامپایلر Rust استفاده می‌کند پوشش داده‌ایم، اما Rust از یک سبک قدیمی‌تر از مسیر فایل نیز پشتیبانی می‌کند. برای یک ماژول به نام front_of_house که در ریشه جعبه (crate) اعلان شده است، کامپایلر کد ماژول را در مکان‌های زیر جستجو می‌کند:

  • src/front_of_house.rs (روشی که پوشش داده شد)
  • src/front_of_house/mod.rs (مسیر قدیمی‌تر، همچنان پشتیبانی‌شده)

برای یک ماژول به نام hosting که زیرماژولی از front_of_house است، کامپایلر کد ماژول را در مکان‌های زیر جستجو می‌کند:

  • src/front_of_house/hosting.rs (روشی که پوشش داده شد)
  • src/front_of_house/hosting/mod.rs (مسیر قدیمی‌تر، همچنان پشتیبانی‌شده)

اگر هر دو سبک را برای یک ماژول استفاده کنید، یک خطای کامپایلر دریافت خواهید کرد. استفاده از ترکیبی از هر دو سبک برای ماژول‌های مختلف در یک پروژه مجاز است، اما ممکن است برای کسانی که پروژه شما را مرور می‌کنند گیج‌کننده باشد.

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

ما کد هر ماژول را به یک فایل جداگانه منتقل کرده‌ایم و درخت ماژول به همان شکل باقی مانده است. فراخوانی توابع در eat_at_restaurant بدون هیچ تغییری کار خواهد کرد، حتی اگر تعریف‌ها در فایل‌های مختلف قرار داشته باشند. این تکنیک به شما امکان می‌دهد ماژول‌ها را به فایل‌های جدید منتقل کنید زیرا اندازه آن‌ها افزایش می‌یابد.

توجه داشته باشید که دستور pub use crate::front_of_house::hosting در src/lib.rs نیز تغییری نکرده است، و همچنین use هیچ تأثیری بر اینکه چه فایل‌هایی به عنوان بخشی از جعبه (crate) کامپایل شوند ندارد. کلمه کلیدی mod ماژول‌ها را اعلان می‌کند و Rust در فایلی با همان نام ماژول به دنبال کدی می‌گردد که وارد آن ماژول شود.

خلاصه

Rust به شما اجازه می‌دهد یک بسته را به چندین جعبه (crate) و یک جعبه (crate) را به ماژول‌ها تقسیم کنید تا بتوانید به آیتم‌هایی که در یک ماژول تعریف شده‌اند از ماژول دیگری ارجاع دهید. می‌توانید این کار را با مشخص کردن مسیرهای مطلق یا نسبی انجام دهید. این مسیرها می‌توانند با یک دستور use به محدوده وارد شوند تا بتوانید از یک مسیر کوتاه‌تر برای استفاده‌های متعدد از آن آیتم در آن محدوده استفاده کنید. کد ماژول به صورت پیش‌فرض خصوصی است، اما می‌توانید با افزودن کلمه کلیدی pub تعریف‌ها را عمومی کنید.

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

مجموعه‌های معمول

کتابخانه‌ی استاندارد Rust شامل تعدادی ساختار داده‌ی بسیار مفید به نام collections (مجموعه‌ها) است. اکثر انواع داده‌ی دیگر نمایانگر یک مقدار خاص هستند، اما مجموعه‌ها می‌توانند چندین مقدار را در خود نگه دارند. بر خلاف انواع داخلی مانند array و tuple، داده‌هایی که این مجموعه‌ها به آن‌ها اشاره می‌کنند، روی heap ذخیره می‌شوند؛ به این معنا که نیازی نیست اندازه‌ی داده‌ها در زمان کامپایل مشخص باشد، و این اندازه می‌تواند در طول اجرای برنامه رشد یا کاهش یابد. هر نوع مجموعه قابلیت‌ها و هزینه‌های متفاوتی دارد، و انتخاب مجموعه‌ی مناسب برای موقعیت فعلی، مهارتی است که با گذشت زمان به دست خواهید آورد. در این فصل، درباره‌ی سه نوع مجموعه که بسیار در برنامه‌های Rust استفاده می‌شوند صحبت خواهیم کرد:

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

برای یادگیری درباره انواع دیگر مجموعه‌هایی که توسط کتابخانه استاندارد ارائه شده‌اند، مستندات را مشاهده کنید.

ما درباره نحوه ایجاد و به‌روزرسانی بردارها، رشته‌ها و هش مپ‌ها، همچنین ویژگی‌هایی که هر کدام را خاص می‌کند، صحبت خواهیم کرد.

ذخیره لیست‌هایی از مقادیر با بردارها

اولین نوع مجموعه‌ای که به آن خواهیم پرداخت، Vec<T> یا همان بردار است. بردارها به شما اجازه می‌دهند که بیش از یک مقدار را در یک ساختار داده ذخیره کنید که تمامی مقادیر را در کنار یکدیگر در حافظه قرار می‌دهد. بردارها فقط می‌توانند مقادیر از یک نوع را ذخیره کنند. این ابزار زمانی مفید است که لیستی از آیتم‌ها مانند خطوط متنی در یک فایل یا قیمت آیتم‌ها در یک سبد خرید داشته باشید.

ایجاد یک بردار جدید

برای ایجاد یک بردار خالی جدید، از تابع Vec::new استفاده می‌کنیم، همانطور که در لیست ۸-۱ نشان داده شده است.

fn main() {
    let v: Vec<i32> = Vec::new();
}
Listing 8-1: ایجاد یک بردار جدید و خالی برای نگهداری مقادیر نوع i32

توجه داشته باشید که ما یک توضیح نوع اضافه کرده‌ایم. چون ما هیچ مقداری به این بردار اضافه نکرده‌ایم، Rust نمی‌داند چه نوع عناصری را قصد داریم ذخیره کنیم. این نکته مهمی است. بردارها با استفاده از جنریک‌ها پیاده‌سازی شده‌اند؛ در فصل ۱۰ خواهیم دید که چگونه می‌توان جنریک‌ها را در انواع خودتان استفاده کرد. در حال حاضر بدانید که نوع Vec<T> ارائه شده توسط کتابخانه استاندارد می‌تواند هر نوعی را نگهداری کند. وقتی یک بردار برای نگهداری نوع خاصی ایجاد می‌کنیم، می‌توانیم نوع موردنظر را داخل براکت‌های زاویه‌ای مشخص کنیم. در لیست ۸-۱، ما به Rust اعلام کرده‌ایم که بردار Vec<T> در v عناصر نوع i32 را نگهداری خواهد کرد.

بیشتر اوقات، شما یک Vec<T> با مقادیر اولیه ایجاد خواهید کرد و Rust نوع مقادیر را از روی آنها استنتاج خواهد کرد، بنابراین به ندرت نیاز به توضیح نوع خواهید داشت. Rust به راحتی ماکروی vec! را فراهم می‌کند که یک بردار جدید ایجاد کرده و مقادیر مورد نظر شما را در آن قرار می‌دهد. لیست ۸-۲ یک بردار جدید Vec<i32> را ایجاد می‌کند که مقادیر 1، 2 و 3 را نگهداری می‌کند. نوع عدد صحیح i32 است چون این نوع پیش‌فرض برای اعداد صحیح است، همانطور که در بخش “انواع داده‌ها” فصل ۳ بحث کردیم.

fn main() {
    let v = vec![1, 2, 3];
}
Listing 8-2: ایجاد یک بردار جدید شامل مقادیر

چون مقادیر اولیه i32 داده‌ایم، Rust می‌تواند استنتاج کند که نوع v Vec<i32> است و نیازی به توضیح نوع نیست. حالا به نحوه به‌روزرسانی یک بردار خواهیم پرداخت.

به‌روزرسانی یک بردار

برای ایجاد یک بردار و سپس اضافه کردن عناصر به آن، می‌توانیم از متد push استفاده کنیم، همانطور که در لیست ۸-۳ نشان داده شده است.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}
Listing 8-3: استفاده از متد push برای اضافه کردن مقادیر به یک بردار

همانطور که با هر متغیری دیگر انجام می‌دهیم، اگر بخواهیم بتوانیم مقدار آن را تغییر دهیم، باید آن را با استفاده از کلیدواژه mut قابل تغییر کنیم، همانطور که در فصل ۳ بحث شد. اعدادی که در داخل بردار قرار می‌دهیم همه از نوع i32 هستند و Rust این نوع را از داده‌ها استنتاج می‌کند، بنابراین نیازی به توضیح نوع Vec<i32> نیست.

خواندن عناصر بردار

دو روش برای ارجاع به یک مقدار ذخیره شده در بردار وجود دارد: از طریق استفاده از اندیس (index)یا با استفاده از متد get. در مثال‌های زیر، انواع مقادیر بازگشتی از این توابع برای وضوح بیشتر مشخص شده‌اند.

لیست ۸-۴ هر دو روش دسترسی به یک مقدار در بردار، با استفاده از سینتکس اندیس (index)و متد get را نشان می‌دهد.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}
Listing 8-4: استفاده از سینتکس اندیس (index)و متد get برای دسترسی به یک آیتم در بردار

به چند جزئیات اینجا توجه کنید. ما از مقدار اندیس (index)2 برای دسترسی به عنصر سوم استفاده می‌کنیم زیرا بردارها با شماره از صفر اندیس‌گذاری می‌شوند. استفاده از & و [] یک مرجع به عنصر در مقدار اندیس (index)را به ما می‌دهد. وقتی از متد get با اندیسی که به عنوان آرگومان داده می‌شود استفاده می‌کنیم، یک Option<&T> دریافت می‌کنیم که می‌توانیم با match از آن استفاده کنیم.

Rust این دو روش ارجاع به یک عنصر را ارائه می‌دهد تا بتوانید انتخاب کنید که برنامه شما چگونه رفتار کند وقتی تلاش می‌کنید از یک مقدار اندیس (index)خارج از محدوده عناصر موجود استفاده کنید. به عنوان یک مثال، بیایید ببینیم چه اتفاقی می‌افتد وقتی یک بردار با پنج عنصر داشته باشیم و سپس تلاش کنیم به یک عنصر در اندیس (index)۱۰۰ با هر دو تکنیک دسترسی پیدا کنیم، همانطور که در لیست ۸-۵ نشان داده شده است.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}
Listing 8-5: تلاش برای دسترسی به عنصر در اندیس (index)۱۰۰ در یک بردار شامل پنج عنصر

وقتی این کد را اجرا می‌کنیم، روش اول [] باعث می‌شود برنامه متوقف شود زیرا به یک عنصر غیرموجود اشاره می‌کند. این روش زمانی بهترین استفاده را دارد که بخواهید برنامه‌تان در صورت تلاش برای دسترسی به عنصری خارج از انتهای بردار، متوقف شود.

وقتی متد get یک اندیس (index)خارج از بردار دریافت می‌کند، مقدار None را بدون متوقف کردن برنامه بازمی‌گرداند. شما از این روش استفاده می‌کنید اگر دسترسی به عنصری خارج از محدوده بردار ممکن است گاه‌به‌گاه در شرایط عادی رخ دهد. کد شما سپس منطق لازم برای مدیریت داشتن Some(&element) یا None را خواهد داشت، همانطور که در فصل ۶ بحث شد. برای مثال، اندیس (index)ممکن است از یک عدد ورودی توسط کاربر بیاید. اگر کاربر تصادفاً عددی وارد کند که بیش از حد بزرگ باشد و برنامه مقدار None دریافت کند، شما می‌توانید به کاربر اطلاع دهید که چند آیتم در بردار موجود است و به او فرصت دیگری برای وارد کردن یک مقدار معتبر بدهید. این راهکار برای کاربر پسندتر است تا این که برنامه به دلیل یک اشتباه تایپی متوقف شود!

وقتی برنامه یک مرجع معتبر دارد، بررسی‌کننده قرض قوانین مالکیت و قرض‌گیری (که در فصل ۴ پوشش داده شد) را اعمال می‌کند تا اطمینان حاصل کند که این مرجع و هر مرجع دیگری به محتوای بردار معتبر باقی می‌مانند. به یاد بیاورید که قانون بیان می‌کند نمی‌توانید مرجع‌های قابل تغییر و غیرقابل تغییر را در یک حوزه داشته باشید. این قانون در لیست ۸-۶ اعمال می‌شود، جایی که یک مرجع غیرقابل تغییر به اولین عنصر در یک بردار نگه داشته شده است و سعی داریم یک عنصر به انتها اضافه کنیم. این برنامه زمانی کار نخواهد کرد اگر همچنین بخواهیم بعداً در تابع به آن عنصر ارجاع دهیم.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("The first element is: {first}");
}
Listing 8-6: تلاش برای اضافه کردن یک عنصر به یک بردار در حالی که یک مرجع به یک آیتم نگه داشته شده است

کامپایل کردن این کد به این خطا منجر می‌شود:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                     ------- immutable borrow later used here

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

کد در لیست ۸-۶ ممکن است به نظر بیاید که باید کار کند: چرا یک مرجع به اولین عنصر باید به تغییرات انتهای بردار اهمیت دهد؟ این خطا به نحوه کار بردارها مربوط است: چون بردارها مقادیر را در کنار یکدیگر در حافظه قرار می‌دهند، اضافه کردن یک عنصر جدید به انتهای بردار ممکن است نیازمند اختصاص حافظه جدید و کپی کردن عناصر قدیمی به مکان جدید باشد، اگر فضای کافی برای قرار دادن همه عناصر در کنار یکدیگر در محل کنونی بردار وجود نداشته باشد. در این حالت، مرجع به اولین عنصر به حافظه‌ای اشاره می‌کند که آزاد شده است. قوانین قرض‌گیری از به وجود آمدن این شرایط در برنامه‌ها جلوگیری می‌کنند.

نکته: برای اطلاعات بیشتر درباره جزئیات پیاده‌سازی نوع Vec<T>، به “The Rustonomicon” مراجعه کنید.

پیمایش بر روی مقادیر در یک بردار

برای دسترسی به هر عنصر در یک بردار به ترتیب، می‌توانیم به جای استفاده از اندیس‌ها برای دسترسی به یک عنصر در هر بار، بر روی تمامی عناصر پیمایش کنیم. لیست ۸-۷ نشان می‌دهد چگونه می‌توان از یک حلقه for برای گرفتن مرجع‌های غیرقابل تغییر به هر عنصر در یک بردار از مقادیر i32 استفاده کرد و آنها را چاپ کرد.

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}
Listing 8-7: چاپ هر عنصر در یک بردار با پیمایش بر روی عناصر با استفاده از یک حلقه for

همچنین می‌توانیم بر روی مرجع‌های قابل تغییر به هر عنصر در یک بردار قابل تغییر پیمایش کنیم تا تغییراتی روی تمام عناصر اعمال کنیم. حلقه for در لیست ۸-۸ مقدار 50 را به هر عنصر اضافه می‌کند.

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}
Listing 8-8: پیمایش بر روی مرجع‌های قابل تغییر به عناصر در یک بردار

برای تغییر مقداری که رفرنس قابل‌تغییر به آن اشاره می‌کند، باید از عملگر * برای dereference کردن استفاده کنیم تا به مقدار درون i دسترسی پیدا کنیم و سپس بتوانیم از عملگر += استفاده کنیم. در بخش “دنبال کردن رفرنس تا رسیدن به مقدار” در فصل ۱۵، بیشتر درباره‌ی عملگر dereference صحبت خواهیم کرد.

پیمایش بر روی یک بردار، چه به صورت غیرقابل تغییر و چه به صورت قابل تغییر، امن است زیرا از قوانین بررسی‌کننده قرض پیروی می‌کند. اگر بخواهیم در بدنه حلقه‌های for در لیست ۸-۷ و لیست ۸-۸ آیتم‌ها را درج یا حذف کنیم، با خطای کامپایل مشابهی با کدی که در لیست ۸-۶ دیدیم روبرو خواهیم شد. مرجع به برداری که حلقه for نگه می‌دارد از تغییر همزمان کل بردار جلوگیری می‌کند.

استفاده از Enum برای ذخیره انواع مختلف

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

برای مثال، فرض کنید می‌خواهیم مقادیر یک ردیف از یک صفحه گسترده را که برخی از ستون‌های آن شامل اعداد صحیح، برخی شامل اعداد اعشاری و برخی شامل رشته‌ها می‌باشند، دریافت کنیم. می‌توانیم یک enum تعریف کنیم که متغیرهای آن انواع مختلف مقادیر را نگهداری کنند، و تمام متغیرهای enum به عنوان یک نوع مشابه (یعنی نوع enum) در نظر گرفته می‌شوند. سپس می‌توانیم یک بردار ایجاد کنیم که این enum را نگهداری کند و در نتیجه انواع مختلف را ذخیره کند. این موضوع در لیست ۸-۹ نمایش داده شده است.

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];
}
Listing 8-9: تعریف یک enum برای ذخیره انواع مختلف در یک بردار

Rust باید بداند چه انواعی در بردار خواهند بود تا بتواند در زمان کامپایل دقیقاً مشخص کند چه مقدار حافظه در heap برای ذخیره هر عنصر نیاز است. همچنین باید به طور صریح مشخص کنیم که چه انواعی در این بردار مجاز هستند. اگر Rust اجازه می‌داد که بردار هر نوعی را نگهداری کند، احتمال داشت که یک یا چند نوع باعث ایجاد خطا در عملیات انجام شده روی عناصر بردار شوند. استفاده از یک enum به علاوه یک عبارت match به این معنی است که Rust در زمان کامپایل اطمینان حاصل خواهد کرد که تمام حالت‌های ممکن مدیریت شده‌اند، همانطور که در فصل ۶ بحث شد.

اگر مجموعه جامعی از انواعی که برنامه در زمان اجرا دریافت می‌کند و باید در بردار ذخیره شود را نمی‌دانید، تکنیک enum کار نخواهد کرد. به جای آن، می‌توانید از یک شیء ویژگی (trait object) استفاده کنید که در فصل ۱۸ مورد بررسی قرار خواهد گرفت.

اکنون که برخی از رایج‌ترین روش‌های استفاده از بردارها را بحث کردیم، مطمئن شوید که مستندات API را برای تمام متدهای مفیدی که کتابخانه استاندارد روی Vec<T> تعریف کرده است مرور کنید. برای مثال، علاوه بر push، متد pop عنصر آخر را حذف کرده و بازمی‌گرداند.

حذف یک بردار، عناصر آن را نیز حذف می‌کند

مانند هر struct دیگری، یک بردار وقتی از محدوده خارج می‌شود آزاد می‌شود، همانطور که در لیست ۸-۱۰ نشان داده شده است.

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}
Listing 8-10: نمایش زمان حذف بردار و عناصر آن

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

حال به نوع مجموعه بعدی می‌پردازیم: String!

ذخیره متن‌های کدگذاری شده UTF-8 با رشته‌ها (strings)

ما در فصل ۴ درباره رشته‌ها صحبت کردیم، اما اکنون به آن‌ها با عمق بیشتری نگاه خواهیم کرد. Rustaceanهای تازه‌وارد معمولاً به دلیل ترکیبی از سه عامل در رشته‌ها دچار مشکل می‌شوند: گرایش Rust به آشکارسازی خطاهای ممکن، رشته‌ها به عنوان یک ساختار داده پیچیده‌تر از آنچه بسیاری از برنامه‌نویسان تصور می‌کنند، و UTF-8. این عوامل به نحوی ترکیب می‌شوند که می‌توانند برای کسانی که از زبان‌های برنامه‌نویسی دیگر می‌آیند دشوار باشند.

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

رشته (string) چیست؟

ابتدا تعریف می‌کنیم که منظور ما از اصطلاح رشته چیست. Rust فقط یک نوع رشته در زبان هسته خود دارد که همان قطعه رشته str است که معمولاً به صورت قرض گرفته شده &str دیده می‌شود. در فصل ۴ درباره قطعه‌های رشته صحبت کردیم، که ارجاعاتی به داده‌های رشته‌ای کدگذاری شده UTF-8 هستند که در جای دیگری ذخیره شده‌اند. به عنوان مثال، رشته‌های لیترال در باینری برنامه ذخیره می‌شوند و بنابراین قطعه‌های رشته هستند.

نوع String، که توسط کتابخانه استاندارد Rust ارائه شده است و نه مستقیماً در زبان هسته کدگذاری شده، یک نوع رشته رشدپذیر، قابل تغییر، و مالک UTF-8 است. وقتی Rustaceanها به “رشته‌ها” در Rust اشاره می‌کنند، ممکن است به نوع String یا قطعه رشته &str اشاره کنند، نه فقط یکی از این دو نوع. اگرچه این بخش عمدتاً درباره String است، اما هر دو نوع در کتابخانه استاندارد Rust به شدت مورد استفاده قرار می‌گیرند و هر دو String و قطعه‌های رشته کدگذاری UTF-8 دارند.

ایجاد یک رشته (strings) جدید

بسیاری از عملیات مشابه موجود در Vec<T> برای String نیز در دسترس است، زیرا String در واقع به عنوان یک پوششی بر روی یک بردار از بایت‌ها پیاده‌سازی شده است، با برخی ضمانت‌ها، محدودیت‌ها، و قابلیت‌های اضافی. مثالی از یک تابع که به همان روش با Vec<T> و String کار می‌کند، تابع new برای ایجاد یک نمونه است، همانطور که در لیست ۸-۱۱ نشان داده شده است.

fn main() {
    let mut s = String::new();
}
Listing 8-11: ایجاد یک String جدید و خالی

این خط یک رشته جدید و خالی به نام s ایجاد می‌کند که می‌توانیم داده‌ها را در آن بارگذاری کنیم. اغلب، داده‌های اولیه‌ای خواهیم داشت که می‌خواهیم رشته را با آن‌ها شروع کنیم. برای این کار، از متد to_string استفاده می‌کنیم که بر روی هر نوعی که ویژگی Display را پیاده‌سازی می‌کند، همانند رشته‌های لیترال، در دسترس است. لیست ۸-۱۲ دو مثال را نشان می‌دهد.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}
Listing 8-12: استفاده از متد to_string برای ایجاد یک String از یک رشته لیترال

این کد یک رشته حاوی initial contents ایجاد می‌کند.

ما همچنین می‌توانیم از تابع String::from برای ایجاد یک String از یک رشته لیترال استفاده کنیم. کد در لیست ۸-۱۳ معادل کدی است که در لیست ۸-۱۲ از to_string استفاده می‌کند.

fn main() {
    let s = String::from("initial contents");
}
Listing 8-13: استفاده از تابع String::from برای ایجاد یک String از یک رشته لیترال

از آنجا که رشته‌ها برای موارد بسیاری استفاده می‌شوند، می‌توانیم از بسیاری از APIهای جنریک مختلف برای رشته‌ها استفاده کنیم که گزینه‌های زیادی را در اختیار ما قرار می‌دهند. برخی از این‌ها ممکن است به نظر اضافی بیایند، اما هرکدام جایگاه خاص خود را دارند! در این مورد، String::from و to_string عملکرد یکسانی دارند، بنابراین انتخاب بین آن‌ها مسئله سبک و خوانایی کد است.

به یاد داشته باشید که رشته‌ها با کدگذاری UTF-8 هستند، بنابراین می‌توانیم هر داده‌ای که به طور صحیح کدگذاری شده باشد را در آن‌ها قرار دهیم، همانطور که در لیست ۸-۱۴ نشان داده شده است.

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}
Listing 8-14: ذخیره سلام‌ها به زبان‌های مختلف در رشته‌ها

تمام این موارد مقادیر معتبر String هستند.

به‌روزرسانی یک رشته

یک String می‌تواند از نظر اندازه رشد کند و محتوای آن تغییر کند، همانطور که محتوای یک Vec<T> تغییر می‌کند، اگر داده بیشتری به آن اضافه کنیم. علاوه بر این، می‌توانیم به راحتی از عملگر + یا ماکروی format! برای الحاق مقادیر String استفاده کنیم.

الحاق به یک رشته (string) با push_str و push

ما می‌توانیم یک String را با استفاده از متد push_str برای الحاق یک قطعه رشته رشد دهیم، همانطور که در لیست ۸-۱۵ نشان داده شده است.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Listing 8-15: الحاق یک قطعه رشته به یک String با استفاده از متد push_str

بعد از این دو خط، مقدار s شامل foobar خواهد بود. متد push_str یک قطعه رشته را به عنوان آرگومان می‌گیرد زیرا ما لزوماً نمی‌خواهیم مالکیت پارامتر را بگیریم. برای مثال، در کدی که در لیست ۸-۱۶ نشان داده شده است، ما می‌خواهیم بتوانیم پس از الحاق محتوای s2 به s1 همچنان از s2 استفاده کنیم.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}
Listing 8-16: استفاده از یک قطعه رشته پس از الحاق محتوای آن به یک String

اگر متد push_str مالکیت s2 را می‌گرفت، نمی‌توانستیم مقدار آن را در خط آخر چاپ کنیم. با این حال، این کد همانطور که انتظار می‌رود کار می‌کند!

متد push یک کاراکتر را به عنوان پارامتر می‌گیرد و آن را به String اضافه می‌کند. لیست ۸-۱۷ حرف l را با استفاده از متد push به یک String اضافه می‌کند.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
Listing 8-17: اضافه کردن یک کاراکتر به مقدار String با استفاده از push

در نتیجه، مقدار s شامل lol خواهد بود.

الحاق با استفاده از عملگر + یا ماکروی format!

اغلب، ممکن است بخواهید دو رشته موجود را با هم ترکیب کنید. یکی از راه‌های انجام این کار استفاده از عملگر + است، همانطور که در لیست ۸-۱۸ نشان داده شده است.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
Listing 8-18: استفاده از عملگر + برای ترکیب دو مقدار String در یک مقدار String جدید

مقدار s3 شامل Hello, world! خواهد بود. دلیل اینکه s1 پس از این الحاق دیگر معتبر نیست و دلیل اینکه ما از یک مرجع به s2 استفاده کردیم، به امضای متدی که هنگام استفاده از عملگر + فراخوانی می‌شود مربوط است. عملگر + از متد add استفاده می‌کند که امضای آن به شکل زیر است:

fn add(self, s: &str) -> String {

در کتابخانه استاندارد، شما add را خواهید دید که با استفاده از جنریک‌ها و انواع مرتبط تعریف شده است. اینجا، ما انواع مشخصی را جایگزین کرده‌ایم، که این همان چیزی است که هنگام فراخوانی این متد با مقادیر String اتفاق می‌افتد. درباره جنریک‌ها در فصل ۱۰ صحبت خواهیم کرد. این امضا به ما سرنخ‌هایی می‌دهد تا بتوانیم بخش‌های چالش‌برانگیز عملگر + را درک کنیم.

اول، s2 یک & دارد، به این معنی که ما یک مرجع از رشته دوم را به رشته اول اضافه می‌کنیم. این به دلیل پارامتر s در تابع add است: ما فقط می‌توانیم یک &str را به یک String اضافه کنیم؛ نمی‌توانیم دو مقدار String را با هم جمع کنیم. اما صبر کنید—نوع &s2، &String است، نه &str همانطور که در پارامتر دوم add مشخص شده است. پس چرا کد در لیست ۸-۱۸ کامپایل می‌شود؟

دلیل اینکه می‌توانیم از &s2 در فراخوانی add استفاده کنیم این است که کامپایلر می‌تواند آرگومان &String را به &str تبدیل کند. هنگامی که ما متد add را فراخوانی می‌کنیم، Rust از یک coercion deref استفاده می‌کند که در اینجا &s2 را به &s2[..] تبدیل می‌کند. ما این موضوع را در فصل ۱۵ به طور عمیق‌تری بررسی خواهیم کرد. از آنجا که add مالکیت پارامتر s را نمی‌گیرد، s2 پس از این عملیات همچنان یک String معتبر باقی خواهد ماند.

دوم، می‌توانیم در امضا ببینیم که add مالکیت self را می‌گیرد زیرا self یک & ندارد. این بدان معناست که s1 در لیست ۸-۱۸ به فراخوانی add منتقل می‌شود و پس از آن دیگر معتبر نخواهد بود. بنابراین، اگرچه let s3 = s1 + &s2; به نظر می‌رسد که هر دو رشته را کپی می‌کند و یک رشته جدید ایجاد می‌کند، این عبارت در واقع مالکیت s1 را می‌گیرد، یک کپی از محتوای s2 را اضافه می‌کند، و سپس مالکیت نتیجه را بازمی‌گرداند. به عبارت دیگر، به نظر می‌رسد که کپی‌های زیادی انجام می‌دهد، اما اینطور نیست؛ پیاده‌سازی کارآمدتر از کپی کردن است.

اگر نیاز به الحاق چندین رشته داشته باشیم، رفتار عملگر + دست‌وپاگیر می‌شود:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

در این نقطه، مقدار s برابر با tic-tac-toe خواهد بود. با تمام این + و کاراکترهای "، دیدن اینکه چه اتفاقی می‌افتد دشوار است. برای ترکیب رشته‌ها به روش‌های پیچیده‌تر، می‌توانیم به جای آن از ماکروی format! استفاده کنیم:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

این کد نیز مقدار s را به tic-tac-toe تنظیم می‌کند. ماکروی format! شبیه به println! کار می‌کند، اما به جای چاپ خروجی روی صفحه، یک String با محتوای مورد نظر بازمی‌گرداند. نسخه کد با استفاده از format! بسیار خواناتر است و کدی که توسط ماکروی format! تولید می‌شود از مراجع استفاده می‌کند، بنابراین این فراخوانی مالکیت هیچ‌یک از پارامترهایش را نمی‌گیرد.

اندیس‌گذاری در رشته‌ها

در بسیاری از زبان‌های برنامه‌نویسی دیگر، دسترسی به کاراکترهای منفرد در یک رشته با اشاره به آن‌ها توسط اندیس (index)یک عملیات معتبر و رایج است. با این حال، اگر تلاش کنید در Rust با استفاده از سینتکس اندیس‌گذاری به بخش‌هایی از یک String دسترسی پیدا کنید، با خطا مواجه می‌شوید. کد نامعتبر در لیست ۸-۱۹ را در نظر بگیرید.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
Listing 8-19: تلاش برای استفاده از سینتکس اندیس‌گذاری با یک String

این کد به خطای زیر منجر خواهد شد:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
          but trait `SliceIndex<[_]>` is implemented for `usize`
  = help: for that trait implementation, expected `[_]`, found `str`
  = note: required for `String` to implement `Index<{integer}>`

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

خطا و توضیحات آن گویای موضوع است: رشته‌های Rust از اندیس‌گذاری پشتیبانی نمی‌کنند. اما چرا؟ برای پاسخ به این سؤال، باید درباره نحوه ذخیره‌سازی رشته‌ها در حافظه توسط Rust صحبت کنیم.

نمایش داخلی

یک String در واقع یک پوشش بر روی Vec<u8> است. بیایید به برخی از مثال‌های رشته‌های کدگذاری شده UTF-8 در لیست ۸-۱۴ نگاه کنیم. ابتدا این مورد:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

در این حالت، مقدار len برابر با 4 خواهد بود، به این معنی که برداری که رشته "Hola" را ذخیره می‌کند ۴ بایت طول دارد. هر یک از این حروف هنگام کدگذاری در UTF-8 یک بایت می‌گیرد. با این حال، خط زیر ممکن است شما را شگفت‌زده کند (توجه داشته باشید که این رشته با حرف بزرگ سیریلیک Ze آغاز می‌شود، نه عدد ۳):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

اگر از شما پرسیده شود طول این رشته چقدر است، ممکن است بگویید ۱۲. اما در واقع، پاسخ Rust ۲۴ است: این تعداد بایت‌هایی است که برای کدگذاری “Здравствуйте” در UTF-8 نیاز است، زیرا هر مقدار اسکالر Unicode در این رشته ۲ بایت فضای ذخیره‌سازی می‌گیرد. بنابراین، یک اندیس (index)در بایت‌های رشته همیشه با یک مقدار اسکالر Unicode معتبر مطابقت ندارد. برای نشان دادن این موضوع، کد نامعتبر زیر در Rust را در نظر بگیرید:

let hello = "Здравствуйте";
let answer = &hello[0];

شما قبلاً می‌دانید که مقدار answer برابر با З، اولین حرف، نخواهد بود. وقتی در UTF-8 کدگذاری می‌شود، اولین بایت از З برابر با 208 و دومین بایت برابر با 151 است، بنابراین ممکن است به نظر برسد که answer باید در واقع 208 باشد، اما 208 به تنهایی یک کاراکتر معتبر نیست. بازگرداندن 208 احتمالاً چیزی نیست که یک کاربر بخواهد اگر درخواست اولین حرف این رشته را داشته باشد؛ با این حال، این تنها داده‌ای است که Rust در اندیس (index)بایت ۰ دارد. کاربران به طور کلی نمی‌خواهند مقدار بایت بازگردانده شود، حتی اگر رشته فقط حروف لاتین داشته باشد: اگر &"hi"[0] یک کد معتبر بود که مقدار بایت را بازمی‌گرداند، مقدار 104 و نه h را بازمی‌گرداند.

پاسخ این است که برای جلوگیری از بازگرداندن یک مقدار غیرمنتظره و ایجاد باگ‌هایی که ممکن است فوراً کشف نشوند، Rust این کد را اصلاً کامپایل نمی‌کند و از سوءتفاهم‌ها در اوایل فرآیند توسعه جلوگیری می‌کند.

بایت‌ها، مقادیر اسکالر و خوشه‌های گرافیمی! اوه خدای من!

نکته دیگری درباره UTF-8 این است که در واقع سه روش مرتبط برای مشاهده رشته‌ها از دیدگاه Rust وجود دارد: به صورت بایت، مقادیر اسکالر، و خوشه‌های گرافیمی (نزدیک‌ترین چیز به چیزی که ما حروف می‌نامیم).

اگر به کلمه هندی “नमस्ते” نوشته شده در اسکریپت Devanagari نگاه کنیم، این کلمه به صورت یک بردار از مقادیر u8 ذخیره می‌شود که به شکل زیر است:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

این ۱۸ بایت است و این همان چیزی است که کامپیوترها در نهایت این داده را ذخیره می‌کنند. اگر به آن‌ها به عنوان مقادیر اسکالر Unicode نگاه کنیم، که همان نوع char در Rust است، این بایت‌ها به این صورت به نظر می‌رسند:

['न', 'म', 'स', '्', 'त', 'े']

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

["न", "म", "स्", "ते"]

Rust روش‌های مختلفی برای تفسیر داده خام رشته ارائه می‌دهد که کامپیوترها ذخیره می‌کنند، بنابراین هر برنامه می‌تواند تفسیری را که نیاز دارد انتخاب کند، صرف نظر از اینکه داده به چه زبان انسانی است.

یکی دیگر از دلایل اینکه Rust به ما اجازه نمی‌دهد در یک String اندیس‌گذاری کنیم تا یک کاراکتر را دریافت کنیم این است که عملیات اندیس‌گذاری باید همیشه در زمان ثابت (O(1)) انجام شود. اما امکان تضمین این عملکرد با یک String وجود ندارد، زیرا Rust باید محتویات را از ابتدا تا اندیس (index)مرور کند تا تعیین کند که چند کاراکتر معتبر وجود دارد.

برش رشته‌ها

اندیس‌گذاری در یک رشته اغلب ایده خوبی نیست زیرا مشخص نیست که نوع بازگشتی عملیات اندیس‌گذاری رشته چه باید باشد: یک مقدار بایت، یک کاراکتر، یک خوشه گرافیمی، یا یک قطعه رشته. بنابراین، اگر واقعاً نیاز به استفاده از اندیس‌ها برای ایجاد قطعه‌های رشته دارید، Rust از شما می‌خواهد بیشتر مشخص کنید.

به جای اندیس‌گذاری با استفاده از [] و یک عدد، می‌توانید از [] با یک بازه استفاده کنید تا یک قطعه رشته که شامل بایت‌های خاصی است ایجاد کنید:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

اینجا، s یک &str خواهد بود که شامل چهار بایت اول رشته است. پیش‌تر اشاره کردیم که هر یک از این کاراکترها دو بایت طول دارند، که به این معنی است که مقدار s برابر با Зд خواهد بود.

اگر سعی کنیم فقط بخشی از بایت‌های یک کاراکتر را با چیزی مثل &hello[0..1] برش دهیم، Rust در زمان اجرا دچار خطا می‌شود، به همان شکلی که اگر یک اندیس (index)نامعتبر در یک بردار دسترسی داده شود:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

هنگام ایجاد قطعه‌های رشته با بازه‌ها باید احتیاط کنید، زیرا این کار ممکن است باعث خرابی برنامه شما شود.

متدهایی برای پیمایش در رشته‌ها

بهترین راه برای کار با بخش‌هایی از رشته‌ها این است که به وضوح مشخص کنید که آیا می‌خواهید روی کاراکترها یا بایت‌ها کار کنید. برای مقادیر اسکالر Unicode منفرد، از متد chars استفاده کنید. فراخوانی chars روی "Зд" دو مقدار از نوع char را جدا کرده و بازمی‌گرداند، و می‌توانید با استفاده از نتیجه پیمایش کنید تا به هر عنصر دسترسی پیدا کنید:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

این کد خروجی زیر را چاپ خواهد کرد:

З
д

به صورت جایگزین، متد bytes هر بایت خام را بازمی‌گرداند که ممکن است برای حوزه کاری شما مناسب باشد:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

این کد چهار بایتی که این رشته را تشکیل می‌دهند چاپ خواهد کرد:

208
151
208
180

اما حتماً به یاد داشته باشید که مقادیر اسکالر Unicode معتبر ممکن است از بیش از یک بایت تشکیل شده باشند.

دریافت خوشه‌های گرافیمی از رشته‌ها، همانند اسکریپت Devanagari، پیچیده است، بنابراین این قابلیت توسط کتابخانه استاندارد ارائه نمی‌شود. اگر به این قابلیت نیاز دارید، کرایت‌هایی در crates.io موجود هستند.

رشته‌ها اینقدر ساده نیستند

به طور خلاصه، رشته‌ها پیچیده هستند. زبان‌های برنامه‌نویسی مختلف انتخاب‌های متفاوتی درباره نحوه نمایش این پیچیدگی به برنامه‌نویس می‌کنند. Rust انتخاب کرده است که مدیریت صحیح داده‌های String رفتار پیش‌فرض برای تمام برنامه‌های Rust باشد، که به این معنی است که برنامه‌نویسان باید در ابتدا بیشتر درباره مدیریت داده‌های UTF-8 فکر کنند. این معامله پیچیدگی بیشتری از رشته‌ها را نسبت به سایر زبان‌های برنامه‌نویسی نشان می‌دهد، اما از مواجهه با خطاهای مربوط به کاراکترهای غیر-ASCII در مراحل بعدی چرخه توسعه جلوگیری می‌کند.

خبر خوب این است که کتابخانه استاندارد عملکردهای زیادی را بر اساس انواع String و &str برای کمک به مدیریت صحیح این شرایط پیچیده ارائه می‌دهد. حتماً مستندات را برای متدهای مفیدی مانند contains برای جستجو در یک رشته و replace برای جایگزینی بخش‌هایی از یک رشته با رشته‌ای دیگر بررسی کنید.

بیایید به چیزی کمی کمتر پیچیده برویم: هش مپ‌ها!

ذخیره کلیدها با مقادیر مرتبط در هش مپ‌ها

آخرین مورد از مجموعه‌های رایج ما، هش مپ است. نوع HashMap<K, V> یک نگاشت از کلیدهایی با نوع K به مقادیری با نوع V را با استفاده از یک تابع هش ذخیره می‌کند، که تعیین می‌کند چگونه این کلیدها و مقادیر در حافظه قرار بگیرند. بسیاری از زبان‌های برنامه‌نویسی از این نوع ساختار داده پشتیبانی می‌کنند، اما اغلب از نام‌های متفاوتی مانند هش، مپ، شیء، جدول هش، دایرکتوری، یا آرایه ارتباطی برای اشاره به آن استفاده می‌کنند.

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

در این بخش، به API اصلی هش مپ‌ها می‌پردازیم، اما امکانات بیشتری در توابع تعریف شده روی HashMap<K, V> در کتابخانه استاندارد وجود دارد. مانند همیشه، مستندات کتابخانه استاندارد را برای اطلاعات بیشتر بررسی کنید.

ایجاد یک هش مپ جدید

یکی از راه‌های ایجاد یک هش مپ خالی استفاده از new و افزودن عناصر با insert است. در لیست ۸-۲۰، ما امتیازات دو تیم به نام‌های Blue و Yellow را پیگیری می‌کنیم. تیم آبی با ۱۰ امتیاز و تیم زرد با ۵۰ امتیاز شروع می‌کنند.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}
Listing 8-20: ایجاد یک هش مپ جدید و درج تعدادی کلید و مقدار

توجه داشته باشید که ابتدا باید HashMap را از بخش مجموعه‌های کتابخانه استاندارد use کنیم. از میان سه مجموعه رایج ما، این یکی کمتر مورد استفاده قرار می‌گیرد، بنابراین به طور پیش‌فرض در محدوده وارد نمی‌شود. همچنین، هش مپ‌ها از حمایت کمتری از کتابخانه استاندارد برخوردارند؛ برای مثال، هیچ ماکروی داخلی برای ساخت آن‌ها وجود ندارد.

همانند بردارها، هش مپ‌ها داده‌های خود را روی heap ذخیره می‌کنند. این HashMap دارای کلیدهایی از نوع String و مقادیری از نوع i32 است. مانند بردارها، هش مپ‌ها همگن هستند: تمام کلیدها باید از یک نوع باشند و تمام مقادیر نیز باید از یک نوع باشند.

دسترسی به مقادیر در یک هش مپ

می‌توانیم یک مقدار را با ارائه کلید آن به متد get از هش مپ دریافت کنیم، همانطور که در لیست ۸-۲۱ نشان داده شده است.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name).copied().unwrap_or(0);
}
Listing 8-21: دسترسی به امتیاز تیم Blue ذخیره شده در هش مپ

اینجا، مقدار score برابر با مقداری خواهد بود که به تیم Blue مرتبط است، و نتیجه 10 خواهد بود. متد get یک Option<&V> را بازمی‌گرداند؛ اگر هیچ مقداری برای آن کلید در هش مپ وجود نداشته باشد، get مقدار None را بازمی‌گرداند. این برنامه مقدار Option را با فراخوانی copied برای دریافت یک Option<i32> به جای Option<&i32> مدیریت می‌کند، سپس با استفاده از unwrap_or مقدار score را به صفر تنظیم می‌کند اگر scores یک ورودی برای کلید نداشته باشد.

می‌توانیم روی هر جفت کلید-مقدار در یک hash map به‌روشی مشابه با vectorها پیمایش کنیم،
با استفاده از یک حلقه‌ی for:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{key}: {value}");
    }
}

این کد هر جفت را به ترتیب دلخواه چاپ خواهد کرد:

Yellow: 50
Blue: 10

هش مپ‌ها و مالکیت

برای انواعی که ویژگی Copy را پیاده‌سازی می‌کنند، مانند i32، مقادیر درون هش مپ کپی می‌شوند. برای مقادیر مالک مانند String، مقادیر منتقل شده و هش مپ مالک آن‌ها خواهد شد، همانطور که در لیست ۸-۲۲ نشان داده شده است.

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // field_name and field_value are invalid at this point, try using them and
    // see what compiler error you get!
}
Listing 8-22: نشان دادن اینکه کلیدها و مقادیر پس از وارد شدن به هش مپ، متعلق به آن می‌شوند

پس از انتقال متغیرهای field_name و field_value به هش مپ با فراخوانی insert، دیگر نمی‌توانیم از آن‌ها استفاده کنیم.

اگر رفرنس‌هایی به مقادیر را درون hash map قرار دهیم، آن مقادیر به درون hash map منتقل نخواهند شد (moved نمی‌شوند).
مقدارهایی که این رفرنس‌ها به آن‌ها اشاره می‌کنند، باید حداقل تا زمانی معتبر باشند که hash map معتبر است.
در فصل ۱۰، در بخش “اعتبارسنجی رفرنس‌ها با استفاده از lifetime”،
بیشتر درباره‌ی این مسائل صحبت خواهیم کرد.

به‌روزرسانی یک هش مپ

اگرچه تعداد جفت‌های کلید و مقدار قابل افزایش است، هر کلید یکتا فقط می‌تواند یک مقدار مرتبط داشته باشد (اما نه بالعکس: برای مثال، هر دو تیم Blue و Yellow می‌توانند مقدار 10 را در هش مپ scores ذخیره کنند).

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

بازنویسی یک مقدار

اگر یک کلید و مقدار را درون یک hash map قرار دهیم و سپس همان کلید را با یک مقدار متفاوت دوباره وارد کنیم، مقدار مرتبط با آن کلید جایگزین خواهد شد. حتی با این‌که کد در لیستینگ 8-23 دوبار تابع insert را فراخوانی می‌کند، hash map تنها شامل یک جفت کلید-مقدار خواهد بود، زیرا هر دو بار مقدار مربوط به کلید تیم Blue را وارد می‌کنیم.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{scores:?}");
}
Listing 8-23: جایگزینی یک مقدار ذخیره شده با یک کلید خاص

این کد مقدار {"Blue": 25} را چاپ خواهد کرد. مقدار اصلی 10 بازنویسی شده است.

اضافه کردن یک کلید و مقدار فقط اگر کلید وجود ندارد

بررسی اینکه آیا یک کلید خاص در هش مپ دارای مقدار است یا خیر و سپس انجام اقدامات زیر رایج است: اگر کلید در هش مپ وجود دارد، مقدار موجود باید همانطور که هست باقی بماند؛ اگر کلید وجود ندارد، آن را به همراه یک مقدار وارد کنید.

هش مپ‌ها یک API خاص برای این کار دارند که به نام entry شناخته می‌شود و کلیدی که می‌خواهید بررسی کنید را به عنوان پارامتر می‌گیرد. مقدار بازگشتی متد entry یک enum به نام Entry است که نشان‌دهنده مقداری است که ممکن است وجود داشته باشد یا نداشته باشد. فرض کنید می‌خواهیم بررسی کنیم که آیا کلید تیم Yellow دارای مقدار مرتبط است یا خیر. اگر ندارد، می‌خواهیم مقدار 50 را وارد کنیم، و همینطور برای تیم Blue. با استفاده از API entry، کد به شکل لیست ۸-۲۴ خواهد بود.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{scores:?}");
}
Listing 8-24: استفاده از متد entry برای وارد کردن مقدار فقط در صورتی که کلید قبلاً مقدار ندارد

متد or_insert روی Entry به گونه‌ای تعریف شده است که یک مرجع قابل تغییر به مقدار مرتبط با کلید Entry برمی‌گرداند اگر آن کلید وجود داشته باشد، و اگر نه، پارامتر را به عنوان مقدار جدید برای این کلید وارد کرده و یک مرجع قابل تغییر به مقدار جدید بازمی‌گرداند. این تکنیک بسیار تمیزتر از نوشتن منطق به صورت دستی است و علاوه بر این، با بررسی‌کننده قرض بهتر کار می‌کند.

اجرای کد در لیست ۸-۲۴ مقدار {"Yellow": 50, "Blue": 10} را چاپ خواهد کرد. اولین فراخوانی به entry کلید تیم Yellow را با مقدار 50 وارد می‌کند زیرا تیم Yellow قبلاً مقداری ندارد. دومین فراخوانی به entry هش مپ را تغییر نمی‌دهد زیرا تیم Blue قبلاً مقدار 10 را دارد.

به‌روزرسانی یک مقدار بر اساس مقدار قدیمی

یکی دیگر از موارد استفاده رایج برای هش مپ‌ها این است که مقدار یک کلید را جستجو کرده و سپس بر اساس مقدار قدیمی آن را به‌روزرسانی کنیم. برای مثال، لیست ۸-۲۵ کدی را نشان می‌دهد که تعداد دفعات ظاهر شدن هر کلمه در یک متن را می‌شمارد. ما از یک هش مپ با کلمات به عنوان کلید و مقدار برای نگهداری تعداد دفعات ظاهر شدن هر کلمه استفاده می‌کنیم. اگر این اولین بار باشد که یک کلمه را مشاهده می‌کنیم، ابتدا مقدار 0 را وارد می‌کنیم.

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{map:?}");
}
Listing 8-25: شمارش تعداد وقوع کلمات با استفاده از یک هش مپ که کلمات و تعداد دفعات آن‌ها را ذخیره می‌کند

این کد مقدار {"world": 2, "hello": 1, "wonderful": 1} را چاپ خواهد کرد.
ممکن است همین جفت‌های کلید-مقدار را به ترتیب متفاوتی ببینید:
به یاد داشته باشید از بخش “دسترسی به مقادیر در یک Hash Map”
که پیمایش در یک hash map به‌صورت ترتیبی دلخواه (arbitrary order) انجام می‌شود.

متد split_whitespace یک iterator بر روی زیررشته‌هایی که با فضای خالی جدا شده‌اند از مقدار موجود در text بازمی‌گرداند. متد or_insert یک مرجع قابل تغییر (&mut V) به مقدار مرتبط با کلید مشخص برمی‌گرداند. اگر آن کلید وجود داشته باشد، مقدار بازگشتی همان مقدار موجود است؛ و اگر نه، پارامتر را به عنوان مقدار جدید برای این کلید وارد می‌کند و مرجع قابل تغییر به مقدار جدید را بازمی‌گرداند. در اینجا، ما این مرجع قابل تغییر را در متغیر count ذخیره می‌کنیم، بنابراین برای تخصیص مقدار به آن، باید ابتدا count را با استفاده از عملگر ستاره (*) dereference کنیم. مرجع قابل تغییر در انتهای حلقه for از محدوده خارج می‌شود، بنابراین تمام این تغییرات ایمن هستند و قوانین قرض‌گیری را نقض نمی‌کنند.

توابع هش

به طور پیش‌فرض، HashMap از یک تابع هش به نام SipHash استفاده می‌کند که مقاومت در برابر حملات انکار سرویس (DoS) مربوط به جداول هش1 را فراهم می‌کند. این سریع‌ترین الگوریتم هش موجود نیست، اما مبادله برای امنیت بهتر با کاهش عملکرد ارزشمند است. اگر کد خود را پروفایل کنید و متوجه شوید که تابع هش پیش‌فرض برای اهداف شما بسیار کند است، می‌توانید با مشخص کردن یک هش‌کننده دیگر آن را تغییر دهید. یک هش‌کننده نوعی است که ویژگی BuildHasher را پیاده‌سازی می‌کند. درباره ویژگی‌ها (traits) و نحوه پیاده‌سازی آن‌ها در فصل ۱۰ صحبت خواهیم کرد. نیازی نیست حتماً هش‌کننده خود را از ابتدا پیاده‌سازی کنید؛ در crates.io کتابخانه‌هایی موجود هستند که توسط کاربران Rust به اشتراک گذاشته شده‌اند و هش‌کننده‌هایی با بسیاری از الگوریتم‌های هش رایج ارائه می‌دهند.

خلاصه

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

  1. با داشتن یک لیست از اعداد صحیح، از یک بردار استفاده کرده و میانه (وقتی مرتب‌سازی شود، مقداری که در موقعیت وسط قرار دارد) و مد (مقداری که بیشترین بار ظاهر می‌شود؛ یک هش مپ در اینجا مفید خواهد بود) لیست را بازگردانید.
  2. رشته‌ها را به زبان لاتین خوکی تبدیل کنید. اولین صامت هر کلمه به انتهای کلمه منتقل شده و ay به آن اضافه می‌شود، بنابراین first به irst-fay تبدیل می‌شود. کلماتی که با یک حرف صدادار شروع می‌شوند، hay به انتهای آن‌ها اضافه می‌شود (apple به apple-hay تبدیل می‌شود). جزئیات مربوط به کدگذاری UTF-8 را در نظر داشته باشید!
  3. با استفاده از یک هش مپ و بردارها، یک رابط متنی ایجاد کنید تا به کاربر امکان اضافه کردن نام کارمندان به یک دپارتمان در شرکت را بدهد؛ برای مثال، “Add Sally to Engineering” یا “Add Amir to Sales”. سپس به کاربر اجازه دهید لیستی از تمام افراد در یک دپارتمان یا تمام افراد در شرکت بر اساس دپارتمان، مرتب شده به صورت حروف الفبا، بازیابی کند.

مستندات API کتابخانه استاندارد متدهایی را که بردارها، رشته‌ها، و هش مپ‌ها دارند و برای این تمرین‌ها مفید خواهند بود توصیف می‌کنند!

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


  1. https://en.wikipedia.org/wiki/SipHash

مدیریت خطاها

خطاها بخشی اجتناب‌ناپذیر از زندگی در دنیای نرم‌افزار هستند، و به همین دلیل، Rust ویژگی‌های متعددی برای مدیریت موقعیت‌هایی دارد که در آن‌ها مشکلی پیش می‌آید. در بسیاری از موارد، Rust شما را ملزم می‌کند که امکان وقوع یک خطا را به رسمیت بشناسید و پیش از آن‌که کد شما کامپایل شود، اقدامی انجام دهید. این الزام باعث می‌شود برنامه‌ی شما مقاوم‌تر باشد، زیرا تضمین می‌کند که خطاها را پیش از استقرار کد در محیط اجرایی (production) شناسایی کرده و به‌درستی مدیریت کرده‌اید.

Rust خطاها را به دو دسته اصلی تقسیم می‌کند: خطاهای قابل بازیابی و خطاهای غیرقابل بازیابی. برای یک خطای قابل بازیابی، مانند خطای فایل یافت نشد، احتمالاً می‌خواهیم مشکل را به کاربر گزارش دهیم و عملیات را دوباره انجام دهیم. خطاهای غیرقابل بازیابی همیشه نشانه‌های باگ‌ها هستند، مانند تلاش برای دسترسی به مکانی خارج از انتهای یک آرایه، بنابراین می‌خواهیم بلافاصله برنامه را متوقف کنیم.

بیشتر زبان‌ها بین این دو نوع خطا تفاوت قائل نمی‌شوند و هر دو را به یک شکل مدیریت می‌کنند، با استفاده از مکانیزم‌هایی مانند استثناها. Rust استثناها ندارد. در عوض، نوع Result<T, E> برای خطاهای قابل بازیابی و ماکروی panic! که اجرای برنامه را زمانی که با یک خطای غیرقابل بازیابی روبرو می‌شود متوقف می‌کند، ارائه می‌دهد. این فصل ابتدا به فراخوانی panic! می‌پردازد و سپس در مورد بازگرداندن مقادیر Result<T, E> صحبت می‌کند. علاوه بر این، ملاحظاتی را هنگام تصمیم‌گیری در مورد اینکه آیا سعی در بازیابی از یک خطا کنیم یا اجرای برنامه را متوقف کنیم، بررسی خواهیم کرد.

خطاهای غیرقابل بازیابی با panic!

گاهی اوقات اتفاقات بدی در کد شما رخ می‌دهد و هیچ کاری نمی‌توانید در مورد آن انجام دهید. در این موارد، Rust ماکروی panic! را ارائه می‌دهد. دو راه برای ایجاد یک خطا با panic! وجود دارد: با انجام عملی که باعث ایجاد خطا می‌شود (مانند دسترسی به یک اندیس (index)خارج از محدوده در یک آرایه) یا با صراحت فراخوانی ماکروی panic!. در هر دو حالت، ما یک خطا در برنامه خود ایجاد می‌کنیم. به طور پیش‌فرض، این خطاها یک پیام خطا چاپ می‌کنند، استک را unwind می‌کنند، داده‌ها را پاکسازی می‌کنند و برنامه را متوقف می‌کنند. با استفاده از یک متغیر محیطی، می‌توانید Rust را مجبور کنید هنگام وقوع یک panic، استک فراخوانی را نمایش دهد تا ردیابی منبع خطا آسان‌تر شود.

Unwinding the Stack یا متوقف کردن در پاسخ به یک Panic

به طور پیش‌فرض، هنگامی که یک خطا رخ می‌دهد، برنامه شروع به unwinding می‌کند، که به معنی این است که Rust استک را به سمت بالا پیمایش می‌کند و داده‌ها را از هر تابعی که با آن برخورد می‌کند پاکسازی می‌کند. با این حال، پیمایش به بالا و پاکسازی کار زیادی است. بنابراین، Rust به شما اجازه می‌دهد گزینه جایگزین abort کردن فوری را انتخاب کنید، که برنامه را بدون پاکسازی متوقف می‌کند.

حافظه‌ای که برنامه استفاده می‌کرد نیاز به پاکسازی توسط سیستم عامل خواهد داشت. اگر در پروژه خود نیاز دارید تا فایل باینری حاصل را تا حد ممکن کوچک کنید، می‌توانید از unwind به abort در زمان خطا تغییر دهید با اضافه کردن panic = 'abort' به بخش‌های مناسب [profile] در فایل Cargo.toml خود. برای مثال، اگر می‌خواهید در حالت release در زمان وقوع خطا متوقف شوید، این مورد را اضافه کنید:

[profile.release]
panic = 'abort'

بیایید فراخوانی panic! را در یک برنامه ساده امتحان کنیم:

Filename: src/main.rs
fn main() {
    panic!("crash and burn");
}

وقتی برنامه را اجرا کنید، چیزی شبیه به این خواهید دید:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

فراخوانی panic! پیام خطای موجود در دو خط آخر را ایجاد می‌کند. خط اول پیام خطای panic! ما و مکانی در کد منبع ما که این خطا رخ داده است را نشان می‌دهد: src/main.rs:2:5 نشان می‌دهد که این خط دوم، پنجمین کاراکتر در فایل src/main.rs ما است.

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

ما می‌توانیم از backtrace توابعی که فراخوانی panic! از آن‌ها آمده است استفاده کنیم تا بخش کد ما که باعث مشکل شده است را پیدا کنیم. برای درک نحوه استفاده از backtrace یک panic!، بیایید یک مثال دیگر ببینیم و مشاهده کنیم زمانی که یک فراخوانی panic! از یک کتابخانه به دلیل یک باگ در کد ما رخ می‌دهد، به جای اینکه کد ما مستقیماً ماکرو را فراخوانی کند، چگونه است. لیست ۹-۱ کدی دارد که تلاش می‌کند به یک اندیس (index)در یک بردار که خارج از محدوده اندیس‌های معتبر است دسترسی پیدا کند.

Filename: src/main.rs
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}
Listing 9-1: تلاش برای دسترسی به عنصری که خارج از انتهای بردار است، که منجر به فراخوانی panic! خواهد شد

در اینجا، ما سعی داریم به عنصر صدم بردار خود دسترسی پیدا کنیم (که در اندیس (index)۹۹ است زیرا اندیس‌گذاری از صفر شروع می‌شود)، اما بردار فقط سه عنصر دارد. در این وضعیت، Rust با یک خطا متوقف می‌شود. استفاده از [] قرار است یک عنصر را بازگرداند، اما اگر یک اندیس (index)نامعتبر را ارائه دهید، هیچ عنصری وجود ندارد که Rust بتواند به درستی بازگرداند.

در زبان C، تلاش برای خواندن فراتر از انتهای یک ساختار داده رفتاری نامشخص دارد. ممکن است هر چیزی که در مکان حافظه‌ای که با آن عنصر در ساختار داده مطابقت دارد باشد را دریافت کنید، حتی اگر آن حافظه متعلق به آن ساختار نباشد. این به عنوان buffer overread شناخته می‌شود و می‌تواند به آسیب‌پذیری‌های امنیتی منجر شود اگر یک مهاجم بتواند اندیس (index)را به گونه‌ای دستکاری کند که داده‌هایی را بخواند که نباید به آن‌ها دسترسی داشته باشد و پس از ساختار داده ذخیره شده‌اند.

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

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

این خطا به خط ۴ فایل main.rs ما اشاره می‌کند، جایی که سعی داریم به اندیس (index)99 بردار v دسترسی پیدا کنیم.

خط note: به ما می‌گوید که می‌توانیم متغیر محیطی RUST_BACKTRACE را تنظیم کنیم تا یک backtrace دقیقاً از آنچه باعث خطا شده است دریافت کنیم. یک backtrace لیستی از تمام توابعی است که تا این نقطه فراخوانی شده‌اند. backtraceها در Rust مانند زبان‌های دیگر کار می‌کنند: کلید خواندن backtrace این است که از بالا شروع کرده و تا زمانی که فایل‌هایی که شما نوشته‌اید را ببینید، بخوانید. این همان جایی است که مشکل از آنجا منشأ گرفته است. خطوط بالاتر از آن نقطه کدی است که کد شما فراخوانی کرده است؛ خطوط پایین‌تر کدی است که کد شما را فراخوانی کرده است. این خطوط قبل و بعد ممکن است شامل کد هسته Rust، کد کتابخانه استاندارد، یا جعبه(crate)هایی که استفاده می‌کنید باشند. بیایید با تنظیم متغیر محیطی RUST_BACKTRACE به هر مقداری به غیر از 0 یک backtrace دریافت کنیم. لیست ۹-۲ خروجی مشابه چیزی را که خواهید دید نشان می‌دهد.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:273:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3361:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Listing 9-2: backtrace تولید شده توسط فراخوانی به panic! که وقتی متغیر محیطی RUST_BACKTRACE تنظیم شده است نمایش داده می‌شود

این مقدار زیادی خروجی است! خروجی دقیق ممکن است بسته به سیستم عامل و نسخه Rust شما متفاوت باشد. برای دریافت backtraceها با این اطلاعات، باید نمادهای اشکال‌زدایی (debug symbols) فعال باشند. نمادهای اشکال‌زدایی به طور پیش‌فرض هنگام استفاده از cargo build یا cargo run بدون فلگ --release فعال هستند، همانطور که در اینجا انجام دادیم.

در خروجی لیست ۹-۲، خط ۶ از backtrace به خطی در پروژه ما اشاره می‌کند که باعث مشکل شده است: خط ۴ فایل src/main.rs. اگر نمی‌خواهیم برنامه ما دچار خطا شود، باید بررسی خود را از مکانی که توسط اولین خطی که اشاره به فایلی که نوشته‌ایم دارد، آغاز کنیم. در لیست ۹-۱، جایی که به عمد کدی نوشته‌ایم که باعث خطا شود، راه حل رفع این خطا این است که درخواست یک عنصر فراتر از محدوده اندیس‌های بردار نکنیم. زمانی که کد شما در آینده دچار خطا می‌شود، باید بفهمید که کد با چه مقادیری چه عملی انجام می‌دهد که باعث خطا می‌شود و کد چه کاری باید انجام دهد.

ما در بخش “To panic! or Not to panic! که بعداً در این فصل آمده است، دوباره به موضوع panic! و زمانی که باید و نباید از panic! برای مدیریت شرایط خطا استفاده کنیم بازخواهیم گشت. اکنون، به بررسی نحوه بازیابی از یک خطا با استفاده از Result می‌پردازیم.

خطاهای قابل بازیابی با Result

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

به یاد بیاورید از بخش “Handling Potential Failure with Result در فصل ۲ که Result به صورت یک enum تعریف شده که دو حالت دارد، Ok و Err، به صورت زیر:

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

T و E پارامترهای نوع جنریک هستند: ما درباره جنریک‌ها به طور کامل‌تر در فصل ۱۰ صحبت خواهیم کرد. چیزی که اکنون باید بدانید این است که T نمایانگر نوع مقداری است که در حالت موفقیت در داخل Ok بازگردانده می‌شود، و E نمایانگر نوع خطایی است که در حالت شکست در داخل Err بازگردانده می‌شود. زیرا Result این پارامترهای نوع جنریک را دارد، می‌توانیم نوع Result و توابع تعریف شده روی آن را در بسیاری از شرایط مختلف که مقادیر موفقیت و خطا ممکن است متفاوت باشند، استفاده کنیم.

بیایید تابعی را فراخوانی کنیم که یک مقدار Result را بازمی‌گرداند زیرا این تابع ممکن است با شکست مواجه شود. در لیست ۹-۳ سعی می‌کنیم یک فایل را باز کنیم.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}
Listing 9-3: باز کردن یک فایل

نوع بازگشتی File::open یک Result<T, E> است. پارامتر نوع جنریک T توسط پیاده‌سازی File::open با نوع مقدار موفقیت، یعنی std::fs::File، که یک فایل هندل است، مقداردهی شده است. نوع E استفاده شده در مقدار خطا std::io::Error است. این نوع بازگشتی به این معنی است که فراخوانی File::open ممکن است موفقیت‌آمیز باشد و یک فایل هندل بازگرداند که می‌توانیم از آن برای خواندن یا نوشتن استفاده کنیم. همچنین ممکن است این فراخوانی با شکست مواجه شود: برای مثال، فایل ممکن است وجود نداشته باشد یا ممکن است مجوز دسترسی به فایل را نداشته باشیم. تابع File::open باید روشی داشته باشد تا به ما بگوید که آیا موفقیت‌آمیز بود یا شکست خورد و در عین حال فایل هندل یا اطلاعات خطا را به ما بدهد. این اطلاعات دقیقاً همان چیزی است که enum Result منتقل می‌کند.

در حالتی که File::open موفقیت‌آمیز باشد، مقدار در متغیر greeting_file_result یک نمونه از Ok خواهد بود که یک فایل هندل را شامل می‌شود. در حالتی که با شکست مواجه شود، مقدار در greeting_file_result یک نمونه از Err خواهد بود که اطلاعات بیشتری در مورد نوع خطایی که رخ داده است را شامل می‌شود.

باید به کد در لیست ۹-۳ اضافه کنیم تا اقدامات متفاوتی بسته به مقداری که File::open بازمی‌گرداند انجام دهیم. لیست ۹-۴ یک روش برای مدیریت Result با استفاده از یک ابزار پایه، یعنی عبارت match که در فصل ۶ مورد بحث قرار گرفت، نشان می‌دهد.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}
Listing 9-4: استفاده از عبارت match برای مدیریت حالت‌های Result که ممکن است بازگردانده شود

توجه داشته باشید که مانند enum Option، enum Result و حالات آن به وسیله prelude به محدوده آورده شده‌اند، بنابراین نیازی نیست قبل از حالات Ok و Err در بازوهای match از Result:: استفاده کنیم.

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

بازوی دیگر match حالت زمانی را مدیریت می‌کند که از File::open یک مقدار Err دریافت می‌کنیم. در این مثال، تصمیم گرفته‌ایم ماکروی panic! را فراخوانی کنیم. اگر فایل hello.txt در دایرکتوری فعلی ما وجود نداشته باشد و این کد را اجرا کنیم، خروجی زیر را از ماکروی panic! خواهیم دید:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`

thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

مثل همیشه، این خروجی دقیقاً به ما می‌گوید چه اشتباهی رخ داده است.

مطابقت بر اساس خطاهای مختلف

کد در لیست ۹-۴ در هر صورتی که File::open با شکست مواجه شود، ماکروی panic! را فراخوانی می‌کند. با این حال، ما می‌خواهیم اقدامات متفاوتی برای دلایل مختلف شکست انجام دهیم. اگر File::open به دلیل وجود نداشتن فایل شکست بخورد، می‌خواهیم فایل را ایجاد کنیم و هندل فایل جدید را بازگردانیم. اگر File::open به دلایل دیگری شکست بخورد—برای مثال، به دلیل نداشتن مجوز باز کردن فایل—همچنان می‌خواهیم کد مانند لیست ۹-۴ panic! کند. برای این کار، یک عبارت match داخلی اضافه می‌کنیم که در لیست ۹-۵ نشان داده شده است.

Filename: src/main.rs
use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}
Listing 9-5: مدیریت انواع مختلف خطاها به روش‌های مختلف

نوع مقداری که File::open درون حالت Err بازمی‌گرداند، io::Error است که یک ساختار داده ارائه شده توسط کتابخانه استاندارد است. این ساختار دارای متدی به نام kind است که می‌توانیم آن را برای دریافت مقدار io::ErrorKind فراخوانی کنیم. enum io::ErrorKind توسط کتابخانه استاندارد ارائه شده و شامل حالت‌هایی است که انواع مختلف خطاهای ممکن در یک عملیات io را نمایش می‌دهد. حالتی که می‌خواهیم از آن استفاده کنیم ErrorKind::NotFound است که نشان می‌دهد فایل مورد نظر برای باز کردن هنوز وجود ندارد. بنابراین، ما بر روی greeting_file_result مطابقت می‌دهیم، اما همچنین یک match داخلی بر روی error.kind() داریم.

شرطی که می‌خواهیم در match داخلی بررسی کنیم این است که آیا مقدار بازگردانده شده توسط error.kind() همان حالت NotFound از enum ErrorKind است یا خیر. اگر چنین باشد، سعی می‌کنیم فایل را با File::create ایجاد کنیم. با این حال، از آنجایی که File::create نیز ممکن است شکست بخورد، به یک بازوی دوم در عبارت match داخلی نیاز داریم. هنگامی که فایل نمی‌تواند ایجاد شود، یک پیام خطای متفاوت چاپ می‌شود. بازوی دوم match بیرونی به همان شکل باقی می‌ماند، بنابراین برنامه برای هر خطایی به جز خطای وجود نداشتن فایل، با خطا متوقف می‌شود.

جایگزین‌هایی برای استفاده از match با Result<T, E>

استفاده از match زیاد است! عبارت match بسیار مفید است اما همچنان ابتدایی محسوب می‌شود. در فصل ۱۳، درباره closures یاد خواهید گرفت که در بسیاری از متدهایی که روی Result<T, E> تعریف شده‌اند استفاده می‌شوند. این متدها می‌توانند هنگام مدیریت مقادیر Result<T, E> در کد شما، مختصرتر از استفاده از match باشند.

برای مثال، در اینجا راه دیگری برای نوشتن همان منطق نشان داده شده در لیست ۹-۵ آورده شده است، این بار با استفاده از closures و متد unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

اگرچه این کد همان رفتار لیست ۹-۵ را دارد، اما شامل هیچ عبارت match نیست و خواندن آن تمیزتر است. بعد از خواندن فصل ۱۳، به این مثال بازگردید و متد unwrap_or_else را در مستندات کتابخانه استاندارد بررسی کنید. بسیاری از این متدها می‌توانند عبارت‌های match تو در تو را هنگام کار با خطاها ساده کنند.

میان‌برهایی برای توقف برنامه در صورت خطا: unwrap و expect

استفاده از match به اندازه کافی خوب کار می‌کند، اما ممکن است کمی طولانی باشد و همیشه به خوبی نیت را منتقل نکند. نوع Result<T, E> دارای بسیاری از متدهای کمکی است که برای انجام وظایف خاص‌تر تعریف شده‌اند. متد unwrap یک روش میان‌بر است که دقیقاً مانند عبارت match که در لیست ۹-۴ نوشتیم، پیاده‌سازی شده است. اگر مقدار Result در حالت Ok باشد، unwrap مقدار داخل Ok را بازمی‌گرداند. اگر مقدار Result در حالت Err باشد، unwrap ماکروی panic! را برای ما فراخوانی می‌کند. در اینجا یک مثال از استفاده از unwrap آورده شده است:

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

اگر این کد را بدون فایل hello.txt اجرا کنیم، یک پیام خطا از فراخوانی panic! که متد unwrap انجام می‌دهد خواهیم دید:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

به همین ترتیب، متد expect به ما اجازه می‌دهد پیام خطای ماکروی panic! را نیز انتخاب کنیم. استفاده از expect به جای unwrap و ارائه پیام‌های خطای خوب می‌تواند نیت شما را بهتر منتقل کند و پیگیری منبع یک خطا را آسان‌تر کند. سینتکس expect به این شکل است:

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

ما از expect به همان شیوه‌ای استفاده می‌کنیم که از unwrap استفاده می‌کنیم: برای بازگرداندن فایل هندل یا فراخوانی ماکروی panic!. پیام خطایی که توسط expect در فراخوانی panic! استفاده می‌شود، پارامتری است که ما به expect می‌دهیم، به جای پیام پیش‌فرض panic! که توسط unwrap استفاده می‌شود. اینجا چیزی است که به نظر می‌رسد:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

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

انتشار خطاها (Propagating Errors)

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

برای مثال، لیست ۹-۶ یک تابع را نشان می‌دهد که یک نام کاربری را از یک فایل می‌خواند. اگر فایل وجود نداشته باشد یا قابل خواندن نباشد، این تابع آن خطاها را به کدی که تابع را فراخوانی کرده بازمی‌گرداند.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}
Listing 9-6: یک تابع که خطاها را به کد فراخوانی‌کننده بازمی‌گرداند با استفاده از match

این تابع می‌تواند به روشی بسیار کوتاه‌تر نوشته شود، اما ما قرار است با انجام بسیاری از کارها به صورت دستی، مدیریت خطاها را بررسی کنیم. در انتها، راه کوتاه‌تر را نشان خواهیم داد. بیایید ابتدا به نوع بازگشتی تابع نگاه کنیم: Result<String, io::Error>. این به این معناست که تابع مقداری از نوع Result<T, E> بازمی‌گرداند، جایی که پارامتر جنریک T با نوع مشخص String مقداردهی شده است و نوع جنریک E با نوع مشخص io::Error.

اگر این تابع بدون هیچ مشکلی موفقیت‌آمیز باشد، کدی که این تابع را فراخوانی می‌کند یک مقدار Ok دریافت می‌کند که یک String را نگهداری می‌کند—نام کاربری‌ای که این تابع از فایل خوانده است. اگر این تابع با مشکلی مواجه شود، کدی که آن را فراخوانی کرده است یک مقدار Err دریافت می‌کند که یک نمونه از io::Error را نگهداری می‌کند که اطلاعات بیشتری درباره مشکلاتی که رخ داده‌اند شامل می‌شود. ما io::Error را به عنوان نوع بازگشتی این تابع انتخاب کردیم زیرا این همان نوعی است که مقدار خطا از هر دو عملیات فراخوانی شده در بدنه این تابع که ممکن است شکست بخورند بازمی‌گرداند: تابع File::open و متد read_to_string.

بدنه تابع با فراخوانی تابع File::open شروع می‌شود. سپس مقدار Result را با یک match مشابه آنچه در لیست ۹-۴ دیدیم مدیریت می‌کنیم. اگر File::open موفق شود، هندل فایل در متغیر الگو file به مقدار در متغیر قابل تغییر username_file تبدیل می‌شود و تابع ادامه می‌یابد. در حالت Err، به جای فراخوانی panic!، از کلیدواژه return استفاده می‌کنیم تا زودتر از تابع خارج شویم و مقدار خطا از File::open که اکنون در متغیر الگو e قرار دارد را به کدی که تابع را فراخوانی کرده بازگردانیم.

بنابراین، اگر یک هندل فایل در username_file داشته باشیم، تابع سپس یک String جدید در متغیر username ایجاد کرده و متد read_to_string را روی هندل فایل در username_file فراخوانی می‌کند تا محتوای فایل را در username بخواند. متد read_to_string نیز یک مقدار Result بازمی‌گرداند زیرا ممکن است با شکست مواجه شود، حتی اگر File::open موفق بوده باشد. بنابراین، به یک match دیگر برای مدیریت آن Result نیاز داریم: اگر read_to_string موفق شود، آنگاه تابع ما موفقیت‌آمیز بوده و نام کاربری از فایل که اکنون در username است، درون یک Ok بازمی‌گرداند. اگر read_to_string شکست بخورد، مقدار خطا را به همان شیوه‌ای که مقدار خطا را در match که مقدار بازگشتی File::open را مدیریت می‌کرد بازمی‌گردانیم. با این حال، نیازی نیست که به صراحت بگوییم return، زیرا این آخرین عبارت در تابع است.

کدی که این تابع را فراخوانی می‌کند سپس مدیریت دریافت مقدار Ok که شامل یک نام کاربری است یا مقدار Err که شامل یک io::Error است را انجام می‌دهد. این به کدی که تابع را فراخوانی می‌کند بستگی دارد که تصمیم بگیرد با این مقادیر چه کاری انجام دهد. اگر کد فراخوانی‌کننده یک مقدار Err دریافت کند، می‌تواند panic! را فراخوانی کرده و برنامه را متوقف کند، از یک نام کاربری پیش‌فرض استفاده کند، یا به جای فایل نام کاربری را از مکان دیگری جستجو کند، برای مثال. ما اطلاعات کافی درباره اینکه کد فراخوانی‌کننده دقیقاً چه می‌خواهد انجام دهد نداریم، بنابراین تمام اطلاعات موفقیت یا خطا را به بالا منتقل می‌کنیم تا آن را به درستی مدیریت کند.

این الگوی انتشار خطاها در Rust آن‌قدر رایج است که Rust عملگر ? را برای آسان‌تر کردن این کار فراهم می‌کند.

یک میان‌بر برای انتشار خطاها: عملگر ?

لیست ۹-۷ پیاده‌سازی read_username_from_file را نشان می‌دهد که همان عملکرد لیست ۹-۶ را دارد، اما این پیاده‌سازی از عملگر ? استفاده می‌کند.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}
Listing 9-7: یک تابع که خطاها را به کد فراخوانی‌کننده با استفاده از عملگر ? بازمی‌گرداند

عملگر ? که پس از یک مقدار Result قرار می‌گیرد تقریباً به همان شیوه‌ای عمل می‌کند که عبارات match که برای مدیریت مقادیر Result در لیست ۹-۶ تعریف کردیم. اگر مقدار Result در حالت Ok باشد، مقدار درون Ok از این عبارت بازگردانده می‌شود و برنامه ادامه می‌یابد. اگر مقدار در حالت Err باشد، مقدار Err از کل تابع بازگردانده می‌شود به گونه‌ای که انگار کلیدواژه return را استفاده کرده‌ایم تا مقدار خطا به کد فراخوانی‌کننده منتقل شود.

تفاوتی بین کاری که عبارت match در لیست ۹-۶ انجام می‌دهد و کاری که عملگر ? انجام می‌دهد وجود دارد: مقادیر خطا که عملگر ? روی آن‌ها فراخوانی می‌شود از طریق تابع from که در ویژگی From کتابخانه استاندارد تعریف شده است عبور می‌کنند، که برای تبدیل مقادیر از یک نوع به نوع دیگر استفاده می‌شود. وقتی عملگر ? تابع from را فراخوانی می‌کند، نوع خطای دریافت شده به نوع خطای تعریف شده در نوع بازگشتی تابع فعلی تبدیل می‌شود. این موضوع زمانی مفید است که یک تابع یک نوع خطا را برای نمایش تمام راه‌هایی که ممکن است یک تابع شکست بخورد بازگرداند، حتی اگر بخش‌هایی ممکن است به دلایل بسیار مختلفی شکست بخورند.

برای مثال، می‌توانیم تابع read_username_from_file در لیست ۹-۷ را تغییر دهیم تا یک نوع خطای سفارشی به نام OurError که تعریف کرده‌ایم بازگرداند. اگر همچنین impl From<io::Error> for OurError را تعریف کنیم تا یک نمونه از OurError را از یک io::Error بسازد، سپس فراخوانی‌های عملگر ? در بدنه تابع read_username_from_file تابع from را فراخوانی کرده و نوع خطاها را بدون نیاز به افزودن کد اضافی به تابع تبدیل می‌کنند.

در زمینه لیست ۹-۷، عملگر ? در انتهای فراخوانی File::open مقدار درون یک Ok را به متغیر username_file بازمی‌گرداند. اگر خطایی رخ دهد، عملگر ? زودتر از کل تابع خارج شده و هر مقدار Err را به کد فراخوانی‌کننده بازمی‌گرداند. همین موضوع برای عملگر ? در انتهای فراخوانی read_to_string صدق می‌کند.

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

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}
Listing 9-8: زنجیره کردن فراخوانی متدها پس از عملگر ?

ما ایجاد String جدید در username را به ابتدای تابع منتقل کرده‌ایم؛ آن قسمت تغییر نکرده است. به جای ایجاد یک متغیر username_file، ما فراخوانی read_to_string را مستقیماً به نتیجه File::open("hello.txt")? زنجیره کرده‌ایم. همچنان یک عملگر ? در انتهای فراخوانی read_to_string داریم و همچنان مقدار Ok شامل username را زمانی که هر دو File::open و read_to_string موفق هستند بازمی‌گردانیم، به جای بازگرداندن خطاها. عملکرد دوباره همانند لیست ۹-۶ و لیست ۹-۷ است؛ این فقط یک روش متفاوت و کاربرپسندتر برای نوشتن آن است.

لیست ۹-۹ روشی برای کوتاه‌تر کردن این کد با استفاده از fs::read_to_string را نشان می‌دهد.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}
Listing 9-9: استفاده از fs::read_to_string به جای باز کردن و سپس خواندن فایل

خواندن یک فایل به یک رشته یک عملیات نسبتاً رایج است، بنابراین کتابخانه استاندارد تابع مناسب fs::read_to_string را فراهم می‌کند که فایل را باز می‌کند، یک String جدید ایجاد می‌کند، محتوای فایل را می‌خواند، محتوا را در آن String قرار می‌دهد و آن را بازمی‌گرداند. البته، استفاده از fs::read_to_string به ما فرصتی برای توضیح تمام مدیریت خطاها نمی‌دهد، بنابراین ابتدا آن را به روش طولانی‌تر انجام دادیم.

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

عملگر ? فقط در توابعی استفاده می‌شود که نوع بازگشتی آن‌ها با مقدار استفاده شده توسط ? سازگار باشد. این به این دلیل است که عملگر ? برای بازگرداندن زودهنگام یک مقدار از تابع تعریف شده است، به همان شیوه‌ای که عبارت match در لیست ۹-۶ تعریف شده است. در لیست ۹-۶، match از یک مقدار Result استفاده می‌کرد و بازوی بازگشتی زودهنگام یک مقدار Err(e) را بازمی‌گرداند. نوع بازگشتی تابع باید یک Result باشد تا با این بازگشت سازگار باشد.

در لیست ۹-۱۰، بیایید به خطایی که دریافت می‌کنیم وقتی که از عملگر ? در یک تابع main با نوع بازگشتی‌ای که با نوع مقدار استفاده شده در ? سازگار نیست استفاده می‌کنیم نگاه کنیم.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}
Listing 9-10: تلاش برای استفاده از ? در تابع main که نوع بازگشتی آن () است و کامپایل نمی‌شود.

این کد یک فایل را باز می‌کند، که ممکن است شکست بخورد. عملگر ? مقدار Result بازگردانده شده توسط File::open را دنبال می‌کند، اما این تابع main نوع بازگشتی () دارد، نه Result. وقتی این کد را کامپایل می‌کنیم، پیام خطای زیر را دریافت می‌کنیم:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

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

این خطا نشان می‌دهد که فقط می‌توان از عملگر ? در توابعی که نوع بازگشتی آن‌ها Result، Option، یا نوع دیگری که FromResidual را پیاده‌سازی می‌کند استفاده کرد.

برای رفع این خطا، دو انتخاب دارید. یکی این است که نوع بازگشتی تابع خود را تغییر دهید تا با مقداری که از عملگر ? استفاده می‌کنید سازگار باشد، به شرطی که محدودیتی مانع از انجام این کار نداشته باشید. انتخاب دیگر این است که از یک match یا یکی از متدهای Result<T, E> برای مدیریت Result<T, E> به شیوه‌ای که مناسب است استفاده کنید.

پیام خطا همچنین اشاره کرد که ? می‌تواند با مقادیر Option<T> نیز استفاده شود. همانند استفاده از ? روی Result، فقط می‌توانید از ? روی Option در تابعی استفاده کنید که یک Option بازمی‌گرداند. رفتار عملگر ? وقتی روی یک Option<T> فراخوانی می‌شود شبیه به رفتار آن وقتی روی یک Result<T, E> فراخوانی می‌شود: اگر مقدار None باشد، None زودهنگام از تابع بازگردانده می‌شود. اگر مقدار Some باشد، مقدار داخل Some مقدار نتیجه عبارت است و تابع ادامه می‌دهد. لیست ۹-۱۱ مثالی از تابعی را نشان می‌دهد که آخرین کاراکتر خط اول متن داده شده را پیدا می‌کند.

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}
Listing 9-11: استفاده از عملگر ? روی یک مقدار Option<T>

این تابع Option<char> بازمی‌گرداند زیرا ممکن است یک کاراکتر وجود داشته باشد، اما ممکن است وجود نداشته باشد. این کد آرگومان قطعه رشته text را می‌گیرد و متد lines را روی آن فراخوانی می‌کند، که یک iterator روی خطوط درون رشته بازمی‌گرداند. چون این تابع می‌خواهد خط اول را بررسی کند، next را روی iterator فراخوانی می‌کند تا اولین مقدار از iterator را دریافت کند. اگر text رشته‌ای خالی باشد، این فراخوانی به next مقدار None بازمی‌گرداند، که در این صورت از ? برای متوقف کردن و بازگرداندن None از last_char_of_first_line استفاده می‌کنیم. اگر text رشته خالی نباشد، next یک مقدار Some شامل یک قطعه رشته از خط اول در text بازمی‌گرداند.

عملگر ? قطعه رشته را استخراج می‌کند و می‌توانیم متد chars را روی آن فراخوانی کنیم تا یک iterator از کاراکترهای آن دریافت کنیم. ما به آخرین کاراکتر در این خط اول علاقه‌مند هستیم، بنابراین متد last را فراخوانی می‌کنیم تا آخرین مورد در iterator را بازگرداند. این یک Option است زیرا ممکن است خط اول رشته‌ای خالی باشد؛ برای مثال، اگر text با یک خط خالی شروع شود اما کاراکترهایی در خطوط دیگر داشته باشد، مانند "\nhi". با این حال، اگر آخرین کاراکتری در خط اول وجود داشته باشد، در حالت Some بازگردانده می‌شود. عملگر ? در میانه به ما راهی مختصر برای بیان این منطق می‌دهد و اجازه می‌دهد تابع را در یک خط پیاده‌سازی کنیم. اگر نمی‌توانستیم از عملگر ? روی Option استفاده کنیم، باید این منطق را با فراخوانی متدهای بیشتر یا یک عبارت match پیاده‌سازی می‌کردیم.

توجه داشته باشید که می‌توانید از عملگر ? روی یک Result در یک تابع که یک Result بازمی‌گرداند استفاده کنید، و می‌توانید از عملگر ? روی یک Option در یک تابع که یک Option بازمی‌گرداند استفاده کنید، اما نمی‌توانید این دو را با هم ترکیب کنید. عملگر ? به طور خودکار یک Result را به یک Option یا برعکس تبدیل نمی‌کند؛ در این موارد، می‌توانید از متدهایی مانند ok روی Result یا ok_or روی Option برای تبدیل صریح استفاده کنید.

تا کنون، تمام توابع main که استفاده کرده‌ایم مقدار () بازمی‌گرداندند. تابع main خاص است زیرا نقطه ورود و خروج یک برنامه اجرایی است، و محدودیت‌هایی در نوع بازگشتی آن وجود دارد تا برنامه همانطور که انتظار می‌رود رفتار کند.

خوشبختانه، main می‌تواند یک Result<(), E> نیز بازگرداند. لیست ۹-۱۲ کد لیست ۹-۱۰ را دارد، اما نوع بازگشتی main را به Result<(), Box<dyn Error>> تغییر داده‌ایم و یک مقدار بازگشتی Ok(()) به انتهای آن اضافه کرده‌ایم. این کد اکنون کامپایل می‌شود.

Filename: src/main.rs
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}
Listing 9-12: تغییر تابع main برای بازگرداندن Result<(), E> اجازه می‌دهد از عملگر ? روی مقادیر Result استفاده شود.

نوع Box<dyn Error> یک trait object است، که در فصل ۱۸ در بخش “استفاده از Trait Objectها برای مقادیر با انواع متفاوت” درباره‌ی آن صحبت خواهیم کرد. فعلاً می‌توانید Box<dyn Error> را به معنای «هر نوع خطایی» در نظر بگیرید. استفاده از ? روی یک مقدار Result در تابع main با نوع خطای Box<dyn Error> مجاز است، زیرا این امکان را می‌دهد که هر مقدار Err زودتر بازگردانده شود. حتی اگر بدنه‌ی این تابع main فقط خطاهایی از نوع std::io::Error بازگرداند، با مشخص کردن Box<dyn Error> در امضا، این امضا همچنان معتبر باقی خواهد ماند حتی اگر بعداً کدی به main اضافه شود که خطاهای دیگری را بازمی‌گرداند.

وقتی یک تابع main یک Result<(), E> بازمی‌گرداند، برنامه اجرایی با مقدار 0 خارج می‌شود اگر main مقدار Ok(()) بازگرداند و با یک مقدار غیر صفر خارج می‌شود اگر main مقدار Err بازگرداند. برنامه‌های اجرایی نوشته شده در C هنگام خروج مقادیر صحیح بازمی‌گردانند: برنامه‌هایی که با موفقیت خارج می‌شوند مقدار صحیح 0 را بازمی‌گردانند و برنامه‌هایی که دچار خطا می‌شوند مقداری غیر از 0 بازمی‌گردانند. Rust نیز مقادیر صحیح را از برنامه‌های اجرایی بازمی‌گرداند تا با این قرارداد سازگار باشد.

تابع main می‌تواند هر نوعی را که ویژگی std::process::Termination را پیاده‌سازی می‌کند بازگرداند، که شامل تابع report است که یک ExitCode بازمی‌گرداند. مستندات کتابخانه استاندارد را برای اطلاعات بیشتر درباره پیاده‌سازی ویژگی Termination برای انواع خودتان مطالعه کنید.

اکنون که جزئیات فراخوانی panic! یا بازگرداندن Result را بررسی کردیم، بیایید به موضوع نحوه تصمیم‌گیری درباره اینکه کدامیک در چه مواردی مناسب است بازگردیم.

آیا باید از panic! استفاده کنیم یا نه؟

چگونه تصمیم می‌گیرید که چه زمانی باید panic! را فراخوانی کنید و چه زمانی باید یک Result بازگردانید؟ وقتی کد دچار خطا می‌شود، هیچ راهی برای بازیابی وجود ندارد. شما می‌توانید در هر وضعیت خطایی، چه قابل بازیابی باشد و چه نباشد، panic! را فراخوانی کنید، اما در این صورت، شما به جای کد فراخوانی‌کننده تصمیم می‌گیرید که وضعیت غیرقابل بازیابی است. وقتی تصمیم می‌گیرید یک مقدار Result بازگردانید، به کد فراخوانی‌کننده گزینه‌هایی می‌دهید. کد فراخوانی‌کننده می‌تواند انتخاب کند که تلاش کند خطا را به روشی که برای وضعیت خودش مناسب است بازیابی کند، یا می‌تواند تصمیم بگیرد که مقدار Err در این مورد غیرقابل بازیابی است و بنابراین panic! را فراخوانی کرده و خطای قابل بازیابی شما را به یک خطای غیرقابل بازیابی تبدیل کند. بنابراین، بازگرداندن Result یک انتخاب پیش‌فرض خوب است وقتی تابعی تعریف می‌کنید که ممکن است شکست بخورد.

در وضعیت‌هایی مانند مثال‌ها، کد نمونه‌سازی (prototype) و آزمون‌ها، مناسب‌تر است که کدی بنویسید که متوقف شود به جای بازگرداندن یک Result. بیایید بررسی کنیم چرا، سپس وضعیت‌هایی را بحث کنیم که کامپایلر نمی‌تواند بفهمد که شکست غیرممکن است، اما شما به عنوان یک انسان می‌توانید. این فصل با برخی دستورالعمل‌های کلی درباره تصمیم‌گیری درباره اینکه آیا در کد کتابخانه باید از panic! استفاده کرد یا نه، به پایان خواهد رسید.

مثال‌ها، کد نمونه‌سازی، و آزمون‌ها

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

به همین ترتیب، متدهای unwrap و expect بسیار مفید هستند وقتی که در حال نمونه‌سازی هستید و هنوز تصمیم نگرفته‌اید که چگونه خطاها را مدیریت کنید. آن‌ها نشانه‌های واضحی در کد شما می‌گذارند برای زمانی که آماده باشید برنامه خود را قدرتمندتر کنید.

اگر یک متد در یک آزمون شکست بخورد، می‌خواهید کل آزمون شکست بخورد، حتی اگر آن متد ویژگی‌ای که تحت آزمون قرار دارد نباشد. از آنجا که panic! راهی است که یک آزمون به عنوان شکست‌خورده علامت‌گذاری می‌شود، فراخوانی unwrap یا expect دقیقاً همان چیزی است که باید اتفاق بیفتد.

مواردی که شما اطلاعات بیشتری نسبت به کامپایلر دارید

در زمانی که منطق دیگری در برنامه شما وجود دارد که تضمین می‌کند مقدار Result از نوع Ok خواهد بود،
اما این منطق چیزی نیست که کامپایلر بتواند آن را درک کند،
استفاده از expect نیز مناسب خواهد بود.
شما همچنان با یک مقدار Result مواجه هستید که باید آن را مدیریت کنید:
عملیاتی که فراخوانی می‌کنید به‌صورت کلی ممکن است شکست بخورد،
حتی اگر در موقعیت خاص شما از نظر منطقی وقوع خطا غیرممکن باشد.
اگر با بررسی دستی کد بتوانید اطمینان حاصل کنید که هیچ‌گاه با واریانت Err روبه‌رو نخواهید شد،
استفاده از expect کاملاً قابل‌قبول است،
به شرط آن‌که دلیل این اطمینان خود را در قالب متن آرگومان expect مستندسازی کنید.
در این‌جا یک مثال آورده شده است:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

ما یک نمونه IpAddr را با تجزیه یک رشته ثابت‌شده ایجاد می‌کنیم. ما می‌توانیم ببینیم که 127.0.0.1 یک آدرس IP معتبر است، بنابراین استفاده از expect در اینجا قابل قبول است. با این حال، داشتن یک رشته ثابت‌شده و معتبر نوع بازگشتی متد parse را تغییر نمی‌دهد: ما همچنان یک مقدار Result دریافت می‌کنیم و کامپایلر همچنان ما را مجبور می‌کند که با Result برخورد کنیم، انگار که حالت Err ممکن است، زیرا کامپایلر به اندازه کافی هوشمند نیست تا ببیند این رشته همیشه یک آدرس IP معتبر است. اگر رشته آدرس IP از یک کاربر می‌آمد به جای اینکه در برنامه ثابت شده باشد و بنابراین امکان شکست وجود داشت، قطعاً می‌خواستیم که Result را به روشی قدرتمندتر مدیریت کنیم. اشاره به این فرض که این آدرس IP ثابت‌شده است، ما را ترغیب می‌کند که در صورت نیاز به دریافت آدرس IP از منبع دیگری در آینده، expect را به کد مدیریت خطای بهتر تغییر دهیم.

دستورالعمل‌هایی برای مدیریت خطاها

توصیه می‌شود که کد شما زمانی که ممکن است به وضعیت نامناسبی برسد، دچار panic! شود. در این زمینه، یک وضعیت نامناسب زمانی رخ می‌دهد که برخی فرضیات، تضمین‌ها، قراردادها، یا تغییرناپذیری‌ها شکسته شوند، مانند زمانی که مقادیر نامعتبر، مقادیر متناقض، یا مقادیر گمشده به کد شما پاس داده می‌شوند—به علاوه یکی یا بیشتر از شرایط زیر:

  • وضعیت نادرست (bad state) چیزی غیرمنتظره است، بر خلاف موقعیت‌هایی که احتمالاً گاهی اتفاق می‌افتند، مانند وارد کردن داده با فرمت نادرست توسط کاربر.
  • کد شما پس از این نقطه باید به نبودن در چنین وضعیت نادرستی تکیه کند، به‌جای آن‌که در هر مرحله مشکل را بررسی کند.
  • راه مناسبی برای رمزگذاری این اطلاعات در قالب typeهایی که استفاده می‌کنید وجود ندارد. در فصل ۱۸، در بخش “رمزگذاری وضعیت‌ها و رفتارها به‌صورت type” با مثالی منظور خود را توضیح خواهیم داد.

اگر کسی کد شما را فراخوانی کند و مقادیری که منطقی نیستند را پاس دهد، بهتر است که یک خطا بازگردانید تا کاربر کتابخانه بتواند تصمیم بگیرد که در آن مورد چه کاری انجام دهد. با این حال، در مواردی که ادامه دادن می‌تواند ناامن یا مضر باشد، بهترین انتخاب ممکن است فراخوانی panic! و هشدار به شخصی که از کتابخانه شما استفاده می‌کند درباره باگ در کد آن‌ها باشد تا بتوانند آن را در حین توسعه رفع کنند. به همین ترتیب، panic! اغلب مناسب است اگر کد خارجی که از کنترل شما خارج است را فراخوانی می‌کنید و آن کد یک وضعیت نامعتبر بازمی‌گرداند که شما هیچ راهی برای رفع آن ندارید.

با این حال، زمانی که شکست مورد انتظار است، مناسب‌تر است که یک Result بازگردانید تا یک فراخوانی panic!. مثال‌ها شامل پردازشی هستند که داده‌های نادرست دریافت می‌کند یا یک درخواست HTTP که بازگشت وضعیت نشان می‌دهد که به محدودیت نرخ برخورد کرده‌اید. در این موارد، بازگرداندن یک Result نشان می‌دهد که شکست یک احتمال مورد انتظار است که کد فراخوانی‌کننده باید تصمیم بگیرد چگونه آن را مدیریت کند.

وقتی کد شما عملیاتی انجام می‌دهد که می‌تواند در صورت فراخوانی با مقادیر نامعتبر کاربر را در معرض خطر قرار دهد، کد شما باید ابتدا مقادیر را تأیید کند و اگر مقادیر نامعتبر هستند دچار panic! شود. این بیشتر به دلایل ایمنی است: تلاش برای انجام عملیات روی داده‌های نامعتبر می‌تواند کد شما را در معرض آسیب‌پذیری‌ها قرار دهد. این دلیل اصلی است که کتابخانه استاندارد اگر شما تلاش کنید به حافظه خارج از محدوده دسترسی پیدا کنید، دچار panic! می‌شود: تلاش برای دسترسی به حافظه‌ای که به ساختار داده جاری تعلق ندارد یک مشکل امنیتی رایج است. توابع اغلب قراردادهایی دارند: رفتار آن‌ها فقط در صورتی تضمین می‌شود که ورودی‌ها نیازمندی‌های خاصی را برآورده کنند. دچار panic! شدن وقتی که قرارداد نقض می‌شود منطقی است زیرا نقض قرارداد همیشه نشان‌دهنده یک باگ در طرف فراخوانی‌کننده است و نوع خطایی نیست که بخواهید کد فراخوانی‌کننده به طور صریح مدیریت کند. در واقع، هیچ راه معقولی برای بازیابی کد فراخوانی‌کننده وجود ندارد؛ برنامه‌نویسان فراخوانی‌کننده باید کد را اصلاح کنند. قراردادهای یک تابع، به خصوص زمانی که نقض آن باعث panic! می‌شود، باید در مستندات API تابع توضیح داده شوند.

با این حال، داشتن بررسی‌های خطا در تمام توابع شما بسیار طولانی و ناخوشایند خواهد بود. خوشبختانه، شما می‌توانید از سیستم نوع Rust (و در نتیجه بررسی نوعی که توسط کامپایلر انجام می‌شود) برای انجام بسیاری از بررسی‌ها استفاده کنید. اگر تابع شما یک نوع خاص را به عنوان پارامتر داشته باشد، می‌توانید با اطمینان از اینکه کامپایلر قبلاً تضمین کرده است که یک مقدار معتبر دارید، منطق کد خود را پیش ببرید. برای مثال، اگر شما یک نوع به جای یک Option داشته باشید، برنامه شما انتظار دارد که چیزی به جای هیچ‌چیز وجود داشته باشد. سپس کد شما نیازی به مدیریت دو حالت برای حالت‌های Some و None ندارد: فقط یک حالت برای داشتن یک مقدار به طور قطعی خواهد داشت. کدی که سعی می‌کند هیچ‌چیز به تابع شما پاس دهد حتی کامپایل نخواهد شد، بنابراین تابع شما نیازی به بررسی این حالت در زمان اجرا ندارد. مثال دیگر استفاده از یک نوع عددی بدون علامت مانند u32 است که تضمین می‌کند پارامتر هرگز منفی نخواهد بود.

ایجاد انواع سفارشی برای اعتبارسنجی

بیایید ایده استفاده از سیستم نوع Rust برای اطمینان از داشتن یک مقدار معتبر را یک قدم فراتر ببریم و به ایجاد یک نوع سفارشی برای اعتبارسنجی نگاه کنیم. بازی حدس عدد در فصل ۲ را به یاد بیاورید که کد ما از کاربر خواست تا یک عدد بین ۱ تا ۱۰۰ حدس بزند. ما هرگز اعتبارسنجی نکردیم که حدس کاربر بین این اعداد باشد قبل از اینکه آن را با عدد مخفی مقایسه کنیم؛ فقط بررسی کردیم که حدس مثبت باشد. در این مورد، پیامدها چندان شدید نبودند: خروجی ما با پیام‌های “خیلی بزرگ” یا “خیلی کوچک” همچنان درست بود. اما این می‌تواند بهبودی مفید باشد که کاربر را به سمت حدس‌های معتبر هدایت کنیم و رفتار متفاوتی داشته باشیم وقتی کاربر عددی خارج از محدوده حدس می‌زند در مقابل زمانی که، برای مثال، حروف تایپ می‌کند.

یک راه برای انجام این کار این است که حدس را به جای فقط یک u32، به صورت یک i32 تجزیه کنیم تا اجازه دهیم اعداد منفی نیز در نظر گرفته شوند، و سپس یک بررسی برای اینکه عدد در محدوده است یا نه اضافه کنیم، مانند زیر:

Filename: src/main.rs
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);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

عبارت if بررسی می‌کند که آیا مقدار ما خارج از محدوده است، به کاربر درباره مشکل اطلاع می‌دهد و continue را فراخوانی می‌کند تا تکرار بعدی حلقه شروع شود و درخواست یک حدس دیگر شود. بعد از عبارت if، می‌توانیم با مقایسه بین guess و عدد مخفی ادامه دهیم، زیرا می‌دانیم که guess بین ۱ و ۱۰۰ است.

با این حال، این یک راه‌حل ایده‌آل نیست: اگر بسیار حیاتی باشد که برنامه فقط بر روی مقادیر بین ۱ و ۱۰۰ عمل کند، و برنامه توابع زیادی با این نیاز داشته باشد، داشتن چنین بررسی‌هایی در هر تابع خسته‌کننده خواهد بود (و ممکن است عملکرد را تحت تأثیر قرار دهد).

در عوض، می‌توانیم یک نوع جدید در یک ماژول اختصاصی تعریف کنیم و اعتبارسنجی‌ها (validations) را در تابعی قرار دهیم که وظیفه‌ی ایجاد یک نمونه از آن نوع را دارد، به‌جای آن‌که این اعتبارسنجی‌ها را در همه‌جا تکرار کنیم. به این ترتیب، استفاده از این نوع جدید در امضای توابع ایمن خواهد بود و می‌توان با اطمینان از مقادیری که دریافت می‌کنند استفاده کرد. لیستینگ 9-13 یک روش برای تعریف نوع Guess را نشان می‌دهد که تنها زمانی یک نمونه از Guess ایجاد می‌کند که تابع new مقداری بین 1 تا 100 دریافت کرده باشد.

Filename: src/guessing_game.rs
#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Listing 9-13: نوع Guess که تنها با مقادیری بین 1 تا 100 ادامه می‌دهد

توجه داشته باشید که این کد در فایل src/guessing_game.rs
بستگی به اضافه کردن یک اعلان ماژول به شکل mod guessing_game; در فایل src/lib.rs دارد
که در این‌جا نشان داده نشده است.
درون فایل این ماژول جدید، یک struct به نام Guess تعریف کرده‌ایم
که در همان ماژول قرار دارد و دارای یک فیلد به نام value است که یک مقدار از نوع i32 را نگه می‌دارد.
این همان مکانی است که عدد در آن ذخیره خواهد شد.

سپس یک تابع وابسته به نام new روی Guess پیاده‌سازی می‌کنیم که نمونه‌هایی از مقادیر Guess ایجاد می‌کند. تابع new به گونه‌ای تعریف شده که یک پارامتر به نام value از نوع i32 داشته باشد و یک Guess بازگرداند. کدی که در بدنه تابع new قرار دارد مقدار value را بررسی می‌کند تا مطمئن شود که بین ۱ و ۱۰۰ است. اگر مقدار value این آزمون را پاس نکند، یک فراخوانی به panic! انجام می‌دهیم، که به برنامه‌نویسی که کد فراخوانی‌کننده را می‌نویسد هشدار می‌دهد که باگی دارد که باید برطرف کند، زیرا ایجاد یک Guess با مقدار value خارج از این محدوده قرارداد تابع Guess::new را نقض می‌کند. شرایطی که ممکن است باعث panic! در Guess::new شود باید در مستندات عمومی API آن مورد بحث قرار گیرد؛ ما در فصل ۱۴ درباره قراردادهای مستندات که نشان‌دهنده احتمال وقوع panic! هستند صحبت خواهیم کرد. اگر مقدار value آزمون را پاس کند، یک Guess جدید با فیلد value تنظیم شده به پارامتر value ایجاد می‌کنیم و Guess را بازمی‌گردانیم.

در مرحله‌ی بعد، متدی به نام value پیاده‌سازی می‌کنیم که self را به‌صورت رفرنس قرض می‌گیرد، پارامتر دیگری ندارد، و یک مقدار i32 بازمی‌گرداند. این نوع متد گاهی getter نامیده می‌شود، زیرا هدف آن دریافت داده‌ای از فیلدهای ساختار و بازگرداندن آن است. این متد عمومی لازم است چون فیلد value در struct مربوط به Guess خصوصی است. خصوصی بودن فیلد value اهمیت دارد تا کدی که از struct Guess استفاده می‌کند، اجازه نداشته باشد مستقیماً value را مقداردهی کند: کد خارج از ماژول guessing_game باید از تابع Guess::new برای ساخت نمونه‌ای از Guess استفاده کند، و به این ترتیب، اطمینان حاصل می‌شود که هیچ راهی برای ساخت یک Guess با مقدار valueای که بررسی‌های موجود در تابع Guess::new را نگذرانده، وجود ندارد.

تابعی که یک پارامتر می‌گیرد یا فقط اعدادی بین ۱ و ۱۰۰ بازمی‌گرداند می‌تواند در امضای خود اعلام کند که یک Guess می‌گیرد یا بازمی‌گرداند به جای یک i32 و نیازی به انجام بررسی‌های اضافی در بدنه خود ندارد.

خلاصه

ویژگی‌های مدیریت خطای Rust طراحی شده‌اند تا به شما کمک کنند کدی قدرتمندتر بنویسید. ماکروی panic! نشان می‌دهد که برنامه شما در حالتی قرار دارد که نمی‌تواند آن را مدیریت کند و به شما امکان می‌دهد فرآیند را متوقف کنید به جای اینکه سعی کنید با مقادیر نامعتبر یا نادرست ادامه دهید. Enum Result از سیستم نوع Rust استفاده می‌کند تا نشان دهد که عملیات ممکن است به روشی شکست بخورد که کد شما می‌تواند از آن بازیابی کند. می‌توانید از Result برای اطلاع دادن به کدی که کد شما را فراخوانی می‌کند استفاده کنید که باید موفقیت یا شکست احتمالی را نیز مدیریت کند. استفاده از panic! و Result در شرایط مناسب باعث می‌شود کد شما در برابر مشکلات اجتناب‌ناپذیر قابل اطمینان‌تر شود.

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

انواع جنریک، ویژگی‌ها (Traits)، و طول عمرها (Lifetimes)

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

توابع می‌توانند پارامترهایی از نوع جنریک بگیرند، به جای یک نوع مشخص مانند i32 یا String، به همان روشی که پارامترهایی با مقادیر ناشناخته می‌گیرند تا بتوانند کد مشابهی را روی مقادیر مشخص مختلف اجرا کنند. در واقع، ما قبلاً در فصل ۶ با Option<T>، در فصل ۸ با Vec<T> و HashMap<K, V>، و در فصل ۹ با Result<T, E> از جنریک‌ها استفاده کرده‌ایم. در این فصل، یاد خواهید گرفت که چگونه انواع، توابع، و متدهای خود را با جنریک‌ها تعریف کنید!

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

سپس یاد می‌گیرید که چگونه از ویژگی‌ها (Traits) برای تعریف رفتار به صورت جنریک استفاده کنید. می‌توانید ویژگی‌ها را با انواع جنریک ترکیب کنید تا نوع جنریک را محدود کنید که فقط آن نوع‌هایی را بپذیرد که رفتار خاصی دارند، به جای هر نوعی.

در نهایت، درباره طول عمر‌ها (Lifetimes) صحبت خواهیم کرد: نوعی از جنریک‌ها که به کامپایلر اطلاعاتی درباره نحوه ارتباط مراجع با یکدیگر می‌دهند. طول عمرها به ما اجازه می‌دهند اطلاعات کافی درباره مقادیر قرض گرفته شده به کامپایلر بدهیم تا اطمینان حاصل کند که مراجع در شرایط بیشتری معتبر خواهند بود.

حذف تکرار با استخراج یک تابع

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

با برنامه کوتاه در لیست ۱۰-۱ که بزرگ‌ترین عدد را در یک لیست پیدا می‌کند، شروع می‌کنیم.

Filename: src/main.rs
fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
    assert_eq!(*largest, 100);
}
Listing 10-1: یافتن بزرگ‌ترین عدد در یک لیست اعداد

ما یک لیست از اعداد صحیح را در متغیر number_list ذخیره می‌کنیم و یک مرجع به اولین عدد در لیست را در متغیری به نام largest قرار می‌دهیم. سپس تمام اعداد لیست را پیمایش می‌کنیم و اگر عدد فعلی بزرگ‌تر از عدد ذخیره شده در largest باشد، مرجع در آن متغیر را جایگزین می‌کنیم. با این حال، اگر عدد فعلی کوچک‌تر یا مساوی با بزرگ‌ترین عدد دیده شده تاکنون باشد، متغیر تغییری نمی‌کند و کد به عدد بعدی در لیست می‌رود. پس از بررسی تمام اعداد در لیست، largest باید به بزرگ‌ترین عدد اشاره کند که در این مورد ۱۰۰ است.

اکنون از ما خواسته شده است که بزرگ‌ترین عدد را در دو لیست مختلف اعداد پیدا کنیم. برای انجام این کار، می‌توانیم انتخاب کنیم که کد در لیست ۱۰-۱ را تکرار کنیم و از همان منطق در دو مکان مختلف در برنامه استفاده کنیم، همانطور که در لیست ۱۰-۲ نشان داده شده است.

Filename: src/main.rs
fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
}
Listing 10-2: کدی برای یافتن بزرگ‌ترین عدد در دو لیست اعداد

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

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

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

Filename: src/main.rs
fn largest(list: &[i32]) -> &i32 {
    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}");
    assert_eq!(*result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 6000);
}
Listing 10-3: کد انتزاعی برای یافتن بزرگ‌ترین عدد در دو لیست

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

به طور خلاصه، مراحل زیر را برای تغییر کد از لیست ۱۰-۲ به لیست ۱۰-۳ طی کردیم:

  1. کد تکراری را شناسایی کنید.
  2. کد تکراری را به بدنه یک تابع استخراج کرده و ورودی‌ها و مقادیر بازگشتی آن کد را در امضای تابع مشخص کنید.
  3. دو نمونه از کد تکراری را به جای آن با فراخوانی تابع به‌روزرسانی کنید.

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

برای مثال، فرض کنید دو تابع داشتیم: یکی که بزرگ‌ترین مورد را در یک بخش از مقادیر i32 پیدا می‌کند و دیگری که بزرگ‌ترین مورد را در یک بخش از مقادیر char پیدا می‌کند. چگونه می‌توانیم این تکرار را حذف کنیم؟ بیایید پیدا کنیم!

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

ما از جنریک‌ها برای ایجاد تعریف‌هایی برای مواردی مانند امضای توابع یا ساختارها (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 را در یک بخش پیدا می‌کند. بدنه توابع دارای کد یکسانی هستند، بنابراین با معرفی یک پارامتر نوع جنریک در یک تابع واحد، تکرار را حذف می‌کنیم.

برای پارامتری‌سازی نوع‌ها در یک تابع جدید، باید همان‌طور که برای پارامترهای مقداری (value parameters) نام مشخص می‌کنیم، برای پارامتر نوع نیز یک نام تعیین کنیم. می‌توانید از هر شناسه‌ای به عنوان نام پارامتر نوع استفاده کنید، اما ما از T استفاده خواهیم کرد، چون طبق قرارداد، نام پارامترهای نوع در Rust کوتاه هستند— اغلب تنها یک حرف—و همچنین طبق قرارداد نام‌گذاری نوع‌ها در Rust به‌صورت CamelCase نوشته می‌شوند. T که مخفف type است، انتخاب پیش‌فرض اکثر برنامه‌نویسان Rust می‌باشد.

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

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

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

لیستینگ 10-5 تعریف ترکیبی تابع largest را نشان می‌دهد که از نوع داده‌ی generic در امضای خود استفاده می‌کند.
این لیستینگ همچنین نشان می‌دهد که چگونه می‌توان این تابع را با یک slice از مقادیر 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` with trait `PartialOrd`
  |
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 است، و ما در بخش بعدی درباره‌ی traitها صحبت خواهیم کرد. فعلاً بدانید که این خطا بیان می‌کند بدنه‌ی تابع largest برای همه‌ی نوع‌هایی که T می‌تواند باشد، کار نخواهد کرد. چون می‌خواهیم در بدنه‌ی تابع مقادیری از نوع T را با هم مقایسه کنیم، تنها می‌توانیم از نوع‌هایی استفاده کنیم که مقادیر آن‌ها قابل مقایسه (ترتیب‌پذیر) باشند. برای فعال کردن امکان مقایسه، کتابخانه‌ی استاندارد traitای به نام std::cmp::PartialOrd دارد که می‌توان آن را روی نوع‌ها پیاده‌سازی کرد (برای اطلاعات بیشتر درباره‌ی این trait، به ضمیمه‌ی C مراجعه کنید). برای اصلاح لیستینگ 10-5، می‌توانیم پیشنهاد متن خطا را دنبال کنیم و نوع‌های معتبر برای 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 را در زمان اجرا بسیار کارآمد می‌کند.

ویژگی‌ها (Traits): تعریف رفتار مشترک

یک ویژگی (trait) عملکردی را که یک نوع خاص دارد تعریف می‌کند و می‌تواند با انواع دیگر به اشتراک بگذارد. ما می‌توانیم از traitها برای تعریف رفتار مشترک به صورت انتزاعی استفاده کنیم. همچنین می‌توانیم از محدودیت‌های ویژگی (trait bounds) برای مشخص کردن اینکه یک نوع جنریک می‌تواند هر نوعی باشد که رفتار خاصی دارد، استفاده کنیم.

توجه: ویژگی‌ها شبیه به مفهومی هستند که اغلب در زبان‌های دیگر به نام interfaces شناخته می‌شود، البته با برخی تفاوت‌ها.

تعریف یک trait

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

برای مثال، فرض کنید چندین struct داریم که انواع مختلفی از متن با اندازه‌های متفاوت را نگهداری می‌کنند: یک ساختار NewsArticle که یک خبر را در مکان خاصی نگهداری می‌کند، و یک SocialPost که حداکثر می‌تواند ۲۸۰ کاراکتر داشته باشد به‌همراه متاداده‌ای که مشخص می‌کند آیا پست جدید، بازنشر (repost)، یا پاسخ به پست دیگری بوده است.

ما می‌خواهیم یک crate کتابخانه‌ای برای جمع‌آوری رسانه‌ها به نام aggregator بسازیم که بتواند خلاصه‌هایی از داده‌هایی که ممکن است در نمونه‌هایی از NewsArticle یا SocialPost ذخیره شده باشند را نمایش دهد. برای انجام این کار، به یک خلاصه از هر نوع نیاز داریم، و این خلاصه را با فراخوانی متد summarize روی یک نمونه درخواست خواهیم کرد. لیستینگ 10-12 تعریف یک trait عمومی به نام Summary را نشان می‌دهد که این رفتار را بیان می‌کند.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String;
}
Listing 10-12: ویژگی Summary که شامل رفتار ارائه‌شده توسط یک متد summarize است

در اینجا، یک ویژگی با استفاده از کلیدواژه trait و سپس نام ویژگی، که در اینجا Summary است، اعلام می‌کنیم. همچنین ویژگی را به عنوان pub اعلام می‌کنیم تا کرایت‌هایی که به این کرایت وابسته هستند نیز بتوانند از این ویژگی استفاده کنند، همانطور که در چند مثال خواهیم دید. در داخل آکولادها، امضاهای متدی را اعلام می‌کنیم که رفتارهای نوع‌هایی که این ویژگی را پیاده‌سازی می‌کنند توصیف می‌کنند، که در این مورد fn summarize(&self) -> String است.

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

یک ویژگی می‌تواند چندین متد در بدنه خود داشته باشد: امضاهای متدها به صورت یک خط در هر خط فهرست می‌شوند و هر خط با یک نقطه‌ویرگول پایان می‌یابد.

پیاده‌سازی یک ویژگی (trait) روی یک نوع

حالا که امضاهای مورد نظر برای متدهای trait به نام Summary را تعریف کرده‌ایم، می‌توانیم آن را روی نوع‌های موجود در گردآورنده‌ی رسانه‌ای‌مان پیاده‌سازی کنیم. لیستینگ 10-13 پیاده‌سازی trait Summary را روی structای به نام NewsArticle نشان می‌دهد، که از عنوان (headline)، نویسنده (author)، و مکان (location) برای ایجاد مقدار بازگشتی متد summarize استفاده می‌کند. برای ساختار SocialPost، متد summarize را به‌گونه‌ای تعریف می‌کنیم که ابتدا نام کاربری بیاید و سپس تمام متن پست نمایش داده شود، با این فرض که محتوای پست از پیش به ۲۸۰ کاراکتر محدود شده است.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-13: پیاده‌سازی trait به نام Summary روی نوع‌های NewsArticle و SocialPost

پیاده‌سازی یک ویژگی روی یک نوع مشابه پیاده‌سازی متدهای معمولی است. تفاوت این است که بعد از impl، نام ویژگی‌ای که می‌خواهیم پیاده‌سازی کنیم را قرار می‌دهیم، سپس از کلمه کلیدی for استفاده می‌کنیم و سپس نام نوعی که می‌خواهیم ویژگی را برای آن پیاده‌سازی کنیم مشخص می‌کنیم. درون بلوک impl، امضاهای متدی که تعریف ویژگی مشخص کرده‌اند را قرار می‌دهیم. به جای اضافه کردن یک نقطه‌ویرگول بعد از هر امضا، از آکولادها استفاده می‌کنیم و بدنه متد را با رفتار خاصی که می‌خواهیم متدهای ویژگی برای نوع خاص داشته باشند پر می‌کنیم.

حالا که کتابخانه ویژگی Summary را روی NewsArticle و Tweet پیاده‌سازی کرده است، کاربران این کرایت می‌توانند متدهای ویژگی را روی نمونه‌های NewsArticle و Tweet فراخوانی کنند، به همان روشی که متدهای معمولی را فراخوانی می‌کنیم. تنها تفاوت این است که کاربر باید ویژگی را به همراه نوع‌ها به محدوده وارد کند. در اینجا مثالی از اینکه چگونه یک کرایت باینری می‌تواند از کرایت کتابخانه aggregator ما استفاده کند آورده شده است:

use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}

این کد مقدار زیر را چاپ می‌کند:

1 new post: horse_ebooks: of course, as you probably already know, people

سایر crateهایی که به crate aggregator وابسته هستند نیز می‌توانند trait به نام Summary را وارد حوزه کنند و آن را روی نوع‌های خودشان پیاده‌سازی نمایند. یک محدودیت مهم این است که تنها زمانی می‌توانیم یک trait را روی یک نوع پیاده‌سازی کنیم که یا trait یا نوع، یا هر دو، در crate ما محلی (local) باشند. برای مثال، می‌توانیم traitهای کتابخانه‌ی استاندارد مانند Display را روی یک نوع سفارشی مانند SocialPost پیاده‌سازی کنیم زیرا نوع SocialPost در crate aggregator محلی است. همچنین می‌توانیم trait Summary را روی Vec<T> در crate aggregator پیاده‌سازی کنیم چون trait Summary در crate ما محلی است.

اما نمی‌توانیم ویژگی‌های خارجی را روی نوع‌های خارجی پیاده‌سازی کنیم. برای مثال، نمی‌توانیم ویژگی Display را روی Vec<T> در کرایت aggregator پیاده‌سازی کنیم زیرا Display و Vec<T> هر دو در کتابخانه استاندارد تعریف شده‌اند و به کرایت aggregator محلی نیستند. این محدودیت بخشی از خاصیتی به نام انسجام (coherence) و به طور خاص‌تر قانون یتیم (orphan rule) است، که به این دلیل نامگذاری شده است که نوع والد وجود ندارد. این قانون اطمینان می‌دهد که کد دیگران نمی‌تواند کد شما را خراب کند و برعکس. بدون این قانون، دو کرایت می‌توانستند همان ویژگی را برای همان نوع پیاده‌سازی کنند و Rust نمی‌دانست کدام پیاده‌سازی را استفاده کند.

پیاده‌سازی‌های پیش‌فرض

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

در لیست ۱۰-۱۴، یک رشته پیش‌فرض برای متد summarize ویژگی Summary مشخص می‌کنیم به جای اینکه فقط امضای متد را تعریف کنیم، همانطور که در لیست ۱۰-۱۲ انجام دادیم.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-14: تعریف ویژگی Summary با یک پیاده‌سازی پیش‌فرض برای متد summarize

برای استفاده از یک پیاده‌سازی پیش‌فرض برای خلاصه کردن نمونه‌های NewsArticle، یک بلوک impl خالی با impl Summary for NewsArticle {} مشخص می‌کنیم.

اگرچه دیگر متد summarize را مستقیماً روی NewsArticle تعریف نمی‌کنیم، یک پیاده‌سازی پیش‌فرض ارائه داده‌ایم و مشخص کرده‌ایم که NewsArticle ویژگی Summary را پیاده‌سازی می‌کند. در نتیجه، همچنان می‌توانیم متد summarize را روی یک نمونه از NewsArticle فراخوانی کنیم، مانند این:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

این کد New article available! (Read more...) را چاپ می‌کند.

ایجاد یک پیاده‌سازی پیش‌فرض (default) نیازی به تغییر در پیاده‌سازی trait Summary برای SocialPost در لیستینگ 10-13 ندارد. دلیل آن این است که سینتکس بازنویسی (override) یک پیاده‌سازی پیش‌فرض، دقیقاً همان سینتکسی است که برای پیاده‌سازی یک متد از trait که پیاده‌سازی پیش‌فرض ندارد استفاده می‌شود.

پیاده‌سازی‌های پیش‌فرض می‌توانند متدهای دیگر را در همان ویژگی فراخوانی کنند، حتی اگر آن متدهای دیگر پیاده‌سازی پیش‌فرض نداشته باشند. به این روش، یک ویژگی می‌تواند مقدار زیادی عملکرد مفید ارائه دهد و فقط از پیاده‌سازان بخواهد که بخشی از آن را مشخص کنند. برای مثال، می‌توانیم ویژگی Summary را به گونه‌ای تعریف کنیم که یک متد summarize_author داشته باشد که پیاده‌سازی آن الزامی است و سپس یک متد summarize تعریف کنیم که یک پیاده‌سازی پیش‌فرض دارد و متد summarize_author را فراخوانی می‌کند:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

برای استفاده از این نسخه از Summary، فقط باید summarize_author را هنگامی که ویژگی را روی یک نوع پیاده‌سازی می‌کنیم، تعریف کنیم:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

پس از این‌که summarize_author را تعریف کردیم، می‌توانیم متد summarize را روی نمونه‌هایی از struct به نام SocialPost فراخوانی کنیم، و پیاده‌سازی پیش‌فرض متد summarize، از پیاده‌سازی‌ای که برای summarize_author ارائه داده‌ایم استفاده خواهد کرد. از آن‌جا که ما summarize_author را پیاده‌سازی کرده‌ایم، trait به نام Summary رفتار متد summarize را بدون نیاز به نوشتن کد اضافی در اختیار ما قرار داده است. در اینجا نمونه‌ای از این وضعیت آمده است:

use aggregator::{self, SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}

این کد مقدار زیر را چاپ می‌کند: 1 new post: (Read more from @horse_ebooks...)

توجه داشته باشید که امکان فراخوانی پیاده‌سازی پیش‌فرض از یک پیاده‌سازی بازنویسی شده از همان متد وجود ندارد.

ویژگی‌ها (traits) به عنوان پارامترها

حالا که می‌دانید چگونه یک trait را تعریف و پیاده‌سازی کنید، می‌توانیم بررسی کنیم که چگونه از traitها برای تعریف توابعی استفاده کنیم که انواع مختلفی را به‌عنوان پارامتر بپذیرند. ما از trait Summary که روی نوع‌های NewsArticle و SocialPost در لیستینگ 10-13 پیاده‌سازی کردیم، استفاده خواهیم کرد تا تابعی به نام notify تعریف کنیم که متد summarize را روی پارامتر item خود فراخوانی می‌کند— پارامتری که از نوعی است که trait Summary را پیاده‌سازی کرده باشد. برای این کار، از سینتکس impl Trait استفاده می‌کنیم، به این صورت:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

به جای استفاده از یک نوع مشخص برای پارامتر item،
از کلیدواژه‌ی impl و نام trait استفاده می‌کنیم.
این پارامتر هر نوعی را می‌پذیرد که trait مشخص‌شده را پیاده‌سازی کرده باشد.
در بدنه‌ی تابع notify، می‌توانیم هر متدی از trait Summary را روی item فراخوانی کنیم،
مانند متد summarize.
می‌توانیم notify را فراخوانی کرده و هر نمونه‌ای از NewsArticle یا SocialPost را به آن پاس دهیم.
کدی که تابع را با نوعی دیگر، مانند String یا i32، فراخوانی کند کامپایل نخواهد شد،
زیرا این نوع‌ها trait Summary را پیاده‌سازی نکرده‌اند.

نحو محدودیت ویژگی (Trait Bound Syntax)

نحو impl Trait برای موارد ساده مناسب است اما در واقع یک شکل کوتاه‌شده از یک فرم طولانی‌تر به نام محدودیت ویژگی (trait bound) است؛ به این صورت:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

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

نحو impl Trait در موارد ساده مناسب است و کد را مختصرتر می‌کند، در حالی که نحو کامل‌تر محدودیت ویژگی می‌تواند پیچیدگی بیشتری را در موارد دیگر بیان کند. برای مثال، می‌توانیم دو پارامتر داشته باشیم که ویژگی Summary را پیاده‌سازی می‌کنند. انجام این کار با نحو impl Trait به این صورت است:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

استفاده از impl Trait مناسب است اگر بخواهیم این تابع اجازه دهد item1 و item2 انواع مختلفی داشته باشند (به شرطی که هر دو نوع ویژگی Summary را پیاده‌سازی کنند). اما اگر بخواهیم هر دو پارامتر یک نوع یکسان داشته باشند، باید از محدودیت ویژگی استفاده کنیم، مانند این:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

نوع جنریک T که به عنوان نوع پارامترهای item1 و item2 مشخص شده است، تابع را محدود می‌کند به این صورت که نوع مشخص مقدار پاس‌داده‌شده به عنوان آرگومان برای item1 و item2 باید یکسان باشد.

مشخص کردن محدودیت‌های ویژگی چندگانه با نحو +

ما همچنین می‌توانیم بیش از یک محدودیت ویژگی مشخص کنیم. فرض کنید می‌خواهیم notify از فرمت‌بندی نمایش (display formatting) و همچنین summarize روی item استفاده کند: در تعریف notify مشخص می‌کنیم که item باید هر دو ویژگی Display و Summary را پیاده‌سازی کند. این کار را می‌توانیم با نحو + انجام دهیم:

pub fn notify(item: &(impl Summary + Display)) {

نحو + همچنین با محدودیت ویژگی روی انواع جنریک معتبر است:

pub fn notify<T: Summary + Display>(item: &T) {

با مشخص کردن این دو محدودیت ویژگی، بدنه notify می‌تواند متد summarize را فراخوانی کند و از {} برای فرمت‌بندی item استفاده کند.

محدودیت‌های ویژگی واضح‌تر با بندهای where

استفاده از تعداد زیادی محدودیت ویژگی معایب خود را دارد. هر جنریک محدودیت‌های ویژگی مخصوص به خود را دارد، بنابراین توابعی با چندین پارامتر نوع جنریک می‌توانند شامل اطلاعات زیادی درباره محدودیت‌های ویژگی بین نام تابع و لیست پارامترهای آن باشند، که باعث سخت شدن خواندن امضای تابع می‌شود. به همین دلیل، Rust نحو جایگزینی برای مشخص کردن محدودیت‌های ویژگی در داخل یک بند where پس از امضای تابع ارائه می‌دهد. بنابراین، به جای نوشتن این:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

می‌توانیم از یک بند where به این صورت استفاده کنیم:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

امضای این تابع کمتر شلوغ است: نام تابع، لیست پارامترها، و نوع بازگشتی به هم نزدیک‌تر هستند، مشابه یک تابع بدون محدودیت‌های ویژگی زیاد.

بازگرداندن نوع‌هایی که ویژگی‌ها را پیاده‌سازی می‌کنند

ما همچنین می‌توانیم از نحو impl Trait در موقعیت بازگشتی استفاده کنیم تا مقداری از نوعی که یک ویژگی را پیاده‌سازی می‌کند بازگردانیم، همانطور که در اینجا نشان داده شده است:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

با استفاده از impl Summary برای نوع بازگشتی، مشخص می‌کنیم که تابع returns_summarizable مقداری را بازمی‌گرداند که trait Summary را پیاده‌سازی می‌کند، بدون اینکه نوع مشخص آن را نام ببریم. در این حالت، returns_summarizable یک SocialPost را بازمی‌گرداند، اما کدی که این تابع را فراخوانی می‌کند نیازی به دانستن این موضوع ندارد.

توانایی مشخص کردن یک نوع بازگشتی تنها بر اساس ویژگی‌ای که پیاده‌سازی می‌کند، به ویژه در زمینه closures و iterators مفید است، که در فصل ۱۳ به آن‌ها می‌پردازیم. closures و iterators نوع‌هایی ایجاد می‌کنند که تنها کامپایلر آن‌ها را می‌شناسد یا نوع‌هایی که بسیار طولانی هستند تا مشخص شوند. نحو impl Trait به شما اجازه می‌دهد که به طور مختصر مشخص کنید یک تابع نوعی که ویژگی Iterator را پیاده‌سازی می‌کند بازمی‌گرداند، بدون نیاز به نوشتن یک نوع بسیار طولانی.

با این حال، تنها زمانی می‌توانید از impl Trait استفاده کنید که قرار است فقط یک نوع خاص را بازگردانید. برای مثال، کدی که بسته به شرایط، یا یک NewsArticle یا یک SocialPost بازمی‌گرداند و نوع بازگشتی آن به صورت impl Summary مشخص شده، کار نخواهد کرد:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        SocialPost {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            repost: false,
        }
    }
}

بازگرداندن یکی از نوع‌های NewsArticle یا SocialPost مجاز نیست، به دلیل محدودیت‌هایی که در پیاده‌سازی نحوه عملکرد نحوی impl Trait در کامپایلر وجود دارد. نحوه نوشتن تابعی با چنین رفتاری را در بخش «استفاده از trait objectهایی که امکان داشتن مقادیر با نوع‌های مختلف را می‌دهند» از فصل ۱۸ بررسی خواهیم کرد.

استفاده از محدودیت‌های ویژگی برای پیاده‌سازی شرطی متدها

با استفاده از یک محدودیت ویژگی در یک بلوک impl که از پارامترهای نوع جنریک استفاده می‌کند، می‌توانیم متدها را به طور شرطی برای نوع‌هایی که ویژگی‌های مشخص‌شده را پیاده‌سازی می‌کنند پیاده‌سازی کنیم. برای مثال، نوع Pair<T> در لیست ۱۰-۱۵ همیشه تابع new را پیاده‌سازی می‌کند تا یک نمونه جدید از Pair<T> بازگرداند (به یاد داشته باشید از بخش “تعریف متدها” در فصل ۵ که Self یک نام مستعار برای نوع بلوک impl است که در اینجا Pair<T> است). اما در بلوک impl بعدی، Pair<T> فقط متد cmp_display را پیاده‌سازی می‌کند اگر نوع داخلی T ویژگی PartialOrd که مقایسه را ممکن می‌کند و ویژگی Display که چاپ را ممکن می‌کند، پیاده‌سازی کند.

Filename: src/lib.rs
use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}
Listing 10-15: پیاده‌سازی شرطی متدها روی یک نوع جنریک بر اساس محدودیت‌های ویژگی

ما همچنین می‌توانیم یک ویژگی را به طور شرطی برای هر نوعی که ویژگی دیگری را پیاده‌سازی می‌کند، پیاده‌سازی کنیم. پیاده‌سازی‌های یک ویژگی روی هر نوعی که محدودیت‌های ویژگی را برآورده می‌کند پیاده‌سازی‌های کلی (blanket implementations) نامیده می‌شوند و به طور گسترده در کتابخانه استاندارد Rust استفاده می‌شوند. برای مثال، کتابخانه استاندارد ویژگی ToString را روی هر نوعی که ویژگی Display را پیاده‌سازی می‌کند، پیاده‌سازی می‌کند. بلوک impl در کتابخانه استاندارد شبیه به این کد است:

impl<T: Display> ToString for T {
    // --snip--
}

از آنجا که کتابخانه استاندارد این پیاده‌سازی کلی را دارد، می‌توانیم متد to_string تعریف‌شده توسط ویژگی ToString را روی هر نوعی که ویژگی Display را پیاده‌سازی می‌کند، فراخوانی کنیم. برای مثال، می‌توانیم اعداد صحیح را به مقادیر String متناظرشان تبدیل کنیم مانند این:

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

پیاده‌سازی‌های کلی در مستندات ویژگی در بخش “Implementors” ظاهر می‌شوند.

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

اعتبارسنجی مراجع با طول عمرها

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

جزئیاتی که در بخش “رفرنس‌ها و قرض‌گیری” در فصل ۴ مطرح نکردیم، این است که هر رفرنس در Rust دارای یک lifetime است، یعنی حوزه‌ای که آن رفرنس در آن معتبر است. بیشتر مواقع، lifetimeها به‌صورت ضمنی و استنتاج‌شده هستند، درست مانند بیشتر مواقعی که نوع‌ها به‌صورت استنتاج‌شده هستند. ما تنها زمانی ملزم به افزودن annotation برای نوع‌ها هستیم که چند نوع ممکن وجود داشته باشد. به‌طور مشابه، زمانی که lifetimeهای رفرنس‌ها ممکن است به چند شکل مختلف به هم مرتبط باشند، باید رابطه‌ی آن‌ها را با پارامترهای lifetime generic مشخص کنیم. Rust این الزام را دارد تا اطمینان حاصل شود که رفرنس‌های واقعی که در زمان اجرا استفاده می‌شوند، قطعاً معتبر خواهند بود.

افزودن annotation برای lifetimeها حتی مفهومی نیست که اکثر زبان‌های برنامه‌نویسی دیگر داشته باشند، بنابراین این موضوع برای شما غریب به نظر خواهد رسید. اگرچه در این فصل به‌صورت کامل به lifetimeها نمی‌پردازیم، اما روش‌های رایجی که ممکن است با سینتکس lifetime مواجه شوید را بررسی خواهیم کرد تا با این مفهوم آشنا شوید.

جلوگیری از مراجع آویزان (Dangling References) با طول عمرها

هدف اصلی طول عمرها جلوگیری از مراجع آویزان است، که باعث می‌شوند یک برنامه به داده‌هایی غیر از داده‌هایی که قرار بوده مراجعه کند اشاره کند. برنامه‌ای را در نظر بگیرید که در لیست ۱۰-۱۶ نشان داده شده است و دارای یک محدوده خارجی و یک محدوده داخلی است.

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}
Listing 10-16: تلاشی برای استفاده از مرجعی که مقدار آن از محدوده خارج شده است

توجه: مثال‌های لیستینگ‌های 10-16، 10-17، و 10-23 متغیرهایی را بدون مقداردهی اولیه اعلام می‌کنند، بنابراین نام متغیر در حوزه‌ی بیرونی وجود دارد. در نگاه اول، این ممکن است به‌نظر برسد که با عدم وجود مقدار null در Rust مغایرت دارد. اما اگر سعی کنیم متغیری را قبل از مقداردهی استفاده کنیم، خطای زمان کامپایل دریافت خواهیم کرد که نشان می‌دهد Rust واقعاً اجازه‌ی وجود مقدار null را نمی‌دهد.

محدوده خارجی یک متغیر به نام r را بدون مقدار اولیه اعلام می‌کند، و محدوده داخلی یک متغیر به نام x را با مقدار اولیه 5 اعلام می‌کند. در داخل محدوده داخلی، تلاش می‌کنیم مقدار r را به عنوان یک مرجع به x تنظیم کنیم. سپس محدوده داخلی به پایان می‌رسد و سعی می‌کنیم مقدار موجود در r را چاپ کنیم. این کد کامپایل نخواهد شد زیرا مقداری که r به آن اشاره می‌کند قبل از اینکه سعی کنیم از آن استفاده کنیم از محدوده خارج شده است. پیام خطای زیر را دریافت می‌کنیم:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                  --- borrow later used here

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

پیام خطا می‌گوید که متغیر x “به اندازه کافی طول عمر ندارد.” دلیل این است که x وقتی محدوده داخلی در خط ۷ پایان می‌یابد، از محدوده خارج می‌شود. اما r همچنان برای محدوده خارجی معتبر است؛ زیرا محدوده آن بزرگ‌تر است، می‌گوییم که “طول عمر بیشتری دارد.” اگر Rust به این کد اجازه کار کردن می‌داد، r به حافظه‌ای اشاره می‌کرد که وقتی x از محدوده خارج شد آزاد شده است، و هر کاری که سعی می‌کردیم با r انجام دهیم به درستی کار نمی‌کرد. پس چگونه Rust تشخیص می‌دهد که این کد نامعتبر است؟ از یک بررسی‌کننده قرض (borrow checker) استفاده می‌کند.

بررسی‌کننده قرض (Borrow Checker)

کامپایلر Rust دارای یک بررسی‌کننده قرض است که محدوده‌ها را مقایسه می‌کند تا تعیین کند که آیا تمام قرض‌ها معتبر هستند یا خیر. لیست ۱۰-۱۷ همان کد لیست ۱۰-۱۶ را نشان می‌دهد اما با حاشیه‌نویسی‌هایی که طول عمر متغیرها را نشان می‌دهد.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
Listing 10-17: حاشیه‌نویسی طول عمرهای r و x که به ترتیب به نام‌های 'a و 'b نام‌گذاری شده‌اند

در اینجا، طول عمر r را با 'a و طول عمر x را با 'b حاشیه‌نویسی کرده‌ایم. همانطور که می‌بینید، بلوک داخلی 'b بسیار کوچک‌تر از بلوک طول عمر خارجی 'a است. در زمان کامپایل، Rust اندازه دو طول عمر را مقایسه می‌کند و می‌بیند که r دارای طول عمر 'a است اما به حافظه‌ای اشاره می‌کند که طول عمر آن 'b است. برنامه رد می‌شود زیرا 'b کوتاه‌تر از 'a است: موضوع مرجع به اندازه مرجع زنده نیست.

لیست ۱۰-۱۸ کد را اصلاح می‌کند تا یک مرجع آویزان نداشته باشد و بدون هیچ خطایی کامپایل شود.

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          // --+       |
}                         // ----------+
Listing 10-18: یک مرجع معتبر زیرا داده‌ها طول عمری طولانی‌تر از مرجع دارند

در اینجا، x دارای طول عمر 'b است که در این مورد بزرگ‌تر از 'a است. این بدان معناست که r می‌تواند به x اشاره کند زیرا Rust می‌داند که مرجع در r همیشه در حالی که x معتبر است، معتبر خواهد بود.

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

طول عمرهای جنریک در توابع

ما تابعی خواهیم نوشت که طولانی‌ترین قطعه رشته (string slice) را بازمی‌گرداند. این تابع دو قطعه رشته می‌گیرد و یک قطعه رشته بازمی‌گرداند. پس از پیاده‌سازی تابع longest، کد در لیست ۱۰-۱۹ باید The longest string is abcd را چاپ کند.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}
Listing 10-19: یک تابع main که تابع longest را فراخوانی می‌کند تا طولانی‌ترین قطعه رشته را پیدا کند

توجه کنید که ما می‌خواهیم تابع پارامترهایی از نوع string slice دریافت کند، که رفرنس هستند، نه رشته‌های کامل، زیرا نمی‌خواهیم تابع longest مالک پارامترهایش باشد. برای بحث بیشتر درباره‌ی دلیل انتخاب چنین پارامترهایی در لیستینگ 10-19، به بخش “String Slices به عنوان پارامتر” در فصل ۴ مراجعه کنید.

اگر سعی کنیم تابع longest را همانطور که در لیست ۱۰-۲۰ نشان داده شده است پیاده‌سازی کنیم، کامپایل نمی‌شود.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-20: پیاده‌سازی تابع longest که طولانی‌ترین قطعه رشته را بازمی‌گرداند اما هنوز کامپایل نمی‌شود

به جای آن، خطای زیر را دریافت می‌کنیم که درباره طول عمرها صحبت می‌کند:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

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

متن کمکی نشان می‌دهد که نوع بازگشتی نیاز به یک پارامتر طول عمر جنریک دارد زیرا Rust نمی‌تواند تشخیص دهد که مرجع بازگردانده‌شده به x اشاره می‌کند یا به y. در واقع، ما هم نمی‌دانیم، زیرا بلوک if در بدنه این تابع یک مرجع به x بازمی‌گرداند و بلوک else یک مرجع به y بازمی‌گرداند!

وقتی این تابع را تعریف می‌کنیم، مقادیر مشخصی که به این تابع پاس داده می‌شوند را نمی‌دانیم، بنابراین نمی‌دانیم که آیا حالت if یا حالت else اجرا خواهد شد. همچنین طول عمرهای مشخص مراجع پاس‌داده‌شده را نمی‌دانیم، بنابراین نمی‌توانیم به محدوده‌ها مانند لیست‌های ۱۰-۱۷ و ۱۰-۱۸ نگاه کنیم تا تعیین کنیم که مرجعی که بازمی‌گردانیم همیشه معتبر خواهد بود. بررسی‌کننده قرض هم نمی‌تواند این موضوع را تعیین کند زیرا نمی‌داند چگونه طول عمرهای x و y به طول عمر مقدار بازگشتی مرتبط هستند. برای رفع این خطا، پارامترهای طول عمر جنریک اضافه می‌کنیم که رابطه بین مراجع را تعریف می‌کنند تا بررسی‌کننده قرض بتواند تحلیل خود را انجام دهد.

نحو حاشیه‌نویسی طول عمر

حاشیه‌نویسی طول عمر طول عمر هیچ‌یک از مراجع را تغییر نمی‌دهد. بلکه، آن‌ها روابط طول عمرهای چندین مرجع را بدون تأثیر بر طول عمرها توصیف می‌کنند. همانطور که توابع می‌توانند هر نوعی را بپذیرند وقتی امضا یک پارامتر نوع جنریک را مشخص می‌کند، توابع می‌توانند مراجع با هر طول عمری را بپذیرند با مشخص کردن یک پارامتر طول عمر جنریک.

حاشیه‌نویسی طول عمر دارای نحو کمی غیرمعمول است: نام‌های پارامتر طول عمر باید با یک آپاستروف (') شروع شوند و معمولاً همه حروف کوچک و بسیار کوتاه هستند، مانند نوع‌های جنریک. بیشتر افراد از نام 'a برای اولین حاشیه‌نویسی طول عمر استفاده می‌کنند. ما حاشیه‌نویسی‌های پارامتر طول عمر را بعد از & یک مرجع قرار می‌دهیم و از یک فاصله برای جدا کردن حاشیه‌نویسی از نوع مرجع استفاده می‌کنیم.

در اینجا چند مثال آورده شده است: یک مرجع به یک i32 بدون پارامتر طول عمر، یک مرجع به یک i32 که یک پارامتر طول عمر به نام 'a دارد، و یک مرجع قابل تغییر به یک i32 که همچنین طول عمر 'a دارد:

&i32        // یک مرجع
&'a i32     // یک مرجع با طول عمر صریح
&'a mut i32 // یک مرجع قابل تغییر با طول عمر صریح

یک حاشیه‌نویسی طول عمر به تنهایی معنای زیادی ندارد زیرا حاشیه‌نویسی‌ها برای توضیح دادن به Rust هستند که پارامترهای طول عمر جنریک چندین مرجع چگونه به یکدیگر مرتبط هستند. بیایید بررسی کنیم که حاشیه‌نویسی‌های طول عمر چگونه در زمینه تابع longest به یکدیگر مرتبط هستند.

حاشیه‌نویسی طول عمر در امضاهای توابع

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

ما می‌خواهیم امضا محدودیت زیر را بیان کند: مرجع بازگردانده‌شده تا زمانی که هر دو پارامتر معتبر هستند معتبر خواهد بود. این رابطه بین طول عمرهای پارامترها و مقدار بازگشتی است. طول عمر را به نام 'a می‌نامیم و سپس آن را به هر مرجع اضافه می‌کنیم، همانطور که در لیست ۱۰-۲۱ نشان داده شده است.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-21: تعریف تابع longest که مشخص می‌کند تمام مراجع در امضا باید طول عمر یکسانی به نام 'a داشته باشند

این کد باید کامپایل شود و نتیجه مورد نظر ما را زمانی که با تابع main در لیست ۱۰-۱۹ استفاده می‌کنیم تولید کند.

امضای تابع اکنون به Rust می‌گوید که برای برخی طول عمر 'a، تابع دو پارامتر می‌گیرد که هر دو قطعه رشته‌هایی هستند که حداقل به مدت طول عمر 'a زندگی می‌کنند. امضای تابع همچنین به Rust می‌گوید که قطعه رشته‌ای که از تابع بازگردانده می‌شود حداقل به مدت طول عمر 'a زندگی می‌کند. در عمل، این بدان معناست که طول عمر مرجعی که توسط تابع longest بازگردانده می‌شود همان طول عمر کوچکتر مقادیر اشاره‌شده توسط آرگومان‌های تابع است. این روابط چیزی است که ما می‌خواهیم Rust هنگام تحلیل این کد از آن‌ها استفاده کند.

به یاد داشته باشید، وقتی پارامترهای طول عمر را در این امضای تابع مشخص می‌کنیم، طول عمر هیچ‌یک از مقادیر پاس‌داده‌شده یا بازگردانده‌شده را تغییر نمی‌دهیم. بلکه، مشخص می‌کنیم که بررسی‌کننده قرض باید هر مقداری را که به این محدودیت‌ها پایبند نیست رد کند. توجه داشته باشید که تابع longest نیازی به دانستن دقیق اینکه x و y چقدر زنده خواهند ماند ندارد، تنها اینکه برخی محدوده‌ها می‌توانند جایگزین 'a شوند که این امضا را برآورده کنند.

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

وقتی مراجع مشخصی را به longest پاس می‌دهیم، طول عمر مشخصی که برای 'a جایگزین می‌شود بخشی از محدوده x است که با محدوده y هم‌پوشانی دارد. به عبارت دیگر، طول عمر جنریک 'a طول عمر مشخصی را می‌گیرد که برابر با کوچک‌ترین طول عمرهای x و y است. از آنجا که مرجع بازگردانده‌شده را با همان پارامتر طول عمر 'a حاشیه‌نویسی کرده‌ایم، مرجع بازگردانده‌شده نیز برای مدت کوچک‌ترین طول عمرهای x و y معتبر خواهد بود.

بیایید ببینیم حاشیه‌نویسی طول عمرها چگونه تابع longest را محدود می‌کند با پاس دادن مراجع که طول عمرهای مشخص مختلفی دارند. لیست ۱۰-۲۲ یک مثال ساده است.

Filename: src/main.rs
fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {result}");
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-22: استفاده از تابع longest با مراجع به مقادیر String که طول عمرهای مشخص مختلفی دارند

در این مثال، string1 تا پایان محدوده خارجی معتبر است، string2 تا پایان محدوده داخلی معتبر است، و result به چیزی اشاره می‌کند که تا پایان محدوده داخلی معتبر است. این کد را اجرا کنید و خواهید دید که بررسی‌کننده قرض آن را تأیید می‌کند؛ کد کامپایل می‌شود و The longest string is long string is long را چاپ می‌کند.

حال، بیایید مثالی را امتحان کنیم که نشان دهد طول عمر مرجع در result باید کوچک‌ترین طول عمر دو آرگومان باشد. اعلام متغیر result را به بیرون از محدوده داخلی منتقل می‌کنیم، اما مقداردهی به متغیر result را درون محدوده با string2 نگه می‌داریم. سپس println! که از result استفاده می‌کند را به بیرون از محدوده داخلی، پس از پایان محدوده داخلی منتقل می‌کنیم. کد در لیست ۱۰-۲۳ کامپایل نمی‌شود.

Filename: src/main.rs
fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-23: تلاش برای استفاده از result پس از اینکه string2 از محدوده خارج شده است

وقتی تلاش می‌کنیم این کد را کامپایل کنیم، خطای زیر را دریافت می‌کنیم:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
 --> src/main.rs:6:44
  |
5 |         let string2 = String::from("xyz");
  |             ------- binding `string2` declared here
6 |         result = longest(string1.as_str(), string2.as_str());
  |                                            ^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `string2` dropped here while still borrowed
8 |     println!("The longest string is {result}");
  |                                     -------- borrow later used here

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

این خطا نشان می‌دهد که برای اینکه result برای دستور println! معتبر باشد، string2 باید تا پایان محدوده خارجی معتبر باشد. Rust این را می‌داند زیرا طول عمرهای پارامترهای تابع و مقادیر بازگشتی را با استفاده از همان پارامتر طول عمر 'a حاشیه‌نویسی کرده‌ایم.

به عنوان انسان، می‌توانیم به این کد نگاه کنیم و ببینیم که string1 طولانی‌تر از string2 است، و بنابراین، result یک مرجع به string1 خواهد داشت. زیرا string1 هنوز از محدوده خارج نشده است، یک مرجع به string1 برای دستور println! همچنان معتبر خواهد بود. با این حال، کامپایلر نمی‌تواند ببیند که این مرجع در این مورد معتبر است. ما به Rust گفته‌ایم که طول عمر مرجع بازگردانده‌شده توسط تابع longest همان طول عمر کوچک‌ترین مرجع‌های پاس‌داده‌شده است. بنابراین، بررسی‌کننده قرض کد در لیست ۱۰-۲۳ را به عنوان داشتن یک مرجع نامعتبر احتمالی رد می‌کند.

سعی کنید آزمایش‌های بیشتری طراحی کنید که مقادیر و طول عمر مراجع پاس‌داده‌شده به تابع longest و نحوه استفاده از مرجع بازگردانده‌شده را تغییر دهند. فرضیاتی درباره اینکه آیا آزمایش‌های شما بررسی‌کننده قرض را پاس می‌کنند یا نه ایجاد کنید؛ سپس بررسی کنید که آیا درست می‌گویید!

تفکر بر اساس طول عمرها

نحوه نیاز شما به مشخص کردن پارامترهای طول عمر به آنچه که تابع شما انجام می‌دهد بستگی دارد. برای مثال، اگر پیاده‌سازی تابع longest را تغییر دهیم تا همیشه اولین پارامتر را به جای طولانی‌ترین قطعه رشته بازگرداند، نیازی به مشخص کردن طول عمر برای پارامتر y نخواهیم داشت. کد زیر کامپایل می‌شود:

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

ما یک پارامتر طول عمر 'a برای پارامتر x و نوع بازگشتی مشخص کرده‌ایم، اما برای پارامتر y نه، زیرا طول عمر y هیچ رابطه‌ای با طول عمر x یا مقدار بازگشتی ندارد.

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

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

در اینجا، حتی اگر یک پارامتر طول عمر 'a برای نوع بازگشتی مشخص کرده باشیم، این پیاده‌سازی کامپایل نمی‌شود زیرا طول عمر مقدار بازگشتی به هیچ وجه به طول عمر پارامترها مرتبط نیست. پیام خطایی که دریافت می‌کنیم به این شکل است:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `result` is borrowed here

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

مشکل این است که result از محدوده خارج می‌شود و در پایان تابع longest پاک می‌شود. همچنین سعی می‌کنیم یک مرجع به result را از تابع بازگردانیم. هیچ راهی وجود ندارد که بتوانیم پارامترهای طول عمری مشخص کنیم که مرجع آویزان را تغییر دهد، و Rust به ما اجازه نمی‌دهد یک مرجع آویزان ایجاد کنیم. در این مورد، بهترین راه حل این است که یک نوع داده مالک (owned) به جای یک مرجع بازگردانیم تا تابع فراخوانی‌کننده مسئول پاک‌سازی مقدار باشد.

در نهایت، نحو طول عمرها درباره ارتباط دادن طول عمرهای پارامترها و مقادیر بازگشتی توابع است. وقتی این ارتباط برقرار شد، Rust اطلاعات کافی برای اجازه دادن به عملیات‌های ایمن از نظر حافظه و منع عملیات‌هایی که باعث ایجاد اشاره‌گر (Pointer)های آویزان یا نقض ایمنی حافظه می‌شوند، دارد.

حاشیه‌نویسی طول عمر در تعریف ساختارها

تا کنون، ساختارهایی که تعریف کرده‌ایم همه دارای نوع‌های مالک بوده‌اند. می‌توانیم ساختارهایی را تعریف کنیم که مراجع نگه می‌دارند، اما در این صورت باید برای هر مرجعی در تعریف ساختار یک حاشیه‌نویسی طول عمر اضافه کنیم. لیست ۱۰-۲۴ یک ساختار به نام ImportantExcerpt دارد که یک قطعه رشته نگه می‌دارد.

Filename: src/main.rs
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}
Listing 10-24: یک ساختار که یک مرجع نگه می‌دارد و نیاز به یک حاشیه‌نویسی طول عمر دارد

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

تابع main در اینجا یک نمونه از ساختار ImportantExcerpt ایجاد می‌کند که یک مرجع به اولین جمله از String که توسط متغیر novel نگه داشته می‌شود، نگه می‌دارد. داده‌های novel قبل از ایجاد نمونه ImportantExcerpt وجود دارند. علاوه بر این، novel تا بعد از خروج ImportantExcerpt از محدوده از محدوده خارج نمی‌شود، بنابراین مرجع در نمونه ImportantExcerpt معتبر است.

حذف طول عمر (Lifetime Elision)

آموختید که هر مرجع دارای یک طول عمر است و شما باید برای توابع یا ساختارهایی که از مراجع استفاده می‌کنند پارامترهای طول عمر مشخص کنید. با این حال، ما تابعی در لیست ۴-۹ داشتیم که دوباره در لیست ۱۰-۲۵ نشان داده شده است، که بدون حاشیه‌نویسی طول عمر کامپایل شد.

Filename: src/lib.rs
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}
Listing 10-25: یک تابع که در لیست ۴-۹ تعریف کردیم و بدون حاشیه‌نویسی طول عمر کامپایل شد، حتی با اینکه پارامتر و نوع بازگشتی مراجع هستند

دلیل اینکه این تابع بدون حاشیه‌نویسی طول عمر کامپایل می‌شود تاریخی است: در نسخه‌های اولیه (قبل از 1.0) از Rust، این کد کامپایل نمی‌شد زیرا هر مرجع نیاز به یک طول عمر صریح داشت. در آن زمان، امضای تابع به این صورت نوشته می‌شد:

fn first_word<'a>(s: &'a str) -> &'a str {

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

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

الگوهایی که در تحلیل مراجع Rust برنامه‌ریزی شده‌اند قوانین حذف طول عمر (lifetime elision rules) نامیده می‌شوند. این‌ها قوانینی نیستند که برنامه‌نویسان باید رعایت کنند؛ بلکه مجموعه‌ای از موارد خاص هستند که کامپایلر آن‌ها را در نظر می‌گیرد و اگر کد شما با این موارد مطابقت داشته باشد، نیازی به نوشتن طول عمرها به صورت صریح نخواهید داشت.

قواعد elision نمی‌توانند inference کامل انجام دهند. اگر پس از اعمال این قواعد همچنان ابهامی درباره‌ی lifetime رفرنس‌ها وجود داشته باشد، کامپایلر حدس نمی‌زند که lifetime باقی‌مانده‌ها باید چه باشد. به جای حدس زدن، کامپایلر خطایی نمایش می‌دهد که می‌توانید با اضافه کردن annotationهای lifetime آن را رفع کنید.

طول عمرهای روی پارامترهای تابع یا متد طول عمر ورودی (input lifetimes) نامیده می‌شوند، و طول عمرهای روی مقادیر بازگشتی طول عمر خروجی (output lifetimes) نامیده می‌شوند.

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

  • قانون اول: کامپایلر یک پارامتر طول عمر به هر پارامتر که یک مرجع است اختصاص می‌دهد. به عبارت دیگر، یک تابع با یک پارامتر یک پارامتر طول عمر می‌گیرد: fn foo<'a>(x: &'a i32)؛ یک تابع با دو پارامتر دو پارامتر طول عمر جداگانه می‌گیرد: fn foo<'a, 'b>(x: &'a i32, y: &'b i32)؛ و به همین ترتیب.
  • قانون دوم: اگر دقیقاً یک پارامتر طول عمر ورودی وجود داشته باشد، آن طول عمر به تمام پارامترهای طول عمر خروجی اختصاص داده می‌شود: fn foo<'a>(x: &'a i32) -> &'a i32.
  • قانون سوم: اگر چندین پارامتر طول عمر ورودی وجود داشته باشد، اما یکی از آن‌ها &self یا &mut self باشد زیرا این یک متد است، طول عمر self به تمام پارامترهای طول عمر خروجی اختصاص داده می‌شود. این قانون سوم خواندن و نوشتن متدها را بسیار آسان‌تر می‌کند زیرا نمادهای کمتری لازم است.

بیایید وانمود کنیم که ما کامپایلر هستیم. این قوانین را برای تشخیص طول عمر مراجع در امضای تابع first_word در لیست ۱۰-۲۵ اعمال می‌کنیم. امضا بدون هیچ طول عمری که با مراجع مرتبط باشد شروع می‌شود:

fn first_word(s: &str) -> &str {

سپس کامپایلر قانون اول را اعمال می‌کند که مشخص می‌کند هر پارامتر طول عمر خاص خود را دریافت می‌کند. ما آن را طبق معمول 'a می‌نامیم، بنابراین امضا اکنون به این صورت است:

fn first_word<'a>(s: &'a str) -> &str {

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

fn first_word<'a>(s: &'a str) -> &'a str {

حالا تمام مراجع در این امضای تابع طول عمر دارند و کامپایلر می‌تواند تحلیل خود را بدون نیاز به برنامه‌نویس برای حاشیه‌نویسی طول عمرها در این امضای تابع ادامه دهد.

بیایید به یک مثال دیگر نگاه کنیم، این بار با استفاده از تابع longest که در ابتدا هیچ پارامتر طول عمری نداشت، همانطور که در لیست ۱۰-۲۰ کار خود را با آن شروع کردیم:

fn longest(x: &str, y: &str) -> &str {

بیایید قانون اول را اعمال کنیم: هر پارامتر طول عمر خاص خود را دریافت می‌کند. این بار دو پارامتر داریم، بنابراین دو طول عمر داریم:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

می‌بینید که قانون دوم اعمال نمی‌شود زیرا بیش از یک طول عمر ورودی وجود دارد. قانون سوم نیز اعمال نمی‌شود زیرا longest یک تابع است و نه یک متد، بنابراین هیچ یک از پارامترها self نیستند. پس از عبور از تمام سه قانون، هنوز طول عمر نوع بازگشتی را تعیین نکرده‌ایم. به همین دلیل است که هنگام تلاش برای کامپایل کد در لیست ۱۰-۲۰ خطا گرفتیم: کامپایلر قوانین حذف طول عمر را مرور کرد اما همچنان نتوانست تمام طول عمرهای مراجع در امضا را تعیین کند.

از آنجا که قانون سوم واقعاً فقط در امضاهای متد اعمال می‌شود، به بررسی طول عمرها در آن زمینه می‌پردازیم تا ببینیم چرا قانون سوم باعث می‌شود که اغلب نیازی به حاشیه‌نویسی طول عمر در امضاهای متد نداشته باشیم.

حاشیه‌نویسی طول عمر در تعریف متدها

وقتی متدهایی را روی یک struct دارای lifetime پیاده‌سازی می‌کنیم،
از همان سینتکسی استفاده می‌کنیم که برای پارامترهای نوع generic به کار می‌رود،
همان‌طور که در لیستینگ 10-11 نشان داده شده است.
مکان اعلام و استفاده از پارامترهای lifetime بستگی به این دارد که آیا این lifetimeها
مرتبط با فیلدهای struct هستند یا پارامترها و مقادیر بازگشتی متد.

نام‌های طول عمر برای فیلدهای ساختار همیشه باید بعد از کلمه کلیدی impl اعلام شوند و سپس بعد از نام ساختار استفاده شوند، زیرا این طول عمرها بخشی از نوع ساختار هستند.

در امضاهای متد در داخل بلوک impl، مراجع ممکن است به طول عمر مراجع در فیلدهای ساختار مرتبط باشند، یا ممکن است مستقل باشند. علاوه بر این، قوانین حذف طول عمر اغلب باعث می‌شوند که حاشیه‌نویسی طول عمر در امضاهای متد ضروری نباشد. بیایید به چند مثال با استفاده از ساختار ImportantExcerpt که در لیست ۱۰-۲۴ تعریف کردیم، نگاه کنیم.

ابتدا از متدی به نام level استفاده می‌کنیم که تنها پارامتر آن مرجعی به self است و مقدار بازگشتی آن یک i32 است که به چیزی اشاره نمی‌کند:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

اعلام پارامتر طول عمر بعد از impl و استفاده از آن بعد از نام نوع الزامی است، اما ما نیازی به حاشیه‌نویسی طول عمر مرجع به self نداریم زیرا قانون اول حذف اعمال می‌شود.

در اینجا مثالی است که قانون سوم حذف طول عمر اعمال می‌شود:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

دو طول عمر ورودی وجود دارد، بنابراین Rust قانون اول حذف طول عمر را اعمال می‌کند و طول عمرهای جداگانه‌ای به &self و announcement می‌دهد. سپس، چون یکی از پارامترها &self است، نوع بازگشتی طول عمر &self را دریافت می‌کند، و تمام طول عمرها در نظر گرفته شده‌اند.

طول عمر استاتیک

یک طول عمر خاص که باید درباره آن صحبت کنیم 'static است، که نشان می‌دهد مرجع مورد نظر می‌تواند برای کل مدت اجرای برنامه زنده بماند. تمام رشته‌های لیتری دارای طول عمر 'static هستند، که می‌توانیم آن را به این صورت حاشیه‌نویسی کنیم:

#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

متن این رشته مستقیماً در باینری برنامه ذخیره می‌شود، که همیشه در دسترس است. بنابراین، طول عمر تمام رشته‌های لیتری 'static است.

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

پارامترهای نوع جنریک، محدودیت ویژگی، و طول عمرها با هم

بیایید به طور مختصر به نحو مشخص کردن پارامترهای نوع جنریک، محدودیت ویژگی، و طول عمرها در یک تابع نگاه کنیم!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {result}");
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {ann}");
    if x.len() > y.len() { x } else { y }
}

این تابع longest از لیست ۱۰-۲۱ است که طولانی‌ترین قطعه رشته را بازمی‌گرداند. اما اکنون یک پارامتر اضافی به نام ann دارد که از نوع جنریک T است، که می‌تواند با هر نوعی که ویژگی Display را پیاده‌سازی می‌کند، پر شود، همانطور که توسط بند where مشخص شده است. این پارامتر اضافی با استفاده از {} چاپ خواهد شد، به همین دلیل محدودیت ویژگی Display ضروری است. از آنجا که طول عمرها نوعی جنریک هستند، اعلام طول عمر 'a و پارامتر نوع جنریک T در همان لیست داخل پرانتزهای زاویه‌ای بعد از نام تابع قرار می‌گیرند.

خلاصه

در این فصل مطالب زیادی را پوشش دادیم! حالا که با پارامترهای نوع جنریک، ویژگی‌ها و محدودیت‌های ویژگی، و پارامترهای طول عمر جنریک آشنا شدید، آماده هستید تا کدی بدون تکرار بنویسید که در بسیاری از موقعیت‌های مختلف کار کند. پارامترهای نوع جنریک به شما اجازه می‌دهند که کد را روی انواع مختلف اعمال کنید. ویژگی‌ها و محدودیت‌های ویژگی اطمینان حاصل می‌کنند که حتی با اینکه نوع‌ها جنریک هستند، رفتار مورد نیاز کد را خواهند داشت. شما یاد گرفتید چگونه از حاشیه‌نویسی طول عمر استفاده کنید تا اطمینان حاصل شود که این کد انعطاف‌پذیر هیچ مرجع آویزانی نخواهد داشت. و تمام این تحلیل‌ها در زمان کامپایل انجام می‌شود، که بر عملکرد زمان اجرا تأثیری ندارد!

باور کنید یا نه، مطالب بیشتری برای یادگیری در مورد موضوعاتی که در این فصل بحث شد وجود دارد: فصل ۱۸ به اشیاء ویژگی (trait objects) می‌پردازد، که راه دیگری برای استفاده از ویژگی‌ها است. همچنین سناریوهای پیچیده‌تری وجود دارد که شامل حاشیه‌نویسی طول عمر هستند و فقط در سناریوهای بسیار پیشرفته به آن‌ها نیاز خواهید داشت. برای این موارد، باید مرجع Rust را مطالعه کنید. اما بعد از این، یاد خواهید گرفت که چگونه تست‌هایی در Rust بنویسید تا مطمئن شوید کد شما همانطور که باید کار می‌کند.

نوشتن تست‌های خودکار

در مقاله‌ی خود در سال ۱۹۷۲ با عنوان «برنامه‌نویس فروتن»، اَدسگر و. دایکسترا بیان کرد که «تست برنامه می‌تواند راهی بسیار مؤثر برای نشان دادن وجود باگ‌ها باشد، اما برای اثبات عدم وجود آن‌ها کاملاً ناکافی است.» این بدان معنا نیست که نباید تا حد امکان تلاش کنیم برنامه را تست کنیم!

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

فرض کنید یک تابع به نام add_two می‌نویسیم که ۲ را به هر عددی که به آن پاس داده شود اضافه می‌کند. امضای این تابع یک عدد صحیح به عنوان پارامتر می‌پذیرد و یک عدد صحیح به عنوان نتیجه بازمی‌گرداند. هنگامی که این تابع را پیاده‌سازی و کامپایل می‌کنیم، Rust تمام بررسی‌های نوع و قرض‌گیری را که تا کنون آموخته‌اید انجام می‌دهد تا اطمینان حاصل شود که، به عنوان مثال، ما یک مقدار String یا یک مرجع نامعتبر را به این تابع پاس نمی‌دهیم. اما Rust نمی‌تواند بررسی کند که این تابع دقیقاً همان کاری را که ما قصد داریم انجام دهد، که بازگرداندن پارامتر به علاوه ۲ است نه مثلاً پارامتر به علاوه ۱۰ یا پارامتر منهای ۵۰! اینجا جایی است که تست‌ها وارد می‌شوند.

ما می‌توانیم تست‌هایی بنویسیم که، به عنوان مثال، تأیید می‌کنند که وقتی 3 را به تابع add_two پاس می‌دهیم، مقدار بازگردانده شده 5 است. می‌توانیم این تست‌ها را هر زمان که تغییری در کد خود ایجاد می‌کنیم اجرا کنیم تا مطمئن شویم که هر رفتار درستی که وجود داشته تغییر نکرده است.

تست‌نویسی یک مهارت پیچیده است: اگرچه نمی‌توانیم در یک فصل تمام جزئیات مربوط به نحوه نوشتن تست‌های خوب را پوشش دهیم، در این فصل درباره مکانیک تسهیلات تست Rust بحث خواهیم کرد. درباره حاشیه‌نویسی‌ها و ماکروهایی که هنگام نوشتن تست‌ها در اختیار دارید صحبت خواهیم کرد، رفتار پیش‌فرض و گزینه‌های ارائه‌شده برای اجرای تست‌ها را بررسی خواهیم کرد، و نحوه سازماندهی تست‌ها به تست‌های واحد و تست‌های یکپارچه را یاد خواهیم گرفت.

چگونه تست بنویسیم

تست‌ها توابعی در Rust هستند که بررسی می‌کنند کد غیرتستی به شکل مورد انتظار کار می‌کند. بدنه توابع تست معمولاً این سه عمل را انجام می‌دهد:

  • تنظیم هر داده یا وضعیت مورد نیاز.
  • اجرای کدی که می‌خواهید تست کنید.
  • تأیید اینکه نتایج همان چیزی است که انتظار دارید.

بیایید به ویژگی‌هایی که Rust به طور خاص برای نوشتن تست‌هایی که این اقدامات را انجام می‌دهند فراهم کرده است نگاهی بیندازیم. این ویژگی‌ها شامل ویژگی test، چند ماکرو و ویژگی should_panic هستند.

آناتومی یک تابع تست

در ساده‌ترین حالت، یک تست در Rust یک تابع است که با ویژگی test حاشیه‌نویسی شده است. ویژگی‌ها متاداده‌هایی درباره بخش‌های کد Rust هستند؛ یک مثال ویژگی derive است که در فصل ۵ با ساختارها استفاده کردیم. برای تغییر یک تابع به یک تابع تست، #[test] را به خط قبل از fn اضافه کنید. وقتی تست‌های خود را با فرمان cargo test اجرا می‌کنید، Rust یک باینری تست رانر ایجاد می‌کند که توابع حاشیه‌نویسی‌شده را اجرا می‌کند و گزارش می‌دهد که آیا هر تابع تست موفق یا ناموفق بوده است.

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

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

بیایید یک پروژه کتابخانه‌ای جدید به نام adder ایجاد کنیم که دو عدد را با هم جمع کند:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

محتویات فایل src/lib.rs در کتابخانه adder شما باید شبیه به لیست ۱۱-۱ باشد.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-1: کدی که به طور خودکار توسط cargo new تولید می‌شود

این فایل با یک تابع نمونه به نام add شروع می‌شود تا چیزی برای تست کردن داشته باشیم.

فعلاً روی تابع it_works تمرکز می‌کنیم. به حاشیه‌نویسی #[test] توجه کنید: این ویژگی نشان می‌دهد که این یک تابع تست است، بنابراین تست رانر می‌داند که این تابع را به عنوان یک تست در نظر بگیرد. ممکن است توابع غیرتستی نیز در ماژول tests داشته باشیم که به تنظیم سناریوهای مشترک یا انجام عملیات‌های مشترک کمک می‌کنند، بنابراین همیشه باید مشخص کنیم کدام توابع تست هستند.

بدنه تابع نمونه از ماکرو assert_eq! استفاده می‌کند تا اطمینان حاصل کند که result، که حاوی نتیجه فراخوانی add با مقادیر ۲ و ۲ است، برابر با ۴ باشد. این اطمینان به عنوان یک مثال از فرمت یک تست معمولی عمل می‌کند. بیایید آن را اجرا کنیم تا ببینیم این تست پاس می‌شود.

فرمان cargo test تمام تست‌های پروژه ما را اجرا می‌کند، همانطور که در لیست ۱۱-۲ نشان داده شده است.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Listing 11-2: خروجی اجرای تستی که به طور خودکار تولید شده است

Cargo تست را کامپایل و اجرا کرد. خط running 1 test را می‌بینیم. خط بعدی نام تابع تست تولیدشده را نشان می‌دهد، که tests::it_works نام دارد، و نتیجه اجرای آن تست ok است. خلاصه کلی test result: ok. نشان می‌دهد که تمام تست‌ها پاس شده‌اند، و بخشی که 1 passed; 0 failed را می‌خواند تعداد تست‌هایی که پاس شده‌اند یا ناموفق بوده‌اند را نشان می‌دهد.

امکان علامت‌گذاری یک تست به‌عنوان ignored وجود دارد تا در یک اجرای خاص اجرا نشود؛
ما این موضوع را در بخش “نادیده گرفتن برخی تست‌ها مگر در صورت درخواست خاص”
در ادامه‌ی این فصل بررسی خواهیم کرد.
چون در اینجا این کار را انجام نداده‌ایم، خلاصه نشان‌دهنده‌ی 0 ignored است.
همچنین می‌توانیم آرگومانی به دستور cargo test بدهیم تا فقط تست‌هایی اجرا شوند که نامشان با رشته‌ای مطابقت دارد؛
این کار filtering نامیده می‌شود و در بخش “اجرای زیرمجموعه‌ای از تست‌ها بر اساس نام” بررسی خواهد شد.
در اینجا تست‌ها فیلتر نشده‌اند، بنابراین انتهای خلاصه 0 filtered out را نشان می‌دهد.

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

قسمت بعدی خروجی تست که از Doc-tests adder شروع می‌شود، مربوط به نتایج تست‌های مستندات است. فعلاً تست مستنداتی نداریم، اما Rust می‌تواند هر نمونه کدی که در مستندات API ما آمده است را کامپایل کند. این ویژگی به هماهنگ نگه‌داشتن مستندات و کد شما کمک می‌کند! در بخش “نظرات مستندات به‌عنوان تست” در فصل ۱۴، نحوه‌ی نوشتن تست‌های مستندات را بررسی خواهیم کرد. فعلاً خروجی Doc-tests را نادیده می‌گیریم.

بیایید تست را مطابق نیازهای خود شخصی‌سازی کنیم. ابتدا نام تابع it_works را به یک نام دیگر، مانند exploration تغییر دهید، به این صورت:

Filename: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

سپس دوباره cargo test را اجرا کنید. خروجی اکنون به جای it_works نام exploration را نشان می‌دهد:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

حالا یک تست دیگر اضافه می‌کنیم، اما این بار تستی می‌نویسیم که شکست بخورد! تست‌ها زمانی شکست می‌خورند که چیزی در تابع تست باعث ایجاد panic شود. هر تست در یک نخ (thread) جدید اجرا می‌شود، و وقتی نخ اصلی می‌بیند که یک نخ تست متوقف شده است، تست به عنوان شکست‌خورده علامت‌گذاری می‌شود. در فصل ۹، درباره اینکه ساده‌ترین راه برای panic کردن فراخوانی ماکروی panic! است صحبت کردیم. تابع جدیدی به نام another وارد کنید تا فایل src/lib.rs شما شبیه به لیست ۱۱-۳ شود.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: اضافه کردن یک تست دوم که به دلیل فراخوانی ماکروی panic! شکست می‌خورد

دوباره تست‌ها را با استفاده از cargo test اجرا کنید. خروجی باید شبیه به لیست ۱۱-۴ باشد، که نشان می‌دهد تست exploration موفق شده است و another شکست خورده است.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`
Listing 11-4: نتایج تست زمانی که یک تست موفق می‌شود و یک تست شکست می‌خورد

به جای ok، در خط test tests::another عبارت FAILED نمایش داده می‌شود. دو بخش جدید بین نتایج فردی و خلاصه ظاهر می‌شوند: اولی دلیل دقیق هر شکست تست را نمایش می‌دهد. در این مورد، جزئیات نشان می‌دهد که tests::another شکست خورده زیرا در خط ۱۷ فایل src/lib.rs با پیام Make this test fail دچار panic شده است. بخش بعدی فقط نام تمام تست‌های شکست‌خورده را فهرست می‌کند، که وقتی تعداد تست‌ها زیاد و خروجی شکست تست‌ها مفصل است، مفید است. می‌توانیم از نام تست شکست‌خورده استفاده کنیم تا فقط آن تست را اجرا کنیم و راحت‌تر آن را اشکال‌زدایی کنیم؛ در بخش “کنترل نحوه‌ی اجرای تست‌ها” بیشتر درباره‌ی روش‌های اجرای تست صحبت خواهیم کرد.

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

بررسی نتایج با ماکروی assert!

ماکروی assert! که توسط کتابخانه استاندارد ارائه شده است، زمانی مفید است که بخواهید اطمینان حاصل کنید که یک شرط در یک تست به true ارزیابی می‌شود. ماکروی assert! یک آرگومان می‌گیرد که به یک مقدار بولی ارزیابی می‌شود. اگر مقدار true باشد، هیچ اتفاقی نمی‌افتد و تست پاس می‌شود. اگر مقدار false باشد، ماکروی assert! فراخوانی panic! را انجام می‌دهد تا باعث شکست تست شود. استفاده از ماکروی assert! به ما کمک می‌کند تا بررسی کنیم که کد ما همانطور که قصد داریم عمل می‌کند.

در فصل ۵، لیست ۵-۱۵، از یک ساختار Rectangle و یک متد can_hold استفاده کردیم، که در لیست ۱۱-۵ دوباره تکرار شده است. این کد را در فایل src/lib.rs قرار دهید، سپس با استفاده از ماکروی assert! چند تست برای آن بنویسید.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}
Listing 11-5: ساختار Rectangle و متد can_hold آن از فصل ۵

متد can_hold یک مقدار بولی بازمی‌گرداند، که به این معنی است که یک مورد استفاده عالی برای ماکروی assert! است. در لیست ۱۱-۶، ما تستی می‌نویسیم که متد can_hold را با ایجاد یک نمونه از Rectangle که عرض ۸ و ارتفاع ۷ دارد آزمایش می‌کند و تأیید می‌کند که می‌تواند نمونه دیگری از Rectangle که عرض ۵ و ارتفاع ۱ دارد را در خود جای دهد.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: تستی برای can_hold که بررسی می‌کند آیا یک مستطیل بزرگ‌تر می‌تواند واقعاً یک مستطیل کوچک‌تر را در خود جای دهد

به خط use super::*; در داخل ماژول tests توجه کنید. ماژول tests یک ماژول معمولی است که از قوانین دیدپذیری معمولی که در فصل ۷ در بخش “مسیرها برای اشاره به یک مورد در درخت ماژول” پوشش دادیم پیروی می‌کند. از آنجا که ماژول tests یک ماژول داخلی است، باید کدی که در ماژول خارجی است را به دامنه ماژول داخلی بیاوریم. در اینجا از یک glob استفاده می‌کنیم، بنابراین هر چیزی که در ماژول خارجی تعریف کنیم برای این ماژول tests در دسترس است.

تست خود را larger_can_hold_smaller نام‌گذاری کرده‌ایم، و دو نمونه Rectangle که نیاز داشتیم را ایجاد کرده‌ایم. سپس ماکروی assert! را فراخوانی کردیم و نتیجه فراخوانی larger.can_hold(&smaller) را به آن پاس دادیم. این عبارت قرار است true بازگرداند، بنابراین تست ما باید پاس شود. بیایید ببینیم چه اتفاقی می‌افتد!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

پاس شد! حالا یک تست دیگر اضافه کنیم، این بار تأیید می‌کنیم که یک مستطیل کوچک‌تر نمی‌تواند یک مستطیل بزرگ‌تر را در خود جای دهد:

Filename: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

از آنجا که نتیجه صحیح تابع can_hold در این مورد false است، باید آن نتیجه را قبل از پاس دادن به ماکروی assert! منفی کنیم. به این ترتیب، تست ما زمانی پاس می‌شود که can_hold مقدار false را بازگرداند:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

دو تست که پاس می‌شوند! حالا بیایید ببینیم وقتی باگی به کد خود وارد می‌کنیم چه اتفاقی برای نتایج تست ما می‌افتد. پیاده‌سازی متد can_hold را با جایگزینی علامت بزرگتر (>) با علامت کوچکتر (<) هنگام مقایسه عرض‌ها تغییر می‌دهیم:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

اجرای تست‌ها اکنون خروجی زیر را تولید می‌کند:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

تست‌های ما باگ را پیدا کردند! از آنجا که larger.width مقدار 8 و smaller.width مقدار 5 دارد، مقایسه عرض‌ها در can_hold اکنون false بازمی‌گرداند: ۸ کمتر از ۵ نیست.

تست برابری با ماکروهای assert_eq! و assert_ne!

یک روش معمول برای بررسی عملکرد، تست برابری بین نتیجه کد تحت تست و مقدار مورد انتظار است. می‌توانید این کار را با استفاده از ماکروی assert! و پاس دادن یک عبارت با استفاده از عملگر == انجام دهید. با این حال، این یک تست بسیار معمول است که کتابخانه استاندارد یک جفت ماکرو—assert_eq! و assert_ne!—برای انجام این تست به صورت راحت‌تر فراهم کرده است. این ماکروها به ترتیب دو آرگومان را برای برابری یا نابرابری مقایسه می‌کنند. اگر ادعا شکست بخورد، این ماکروها دو مقدار را نیز چاپ می‌کنند، که مشاهده دلیل شکست تست را آسان‌تر می‌کند. در مقابل، ماکروی assert! فقط نشان می‌دهد که یک مقدار false برای عبارت == دریافت کرده است، بدون چاپ مقادیری که منجر به مقدار false شده‌اند.

در لیست ۱۱-۷، تابعی به نام add_two می‌نویسیم که ۲ را به پارامتر خود اضافه می‌کند، سپس این تابع را با استفاده از ماکروی assert_eq! تست می‌کنیم.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}
Listing 11-7: تست تابع add_two با استفاده از ماکروی assert_eq!

بیایید بررسی کنیم که آیا پاس می‌شود!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ما متغیری به نام result ایجاد می‌کنیم که نتیجه‌ی فراخوانی add_two(2) را نگه می‌دارد. سپس result و عدد 4 را به‌عنوان آرگومان به ماکروی assert_eq! می‌دهیم. خط خروجی این تست به شکل test tests::it_adds_two ... ok است، و متن ok نشان می‌دهد که تست ما با موفقیت گذشت!

pub fn add_two(a: u64) -> u64 {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

تست‌ها را دوباره اجرا کنید:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

تست ما باگ را پیدا کرد! تست tests::it_adds_two شکست خورد، و پیام نشان می‌دهد که ادعای ناموفق left == right بوده است و مقدارهای left و right چیستند. این پیام به ما کمک می‌کند تا فرآیند اشکال‌زدایی را شروع کنیم: آرگومان left که نتیجه‌ی فراخوانی add_two(2) بود، مقدار 5 داشت، اما آرگومان right مقدار 4 بود. می‌توانید تصور کنید که این موضوع زمانی که تعداد زیادی تست اجرا می‌شود، چقدر مفید است.

توجه کنید که در برخی زبان‌ها و فریم‌ورک‌های تست، پارامترهای تابع ادعای برابری (assertion) به نام‌های expected و actual شناخته می‌شوند و ترتیب آرگومان‌ها اهمیت دارد. اما در Rust، این پارامترها left و right نامیده می‌شوند و ترتیب مقدار مورد انتظار و مقدار تولید شده توسط کد اهمیت ندارد. می‌توانیم ادعای این تست را به صورت assert_eq!(4, result) نیز بنویسیم، که نتیجه‌ی همان پیام شکست با عنوان assertion `left == right` failed را خواهد داشت.

ماکروی assert_ne! زمانی پاس می‌شود که دو مقداری که به آن می‌دهیم برابر نباشند و شکست می‌خورد اگر برابر باشند. این ماکرو برای مواردی مفید است که مطمئن نیستیم یک مقدار چه خواهد بود، اما می‌دانیم که مقدار به طور قطع چه نباید باشد. برای مثال، اگر تابعی را تست می‌کنیم که تضمین شده است ورودی خود را به نوعی تغییر دهد، اما نحوه تغییر ورودی به روز هفته‌ای که تست‌های خود را اجرا می‌کنیم بستگی دارد، بهترین چیزی که می‌توانیم تأیید کنیم این است که خروجی تابع برابر با ورودی نیست.

در پس‌زمینه، ماکروهای assert_eq! و assert_ne! به ترتیب از عملگرهای == و != استفاده می‌کنند. وقتی ادعا شکست می‌خورد، این ماکروها آرگومان‌های خود را با استفاده از قالب‌بندی دیباگ چاپ می‌کنند، که به این معنی است که مقادیر مقایسه‌شده باید ویژگی‌های PartialEq و Debug را پیاده‌سازی کنند. تمام نوع‌های اولیه و بیشتر نوع‌های کتابخانه استاندارد این ویژگی‌ها را پیاده‌سازی می‌کنند. برای ساختارها و انوم‌هایی که خودتان تعریف می‌کنید، باید PartialEq را برای تأیید برابری این نوع‌ها پیاده‌سازی کنید. همچنین باید Debug را برای چاپ مقادیر زمانی که ادعا شکست می‌خورد پیاده‌سازی کنید. از آنجا که هر دو ویژگی قابل اشتقاق هستند، همانطور که در لیست ۵-۱۲ فصل ۵ اشاره شد، این معمولاً به سادگی افزودن حاشیه‌نویسی #[derive(PartialEq, Debug)] به تعریف ساختار یا انوم شما است. برای جزئیات بیشتر در مورد این ویژگی‌ها و سایر ویژگی‌های قابل اشتقاق، به ضمیمه ج، “ویژگی‌های قابل اشتقاق” مراجعه کنید.

افزودن پیام‌های شکست سفارشی

شما همچنین می‌توانید یک پیام سفارشی را به‌عنوان آرگومان‌های اختیاری به ماکروهای assert!، assert_eq! و assert_ne! اضافه کنید تا همراه با پیام شکست چاپ شود. هر آرگومانی که پس از آرگومان‌های ضروری وارد شود، به ماکرو format! (که در بخش “ادغام با عملگر + یا ماکرو format! در فصل ۸ توضیح داده شده) ارسال می‌شود، پس می‌توانید یک رشته‌ی قالب (format string) حاوی جای‌نگهدارهای {} و مقادیری برای جای‌گذاری در آن‌ها ارسال کنید. پیام‌های سفارشی برای مستندسازی معنای یک assertion مفید هستند؛ وقتی تست شکست می‌خورد، درک بهتری از مشکل کد خواهید داشت.

برای مثال، فرض کنید تابعی داریم که افراد را با نامشان خوشامد می‌گوید و می‌خواهیم تست کنیم که نامی که به تابع پاس می‌دهیم در خروجی ظاهر می‌شود:

Filename: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

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

حالا بیایید یک باگ به این کد وارد کنیم با تغییر greeting به‌طوری که name را شامل نشود تا ببینیم پیام شکست تست پیش‌فرض چگونه است:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

اجرای این تست خروجی زیر را تولید می‌کند:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

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

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

حالا وقتی تست را اجرا می‌کنیم، یک پیام خطای اطلاع‌رسان‌تر دریافت خواهیم کرد:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

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

بررسی پانیک با should_panic

علاوه بر بررسی مقادیر بازگشتی، مهم است که بررسی کنیم کد ما شرایط خطا را همانطور که انتظار داریم مدیریت می‌کند. برای مثال، نوع Guess را که در فصل ۹، لیست ۹-۱۳ ایجاد کردیم در نظر بگیرید. سایر کدهایی که از Guess استفاده می‌کنند به این تضمین وابسته هستند که نمونه‌های Guess فقط مقادیر بین ۱ و ۱۰۰ را شامل می‌شوند. می‌توانیم تستی بنویسیم که اطمینان حاصل کند که تلاش برای ایجاد یک نمونه Guess با مقداری خارج از این بازه منجر به پانیک می‌شود.

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

لیست ۱۱-۸ یک تست را نشان می‌دهد که بررسی می‌کند شرایط خطای Guess::new زمانی که انتظار داریم رخ می‌دهند.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-8: تست کردن اینکه آیا یک شرط باعث یک panic! می‌شود

ما ویژگی #[should_panic] را بعد از ویژگی #[test] و قبل از تابع تستی که به آن اعمال می‌شود قرار می‌دهیم. بیایید به نتیجه‌ای که وقتی این تست پاس می‌شود نگاه کنیم:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

به نظر خوب می‌آید! حالا بیایید یک باگ در کد خود وارد کنیم با حذف شرطی که تابع new را مجبور می‌کند اگر مقدار بیشتر از ۱۰۰ باشد پانیک کند:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

وقتی تست در لیست ۱۱-۸ را اجرا می‌کنیم، شکست می‌خورد:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

در این مورد پیام خیلی مفیدی دریافت نمی‌کنیم، اما وقتی به تابع تست نگاه می‌کنیم، می‌بینیم که با #[should_panic] حاشیه‌نویسی شده است. شکست به این معناست که کدی که در تابع تست قرار دارد باعث یک پانیک نشده است.

تست‌هایی که از should_panic استفاده می‌کنند می‌توانند دقیق نباشند. یک تست should_panic حتی اگر تست برای دلیلی غیر از آنچه انتظار داشتیم پانیک کند، پاس می‌شود. برای دقیق‌تر کردن تست‌های should_panic، می‌توانیم یک پارامتر اختیاری expected به ویژگی should_panic اضافه کنیم. تست رانر اطمینان حاصل می‌کند که پیام شکست شامل متن ارائه‌شده است. برای مثال، کد تغییر داده‌شده برای Guess در لیست ۱۱-۹ را در نظر بگیرید که تابع new با پیام‌های مختلف بسته به اینکه مقدار خیلی کوچک یا خیلی بزرگ باشد پانیک می‌کند.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-9: تست کردن یک panic! با یک پیام پانیک که حاوی یک زیررشته مشخص است

این تست پاس می‌شود زیرا مقداری که در پارامتر expected ویژگی should_panic قرار داده‌ایم یک زیررشته از پیامی است که تابع Guess::new با آن پانیک می‌کند. می‌توانستیم کل پیام پانیکی که انتظار داریم را مشخص کنیم، که در این مورد می‌شد Guess value must be less than or equal to 100, got 200. آنچه انتخاب می‌کنید بستگی به این دارد که چه مقدار از پیام پانیک منحصر به فرد یا پویا است و چقدر می‌خواهید تست شما دقیق باشد. در این مورد، یک زیررشته از پیام پانیک کافی است تا اطمینان حاصل شود که کد در تابع تست مورد else if value > 100 را اجرا می‌کند.

برای دیدن اینکه وقتی یک تست should_panic با یک پیام expected شکست می‌خورد چه اتفاقی می‌افتد، بیایید دوباره یک باگ به کد خود وارد کنیم با جابه‌جا کردن بدنه‌های بلوک‌های if value < 1 و else if value > 100:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

این بار وقتی تست should_panic را اجرا می‌کنیم، شکست خواهد خورد:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----

thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"less than or equal to 100"`

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

پیام شکست نشان می‌دهد که این تست همانطور که انتظار داشتیم پانیک کرد، اما پیام پانیک شامل رشته مورد انتظار less than or equal to 100 نبود. پیام پانیکی که در این مورد دریافت کردیم Guess value must be greater than or equal to 1, got 200. بود. حالا می‌توانیم شروع به پیدا کردن محل باگ کنیم!

استفاده از Result<T, E> در تست‌ها

تست‌های ما تا اینجا همه زمانی که شکست می‌خورند پانیک می‌کنند. همچنین می‌توانیم تست‌هایی بنویسیم که از Result<T, E> استفاده کنند! در اینجا تست لیست ۱۱-۱ را بازنویسی کرده‌ایم تا از Result<T, E> استفاده کند و به جای پانیک کردن، یک Err بازگرداند:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

تابع it_works اکنون نوع بازگشتی Result<(), String> دارد. در بدنه تابع، به جای فراخوانی ماکروی assert_eq!، وقتی تست پاس می‌شود Ok(()) و وقتی تست شکست می‌خورد یک Err با یک String داخل آن بازمی‌گردانیم.

نوشتن تست‌هایی که یک Result<T, E> بازمی‌گردانند به شما اجازه می‌دهد از عملگر سوالی ? در بدنه تست‌ها استفاده کنید، که می‌تواند راهی راحت برای نوشتن تست‌هایی باشد که اگر هر عملیاتی در آن‌ها یک واریانت Err بازگرداند، شکست بخورند.

شما نمی‌توانید از حاشیه‌نویسی #[should_panic] در تست‌هایی که از Result<T, E> استفاده می‌کنند استفاده کنید. برای تأیید اینکه یک عملیات یک واریانت Err بازمی‌گرداند، از عملگر سوالی روی مقدار Result<T, E> استفاده نکنید. در عوض، از assert!(value.is_err()) استفاده کنید.

حالا که چندین روش برای نوشتن تست‌ها را یاد گرفتید، بیایید نگاهی به آنچه هنگام اجرای تست‌ها اتفاق می‌افتد بیندازیم و گزینه‌های مختلفی را که می‌توانیم با cargo test استفاده کنیم بررسی کنیم.

کنترل نحوه اجرای تست‌ها

دقیقاً همانطور که cargo run کد شما را کامپایل کرده و باینری حاصل را اجرا می‌کند، cargo test کد شما را در حالت تست کامپایل کرده و باینری تست حاصل را اجرا می‌کند. رفتار پیش‌فرض باینری تولیدشده توسط cargo test این است که تمام تست‌ها را به صورت موازی اجرا کرده و خروجی تولید شده در طول اجرای تست‌ها را ضبط کند. این کار از نمایش خروجی جلوگیری کرده و خواندن خروجی مرتبط با نتایج تست را آسان‌تر می‌کند. با این حال، می‌توانید با مشخص کردن گزینه‌های خط فرمان این رفتار پیش‌فرض را تغییر دهید.

برخی گزینه‌های خط فرمان به cargo test می‌روند و برخی دیگر به باینری تست حاصل ارسال می‌شوند. برای جدا کردن این دو نوع آرگومان، آرگومان‌هایی که به cargo test می‌روند را ذکر کنید و سپس جداکننده -- و آرگومان‌هایی که به باینری تست می‌روند را بیاورید. اجرای cargo test --help گزینه‌هایی را نمایش می‌دهد که می‌توانید با cargo test استفاده کنید، و اجرای cargo test -- --help گزینه‌هایی را که می‌توانید پس از جداکننده استفاده کنید نمایش می‌دهد. این گزینه‌ها همچنین در بخش “تست‌ها” از کتاب rustc مستند شده‌اند.

اجرای تست‌ها به صورت موازی یا متوالی

وقتی چندین تست را اجرا می‌کنید، به طور پیش‌فرض این تست‌ها به صورت موازی با استفاده از نخ‌ها (threads) اجرا می‌شوند، به این معنی که سریع‌تر به پایان می‌رسند و بازخورد سریع‌تری دریافت می‌کنید. از آنجا که تست‌ها به صورت هم‌زمان اجرا می‌شوند، باید اطمینان حاصل کنید که تست‌های شما به یکدیگر یا به هیچ حالت مشترکی، از جمله یک محیط مشترک مانند دایرکتوری کاری جاری یا متغیرهای محیطی، وابسته نیستند.

برای مثال، فرض کنید هر یک از تست‌های شما کدی را اجرا می‌کند که یک فایل به نام test-output.txt روی دیسک ایجاد کرده و داده‌هایی در آن فایل می‌نویسد. سپس هر تست داده‌های موجود در آن فایل را خوانده و تأیید می‌کند که فایل شامل یک مقدار خاص است، که در هر تست متفاوت است. چون تست‌ها به طور هم‌زمان اجرا می‌شوند، ممکن است یک تست فایل را در زمانی که تست دیگری در حال نوشتن و خواندن فایل است، بازنویسی کند. در این صورت، تست دوم شکست خواهد خورد، نه به این دلیل که کد اشتباه است بلکه به این دلیل که تست‌ها در هنگام اجرای موازی با یکدیگر تداخل پیدا کرده‌اند. یک راه‌حل این است که مطمئن شوید هر تست به یک فایل متفاوت می‌نویسد؛ راه‌حل دیگر این است که تست‌ها را یکی یکی اجرا کنید.

اگر نمی‌خواهید تست‌ها به صورت موازی اجرا شوند یا اگر می‌خواهید کنترل بیشتری بر تعداد نخ‌های استفاده‌شده داشته باشید، می‌توانید فلگ --test-threads و تعداد نخ‌هایی که می‌خواهید استفاده کنید را به باینری تست ارسال کنید. به مثال زیر توجه کنید:

$ cargo test -- --test-threads=1

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

نمایش خروجی توابع

به طور پیش‌فرض، اگر یک تست پاس شود، کتابخانه تست Rust هر چیزی که به خروجی استاندارد چاپ شده را ضبط می‌کند. برای مثال، اگر در یک تست از println! استفاده کنیم و تست پاس شود، خروجی println! را در ترمینال نخواهیم دید؛ فقط خطی که نشان می‌دهد تست پاس شده است را خواهیم دید. اگر یک تست شکست بخورد، هر چیزی که به خروجی استاندارد چاپ شده باشد را همراه با پیام شکست خواهیم دید.

برای مثال، لیست ۱۱-۱۰ یک تابع ساده دارد که مقدار پارامتر خود را چاپ کرده و مقدار ۱۰ را بازمی‌گرداند، همچنین یک تست که پاس می‌شود و یک تست که شکست می‌خورد.

Filename: src/lib.rs
fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {a}");
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(value, 10);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(value, 5);
    }
}
Listing 11-10: تست‌هایی برای یک تابع که از println! استفاده می‌کند

وقتی این تست‌ها را با cargo test اجرا می‌کنیم، خروجی زیر را خواهیم دید:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

توجه کنید که در هیچ جای این خروجی I got the value 4 که هنگام اجرای تست پاس‌شده چاپ می‌شود، نمی‌بینیم. این خروجی ضبط شده است. خروجی تست شکست‌خورده، I got the value 8، در بخش خلاصه خروجی تست ظاهر می‌شود که علت شکست تست را نیز نشان می‌دهد.

اگر بخواهیم مقادیر چاپ‌شده برای تست‌های پاس‌شده را نیز ببینیم، می‌توانیم به Rust بگوییم که خروجی تست‌های موفق را با استفاده از --show-output نیز نمایش دهد:

$ cargo test -- --show-output

وقتی تست‌های لیست ۱۱-۱۰ را دوباره با فلگ --show-output اجرا می‌کنیم، خروجی زیر را خواهیم دید:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

successes:

---- tests::this_test_will_pass stdout ----
I got the value 4


successes:
    tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

اجرای زیرمجموعه‌ای از تست‌ها با نام

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

برای نشان دادن نحوه اجرای یک زیرمجموعه از تست‌ها، ابتدا سه تست برای تابع add_two خود ایجاد می‌کنیم، همانطور که در لیست ۱۱-۱۱ نشان داده شده است، و انتخاب می‌کنیم کدام‌یک را اجرا کنیم.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }

    #[test]
    fn add_three_and_two() {
        let result = add_two(3);
        assert_eq!(result, 5);
    }

    #[test]
    fn one_hundred() {
        let result = add_two(100);
        assert_eq!(result, 102);
    }
}
Listing 11-11: سه تست با سه نام مختلف

اگر تست‌ها را بدون پاس دادن هیچ آرگومانی اجرا کنیم، همانطور که قبلاً دیدیم، تمام تست‌ها به صورت موازی اجرا می‌شوند:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

اجرای تست‌های منفرد

می‌توانیم نام هر تابع تست را به cargo test پاس دهیم تا فقط همان تست اجرا شود:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

فقط تستی با نام one_hundred اجرا شد؛ دو تست دیگر با این نام مطابقت نداشتند. خروجی تست به ما اطلاع می‌دهد که تست‌های بیشتری وجود داشته‌اند که اجرا نشده‌اند و در انتها 2 filtered out را نمایش می‌دهد.

نمی‌توانیم به این روش نام چندین تست را مشخص کنیم؛ فقط اولین مقداری که به cargo test داده می‌شود استفاده خواهد شد. اما راهی برای اجرای چندین تست وجود دارد.

فیلتر کردن برای اجرای چندین تست

می‌توانیم بخشی از یک نام تست را مشخص کنیم، و هر تستی که نامش با آن مقدار مطابقت داشته باشد اجرا خواهد شد. برای مثال، چون دو تا از نام‌های تست‌های ما شامل add هستند، می‌توانیم آن دو را با اجرای cargo test add اجرا کنیم:

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

این فرمان تمام تست‌هایی که add در نامشان دارند را اجرا کرد و تستی با نام one_hundred را فیلتر کرد. همچنین توجه داشته باشید که ماژولی که یک تست در آن ظاهر می‌شود بخشی از نام تست می‌شود، بنابراین می‌توانیم تمام تست‌های یک ماژول را با فیلتر کردن روی نام ماژول اجرا کنیم.

نادیده گرفتن برخی تست‌ها مگر اینکه صریحاً درخواست شوند

گاهی اوقات چند تست خاص می‌توانند بسیار وقت‌گیر باشند، بنابراین ممکن است بخواهید آن‌ها را در اکثر اجراهای cargo test حذف کنید. به جای لیست کردن تمام تست‌هایی که می‌خواهید اجرا کنید، می‌توانید تست‌های وقت‌گیر را با استفاده از ویژگی ignore حاشیه‌نویسی کنید تا آن‌ها را حذف کنید، همانطور که در اینجا نشان داده شده است:

Filename: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

بعد از #[test]، خط #[ignore] را به تستی که می‌خواهیم حذف کنیم اضافه می‌کنیم. حالا وقتی تست‌های خود را اجرا می‌کنیم، it_works اجرا می‌شود، اما expensive_test اجرا نمی‌شود:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::expensive_test ... ignored
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

تابع expensive_test به عنوان ignored فهرست شده است. اگر بخواهیم فقط تست‌های نادیده‌گرفته‌شده را اجرا کنیم، می‌توانیم از cargo test -- --ignored استفاده کنیم:

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

با کنترل اینکه کدام تست‌ها اجرا می‌شوند، می‌توانید مطمئن شوید که نتایج cargo test شما به سرعت بازگردانده می‌شوند. وقتی در نقطه‌ای هستید که منطقی است نتایج تست‌های ignored را بررسی کنید و زمان برای انتظار نتایج دارید، می‌توانید به جای آن cargo test -- --ignored را اجرا کنید. اگر می‌خواهید تمام تست‌ها را اجرا کنید، چه نادیده‌گرفته‌شده و چه نشده، می‌توانید cargo test -- --include-ignored را اجرا کنید.

سازماندهی تست‌ها

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

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

تست‌های واحد

هدف تست‌های واحد این است که هر واحد کد را به طور جداگانه از سایر کدها تست کنند تا به سرعت مشخص شود که کد کجا به درستی کار می‌کند و کجا نه. تست‌های واحد را در دایرکتوری src در هر فایل با کدی که تست می‌کنند قرار می‌دهید. کنوانسیون این است که یک ماژول به نام tests در هر فایل ایجاد کنید تا توابع تست را در آن قرار دهید و ماژول را با cfg(test) حاشیه‌نویسی کنید.

ماژول تست‌ها و #[cfg(test)]

حاشیه‌نویسی #[cfg(test)] روی ماژول tests به Rust می‌گوید که کد تست فقط وقتی که cargo test اجرا شود کامپایل و اجرا شود، نه وقتی که cargo build اجرا شود. این باعث صرفه‌جویی در زمان کامپایل وقتی فقط می‌خواهید کتابخانه را بسازید می‌شود و فضای کمتری در نتیجه کامپایل‌شده می‌گیرد زیرا تست‌ها شامل نمی‌شوند. مشاهده خواهید کرد که چون تست‌های یکپارچه در یک دایرکتوری جداگانه قرار می‌گیرند، نیازی به حاشیه‌نویسی #[cfg(test)] ندارند. با این حال، چون تست‌های واحد در همان فایل‌هایی که کد قرار دارد قرار می‌گیرند، از #[cfg(test)] استفاده می‌کنید تا مشخص کنید که نباید در نتیجه کامپایل‌شده قرار گیرند.

به یاد بیاورید وقتی پروژه جدید adder را در بخش اول این فصل تولید کردیم، Cargo این کد را برای ما تولید کرد:

Filename: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

روی ماژول tests که به طور خودکار تولید شده است، ویژگی cfg مخفف پیکربندی است و به Rust می‌گوید که آیتم زیر فقط در صورت وجود یک گزینه پیکربندی مشخص گنجانده شود. در این مورد، گزینه پیکربندی test است، که توسط Rust برای کامپایل و اجرای تست‌ها ارائه می‌شود. با استفاده از ویژگی cfg، Cargo کد تست ما را فقط در صورتی که تست‌ها را به طور فعال با cargo test اجرا کنیم، کامپایل می‌کند. این شامل هر تابع کمکی که ممکن است در این ماژول باشد نیز می‌شود، علاوه بر توابعی که با #[test] حاشیه‌نویسی شده‌اند.

تست توابع خصوصی

در جامعه تست‌نویسی بحث‌هایی درباره اینکه آیا توابع خصوصی باید مستقیماً تست شوند یا نه وجود دارد، و برخی زبان‌ها تست کردن توابع خصوصی را دشوار یا غیرممکن می‌کنند. صرف نظر از اینکه از کدام ایدئولوژی تست‌نویسی پیروی می‌کنید، قوانین خصوصی‌سازی Rust به شما اجازه می‌دهند توابع خصوصی را تست کنید. کدی که در لیست ۱۱-۱۲ با تابع خصوصی internal_adder ارائه شده است را در نظر بگیرید.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    internal_adder(a, 2)
}

fn internal_adder(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-12: تست یک تابع خصوصی

توجه داشته باشید که تابع internal_adder به‌صورت pub علامت‌گذاری نشده است.
تست‌ها فقط کد Rust هستند و ماژول tests نیز یک ماژول عادی دیگر است.
همان‌طور که در بخش “مسیرها برای ارجاع به یک آیتم در درخت ماژول” بحث کردیم،
آیتم‌های ماژول‌های فرزند می‌توانند از آیتم‌های ماژول‌های والد خود استفاده کنند.
در این تست، با استفاده از use super::* تمام آیتم‌های ماژول والد tests وارد حوزه می‌شوند،
و سپس تست می‌تواند تابع internal_adder را فراخوانی کند.
اگر فکر می‌کنید توابع خصوصی نباید تست شوند، در Rust هیچ اجباری برای انجام این کار وجود ندارد.

تست‌های یکپارچه

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

دایرکتوری tests

ما یک دایرکتوری به نام tests در سطح بالای دایرکتوری پروژه خود، در کنار src ایجاد می‌کنیم. Cargo می‌داند که باید به دنبال فایل‌های تست یکپارچه در این دایرکتوری بگردد. سپس می‌توانیم به هر تعداد فایل تست که می‌خواهیم ایجاد کنیم، و Cargo هر یک از فایل‌ها را به عنوان یک crate جداگانه کامپایل می‌کند.

بیایید یک تست یکپارچه ایجاد کنیم. با کدی که هنوز در فایل src/lib.rs از لیست ۱۱-۱۲ قرار دارد، یک دایرکتوری tests ایجاد کنید و یک فایل جدید به نام tests/integration_test.rs بسازید. ساختار دایرکتوری شما باید به این صورت باشد:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

کد موجود در لیست ۱۱-۱۳ را در فایل tests/integration_test.rs وارد کنید.

Filename: tests/integration_test.rs
use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}
Listing 11-13: یک تست یکپارچه برای تابعی در crate adder

هر فایل در دایرکتوری tests یک crate جداگانه است، بنابراین باید کتابخانه خود را به دامنه هر crate تست وارد کنیم. به همین دلیل، در بالای کد use adder::add_two; را اضافه می‌کنیم، که در تست‌های واحد نیازی به آن نداشتیم.

نیازی نیست هیچ کدی در فایل tests/integration_test.rs را با #[cfg(test)] علامت‌گذاری کنیم. Cargo دایرکتوری tests را به طور خاص مدیریت می‌کند و فایل‌های موجود در این دایرکتوری را فقط زمانی که cargo test اجرا کنیم کامپایل می‌کند. اکنون cargo test را اجرا کنید:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

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

بخش اول برای تست‌های واحد همان چیزی است که قبلاً دیده‌ایم: یک خط برای هر تست واحد (یکی به نام internal که در لیست ۱۱-۱۲ اضافه کردیم) و سپس یک خط خلاصه برای تست‌های واحد.

بخش تست‌های یکپارچه با خط Running tests/integration_test.rs شروع می‌شود. سپس یک خط برای هر تابع تست در آن تست یکپارچه و یک خط خلاصه برای نتایج تست یکپارچه دقیقاً قبل از شروع بخش Doc-tests adder وجود دارد.

هر فایل تست یکپارچه بخش خاص خود را دارد، بنابراین اگر فایل‌های بیشتری در دایرکتوری tests اضافه کنیم، بخش‌های بیشتری برای تست‌های یکپارچه خواهیم داشت.

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

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

این فرمان فقط تست‌های موجود در فایل tests/integration_test.rs را اجرا می‌کند.

زیرماژول‌ها در تست‌های یکپارچه

با اضافه کردن تست‌های یکپارچه بیشتر، ممکن است بخواهید فایل‌های بیشتری در دایرکتوری tests برای کمک به سازماندهی آن‌ها ایجاد کنید؛ برای مثال، می‌توانید توابع تست را بر اساس عملکردی که تست می‌کنند گروه‌بندی کنید. همانطور که قبلاً ذکر شد، هر فایل در دایرکتوری tests به عنوان یک crate جداگانه کامپایل می‌شود، که برای ایجاد دامنه‌های جداگانه مفید است تا بیشتر شبیه نحوه استفاده کاربران نهایی از crate شما باشد. با این حال، این به این معنی است که فایل‌های موجود در دایرکتوری tests رفتار یکسانی با فایل‌های موجود در src ندارند، همانطور که در فصل ۷ درباره جدا کردن کد به ماژول‌ها و فایل‌ها آموختید.

این رفتار متفاوت فایل‌های دایرکتوری tests بیشترین توجه را زمانی جلب می‌کند که مجموعه‌ای از توابع کمکی برای استفاده در چندین فایل تست یکپارچه دارید و سعی می‌کنید مراحل بخش “جدا کردن ماژول‌ها به فایل‌های مختلف” در فصل ۷ را برای استخراج آن‌ها به یک ماژول مشترک دنبال کنید. برای مثال، اگر tests/common.rs ایجاد کنیم و یک تابع به نام setup در آن قرار دهیم، می‌توانیم کدی به setup اضافه کنیم که می‌خواهیم از چندین تابع تست در چندین فایل تست فراخوانی کنیم:

Filename: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

وقتی دوباره تست‌ها را اجرا می‌کنیم، یک بخش جدید در خروجی تست برای فایل common.rs خواهیم دید، حتی اگر این فایل هیچ تابع تستی ندارد و تابع setup را از هیچ جایی فراخوانی نکرده‌ایم:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

داشتن common در نتایج تست با running 0 tests نمایش داده شده برای آن، چیزی نبود که می‌خواستیم. ما فقط می‌خواستیم برخی کدها را با دیگر فایل‌های تست یکپارچه به اشتراک بگذاریم. برای جلوگیری از نمایش common در خروجی تست، به جای ایجاد tests/common.rs، فایل tests/common/mod.rs را ایجاد می‌کنیم. اکنون ساختار دایرکتوری پروژه به این شکل است:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

این روش نام‌گذاری قدیمی‌تر است که Rust نیز آن را می‌شناسد و در بخش “مسیرهای فایل جایگزین” در فصل ۷ به آن اشاره کردیم.
نام‌گذاری فایل به این صورت به Rust می‌گوید که ماژول common را به عنوان یک فایل تست یکپارچه (integration test) در نظر نگیرد.
وقتی کد تابع setup را به فایل tests/common/mod.rs منتقل کنیم و فایل tests/common.rs را حذف کنیم،
بخش مربوطه در خروجی تست دیگر ظاهر نخواهد شد.
فایل‌های موجود در زیرشاخه‌های دایرکتوری tests به صورت crateهای جداگانه کامپایل نمی‌شوند
و در خروجی تست نیز بخش مجزایی ندارند.

پس از ایجاد tests/common/mod.rs، می‌توانیم از آن به عنوان یک ماژول در هر یک از فایل‌های تست یکپارچه استفاده کنیم. در اینجا یک مثال از فراخوانی تابع setup از تست it_adds_two در tests/integration_test.rs آمده است:

Filename: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

توجه داشته باشید که اعلان mod common; مشابه اعلان ماژولی است که در لیست ۷-۲۱ نشان دادیم. سپس، در تابع تست، می‌توانیم تابع common::setup() را فراخوانی کنیم.

تست‌های یکپارچه برای crate‌های دودویی

اگر پروژه ما یک crate دودویی باشد که فقط شامل یک فایل src/main.rs است و فایل src/lib.rs ندارد، نمی‌توانیم تست‌های یکپارچه را در دایرکتوری tests ایجاد کنیم و توابع تعریف‌شده در فایل src/main.rs را با یک عبارت use به دامنه وارد کنیم. فقط crate‌های کتابخانه‌ای توابعی را که سایر crate‌ها می‌توانند استفاده کنند در معرض قرار می‌دهند؛ crate‌های دودویی برای اجرای مستقل طراحی شده‌اند.

این یکی از دلایلی است که پروژه‌های Rust که یک دودویی ارائه می‌دهند، معمولاً یک فایل src/main.rs ساده دارند که به منطق موجود در فایل src/lib.rs فراخوانی می‌کند. با استفاده از این ساختار، تست‌های یکپارچه می‌توانند crate کتابخانه‌ای را با use تست کنند تا قابلیت مهم را در دسترس قرار دهند. اگر قابلیت مهم کار کند، مقدار کمی کد در فایل src/main.rs نیز کار خواهد کرد، و نیازی به تست آن مقدار کم از کد نیست.

خلاصه

ویژگی‌های تست‌نویسی در Rust راهی برای مشخص کردن نحوه عملکرد کد فراهم می‌کنند تا اطمینان حاصل شود که کد همانطور که انتظار می‌رود کار می‌کند، حتی زمانی که تغییراتی در آن ایجاد می‌کنید. تست‌های واحد بخش‌های مختلف یک کتابخانه را به طور جداگانه آزمایش می‌کنند و می‌توانند جزئیات پیاده‌سازی خصوصی را تست کنند. تست‌های یکپارچه بررسی می‌کنند که آیا بخش‌های مختلف کتابخانه به درستی با یکدیگر کار می‌کنند یا نه، و از رابط عمومی کتابخانه برای تست کد به همان روشی که کد خارجی از آن استفاده می‌کند، استفاده می‌کنند. حتی با وجود اینکه سیستم نوع‌ها و قوانین مالکیت در Rust به جلوگیری از برخی انواع باگ‌ها کمک می‌کند، تست‌ها همچنان برای کاهش باگ‌های منطقی که به نحوه عملکرد مورد انتظار کد مربوط می‌شوند، مهم هستند.

بیایید دانش خود را که در این فصل و فصل‌های قبلی یاد گرفتید، ترکیب کرده و روی یک پروژه کار کنیم!

یک پروژه ورودی/خروجی: ساخت یک برنامه خط فرمان

این فصل مروری بر بسیاری از مهارت‌هایی است که تا کنون آموخته‌اید و همچنین بررسی چند ویژگی دیگر از کتابخانه استاندارد. ما یک ابزار خط فرمان خواهیم ساخت که با ورودی/خروجی فایل و خط فرمان تعامل می‌کند تا برخی از مفاهیم Rust را که اکنون در اختیار دارید تمرین کنیم.

سرعت، ایمنی، خروجی تک‌باینری و پشتیبانی چند‌پلتفرمی Rust، آن را به زبانی ایده‌آل برای ایجاد ابزارهای خط فرمان تبدیل می‌کند. بنابراین برای پروژه خود، نسخه‌ای از ابزار جستجوی خط فرمان کلاسیک grep (globally search a regular expression and print) را خواهیم ساخت. در ساده‌ترین حالت، grep یک فایل مشخص را برای یک رشته مشخص جستجو می‌کند. برای انجام این کار، grep به عنوان آرگومان‌های خود مسیر فایل و یک رشته را دریافت می‌کند. سپس فایل را می‌خواند، خطوطی که شامل آرگومان رشته هستند را پیدا می‌کند و آن خطوط را چاپ می‌کند.

در طول مسیر، نشان خواهیم داد که چگونه ابزار خط فرمان ما از ویژگی‌های ترمینال استفاده کند که بسیاری از ابزارهای خط فرمان دیگر از آن‌ها استفاده می‌کنند. مقدار یک متغیر محیطی را برای اجازه به کاربر برای پیکربندی رفتار ابزار خود می‌خوانیم. همچنین پیام‌های خطا را به جریان کنسول خطای استاندارد (stderr) به جای خروجی استاندارد (stdout) چاپ می‌کنیم تا مثلاً کاربر بتواند خروجی موفقیت‌آمیز را به یک فایل هدایت کند در حالی که هنوز پیام‌های خطا را روی صفحه مشاهده می‌کند.

یکی از اعضای جامعه Rust، Andrew Gallant، نسخه‌ای کامل، بسیار سریع از grep به نام ripgrep ایجاد کرده است. در مقایسه، نسخه ما نسبتاً ساده خواهد بود، اما این فصل به شما برخی از دانش‌های پایه‌ای که برای درک پروژه‌های واقعی مانند ripgrep نیاز دارید را خواهد داد.

پروژه grep ما ترکیبی از تعدادی مفاهیمی است که تاکنون آموخته‌اید:

  • سازماندهی کد (فصل ۷)
  • استفاده از بردارها و رشته‌ها (فصل ۸)
  • مدیریت خطاها (فصل ۹)
  • استفاده از صفات و طول عمرها در موارد مناسب (فصل ۱۰)
  • نوشتن تست‌ها (فصل ۱۱)

همچنین به طور مختصر به معرفی closures، iterators، و trait objects می‌پردازیم که به طور کامل در فصل ۱۳ و فصل ۱۸ پوشش داده خواهند شد.

پذیرش آرگومان‌های خط فرمان

بیایید با استفاده از cargo new یک پروژه جدید ایجاد کنیم. پروژه خود را minigrep می‌نامیم تا آن را از ابزار grep که ممکن است در سیستم شما وجود داشته باشد متمایز کنیم.

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

اولین کار این است که minigrep آرگومان‌های خط فرمان خود، شامل مسیر فایل و رشته‌ای برای جستجو، را بپذیرد. به عبارت دیگر، می‌خواهیم بتوانیم برنامه خود را با cargo run، دو خط تیره برای نشان دادن اینکه آرگومان‌های بعدی برای برنامه ما هستند و نه برای cargo، یک رشته برای جستجو و یک مسیر فایل برای جستجو اجرا کنیم، مانند زیر:

$ cargo run -- searchstring example-filename.txt

در حال حاضر، برنامه‌ای که توسط cargo new تولید شده است نمی‌تواند آرگومان‌هایی که به آن می‌دهیم را پردازش کند. برخی کتابخانه‌های موجود در crates.io می‌توانند برای نوشتن برنامه‌ای که آرگومان‌های خط فرمان را بپذیرد کمک کنند، اما چون شما تازه با این مفهوم آشنا می‌شوید، بیایید این قابلیت را خودمان پیاده‌سازی کنیم.

خواندن مقادیر آرگومان‌ها

برای اینکه minigrep بتواند مقادیر آرگومان‌های خط فرمان را که به آن می‌دهیم بخواند، به تابع std::env::args که در کتابخانه استاندارد Rust ارائه شده است نیاز خواهیم داشت. این تابع یک iterator از آرگومان‌های خط فرمانی که به minigrep داده شده است بازمی‌گرداند. ما در فصل ۱۳ به طور کامل iteratorها را پوشش خواهیم داد. در حال حاضر، فقط باید دو نکته درباره iteratorها بدانید: iteratorها یک سری مقادیر تولید می‌کنند و ما می‌توانیم تابع collect را روی یک iterator فراخوانی کنیم تا آن را به یک collection، مانند یک بردار، که شامل تمام عناصر تولیدشده توسط iterator است، تبدیل کنیم.

کد موجود در لیست ۱۲-۱ به برنامه minigrep شما اجازه می‌دهد تا هر آرگومان خط فرمانی که به آن داده شده را بخواند و سپس مقادیر را به یک بردار جمع‌آوری کند.

Filename: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}
Listing 12-1: جمع‌آوری آرگومان‌های خط فرمان به یک بردار و چاپ آن‌ها

ابتدا ماژول std::env را با یک دستور use به دامنه وارد می‌کنیم تا بتوانیم از تابع args آن استفاده کنیم. توجه کنید که تابع std::env::args در دو سطح ماژول تو در تو قرار دارد. همانطور که در فصل ۷ بحث کردیم، در مواردی که تابع موردنظر در بیش از یک ماژول تو در تو قرار دارد، ترجیح می‌دهیم ماژول والد را به دامنه وارد کنیم نه تابع. با این کار، می‌توانیم به راحتی از توابع دیگر std::env استفاده کنیم. همچنین این روش کمتر مبهم است نسبت به اضافه کردن use std::env::args و سپس فراخوانی تابع با فقط args، چون args ممکن است به راحتی با یک تابع تعریف‌شده در ماژول جاری اشتباه گرفته شود.

تابع args و یونیکد نامعتبر

توجه داشته باشید که std::env::args اگر هر آرگومانی شامل یونیکد نامعتبر باشد، پانیک خواهد کرد. اگر برنامه شما نیاز به پذیرش آرگومان‌هایی با یونیکد نامعتبر دارد، به جای آن از std::env::args_os استفاده کنید. این تابع یک iterator بازمی‌گرداند که مقادیر OsString به جای String تولید می‌کند. ما برای سادگی std::env::args را اینجا انتخاب کرده‌ایم زیرا مقادیر OsString بسته به پلتفرم متفاوت هستند و کار با آن‌ها پیچیده‌تر از مقادیر String است.

در اولین خط از تابع main، ما تابع env::args را فراخوانی می‌کنیم و بلافاصله از تابع collect استفاده می‌کنیم تا iterator را به یک بردار که شامل تمام مقادیر تولید‌شده توسط iterator است، تبدیل کنیم. می‌توانیم از تابع collect برای ایجاد انواع مختلفی از collectionها استفاده کنیم، بنابراین نوع args را به طور صریح با ذکر می‌کنیم که می‌خواهیم یک بردار از رشته‌ها داشته باشیم. با اینکه به ندرت نیاز به ذکر نوع‌ها در Rust دارید، تابع collect یکی از تابع‌هایی است که اغلب باید نوع آن را ذکر کنید، زیرا Rust نمی‌تواند نوع collection مورد نظر شما را استنباط کند.

در نهایت، بردار را با استفاده از ماکروی debug چاپ می‌کنیم. بیایید ابتدا کد را بدون آرگومان اجرا کنیم و سپس با دو آرگومان:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
$ cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

توجه کنید که اولین مقدار در بردار "target/debug/minigrep" است، که نام باینری ما است. این رفتار با لیست آرگومان‌ها در زبان C مطابقت دارد و به برنامه‌ها اجازه می‌دهد از نامی که با آن اجرا شده‌اند، در اجرای خود استفاده کنند. دسترسی به نام برنامه اغلب مفید است، مثلاً برای چاپ آن در پیام‌ها یا تغییر رفتار برنامه بر اساس نام مستعار خط فرمانی که برای اجرای برنامه استفاده شده است. اما برای اهداف این فصل، آن را نادیده می‌گیریم و فقط دو آرگومان مورد نیاز را ذخیره می‌کنیم.

ذخیره مقادیر آرگومان‌ها در متغیرها

در حال حاضر، برنامه قادر به دسترسی به مقادیر مشخص‌شده به عنوان آرگومان‌های خط فرمان است. اکنون نیاز داریم مقادیر دو آرگومان را در متغیرهایی ذخیره کنیم تا بتوانیم از آن‌ها در بقیه برنامه استفاده کنیم. این کار را در لیست ۱۲-۲ انجام می‌دهیم.

Filename: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}
Listing 12-2: ایجاد متغیرها برای نگه‌داری آرگومان جستجو و مسیر فایل

همانطور که هنگام چاپ بردار مشاهده کردیم، نام برنامه اولین مقدار در بردار را در args[0] اشغال می‌کند، بنابراین آرگومان‌ها را از اندیس (index)۱ شروع می‌کنیم. اولین آرگومان که minigrep دریافت می‌کند، رشته‌ای است که می‌خواهیم جستجو کنیم، بنابراین یک مرجع به اولین آرگومان را در متغیر query قرار می‌دهیم. آرگومان دوم مسیر فایل خواهد بود، بنابراین یک مرجع به آرگومان دوم را در متغیر file_path قرار می‌دهیم.

ما به طور موقت مقادیر این متغیرها را چاپ می‌کنیم تا اثبات کنیم که کد همانطور که می‌خواهیم کار می‌کند. بیایید دوباره این برنامه را با آرگومان‌های test و sample.txt اجرا کنیم:

$ cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

عالی است، برنامه کار می‌کند! مقادیر آرگومان‌های مورد نیاز ما در متغیرهای درست ذخیره می‌شوند. بعداً برخی از خطاها را مدیریت خواهیم کرد، مثل وقتی که کاربر هیچ آرگومانی ارائه نمی‌دهد؛ فعلاً، آن شرایط را نادیده می‌گیریم و روی افزودن قابلیت خواندن فایل تمرکز می‌کنیم.

خواندن یک فایل

اکنون قابلیت خواندن فایل مشخص‌شده در آرگومان file_path را اضافه می‌کنیم. ابتدا به یک فایل نمونه برای تست نیاز داریم: از یک فایل با مقدار کمی متن در چندین خط که برخی کلمات در آن تکرار شده‌اند استفاده می‌کنیم. لیست ۱۲-۳ شامل شعری از امیلی دیکینسون است که به خوبی برای این منظور مناسب است! یک فایل به نام poem.txt در سطح اصلی پروژه خود ایجاد کنید و شعر “I’m Nobody! Who are you?” را وارد کنید.

Filename: poem.txt
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Listing 12-3: شعری از امیلی دیکینسون که مورد تست مناسبی است.

با متن در جای خود، فایل src/main.rs را ویرایش کرده و کدی برای خواندن فایل اضافه کنید، همانطور که در لیست ۱۲-۴ نشان داده شده است.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}
Listing 12-4: خواندن محتوای فایل مشخص‌شده توسط آرگومان دوم

ابتدا بخشی مرتبط از کتابخانه استاندارد را با یک دستور use وارد می‌کنیم: برای مدیریت فایل‌ها به std::fs نیاز داریم.

در تابع main، دستور جدید fs::read_to_string مقدار file_path را می‌گیرد، آن فایل را باز می‌کند و مقداری از نوع std::io::Result<String> را که شامل محتوای فایل است، بازمی‌گرداند.

پس از آن، دوباره یک دستور موقت println! اضافه می‌کنیم که مقدار contents را پس از خواندن فایل چاپ می‌کند تا مطمئن شویم برنامه تا اینجا کار می‌کند.

بیایید این کد را با هر رشته‌ای به عنوان اولین آرگومان خط فرمان (چون هنوز بخش جستجو را پیاده‌سازی نکرده‌ایم) و فایل poem.txt به عنوان آرگومان دوم اجرا کنیم:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

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

Refactoring to Improve Modularity and Error Handling

To improve our program, we’ll fix four problems that have to do with the program’s structure and how it’s handling potential errors. First, our main function now performs two tasks: it parses arguments and reads files. As our program grows, the number of separate tasks the main function handles will increase. As a function gains responsibilities, it becomes more difficult to reason about, harder to test, and harder to change without breaking one of its parts. It’s best to separate functionality so each function is responsible for one task.

This issue also ties into the second problem: although query and file_path are configuration variables to our program, variables like contents are used to perform the program’s logic. The longer main becomes, the more variables we’ll need to bring into scope; the more variables we have in scope, the harder it will be to keep track of the purpose of each. It’s best to group the configuration variables into one structure to make their purpose clear.

The third problem is that we’ve used expect to print an error message when reading the file fails, but the error message just prints Should have been able to read the file. Reading a file can fail in a number of ways: for example, the file could be missing, or we might not have permission to open it. Right now, regardless of the situation, we’d print the same error message for everything, which wouldn’t give the user any information!

Fourth, we use expect to handle an error, and if the user runs our program without specifying enough arguments, they’ll get an index out of bounds error from Rust that doesn’t clearly explain the problem. It would be best if all the error-handling code were in one place so future maintainers had only one place to consult the code if the error-handling logic needed to change. Having all the error-handling code in one place will also ensure that we’re printing messages that will be meaningful to our end users.

Let’s address these four problems by refactoring our project.

Separation of Concerns for Binary Projects

The organizational problem of allocating responsibility for multiple tasks to the main function is common to many binary projects. As a result, the Rust community has developed guidelines for splitting the separate concerns of a binary program when main starts getting large. This process has the following steps:

  • Split your program into a main.rs file and a lib.rs file and move your program’s logic to lib.rs.
  • As long as your command line parsing logic is small, it can remain in main.rs.
  • When the command line parsing logic starts getting complicated, extract it from main.rs and move it to lib.rs.

The responsibilities that remain in the main function after this process should be limited to the following:

  • Calling the command line parsing logic with the argument values
  • Setting up any other configuration
  • Calling a run function in lib.rs
  • Handling the error if run returns an error

This pattern is about separating concerns: main.rs handles running the program and lib.rs handles all the logic of the task at hand. Because you can’t test the main function directly, this structure lets you test all of your program’s logic by moving it into functions in lib.rs. The code that remains in main.rs will be small enough to verify its correctness by reading it. Let’s rework our program by following this process.

Extracting the Argument Parser

We’ll extract the functionality for parsing arguments into a function that main will call to prepare for moving the command line parsing logic to src/lib.rs. Listing 12-5 shows the new start of main that calls a new function parse_config, which we’ll define in src/main.rs for the moment.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}
Listing 12-5: استخراج تابع parse_config از main

ما همچنان آرگومان‌های خط فرمان را به یک بردار جمع‌آوری می‌کنیم، اما به جای اینکه مقدار آرگومان در اندیس (index)۱ را به متغیر query و مقدار آرگومان در اندیس (index)۲ را به متغیر file_path در تابع main اختصاص دهیم، کل بردار را به تابع parse_config ارسال می‌کنیم. تابع parse_config سپس منطق مشخص می‌کند که کدام آرگومان در کدام متغیر قرار می‌گیرد و مقادیر را به تابع main بازمی‌گرداند. ما همچنان متغیرهای query و file_path را در main ایجاد می‌کنیم، اما main دیگر مسئول تعیین ارتباط آرگومان‌های خط فرمان و متغیرها نیست.

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

گروه‌بندی مقادیر پیکربندی

می‌توانیم یک گام کوچک دیگر برای بهبود بیشتر تابع parse_config برداریم. در حال حاضر، ما یک tuple بازمی‌گردانیم، اما بلافاصله آن tuple را به قسمت‌های جداگانه تقسیم می‌کنیم. این نشانه‌ای است که شاید هنوز انتزاع درستی نداریم.

نشانه دیگری که نشان می‌دهد جا برای بهبود وجود دارد، قسمت config در parse_config است، که نشان می‌دهد دو مقداری که بازمی‌گردانیم به هم مرتبط هستند و هر دو بخشی از یک مقدار پیکربندی هستند. ما در حال حاضر این معنا را در ساختار داده‌ها به جز با گروه‌بندی دو مقدار در یک tuple منتقل نمی‌کنیم؛ در عوض، این دو مقدار را در یک struct قرار می‌دهیم و به هر یک از فیلدهای struct نامی معنادار می‌دهیم. انجام این کار درک نحوه ارتباط مقادیر مختلف و هدف آن‌ها را برای نگهداری‌کنندگان آینده این کد آسان‌تر می‌کند.

لیست ۱۲-۶ بهبودهای تابع parse_config را نشان می‌دهد.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}
Listing 12-6: بازسازی parse_config برای بازگرداندن یک نمونه از struct Config

ما یک ساختار جدید به نام Config تعریف کرده‌ایم که دارای فیلدهایی با نام‌های query و file_path است. امضای تابع parse_config اکنون نشان می‌دهد که این تابع یک مقدار Config را بازمی‌گرداند. در بدنه تابع parse_config، جایی که قبلاً اسلایس‌های رشته‌ای را که به مقادیر String در args اشاره می‌کردند بازمی‌گرداندیم، اکنون Config را طوری تعریف می‌کنیم که دارای مقادیر String متعلق به خود باشد.

متغیر args در تابع main مالک مقادیر آرگومان است و فقط به تابع parse_config اجازه قرض گرفتن آن‌ها را می‌دهد، به این معنی که اگر Config بخواهد مالک مقادیر در args شود، قوانین قرض‌گیری Rust را نقض می‌کنیم.

چندین روش برای مدیریت داده‌های String وجود دارد؛ ساده‌ترین و شاید ناکارآمدترین روش، فراخوانی متد clone روی مقادیر است. این کار یک کپی کامل از داده‌ها برای نمونه Config ایجاد می‌کند که مالک آن است. این روش زمان و حافظه بیشتری نسبت به ذخیره یک مرجع به داده‌ها نیاز دارد. با این حال، کپی کردن داده‌ها باعث می‌شود که کد ما بسیار ساده شود زیرا نیازی به مدیریت طول عمر مراجع نداریم؛ در این شرایط، از دست دادن کمی کارایی برای دستیابی به سادگی ارزشمند است.

هزینه‌ها و مزایای استفاده از clone

در بین بسیاری از برنامه‌نویسان Rust، تمایلی به استفاده از clone برای رفع مشکلات مالکیت به دلیل هزینه اجرای آن وجود دارد. در فصل ۱۳، یاد خواهید گرفت که چگونه در این نوع موقعیت‌ها از روش‌های کارآمدتر استفاده کنید. اما در حال حاضر، کپی کردن چند رشته برای ادامه پیشرفت اشکالی ندارد زیرا این کپی‌ها فقط یک‌بار انجام می‌شوند و مسیر فایل و رشته جستجوی شما بسیار کوچک هستند. بهتر است یک برنامه کارا که کمی ناکارآمد است داشته باشید تا اینکه در اولین تلاش خود برای نوشتن کد، بهینه‌سازی بیش از حد انجام دهید. با تجربه بیشتر در Rust، شروع با راه‌حل کارآمدتر آسان‌تر خواهد بود، اما در حال حاضر استفاده از clone کاملاً قابل قبول است.

ما تابع main را به‌روزرسانی کردیم تا نمونه‌ای از Config که توسط parse_config بازگردانده می‌شود را در یک متغیر به نام config قرار دهد، و کدی که قبلاً از متغیرهای جداگانه query و file_path استفاده می‌کرد، اکنون از فیلدهای موجود در struct Config استفاده می‌کند.

اکنون کد ما به‌وضوح نشان می‌دهد که query و file_path به هم مرتبط هستند و هدف آن‌ها تنظیم نحوه کار برنامه است. هر کدی که از این مقادیر استفاده می‌کند می‌داند که باید آن‌ها را در نمونه config در فیلدهایی که نام آن‌ها برای هدفشان انتخاب شده است، پیدا کند.

ایجاد سازنده برای Config

تا اینجا، منطق مسئول تجزیه آرگومان‌های خط فرمان را از main استخراج کرده و در تابع parse_config قرار داده‌ایم. این کار به ما کمک کرد ببینیم که مقادیر query و file_path به هم مرتبط هستند و این رابطه باید در کد ما منتقل شود. سپس یک struct به نام Config اضافه کردیم تا هدف مشترک query و file_path را نام‌گذاری کنیم و بتوانیم نام مقادیر را به‌عنوان فیلدهای struct از تابع parse_config بازگردانیم.

حالا که هدف تابع parse_config ایجاد یک نمونه از Config است، می‌توانیم parse_config را از یک تابع معمولی به یک تابع با نام new تغییر دهیم که به struct Config مرتبط است. این تغییر کد را به‌صورت idiomatic‌تر می‌کند. ما می‌توانیم نمونه‌هایی از انواع موجود در کتابخانه استاندارد، مانند String، را با فراخوانی String::new ایجاد کنیم. به همین ترتیب، با تغییر parse_config به تابع new مرتبط با Config، می‌توانیم نمونه‌هایی از Config را با فراخوانی Config::new ایجاد کنیم. لیست ۱۲-۷ تغییرات لازم را نشان می‌دهد.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-7: تغییر parse_config به Config::new

ما تابع main را که در آن parse_config را فراخوانی می‌کردیم به‌روزرسانی کرده‌ایم تا به‌جای آن Config::new را فراخوانی کند. نام parse_config را به new تغییر داده و آن را در یک بلوک impl قرار داده‌ایم که تابع new را به Config مرتبط می‌کند. کد را دوباره کامپایل کنید تا مطمئن شوید که کار می‌کند.

رفع مشکلات مدیریت خطا

حالا روی رفع مشکلات مدیریت خطا کار می‌کنیم. به خاطر بیاورید که تلاش برای دسترسی به مقادیر موجود در بردار args در اندیس (index)۱ یا ۲ باعث می‌شود برنامه در صورت داشتن کمتر از سه آیتم، دچار وحشت شود. برنامه را بدون هیچ آرگومانی اجرا کنید؛ این حالت به شکل زیر خواهد بود:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

خط index out of bounds: the len is 1 but the index is 1 یک پیام خطا است که برای برنامه‌نویسان در نظر گرفته شده است. این پیام به کاربران نهایی کمکی نمی‌کند تا بفهمند باید چه کار کنند. حالا این مشکل را رفع می‌کنیم.

بهبود پیام خطا

در لیست ۱۲-۸، یک بررسی در تابع new اضافه می‌کنیم که بررسی می‌کند آیا آرایه به‌اندازه کافی طولانی است تا بتوان به اندیس‌های ۱ و ۲ دسترسی داشت. اگر طول آرایه کافی نباشد، برنامه دچار وحشت می‌شود و یک پیام خطای بهتر نمایش می‌دهد.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-8: افزودن بررسی برای تعداد آرگومان‌ها

این کد شبیه به تابع Guess::new که در لیست ۹-۱۳ نوشتیم است، جایی که وقتی آرگومان value خارج از محدوده مقادیر معتبر بود، panic! فراخوانی کردیم. به جای بررسی محدوده مقادیر، در اینجا بررسی می‌کنیم که طول args حداقل برابر با 3 باشد و بقیه تابع می‌تواند با فرض اینکه این شرط برقرار شده است، عمل کند. اگر args کمتر از سه آیتم داشته باشد، این شرط true خواهد بود و ما ماکرو panic! را برای خاتمه برنامه بلافاصله فراخوانی می‌کنیم.

با این چند خط اضافی در new، بیایید دوباره برنامه را بدون هیچ آرگومانی اجرا کنیم تا ببینیم اکنون پیام خطا چگونه است:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

این خروجی بهتر است: اکنون یک پیام خطای منطقی داریم. با این حال، هنوز اطلاعات اضافی داریم که نمی‌خواهیم به کاربران خود ارائه دهیم. شاید تکنیکی که در لیست ۹-۱۳ استفاده کردیم بهترین گزینه برای اینجا نباشد: یک فراخوانی به panic! برای مشکل برنامه‌نویسی مناسب‌تر است تا یک مشکل استفاده، همان‌طور که در فصل ۹ بحث شد. در عوض، از تکنیک دیگری که در فصل ۹ یاد گرفتید استفاده می‌کنیم—بازگرداندن یک Result که نشان‌دهنده موفقیت یا خطا است.

بازگرداندن یک Result به جای فراخوانی panic!

ما می‌توانیم به جای آن، یک مقدار Result بازگردانیم که در صورت موفقیت شامل یک نمونه از Config باشد و در صورت خطا مشکل را توصیف کند. همچنین قصد داریم نام تابع را از new به build تغییر دهیم زیرا بسیاری از برنامه‌نویسان انتظار دارند که توابع new هرگز شکست نخورند. وقتی Config::build با main ارتباط برقرار می‌کند، می‌توانیم از نوع Result برای اعلام مشکل استفاده کنیم. سپس می‌توانیم main را تغییر دهیم تا یک واریانت Err را به یک پیام خطای عملی‌تر برای کاربران خود تبدیل کنیم، بدون متن‌های اضافی مربوط به thread 'main' و RUST_BACKTRACE که یک فراخوانی به panic! ایجاد می‌کند.

لیست ۱۲-۹ تغییراتی را که باید در مقدار بازگشتی تابع که اکنون آن را Config::build می‌نامیم و بدنه تابع برای بازگرداندن یک Result ایجاد کنیم، نشان می‌دهد. توجه داشته باشید که این کد تا زمانی که main را نیز به‌روزرسانی نکنیم کامپایل نمی‌شود، که این کار را در لیست بعدی انجام خواهیم داد.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-9: بازگرداندن یک Result از Config::build

تابع build و بازگشت مقدار Result

تابع build ما اکنون یک مقدار Result را بازمی‌گرداند که در صورت موفقیت شامل یک نمونه از Config و در صورت خطا یک مقدار رشته‌ای ثابت (string literal) است. مقادیر خطای ما همیشه رشته‌های ثابت با طول عمر 'static خواهند بود.

ما دو تغییر در بدنه تابع ایجاد کرده‌ایم: به جای فراخوانی panic! زمانی که کاربر آرگومان‌های کافی ارائه نمی‌دهد، اکنون یک مقدار Err بازمی‌گردانیم و مقدار بازگشتی Config را در یک Ok قرار داده‌ایم. این تغییرات باعث می‌شوند تابع با امضای نوع جدید خود سازگار باشد.

بازگرداندن مقدار Err از Config::build به تابع main اجازه می‌دهد که مقدار Result بازگشتی از تابع build را مدیریت کرده و در صورت بروز خطا، فرآیند را به شکلی تمیزتر خاتمه دهد.

فراخوانی Config::build و مدیریت خطاها

برای مدیریت حالت خطا و چاپ یک پیام دوستانه برای کاربر، باید تابع main را به‌روزرسانی کنیم تا مقدار Result بازگردانده‌شده توسط Config::build را مدیریت کند. این کار در لیست ۱۲-۱۰ نشان داده شده است. همچنین مسئولیت خاتمه دادن ابزار خط فرمان با کد خطای غیر صفر را از panic! گرفته و به صورت دستی پیاده‌سازی خواهیم کرد. کد خروجی غیر صفر به عنوان یک قرارداد برای اعلام وضعیت خطا به فرآیندی که برنامه ما را فراخوانده است، استفاده می‌شود.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-10: خروج با کد خطا در صورت شکست در ساخت یک Config

در این لیستینگ، ما از متدی استفاده کرده‌ایم که هنوز جزئیات آن را به‌طور کامل پوشش نداده‌ایم: unwrap_or_else. این متد که در استاندارد کتابخانه Rust برای Result<T, E> تعریف شده است، به ما امکان می‌دهد مدیریت خطاهای سفارشی و بدون استفاده از panic! را تعریف کنیم. اگر مقدار Result از نوع Ok باشد، رفتار این متد مشابه unwrap است: مقدار داخلی که Ok در خود قرار داده را بازمی‌گرداند. با این حال، اگر مقدار از نوع Err باشد، این متد کدی را که در closure تعریف کرده‌ایم اجرا می‌کند. Closure یک تابع ناشناس است که آن را تعریف کرده و به‌عنوان آرگومان به unwrap_or_else ارسال می‌کنیم.

ما closures را به تفصیل در فصل ۱۳ توضیح خواهیم داد. فعلاً کافی است بدانید که unwrap_or_else مقدار داخلی Err را به closure می‌دهد. در اینجا، مقدار استاتیک "not enough arguments" که در لیستینگ 12-9 اضافه کردیم، به closure ارسال شده و به آرگومان err تخصیص داده می‌شود، که بین خط عمودی‌ها قرار دارد. کد درون closure سپس می‌تواند از مقدار err استفاده کند.

ما همچنین یک خط جدید use اضافه کرده‌ایم تا process را از کتابخانه استاندارد به محدوده بیاوریم. کدی که در حالت خطا اجرا می‌شود تنها شامل دو خط است: ابتدا مقدار err را چاپ می‌کنیم و سپس process::exit را فراخوانی می‌کنیم. تابع process::exit بلافاصله برنامه را متوقف کرده و عددی که به‌عنوان کد وضعیت خروج ارسال شده است را بازمی‌گرداند. این روش شبیه مدیریت مبتنی بر panic! است که در لیستینگ 12-8 استفاده کردیم، اما دیگر خروجی اضافی تولید نمی‌شود. حالا آن را آزمایش کنیم:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

عالی! این خروجی برای کاربران ما بسیار دوستانه‌تر است.

Extracting Logic from main

Now that we’ve finished refactoring the configuration parsing, let’s turn to the program’s logic. As we stated in “Separation of Concerns for Binary Projects”, we’ll extract a function named run that will hold all the logic currently in the main function that isn’t involved with setting up configuration or handling errors. When we’re done, main will be concise and easy to verify by inspection, and we’ll be able to write tests for all the other logic.

Listing 12-11 shows the extracted run function. For now, we’re just making the small, incremental improvement of extracting the function. We’re still defining the function in src/main.rs.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-11: استخراج تابع run از main

با این تغییرات، main اکنون تابع run را فراخوانی می‌کند و مسئولیت اجرای منطق اصلی برنامه را به آن واگذار می‌کند. این جداسازی باعث می‌شود تابع main ساده‌تر شود و ما بتوانیم تست‌های دقیقی برای بخش‌های مختلف کد بنویسیم. این روش به بهبود قابلیت نگهداری و خوانایی کد کمک شایانی می‌کند.

بازگرداندن خطاها از تابع run

اکنون که منطق باقی‌مانده برنامه را در تابع run جدا کرده‌ایم، می‌توانیم مانند Config::build در لیستینگ 12-9، مدیریت خطا را بهبود بخشیم. به جای اجازه دادن به برنامه برای اجرای panic با فراخوانی expect، تابع run در صورت بروز مشکل یک Result<T, E> بازمی‌گرداند. این رویکرد به ما امکان می‌دهد منطق مرتبط با مدیریت خطا را به صورت کاربرپسندانه‌ای در تابع main متمرکز کنیم. تغییرات لازم برای امضا و بدنه تابع run در لیستینگ 12-12 نشان داده شده است:

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-12: تغییر تابع run برای بازگرداندن Result

تغییرات مهم

  • تغییر نوع بازگشتی: نوع بازگشتی تابع run به Result<(), Box<dyn Error>> تغییر داده شده است. این تابع قبلاً نوع واحد (()) را بازمی‌گرداند، که همچنان برای حالت موفقیت حفظ شده است. برای نوع خطا از یک شیء صفات به نام Box<dyn Error> استفاده کرده‌ایم (و با استفاده از use، std::error::Error را به محدوده آورده‌ایم). در فصل 18 بیشتر درباره شیء صفات صحبت خواهیم کرد. فعلاً کافی است بدانید که Box<dyn Error> به این معنا است که تابع می‌تواند نوعی از مقدار را که صفت Error را پیاده‌سازی کرده بازگرداند، بدون اینکه نوع خاصی را مشخص کند. کلمه کلیدی dyn به معنای دینامیک است.
  • حذف expect و استفاده از عملگر ?: به جای استفاده از panic! در صورت بروز خطا، عملگر ? مقدار خطا را از تابع جاری بازمی‌گرداند تا فراخوانی‌کننده بتواند آن را مدیریت کند.
  • بازگرداندن مقدار Ok در حالت موفقیت: تابع run اکنون در حالت موفقیت مقدار Ok را بازمی‌گرداند. ما نوع موفقیت تابع را به عنوان () در امضا تعریف کرده‌ایم، که به این معنا است که باید مقدار نوع واحد را در مقدار Ok قرار دهیم. نحو Ok(()) ممکن است در ابتدا کمی عجیب به نظر برسد، اما استفاده از () به این صورت روش استاندارد برای نشان دادن این است که تابع run تنها برای تأثیرات جانبی فراخوانی شده و مقداری بازنمی‌گرداند که به آن نیاز داشته باشیم.
```

بررسی کد

اجرای این کد باعث می‌شود که کد کامپایل شود اما یک هشدار نمایش دهد:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust به ما یادآوری می‌کند که کد ما مقدار Result را نادیده گرفته است و این مقدار ممکن است نشان‌دهنده بروز خطا باشد. اما ما بررسی نمی‌کنیم که آیا خطایی رخ داده است یا خیر، و کامپایلر به ما یادآوری می‌کند که احتمالاً نیاز به مدیریت خطا در این بخش داریم. اکنون این مشکل را اصلاح خواهیم کرد.

مدیریت خطاهای بازگردانده‌شده از run در main

ما خطاها را بررسی کرده و با استفاده از تکنیکی مشابه آنچه در Config::build در لیست ۱۲-۱۰ استفاده کردیم مدیریت می‌کنیم، اما با یک تفاوت کوچک:

Filename: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

ما به جای unwrap_or_else از if let استفاده می‌کنیم تا بررسی کنیم آیا run یک مقدار Err بازمی‌گرداند یا خیر و در صورت وقوع، process::exit(1) را فراخوانی کنیم. تابع run مقداری بازنمی‌گرداند که بخواهیم به همان شیوه‌ای که Config::build نمونه Config را بازمی‌گرداند آن را unwrap کنیم. از آنجایی که run در صورت موفقیت مقدار () بازمی‌گرداند، ما فقط به شناسایی یک خطا اهمیت می‌دهیم، بنابراین نیازی به unwrap_or_else برای بازگرداندن مقدار آن نداریم، که تنها () خواهد بود.

بدنه‌های if let و unwrap_or_else در هر دو حالت یکسان هستند: ما خطا را چاپ کرده و خارج می‌شویم.

Splitting Code into a Library Crate

Our minigrep project is looking good so far! Now we’ll split the src/main.rs file and put some code into the src/lib.rs file. That way, we can test the code and have a src/main.rs file with fewer responsibilities.

Let’s move all the code that isn’t in the main function from src/main.rs to src/lib.rs:

  • The run function definition
  • The relevant use statements
  • The definition of Config
  • The Config::build function definition

The contents of src/lib.rs should have the signatures shown in Listing 12-13 (we’ve omitted the bodies of the functions for brevity). Note that this won’t compile until we modify src/main.rs in Listing 12-14.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Listing 12-13: Moving Config and run into src/lib.rs

We’ve made liberal use of the pub keyword: on Config, on its fields and its build method, and on the run function. We now have a library crate that has a public API we can test!

Now we need to bring the code we moved to src/lib.rs into the scope of the binary crate in src/main.rs, as shown in Listing 12-14.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

// --snip--
use minigrep::search;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

// --snip--


struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}
Listing 12-14: Using the minigrep library crate in src/main.rs

We add a use minigrep::Config line to bring the Config type from the library crate into the binary crate’s scope, and we prefix the run function with our crate name. Now all the functionality should be connected and should work. Run the program with cargo run and make sure everything works correctly.

وای! این یک کار سخت بود، اما ما خودمان را برای موفقیت در آینده آماده کردیم. اکنون مدیریت خطاها بسیار آسان‌تر شده است و کد ما ماژولارتر شده است. از اینجا به بعد تقریباً تمام کارهای ما در فایل src/lib.rs انجام خواهد شد.

بیایید از این ماژولاریت جدید برای انجام کاری استفاده کنیم که با کد قبلی دشوار بود اما با کد جدید آسان است: نوشتن چند تست!

توسعه قابلیت‌های کتابخانه با توسعه آزمون‌محور (TDD) یا همان (Test-Driven Development)

اکنون که منطق جست‌وجو را در فایل src/lib.rs و جدا از تابع main داریم، نوشتن تست برای عملکرد اصلی کد بسیار آسان‌تر شده است. می‌توانیم توابع را مستقیماً با آرگومان‌های مختلف فراخوانی کنیم و مقادیر بازگشتی را بررسی کنیم، بدون آن‌که نیاز باشد باینری خود را از طریق خط فرمان اجرا کنیم.

در این بخش، منطق جستجو را با استفاده از فرآیند توسعه آزمون‌محور (TDD) به برنامه minigrep اضافه خواهیم کرد. مراحل این فرآیند به شرح زیر است:

  1. نوشتن یک تست که شکست می‌خورد و اجرای آن برای اطمینان از اینکه به دلیلی که انتظار داشتید شکست می‌خورد.
  2. نوشتن یا تغییر کد به اندازه‌ای که تست جدید پاس شود.
  3. بازسازی کدی که به تازگی اضافه یا تغییر داده شده و اطمینان از اینکه تست‌ها همچنان پاس می‌شوند.
  4. تکرار از مرحله ۱!

TDD تنها یکی از روش‌های نوشتن نرم‌افزار است، اما می‌تواند به طراحی بهتر کد کمک کند. نوشتن تست قبل از نوشتن کدی که تست را پاس می‌کند، کمک می‌کند تا پوشش تست بالا در طول فرآیند حفظ شود.

ما با استفاده از TDD پیاده‌سازی قابلیت جستجوی رشته کوئری در محتوای فایل و تولید لیستی از خطوط مطابق با کوئری را توسعه خواهیم داد. این قابلیت را در تابعی به نام search اضافه خواهیم کرد.

نوشتن یک تست که شکست می‌خورد

در فایل src/lib.rs، یک ماژول tests با یک تابع تست اضافه می‌کنیم، همان‌طور که در [فصل ۱۱][ch11-anatomy] انجام دادیم. تابع تست، رفتاری را که از تابع search انتظار داریم مشخص می‌کند: این تابع یک query و متنی برای جست‌وجو دریافت می‌کند، و تنها خطوطی از متن را که شامل query هستند بازمی‌گرداند. لیست ۱۲-۱۵ این تست را نشان می‌دهد.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --snip--

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-15: ایجاد یک تست شکست‌خورده برای تابع search به منظور پیاده‌سازی عملکرد مورد انتظار

این تست به دنبال رشته "duct" می‌گردد. متنی که در آن جستجو می‌کنیم شامل سه خط است که تنها یکی از آن‌ها شامل "duct" است (توجه داشته باشید که بک‌اسلش بعد از علامت نقل قول بازکننده به Rust می‌گوید که کاراکتر newline در ابتدای محتویات این literal رشته قرار ندهد). ما تأیید می‌کنیم که مقدار بازگردانده شده از تابع search تنها شامل خطی است که انتظار داریم.

هنوز قادر به اجرای این تست و مشاهده شکست آن نیستیم زیرا تست حتی کامپایل نمی‌شود: تابع search هنوز وجود ندارد! بر اساس اصول TDD، ما تنها به اندازه‌ای کد اضافه می‌کنیم که تست کامپایل و اجرا شود، با اضافه کردن یک تعریف از تابع search که همیشه یک بردار خالی بازمی‌گرداند، همانطور که در لیست ۱۲-۱۶ نشان داده شده است. سپس تست باید کامپایل و شکست بخورد زیرا یک بردار خالی با یک بردار شامل خط "safe, fast, productive." مطابقت ندارد.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-16: تعریف حداقل کد برای تابع search تا تست ما کامپایل شود

حال بیایید بررسی کنیم که چرا نیاز داریم یک lifetime صریح با نام 'a در امضای تابع search تعریف کنیم و این lifetime را با آرگومان contents و مقدار بازگشتی استفاده کنیم. به یاد بیاورید که در فصل ۱۰، پارامترهای lifetime مشخص می‌کردند که lifetime کدام آرگومان با lifetime مقدار بازگشتی مرتبط است. در این‌جا، مشخص می‌کنیم که بردار بازگشتی باید شامل برش‌هایی از رشته باشد که به بخش‌هایی از آرگومان contents رفرنس می‌دهند (نه آرگومان query).

متوجه می‌شوید که ما نیاز داریم یک طول عمر صریح 'a در امضای تابع search تعریف کنیم و از آن طول عمر با آرگومان contents و مقدار بازگشتی استفاده کنیم. به یاد داشته باشید که در فصل ۱۰ توضیح دادیم که پارامترهای طول عمر مشخص می‌کنند کدام طول عمر آرگومان به طول عمر مقدار بازگشتی متصل است. در این مورد، ما مشخص می‌کنیم که بردار بازگشتی باید شامل برش‌های رشته‌ای باشد که به برش‌های آرگومان contents اشاره دارند (نه آرگومان query).

به عبارت دیگر، به Rust می‌گوییم داده‌ای که توسط تابع search بازگردانده می‌شود به اندازه داده‌ای که به تابع search در آرگومان contents منتقل می‌شود زنده خواهد بود. این مهم است! داده‌ای که توسط یک برش مرجع داده می‌شود باید معتبر باشد تا مرجع نیز معتبر باشد؛ اگر کامپایلر فرض کند که ما در حال ساختن برش‌های رشته‌ای از query هستیم به جای contents، بررسی‌های ایمنی را به اشتباه انجام خواهد داد.

اگر طول عمرها را فراموش کنیم و سعی کنیم این تابع را کامپایل کنیم، این خطا را دریافت خواهیم کرد:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:51
  |
1 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
  |                      ----            ----         ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
  |
1 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
  |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

کامپایلر Rust نمی‌تواند به‌صورت خودکار تشخیص دهد که کدام‌یک از دو پارامتر باید به مقدار بازگشتی مرتبط باشد، بنابراین باید این موضوع را به‌صراحت به آن اعلام کنیم.
توجه داشته باشید که متن راهنمای خطا پیشنهاد می‌دهد که برای همه پارامترها و نوع بازگشتی، از یک پارامتر lifetime مشترک استفاده شود، که این پیشنهاد نادرست است!
از آن‌جا که contents پارامتری است که تمام متن ما را در بر دارد و ما قصد داریم بخش‌هایی از آن متن را که با جستجو مطابقت دارند بازگردانیم، می‌دانیم که فقط contents باید با مقدار بازگشتی از طریق نگارش lifetime مرتبط شود.

دیگر زبان‌های برنامه‌نویسی نیازی ندارند آرگومان‌ها را به مقادیر بازگشتی در امضا متصل کنید، اما این تمرین با گذشت زمان آسان‌تر می‌شود. ممکن است بخواهید این مثال را با مثال‌های موجود در بخش “اعتبارسنجی مراجع با طول عمر” از فصل ۱۰ مقایسه کنید.

نوشتن کدی برای پاس کردن تست

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

  1. تکرار از طریق هر خط از محتوای فایل.
  2. بررسی اینکه آیا خط شامل رشته کوئری ما هست یا نه.
  3. اگر خط شامل کوئری بود، آن را به لیست مقادیر بازگشتی اضافه کنیم.
  4. اگر نبود، کاری انجام ندهیم.
  5. لیست نتایجی که مطابقت دارند را بازگردانیم.

بیایید هر مرحله را یکی‌یکی اجرا کنیم، با تکرار از طریق خطوط شروع می‌کنیم.

تکرار از طریق خطوط با متد lines

Rust یک متد مفید برای مدیریت تکرار خط به خط در رشته‌ها ارائه می‌دهد که به طور مناسبی lines نامیده شده است و همانطور که در لیست ۱۲-۱۷ نشان داده شده کار می‌کند. توجه داشته باشید که این کد هنوز کامپایل نخواهد شد.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-17: تکرار از طریق هر خط در contents

متد lines یک iterator برمی‌گرداند. ما در فصل ۱۳ عمیقاً در مورد iteratorها صحبت خواهیم کرد، اما به یاد داشته باشید که قبلاً این روش استفاده از یک iterator را در لیست ۳-۵ دیدید، جایی که از یک حلقه for با یک iterator برای اجرای کدی روی هر آیتم در یک مجموعه استفاده کردیم.

جستجو در هر خط برای کوئری

اکنون، بررسی خواهیم کرد که آیا خط فعلی شامل رشته کوئری ما هست یا نه. خوشبختانه، رشته‌ها یک متد مفید به نام contains دارند که این کار را برای ما انجام می‌دهد! یک فراخوانی به متد contains را در تابع search اضافه کنید، همانطور که در لیست ۱۲-۱۸ نشان داده شده است. توجه داشته باشید که این کد همچنان کامپایل نخواهد شد.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-18: اضافه کردن قابلیت بررسی اینکه آیا خط شامل رشته موجود در query هست یا نه

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

ذخیره خطوط مطابق

برای تکمیل این تابع، نیاز داریم روشی برای ذخیره خطوط مطابق که می‌خواهیم بازگردانیم داشته باشیم. برای این کار، می‌توانیم یک بردار mutable قبل از حلقه for ایجاد کنیم و با استفاده از متد push یک خط را در بردار ذخیره کنیم. بعد از حلقه for، بردار را بازمی‌گردانیم، همانطور که در لیست ۱۲-۱۹ نشان داده شده است.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-19: ذخیره خطوط مطابق برای بازگرداندن آن‌ها

اکنون تابع search باید فقط خطوطی را که شامل query هستند بازگرداند، و تست ما باید پاس شود. بیایید تست را اجرا کنیم:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

تست ما پاس شد، بنابراین می‌دانیم که کار می‌کند!

در این مرحله، می‌توانیم فرصت‌هایی برای بازسازی پیاده‌سازی تابع جستجو در نظر بگیریم و در عین حال تست‌ها را پاس نگه داریم تا همان قابلیت را حفظ کنیم. کد در تابع جستجو چندان بد نیست، اما از برخی ویژگی‌های مفید iteratorها استفاده نمی‌کند. ما در فصل ۱۳ به این مثال بازخواهیم گشت، جایی که iteratorها را با جزئیات بررسی می‌کنیم و به نحوه بهبود آن می‌پردازیم.

استفاده از تابع search در تابع run

اکنون که تابع search کار می‌کند و تست شده است، باید تابع search را از تابع run فراخوانی کنیم. ما باید مقدار config.query و contents که run از فایل می‌خواند را به تابع search بدهیم. سپس run هر خطی که از search برگردانده شده را چاپ خواهد کرد:

Filename: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

ما هنوز از یک حلقه for برای بازگرداندن هر خط از search و چاپ آن استفاده می‌کنیم.

اکنون باید کل برنامه به‌درستی کار کند! بیایید آن را امتحان کنیم، ابتدا با واژه‌ای مانند frog که باید دقیقاً یک خط از شعر امیلی دیکینسون را بازگرداند.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

عالی! حالا بیایید کلمه‌ای را امتحان کنیم که چندین خط را مطابقت دهد، مثل body:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

و در نهایت، مطمئن شویم که وقتی کلمه‌ای را جستجو می‌کنیم که در هیچ جای شعر وجود ندارد، مثل monomorphization، هیچ خطی دریافت نخواهیم کرد:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

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

برای تکمیل این پروژه، به طور مختصر نشان خواهیم داد که چگونه با متغیرهای محیطی کار کنیم و چگونه به خطای استاندارد (standard error) چاپ کنیم، که هر دو در هنگام نوشتن برنامه‌های خط فرمان مفید هستند.

کار با متغیرهای محیطی

ما باینری minigrep را با افزودن یک ویژگی اضافی بهبود می‌دهیم: گزینه‌ای برای جستجوی حساس‌نبودن به حروف کوچک و بزرگ که کاربر می‌تواند از طریق یک متغیر محیطی آن را فعال کند. می‌توانستیم این ویژگی را به‌صورت گزینه‌ای در خط فرمان پیاده‌سازی کنیم و از کاربران بخواهیم که هر بار آن را وارد کنند، اما با استفاده از یک متغیر محیطی، به کاربران اجازه می‌دهیم که فقط یک‌بار این متغیر را تنظیم کنند و در کل آن نشست ترمینال، تمام جستجوهای آن‌ها به‌صورت حساس‌نبودن به حروف انجام شود.

نوشتن یک تست شکست‌خورده برای تابع search_case_insensitive

ابتدا تابع جدیدی به نام search_case_insensitive به کتابخانه minigrep اضافه می‌کنیم که زمانی فراخوانی می‌شود که متغیر محیطی مقدار داشته باشد. ما همچنان از فرایند توسعه آزمون‌محور (TDD) پیروی می‌کنیم، بنابراین گام اول، نوشتن یک تست شکست‌خورده است. یک تست جدید برای تابع search_case_insensitive اضافه می‌کنیم و نام تست قبلی‌مان را از one_result به case_sensitive تغییر می‌دهیم تا تفاوت بین این دو تست واضح‌تر شود، همان‌طور که در لیست 12-20 نشان داده شده است.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-20: افزودن یک تست شکست‌خورده جدید برای تابع غیرحساس به حروف کوچک و بزرگ که قصد داریم اضافه کنیم

توجه کنید که ما متن تست قدیمی را نیز ویرایش کرده‌ایم. ما یک خط جدید با متن "Duct tape." با حرف بزرگ D اضافه کرده‌ایم که نباید با عبارت جستجو "duct" در حالت حساس به حروف کوچک و بزرگ مطابقت داشته باشد. تغییر دادن تست قدیمی به این صورت کمک می‌کند که مطمئن شویم عملکرد جستجوی حساس به حروف کوچک و بزرگ که قبلاً پیاده‌سازی کرده‌ایم به طور تصادفی شکسته نمی‌شود. این تست باید اکنون عبور کند و همچنان باید عبور کند در حالی که ما روی جستجوی غیرحساس به حروف کار می‌کنیم.

تست جدید برای جستجوی غیرحساس به حروف کوچک و بزرگ از "rUsT" به عنوان عبارت جستجو استفاده می‌کند. در تابع search_case_insensitive که قصد داریم اضافه کنیم، عبارت جستجوی "rUsT" باید با خط حاوی "Rust:" با حرف بزرگ R و خط "Trust me." مطابقت داشته باشد، حتی اگر هر دو حالت متفاوتی نسبت به عبارت جستجو داشته باشند. این تست شکست‌خورده ما است و به دلیل اینکه هنوز تابع search_case_insensitive تعریف نشده است، کامپایل نخواهد شد. می‌توانید یک پیاده‌سازی موقتی که همیشه یک وکتور خالی برمی‌گرداند اضافه کنید، مشابه کاری که برای تابع search در لیستینگ 12-16 انجام دادیم تا تست کامپایل شده و شکست بخورد.

پیاده‌سازی تابع search_case_insensitive

تابع search_case_insensitive که در لیستینگ 12-21 نشان داده شده است، تقریباً مشابه تابع search خواهد بود. تنها تفاوت این است که ما عبارت جستجو و هر خط را کوچک‌حرف می‌کنیم تا صرف‌نظر از مورد ورودی‌ها، هنگام بررسی اینکه آیا خط شامل عبارت جستجو است، هر دو به یک مورد تبدیل شوند.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-21: تعریف تابع search_case_insensitive برای کوچک‌حرف کردن عبارت جستجو و خط قبل از مقایسه آنها

ابتدا رشته query را به حروف کوچک تبدیل می‌کنیم و آن را در متغیر جدیدی با همان نام ذخیره می‌کنیم و مقدار اصلی query را شَدو (shadow) می‌کنیم. فراخوانی to_lowercase بر روی query ضروری است تا صرف‌نظر از این‌که کاربر عبارت مورد جستجوی خود را به صورت "rust"، "RUST"، "Rust" یا "rUsT" وارد کند، ما با آن گویی که "rust" وارد شده است برخورد کنیم و نسبت به حروف کوچک و بزرگ حساس نباشیم. هرچند to_lowercase نگاشت پایه‌ی Unicode را انجام می‌دهد، اما صد درصد دقیق نخواهد بود. اگر قصد نوشتن یک برنامه واقعی را داشتیم، نیاز به کار بیشتری در این بخش بود، اما از آن‌جا که این بخش در مورد متغیرهای محیطی است، نه Unicode، در همین حد باقی می‌مانیم.

توجه کنید که اکنون query یک رشته (String) به جای برش رشته (string slice) است، زیرا فراخوانی to_lowercase داده‌های جدید ایجاد می‌کند به جای اینکه به داده‌های موجود اشاره کند. به عنوان مثال، بگویید عبارت جستجو "rUsT" است: آن رشته شامل یک u یا t کوچک نیست که بتوانیم استفاده کنیم، بنابراین باید یک String جدید شامل "rust" تخصیص دهیم. وقتی اکنون query را به عنوان یک آرگومان به متد contains منتقل می‌کنیم، نیاز داریم که یک علامت & اضافه کنیم چون امضای contains به گونه‌ای تعریف شده است که یک برش رشته دریافت می‌کند.

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

بیایید ببینیم آیا این پیاده‌سازی تست‌ها را پاس می‌کند یا خیر:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

عالی! تست‌ها پاس شدند. حالا بیایید تابع جدید search_case_insensitive را از تابع run فراخوانی کنیم. ابتدا یک گزینه پیکربندی به ساختار Config اضافه می‌کنیم تا بین جستجوی حساس به حروف کوچک و بزرگ و غیرحساس به حروف کوچک و بزرگ سوئیچ کنیم. افزودن این فیلد باعث ایجاد خطاهای کامپایل می‌شود زیرا هنوز این فیلد را در هیچ جا مقداردهی نکرده‌ایم:

Filename: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

We added the ignore_case field that holds a Boolean. Next, we need the run function to check the ignore_case field’s value and use that to decide whether to call the search function or the search_case_insensitive function, as shown in Listing 12-22. This still won’t compile yet.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 12-22: Calling either search or search_case_insensitive based on the value in config.ignore_case

در نهایت، باید بررسی کنیم که آیا متغیر محیطی تنظیم شده است یا خیر. توابع مربوط به کار با متغیرهای محیطی در ماژول env از کتابخانه استاندارد قرار دارند که در بالای فایل src/main.rs از قبل در scope قرار دارد. برای بررسی اینکه آیا متغیر محیطی‌ای به نام IGNORE_CASE مقداری دارد یا نه، از تابع var در ماژول env استفاده می‌کنیم، همان‌طور که در لیست 12-23 نشان داده شده است.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 12-23: بررسی وجود مقدار در متغیر محیطی با نام IGNORE_CASE

اینجا یک متغیر جدید به نام ignore_case ایجاد می‌کنیم. برای مقداردهی آن، تابع env::var را فراخوانی کرده و نام متغیر محیطی IGNORE_CASE را به آن می‌دهیم. تابع env::var یک Result برمی‌گرداند که در صورت تنظیم بودن متغیر محیطی به هر مقداری، مقدار Ok با مقدار متغیر محیطی را دارد. اگر متغیر محیطی تنظیم نشده باشد، مقدار Err برگردانده می‌شود.

ما از متد is_ok روی Result استفاده می‌کنیم تا بررسی کنیم که آیا متغیر محیطی تنظیم شده است، که نشان می‌دهد برنامه باید جستجو را به صورت غیرحساس به حروف کوچک و بزرگ انجام دهد. اگر متغیر محیطی IGNORE_CASE به هیچ مقداری تنظیم نشده باشد، is_ok مقدار false برمی‌گرداند و برنامه جستجو را به صورت حساس به حروف کوچک و بزرگ انجام می‌دهد. ما به مقدار متغیر محیطی نیازی نداریم، فقط می‌خواهیم بررسی کنیم که آیا تنظیم شده است یا نه. بنابراین از is_ok به جای متدهایی مانند unwrap، expect یا دیگر متدهای مرتبط با Result استفاده می‌کنیم.

ما مقدار متغیر ignore_case را به نمونه Config منتقل می‌کنیم تا تابع run بتواند این مقدار را بخواند و تصمیم بگیرد که آیا باید تابع search_case_insensitive یا search را فراخوانی کند.

امتحان کردن برنامه

حالا بیایید برنامه را امتحان کنیم! ابتدا برنامه را بدون تنظیم متغیر محیطی و با عبارت جستجوی to اجرا می‌کنیم. این عبارت باید با هر خطی که شامل کلمه to به صورت تمام حروف کوچک باشد، مطابقت داشته باشد:

$ cargo run -- to poem.txt

برنامه همچنان باید به درستی کار کند و تنها خطوطی که کاملاً با عبارت مطابقت دارند را برگرداند. حالا برنامه را با متغیر محیطی IGNORE_CASE که به مقدار 1 تنظیم شده است اجرا می‌کنیم و همان عبارت جستجو to را امتحان می‌کنیم:

$ IGNORE_CASE=1 cargo run -- to poem.txt

در صورت استفاده از PowerShell، نیاز است که متغیر محیطی را تنظیم کنید و سپس برنامه را به صورت دستورات جداگانه اجرا کنید:

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

این دستور باعث می‌شود که IGNORE_CASE برای مدت زمان نشست ترمینال شما تنظیم باقی بماند. می‌توانید آن را با دستور Remove-Item حذف کنید:

PS> Remove-Item Env:IGNORE_CASE

برنامه باید خطوطی که شامل to هستند و ممکن است حروف بزرگ داشته باشند را برگرداند:

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

عالی! حالا برنامه minigrep ما می‌تواند جستجوهای غیرحساس به حروف کوچک و بزرگ را انجام دهد که با یک متغیر محیطی کنترل می‌شود. حالا شما می‌دانید چگونه گزینه‌هایی را که از طریق آرگومان‌های خط فرمان یا متغیرهای محیطی تنظیم می‌شوند مدیریت کنید.

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

ماژول std::env ویژگی‌های مفید بسیاری برای کار با متغیرهای محیطی دارد: مستندات آن را بررسی کنید تا ببینید چه امکاناتی در دسترس است.

نوشتن پیام‌های خطا به خروجی خطای استاندارد به جای خروجی استاندارد

در حال حاضر، ما تمام خروجی‌های خود را با استفاده از ماکروی println! به ترمینال می‌نویسیم. در بیشتر ترمینال‌ها، دو نوع خروجی وجود دارد: خروجی استاندارد (stdout) برای اطلاعات عمومی و خروجی خطای استاندارد (stderr) برای پیام‌های خطا. این تمایز به کاربران امکان می‌دهد که خروجی موفقیت‌آمیز یک برنامه را به یک فایل هدایت کنند اما همچنان پیام‌های خطا را روی صفحه ببینند.

ماکروی println! فقط قادر به نوشتن در خروجی استاندارد است، بنابراین برای نوشتن به خروجی خطای استاندارد باید از چیزی دیگر استفاده کنیم.

بررسی محل نوشتن خطاها

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

برنامه‌های خط فرمان انتظار می‌رود که پیام‌های خطای خود را به جریان خروجی خطای استاندارد ارسال کنند تا در صورت هدایت جریان خروجی استاندارد به یک فایل، پیام‌های خطا همچنان روی صفحه نمایش داده شوند. برنامه ما در حال حاضر به درستی عمل نمی‌کند: ما به زودی خواهیم دید که پیام خطا به جای صفحه نمایش به فایل ذخیره می‌شود!

برای نشان دادن این رفتار، برنامه را با استفاده از دستور > و مسیر فایل output.txt که می‌خواهیم جریان خروجی استاندارد را به آن هدایت کنیم، اجرا می‌کنیم. هیچ آرگومانی ارائه نخواهیم کرد، که باید منجر به یک خطا شود:

$ cargo run > output.txt

دستور > به شل می‌گوید که محتوای جریان خروجی استاندارد را به output.txt بنویسد به جای اینکه آن را روی صفحه نمایش دهد. ما پیام خطایی که انتظار داشتیم روی صفحه ببینیم را ندیدیم، بنابراین به این معنی است که باید در فایل ذخیره شده باشد. این همان چیزی است که output.txt شامل می‌شود:

Problem parsing arguments: not enough arguments

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

نوشتن خطاها به خروجی خطای استاندارد

ما از کدی که در لیستینگ 12-24 نشان داده شده است برای تغییر نحوه چاپ پیام‌های خطا استفاده می‌کنیم. به دلیل بازسازی‌ای که قبلاً در این فصل انجام دادیم، تمام کدی که پیام‌های خطا را چاپ می‌کند در یک تابع به نام main قرار دارد. کتابخانه استاندارد ماکروی eprintln! را ارائه می‌دهد که به جریان خروجی خطای استاندارد چاپ می‌کند، بنابراین دو جایی که ما println! را برای چاپ خطاها فراخوانی کرده‌ایم را به eprintln! تغییر می‌دهیم.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 12-24: نوشتن پیام‌های خطا به خروجی خطای استاندارد به جای خروجی استاندارد با استفاده از eprintln!

حالا برنامه را دوباره اجرا می‌کنیم به همان روش، بدون هیچ آرگومانی و با هدایت خروجی استاندارد با استفاده از >:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

حالا خطا را روی صفحه می‌بینیم و output.txt خالی است، که همان رفتاری است که از برنامه‌های خط فرمان انتظار داریم.

برنامه را دوباره اجرا می‌کنیم با آرگومان‌هایی که خطایی ایجاد نمی‌کنند اما همچنان خروجی استاندارد را به یک فایل هدایت می‌کنند، مانند این:

$ cargo run -- to poem.txt > output.txt

هیچ خروجی روی ترمینال نخواهیم دید و output.txt شامل نتایج ما خواهد بود:

Filename: output.txt

Are you nobody, too?
How dreary to be somebody!

این نشان می‌دهد که اکنون از خروجی استاندارد برای خروجی‌های موفقیت‌آمیز و از خروجی خطای استاندارد برای خروجی‌های خطا استفاده می‌کنیم، همان‌طور که مناسب است.

خلاصه

این فصل به طور خلاصه برخی از مفاهیم اصلی که تاکنون آموخته‌اید را مرور کرد و توضیح داد که چگونه عملیات ورودی/خروجی معمول را در Rust انجام دهید. با استفاده از آرگومان‌های خط فرمان، فایل‌ها، متغیرهای محیطی و ماکروی eprintln! برای چاپ خطاها، شما اکنون آماده‌اید تا برنامه‌های خط فرمان بنویسید. همراه با مفاهیم فصل‌های قبلی، کد شما سازماندهی خوبی خواهد داشت، داده‌ها را به طور مؤثر در ساختارهای داده مناسب ذخیره می‌کند، خطاها را به خوبی مدیریت می‌کند و به خوبی تست شده است.

ویژگی‌های زبان‌های تابعی: تکرارگرها و closureها

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

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

به طور خاص، ما پوشش خواهیم داد:

  • _Closure_‌ها، ساختاری شبیه به تابع که می‌توان آن را در یک متغیر ذخیره کرد
  • _پیمایشگر_‌ها (Iterator)، روشی برای پردازش مجموعه‌ای از عناصر
  • نحوه استفاده از closureها و iteratorها برای بهبود پروژهٔ ورودی/خروجی در فصل ۱۲
  • عملکرد closureها و iteratorها (هشدار: آن‌ها سریع‌تر از چیزی هستند که ممکن است فکر کنید!)

ما قبلاً برخی از ویژگی‌های دیگر Rust، مانند الگوها و enums را پوشش داده‌ایم که همچنین از سبک تابعی تأثیر گرفته‌اند. از آنجایی که تسلط بر closureها و تکرارگرها بخش مهمی از نوشتن کد ایدوماکتیک و سریع در Rust است، ما کل این فصل را به آن‌ها اختصاص خواهیم داد.

closureها: توابع ناشناسی که محیط خود را می‌گیرند

closureهای Rust توابع ناشناسی هستند که می‌توانید آن‌ها را در یک متغیر ذخیره کنید یا به عنوان آرگومان به توابع دیگر ارسال کنید. شما می‌توانید closure را در یک مکان ایجاد کنید و سپس آن را در جای دیگری فراخوانی کنید تا در یک زمینه متفاوت ارزیابی شود. برخلاف توابع، closureها می‌توانند مقادیر را از محیطی که در آن تعریف شده‌اند، بگیرند. ما نشان خواهیم داد که چگونه این ویژگی‌های closure امکان استفاده مجدد از کد و سفارشی‌سازی رفتار را فراهم می‌کند.

گرفتن محیط با closureها

در ابتدا بررسی می‌کنیم که چگونه می‌توانیم از closureها برای گرفتن مقادیر از محیطی که در آن تعریف شده‌اند جهت استفاده در آینده استفاده کنیم. سناریو به این صورت است: هر از چندگاهی، شرکت تی‌شرت ما یک تی‌شرت خاص و نسخه محدود را به عنوان تبلیغ به یکی از افراد موجود در لیست ایمیل هدیه می‌دهد. افراد موجود در لیست ایمیل می‌توانند به‌صورت اختیاری رنگ مورد علاقه خود را به پروفایلشان اضافه کنند. اگر فردی که برای دریافت تی‌شرت رایگان انتخاب شده، رنگ مورد علاقه‌ای مشخص کرده باشد، همان رنگ را دریافت خواهد کرد. اگر رنگی انتخاب نکرده باشد، رنگی را دریافت خواهد کرد که شرکت در حال حاضر بیشترین موجودی از آن را دارد.

راه‌های زیادی برای پیاده‌سازی این سناریو وجود دارد. در این مثال، از یک enum به نام ShirtColor استفاده می‌کنیم که شامل دو حالت Red و Blue است (برای سادگی، تعداد رنگ‌ها را محدود کرده‌ایم). موجودی شرکت با یک struct به نام Inventory نمایش داده می‌شود که دارای فیلدی به نام shirts است و یک Vec<ShirtColor> شامل رنگ‌های تی‌شرت موجود در انبار را نگهداری می‌کند. متد giveaway که روی Inventory تعریف شده، رنگ دلخواه (اختیاری) فرد برنده تی‌شرت رایگان را دریافت می‌کند و رنگ تی‌شرتی که قرار است به او داده شود را بازمی‌گرداند. این ساختار در لیست 13-1 نشان داده شده است.

Filename: src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
        shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
    };

    let user_pref1 = Some(ShirtColor::Red);
    let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
    let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}
Listing 13-1: سناریوی هدیه شرکت تی‌شرت

در این کد، store تعریف‌شده در main دو تی‌شرت آبی و یک تی‌شرت قرمز باقی‌مانده برای توزیع در این تبلیغ نسخه محدود دارد. ما متد giveaway را برای یک کاربر با ترجیح یک تی‌شرت قرمز و یک کاربر بدون هیچ ترجیحی فراخوانی می‌کنیم.

دوباره تأکید می‌کنیم که این کد را می‌توان به روش‌های مختلفی پیاده‌سازی کرد. در اینجا، برای تمرکز بر closureها، به مفاهیمی که قبلاً آموخته‌اید پایبند مانده‌ایم، به جز بخش بدنه متد giveaway که از یک closure استفاده می‌کند. در متد giveaway، ما اولویت کاربر را به عنوان یک آرگومان از نوع Option<ShirtColor> دریافت می‌کنیم و متد unwrap_or_else را روی user_preference فراخوانی می‌کنیم.

متد unwrap_or_else روی Option<T> توسط کتابخانه استاندارد تعریف شده است. این متد یک آرگومان می‌گیرد: یک closure بدون هیچ آرگومانی که یک مقدار T را بازمی‌گرداند (همان نوعی که در متغیر Some از Option<T> ذخیره شده است، در این مورد ShirtColor). اگر Option<T> مقدار Some داشته باشد، unwrap_or_else مقدار داخل Some را بازمی‌گرداند. اگر Option<T> مقدار None باشد، unwrap_or_else closure را فراخوانی کرده و مقداری که closure بازمی‌گرداند را بازمی‌گرداند.

ما عبارت closure || self.most_stocked() را به عنوان آرگومان به unwrap_or_else ارسال می‌کنیم. این یک closure است که خود هیچ آرگومانی نمی‌گیرد (اگر closure آرگومان‌هایی داشت، آن‌ها بین دو خط عمودی قرار می‌گرفتند). بدنه closure متد self.most_stocked() را فراخوانی می‌کند. ما closure را اینجا تعریف می‌کنیم و پیاده‌سازی unwrap_or_else در صورت نیاز، closure را ارزیابی می‌کند.

اجرای این کد موارد زیر را چاپ می‌کند:

$ cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue

یکی از جنبه‌های جالب در اینجا این است که ما یک closure ارسال کرده‌ایم که متد self.most_stocked() را روی نمونه فعلی Inventory فراخوانی می‌کند. کتابخانه استاندارد نیازی به دانستن چیزی درباره انواع Inventory یا ShirtColor که تعریف کرده‌ایم یا منطقی که می‌خواهیم در این سناریو استفاده کنیم، ندارد. closure یک ارجاع غیرقابل تغییر به نمونه self از Inventory را می‌گیرد و آن را همراه با کدی که مشخص کرده‌ایم به متد unwrap_or_else ارسال می‌کند. از طرف دیگر، توابع قادر به گرفتن محیط خود به این صورت نیستند.

استنباط نوع closure و حاشیه‌نویسی

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

closureها معمولاً کوتاه هستند و فقط در یک زمینه محدود مرتبط هستند، نه در هر سناریوی دلخواه. در این زمینه‌های محدود، کامپایلر می‌تواند انواع پارامترها و مقدار بازگشتی را استنباط کند، مشابه آنچه که می‌تواند انواع اکثر متغیرها را استنباط کند (موارد نادری وجود دارند که کامپایلر به حاشیه‌نویسی نوع closure نیز نیاز دارد).

همانند متغیرها، ما می‌توانیم حاشیه‌نویسی نوع اضافه کنیم اگر بخواهیم وضوح و شفافیت را افزایش دهیم، به قیمت پرحرف‌تر شدن از آنچه که به طور دقیق ضروری است. افزودن حاشیه‌نویسی نوع برای یک closure به این صورت است که در لیستینگ 13-2 نشان داده شده است. در این مثال، ما یک closure تعریف کرده و آن را در یک متغیر ذخیره می‌کنیم، به جای اینکه closure را در مکانی که به عنوان آرگومان ارسال می‌کنیم تعریف کنیم، همانطور که در لیستینگ 13-1 انجام دادیم.
Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intensity));
        println!("Next, do {} situps!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}
Listing 13-2: افزودن حاشیه‌نویسی‌های اختیاری برای انواع آرگومان‌ها و مقدار بازگشتی در closure

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

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

خط اول تعریف یک تابع را نشان می‌دهد، و خط دوم تعریف یک closure با حاشیه‌نویسی کامل را نمایش می‌دهد. در خط سوم، حاشیه‌نویسی انواع از تعریف closure حذف شده است. در خط چهارم، براکت‌ها را حذف می‌کنیم، که اختیاری هستند زیرا بدنه closure فقط یک عبارت دارد. همه این‌ها تعاریف معتبری هستند که هنگام فراخوانی رفتار یکسانی تولید می‌کنند. خطوط add_one_v3 و add_one_v4 نیاز دارند که closureها ارزیابی شوند تا کامپایل شوند زیرا انواع از نحوه استفاده آن‌ها استنباط خواهند شد. این مشابه با let v = Vec::new(); است که نیاز دارد یا حاشیه‌نویسی نوع داشته باشد یا مقادیر از نوعی در Vec وارد شوند تا Rust بتواند نوع را استنباط کند.

برای تعریف closureها، کامپایلر یک نوع مشخص برای هر یک از پارامترها و مقدار بازگشتی آن‌ها استنباط می‌کند. برای مثال، لیستینگ 13-3 تعریف یک closure کوتاه را نشان می‌دهد که فقط مقداری که به عنوان پارامتر دریافت می‌کند را بازمی‌گرداند. این closure برای اهداف این مثال استفاده چندانی ندارد. توجه کنید که هیچ حاشیه‌نویسی نوعی به تعریف اضافه نکرده‌ایم. چون هیچ حاشیه‌نویسی وجود ندارد، می‌توانیم closure را با هر نوعی فراخوانی کنیم، همان‌طور که اولین بار این کار را با String انجام دادیم. اگر سپس سعی کنیم example_closure را با یک عدد صحیح فراخوانی کنیم، خطایی دریافت خواهیم کرد.

Filename: src/main.rs
fn main() {
    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);
}
Listing 13-3: تلاش برای فراخوانی یک closure که انواع آن با استفاده از دو نوع مختلف استنباط شده است

کامپایلر این خطا را می‌دهد:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^- help: try using a conversion method: `.to_string()`
  |             |               |
  |             |               expected `String`, found integer
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:4:29
  |
4 |     let s = example_closure(String::from("hello"));
  |             --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:2:28
  |
2 |     let example_closure = |x| x;
  |                            ^

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

اولین باری که example_closure را با مقدار String فراخوانی می‌کنیم، کامپایلر نوع x و مقدار بازگشتی closure را به عنوان String استنباط می‌کند. سپس این انواع در closure example_closure قفل می‌شوند و هنگام تلاش برای استفاده از یک نوع دیگر با همان closure، یک خطای نوع دریافت می‌کنیم.

گرفتن ارجاعات یا انتقال مالکیت

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

در لیستینگ 13-4، یک closure تعریف می‌کنیم که یک ارجاع غیرقابل تغییر به بردار با نام list را می‌گیرد زیرا فقط به یک ارجاع غیرقابل تغییر نیاز دارد تا مقدار را چاپ کند:

Filename: src/main.rs
fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let only_borrows = || println!("From closure: {list:?}");

    println!("Before calling closure: {list:?}");
    only_borrows();
    println!("After calling closure: {list:?}");
}
Listing 13-4: تعریف و فراخوانی یک closure که یک ارجاع غیرقابل تغییر می‌گیرد

این مثال همچنین نشان می‌دهد که یک متغیر می‌تواند به تعریف یک closure متصل شود و بعداً می‌توان closure را با استفاده از نام متغیر و پرانتزها فراخوانی کرد، گویی که نام متغیر یک نام تابع است.

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

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

در ادامه، در لیستینگ 13-5، بدنه closure را تغییر می‌دهیم تا یک عنصر به بردار list اضافه کند. closure اکنون یک ارجاع قابل تغییر می‌گیرد:

Filename: src/main.rs
fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {list:?}");
}
Listing 13-5: تعریف و فراخوانی یک closure که یک ارجاع قابل تغییر می‌گیرد

این کد کامپایل شده، اجرا می‌شود و نتیجه زیر را چاپ می‌کند:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

توجه داشته باشید که دیگر println! بین تعریف و فراخوانی closure borrows_mutably وجود ندارد: زمانی که borrows_mutably تعریف می‌شود، یک ارجاع قابل تغییر به list می‌گیرد. ما بعد از فراخوانی closure دوباره از آن استفاده نمی‌کنیم، بنابراین قرض‌گیری قابل تغییر پایان می‌یابد. بین تعریف closure و فراخوانی آن، قرض‌گیری غیرقابل تغییر برای چاپ مجاز نیست، زیرا هیچ قرض دیگری هنگام وجود یک قرض قابل تغییر مجاز نیست. سعی کنید یک println! در آنجا اضافه کنید تا ببینید چه پیام خطایی دریافت می‌کنید!

اگر بخواهید closure را مجبور کنید که مالکیت مقادیر استفاده‌شده در محیط را بگیرد، حتی اگر بدنه closure به طور دقیق به مالکیت نیاز نداشته باشد، می‌توانید از کلیدواژه move قبل از لیست پارامترها استفاده کنید.

این تکنیک بیشتر زمانی مفید است که یک closure را به یک نخ جدید ارسال می‌کنید تا داده‌ها به گونه‌ای انتقال داده شوند که توسط نخ جدید مالکیت پیدا کنند. ما موضوع نخ‌ها و دلایلی که ممکن است بخواهید از آن‌ها استفاده کنید را به تفصیل در فصل 16 زمانی که در مورد هم‌زمانی صحبت می‌کنیم، بررسی خواهیم کرد. اما برای حالا، بیایید به صورت مختصر ایجاد یک نخ جدید با استفاده از یک closure که به کلیدواژه move نیاز دارد را بررسی کنیم. لیستینگ 13-6 لیستینگ 13-4 را اصلاح می‌کند تا بردار را در یک نخ جدید چاپ کند به جای اینکه در نخ اصلی این کار را انجام دهد:

Filename: src/main.rs
use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    thread::spawn(move || println!("From thread: {list:?}"))
        .join()
        .unwrap();
}
Listing 13-6: استفاده از move برای مجبور کردن closure به گرفتن مالکیت list برای نخ

ما یک نخ (thread) جدید ایجاد می‌کنیم و به آن یک closure برای اجرا به‌عنوان آرگومان می‌دهیم. بدنه‌ی closure لیست را چاپ می‌کند. در لیستینگ 13-4، closure فقط با استفاده از یک رفرنس تغییرناپذیر (immutable reference) به list دسترسی دارد، زیرا این کمترین میزان دسترسی موردنیاز برای چاپ لیست است.
در این مثال، با اینکه بدنه‌ی closure هنوز فقط به یک رفرنس تغییرناپذیر نیاز دارد، ما باید مشخص کنیم که list باید به درون closure منتقل شود. برای این کار، از کلمه‌ی کلیدی move در ابتدای تعریف closure استفاده می‌کنیم.

اگر نخ اصلی (main thread) قبل از فراخوانی join عملیات بیشتری انجام دهد، ممکن است نخ جدید زودتر از نخ اصلی تمام شود، یا بالعکس، نخ اصلی زودتر خاتمه یابد. اگر نخ اصلی مالکیت list را حفظ کرده باشد ولی قبل از پایان نخ جدید خاتمه یابد و list را آزاد کند، رفرنسی که نخ جدید استفاده می‌کند نامعتبر خواهد شد.

بنابراین، کامپایلر الزام می‌کند که list به درون closure داده‌شده به نخ جدید منتقل شود تا رفرنس معتبر باقی بماند.

سعی کنید کلمه‌ی کلیدی move را حذف کنید یا از list در نخ اصلی پس از تعریف closure استفاده کنید تا ببینید چه خطاهایی از سوی کامپایلر دریافت می‌کنید!

نخ جدید ممکن است قبل از تکمیل نخ اصلی تمام شود، یا نخ اصلی ممکن است زودتر تمام شود. اگر نخ اصلی مالکیت list را حفظ می‌کرد اما قبل از نخ جدید به پایان می‌رسید و list را حذف می‌کرد، ارجاع غیرقابل تغییر در نخ دیگر معتبر نبود. بنابراین، کامپایلر نیاز دارد که list به داخل closure داده‌شده به نخ جدید منتقل شود تا ارجاع معتبر باقی بماند. سعی کنید کلمه کلیدی move را حذف کنید یا از list در نخ اصلی پس از تعریف closure استفاده کنید تا ببینید چه خطاهای کامپایلری دریافت می‌کنید!

انتقال مقادیر گرفته‌شده به خارج از closureها و صفات Fn

زمانی که یک closure رفرنس یا مالکیت یک مقدار را از محیطی که در آن تعریف شده، گرفته باشد (که مشخص می‌کند چه چیزی — در صورت وجود — به درون closure منتقل می‌شود)، کدی که در بدنه‌ی closure قرار دارد تعیین می‌کند که چه اتفاقی برای آن رفرنس‌ها یا مقادیر در زمان اجرای closure می‌افتد (که مشخص می‌کند چه چیزی — در صورت وجود — از closure به بیرون منتقل می‌شود).

بدنه‌ی یک closure می‌تواند هر یک از موارد زیر را انجام دهد:

  • یک مقدار گرفته‌شده را به بیرون از closure منتقل کند (move)
  • مقدار گرفته‌شده را تغییر دهد (mutate)
  • نه مقداری را منتقل کند و نه تغییری ایجاد کند
  • هیچ چیزی از محیط را در ابتدا نگرفته باشد

نحوه گرفتن و مدیریت مقادیر توسط closure از محیط مشخص می‌کند که closure کدام صفات را پیاده‌سازی می‌کند. صفات روشی هستند که توابع و ساختارها می‌توانند مشخص کنند از چه نوع closureهایی می‌توانند استفاده کنند. closureها به صورت خودکار یکی، دو یا هر سه این صفات Fn را پیاده‌سازی می‌کنند، به صورت افزایشی، بسته به نحوه مدیریت مقادیر توسط بدنه closure:

  • FnOnce applies to closures that can be called once. All closures implement at least this trait because all closures can be called. A closure that moves captured values out of its body will only implement FnOnce and none of the other Fn traits because it can only be called once.
  • FnMut applies to closures that don’t move captured values out of their body, but that might mutate the captured values. These closures can be called more than once.
  • Fn applies to closures that don’t move captured values out of their body and that don’t mutate captured values, as well as closures that capture nothing from their environment. These closures can be called more than once without mutating their environment, which is important in cases such as calling a closure multiple times concurrently.

بیایید تعریف متد unwrap_or_else در Option<T> را که در لیستینگ 13-1 استفاده کردیم بررسی کنیم:

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

به یاد داشته باشید که T نوع جنریک است که نوع مقدار موجود در واریانت Some از Option را نشان می‌دهد. این نوع T همچنین نوع بازگشتی تابع unwrap_or_else است: به عنوان مثال، کدی که unwrap_or_else را روی یک Option<String> فراخوانی می‌کند، یک String دریافت خواهد کرد.

بعدی، توجه داشته باشید که تابع unwrap_or_else پارامتر نوع جنریک اضافی F را دارد. نوع F نوع پارامتر نام‌گذاری‌شده f است، که closureی است که هنگام فراخوانی unwrap_or_else ارائه می‌دهیم.

محدودیت صفت مشخص‌شده روی نوع جنریک F، FnOnce() -> T است، که به این معناست که F باید بتواند یک بار فراخوانی شود، هیچ آرگومانی نگیرد و یک T بازگرداند. استفاده از FnOnce در محدودیت صفت، محدودیت این موضوع را بیان می‌کند که unwrap_or_else حداکثر یک بار f را فراخوانی خواهد کرد. در بدنه unwrap_or_else، می‌بینیم که اگر Option برابر با Some باشد، f فراخوانی نمی‌شود. اگر Option برابر با None باشد، f یک بار فراخوانی خواهد شد. از آنجایی که تمام closureها FnOnce را پیاده‌سازی می‌کنند، unwrap_or_else همه انواع سه‌گانه closureها را می‌پذیرد و به اندازه کافی انعطاف‌پذیر است.

نکته: اگر کاری که می‌خواهیم انجام دهیم نیازی به گرفتن مقدار از محیط نداشته باشد، می‌توانیم به‌جای یک closure، از نام یک تابع استفاده کنیم؛ در جایی که نیاز به چیزی داریم که یکی از traitهای Fn را پیاده‌سازی کند. برای مثال، روی یک مقدار از نوع Option<Vec<T>> می‌توانیم unwrap_or_else(Vec::new) را فراخوانی کنیم تا در صورتی که مقدار None بود، یک vector جدید و خالی دریافت کنیم. کامپایلر به‌طور خودکار هرکدام از traitهای Fn که برای تعریف یک تابع مناسب باشند را پیاده‌سازی می‌کند.

حال بیایید به متد sort_by_key از کتابخانه استاندارد که روی sliceها تعریف شده است نگاهی بیندازیم تا ببینیم چه تفاوتی با unwrap_or_else دارد و چرا sort_by_key به جای FnOnce از FnMut به‌عنوان محدودیت trait استفاده می‌کند. این closure یک آرگومان دریافت می‌کند که به‌صورت رفرنسی به آیتم جاری در slice است، و مقداری از نوع K برمی‌گرداند که قابل مرتب‌سازی باشد. این تابع زمانی مفید است که بخواهید یک slice را بر اساس ویژگی خاصی از هر آیتم مرتب کنید. در لیستینگ 13-7، ما یک لیست از نمونه‌های Rectangle داریم و از sort_by_key برای مرتب‌سازی آن‌ها بر اساس ویژگی width از کم به زیاد استفاده می‌کنیم.

اکنون بیایید به متد استاندارد کتابخانه sort_by_key که روی برش‌ها (slices) تعریف شده است نگاهی بیندازیم تا ببینیم چگونه با unwrap_or_else متفاوت است و چرا sort_by_key به جای FnOnce از FnMut برای محدودیت صفت استفاده می‌کند. closure یک آرگومان به شکل یک ارجاع به آیتم جاری در برشی که در نظر گرفته می‌شود می‌گیرد و یک مقدار از نوع K را که قابل مرتب‌سازی است بازمی‌گرداند. این تابع زمانی مفید است که بخواهید یک برش را بر اساس ویژگی خاصی از هر آیتم مرتب کنید. در لیست 13-7، ما لیستی از نمونه‌های Rectangle داریم و از sort_by_key برای مرتب کردن آن‌ها بر اساس ویژگی width از کم به زیاد استفاده می‌کنیم:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{list:#?}");
}
Listing 13-7: استفاده از sort_by_key برای مرتب‌سازی مستطیل‌ها بر اساس عرض

این کد خروجی زیر را چاپ می‌کند:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/rectangles`
[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

دلیل اینکه sort_by_key به گونه‌ای تعریف شده که یک closure FnMut بگیرد این است که closure را چندین بار فراخوانی می‌کند: یک بار برای هر آیتم در برش. closure |r| r.width چیزی را از محیط خود نمی‌گیرد، تغییر نمی‌دهد یا منتقل نمی‌کند، بنابراین با الزامات محدودیت صفت مطابقت دارد.

در مقابل، لیست 13-8 مثالی از closureی را نشان می‌دهد که فقط صفت FnOnce را پیاده‌سازی می‌کند، زیرا مقداری را از محیط منتقل می‌کند. کامپایلر اجازه نمی‌دهد از این closure با sort_by_key استفاده کنیم:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("closure called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{list:#?}");
}
Listing 13-8: تلاش برای استفاده از closure FnOnce با sort_by_key

این یک روش مصنوعی و پیچیده (که کار نمی‌کند) برای تلاش در شمارش تعداد دفعاتی است که sort_by_key closure را هنگام مرتب کردن list فراخوانی می‌کند. این کد سعی می‌کند این شمارش را با افزودن value—یک String از محیط closure—به وکتور sort_operations انجام دهد. closure، value را می‌گیرد و سپس با انتقال مالکیت value به وکتور sort_operations، value را از closure منتقل می‌کند. این closure فقط یک بار می‌تواند فراخوانی شود؛ تلاش برای فراخوانی آن برای بار دوم کار نمی‌کند زیرا value دیگر در محیط وجود ندارد که دوباره به sort_operations اضافه شود! بنابراین، این closure فقط صفت FnOnce را پیاده‌سازی می‌کند. وقتی سعی می‌کنیم این کد را کامپایل کنیم، این خطا دریافت می‌شود که value نمی‌تواند از closure منتقل شود، زیرا closure باید FnMut را پیاده‌سازی کند:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
  --> src/main.rs:18:30
   |
15 |     let value = String::from("closure called");
   |         ----- captured outer variable
16 |
17 |     list.sort_by_key(|r| {
   |                      --- captured by this `FnMut` closure
18 |         sort_operations.push(value);
   |                              ^^^^^ move occurs because `value` has type `String`, which does not implement the `Copy` trait
   |
help: consider cloning the value if the performance cost is acceptable
   |
18 |         sort_operations.push(value.clone());
   |                                   ++++++++

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

این خطا به خطی در بدنه closure اشاره می‌کند که value را از محیط منتقل می‌کند. برای رفع این مشکل، باید بدنه closure را تغییر دهیم تا مقادیر را از محیط منتقل نکند. برای شمارش تعداد دفعاتی که closure فراخوانی می‌شود، نگه داشتن یک شمارنده در محیط و افزایش مقدار آن در بدنه closure روشی ساده‌تر برای محاسبه آن است. closure در لیست 13-9 با sort_by_key کار می‌کند زیرا فقط یک ارجاع قابل تغییر به شمارنده num_sort_operations را می‌گیرد و بنابراین می‌تواند بیش از یک بار فراخوانی شود:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!("{list:#?}, sorted in {num_sort_operations} operations");
}
Listing 13-9: استفاده از یک closure FnMut با sort_by_key مجاز است

صفات Fn هنگام تعریف یا استفاده از توابع یا انواعی که از closureها استفاده می‌کنند، مهم هستند. در بخش بعدی، ما درباره iteratorها بحث خواهیم کرد. بسیاری از متدهای iterator آرگومان‌های closure می‌گیرند، بنابراین این جزئیات closure را هنگام ادامه مطالعه در نظر داشته باشید!

پردازش یک سری از آیتم‌ها با استفاده از Iteratorها

الگوی iterator به شما اجازه می‌دهد تا به ترتیب روی یک دنباله از آیتم‌ها کاری انجام دهید. یک iterator مسئول منطق پیمایش هر آیتم و تعیین زمان پایان دنباله است. وقتی از iteratorها استفاده می‌کنید، نیازی به پیاده‌سازی مجدد آن منطق ندارید.

در Rust، iteratorها تنبل هستند، به این معنی که تا زمانی که متدهایی که iterator را مصرف می‌کنند فراخوانی نشوند، هیچ اثری ندارند. به عنوان مثال، کد در لیست 13-10 یک iterator را بر روی آیتم‌های وکتور v1 با فراخوانی متد iter که روی Vec<T> تعریف شده است، ایجاد می‌کند. این کد به تنهایی هیچ کار مفیدی انجام نمی‌دهد.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}
Listing 13-10: ایجاد یک iterator

پیمایشگر در متغیر v1_iter ذخیره شده است. پس از ایجاد یک پیمایشگر، می‌توانیم آن را به روش‌های مختلفی استفاده کنیم. در لیست 3-5، ما با استفاده از یک حلقه for بر روی یک آرایه پیمایش کردیم تا کدی را روی هر یک از آیتم‌های آن اجرا کنیم. در پشت صحنه، این عملیات به‌طور ضمنی یک پیمایشگر ایجاد کرده و سپس آن را مصرف می‌کند، اما تا این لحظه به‌طور دقیق توضیح ندادیم که این فرآیند چگونه کار می‌کند.

در مثال لیست 13-11، ما ایجاد iterator را از استفاده از آن در حلقه for جدا می‌کنیم. وقتی حلقه for با استفاده از iterator در v1_iter فراخوانی می‌شود، هر عنصر در iterator در یک تکرار از حلقه استفاده می‌شود، که هر مقدار را چاپ می‌کند.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {val}");
    }
}
Listing 13-11: استفاده از یک iterator در حلقه for

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

پیمایشگرها تمام این منطق را برای شما مدیریت می‌کنند و باعث کاهش کدی تکراری می‌شوند که ممکن است به‌اشتباه نوشته شود. پیمایشگرها انعطاف‌پذیری بیشتری در اختیار شما قرار می‌دهند تا بتوانید از همان منطق با انواع مختلفی از توالی‌ها استفاده کنید، نه فقط ساختارهای داده‌ای که می‌توان در آن‌ها با اندیس به عناصر دسترسی داشت، مانند vector. بیایید بررسی کنیم که پیمایشگرها چگونه این کار را انجام می‌دهند.

صفت Iterator و متد next

همه iteratorها یک صفت به نام Iterator را پیاده‌سازی می‌کنند که در کتابخانه استاندارد تعریف شده است. تعریف این صفت به صورت زیر است:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // متدهایی با پیاده‌سازی پیش‌فرض حذف شده‌اند
}
}

توجه کنید که این تعریف از یک نحو جدید استفاده می‌کند: type Item و Self::Item، که یک نوع مرتبط را با این صفت تعریف می‌کنند. ما در فصل 20 به طور مفصل درباره انواع مرتبط صحبت خواهیم کرد. فعلاً فقط باید بدانید که این کد می‌گوید پیاده‌سازی صفت Iterator نیاز دارد که شما یک نوع Item نیز تعریف کنید، و این نوع Item در نوع بازگشتی متد next استفاده می‌شود. به عبارت دیگر، نوع Item همان نوعی خواهد بود که از iterator بازگردانده می‌شود.

صفت Iterator فقط از پیاده‌کنندگان می‌خواهد یک متد را تعریف کنند: متد next، که یک آیتم از iterator را در هر زمان بازمی‌گرداند، که در Some بسته‌بندی شده است، و وقتی پیمایش تمام شد، None بازمی‌گرداند.

ما می‌توانیم مستقیماً متد next را روی iteratorها فراخوانی کنیم؛ لیست 13-12 نشان می‌دهد چه مقادیری از فراخوانی‌های مکرر next روی iterator ایجادشده از وکتور بازگردانده می‌شود.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}
Listing 13-12: فراخوانی متد next روی یک iterator

توجه کنید که ما نیاز داشتیم v1_iter را قابل تغییر (mutable) کنیم: فراخوانی متد next روی یک iterator، وضعیت داخلی را تغییر می‌دهد که iterator از آن برای ردیابی موقعیت خود در دنباله استفاده می‌کند. به عبارت دیگر، این کد iterator را مصرف می‌کند یا از بین می‌برد. هر فراخوانی به next یک آیتم از iterator را مصرف می‌کند. نیازی نبود v1_iter را هنگام استفاده از یک حلقه for قابل تغییر کنیم، زیرا حلقه مالکیت v1_iter را به عهده گرفت و به طور پنهانی آن را قابل تغییر کرد.

همچنین توجه کنید که مقادیری که از فراخوانی‌های next دریافت می‌کنیم، ارجاعات غیرقابل تغییر به مقادیر موجود در وکتور هستند. متد iter یک iterator روی ارجاعات غیرقابل تغییر تولید می‌کند. اگر بخواهیم یک iterator ایجاد کنیم که مالکیت v1 را بگیرد و مقادیر مالک‌شده را بازگرداند، می‌توانیم به جای iter، into_iter را فراخوانی کنیم. به همین ترتیب، اگر بخواهیم روی ارجاعات قابل تغییر پیمایش کنیم، می‌توانیم به جای iter، iter_mut را فراخوانی کنیم.

متدهایی که Iterator را مصرف می‌کنند

صفت Iterator تعداد زیادی متد مختلف با پیاده‌سازی‌های پیش‌فرض ارائه‌شده توسط کتابخانه استاندارد دارد؛ می‌توانید درباره این متدها با نگاه کردن به مستندات API کتابخانه استاندارد برای صفت Iterator اطلاعات بیشتری کسب کنید. برخی از این متدها در تعریف خود متد next را فراخوانی می‌کنند، به همین دلیل است که شما باید متد next را هنگام پیاده‌سازی صفت Iterator تعریف کنید.

متدهایی که next را فراخوانی می‌کنند، تطبیق‌دهنده‌های مصرفی نامیده می‌شوند، زیرا فراخوانی آن‌ها iterator را مصرف می‌کند. یک مثال، متد sum است که مالکیت iterator را به عهده می‌گیرد و با فراخوانی مکرر next، از میان آیتم‌ها عبور می‌کند، بنابراین iterator را مصرف می‌کند. هنگام عبور، هر آیتم را به یک مجموع در حال اجرا اضافه می‌کند و وقتی پیمایش کامل شد، مجموع را بازمی‌گرداند. لیست 13-13 یک تست را نشان می‌دهد که استفاده از متد sum را نشان می‌دهد:

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}
Listing 13-13: فراخوانی متد sum برای گرفتن مجموع همه آیتم‌ها در iterator

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

متدهایی که Iteratorهای دیگری تولید می‌کنند

تطبیق‌دهنده‌های Iterator متدهایی هستند که روی صفت Iterator تعریف شده‌اند و iterator را مصرف نمی‌کنند. در عوض، آن‌ها با تغییر برخی جنبه‌های iterator اصلی، iteratorهای متفاوتی تولید می‌کنند.

لیست 13-14 مثالی از فراخوانی متد تطبیق‌دهنده iterator به نام map را نشان می‌دهد که یک closure را برای فراخوانی روی هر آیتم هنگام پیمایش از میان آیتم‌ها می‌گیرد. متد map یک iterator جدید بازمی‌گرداند که آیتم‌های تغییر یافته را تولید می‌کند. closure در اینجا یک iterator جدید ایجاد می‌کند که در آن هر آیتم از وکتور ۱ واحد افزایش می‌یابد:

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}
Listing 13-14: فراخوانی تطبیق‌دهنده iterator map برای ایجاد یک iterator جدید

با این حال، این کد یک هشدار تولید می‌کند:

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iterators` (bin "iterators") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

کد در لیست 13-14 هیچ کاری انجام نمی‌دهد؛ closureی که مشخص کرده‌ایم هرگز فراخوانی نمی‌شود. این هشدار به ما یادآوری می‌کند چرا: تطبیق‌دهنده‌های iterator تنبل هستند و ما باید iterator را در اینجا مصرف کنیم.

برای رفع این هشدار و مصرف iterator، از متد collect استفاده می‌کنیم، که در فصل 12 با env::args در لیست 12-1 استفاده کردیم. این متد iterator را مصرف کرده و مقادیر حاصل را در یک نوع داده مجموعه جمع‌آوری می‌کند.

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

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}
Listing 13-15: فراخوانی متد map برای ایجاد یک iterator جدید و سپس فراخوانی متد collect برای مصرف iterator جدید و ایجاد یک وکتور

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

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

استفاده از closureهایی که محیط خود را می‌گیرند

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

برای این مثال، از متد filter استفاده خواهیم کرد که یک closure می‌گیرد. closure یک آیتم از iterator دریافت کرده و یک مقدار bool بازمی‌گرداند. اگر closure مقدار true بازگرداند، مقدار در پیمایش تولید شده توسط filter گنجانده می‌شود. اگر closure مقدار false بازگرداند، مقدار گنجانده نخواهد شد.

در لیست 13-16، از filter با یک closure که متغیر shoe_size را از محیط خود می‌گیرد استفاده می‌کنیم تا روی مجموعه‌ای از نمونه‌های ساختار Shoe پیمایش کنیم. این متد فقط کفش‌هایی را که اندازه مشخص شده دارند بازمی‌گرداند.

Filename: src/lib.rs
#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}
Listing 13-16: استفاده از متد filter با یک closure که shoe_size را می‌گیرد

تابع shoes_in_size مالکیت یک وکتور از کفش‌ها و یک اندازه کفش را به عنوان پارامتر می‌گیرد. این تابع یک وکتور بازمی‌گرداند که فقط شامل کفش‌هایی با اندازه مشخص شده است.

در بدنه shoes_in_size، ما into_iter را فراخوانی می‌کنیم تا یک iterator ایجاد کنیم که مالکیت وکتور را می‌گیرد. سپس filter را فراخوانی می‌کنیم تا آن iterator را به یک iterator جدید تبدیل کنیم که فقط شامل عناصری است که closure برای آن‌ها مقدار true بازمی‌گرداند.

closure پارامتر shoe_size را از محیط می‌گیرد و مقدار آن را با اندازه هر کفش مقایسه می‌کند و فقط کفش‌هایی با اندازه مشخص شده را نگه می‌دارد. در نهایت، با فراخوانی collect مقادیر بازگردانده‌شده توسط iterator تطبیق‌یافته در یک وکتور جمع‌آوری می‌شوند که توسط تابع بازگردانده می‌شود.

تست نشان می‌دهد که وقتی shoes_in_size را فراخوانی می‌کنیم، فقط کفش‌هایی را دریافت می‌کنیم که اندازه آن‌ها با مقداری که مشخص کرده‌ایم یکسان است.

بهبود پروژه I/O

با این دانش جدید درباره iteratorها، می‌توانیم پروژه I/O در فصل ۱۲ را با استفاده از iteratorها بهبود بخشیم تا بخش‌هایی از کد واضح‌تر و مختصرتر شوند. بیایید ببینیم چگونه iteratorها می‌توانند پیاده‌سازی تابع Config::build و تابع search را بهبود دهند.

حذف یک clone با استفاده از یک Iterator

در لیست 12-6، کدی اضافه کردیم که یک برش از مقادیر String را گرفته و یک نمونه از ساختار Config ایجاد می‌کرد. این کار با شاخص‌گذاری در برش و کلون کردن مقادیر انجام شد تا ساختار Config مالک آن مقادیر شود. در لیست 13-17، پیاده‌سازی تابع Config::build را همانطور که در لیست 12-23 بود بازتولید کرده‌ایم:

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-17: بازتولید تابع Config::build از لیست 12-23

در آن زمان گفتیم که نگران تماس‌های ناکارآمد clone نباشید زیرا در آینده آن‌ها را حذف خواهیم کرد. خب، اکنون زمان آن فرا رسیده است!

ما در اینجا به clone نیاز داشتیم زیرا در پارامتر args یک برش با عناصر String داریم، اما تابع build مالک args نیست. برای بازگرداندن مالکیت یک نمونه Config، مجبور بودیم مقادیر فیلدهای query و file_path را از Config کلون کنیم تا نمونه Config بتواند مالک مقادیرش باشد.

با دانش جدیدمان درباره iteratorها، می‌توانیم تابع build را تغییر دهیم تا مالکیت یک iterator را به عنوان آرگومان خود بگیرد، به جای اینکه یک برش را قرض بگیرد. ما از قابلیت‌های iterator به جای کدی که طول برش را بررسی می‌کند و به مکان‌های خاص شاخص می‌زند، استفاده خواهیم کرد. این کار مشخص می‌کند که تابع Config::build چه کاری انجام می‌دهد زیرا iterator به مقادیر دسترسی پیدا خواهد کرد.

زمانی که Config::build مالکیت iterator را به دست آورد و استفاده از عملیات شاخص‌گذاری که قرض می‌گیرند را متوقف کرد، می‌توانیم مقادیر String را از iterator به Config منتقل کنیم به جای اینکه clone را فراخوانی کنیم و تخصیص جدیدی ایجاد کنیم.

استفاده مستقیم از Iterator بازگردانده‌شده

فایل src/main.rs پروژه I/O خود را باز کنید، که باید به این شکل باشد:

نام فایل: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

ابتدا شروع تابع main که در لیست 12-24 داشتیم را به کدی که در لیست 13-18 است تغییر می‌دهیم، که این بار از یک iterator استفاده می‌کند. این کد تا زمانی که Config::build را نیز به‌روزرسانی کنیم، کامپایل نخواهد شد.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-18: ارسال مقدار بازگردانده‌شده توسط env::args به Config::build

تابع env::args یک iterator بازمی‌گرداند! به جای جمع‌آوری مقادیر iterator در یک وکتور و سپس ارسال یک برش به Config::build، اکنون ما مالکیت iterator بازگردانده‌شده از env::args را مستقیماً به Config::build ارسال می‌کنیم.

سپس باید تعریف تابع Config::build را به‌روزرسانی کنیم. در فایل src/lib.rs پروژه I/O خود، امضای تابع Config::build را به شکلی که در لیست 13-19 نشان داده شده تغییر دهید. این کد هنوز کامپایل نخواهد شد زیرا باید بدنه تابع را نیز به‌روزرسانی کنیم.

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-19: به‌روزرسانی امضای Config::build برای انتظار یک iterator

مستندات کتابخانه استاندارد برای تابع env::args نشان می‌دهد که نوع iterator بازگردانده‌شده std::env::Args است، و این نوع صفت Iterator را پیاده‌سازی کرده و مقادیر String بازمی‌گرداند.

ما امضای تابع Config::build را به‌روزرسانی کرده‌ایم تا پارامتر args یک نوع جنریک با محدودیت‌های صفت impl Iterator<Item = String> باشد به جای &[String]. این استفاده از نحو impl Trait که در بخش “Traits به عنوان پارامترها” فصل 10 بحث شد، به این معناست که args می‌تواند هر نوعی باشد که صفت Iterator را پیاده‌سازی کرده و آیتم‌های String بازمی‌گرداند.

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

استفاده از متدهای صفت Iterator به جای شاخص‌گذاری

سپس بدنه تابع Config::build را اصلاح می‌کنیم. از آنجا که args صفت Iterator را پیاده‌سازی کرده است، می‌دانیم که می‌توانیم متد next را روی آن فراخوانی کنیم! لیست 13-20 کد لیست 12-23 را برای استفاده از متد next به‌روزرسانی می‌کند:

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-20: تغییر بدنه Config::build برای استفاده از متدهای iterator

به یاد داشته باشید که اولین مقدار در مقدار بازگردانده‌شده از env::args نام برنامه است. ما می‌خواهیم آن را نادیده بگیریم و به مقدار بعدی برسیم، بنابراین ابتدا next را فراخوانی می‌کنیم و هیچ کاری با مقدار بازگشتی انجام نمی‌دهیم. سپس، next را فراخوانی می‌کنیم تا مقداری که می‌خواهیم در فیلد query از Config قرار دهیم را دریافت کنیم. اگر next یک Some بازگرداند، از یک match برای استخراج مقدار استفاده می‌کنیم. اگر None بازگرداند، به این معنی است که آرگومان‌های کافی ارائه نشده‌اند و با مقدار Err زودتر بازمی‌گردیم. همین کار را برای مقدار file_path انجام می‌دهیم.

واضح‌تر کردن کد با تطبیق‌دهنده‌های Iterator

ما همچنین می‌توانیم از iteratorها در تابع search پروژه I/O خود بهره ببریم. این تابع در لیست 13-21 به همان شکلی که در لیست 12-19 بود بازتولید شده است:

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 13-21: پیاده‌سازی تابع search از لیست 12-19

ما می‌توانیم این کد را با استفاده از متدهای تطبیق‌دهنده iterator به شکلی مختصرتر بنویسیم. این کار همچنین به ما اجازه می‌دهد که از داشتن یک وکتور میانی قابل تغییر به نام results اجتناب کنیم. سبک برنامه‌نویسی تابعی ترجیح می‌دهد مقدار حالت‌های قابل تغییر را به حداقل برساند تا کد واضح‌تر شود. حذف حالت قابل تغییر ممکن است امکان ارتقاء آینده را فراهم کند تا جستجو به صورت موازی انجام شود، زیرا نیازی به مدیریت دسترسی همزمان به وکتور results نخواهیم داشت. لیست 13-22 این تغییر را نشان می‌دهد:

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-22: استفاده از متدهای تطبیق‌دهنده iterator در پیاده‌سازی تابع search

به یاد دارید که هدف تابع search این است که تمام خطوط موجود در contents را که شامل query هستند برگرداند. مشابه با مثال filter در لیستینگ 13-16، این کد از آداپتور filter استفاده می‌کند تا فقط خطوطی را نگه دارد که در آن‌ها line.contains(query) مقدار true را بازمی‌گرداند. سپس خطوط مطابق را با استفاده از collect در یک وکتور جدید جمع‌آوری می‌کنیم. خیلی ساده‌تر! شما می‌توانید همین تغییر را در تابع search_case_insensitive نیز اعمال کرده و از متدهای پیمایشگر استفاده کنید.

برای بهبود بیشتر، مقدار بازگشتی تابع search را به‌جای وکتور، یک پیمایشگر قرار دهید؛ با حذف فراخوانی collect و تغییر نوع بازگشتی به impl Iterator<Item = &'a str>، این تابع به یک آداپتور پیمایشگر تبدیل می‌شود. توجه داشته باشید که باید تست‌ها را نیز مطابق این تغییر به‌روزرسانی کنید! یک فایل بزرگ را با ابزار minigrep خود، قبل و بعد از این تغییر جست‌وجو کنید تا تفاوت رفتار را مشاهده نمایید. قبل از این تغییر، برنامه تا زمانی که تمام نتایج جمع‌آوری نشده‌اند چیزی چاپ نمی‌کند، اما پس از این تغییر، نتایج به‌محض یافتن هر خط مطابق چاپ می‌شوند، زیرا حلقه for در تابع run می‌تواند از ویژگی تنبلی پیمایشگر استفاده کند.

انتخاب بین حلقه‌ها و پیمایشگرها

سؤال منطقی بعدی این است که کدام سبک را در کد خود انتخاب کنیم و چرا: پیاده‌سازی اولیه در لیستینگ 13-21 یا نسخه‌ای که از پیمایشگرها استفاده می‌کند در لیستینگ 13-22 (با فرض اینکه تمام نتایج را پیش از بازگرداندن جمع‌آوری می‌کنیم و نه اینکه خود پیمایشگر را بازگردانیم). بیشتر برنامه‌نویسان Rust ترجیح می‌دهند از سبک پیمایشگر استفاده کنند. در ابتدا ممکن است درک آن کمی دشوارتر باشد، اما زمانی که با آداپتورهای مختلف پیمایشگر و عملکرد آن‌ها آشنا شدید، کار با آن‌ها آسان‌تر خواهد بود. به جای کلنجار رفتن با بخش‌های مختلف حلقه و ساخت وکتورهای جدید، کد روی هدف سطح بالای حلقه تمرکز می‌کند. این امر باعث پنهان شدن بخشی از کدهای تکراری شده و فهم مفاهیم خاص این کد (مانند شرط فیلتر شدن هر عنصر پیمایشگر) را آسان‌تر می‌کند.

اما آیا این دو پیاده‌سازی واقعاً معادل هم هستند؟ فرض شهودی ممکن است این باشد که حلقه سطح پایین‌تر سریع‌تر است. بیایید درباره عملکرد صحبت کنیم.

مقایسه عملکرد: حلقه‌ها در برابر Iteratorها

برای تعیین اینکه از حلقه‌ها یا iteratorها استفاده کنید، باید بدانید کدام پیاده‌سازی سریع‌تر است: نسخه تابع search با حلقه صریح for یا نسخه با iteratorها.

ما یک بنچمارک اجرا کردیم که در آن تمام محتوای کتاب The Adventures of Sherlock Holmes اثر سر آرتور کانن دویل را در یک String بارگذاری کردیم و به دنبال کلمه the در محتوا گشتیم. نتایج بنچمارک برای نسخه search با استفاده از حلقه for و نسخه با iteratorها به شرح زیر است:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

دو پیاده‌سازی عملکرد مشابهی دارند! ما کد بنچمارک (benchmark) را اینجا توضیح نمی‌دهیم، زیرا هدف این نیست که ثابت کنیم این دو نسخه معادل هستند، بلکه هدف این است که به یک درک کلی از نحوه مقایسه عملکردی این دو پیاده‌سازی برسیم.

برای یک بنچمارک جامع‌تر، باید از متن‌های مختلف با اندازه‌های گوناگون به‌عنوان contents، کلمات مختلف و کلماتی با طول‌های متفاوت به‌عنوان query، و انواع دیگری از تغییرات استفاده کنید. نکته این است: iteratorها، اگرچه یک انتزاع سطح بالا هستند، به کدی که تقریباً همان سطح پایینی دارد کامپایل می‌شوند، انگار خودتان کد سطح پایین را نوشته باشید. iteratorها یکی از انتزاع‌های بدون هزینه Rust هستند، به این معنی که استفاده از انتزاع هیچ هزینه اضافی زمان اجرای برنامه را تحمیل نمی‌کند. این موضوع مشابه تعریفی است که بیارنه استراس‌تروپ، طراح و پیاده‌ساز اصلی ++C، در مقاله “Foundations of C++” (2012) برای بدون هزینه اضافی ارائه می‌دهد:

به طور کلی، پیاده‌سازی‌های ++C از اصل بدون هزینه اضافی پیروی می‌کنند: چیزی که استفاده نمی‌کنید، هزینه‌ای برای شما ندارد. و علاوه بر این: چیزی که استفاده می‌کنید، نمی‌توانید بهتر از این دستی کدنویسی کنید.

در بسیاری از موارد، کدی که در Rust با استفاده از پیمایشگرها نوشته می‌شود، به همان کدی در اسمبلی کامپایل می‌شود که اگر دستی می‌نوشتید تولید می‌شد. بهینه‌سازی‌هایی مانند بازگشایی حلقه‌ها (loop unrolling) و حذف بررسی محدوده (bounds checking) در دسترسی به آرایه‌ها اعمال می‌شوند و کد نهایی را بسیار بهینه می‌سازند. اکنون که این را می‌دانید، می‌توانید با خیال راحت از پیمایشگرها و closures استفاده کنید! آن‌ها باعث می‌شوند کد سطح بالاتری به نظر برسد، اما هیچ جریمه‌ای از نظر عملکرد در زمان اجرا به همراه ندارند.

خلاصه

closureها و iteratorها ویژگی‌های Rust هستند که از ایده‌های زبان‌های برنامه‌نویسی تابعی الهام گرفته‌اند. آن‌ها به توانایی Rust در بیان واضح ایده‌های سطح بالا با عملکرد سطح پایین کمک می‌کنند. پیاده‌سازی closureها و iteratorها به گونه‌ای است که عملکرد زمان اجرا تحت تأثیر قرار نمی‌گیرد. این بخشی از هدف Rust برای ارائه انتزاع‌های بدون هزینه است.

اکنون که قابلیت بیان پروژه I/O خود را بهبود داده‌ایم، بیایید نگاهی به برخی ویژگی‌های بیشتر cargo بیندازیم که به ما کمک می‌کنند پروژه را با دنیا به اشتراک بگذاریم.

اطلاعات بیشتر درباره Cargo و Crates.io

تا کنون ما فقط از ویژگی‌های ابتدایی Cargo برای ساخت، اجرا و تست کد خود استفاده کرده‌ایم، اما Cargo قابلیت‌های بیشتری دارد. در این فصل، برخی از ویژگی‌های پیشرفته‌تر آن را بررسی می‌کنیم تا نشان دهیم چگونه می‌توانید کارهای زیر را انجام دهید:

  • شخصی‌سازی فرآیند ساخت از طریق پروفایل‌های نسخه انتشار
  • انتشار کتابخانه‌ها در crates.io
  • سازماندهی پروژه‌های بزرگ با استفاده از Workspaces
  • نصب باینری‌ها از crates.io
  • گسترش قابلیت‌های Cargo با استفاده از دستورات سفارشی

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

شخصی‌سازی فرآیند ساخت با استفاده از پروفایل‌های نسخه انتشار

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

‌Cargo دو پروفایل اصلی دارد: پروفایل dev که Cargo هنگام اجرای دستور cargo build از آن استفاده می‌کند، و پروفایل release که هنگام اجرای cargo build --release استفاده می‌شود. پروفایل dev با مقادیر پیش‌فرض مناسبی برای توسعه تعریف شده است، و پروفایل release دارای مقادیر پیش‌فرض مناسبی برای ساخت نسخه نهایی (انتشار) است.

این نام‌های پروفایل ممکن است از خروجی‌های ساخت شما آشنا به نظر برسند:

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
    Finished `release` profile [optimized] target(s) in 0.32s

dev و release پروفایل‌های متفاوتی هستند که توسط کامپایلر استفاده می‌شوند.

Cargo تنظیمات پیش‌فرضی برای هر یک از پروفایل‌ها دارد که زمانی اعمال می‌شوند که هیچ بخش [profile.*] در فایل Cargo.toml پروژه شما به طور صریح اضافه نشده باشد. با افزودن بخش‌های [profile.*] برای هر پروفایلی که می‌خواهید سفارشی کنید، می‌توانید هر بخشی از تنظیمات پیش‌فرض را بازنویسی کنید. به عنوان مثال، در اینجا مقادیر پیش‌فرض تنظیم opt-level برای پروفایل‌های dev و release آورده شده است:

Filename: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

تنظیم opt-level تعیین می‌کند که چه مقدار بهینه‌سازی توسط Rust روی کد شما اعمال شود، و این مقدار در بازه‌ای از ۰ تا ۳ قرار دارد. اعمال بهینه‌سازی‌های بیشتر زمان کامپایل را افزایش می‌دهد، بنابراین اگر در حال توسعه هستید و کدتان را مرتباً کامپایل می‌کنید، ترجیح می‌دهید بهینه‌سازی کمتری انجام شود تا سرعت کامپایل بالاتر باشد، حتی اگر اجرای نهایی برنامه کندتر باشد. به همین دلیل، مقدار پیش‌فرض opt-level برای پروفایل dev برابر با 0 است. زمانی که آماده‌ی انتشار کد خود هستید، بهتر است زمان بیشتری را صرف کامپایل کنید. شما فقط یک‌بار در حالت انتشار کامپایل انجام می‌دهید، اما برنامه‌ی کامپایل‌شده را بارها اجرا خواهید کرد، بنابراین حالت انتشار زمان کامپایل بیشتر را با اجرای سریع‌تر برنامه مبادله می‌کند. به همین دلیل، مقدار پیش‌فرض opt-level برای پروفایل release برابر با 3 است.

Filename: Cargo.toml

[profile.dev]
opt-level = 1

این کد تنظیم پیش‌فرض 0 را بازنویسی می‌کند. اکنون، زمانی که cargo build را اجرا می‌کنیم، Cargo از تنظیمات پیش‌فرض برای پروفایل dev به همراه سفارشی‌سازی ما برای opt-level استفاده خواهد کرد. از آنجایی که ما مقدار opt-level را به 1 تنظیم کرده‌ایم، Cargo بهینه‌سازی‌های بیشتری نسبت به پیش‌فرض اعمال خواهد کرد، اما نه به اندازه یک ساخت در حالت release.

برای مشاهده لیست کامل گزینه‌های پیکربندی و تنظیمات پیش‌فرض برای هر پروفایل، به مستندات Cargo مراجعه کنید.

انتشار یک Crate در Crates.io

ما از پکیج‌های موجود در crates.io به عنوان وابستگی‌های پروژه خود استفاده کرده‌ایم، اما شما همچنین می‌توانید کد خود را با دیگران به اشتراک بگذارید با انتشار پکیج‌های خودتان. رجیستری Crates.io کد منبع پکیج‌های شما را توزیع می‌کند، بنابراین به طور عمده میزبان کدهای منبع باز است.

Rust و Cargo ویژگی‌هایی دارند که باعث می‌شود پکیج منتشرشده شما برای دیگران راحت‌تر پیدا شده و استفاده شود. ما ابتدا درباره برخی از این ویژگی‌ها صحبت می‌کنیم و سپس توضیح می‌دهیم چگونه یک پکیج منتشر کنیم.

ایجاد نظرات مستندات مفید

مستندسازی دقیق پکیج‌های شما به دیگر کاربران کمک می‌کند بدانند چگونه و چه زمانی از آن‌ها استفاده کنند، بنابراین ارزش دارد که وقت خود را برای نوشتن مستندات صرف کنید. در فصل 3، نحوه اضافه کردن نظرات به کد Rust با استفاده از دو اسلش // را بررسی کردیم. Rust همچنین نوع خاصی از نظرات برای مستندات دارد که به نام نظرات مستندات شناخته می‌شود و مستندات HTML تولید می‌کند. این مستندات HTML محتوای نظرات مستندات را برای آیتم‌های عمومی API نمایش می‌دهد که برای برنامه‌نویسانی که به دنبال دانستن چگونگی استفاده از crate شما هستند، طراحی شده است و نه چگونگی پیاده‌سازی crate شما.

نظرات مستندات به جای دو اسلش از سه اسلش /// استفاده می‌کنند و از نشانه‌گذاری Markdown برای قالب‌بندی متن پشتیبانی می‌کنند. نظرات مستندات را درست قبل از آیتمی که قرار است مستندسازی شود قرار دهید. لیستینگ 14-1 نظرات مستندات برای یک تابع add_one در یک crate به نام my_crate را نشان می‌دهد.

Filename: src/lib.rs
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
Listing 14-1: یک نظر مستند برای یک تابع

اینجا، ما توضیحی درباره عملکرد تابع add_one می‌دهیم، بخشی با عنوان Examples شروع می‌کنیم، و سپس کدی که نشان می‌دهد چگونه از تابع add_one استفاده کنیم ارائه می‌دهیم. می‌توانیم مستندات HTML را از این نظر مستند با اجرای دستور cargo doc تولید کنیم. این دستور ابزار rustdoc که با Rust توزیع شده را اجرا می‌کند و مستندات HTML تولیدشده را در دایرکتوری target/doc قرار می‌دهد.

<<<<<<< HEAD

برای راحتی، اجرای دستور cargo doc --open مستندات HTML را برای crate فعلی شما (و همچنین مستندات همه وابستگی‌های crate شما) می‌سازد و نتیجه را در مرورگر وب باز می‌کند. به تابع add_one بروید و خواهید دید که چگونه متن موجود در نظرات مستندات نمایش داده می‌شود، همانطور که در شکل 14-1 نشان داده شده است:

For convenience, running cargo doc --open will build the HTML for your current crate’s documentation (as well as the documentation for all of your crate’s dependencies) and open the result in a web browser. Navigate to the add_one function and you’ll see how the text in the documentation comments is rendered, as shown in Figure 14-1.

upstream/main

مستندات HTML تولیدشده برای تابع `add_one` از `my_crate`

شکل 14-1: مستندات HTML برای تابع add_one

بخش‌های متداول مورد استفاده

ما در لیستینگ 14-1 از عنوان Markdown # Examples برای ایجاد یک بخش در HTML با عنوان “Examples” استفاده کردیم. در اینجا برخی دیگر از بخش‌هایی که نویسندگان crate معمولاً در مستندات خود استفاده می‌کنند آورده شده است:

  • Panics: سناریوهایی که در آن ممکن است تابع مستند شده باعث ایجاد panic شود. فراخوانان تابع که نمی‌خواهند برنامه‌هایشان panic کنند باید مطمئن شوند که تابع را در این شرایط فراخوانی نمی‌کنند.
  • Errors: اگر تابع یک مقدار Result بازگرداند، توضیح انواع خطاهایی که ممکن است رخ دهد و شرایطی که ممکن است این خطاها را ایجاد کند، برای فراخوانان مفید است تا بتوانند کدهایی برای مدیریت انواع مختلف خطاها بنویسند.
  • Safety: اگر تابع unsafe برای فراخوانی باشد (ما عدم ایمنی را در فصل 20 بررسی خواهیم کرد)، باید بخشی توضیح دهد که چرا تابع ناامن است و اصولی را که تابع از فراخوانان انتظار دارد رعایت کنند پوشش دهد.

بیشتر نظرات مستندات به همه این بخش‌ها نیاز ندارند، اما این یک چک‌لیست خوب برای یادآوری جنبه‌هایی از کد شما است که کاربران علاقه‌مند به دانستن آن هستند.

نظرات مستندات به عنوان تست

<<<<<<< HEAD

اضافه کردن بلوک‌های کد مثال به نظرات مستندات شما می‌تواند به نمایش نحوه استفاده از کتابخانه شما کمک کند، و انجام این کار یک مزیت اضافی دارد: اجرای دستور cargo test، مثال‌های کد در مستندات شما را به عنوان تست اجرا خواهد کرد! هیچ چیزی بهتر از مستندات با مثال نیست. اما هیچ چیزی بدتر از مثال‌هایی نیست که کار نمی‌کنند زیرا کد از زمان نوشته شدن مستندات تغییر کرده است. اگر cargo test را با مستندات تابع add_one از لیستینگ 14-1 اجرا کنیم، بخشی در نتایج تست مانند زیر خواهیم دید:

Adding example code blocks in your documentation comments can help demonstrate how to use your library, and doing so has an additional bonus: running cargo test will run the code examples in your documentation as tests! Nothing is better than documentation with examples. But nothing is worse than examples that don’t work because the code has changed since the documentation was written. If we run cargo test with the documentation for the add_one function from Listing 14-1, we will see a section in the test results that looks like this:

upstream/main

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

<<<<<<< HEAD

اکنون، اگر تابع یا مثال را تغییر دهیم به طوری که assert_eq! در مثال باعث panic شود و دوباره cargo test را اجرا کنیم، خواهیم دید که تست‌های مستندات تشخیص می‌دهند که مثال و کد با یکدیگر همگام نیستند!

Now, if we change either the function or the example so the assert_eq! in the example panics, and run cargo test again, we’ll see that the doc tests catch that the example and the code are out of sync with each other!

upstream/main

مستندسازی آیتم‌های شامل شده

<<<<<<< HEAD سبک نظر مستند //! مستندات را به آیتمی که نظرات را شامل می‌شود اضافه می‌کند، به جای آیتم‌هایی که بعد از نظرات قرار دارند. ما معمولاً از این نظرات مستند در فایل اصلی crate (src/lib.rs بر اساس قرارداد) یا در داخل یک ماژول برای مستندسازی کل crate یا ماژول استفاده می‌کنیم.

برای مثال، برای اضافه کردن مستنداتی که هدف crate my_crate را که شامل تابع add_one است توضیح می‌دهد، نظرات مستندی که با //! شروع می‌شوند را به ابتدای فایل src/lib.rs اضافه می‌کنیم، همان‌طور که در لیستینگ 14-2 نشان داده شده است:

The style of doc comment //! adds documentation to the item that contains the comments rather than to the items following the comments. We typically use these doc comments inside the crate root file (src/lib.rs by convention) or inside a module to document the crate or the module as a whole.

For example, to add documentation that describes the purpose of the my_crate crate that contains the add_one function, we add documentation comments that start with //! to the beginning of the src/lib.rs file, as shown in Listing 14-2.

upstream/main

Filename: src/lib.rs
//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --snip--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
Listing 14-2: مستندات برای کل crate my_crate

توجه داشته باشید که هیچ کدی بعد از آخرین خطی که با //! شروع می‌شود وجود ندارد. چون ما نظرات را با //! شروع کرده‌ایم به جای ///، ما در حال مستندسازی آیتمی هستیم که این نظر را شامل می‌شود به جای آیتمی که بعد از این نظر قرار دارد. در این مورد، آن آیتم فایل src/lib.rs است که ریشه crate است. این نظرات کل crate را توضیح می‌دهند.

<<<<<<< HEAD

وقتی cargo doc --open را اجرا می‌کنیم، این نظرات در صفحه اول مستندات crate my_crate بالای لیست آیتم‌های عمومی در crate نمایش داده می‌شوند، همان‌طور که در شکل 14-2 نشان داده شده است:

When we run cargo doc --open, these comments will display on the front page of the documentation for my_crate above the list of public items in the crate, as shown in Figure 14-2.

upstream/main

Rendered HTML documentation with a comment for the crate as a whole

شکل 14-2: مستندات تولید شده برای my_crate، شامل توضیحات در مورد کل crate

نظرات مستندات داخل آیتم‌ها به ویژه برای توصیف crates و ماژول‌ها مفید هستند. از آن‌ها برای توضیح هدف کلی container استفاده کنید تا به کاربران خود در درک سازمان‌دهی crate کمک کنید.

صادرات یک API عمومی کارآمد با استفاده از pub use

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

<<<<<<< HEAD در فصل 7، نحوه عمومی کردن آیتم‌ها با استفاده از کلمه کلیدی pub و آوردن آیتم‌ها به یک scope با استفاده از کلمه کلیدی use را پوشش دادیم. با این حال، ساختاری که هنگام توسعه یک crate برای شما منطقی به نظر می‌رسد ممکن است برای کاربران شما چندان مناسب نباشد. ممکن است بخواهید ساختارهای خود را در یک سلسله‌مراتب با چندین سطح سازماندهی کنید، اما سپس افرادی که می‌خواهند از یک نوع تعریف‌شده عمیق در سلسله‌مراتب استفاده کنند ممکن است در پیدا کردن آن نوع دچار مشکل شوند. همچنین ممکن است مجبور شوند به جای use my_crate::UsefulType;، چیزی مانند use my_crate::some_module::another_module::UsefulType; بنویسند که ناخوشایند است.

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

برای مثال، فرض کنید ما یک کتابخانه به نام art برای مدل‌سازی مفاهیم هنری ایجاد کرده‌ایم. در این کتابخانه دو ماژول وجود دارند: یک ماژول kinds که شامل دو enum به نام‌های PrimaryColor و SecondaryColor است و یک ماژول utils که شامل یک تابع به نام mix است، همان‌طور که در لیستینگ 14-3 نشان داده شده است:

In Chapter 7, we covered how to make items public using the pub keyword, and how to bring items into a scope with the use keyword. However, the structure that makes sense to you while you’re developing a crate might not be very convenient for your users. You might want to organize your structs in a hierarchy containing multiple levels, but then people who want to use a type you’ve defined deep in the hierarchy might have trouble finding out that type exists. They might also be annoyed at having to enter use my_crate::some_module::another_module::UsefulType; rather than use my_crate::UsefulType;.

The good news is that if the structure isn’t convenient for others to use from another library, you don’t have to rearrange your internal organization: instead, you can re-export items to make a public structure that’s different from your private structure by using pub use. Re-exporting takes a public item in one location and makes it public in another location, as if it were defined in the other location instead.

For example, say we made a library named art for modeling artistic concepts. Within this library are two modules: a kinds module containing two enums named PrimaryColor and SecondaryColor and a utils module containing a function named mix, as shown in Listing 14-3.

upstream/main

Filename: src/lib.rs
//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --snip--
        unimplemented!();
    }
}
Listing 14-3: یک کتابخانه art با آیتم‌هایی که در ماژول‌های kinds و utils سازماندهی شده‌اند

<<<<<<< HEAD

شکل 14-3 نشان می‌دهد که صفحه اول مستندات این crate که توسط cargo doc تولید شده است چگونه به نظر می‌رسد:

Figure 14-3 shows what the front page of the documentation for this crate generated by cargo doc would look like.

upstream/main

مستندات تولید شده برای crate `art` که ماژول‌های `kinds` و `utils` را لیست می‌کند

شکل 14-3: صفحه اول مستندات crate art که ماژول‌های kinds و utils را لیست می‌کند

توجه کنید که انواع PrimaryColor و SecondaryColor در صفحه اول لیست نشده‌اند، و تابع mix نیز لیست نشده است. برای دیدن آن‌ها باید روی kinds و utils کلیک کنیم.

<<<<<<< HEAD

یک crate دیگر که به این کتابخانه وابسته است نیاز دارد که بیانیه‌های use مشخص کنند که آیتم‌ها را از art به scope می‌آورند، و ساختار ماژول تعریف‌شده کنونی را بیان کنند. لیستینگ 14-4 یک مثال از crate‌ای که آیتم‌های PrimaryColor و mix را از crate art استفاده می‌کند نشان می‌دهد:

Another crate that depends on this library would need use statements that bring the items from art into scope, specifying the module structure that’s currently defined. Listing 14-4 shows an example of a crate that uses the PrimaryColor and mix items from the art crate.

upstream/main

Filename: src/main.rs
use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
Listing 14-4: A crate using the art crate’s items with its internal structure exported

نویسنده کدی که در لیستینگ 14-4 نشان داده شده و از crate art استفاده می‌کند، مجبور بوده متوجه شود که PrimaryColor در ماژول kinds و mix در ماژول utils قرار دارد. ساختار ماژول crate art بیشتر برای توسعه‌دهندگانی که روی این crate کار می‌کنند مرتبط است تا کسانی که از آن استفاده می‌کنند. ساختار داخلی اطلاعات مفیدی برای کسی که می‌خواهد نحوه استفاده از crate art را بفهمد ارائه نمی‌دهد، بلکه بیشتر باعث سردرگمی می‌شود، زیرا توسعه‌دهندگانی که از آن استفاده می‌کنند باید بفهمند کجا را باید جستجو کنند و نام‌های ماژول را در بیانیه‌های use مشخص کنند.

<<<<<<< HEAD

برای حذف سازمان‌دهی داخلی از API عمومی، می‌توانیم کد crate art را در لیستینگ 14-3 تغییر دهیم تا بیانیه‌های pub use را برای صادرات مجدد آیتم‌ها در سطح بالا اضافه کنیم، همان‌طور که در لیستینگ 14-5 نشان داده شده است:

To remove the internal organization from the public API, we can modify the art crate code in Listing 14-3 to add pub use statements to re-export the items at the top level, as shown in Listing 14-5.

upstream/main

Filename: src/lib.rs
//! # Art
//!
//! A library for modeling artistic concepts.

pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    // --snip--
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    // --snip--
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        SecondaryColor::Orange
    }
}
Listing 14-5: افزودن بیانیه‌های pub use برای صادرات مجدد آیتم‌ها

مستندات API که cargo doc برای این crate تولید می‌کند اکنون صادرات‌های مجدد را در صفحه اول لیست کرده و به آن‌ها لینک می‌دهد، همان‌طور که در شکل 14-4 نشان داده شده است. این کار پیدا کردن انواع PrimaryColor و SecondaryColor و تابع mix را آسان‌تر می‌کند.

مستندات تولیدشده برای crate `art` با صادرات‌های مجدد در صفحه اول

شکل 14-4: صفحه اول مستندات برای crate art که صادرات‌های مجدد را لیست می‌کند

<<<<<<< HEAD

کاربران crate art همچنان می‌توانند ساختار داخلی را از لیستینگ 14-3 ببینند و استفاده کنند، همان‌طور که در لیستینگ 14-4 نشان داده شده است، یا می‌توانند از ساختار راحت‌تر در لیستینگ 14-5 استفاده کنند، همان‌طور که در لیستینگ 14-6 نشان داده شده است:

The art crate users can still see and use the internal structure from Listing 14-3 as demonstrated in Listing 14-4, or they can use the more convenient structure in Listing 14-5, as shown in Listing 14-6.

upstream/main

Filename: src/main.rs
use art::PrimaryColor;
use art::mix;

fn main() {
    // --snip--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
Listing 14-6: یک برنامه که از آیتم‌های صادرات‌شده مجدد crate art استفاده می‌کند

در مواردی که ماژول‌های تو در تو زیادی وجود دارند، صادرات مجدد انواع در سطح بالا با pub use می‌تواند تفاوت بزرگی در تجربه افرادی که از crate استفاده می‌کنند ایجاد کند. یکی دیگر از استفاده‌های رایج pub use، صادرات مجدد تعاریف یک وابستگی در crate فعلی برای تبدیل تعاریف آن به بخشی از API عمومی crate شما است.

ایجاد یک ساختار API عمومی مفید بیشتر شبیه یک هنر است تا یک علم، و می‌توانید با آزمون و خطا API‌ای پیدا کنید که بهترین کارکرد را برای کاربران شما داشته باشد. انتخاب pub use به شما انعطاف می‌دهد که چگونه crate خود را به صورت داخلی ساختار دهید و آن ساختار داخلی را از چیزی که به کاربران خود ارائه می‌دهید جدا کنید. به برخی از کدهای crate‌هایی که نصب کرده‌اید نگاهی بیندازید تا ببینید آیا ساختار داخلی آن‌ها با API عمومی آن‌ها تفاوت دارد یا خیر.

تنظیم یک حساب در Crates.io

قبل از اینکه بتوانید هر crate‌ای را منتشر کنید، نیاز دارید که یک حساب در crates.io ایجاد کنید و یک توکن API دریافت کنید. برای این کار، به صفحه اصلی در crates.io بروید و از طریق حساب GitHub وارد شوید. (در حال حاضر حساب GitHub یک نیاز است، اما ممکن است سایت در آینده از روش‌های دیگری برای ایجاد حساب پشتیبانی کند.) پس از ورود به سیستم، به تنظیمات حساب خود در https://crates.io/me/ بروید و کلید API خود را دریافت کنید. سپس دستور cargo login را اجرا کرده و کلید API خود را وارد کنید، مانند این:

$ cargo login
abcdefghijklmnopqrstuvwxyz012345

<<<<<<< HEAD

این دستور Cargo را از توکن API شما مطلع کرده و آن را به صورت محلی در فایل ~/.cargo/credentials ذخیره می‌کند. توجه داشته باشید که این توکن یک راز است: آن را با هیچ‌کس دیگری به اشتراک نگذارید. اگر به هر دلیلی این توکن را با کسی به اشتراک گذاشتید، باید آن را لغو کنید و یک توکن جدید در crates.io ایجاد کنید.

This command will inform Cargo of your API token and store it locally in ~/.cargo/credentials.toml. Note that this token is a secret: do not share it with anyone else. If you do share it with anyone for any reason, you should revoke it and generate a new token on crates.io.

upstream/main

افزودن متادیتا به یک Crate جدید

فرض کنید یک crate دارید که می‌خواهید منتشر کنید. قبل از انتشار، نیاز دارید که برخی متادیتا را در بخش [package] فایل Cargo.toml crate خود اضافه کنید.

crate شما باید یک نام منحصر به فرد داشته باشد. در حالی که به صورت محلی روی یک crate کار می‌کنید، می‌توانید هر نامی که دوست دارید برای crate خود انتخاب کنید. با این حال، نام‌های crate در crates.io به صورت اولین درخواست‌کننده تخصیص داده می‌شوند. هنگامی که یک نام برای یک crate گرفته شود، هیچ کس دیگری نمی‌تواند یک crate با آن نام منتشر کند. قبل از تلاش برای انتشار یک crate، جستجو کنید که نامی که می‌خواهید استفاده کنید در دسترس است یا خیر. اگر نام استفاده شده باشد، باید یک نام دیگر پیدا کنید و فیلد name را در فایل Cargo.toml در زیر بخش [package] ویرایش کنید تا از نام جدید برای انتشار استفاده کنید، مانند زیر:

Filename: Cargo.toml

[package]
name = "guessing_game"

<<<<<<< HEAD

حتی اگر یک نام منحصر به فرد انتخاب کرده باشید، زمانی که cargo publish را برای انتشار crate در این مرحله اجرا کنید، یک هشدار و سپس یک خطا دریافت خواهید کرد:

Even if you’ve chosen a unique name, when you run cargo publish to publish the crate at this point, you’ll get a warning and then an error:

upstream/main

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--snip--
error: failed to publish to registry at https://crates.io

Caused by:
  the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields

<<<<<<< HEAD

این خطا به دلیل این است که شما برخی اطلاعات حیاتی را از دست داده‌اید: یک توضیح و یک مجوز مورد نیاز است تا افراد بدانند crate شما چه کاری انجام می‌دهد و تحت چه شرایطی می‌توانند از آن استفاده کنند. در فایل Cargo.toml، یک توضیح اضافه کنید که فقط یک یا دو جمله باشد، زیرا این توضیح همراه crate شما در نتایج جستجو ظاهر خواهد شد. برای فیلد license، باید یک مقدار شناسگر مجوز ارائه دهید. پروژه Software Package Data Exchange (SPDX) لیستی از شناسگرهایی که می‌توانید برای این مقدار استفاده کنید را ارائه می‌دهد. برای مثال، برای مشخص کردن اینکه crate خود را با استفاده از مجوز MIT منتشر کرده‌اید، شناسگر MIT را اضافه کنید:

This results in an error because you’re missing some crucial information: a description and license are required so people will know what your crate does and under what terms they can use it. In Cargo.toml, add a description that’s just a sentence or two, because it will appear with your crate in search results. For the license field, you need to give a license identifier value. The Linux Foundation’s Software Package Data Exchange (SPDX) lists the identifiers you can use for this value. For example, to specify that you’ve licensed your crate using the MIT License, add the MIT identifier:

upstream/main

Filename: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

اگر می‌خواهید از مجوزی استفاده کنید که در لیست SPDX موجود نیست، باید متن آن مجوز را در یک فایل قرار دهید، فایل را در پروژه خود اضافه کنید و سپس از کلید license-file برای مشخص کردن نام آن فایل به جای استفاده از کلید license استفاده کنید.

راهنمایی درباره اینکه کدام مجوز برای پروژه شما مناسب است، فراتر از محدوده این کتاب است. بسیاری از افراد در جامعه Rust پروژه‌های خود را به همان روشی که Rust مجوز داده است، با استفاده از یک مجوز دوگانه MIT OR Apache-2.0 مجوز می‌دهند. این روش نشان می‌دهد که شما می‌توانید چندین شناسه مجوز را با جدا کردن آن‌ها با OR مشخص کنید تا چندین مجوز برای پروژه خود داشته باشید.

با یک نام منحصر به فرد، نسخه، توضیحات، و یک مجوز اضافه شده، فایل Cargo.toml برای یک پروژه آماده انتشار ممکن است به این صورت باشد:

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

<<<<<<< HEAD

مستندات Cargo سایر متادیتاهایی که می‌توانید مشخص کنید تا دیگران بتوانند crate شما را راحت‌تر پیدا کرده و استفاده کنند توضیح می‌دهد.

Cargo’s documentation describes other metadata you can specify to ensure that others can discover and use your crate more easily.

upstream/main

انتشار در Crates.io

اکنون که یک حساب ایجاد کرده‌اید، توکن API خود را ذخیره کرده‌اید، نامی برای crate خود انتخاب کرده‌اید، و متادیتای مورد نیاز را مشخص کرده‌اید، آماده انتشار هستید! انتشار یک crate نسخه‌ای خاص از آن را در crates.io آپلود می‌کند تا دیگران بتوانند از آن استفاده کنند.

<<<<<<< HEAD

دقت کنید، زیرا انتشار دائمی است. نسخه هرگز نمی‌تواند بازنویسی شود، و کد نمی‌تواند حذف شود. یکی از اهداف اصلی crates.io این است که به عنوان یک آرشیو دائمی از کد عمل کند، به طوری که ساخت‌های همه پروژه‌هایی که به crates از crates.io وابسته هستند، همچنان کار کنند. اجازه حذف نسخه‌ها تحقق این هدف را غیرممکن می‌کند. با این حال، هیچ محدودیتی برای تعداد نسخه‌های crate که می‌توانید منتشر کنید وجود ندارد.

Be careful, because a publish is permanent. The version can never be overwritten, and the code cannot be deleted except in certain circumstances. One major goal of Crates.io is to act as a permanent archive of code so that builds of all projects that depend on crates from crates.io will continue to work. Allowing version deletions would make fulfilling that goal impossible. However, there is no limit to the number of crate versions you can publish.

upstream/main

دستور cargo publish را دوباره اجرا کنید. اکنون باید موفق شود:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
    Packaged 6 files, 1.2KiB (895.0B compressed)
   Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
   Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
   Uploading guessing_game v0.1.0 (file:///projects/guessing_game)
    Uploaded guessing_game v0.1.0 to registry `crates-io`
note: waiting for `guessing_game v0.1.0` to be available at registry
`crates-io`.
You may press ctrl-c to skip waiting; the crate should be available shortly.
   Published guessing_game v0.1.0 at registry `crates-io`

تبریک می‌گویم! شما اکنون کد خود را با جامعه Rust به اشتراک گذاشته‌اید و هر کسی می‌تواند به راحتی crate شما را به عنوان یک وابستگی به پروژه خود اضافه کند.

انتشار نسخه جدیدی از یک Crate موجود

<<<<<<< HEAD

وقتی تغییراتی در crate خود ایجاد کرده‌اید و آماده انتشار یک نسخه جدید هستید، مقدار version مشخص‌شده در فایل Cargo.toml خود را تغییر داده و دوباره منتشر کنید. از قوانین نسخه‌بندی معنایی (Semantic Versioning) استفاده کنید تا تصمیم بگیرید که بر اساس نوع تغییراتی که ایجاد کرده‌اید، چه شماره نسخه‌ای مناسب است. سپس دستور cargo publish را اجرا کنید تا نسخه جدید آپلود شود.

When you’ve made changes to your crate and are ready to release a new version, you change the version value specified in your Cargo.toml file and republish. Use the Semantic Versioning rules to decide what an appropriate next version number is, based on the kinds of changes you’ve made. Then run cargo publish to upload the new version.

upstream/main

از رده خارج کردن نسخه‌ها از Crates.io با استفاده از cargo yank

<<<<<<< HEAD اگرچه نمی‌توانید نسخه‌های قبلی یک crate را حذف کنید، می‌توانید از اضافه شدن آن‌ها به عنوان وابستگی جدید در پروژه‌های آینده جلوگیری کنید. این ویژگی زمانی مفید است که یک نسخه از crate به هر دلیلی خراب باشد. در چنین مواردی، Cargo از یَنک کردن (yanking) یک نسخه از crate پشتیبانی می‌کند.

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

Although you can’t remove previous versions of a crate, you can prevent any future projects from adding them as a new dependency. This is useful when a crate version is broken for one reason or another. In such situations, Cargo supports yanking a crate version.

Yanking a version prevents new projects from depending on that version while allowing all existing projects that depend on it to continue. Essentially, a yank means that all projects with a Cargo.lock will not break, and any future Cargo.lock files generated will not use the yanked version.

upstream/main

برای یَنک کردن یک نسخه از یک crate، در دایرکتوری crate‌ای که قبلاً منتشر کرده‌اید، دستور cargo yank را اجرا کرده و نسخه‌ای که می‌خواهید یَنک کنید را مشخص کنید. به عنوان مثال، اگر ما یک crate به نام guessing_game نسخه 1.0.1 منتشر کرده باشیم و بخواهیم آن را یَنک کنیم، در دایرکتوری پروژه guessing_game این دستور را اجرا می‌کنیم:

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank [email protected]

با افزودن گزینه --undo به دستور، می‌توانید یَنک را لغو کرده و به پروژه‌ها اجازه دهید دوباره به آن نسخه وابسته شوند:

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank [email protected]

یَنک هیچ کدی را حذف نمی‌کند. به عنوان مثال، نمی‌تواند اطلاعات حساسی که به طور تصادفی آپلود شده‌اند را حذف کند. اگر چنین اتفاقی افتاد، باید فوراً آن اطلاعات حساس را بازنشانی کنید.

Workspaces در Cargo

در فصل 12، ما یک پکیج ساختیم که شامل یک crate باینری و یک crate کتابخانه‌ای بود. همان‌طور که پروژه شما توسعه می‌یابد، ممکن است متوجه شوید که crate کتابخانه‌ای همچنان بزرگ‌تر می‌شود و بخواهید پکیج خود را بیشتر به crate‌های کتابخانه‌ای چندگانه تقسیم کنید. Cargo یک ویژگی به نام workspaces ارائه می‌دهد که می‌تواند به مدیریت پکیج‌های مرتبط که به صورت همزمان توسعه داده می‌شوند کمک کند.

Creating a Workspace

A workspace is a set of packages that share the same Cargo.lock and output directory. Let’s make a project using a workspace—we’ll use trivial code so we can concentrate on the structure of the workspace. There are multiple ways to structure a workspace, so we’ll just show one common way. We’ll have a workspace containing a binary and two libraries. The binary, which will provide the main functionality, will depend on the two libraries. One library will provide an add_one function, and a second library an add_two function. These three crates will be part of the same workspace. We’ll start by creating a new directory for the workspace:

$ mkdir add
$ cd add

Next, in the add directory, we create the Cargo.toml file that will configure the entire workspace. This file won’t have a [package] section. Instead, it will start with a [workspace] section that will allow us to add members to the workspace. We also make a point to use the latest and greatest version of Cargo’s resolver algorithm in our workspace by setting the resolver to "2".

Filename: Cargo.toml

[workspace]
resolver = "3"

Next, we’ll create the adder binary crate by running cargo new within the add directory:

$ cargo new adder
     Created binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

Running cargo new inside a workspace also automatically adds the newly created package to the members key in the [workspace] definition in the workspace Cargo.toml, like this:

[workspace]
resolver = "3"
members = ["adder"]

در این مرحله، می‌توانیم workspace را با اجرای دستور cargo build بسازیم. فایل‌های موجود در دایرکتوری add شما باید به این صورت باشند:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

Workspace یک دایرکتوری target در سطح بالا دارد که فایل‌های کامپایل‌شده در آن قرار خواهند گرفت. پکیج adder دایرکتوری target اختصاصی خود را ندارد. حتی اگر دستور cargo build را از داخل دایرکتوری adder اجرا کنیم، فایل‌های کامپایل‌شده همچنان در add/target قرار می‌گیرند نه در add/adder/target. Cargo دایرکتوری target را در یک workspace به این صورت ساختاردهی می‌کند زیرا crate‌های موجود در یک workspace برای وابستگی به یکدیگر طراحی شده‌اند. اگر هر crate دایرکتوری target اختصاصی خود را داشت، هر crate مجبور بود هر کدام از crate‌های دیگر را در workspace دوباره کامپایل کند تا فایل‌های کامپایل‌شده را در دایرکتوری target خود قرار دهد. با به اشتراک‌گذاری یک دایرکتوری target، crate‌ها می‌توانند از ساخت مجدد غیرضروری جلوگیری کنند.

ایجاد پکیج دوم در Workspace

Next, let’s create another member package in the workspace and call it add_one. Change the top-level Cargo.toml to specify the add_one path in the members list:

Filename: Cargo.toml

[workspace]
resolver = "3"
members = ["adder", "add_one"]

Then generate a new library crate named add_one:

$ cargo new add_one --lib
     Created library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

The top-level Cargo.toml will now include the add_one path in the members list:

Filename: Cargo.toml

[workspace]
resolver = "3"
members = ["adder", "add_one"]

دایرکتوری add شما اکنون باید شامل این دایرکتوری‌ها و فایل‌ها باشد:

├── Cargo.lock
├── Cargo.toml
├── add_one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

در فایل add_one/src/lib.rs، تابعی به نام add_one اضافه کنیم:

Filename: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

حالا می‌توانیم پکیج adder که حاوی باینری ما است را وابسته به پکیج add_one که حاوی کتابخانه ما است کنیم. ابتدا باید یک وابستگی مسیر (path dependency) به add_one در فایل adder/Cargo.toml اضافه کنیم.

Filename: adder/Cargo.toml

[dependencies]
add_one = { path = "../add_one" }

Cargo فرض نمی‌کند که crate‌های موجود در یک workspace به یکدیگر وابسته هستند، بنابراین ما باید به صراحت روابط وابستگی را مشخص کنیم.

در ادامه، بیایید از تابع add_one (از crate به نام add_one) در crate به نام adder استفاده کنیم. فایل adder/src/main.rs را باز کنید و تابع main را تغییر دهید تا تابع add_one را فراخوانی کند، همان‌طور که در لیست ۱۴-۷ نشان داده شده است.

Filename: adder/src/main.rs
fn main() {
    let num = 10;
    println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}
Listing 14-7: Using the add_one library crate in the adder crate

بیایید workspace را با اجرای دستور cargo build در دایرکتوری سطح بالای add بسازیم!

$ cargo build
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s

برای اجرای crate باینری از دایرکتوری add، می‌توانیم با استفاده از آرگومان -p و نام پکیج همراه با دستور cargo run مشخص کنیم کدام پکیج در workspace اجرا شود:

$ cargo run -p adder
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

این کد در فایل adder/src/main.rs را اجرا می‌کند که به crate add_one وابسته است.

وابستگی به یک پکیج خارجی در یک Workspace

توجه کنید که workspace فقط یک فایل Cargo.lock در سطح بالا دارد، به جای اینکه هر crate دایرکتوری خود فایل Cargo.lock داشته باشد. این اطمینان حاصل می‌کند که تمام crate‌ها از همان نسخه تمام وابستگی‌ها استفاده می‌کنند. اگر پکیج rand را به فایل‌های adder/Cargo.toml و add_one/Cargo.toml اضافه کنیم، Cargo هر دو را به یک نسخه از rand تبدیل می‌کند و آن را در فایل Cargo.lock ثبت می‌کند. اطمینان از اینکه همه crate‌های موجود در workspace از همان وابستگی‌ها استفاده می‌کنند، به این معناست که crate‌ها همیشه با یکدیگر سازگار خواهند بود. بیایید پکیج rand را به بخش [dependencies] در فایل add_one/Cargo.toml اضافه کنیم تا بتوانیم از crate rand در crate add_one استفاده کنیم:

Filename: add_one/Cargo.toml

[dependencies]
rand = "0.8.5"

حالا می‌توانیم use rand; را به فایل add_one/src/lib.rs اضافه کنیم و با اجرای دستور cargo build در دایرکتوری add کل workspace را بسازیم، که crate rand را وارد کرده و کامپایل خواهد کرد. یک هشدار دریافت خواهیم کرد زیرا به rand که به محدوده وارد شده است اشاره‌ای نمی‌کنیم:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
   --snip--
   Compiling rand v0.8.5
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
warning: unused import: `rand`
 --> add_one/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `add_one` (lib) generated 1 warning (run `cargo fix --lib -p add_one` to apply 1 suggestion)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s

فایل Cargo.lock در سطح بالا اکنون اطلاعاتی درباره وابستگی add_one به rand دارد. با این حال، حتی اگر rand در جایی از workspace استفاده شود، نمی‌توانیم از آن در crate‌های دیگر workspace استفاده کنیم مگر اینکه rand را به فایل‌های Cargo.toml آن‌ها نیز اضافه کنیم. برای مثال، اگر use rand; را به فایل adder/src/main.rs برای پکیج adder اضافه کنیم، با خطا مواجه خواهیم شد:

$ cargo build
  --snip--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
 --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`

To fix this, edit the Cargo.toml file for the adder package and indicate that rand is a dependency for it as well. Building the adder package will add rand to the list of dependencies for adder in Cargo.lock, but no additional copies of rand will be downloaded. Cargo will ensure that every crate in every package in the workspace using the rand package will be using the same version as long as they specify compatible versions of rand, saving us space and ensuring that the crates in the workspace will be compatible with each other.

If crates in the workspace specify incompatible versions of the same dependency, Cargo will resolve each of them, but will still try to resolve as few versions as possible.

افزودن یک تست به یک Workspace

برای یک بهبود دیگر، بیایید یک تست برای تابع add_one::add_one در crate add_one اضافه کنیم:

Filename: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

حالا دستور cargo test را در دایرکتوری سطح بالای add اجرا کنید. اجرای دستور cargo test در یک workspace با ساختاری مانند این، تست‌های تمام crate‌های موجود در workspace را اجرا خواهد کرد:

$ cargo test
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/adder-3a47283c568d2b6a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

بخش اول خروجی نشان می‌دهد که تست it_works در crate add_one پاس شده است. بخش بعدی نشان می‌دهد که هیچ تستی در crate adder پیدا نشده است، و سپس بخش آخر نشان می‌دهد که هیچ تست مستنداتی در crate add_one پیدا نشده است.

ما همچنین می‌توانیم تست‌های یک crate خاص در workspace را از دایرکتوری سطح بالا با استفاده از گزینه -p و مشخص کردن نام crate‌ای که می‌خواهیم تست کنیم، اجرا کنیم:

$ cargo test -p add_one
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

این خروجی نشان می‌دهد که cargo test فقط تست‌های crate add_one را اجرا کرده و تست‌های crate adder را اجرا نکرده است.

If you publish the crates in the workspace to crates.io, each crate in the workspace will need to be published separately. Like cargo test, we can publish a particular crate in our workspace by using the -p flag and specifying the name of the crate we want to publish.

For additional practice, add an add_two crate to this workspace in a similar way as the add_one crate!

As your project grows, consider using a workspace: it’s easier to understand smaller, individual components than one big blob of code. Furthermore, keeping the crates in a workspace can make coordination between crates easier if they are often changed at the same time.

نصب باینری‌ها با استفاده از cargo install

دستور cargo install به شما این امکان را می‌دهد که crate‌های باینری را به صورت محلی نصب و استفاده کنید. این دستور به‌منظور جایگزینی بسته‌های سیستمی طراحی نشده است؛ بلکه یک راه آسان برای توسعه‌دهندگان Rust فراهم می‌کند تا ابزارهایی که دیگران در crates.io به اشتراک گذاشته‌اند را نصب کنند. توجه داشته باشید که فقط پکیج‌هایی را که دارای هدف باینری هستند می‌توانید نصب کنید. هدف باینری برنامه قابل‌اجرا است که در صورتی ایجاد می‌شود که crate شامل یک فایل src/main.rs یا فایل دیگری باشد که به عنوان باینری مشخص شده است. این در مقابل هدف کتابخانه‌ای قرار دارد که به تنهایی قابل اجرا نیست، اما برای استفاده در سایر برنامه‌ها مناسب است. معمولاً crate‌ها در فایل README اطلاعاتی در مورد اینکه آیا یک crate کتابخانه است، دارای هدف باینری است یا هر دو، ارائه می‌دهند.

تمام باینری‌هایی که با دستور cargo install نصب می‌شوند، در پوشه‌ی bin مسیر نصب ذخیره می‌شوند. اگر Rust را با استفاده از rustup.rs نصب کرده باشید و هیچ پیکربندی خاصی انجام نداده باشید، این دایرکتوری به صورت $HOME/.cargo/bin خواهد بود. اطمینان حاصل کنید که این دایرکتوری در متغیر محیطی $PATH شما قرار دارد تا بتوانید برنامه‌هایی را که با cargo install نصب کرده‌اید اجرا کنید.

برای مثال، در فصل 12 اشاره کردیم که یک پیاده‌سازی Rust از ابزار grep به نام ripgrep وجود دارد که برای جستجوی فایل‌ها استفاده می‌شود. برای نصب ripgrep می‌توانیم دستور زیر را اجرا کنیم:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v14.1.1
  Downloaded 1 crate (213.6 KB) in 0.40s
  Installing ripgrep v14.1.1
--snip--
   Compiling grep v0.3.2
    Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v14.1.1` (executable `rg`)

خط ماقبل آخر خروجی، محل و نام باینری نصب‌شده را نشان می‌دهد که در مورد ripgrep، نام آن rg است. تا زمانی که دایرکتوری نصب در متغیر محیطی $PATH شما قرار داشته باشد ــ همان‌طور که پیش‌تر ذکر شد ــ می‌توانید دستور rg --help را اجرا کرده و استفاده از یک ابزار جستجوی فایل سریع‌تر و Rust-محور را آغاز کنید!

گسترش Cargo با دستورات سفارشی

با این حال، همچنان باید به‌صورت صریح نوع رفرنس پین‌شده را مشخص کنیم؛ در غیر این صورت، Rust نمی‌داند که این‌ها باید به عنوان trait objectهای داینامیک تفسیر شوند، که این همان چیزی است که در Vec به آن نیاز داریم. بنابراین، pin را به لیست واردات‌مان از std::pin اضافه می‌کنیم. سپس می‌توانیم هر future را هنگام تعریف آن با pin! پین کنیم و futures را به‌صورت یک Vec شامل رفرنس‌های mutable پین‌شده به نوع dynamic future تعریف کنیم، همان‌طور که در لیستینگ 17-19 نشان داده شده است.

خلاصه

اشتراک‌گذاری کد با Cargo و crates.io بخشی از عواملی است که اکوسیستم Rust را برای بسیاری از وظایف مختلف مفید می‌کند. کتابخانه استاندارد Rust کوچک و پایدار است، اما crate‌ها به راحتی قابل اشتراک‌گذاری، استفاده و بهبود هستند و با یک خط زمانی متفاوت از زبان توسعه می‌یابند. از اشتراک‌گذاری کدی که برای شما مفید است در crates.io خجالت نکشید؛ احتمالاً برای دیگران نیز مفید خواهد بود!

اشاره‌گر (Pointer)های هوشمند (Smart Pointers)

یک pointer یک مفهوم عمومی برای متغیری است که یک آدرس در حافظه را در خود نگه می‌دارد. این آدرس به داده‌ای دیگر اشاره می‌کند یا به‌عبارتی «نشان می‌دهد». رایج‌ترین نوع pointer در Rust یک رفرنس است، که در فصل ۴ با آن آشنا شدید. رفرنس‌ها با نماد & مشخص می‌شوند و مقدار مورد اشاره را قرض می‌گیرند. آن‌ها هیچ قابلیت ویژه‌ای جز اشاره به داده ندارند و هیچ سرباری نیز ندارند.

از سوی دیگر، اشاره‌گر (Pointer)های هوشمند ساختارهای داده‌ای هستند که مانند یک اشاره‌گر (Pointer) عمل می‌کنند، اما همچنین دارای فرا داده و قابلیت‌های اضافی هستند. مفهوم اشاره‌گر (Pointer)های هوشمند منحصراً به Rust اختصاص ندارد: اشاره‌گر (Pointer)های هوشمند در ابتدا در C++ معرفی شدند و در زبان‌های دیگر نیز وجود دارند. Rust مجموعه‌ای از اشاره‌گر (Pointer)های هوشمند در کتابخانه استاندارد خود دارد که عملکردی فراتر از آنچه که ارجاعات فراهم می‌کنند، ارائه می‌دهند. برای بررسی مفهوم کلی، به چند مثال مختلف از اشاره‌گر (Pointer)های هوشمند نگاهی خواهیم انداخت، از جمله نوع اشاره‌گر (Pointer) هوشمند شمارش ارجاعات. این اشاره‌گر (Pointer) به شما امکان می‌دهد تا داده‌ها مالکیت‌های متعددی داشته باشند، با ردیابی تعداد مالکان و پاک کردن داده هنگامی که هیچ مالکی باقی نماند.

زبان Rust با معرفی مفاهیمی مانند مالکیت و قرض‌گرفتن، تفاوت مهمی بین رفرنس‌ها و smart pointerها ایجاد کرده است: در حالی‌که رفرنس‌ها تنها داده را قرض می‌گیرند، در بسیاری از موارد smart pointerها مالکیت داده‌ای را که به آن اشاره می‌کنند در اختیار دارند.

smart pointerها معمولاً با استفاده از structها پیاده‌سازی می‌شوند. برخلاف structهای معمولی، smart pointerها traitهای Deref و Drop را پیاده‌سازی می‌کنند. trait مربوط به Deref این امکان را فراهم می‌کند که یک نمونه از smart pointer مانند یک رفرنس رفتار کند، به‌طوری‌که بتوانید کد خود را به‌گونه‌ای بنویسید که با هر دو ــ یعنی هم رفرنس‌ها و هم smart pointerها ــ کار کند. trait مربوط به Drop نیز به شما اجازه می‌دهد کدی را شخصی‌سازی کنید که هنگام خروج یک نمونه از smart pointer از حوزه (scope) اجرا می‌شود. در این فصل، هر دو trait را بررسی خواهیم کرد و نشان خواهیم داد که چرا این ویژگی‌ها برای smart pointerها اهمیت دارند.

از آنجا که الگوی اشاره‌گر (Pointer) هوشمند یک الگوی طراحی کلی است که به‌طور مکرر در Rust استفاده می‌شود، این فصل تمام اشاره‌گر (Pointer)های هوشمند موجود را پوشش نمی‌دهد. بسیاری از کتابخانه‌ها اشاره‌گر (Pointer)های هوشمند خاص خود را دارند و حتی می‌توانید اشاره‌گر (Pointer) هوشمند خود را بنویسید. ما رایج‌ترین اشاره‌گر (Pointer)های هوشمند در کتابخانه استاندارد را پوشش خواهیم داد:

  • Box<T> برای تخصیص مقادیر در heap
  • Rc<T>، یک نوع شمارش‌گر رفرنس که امکان مالکیت چندگانه را فراهم می‌کند
  • Ref<T> و RefMut<T>، که از طریق RefCell<T> قابل دسترسی هستند؛ نوعی که قوانین قرض‌گرفتن را در زمان اجرا به‌جای زمان کامپایل اعمال می‌کند

علاوه بر این، الگوی تغییرپذیری درونی (interior mutability) را بررسی خواهیم کرد، جایی که یک نوع غیرقابل‌تغییر، یک API برای تغییر مقدار درونی خود فراهم می‌کند. همچنین به چرخه‌های رفرنس (reference cycles) می‌پردازیم: اینکه چگونه می‌توانند باعث نشت حافظه شوند و چگونه می‌توان از آن‌ها جلوگیری کرد.

بیایید شروع کنیم!

استفاده از Box<T> برای اشاره به داده‌ها در Heap

ساده‌ترین اشاره‌گر (Pointer) هوشمند یک جعبه است که نوع آن به صورت Box<T> نوشته می‌شود. جعبه‌ها به شما امکان می‌دهند داده‌ها را در heap ذخیره کنید به جای stack. چیزی که در stack باقی می‌ماند، اشاره‌گر (Pointer)ی به داده‌های heap است. برای مرور تفاوت بین stack و heap به فصل ۴ مراجعه کنید.

جعبه‌ها هیچ سربار عملکردی ندارند، به‌جز ذخیره داده‌های خود در heap به جای stack. اما آن‌ها قابلیت‌های اضافی زیادی ندارند. شما اغلب آن‌ها را در این موقعیت‌ها استفاده خواهید کرد:

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

اولین حالت را در بخش “فعال‌سازی انواع بازگشتی با استفاده از جعبه‌ها” بررسی خواهیم کرد. در حالت دوم، انتقال مالکیت مقدار زیادی داده می‌تواند زمان زیادی بگیرد زیرا داده‌ها در stack کپی می‌شوند. برای بهبود عملکرد در این حالت، می‌توانیم مقدار زیادی داده را در heap و در یک جعبه ذخیره کنیم. سپس، تنها مقدار کمی از داده‌های اشاره‌گر (Pointer) در stack کپی می‌شود، در حالی که داده‌هایی که به آن‌ها اشاره می‌کند در یک مکان در heap باقی می‌مانند. حالت سوم به نام شیء صفت شناخته می‌شود و فصل ۱۸ بخشی کامل به نام “استفاده از اشیای صفت که به شما اجازه می‌دهند مقادیر از انواع مختلف داشته باشید” به این موضوع اختصاص داده است. بنابراین چیزی که اینجا یاد می‌گیرید، دوباره در فصل ۱۸ استفاده خواهید کرد!

استفاده از Box<T> برای ذخیره داده‌ها در Heap

قبل از اینکه مورد استفاده ذخیره در heap برای Box<T> را بحث کنیم، نحو و نحوه تعامل با مقادیر ذخیره‌شده در یک Box<T> را پوشش خواهیم داد.

لیستینگ ۱۵-۱ نشان می‌دهد چگونه می‌توان از یک جعبه برای ذخیره مقدار i32 در heap استفاده کرد:

Filename: src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}
Listing 15-1: ذخیره مقدار i32 در heap با استفاده از یک جعبه

ما متغیر b را تعریف می‌کنیم تا مقدار یک Box که به مقدار 5 اشاره می‌کند را داشته باشد، که در heap تخصیص داده شده است. این برنامه b = 5 را چاپ می‌کند؛ در این حالت، می‌توانیم به داده‌های موجود در جعبه دسترسی داشته باشیم، مشابه حالتی که این داده‌ها در stack بودند. درست مثل هر مقدار مالک، وقتی یک جعبه از دامنه خارج می‌شود، همان طور که b در پایان main این کار را می‌کند، آزاد می‌شود. آزادسازی هم برای جعبه (ذخیره‌شده در stack) و هم داده‌هایی که به آن اشاره می‌کند (ذخیره‌شده در heap) اتفاق می‌افتد.

قرار دادن یک مقدار واحد در heap خیلی مفید نیست، بنابراین جعبه‌ها را به‌تنهایی به این شکل خیلی استفاده نخواهید کرد. داشتن مقادیری مانند یک i32 در stack، جایی که به‌طور پیش‌فرض ذخیره می‌شوند، در اکثر موارد مناسب‌تر است. بیایید به حالتی نگاه کنیم که جعبه‌ها به ما امکان می‌دهند انواعی را تعریف کنیم که بدون آن‌ها نمی‌توانستیم.

فعال‌سازی انواع بازگشتی با استفاده از جعبه‌ها

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

به‌عنوان مثالی از یک نوع بازگشتی، بیایید به لیست cons نگاه کنیم. این یک نوع داده است که معمولاً در زبان‌های برنامه‌نویسی تابعی یافت می‌شود. نوع لیست cons که تعریف خواهیم کرد ساده است به جز بازگشت؛ بنابراین، مفاهیم موجود در مثالی که با آن کار خواهیم کرد، هر زمان که وارد موقعیت‌های پیچیده‌تری با انواع بازگشتی شوید مفید خواهند بود.

اطلاعات بیشتر درباره لیست Cons

یک لیست cons یک ساختار داده‌ای است که از زبان برنامه‌نویسی Lisp و گویش‌های آن می‌آید و از جفت‌های تودرتو تشکیل شده است و نسخه Lisp از یک لیست پیوندی است. نام آن از تابع cons (مخفف “تابع ساخت” یا Construct Function) در Lisp گرفته شده است که یک جفت جدید را از دو آرگومان خود می‌سازد. با فراخوانی cons روی یک جفت که شامل یک مقدار و یک جفت دیگر است، می‌توانیم لیست‌های cons ساخته‌شده از جفت‌های بازگشتی را ایجاد کنیم.

برای مثال، در اینجا یک نمایش شبه‌کد از یک لیست cons که شامل لیست ۱، ۲، ۳ است آورده شده است که هر جفت در داخل پرانتز قرار دارد:

(1, (2, (3, Nil)))

هر آیتم در یک لیست cons شامل دو عنصر است: مقدار آیتم فعلی و آیتم بعدی. آخرین آیتم در لیست تنها شامل مقداری به نام Nil است و آیتم بعدی ندارد. یک لیست cons با فراخوانی بازگشتی تابع cons تولید می‌شود. نام متعارف برای نشان دادن حالت پایه بازگشت، Nil است. توجه داشته باشید که این با مفهوم “null” یا “nil” در فصل ۶ که یک مقدار نامعتبر یا غایب است، متفاوت است.

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

لیستینگ ۱۵-۲ حاوی یک تعریف enum برای یک لیست cons است. توجه داشته باشید که این کد هنوز کامپایل نمی‌شود زیرا نوع List اندازه شناخته‌شده‌ای ندارد، که آن را توضیح خواهیم داد.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}
Listing 15-2: اولین تلاش برای تعریف یک enum برای نمایش یک ساختار داده‌ای لیست cons از مقادیر i32

توجه: ما در حال پیاده‌سازی یک لیست cons هستیم که تنها مقادیر i32 را نگه می‌دارد، برای اهداف این مثال. می‌توانستیم آن را با استفاده از جنریک‌ها، همان‌طور که در فصل ۱۰ بحث کردیم، پیاده‌سازی کنیم تا یک نوع لیست cons تعریف کنیم که بتواند مقادیر هر نوعی را ذخیره کند.

استفاده از نوع List برای ذخیره لیست 1, 2, 3 شبیه به کدی خواهد بود که در لیستینگ ۱۵-۳ آورده شده است:

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

// --snip--

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Listing 15-3: Using the List enum to store the list 1, 2, 3

اولین مقدار Cons مقدار 1 و یک مقدار دیگر از نوع List را نگه می‌دارد. این مقدار List یک مقدار دیگر از نوع Cons است که مقدار 2 و یک مقدار دیگر از نوع List را نگه می‌دارد. این مقدار List یک مقدار دیگر از نوع Cons را نگه می‌دارد که مقدار 3 و یک مقدار دیگر از نوع List را دارد که در نهایت Nil، متغیر غیر بازگشتی که پایان لیست را نشان می‌دهد، است.

اگر سعی کنیم کد در لیستینگ ۱۵-۳ را کامپایل کنیم، خطایی را دریافت می‌کنیم که در لیستینگ ۱۵-۴ نشان داده شده است:

Filename: output.txt
$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Listing 15-4: خطایی که هنگام تلاش برای تعریف یک enum بازگشتی دریافت می‌کنیم

خطا نشان می‌دهد که این نوع “اندازه بی‌نهایت” دارد. دلیل این است که ما List را با یک متغیر تعریف کرده‌ایم که بازگشتی است: به‌طور مستقیم یک مقدار دیگر از نوع خود را نگه می‌دارد. در نتیجه، Rust نمی‌تواند بفهمد چقدر فضا نیاز دارد تا یک مقدار از نوع List را ذخیره کند. بیایید بررسی کنیم چرا این خطا را دریافت می‌کنیم. ابتدا، نگاهی به این می‌اندازیم که Rust چگونه تصمیم می‌گیرد چه مقدار فضا برای ذخیره یک مقدار از نوع غیر بازگشتی نیاز دارد.

محاسبه اندازه یک نوع غیر بازگشتی

ساختار Message را که در لیستینگ ۶-۲ تعریف کرده‌ایم، به‌خاطر بیاورید وقتی که در فصل ۶ در مورد تعریف‌های enum بحث کردیم:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

برای تعیین اینکه چقدر فضا برای یک مقدار از نوع Message اختصاص داده شود، Rust هر یک از متغیرها را بررسی می‌کند تا ببیند کدام متغیر بیشترین فضا را نیاز دارد. Rust می‌بیند که Message::Quit نیازی به فضا ندارد، Message::Move نیاز به فضای کافی برای ذخیره دو مقدار i32 دارد، و همین‌طور ادامه می‌دهد. چون تنها یک متغیر استفاده خواهد شد، بیشترین فضای مورد نیاز برای یک مقدار Message فضایی است که بزرگ‌ترین متغیر آن اشغال می‌کند.

این را با حالتی مقایسه کنید که Rust سعی می‌کند تعیین کند چه مقدار فضا برای یک نوع بازگشتی مانند enum List در لیستینگ ۱۵-۲ نیاز است. کامپایلر با نگاه کردن به متغیر Cons شروع می‌کند که یک مقدار از نوع i32 و یک مقدار از نوع List را نگه می‌دارد. بنابراین، Cons به فضایی معادل اندازه یک i32 به‌علاوه اندازه یک List نیاز دارد. برای فهمیدن اینکه نوع List به چه مقدار حافظه نیاز دارد، کامپایلر متغیرها را بررسی می‌کند و از متغیر Cons شروع می‌کند. متغیر Cons یک مقدار از نوع i32 و یک مقدار از نوع List را نگه می‌دارد، و این فرآیند به‌طور بی‌نهایت ادامه می‌یابد، همان‌طور که در شکل ۱۵-۱ نشان داده شده است.

یک لیست Cons بی‌نهایت: یک مستطیل با برچسب 'Cons' که به دو مستطیل کوچکتر تقسیم شده است. مستطیل اول دارای برچسب 'i32' و مستطیل دوم دارای برچسب 'Cons' است و نسخه‌ای کوچکتر از مستطیل بیرونی 'Cons' را در خود دارد. این مستطیل‌های 'Cons' همچنان نسخه‌های کوچکتری از خود را درون خود نگه می‌دارند تا زمانی که کوچک‌ترین مستطیل قابل‌نمایش یک نماد بی‌نهایت را در خود دارد، که نشان می‌دهد این تکرار تا بی‌نهایت ادامه می‌یابد.

شکل ۱۵-۱: یک List بی‌نهایت شامل متغیرهای Cons بی‌نهایت

استفاده از Box<T> برای به‌دست آوردن یک نوع بازگشتی با اندازه شناخته‌شده

چون Rust نمی‌تواند بفهمد چه مقدار فضا باید برای انواع تعریف‌شده به‌صورت بازگشتی تخصیص دهد، کامپایلر با این پیشنهاد کمکی خطا می‌دهد:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

در این پیشنهاد، “غیرمستقیم‌سازی” به این معنا است که به‌جای ذخیره مستقیم یک مقدار، باید ساختار داده را تغییر دهیم تا مقدار را به‌صورت غیرمستقیم با ذخیره یک اشاره‌گر (Pointer) به مقدار ذخیره کند.

چون Box<T> یک اشاره‌گر (Pointer) است، Rust همیشه می‌داند که یک Box<T> به چه مقدار فضا نیاز دارد: اندازه یک اشاره‌گر (Pointer) بر اساس مقدار داده‌ای که به آن اشاره می‌کند تغییر نمی‌کند. این بدان معنا است که می‌توانیم یک Box<T> را در متغیر Cons قرار دهیم به‌جای یک مقدار دیگر از نوع List. Box<T> به مقدار بعدی List اشاره می‌کند که روی heap خواهد بود به‌جای داخل متغیر Cons. به‌صورت مفهومی، ما همچنان یک لیست داریم که از لیست‌های دیگری تشکیل شده است، اما این پیاده‌سازی اکنون بیشتر شبیه قرار دادن آیتم‌ها در کنار یکدیگر است تا داخل یکدیگر.

ما می‌توانیم تعریف enum List در لیستینگ ۱۵-۲ و استفاده از List در لیستینگ ۱۵-۳ را به کد موجود در لیستینگ ۱۵-۵ تغییر دهیم، که کامپایل خواهد شد:

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Listing 15-5: تعریف List که از Box<T> استفاده می‌کند تا اندازه مشخصی داشته باشد

متغیر Cons به اندازه‌ای برابر با یک i32 به‌علاوه فضای لازم برای نگه‌داری داده‌های اشاره‌گر Box نیاز دارد. متغیر Nil هیچ مقداری را ذخیره نمی‌کند، بنابراین به فضای کمتری روی پشته نسبت به Cons نیاز دارد. اکنون می‌دانیم که هر مقدار از نوع List فضایی برابر با اندازه‌ی یک i32 به‌علاوه اندازه‌ی داده‌ی اشاره‌گر Box اشغال می‌کند. با استفاده از یک Box، زنجیره بازگشتی بی‌نهایت را شکسته‌ایم، بنابراین کامپایلر می‌تواند اندازه مورد نیاز برای ذخیره یک مقدار List را محاسبه کند. شکل 15-2 نشان می‌دهد که متغیر Cons اکنون چگونه به نظر می‌رسد.

یک مستطیل با برچسب 'Cons' که به دو مستطیل کوچکتر تقسیم شده است. مستطیل اول دارای برچسب 'i32' و مستطیل دوم دارای برچسب 'Box' است که یک مستطیل داخلی با برچسب 'usize' درون آن قرار دارد، که اندازه محدود اشاره‌گر درون Box را نشان می‌دهد.

شکل ۱۵-۲: یک List که بی‌نهایت نیست زیرا Cons یک Box نگه می‌دارد

جعبه‌ها تنها غیرمستقیم‌سازی و تخصیص heap را فراهم می‌کنند؛ آن‌ها هیچ قابلیت خاص دیگری ندارند، مانند آنچه با دیگر انواع اشاره‌گر (Pointer) هوشمند خواهیم دید. آن‌ها همچنین سربار عملکردی که این قابلیت‌های خاص ایجاد می‌کنند را ندارند، بنابراین می‌توانند در مواردی مانند لیست cons مفید باشند که غیرمستقیم‌سازی تنها ویژگی مورد نیاز است. ما موارد استفاده بیشتری از جعبه‌ها را نیز در فصل ۱۸ بررسی خواهیم کرد.

نوع Box<T> یک اشاره‌گر (Pointer) هوشمند است زیرا ویژگی Deref را پیاده‌سازی می‌کند، که به مقادیر Box<T> اجازه می‌دهد مانند ارجاعات رفتار کنند. وقتی یک مقدار Box<T> از دامنه خارج می‌شود، داده‌های heap که جعبه به آن اشاره می‌کند نیز به دلیل پیاده‌سازی ویژگی Drop پاک‌سازی می‌شود. این دو ویژگی برای عملکرد انواع دیگر اشاره‌گر (Pointer)های هوشمند که در بقیه این فصل مورد بحث قرار می‌دهیم، اهمیت بیشتری خواهند داشت. بیایید این دو ویژگی را با جزئیات بیشتری بررسی کنیم.

رفتار با اشاره‌گرهای هوشمند مانند رفرنس‌های معمولی با استفاده از Deref

پیاده‌سازی ویژگی Deref به شما امکان می‌دهد رفتار عملگر اشاره‌گر (Pointer)‌زدایی * را سفارشی کنید (این را با عملگر ضرب یا glob اشتباه نگیرید). با پیاده‌سازی Deref به گونه‌ای که یک اشاره‌گر (Pointer) هوشمند بتواند مانند یک ارجاع معمولی رفتار کند، می‌توانید کدی بنویسید که روی ارجاعات عمل می‌کند و از آن کد با اشاره‌گر (Pointer)های هوشمند نیز استفاده کنید.

ابتدا بیایید نگاهی به این بیندازیم که چگونه عملگر اشاره‌گر (Pointer)‌زدایی با ارجاعات معمولی کار می‌کند. سپس سعی می‌کنیم یک نوع سفارشی تعریف کنیم که مانند Box<T> رفتار کند، و بررسی کنیم چرا عملگر اشاره‌گر (Pointer)‌زدایی مانند یک ارجاع روی نوع جدید ما عمل نمی‌کند. ما بررسی می‌کنیم که چگونه پیاده‌سازی ویژگی Deref امکان‌پذیر می‌سازد که اشاره‌گر (Pointer)های هوشمند به شیوه‌ای مشابه ارجاعات عمل کنند. سپس نگاهی به ویژگی فشار اشاره‌گر (Pointer)‌زدایی (deref coercion) در Rust می‌اندازیم و اینکه چگونه به ما اجازه می‌دهد با ارجاعات یا اشاره‌گر (Pointer)های هوشمند کار کنیم.

توجه: یک تفاوت بزرگ بین نوع MyBox<T> که قرار است بسازیم و Box<T> واقعی وجود دارد: نسخه ما داده‌های خود را در heap ذخیره نمی‌کند. ما این مثال را بر روی Deref متمرکز کرده‌ایم، بنابراین مکانی که داده‌ها واقعاً در آن ذخیره می‌شوند کمتر از رفتار اشاره‌گر (Pointer)گونه اهمیت دارد.

دنبال کردن اشاره‌گر (Pointer) به مقدار

یک ارجاع معمولی نوعی اشاره‌گر (Pointer) است، و یکی از راه‌های فکر کردن به یک اشاره‌گر (Pointer) این است که به عنوان یک فلش به یک مقدار ذخیره‌شده در جای دیگری در نظر گرفته شود. در لیستینگ ۱۵-۶، ما یک ارجاع به یک مقدار i32 ایجاد می‌کنیم و سپس از عملگر اشاره‌گر (Pointer)‌زدایی برای دنبال کردن ارجاع به مقدار استفاده می‌کنیم:

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-6: استفاده از عملگر اشاره‌گر (Pointer)‌زدایی برای دنبال کردن یک ارجاع به یک مقدار i32

متغیر x مقدار i32 برابر با 5 را نگه می‌دارد. ما y را برابر با یک ارجاع به x تنظیم می‌کنیم. می‌توانیم تایید کنیم که x برابر با 5 است. با این حال، اگر بخواهیم یک تایید روی مقدار داخل y انجام دهیم، باید از *y برای دنبال کردن ارجاع به مقداری که به آن اشاره می‌کند استفاده کنیم (بنابراین اشاره‌گر (Pointer)‌زدایی) تا کامپایلر بتواند مقدار واقعی را مقایسه کند. وقتی y را اشاره‌گر (Pointer)‌زدایی می‌کنیم، به مقدار صحیحی که y به آن اشاره می‌کند دسترسی داریم و می‌توانیم آن را با 5 مقایسه کنیم.

اگر بخواهیم assert_eq!(5, y); بنویسیم، خطای کامپایل زیر را دریافت می‌کنیم:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

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

مقایسه یک عدد با یک ارجاع به عدد مجاز نیست زیرا آن‌ها انواع متفاوتی هستند. ما باید از عملگر اشاره‌گر (Pointer)‌زدایی برای دنبال کردن ارجاع به مقداری که به آن اشاره می‌کند استفاده کنیم.

استفاده از Box<T> مانند یک ارجاع

ما می‌توانیم کد لیستینگ ۱۵-۶ را برای استفاده از یک Box<T> به‌جای یک ارجاع بازنویسی کنیم؛ عملگر اشاره‌گر (Pointer)‌زدایی که روی Box<T> در لیستینگ ۱۵-۷ استفاده شده است، به همان شیوه‌ای عمل می‌کند که روی ارجاع در لیستینگ ۱۵-۶ عمل می‌کرد:

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-7: استفاده از عملگر اشاره‌گر (Pointer)‌زدایی روی یک Box<i32>

تفاوت اصلی بین لیستینگ ۱۵-۷ و لیستینگ ۱۵-۶ این است که در اینجا y را به‌عنوان یک نمونه از Box<T> تنظیم می‌کنیم که به یک مقدار کپی‌شده از x اشاره می‌کند، به‌جای یک ارجاع که به مقدار x اشاره می‌کند. در تایید نهایی، می‌توانیم از عملگر اشاره‌گر (Pointer)‌زدایی برای دنبال کردن اشاره‌گر (Pointer) Box<T> به همان شیوه‌ای که زمانی که y یک ارجاع بود استفاده کردیم. در ادامه بررسی می‌کنیم چه چیزی در مورد Box<T> خاص است که به ما اجازه می‌دهد از عملگر اشاره‌گر (Pointer)‌زدایی استفاده کنیم، با تعریف نوع خودمان.

تعریف اشاره‌گر (Pointer) هوشمند خودمان

بیایید یک نوع پوشاننده (wrapper type) مشابه با نوع Box<T> که توسط کتابخانه استاندارد ارائه شده است بسازیم تا تجربه کنیم که چگونه انواع اشاره‌گر هوشمند به‌طور پیش‌فرض رفتاری متفاوت از رفرنس‌ها دارند. سپس بررسی خواهیم کرد که چگونه می‌توان قابلیت استفاده از عملگر dereference را به آن افزود.

نکته: یک تفاوت بزرگ بین نوع MyBox<T> که در شرف ساخت آن هستیم و Box<T> واقعی وجود دارد: نسخه‌ی ما داده‌ها را در heap ذخیره نخواهد کرد. ما در این مثال بر Deref تمرکز داریم، بنابراین محل واقعی ذخیره‌سازی داده‌ها اهمیت کمتری نسبت به رفتار مشابه با اشاره‌گر دارد.

نوع Box<T> در نهایت به‌صورت یک tuple struct با یک عضو تعریف شده است، بنابراین در لیست 15-8 نوع MyBox<T> را به همان شیوه تعریف می‌کنیم. همچنین تابعی با نام new تعریف خواهیم کرد تا با تابع new که روی Box<T> تعریف شده، مطابقت داشته باشد.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}
Listing 15-8: تعریف نوع MyBox<T>

ما یک ساختار با نام MyBox تعریف می‌کنیم و یک پارامتر جنریک T اعلام می‌کنیم، زیرا می‌خواهیم نوع ما مقادیر از هر نوعی را نگه دارد. نوع MyBox یک ساختار tuple با یک عنصر از نوع T است. تابع MyBox::new یک پارامتر از نوع T می‌گیرد و یک نمونه از MyBox که مقدار ورودی را نگه می‌دارد برمی‌گرداند.

بیایید تابع main در لیستینگ ۱۵-۷ را به لیستینگ ۱۵-۸ اضافه کنیم و آن را برای استفاده از نوع MyBox<T> که تعریف کرده‌ایم، به جای Box<T> تغییر دهیم. کد موجود در لیستینگ ۱۵-۹ کامپایل نخواهد شد، زیرا Rust نمی‌داند چگونه MyBox را اشاره‌گر (Pointer)‌زدایی کند.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-9: تلاش برای استفاده از MyBox<T> به همان شیوه‌ای که از ارجاعات و Box<T> استفاده کردیم

در اینجا خطای کامپایل که نتیجه می‌شود:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

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

نوع MyBox<T> ما نمی‌تواند اشاره‌گر (Pointer)‌زدایی شود زیرا ما این قابلیت را روی نوع خود پیاده‌سازی نکرده‌ایم. برای فعال کردن اشاره‌گر (Pointer)‌زدایی با عملگر *، ما ویژگی Deref را پیاده‌سازی می‌کنیم.

پیاده‌سازی Deref Trait

همان‌طور که در بخش «پیاده‌سازی یک Trait روی یک نوع» در فصل ۱۰ بحث شد، برای پیاده‌سازی یک trait باید پیاده‌سازی‌هایی برای متدهای موردنیاز آن trait ارائه دهیم. Trait به نام Deref که توسط کتابخانه استاندارد ارائه شده است، از ما می‌خواهد که یک متد به نام deref پیاده‌سازی کنیم که self را به‌صورت وام‌گرفته دریافت کرده و یک رفرنس به داده درونی بازمی‌گرداند. لیست 15-10 پیاده‌سازی‌ای از Deref را نشان می‌دهد که باید به تعریف MyBox<T> اضافه شود.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-10: پیاده‌سازی Deref روی MyBox<T>

سینتکس type Target = T; یک نوع مرتبط برای ویژگی Deref تعریف می‌کند تا از آن استفاده کند. نوع‌های مرتبط یک روش کمی متفاوت برای اعلام یک پارامتر جنریک هستند، اما نیازی نیست در حال حاضر نگران آن‌ها باشید؛ ما در فصل ۲۰ جزئیات بیشتری درباره آن‌ها ارائه خواهیم داد.

بدنه‌ی متد deref را با &self.0 پر می‌کنیم تا deref یک رفرنس به مقداری که می‌خواهیم با عملگر * به آن دسترسی پیدا کنیم، برگرداند؛ به یاد داشته باشید از بخش [«استفاده از tuple struct‌ها بدون فیلدهای نام‌دار برای ایجاد نوع‌های مختلف»][tuple-structs] در فصل ۵ که .0 به اولین مقدار در یک tuple struct دسترسی پیدا می‌کند. تابع main در لیست ۱۵-۹ که روی مقدار MyBox<T> عمل * را فراخوانی می‌کند اکنون کامپایل می‌شود و عبارت‌های assert نیز با موفقیت عبور می‌کنند!

بدون trait به نام Deref، کامپایلر فقط می‌تواند رفرنس‌های & را dereference کند. متد deref این امکان را به کامپایلر می‌دهد که بتواند یک مقدار از هر نوعی که Deref را پیاده‌سازی کرده بگیرد و متد deref را روی آن صدا بزند تا یک رفرنس & دریافت کند که بتواند آن را dereference کند.

وقتی که در لیستینگ ۱۵-۹ *y وارد کردیم، پشت صحنه Rust در واقع این کد را اجرا کرد:

*(y.deref())

Rust عملگر * را با یک فراخوانی به متد deref و سپس یک اشاره‌گر (Pointer)زدایی ساده جایگزین می‌کند، بنابراین لازم نیست درباره این فکر کنیم که آیا نیاز به فراخوانی متد deref داریم یا نه. این ویژگی Rust به ما اجازه می‌دهد کدی بنویسیم که خواه ارجاع معمولی باشد یا نوعی که Deref را پیاده‌سازی کرده باشد، به طور یکسان عمل کند.

دلیلی که متد deref یک رفرنس به یک مقدار بازمی‌گرداند، و این‌که هنوز هم نیاز داریم از عملگر dereference ساده خارج از پرانتزها در *(y.deref()) استفاده کنیم، به سیستم مالکیت مربوط می‌شود. اگر متد deref مقدار را به‌صورت مستقیم بازمی‌گرداند به‌جای بازگرداندن یک رفرنس به مقدار، آنگاه آن مقدار از self خارج (move) می‌شد. ما نمی‌خواهیم در این حالت، یا در بیشتر حالت‌هایی که از عملگر dereference استفاده می‌کنیم، مالکیت مقدار درونی در MyBox<T> را به‌دست بگیریم.

توجه داشته باشید که عملگر * با یک فراخوانی به متد deref و سپس یک فراخوانی به عملگر * فقط یک بار جایگزین می‌شود، هر بار که از * در کدمان استفاده می‌کنیم. از آنجایی که جایگزینی عملگر * بی‌نهایت تکرار نمی‌شود، در نهایت به داده‌ای از نوع i32 می‌رسیم که با 5 در assert_eq! در لیستینگ ۱۵-۹ مطابقت دارد.

فشار اشاره‌گر (Pointer)زدایی ضمنی با توابع و متدها

فشار اشاره‌گر (Pointer)زدایی (Deref coercion) یک ارجاع به نوعی که ویژگی Deref را پیاده‌سازی کرده است به یک ارجاع به نوعی دیگر تبدیل می‌کند. برای مثال، فشار اشاره‌گر (Pointer)زدایی می‌تواند &String را به &str تبدیل کند، زیرا String ویژگی Deref را به گونه‌ای پیاده‌سازی کرده است که &str بازمی‌گرداند. فشار اشاره‌گر (Pointer)زدایی یک ویژگی کاربردی در Rust است که روی آرگومان‌های توابع و متدها اعمال می‌شود و فقط روی انواعی که ویژگی Deref را پیاده‌سازی کرده‌اند عمل می‌کند. این ویژگی به‌صورت خودکار زمانی که یک ارجاع به مقدار یک نوع خاص به‌عنوان آرگومان به یک تابع یا متدی که نوع پارامتر آن با تعریف تابع یا متد مطابقت ندارد، اتفاق می‌افتد. یک توالی از فراخوانی‌های متد deref نوعی را که ارائه داده‌ایم به نوعی که پارامتر نیاز دارد تبدیل می‌کند.

فشار اشاره‌گر (Pointer)زدایی به Rust اضافه شد تا برنامه‌نویسانی که توابع و متدها را می‌نویسند نیاز نداشته باشند مرجع‌دهی‌ها و اشاره‌گر (Pointer)زدایی‌های واضح زیادی با & و * اضافه کنند. این ویژگی همچنین به ما امکان می‌دهد کدی بنویسیم که می‌تواند برای ارجاعات یا اشاره‌گر (Pointer)های هوشمند کار کند.

برای دیدن عملکرد تبدیل خودکار با استفاده از deref (deref coercion) در عمل، بیایید از نوع MyBox<T> که در لیستینگ 15-8 تعریف کردیم، همراه با پیاده‌سازی Deref که در لیستینگ 15-10 اضافه کردیم، استفاده کنیم. لیستینگ 15-11 تعریفی از یک تابع را نشان می‌دهد که یک پارامتر از نوع اسلایس رشته (&str) دارد.

Filename: src/main.rs
fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}
Listing 15-11: یک تابع hello که پارامتر name از نوع &str دارد

می‌توانیم تابع hello را با یک اسلایس رشته به‌عنوان آرگومان فراخوانی کنیم، مانند hello("Rust"); برای مثال. فشار اشاره‌گر (Pointer)زدایی این امکان را فراهم می‌کند که hello را با یک ارجاع به یک مقدار از نوع MyBox<String> فراخوانی کنیم، همان‌طور که در لیستینگ ۱۵-۱۲ نشان داده شده است:

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}
Listing 15-12: فراخوانی hello با یک ارجاع به یک مقدار MyBox<String> که به دلیل فشار اشاره‌گر (Pointer)زدایی کار می‌کند

در اینجا ما تابع hello را با آرگومان &m که یک ارجاع به یک مقدار MyBox<String> است فراخوانی می‌کنیم. از آنجا که ما ویژگی Deref را روی MyBox<T> در لیستینگ ۱۵-۱۰ پیاده‌سازی کردیم، Rust می‌تواند &MyBox<String> را به &String با فراخوانی deref تبدیل کند. کتابخانه استاندارد پیاده‌سازی ویژگی Deref روی String را ارائه می‌دهد که یک اسلایس رشته بازمی‌گرداند، و این در مستندات API برای Deref ذکر شده است. Rust متد deref را دوباره فراخوانی می‌کند تا &String را به &str تبدیل کند که با تعریف تابع hello مطابقت دارد.

اگر Rust فشار اشاره‌گر (Pointer)زدایی را پیاده‌سازی نکرده بود، مجبور بودیم کدی مانند لیستینگ ۱۵-۱۳ را به‌جای کد لیستینگ ۱۵-۱۲ بنویسیم تا hello را با یک مقدار از نوع &MyBox<String> فراخوانی کنیم.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}
Listing 15-13: کدی که باید می‌نوشتیم اگر Rust فشار اشاره‌گر (Pointer)زدایی نداشت

عملگر (*m) مقدار MyBox<String> را به یک String اشاره‌گر (Pointer) زدایی می‌کند. سپس & و [..] یک برش رشته‌ای از String می‌گیرند که برابر با کل رشته است تا با امضای تابع hello تطابق داشته باشد. این کد بدون فشار اشاره‌گر (Pointer)زدایی با تمام این نمادها دشوارتر برای خواندن، نوشتن و درک است. فشار اشاره‌گر (Pointer)زدایی به Rust اجازه می‌دهد این تبدیل‌ها را به‌صورت خودکار برای ما انجام دهد.

وقتی ویژگی Deref برای انواع درگیر تعریف شود، Rust انواع را تحلیل می‌کند و از Deref::deref به دفعات لازم استفاده می‌کند تا یک ارجاع برای مطابقت با نوع پارامتر به دست آید. تعداد دفعاتی که نیاز به فراخوانی Deref::deref است در زمان کامپایل حل می‌شود، بنابراین هیچ هزینه‌ای در زمان اجرا برای استفاده از فشار اشاره‌گر (Pointer)زدایی وجود ندارد!

نحوه تعامل فشار اشاره‌گر (Pointer)زدایی با قابلیت تغییرپذیری

مشابه نحوه استفاده از ویژگی Deref برای بازنویسی عملگر * روی ارجاعات غیرقابل تغییر، می‌توانید از ویژگی DerefMut برای بازنویسی عملگر * روی ارجاعات قابل تغییر استفاده کنید.

Rust هنگام پیدا کردن انواع و پیاده‌سازی‌های ویژگی در سه حالت فشار اشاره‌گر (Pointer)زدایی را انجام می‌دهد:

۱. از &T به &U زمانی که T: Deref<Target=U> باشد ۲. از &mut T به &mut U زمانی که T: DerefMut<Target=U> باشد ۳. از &mut T به &U زمانی که T: Deref<Target=U> باشد

دو مورد اول مشابه یکدیگر هستند، با این تفاوت که مورد دوم، قابلیت تغییر (mutability) را نیز پیاده‌سازی می‌کند. مورد اول بیان می‌کند که اگر یک &T داشته باشید و T پیاده‌ساز Deref برای نوعی U باشد، می‌توانید به‌صورت شفاف (بدون نیاز به تبدیل دستی) یک &U دریافت کنید. مورد دوم نیز بیان می‌کند که همین تبدیل deref coercion برای رفرنس‌های قابل تغییر نیز اعمال می‌شود.

حالت سوم پیچیده‌تر است: Rust همچنین یک ارجاع قابل تغییر را به یک ارجاع غیرقابل تغییر تبدیل می‌کند. اما عکس آن ممکن نیست: ارجاعات غیرقابل تغییر هرگز به ارجاعات قابل تغییر تبدیل نمی‌شوند. به دلیل قوانین قرض‌گیری، اگر یک ارجاع قابل تغییر داشته باشید، آن ارجاع قابل تغییر باید تنها ارجاع به آن داده باشد (در غیر این صورت، برنامه کامپایل نمی‌شد). تبدیل یک ارجاع قابل تغییر به یک ارجاع غیرقابل تغییر هرگز قوانین قرض‌گیری را نمی‌شکند. تبدیل یک ارجاع غیرقابل تغییر به یک ارجاع قابل تغییر نیازمند این است که ارجاع غیرقابل تغییر اولیه تنها ارجاع غیرقابل تغییر به آن داده باشد، اما قوانین قرض‌گیری این را تضمین نمی‌کنند. بنابراین، Rust نمی‌تواند فرض کند که تبدیل یک ارجاع غیرقابل تغییر به یک ارجاع قابل تغییر امکان‌پذیر است.

اجرای کد هنگام پاکسازی با ویژگی Drop

ویژگی دوم که برای الگوی اشاره‌گر (Pointer) هوشمند مهم است، Drop است که به شما امکان می‌دهد سفارشی کنید که وقتی یک مقدار قرار است از دامنه خارج شود، چه اتفاقی بیفتد. می‌توانید یک پیاده‌سازی برای ویژگی Drop روی هر نوعی ارائه دهید و این کد می‌تواند برای آزادسازی منابعی مانند فایل‌ها یا اتصالات شبکه استفاده شود.

ما ویژگی Drop را در زمینه اشاره‌گر (Pointer)های هوشمند معرفی می‌کنیم زیرا عملکرد ویژگی Drop تقریباً همیشه هنگام پیاده‌سازی یک اشاره‌گر (Pointer) هوشمند استفاده می‌شود. برای مثال، وقتی یک Box<T> حذف می‌شود، فضای موجود روی پشته‌ای که باکس به آن اشاره می‌کند، آزاد خواهد شد.

در برخی زبان‌ها، برای برخی نوع‌ها، برنامه‌نویس باید هر بار که استفاده از یک نمونه از آن نوع‌ها تمام می‌شود، کدی را برای آزادسازی حافظه یا منابع اجرا کند. نمونه‌هایی از این نوع شامل فایل هندل‌ها، سوکت‌ها و لاک‌ها هستند. اگر برنامه‌نویس این کار را فراموش کند، ممکن است سیستم دچار بار اضافی شده و از کار بیفتد. در Rust، می‌توانید مشخص کنید که قطعه کد خاصی هنگام خارج شدن یک مقدار از حوزه‌ی دید (scope) اجرا شود، و کامپایلر این کد را به‌صورت خودکار درج خواهد کرد. در نتیجه، نیازی نیست نگران این باشید که در تمام بخش‌های برنامه، کد پاک‌سازی (cleanup) را درج کنید؛ حتی با این وجود نیز دچار نشت منابع نخواهید شد!

شما کدی که باید هنگام خروج مقدار از دامنه اجرا شود را با پیاده‌سازی ویژگی Drop مشخص می‌کنید. ویژگی Drop نیازمند این است که یک متد به نام drop را پیاده‌سازی کنید که یک مرجع متغیر به self می‌گیرد. برای دیدن زمانی که Rust فراخوانی drop را انجام می‌دهد، بیایید drop را با جملات println! برای اکنون پیاده‌سازی کنیم.

لیستینگ 15-14 یک struct به‌نام CustomSmartPointer را نشان می‌دهد که تنها عملکرد سفارشی آن این است که هنگام خارج شدن نمونه از حوزه‌ی دید (scope)، پیام Dropping CustomSmartPointer! را چاپ می‌کند تا نشان دهد چه زمانی Rust متد drop را اجرا می‌کند.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created.");
}
Listing 15-14: ساختار CustomSmartPointer که ویژگی Drop را پیاده‌سازی می‌کند و در آن کد پاکسازی خود را قرار می‌دهیم

trait مربوط به Drop در prelude زبان Rust گنجانده شده است، بنابراین نیازی نیست آن را به‌طور جداگانه به حوزه‌ی دید (scope) وارد کنیم. ما trait Drop را برای CustomSmartPointer پیاده‌سازی کرده‌ایم و برای متد drop یک پیاده‌سازی ارائه داده‌ایم که در آن از println! استفاده می‌شود. بدنه‌ی متد drop جایی است که می‌توانید هر منطقی را که می‌خواهید هنگام خارج شدن یک نمونه از نوع‌تان از scope اجرا شود، قرار دهید. ما در این‌جا صرفاً با چاپ یک متن، به‌صورت بصری نشان می‌دهیم که Rust چه زمانی متد drop را فراخوانی می‌کند.

در تابع main، دو نمونه از CustomSmartPointer ایجاد می‌کنیم و سپس CustomSmartPointers created را چاپ می‌کنیم. در پایان main، نمونه‌های ما از CustomSmartPointer از دامنه خارج خواهند شد و Rust کدی که در متد drop قرار داده‌ایم را فراخوانی خواهد کرد و پیام نهایی ما را چاپ می‌کند. توجه کنید که نیازی به فراخوانی صریح متد drop نداشتیم.

وقتی این برنامه را اجرا می‌کنیم، خروجی زیر را مشاهده خواهیم کرد:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

Rust به صورت خودکار drop را برای ما فراخوانی کرد وقتی که نمونه‌های ما از دامنه خارج شدند و کدی که مشخص کرده بودیم را اجرا کرد. متغیرها به ترتیب معکوس ایجادشان حذف می‌شوند، بنابراین d قبل از c حذف شد. هدف این مثال این است که یک راهنمای بصری برای نحوه کارکرد متد drop به شما بدهد؛ معمولاً شما کد پاکسازی که نوع شما نیاز دارد را مشخص می‌کنید نه یک پیام چاپ.

متأسفانه غیرفعال‌کردن عملکرد خودکار drop کار ساده‌ای نیست. در اغلب موارد نیز نیازی به غیرفعال‌کردن آن نیست؛ تمام هدف trait مربوط به Drop این است که فرآیند پاک‌سازی به‌طور خودکار مدیریت شود. با این حال، گاهی ممکن است بخواهید یک مقدار را زودتر از زمان معمول پاک‌سازی کنید. یکی از نمونه‌ها زمانی است که از smart pointerهایی استفاده می‌کنید که قفل‌ها (locks) را مدیریت می‌کنند: ممکن است بخواهید متد drop که قفل را آزاد می‌کند را به‌صورت دستی فراخوانی کنید تا سایر کدهای همان scope بتوانند قفل را در اختیار بگیرند. Rust اجازه نمی‌دهد متد drop مربوط به trait Drop را به‌صورت دستی فراخوانی کنید؛ در عوض، اگر می‌خواهید یک مقدار را قبل از پایان حوزه‌ی دیدش پاک‌سازی کنید، باید از تابع std::mem::drop که در کتابخانه‌ی استاندارد فراهم شده استفاده کنید.

اگر تلاش کنیم تا متد drop مربوط به trait Drop را به‌صورت دستی فراخوانی کنیم و تابع main موجود در لیستینگ 15-14 را تغییر دهیم، همان‌طور که در لیستینگ 15-15 نشان داده شده است، با خطای کامپایل مواجه خواهیم شد.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main.");
}
Listing 15-15: تلاش برای فراخوانی دستی متد drop از ویژگی Drop برای پاکسازی زودهنگام

وقتی سعی کنیم این کد را کامپایل کنیم، با این خطا مواجه می‌شویم:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed
   |
help: consider using `drop` function
   |
16 |     drop(c);
   |     +++++ ~

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

این پیام خطا نشان می‌دهد که ما اجازه نداریم به‌طور صریح drop را فراخوانی کنیم. پیام خطا از اصطلاح تخریب‌گر (Destructor) استفاده می‌کند که اصطلاحی کلی برای تابعی است که یک نمونه را تمیز می‌کند. یک تخریب‌گر مشابه یک سازنده (Constructor) است که یک نمونه را ایجاد می‌کند. تابع drop در Rust یک تخریب‌گر خاص است.

Rust به ما اجازه نمی‌دهد drop را به صورت صریح فراخوانی کنیم زیرا Rust به‌طور خودکار drop را در انتهای تابع main فراخوانی می‌کند. این موضوع می‌تواند باعث خطای آزادسازی دوگانه شود زیرا Rust سعی می‌کند همان مقدار را دو بار تمیز کند.

ما نمی‌توانیم قرار دادن خودکار drop را هنگام خروج یک مقدار از حوزه غیرفعال کنیم و همچنین نمی‌توانیم متد drop را به صورت صریح فراخوانی کنیم. بنابراین، اگر نیاز به حذف زودهنگام یک مقدار داشته باشیم، باید از تابع std::mem::drop استفاده کنیم.

تابع std::mem::drop با متد drop در trait Drop متفاوت است. این تابع را با ارسال مقداری که می‌خواهیم به‌صورت اجباری drop شود، فراخوانی می‌کنیم. این تابع در prelude قرار دارد، بنابراین می‌توانیم تابع main در لیستینگ 15-15 را تغییر دهیم تا به‌جای فراخوانی مستقیم متد drop، تابع drop را فراخوانی کند؛ همان‌طور که در لیستینگ 15-16 نشان داده شده است.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}
Listing 15-16: فراخوانی std::mem::drop برای حذف صریح یک مقدار قبل از خروج آن از حوزه

اجرای این کد خروجی زیر را چاپ خواهد کرد:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer created.
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main.

متن Dropping CustomSmartPointer with data 'some data'! بین متون CustomSmartPointer created. و CustomSmartPointer dropped before the end of main. چاپ می‌شود و نشان می‌دهد که کد متد drop برای حذف c در آن نقطه فراخوانی شده است.

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

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

اکنون که Box<T> و برخی از ویژگی‌های اشاره‌گر (Pointer)های هوشمند را بررسی کردیم، بیایید به چند اشاره‌گر (Pointer) هوشمند دیگر که در کتابخانه استاندارد تعریف شده‌اند، نگاهی بیندازیم.

Rc<T>، اشاره‌گر (Pointer) هوشمند با شمارش مرجع

در بیشتر موارد، مالکیت واضح است: شما دقیقاً می‌دانید که کدام متغیر مالک یک مقدار مشخص است. با این حال، در مواردی ممکن است یک مقدار چندین مالک داشته باشد. برای مثال، در ساختارهای داده گراف، چندین یال ممکن است به یک گره اشاره کنند و آن گره از نظر مفهومی متعلق به تمام یال‌هایی است که به آن اشاره دارند. یک گره نباید پاکسازی شود مگر اینکه هیچ یالی به آن اشاره نکند و در نتیجه مالکیتی نداشته باشد.

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

تصور کنید Rc<T> مانند یک تلویزیون در اتاق نشیمن است. وقتی یک نفر وارد اتاق می‌شود تا تلویزیون تماشا کند، آن را روشن می‌کند. افراد دیگری هم می‌توانند وارد اتاق شوند و تلویزیون تماشا کنند. وقتی آخرین نفر اتاق را ترک می‌کند، تلویزیون را خاموش می‌کند زیرا دیگر استفاده نمی‌شود. اگر کسی تلویزیون را در حالی که دیگران هنوز در حال تماشای آن هستند خاموش کند، اعتراض تماشاگران باقی‌مانده بلند خواهد شد!

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

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

استفاده از Rc<T> برای اشتراک‌گذاری داده

بیایید به مثال لیست cons که در لیست 15-5 دیدیم برگردیم. به خاطر دارید که این لیست را با استفاده از Box<T> تعریف کرده بودیم. این بار، قصد داریم دو لیست ایجاد کنیم که هر دو مالکیت مشترکی از یک لیست سوم داشته باشند. از نظر مفهومی، این ساختار شبیه به تصویر 15-3 است.

A linked list with the label 'a' pointing to three elements: the first element contains the integer 5 and points to the second element. The second element contains the integer 10 and points to the third element. The third element contains the value 'Nil' that signifies the end of the list; it does not point anywhere. A linked list with the label 'b' points to an element that contains the integer 3 and points to the first element of list 'a'. A linked list with the label 'c' points to an element that contains the integer 4 and also points to the first element of list 'a', so that the tail of lists 'b' and 'c' are both list 'a'

شکل 15-3: دو لیست، b و c، که مالکیت یک لیست سوم، a را به اشتراک می‌گذارند

ما ابتدا لیستی به نام a ایجاد می‌کنیم که شامل مقادیر 5 و سپس 10 است. سپس دو لیست دیگر می‌سازیم: لیست b که با مقدار 3 شروع می‌شود و لیست c که با مقدار 4 شروع می‌شود. هر دو لیست b و c در ادامه به لیست a که شامل 5 و 10 است اشاره خواهند کرد. به عبارت دیگر، هر دو لیست b و c لیست a را به صورت مشترک استفاده می‌کنند.

تلاش برای پیاده‌سازی این سناریو با استفاده از تعریف فعلی List که از Box<T> استفاده می‌کند، امکان‌پذیر نیست؛ همان‌طور که در لیست 15-17 نشان داده شده است.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}

هنگامی که این کد را کامپایل می‌کنیم، با این خطا مواجه می‌شویم:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

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

متغیرهای Cons مالک داده‌هایی هستند که در خود نگه می‌دارند. بنابراین، هنگامی که لیست b را ایجاد می‌کنیم، a به b منتقل می‌شود و b مالک a می‌شود. سپس، هنگامی که سعی می‌کنیم دوباره از a برای ایجاد c استفاده کنیم، این کار مجاز نیست زیرا a قبلاً منتقل شده است.

ما می‌توانیم تعریف Cons را به گونه‌ای تغییر دهیم که به جای نگهداری داده‌ها، ارجاع به آنها را نگه دارد. اما در این صورت باید پارامترهای طول عمر (lifetime parameters) را مشخص کنیم. با مشخص کردن پارامترهای طول عمر، مشخص می‌کنیم که هر عنصر در لیست حداقل به اندازه کل لیست زنده خواهد بود. این موضوع در مورد عناصر و لیست‌های موجود در لیست 15-17 صدق می‌کند، اما در همه سناریوها چنین نیست.

در عوض، تعریف List خود را تغییر می‌دهیم تا به جای Box<T> از Rc<T> استفاده کند، همان‌طور که در لیست 15-18 نشان داده شده است. هر متغیر Cons اکنون یک مقدار و یک Rc<T> اشاره‌کننده به یک List را نگه می‌دارد. وقتی b را ایجاد می‌کنیم، به جای تصاحب مالکیت a، Rc<List> که a نگه می‌دارد را کلون می‌کنیم، بنابراین تعداد ارجاعات از یک به دو افزایش می‌یابد و به a و b اجازه می‌دهیم مالکیت داده‌های موجود در آن Rc<List> را به اشتراک بگذارند. همچنین هنگام ایجاد c، a را کلون می‌کنیم و تعداد ارجاعات از دو به سه افزایش می‌یابد. هر بار که Rc::clone را فراخوانی می‌کنیم، تعداد ارجاعات به داده‌های موجود در Rc<List> افزایش می‌یابد و داده‌ها تا زمانی که هیچ ارجاعی به آنها باقی نماند پاک نمی‌شوند.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}
Listing 15-18: تعریفی از List که از Rc<T> استفاده می‌کند

ما نیاز داریم یک دستور use اضافه کنیم تا Rc<T> را وارد دامنه کنیم، زیرا این نوع در prelude قرار ندارد. در تابع main، یک لیست که مقادیر 5 و 10 را نگه می‌دارد ایجاد می‌کنیم و آن را در یک Rc<List> جدید به نام a ذخیره می‌کنیم. سپس، زمانی که b و c را می‌سازیم، تابع Rc::clone را فراخوانی می‌کنیم و یک رفرنس به Rc<List> موجود در a را به عنوان آرگومان به آن پاس می‌دهیم.

می‌توانستیم a.clone() را به جای Rc::clone(&a) فراخوانی کنیم، اما طبق قرارداد Rust در این موارد از Rc::clone استفاده می‌شود. پیاده‌سازی Rc::clone یک کپی عمیق از تمام داده‌ها ایجاد نمی‌کند، همان‌طور که پیاده‌سازی اکثر انواع دیگر clone این کار را انجام می‌دهد. فراخوانی Rc::clone فقط تعداد ارجاعات را افزایش می‌دهد، که زمان زیادی نمی‌برد. کپی عمیق داده‌ها ممکن است زمان زیادی ببرد. با استفاده از Rc::clone برای شمارش مرجع، می‌توانیم بین کپی‌های عمیق و کپی‌هایی که تعداد ارجاعات را افزایش می‌دهند تمایز بصری قائل شویم. هنگام جستجوی مشکلات عملکرد در کد، فقط لازم است به کپی‌های عمیق توجه کنیم و می‌توانیم فراخوانی‌های Rc::clone را نادیده بگیریم.

کلون کردن یک Rc<T> تعداد ارجاعات را افزایش می‌دهد

اجازه دهید مثال کاری خود را در لیست 15-18 تغییر دهیم تا بتوانیم تغییرات تعداد ارجاعات را هنگام ایجاد و حذف ارجاعات به Rc<List> در a مشاهده کنیم.

در لیست 15-19، main را تغییر خواهیم داد تا یک محدوده داخلی (inner scope) در اطراف لیست c داشته باشد؛ سپس می‌توانیم ببینیم که چگونه تعداد ارجاعات زمانی که c از محدوده خارج می‌شود تغییر می‌کند.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

// --snip--

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
Listing 15-19: چاپ تعداد ارجاعات

در هر نقطه از برنامه که تعداد ارجاعات تغییر می‌کند، تعداد ارجاعات را چاپ می‌کنیم که از طریق فراخوانی تابع Rc::strong_count دریافت می‌شود. این تابع به جای count، strong_count نام‌گذاری شده است زیرا نوع Rc<T> همچنین دارای weak_count است؛ در بخش “جلوگیری از چرخه‌های مرجع: تبدیل یک Rc<T> به یک Weak<T> با کاربرد weak_count آشنا خواهیم شد.

این کد خروجی زیر را تولید می‌کند:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

می‌بینیم که Rc<List> در a تعداد ارجاع اولیه برابر با 1 دارد؛ سپس هر بار که clone را فراخوانی می‌کنیم، تعداد ارجاعات 1 واحد افزایش می‌یابد. هنگامی که c از محدوده خارج می‌شود، تعداد ارجاعات 1 واحد کاهش می‌یابد. لازم نیست تابعی برای کاهش تعداد ارجاعات فراخوانی کنیم، همان‌طور که باید Rc::clone را برای افزایش تعداد ارجاعات فراخوانی کنیم: پیاده‌سازی ویژگی Drop تعداد ارجاعات را به طور خودکار کاهش می‌دهد وقتی که یک مقدار Rc<T> از محدوده خارج می‌شود.

آنچه در این مثال نمی‌توانیم ببینیم این است که وقتی b و سپس a در انتهای main از محدوده خارج می‌شوند، تعداد ارجاعات به 0 می‌رسد و Rc<List> به طور کامل پاک‌سازی می‌شود. استفاده از Rc<T> به یک مقدار اجازه می‌دهد که چندین مالک داشته باشد، و تعداد ارجاعات تضمین می‌کند که مقدار تا زمانی که هر یک از مالکان هنوز وجود دارند، معتبر باقی می‌ماند.

از طریق ارجاعات غیرقابل تغییر، Rc<T> به شما اجازه می‌دهد داده‌ها را بین بخش‌های مختلف برنامه خود برای خواندن به اشتراک بگذارید. اگر Rc<T> به شما اجازه می‌داد که چندین ارجاع قابل تغییر نیز داشته باشید، ممکن بود یکی از قوانین قرض‌گیری که در فصل 4 بحث شد را نقض کنید: چندین قرض قابل تغییر به یک مکان می‌تواند باعث ایجاد تناقضات و مسابقه داده‌ها شود. اما توانایی تغییر داده‌ها بسیار مفید است! در بخش بعدی، به الگوی تغییر‌پذیری داخلی (interior mutability) و نوع RefCell<T> که می‌توانید همراه با Rc<T> برای کار با این محدودیت عدم تغییر‌پذیری استفاده کنید، خواهیم پرداخت.

RefCell<T> و الگوی تغییرپذیری داخلی

تغییرپذیری داخلی یک الگوی طراحی در راست است که به شما اجازه می‌دهد داده‌ها را حتی زمانی که ارجاع‌های غیرقابل‌تغییر به آن داده‌ها وجود دارد، تغییر دهید؛ معمولاً این عمل توسط قوانین وام‌دهی (‌borrowing rules) ممنوع است. برای تغییر داده‌ها، این الگو از کد unsafe درون یک ساختار داده برای تغییر قوانین معمول راست که کنترل تغییرپذیری و وام‌دهی را بر عهده دارند، استفاده می‌کند. کد unsafe به کامپایلر نشان می‌دهد که ما قوانین را به صورت دستی بررسی می‌کنیم و دیگر به کامپایلر اعتماد نداریم که این کار را برای ما انجام دهد؛ ما در فصل 20 بیشتر درباره کد unsafe صحبت خواهیم کرد.

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

بیایید این مفهوم را با بررسی نوع RefCell<T> که از الگوی تغییرپذیری داخلی پیروی می‌کند، بیشتر بررسی کنیم.

اجرای قوانین وام‌دهی در زمان اجرا با RefCell<T>

بر خلاف Rc<T>، نوع RefCell<T> نشان‌دهنده‌ی مالکیت یکتا (single ownership) بر داده‌ای است که در خود نگه می‌دارد. پس چه چیزی RefCell<T> را از نوعی مانند Box<T> متمایز می‌کند؟ قوانین قرض‌گیری (borrowing) را که در فصل ۴ یاد گرفتید به‌خاطر بیاورید:

  • در هر لحظه فقط می‌توانید یا یک رفرنس قابل‌تغییر داشته باشید یا هر تعداد رفرنس تغییرناپذیر (اما نه هر دو همزمان).
  • رفرنس‌ها باید همواره معتبر باشند.

با استفاده از ارجاع‌ها و Box<T>، ثابت‌های قوانین وام‌دهی در زمان کامپایل اعمال می‌شوند. اما با RefCell<T>، این ثابت‌ها در زمان اجرا اعمال می‌شوند. با ارجاع‌ها، اگر این قوانین را بشکنید، یک خطای کامپایل دریافت خواهید کرد. اما با RefCell<T>، اگر این قوانین را بشکنید، برنامه شما دچار وحشت (panic) می‌شود و متوقف می‌شود.

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

مزیت بررسی قوانین وام‌دهی در زمان اجرا این است که سناریوهایی که ایمن از نظر حافظه هستند اجازه می‌یابند، در حالی که ممکن است توسط بررسی‌های زمان کامپایل مجاز نباشند. تحلیل ایستا (static analysis)، مانند کامپایلر راست، به‌طور ذاتی محافظه‌کارانه است. برخی خصوصیات کد غیرممکن است که با تحلیل کد شناسایی شوند: معروف‌ترین مثال، مشکل توقف (Halting Problem) است که فراتر از محدوده این کتاب است اما موضوع جالبی برای تحقیق می‌باشد.

از آن‌جا که برخی تحلیل‌ها غیرممکن هستند، اگر کامپایلر Rust نتواند مطمئن شود که کد با قوانین مالکیت سازگار است، ممکن است یک برنامه‌ی درست را رد کند؛ به این ترتیب، کامپایلر محافظه‌کارانه عمل می‌کند. اگر Rust یک برنامه‌ی نادرست را بپذیرد، کاربران دیگر نمی‌توانند به تضمین‌هایی که Rust ارائه می‌دهد اعتماد کنند. اما اگر Rust یک برنامه‌ی درست را رد کند، نهایتاً برنامه‌نویس دچار زحمت می‌شود، اما اتفاق فاجعه‌باری رخ نخواهد داد. نوع RefCell<T> زمانی مفید است که شما اطمینان دارید کدتان از قوانین قرض‌گیری پیروی می‌کند، اما کامپایلر قادر به درک و تضمین این موضوع نیست.

مشابه Rc<T>، RefCell<T> تنها برای استفاده در سناریوهای تک‌ریسمانی (single-threaded) است و اگر بخواهید آن را در یک بافت چندریسمانی (multithreaded) استفاده کنید، یک خطای زمان کامپایل به شما خواهد داد. ما در فصل 16 درباره نحوه دریافت عملکرد RefCell<T> در یک برنامه چندریسمانی صحبت خواهیم کرد.

در اینجا مروری بر دلایلی برای انتخاب Box<T>، Rc<T> یا RefCell<T> آمده است:

  • Rc<T> امکان چندین مالک برای یک داده را فراهم می‌کند؛ در حالی که Box<T> و RefCell<T> تنها یک مالک دارند.
  • Box<T> اجازه می‌دهد که وام‌دهی‌های غیرقابل‌تغییر یا قابل‌تغییر در زمان کامپایل بررسی شوند؛ Rc<T> تنها وام‌دهی‌های غیرقابل‌تغییر را در زمان کامپایل بررسی می‌کند؛ RefCell<T> اجازه می‌دهد که وام‌دهی‌های غیرقابل‌تغییر یا قابل‌تغییر در زمان اجرا بررسی شوند.
  • از آنجا که RefCell<T> اجازه می‌دهد وام‌دهی‌های قابل‌تغییر در زمان اجرا بررسی شوند، شما می‌توانید مقدار درون RefCell<T> را حتی زمانی که خود RefCell<T> غیرقابل‌تغییر است، تغییر دهید.

تغییر مقدار درون یک مقدار غیرقابل‌تغییر همان الگوی تغییرپذیری داخلی است. بیایید به یک موقعیت که در آن تغییرپذیری داخلی مفید است نگاهی بیندازیم و بررسی کنیم چگونه این امر ممکن است.

تغییرپذیری داخلی: وام‌دهی قابل‌تغییر به یک مقدار غیرقابل‌تغییر

یکی از پیامدهای قوانین وام‌دهی این است که وقتی شما یک مقدار غیرقابل‌تغییر دارید، نمی‌توانید آن را به صورت قابل‌تغییر وام دهید. برای مثال، این کد کامپایل نخواهد شد:

fn main() {
    let x = 5;
    let y = &mut x;
}

اگر سعی کنید این کد را کامپایل کنید، خطای زیر را دریافت خواهید کرد:

$ cargo run
   Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

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

با این حال، موقعیت‌هایی وجود دارند که در آن‌ها مفید است یک مقدار بتواند خود را در متدهایش تغییر دهد اما برای کد دیگر غیرقابل‌تغییر به نظر برسد. کدی که خارج از متدهای مقدار قرار دارد نمی‌تواند مقدار را تغییر دهد. استفاده از RefCell<T> یکی از راه‌هایی است که می‌توانید قابلیت تغییرپذیری داخلی را به دست آورید، اما RefCell<T> به طور کامل قوانین وام‌دهی را دور نمی‌زند: کنترل‌کننده وام‌دهی در کامپایلر این تغییرپذیری داخلی را مجاز می‌کند و قوانین وام‌دهی در عوض در زمان اجرا بررسی می‌شوند. اگر این قوانین را نقض کنید، به جای خطای کامپایل، یک panic! دریافت خواهید کرد.

بیایید با یک مثال عملی کار کنیم که در آن از RefCell<T> برای تغییر مقدار غیرقابل‌تغییر استفاده کنیم و ببینیم چرا این کار مفید است.

یک کاربرد برای تغییرپذیری داخلی: Mock Objects

گاهی اوقات در زمان تست، یک برنامه‌نویس نوعی را به‌جای نوعی دیگر استفاده می‌کند تا بتواند رفتار خاصی را مشاهده کرده و بررسی کند که آن رفتار به‌درستی پیاده‌سازی شده است. این نوع جایگزین را test double می‌نامند. می‌توانید آن را مشابه بدل‌کار در صنعت فیلم‌سازی در نظر بگیرید، جایی که فردی به‌جای بازیگر اصلی برای اجرای یک صحنه‌ی دشوار وارد عمل می‌شود. Test doubleها به‌عنوان جایگزین نوع‌های دیگر هنگام اجرای تست‌ها عمل می‌کنند. _Mock object_ها نوع خاصی از test doubleها هستند که اتفاقات رخ‌داده در طول تست را ثبت می‌کنند تا بتوانید بررسی کنید که اقدامات موردنظر به‌درستی انجام شده‌اند.

راست اشیاء را به همان شکلی که زبان‌های دیگر دارند، ندارد و قابلیت‌های اشیاء Mock را نیز در کتابخانه استاندارد، مانند برخی زبان‌های دیگر، ارائه نمی‌دهد. با این حال، شما می‌توانید یک ساختار (struct) ایجاد کنید که همان مقاصد اشیاء Mock را فراهم کند.

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

کتابخانه‌ی ما تنها وظیفه‌ی پیگیری میزان نزدیکی یک مقدار به مقدار حداکثری و تعیین این‌که در چه زمان‌هایی چه پیام‌هایی باید نمایش داده شوند را بر عهده دارد. برنامه‌هایی که از کتابخانه‌ی ما استفاده می‌کنند، باید مکانیزم ارسال پیام را فراهم کنند: این برنامه می‌تواند پیام را درون رابط کاربری نمایش دهد، یک ایمیل ارسال کند، پیامک بفرستد، یا کار دیگری انجام دهد. کتابخانه نیازی به دانستن جزئیات این فرآیند ندارد. تنها چیزی که نیاز دارد، یک چیزی است که traitای که ما تعریف خواهیم کرد به‌نام Messenger را پیاده‌سازی کند. لیستینگ 15-20 کد کتابخانه را نشان می‌دهد.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}
Listing 15-20: یک کتابخانه برای پیگیری نزدیکی یک مقدار به یک مقدار حداکثری و هشدار در زمانی که مقدار در سطوح خاصی است

یکی از بخش‌های مهم این کد آن است که trait به‌نام Messenger یک متد به‌نام send دارد که یک رفرنس تغییرناپذیر به self و متن پیام را می‌گیرد. این trait رابطی است که شیء mock ما باید آن را پیاده‌سازی کند تا بتواند درست مانند یک شیء واقعی مورد استفاده قرار گیرد. بخش مهم دیگر این است که ما می‌خواهیم رفتار متد set_value روی LimitTracker را تست کنیم. ما می‌توانیم مقادیری که به پارامتر value می‌دهیم را تغییر دهیم، اما set_value چیزی را باز نمی‌گرداند که بتوانیم روی آن assertion انجام دهیم. ما می‌خواهیم بتوانیم بگوییم که اگر یک LimitTracker با چیزی که trait Messenger را پیاده‌سازی می‌کند و یک مقدار مشخص برای max ایجاد کنیم، آنگاه با ارسال اعداد مختلف به‌عنوان value، پیام‌های مناسب از طریق messenger ارسال شوند.

ما به یک شیء mock نیاز داریم که به‌جای ارسال ایمیل یا پیامک هنگام فراخوانی send، فقط پیام‌هایی را که قرار است ارسال شوند ذخیره کند. می‌توانیم یک نمونه‌ی جدید از شیء mock ایجاد کنیم، یک LimitTracker بسازیم که از این mock استفاده می‌کند، متد set_value را روی LimitTracker فراخوانی کنیم، و سپس بررسی کنیم که شیء mock پیام‌هایی را که انتظار داشتیم در خود ذخیره کرده است. لیستینگ 15-21 تلاشی برای پیاده‌سازی چنین شیء mockی را نشان می‌دهد، اما borrow checker اجازه‌ی انجام آن را نمی‌دهد.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}
Listing 15-21: تلاش برای پیاده‌سازی یک MockMessenger که توسط کنترل‌کننده وام‌دهی اجازه داده نمی‌شود

این کد تست یک ساختار MockMessenger تعریف می‌کند که یک فیلد sent_messages با یک Vec از مقادیر String دارد تا پیام‌هایی را که به آن گفته شده است ارسال کند، پیگیری کند. ما همچنین یک تابع مرتبط new تعریف می‌کنیم تا ایجاد مقادیر MockMessenger جدید که با یک لیست خالی از پیام‌ها شروع می‌شود، راحت باشد. سپس ویژگی Messenger را برای MockMessenger پیاده‌سازی می‌کنیم تا بتوانیم یک MockMessenger را به یک LimitTracker بدهیم. در تعریف متد send، ما پیام ارسال‌شده به عنوان یک پارامتر را می‌گیریم و آن را در لیست sent_messages درون MockMessenger ذخیره می‌کنیم.

در این تست، در حال بررسی این هستیم که وقتی به LimitTracker گفته می‌شود مقدار value را به چیزی بیشتر از ۷۵٪ مقدار max تنظیم کند، چه اتفاقی می‌افتد. ابتدا یک MockMessenger جدید می‌سازیم که با یک لیست خالی از پیام‌ها شروع می‌کند. سپس یک LimitTracker جدید ایجاد می‌کنیم و یک رفرنس به MockMessenger جدید و همچنین مقدار max برابر با 100 به آن می‌دهیم. متد set_value را با مقدار 80 روی LimitTracker فراخوانی می‌کنیم، که بیش از ۷۵٪ عدد ۱۰۰ است. سپس بررسی می‌کنیم (assert) که لیست پیام‌هایی که MockMessenger پیگیری می‌کند، اکنون باید شامل یک پیام باشد.

با این حال، یک مشکل با این تست وجود دارد، همانطور که در اینجا نشان داده شده است:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
2  ~     fn send(&mut self, msg: &str);
3  | }
...
56 |     impl Messenger for MockMessenger {
57 ~         fn send(&mut self, message: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error

ما نمی‌توانیم MockMessenger را طوری تغییر دهیم که پیام‌ها را دنبال کند، چون متد send یک رفرنس تغییرناپذیر به self دریافت می‌کند. همچنین نمی‌توانیم پیشنهاد پیام خطا را بپذیریم و &mut self را هم در متد impl و هم در تعریف trait قرار دهیم، زیرا نمی‌خواهیم فقط به‌خاطر تست، trait Messenger را تغییر دهیم. در عوض، باید راهی پیدا کنیم که کد تست ما با طراحی فعلی به‌درستی کار کند.

در چنین وضعیتی، تغییرپذیری درونی (interior mutability) می‌تواند به کمک ما بیاید! ما فیلد sent_messages را درون یک RefCell<T> ذخیره می‌کنیم، و سپس متد send می‌تواند مقدار sent_messages را تغییر دهد تا پیام‌هایی را که دیده‌ایم ذخیره کند. لیستینگ 15-22 نشان می‌دهد که این کار چگونه انجام می‌شود.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
Listing 15-22: استفاده از RefCell<T> برای تغییر یک مقدار داخلی در حالی که مقدار بیرونی غیرقابل‌تغییر در نظر گرفته می‌شود

فیلد sent_messages اکنون از نوع RefCell<Vec<String>> به جای Vec<String> است. در تابع new، یک نمونه جدید از RefCell<Vec<String>> را در اطراف وکتور خالی ایجاد می‌کنیم.

برای پیاده‌سازی متد send، پارامتر اول همچنان یک وام‌دهی غیرقابل‌تغییر به self است، که با تعریف ویژگی مطابقت دارد. ما متد borrow_mut را روی RefCell<Vec<String>> در self.sent_messages فراخوانی می‌کنیم تا یک ارجاع متغیر به مقدار درون RefCell<Vec<String>>، که همان وکتور است، دریافت کنیم. سپس می‌توانیم روی ارجاع متغیر به وکتور، متد push را فراخوانی کنیم تا پیام‌های ارسال‌شده در طول تست را پیگیری کنیم.

آخرین تغییری که باید انجام دهیم در ادعا (assertion) است: برای دیدن تعداد آیتم‌های درون وکتور داخلی، ما متد borrow را روی RefCell<Vec<String>> فراخوانی می‌کنیم تا یک ارجاع غیرقابل‌تغییر به وکتور دریافت کنیم.

حالا که دیدید چگونه از RefCell<T> استفاده کنید، بیایید به نحوه کار آن بپردازیم!

پیگیری وام‌ها در زمان اجرا با RefCell<T>

هنگام ایجاد ارجاع‌های غیرقابل‌تغییر و قابل‌تغییر، ما از سینتکس & و &mut استفاده می‌کنیم. با RefCell<T>، از متدهای borrow و borrow_mut استفاده می‌کنیم، که بخشی از API ایمن متعلق به RefCell<T> هستند. متد borrow نوع اسمارت پوینتر Ref<T> را برمی‌گرداند، و borrow_mut نوع اسمارت پوینتر RefMut<T> را برمی‌گرداند. هر دو نوع، Deref را پیاده‌سازی می‌کنند، بنابراین می‌توانیم با آن‌ها مثل ارجاع‌های معمولی رفتار کنیم.

RefCell<T> تعداد Ref<T> و RefMut<T>هایی را که در حال حاضر فعال هستند، دنبال می‌کند. هر بار که متد borrow را فراخوانی می‌کنیم، RefCell<T> شمارنده‌ی رفرنس‌های تغییرناپذیر فعال را افزایش می‌دهد. زمانی که یک مقدار Ref<T> از حوزه‌ی دید (scope) خارج می‌شود، این شمارنده یک واحد کاهش می‌یابد. درست مانند قوانین قرض‌گیری در زمان کامپایل، RefCell<T> نیز به ما اجازه می‌دهد که در هر لحظه یا چندین رفرنس تغییرناپذیر داشته باشیم یا یک رفرنس قابل‌تغییر، اما نه هر دو به‌طور همزمان.

اگر سعی کنیم این قوانین را نقض کنیم، به جای دریافت یک خطای کامپایل مثل ارجاع‌ها، پیاده‌سازی RefCell<T> در زمان اجرا دچار وحشت (panic) خواهد شد. فهرست 15-23 اصلاحی از پیاده‌سازی متد send در فهرست 15-22 را نشان می‌دهد. ما به عمد سعی داریم دو وام‌دهی قابل‌تغییر در یک دامنه ایجاد کنیم تا نشان دهیم RefCell<T> از انجام این کار در زمان اجرا جلوگیری می‌کند.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            let mut one_borrow = self.sent_messages.borrow_mut();
            let mut two_borrow = self.sent_messages.borrow_mut();

            one_borrow.push(String::from(message));
            two_borrow.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
Listing 15-23: ایجاد دو ارجاع متغیر در یک دامنه برای دیدن اینکه RefCell<T> وحشت خواهد کرد

ما یک متغیر به نام one_borrow برای اسمارت پوینتر RefMut<T> که از borrow_mut بازگردانده شده است، ایجاد می‌کنیم. سپس یک وام‌دهی متغیر دیگر به همان روش در متغیر two_borrow ایجاد می‌کنیم. این کار دو ارجاع متغیر در یک دامنه ایجاد می‌کند، که مجاز نیست. هنگامی که تست‌ها را برای کتابخانه خود اجرا می‌کنیم، کد در فهرست 15-23 بدون هیچ خطایی کامپایل می‌شود، اما تست شکست خواهد خورد:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)

running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED

failures:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----

thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_sends_an_over_75_percent_warning_message

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

توجه داشته باشید که کد با پیام already borrowed: BorrowMutError دچار وحشت (panic) شد. این نحوه عملکرد RefCell<T> برای مدیریت نقض قوانین وام‌دهی در زمان اجرا است.

انتخاب اینکه خطاهای وام‌دهی در زمان اجرا و نه در زمان کامپایل بررسی شوند، همانطور که در اینجا انجام دادیم، به این معنا است که ممکن است اشتباهات در کد شما در مراحل بعدی فرآیند توسعه کشف شوند: شاید حتی تا زمانی که کد شما به محیط تولید (production) استقرار یابد. همچنین، کد شما جریمه عملکردی کوچکی را به دلیل پیگیری وام‌ها در زمان اجرا به جای زمان کامپایل متحمل خواهد شد. با این حال، استفاده از RefCell<T> امکان نوشتن یک شیء Mock را فراهم می‌کند که می‌تواند خود را تغییر دهد تا پیام‌هایی که مشاهده کرده است را پیگیری کند، در حالی که شما آن را در یک زمینه که تنها مقادیر غیرقابل‌تغییر مجاز هستند استفاده می‌کنید. شما می‌توانید با وجود این مبادلات، از RefCell<T> برای دریافت عملکرد بیشتری نسبت به ارجاع‌های معمولی استفاده کنید.

اجازه‌دادن به چند مالک برای داده‌ی قابل‌تغییر با استفاده از Rc<T> و RefCell<T>

یک روش رایج برای استفاده از RefCell<T> ترکیب آن با Rc<T> است. به خاطر بیاورید که Rc<T> به شما اجازه می‌دهد چندین مالک برای برخی داده‌ها داشته باشید، اما فقط دسترسی غیرقابل‌تغییر به آن داده‌ها را می‌دهد. اگر یک Rc<T> داشته باشید که یک RefCell<T> را نگه می‌دارد، می‌توانید یک مقداری داشته باشید که می‌تواند چندین مالک داشته باشد و شما بتوانید آن را تغییر دهید!

برای مثال، لیست cons در لیستینگ 15-18 را به‌خاطر بیاورید که در آن از Rc<T> استفاده کردیم تا چند لیست بتوانند مالکیت مشترک روی یک لیست دیگر داشته باشند. از آن‌جا که Rc<T> فقط مقادیر تغییرناپذیر را نگه می‌دارد، پس از ایجاد لیست‌ها دیگر نمی‌توانیم هیچ‌یک از مقادیر درون آن‌ها را تغییر دهیم. بیایید RefCell<T> را به خاطر توانایی‌اش در تغییر مقادیر، به ترکیب اضافه کنیم. لیستینگ 15-24 نشان می‌دهد که با استفاده از RefCell<T> در تعریف Cons، می‌توانیم مقدار ذخیره‌شده در تمام لیست‌ها را تغییر دهیم.

Filename: src/main.rs
#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {a:?}");
    println!("b after = {b:?}");
    println!("c after = {c:?}");
}
Listing 15-24: استفاده از Rc<RefCell<i32>> برای ایجاد یک List که می‌توانیم آن را تغییر دهیم

ما مقداری که نمونه‌ای از Rc<RefCell<i32>> است ایجاد می‌کنیم و آن را در یک متغیر به نام value ذخیره می‌کنیم تا بتوانیم بعداً به طور مستقیم به آن دسترسی داشته باشیم. سپس یک List در a با یک متغیر Cons که value را نگه می‌دارد ایجاد می‌کنیم. ما نیاز داریم value را کلون کنیم تا هر دو a و value مالک مقدار داخلی 5 باشند، به جای انتقال مالکیت از value به a یا اینکه a از value وام بگیرد.

لیست a را در یک Rc<T> قرار می‌دهیم تا زمانی که لیست‌های b و c را ایجاد می‌کنیم، هر دو بتوانند به a اشاره کنند؛ کاری که در لیستینگ 15-18 نیز انجام دادیم.

پس از آن‌که لیست‌های a، b و c ایجاد شدند، می‌خواهیم عدد ۱۰ را به مقدار موجود در value اضافه کنیم. این کار را با فراخوانی متد borrow_mut روی value انجام می‌دهیم؛ این متد از قابلیت dereferencing خودکار (که در فصل ۵ در بخش «عملگر -> کجاست؟» درباره‌اش صحبت کردیم) استفاده می‌کند تا Rc<T> را به مقدار درونی از نوع RefCell<T> dereference کند. متد borrow_mut یک smart pointer از نوع RefMut<T> برمی‌گرداند، و ما با استفاده از عملگر * (dereference) مقدار درونی را تغییر می‌دهیم.

وقتی a، b و c را چاپ می‌کنیم، می‌بینیم که همه‌ی آن‌ها مقدار تغییر یافته‌ی 15 را دارند، نه مقدار اولیه‌ی 5:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

این تکنیک واقعاً جالب است! با استفاده از RefCell<T>، یک مقدار List داریم که از بیرون تغییرناپذیر به‌نظر می‌رسد. اما می‌توانیم با استفاده از متدهای RefCell<T> که دسترسی به تغییرپذیری درونی را فراهم می‌کنند، داده‌های خود را در مواقع نیاز تغییر دهیم. بررسی‌های زمان اجرا (runtime) برای قوانین قرض‌گیری از بروز data race جلوگیری می‌کنند، و گاهی ارزش دارد که اندکی از سرعت را فدای این انعطاف‌پذیری در ساختارهای داده کنیم. توجه داشته باشید که RefCell<T> برای کد چندنخی (multithreaded) قابل‌استفاده نیست! Mutex<T> نسخه‌ی ایمن در برابر نخ (thread-safe) از RefCell<T> است، و ما در فصل ۱۶ درباره‌ی Mutex<T> صحبت خواهیم کرد.

چرخه‌های ارجاعی می‌توانند منجر به نشت حافظه شوند

تضمین‌های ایمنی حافظه راست ایجاد حافظه‌ای که هرگز پاک نمی‌شود (که به عنوان نشت حافظه شناخته می‌شود) را دشوار می‌کنند، اما غیرممکن نمی‌کنند. جلوگیری کامل از نشت حافظه یکی از تضمین‌های راست نیست، به این معنی که نشت حافظه در راست ایمن است. ما می‌توانیم ببینیم که راست اجازه نشت حافظه را می‌دهد با استفاده از Rc<T> و RefCell<T>: امکان ایجاد ارجاع‌هایی وجود دارد که آیتم‌ها در آن به یکدیگر در یک چرخه ارجاع می‌دهند. این باعث نشت حافظه می‌شود، زیرا شمارش ارجاع هر آیتم در چرخه هرگز به 0 نمی‌رسد و مقادیر هرگز حذف نمی‌شوند.

ایجاد یک چرخه ارجاعی

بیایید بررسی کنیم که چگونه ممکن است یک چرخه‌ی رفرنس (reference cycle) به‌وجود بیاید و چگونه می‌توان از آن جلوگیری کرد. این بررسی را با تعریف enumی به نام List و متدی به نام tail در لیستینگ 15-25 آغاز می‌کنیم.

Filename: src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {}
Listing 15-25: تعریف یک لیست cons که یک RefCell<T> نگه می‌دارد تا بتوانیم آنچه که یک متغیر Cons به آن اشاره می‌کند را تغییر دهیم

ما از یک نسخه دیگر از تعریف List که در فهرست 15-5 آمده بود استفاده می‌کنیم. عنصر دوم در متغیر Cons اکنون RefCell<Rc<List>> است، به این معنی که به جای توانایی تغییر مقدار i32 که در فهرست 15-24 داشتیم، می‌خواهیم مقدار List را که یک متغیر Cons به آن اشاره می‌کند، تغییر دهیم. همچنین، یک متد tail اضافه می‌کنیم تا دسترسی به آیتم دوم را در صورتی که یک متغیر Cons داریم، راحت‌تر کنیم.

در فهرست 15-26، یک تابع main اضافه می‌کنیم که از تعاریف فهرست 15-25 استفاده می‌کند. این کد لیستی در a و لیستی در b ایجاد می‌کند که به لیست a اشاره می‌کند. سپس لیست در a را تغییر می‌دهد تا به b اشاره کند و یک چرخه ارجاعی ایجاد کند. در طول این فرآیند، اظهارات println! وجود دارند که نشان می‌دهند شمارش ارجاع در نقاط مختلف چه مقدار است.

Filename: src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack.
    // println!("a next item = {:?}", a.tail());
}
Listing 15-26: ایجاد یک چرخه ارجاعی از دو مقدار List که به یکدیگر اشاره می‌کنند

ما یک نمونه‌ی Rc<List> ایجاد می‌کنیم که یک مقدار از نوع List را در متغیر a نگه می‌دارد و لیست اولیه‌ای به شکل 5, Nil دارد. سپس یک نمونه‌ی دیگر از Rc<List> در متغیر b می‌سازیم که مقداری برابر با 10 دارد و به لیست موجود در a اشاره می‌کند.

ما a را تغییر می‌دهیم تا به جای Nil به b اشاره کند، و یک چرخه ایجاد می‌کنیم. این کار را با استفاده از متد tail انجام می‌دهیم تا یک ارجاع به RefCell<Rc<List>> در a بگیریم، که آن را در متغیر link قرار می‌دهیم. سپس از متد borrow_mut روی RefCell<Rc<List>> استفاده می‌کنیم تا مقدار داخلی را از یک Rc<List> که مقدار Nil را نگه می‌دارد به Rc<List> در b تغییر دهیم.

وقتی این کد را اجرا می‌کنیم و println! آخر را به طور موقت کامنت می‌کنیم، خروجی زیر را دریافت می‌کنیم:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

تعداد رفرنس‌های نمونه‌های Rc<List> در هر دو متغیر a و b پس از آن‌که لیست در a را طوری تغییر می‌دهیم که به b اشاره کند، برابر با ۲ خواهد شد. در پایان تابع main، Rust متغیر b را حذف می‌کند، که شمارنده‌ی رفرنس نمونه‌ی Rc<List> مربوط به b را از ۲ به ۱ کاهش می‌دهد. حافظه‌ای که Rc<List> در heap نگه می‌دارد در این لحظه آزاد نخواهد شد، چون شمارنده‌ی رفرنس آن هنوز ۱ است، نه صفر. سپس Rust متغیر a را حذف می‌کند، که شمارنده‌ی رفرنس نمونه‌ی Rc<List> مربوط به a را نیز از ۲ به ۱ کاهش می‌دهد. حافظه‌ی این نمونه نیز نمی‌تواند آزاد شود، زیرا نمونه‌ی دیگر از Rc<List> هنوز به آن اشاره دارد. در نتیجه، حافظه‌ای که به این لیست اختصاص داده شده است، برای همیشه آزاد نخواهد شد.

برای تصویرسازی این چرخه‌ی رفرنس، دیاگرام زیر را در شکل 15-4 ایجاد کرده‌ایم:

مستطیلی با برچسب 'a' که به مستطیلی شامل عدد صحیح 5 اشاره می‌کند. مستطیلی با برچسب 'b' که به مستطیلی شامل عدد صحیح 10 اشاره می‌کند. مستطیل حاوی عدد 5 به مستطیل حاوی عدد 10 اشاره دارد، و مستطیل حاوی عدد 10 دوباره به مستطیل حاوی عدد 5 اشاره دارد، و این یک چرخه ایجاد می‌کند

شکل 15-4: یک چرخه ارجاعی از لیست‌های a و b که به یکدیگر اشاره می‌کنند

اگر آخرین println! را از حالت کامنت خارج کرده و برنامه را اجرا کنید، Rust تلاش خواهد کرد این چرخه را چاپ کند؛ چرخه‌ای که در آن a به b اشاره می‌کند، b به a و دوباره a به b و همین‌طور ادامه پیدا می‌کند تا جایی که پشته (stack) پر شده و stack overflow رخ می‌دهد.

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

ایجاد چرخه‌های ارجاعی کار آسانی نیست، اما غیرممکن هم نیست. اگر مقادیر RefCell<T> داشته باشید که مقادیر Rc<T> یا ترکیبات مشابهی از انواع با تغییرپذیری داخلی و شمارش ارجاع را در خود جای دهند، باید مطمئن شوید که چرخه‌ای ایجاد نمی‌کنید؛ نمی‌توانید به راست اعتماد کنید که آن‌ها را شناسایی کند. ایجاد چرخه ارجاعی یک اشکال منطقی در برنامه شما خواهد بود که باید با استفاده از تست‌های خودکار، بررسی کد، و دیگر شیوه‌های توسعه نرم‌افزار، آن را به حداقل برسانید.

یک راه‌حل دیگر برای جلوگیری از چرخه‌های ارجاعی، بازسازی ساختار داده‌هایتان است به‌طوری که برخی ارجاعات بیانگر مالکیت باشند و برخی نباشند. به این ترتیب، می‌توانید چرخه‌هایی داشته باشید که شامل برخی روابط مالکیت و برخی روابط غیرمالکیت هستند، و تنها روابط مالکیت تعیین می‌کنند که آیا یک مقدار می‌تواند حذف شود یا خیر. در فهرست 15-25، ما همیشه می‌خواهیم که متغیرهای Cons مالک لیست‌هایشان باشند، بنابراین بازسازی ساختار داده امکان‌پذیر نیست. بیایید به یک مثال با استفاده از گراف‌ها که شامل گره‌های والد و فرزند هستند نگاه کنیم تا ببینیم چه زمانی روابط غیرمالکیت یک راه مناسب برای جلوگیری از چرخه‌های ارجاعی هستند.

جلوگیری از چرخه‌های رفرنس با استفاده از Weak<T>

تا این‌جا نشان دادیم که فراخوانی Rc::clone شمارنده‌ی strong_count یک نمونه‌ی Rc<T> را افزایش می‌دهد، و یک نمونه‌ی Rc<T> تنها زمانی پاک‌سازی می‌شود که مقدار strong_count آن برابر با صفر باشد. همچنین می‌توانید با فراخوانی Rc::downgrade و ارسال یک رفرنس به Rc<T>، یک رفرنس ضعیف (weak reference) به مقدار درون یک نمونه‌ی Rc<T> ایجاد کنید.

رفرنس‌های قوی (strong references) روشی برای به‌اشتراک‌گذاری مالکیت یک نمونه‌ی Rc<T> هستند. در مقابل، رفرنس‌های ضعیف رابطه‌ی مالکیتی ایجاد نمی‌کنند، و شمارش آن‌ها (weak count) هیچ تأثیری در زمان پاک‌سازی یک نمونه‌ی Rc<T> ندارد. آن‌ها باعث ایجاد چرخه‌ی رفرنس نمی‌شوند، زیرا هر چرخه‌ای که شامل برخی weak reference باشد، زمانی شکسته می‌شود که شمارنده‌ی قوی (strong_count) مقادیر درگیر در آن چرخه به صفر برسد.

وقتی Rc::downgrade را فراخوانی می‌کنید، یک اسمارت پوینتر از نوع Weak<T> دریافت می‌کنید. به جای افزایش شمارش strong_count در نمونه Rc<T> به مقدار 1، فراخوانی Rc::downgrade شمارش weak_count را به مقدار 1 افزایش می‌دهد. نوع Rc<T> از weak_count برای پیگیری تعداد ارجاعات Weak<T> موجود استفاده می‌کند، مشابه strong_count. تفاوت این است که شمارش weak_count نیازی به 0 بودن برای پاک‌سازی نمونه Rc<T> ندارد.

از آن‌جا که مقداری که یک Weak<T> به آن اشاره می‌کند ممکن است پیش از این پاک شده باشد، برای انجام هر کاری با آن مقدار ابتدا باید مطمئن شوید که هنوز وجود دارد. برای این کار، متد upgrade را روی یک نمونه‌ی Weak<T> فراخوانی می‌کنید؛ این متد یک Option<Rc<T>> بازمی‌گرداند. اگر مقدار Rc<T> هنوز پاک نشده باشد، نتیجه‌ی Some دریافت خواهید کرد؛ و اگر مقدار Rc<T> قبلاً پاک شده باشد، نتیجه‌ی None خواهد بود. از آن‌جا که upgrade یک Option<Rc<T>> برمی‌گرداند، Rust شما را ملزم می‌کند که هر دو حالت Some و None را مدیریت کنید، و در نتیجه از بروز اشاره‌گر نامعتبر جلوگیری می‌شود.

برای مثال، به جای استفاده از یک لیست که آیتم‌های آن فقط درباره آیتم بعدی اطلاع دارند، ما یک درخت ایجاد خواهیم کرد که آیتم‌های آن درباره آیتم‌های فرزند و والد خود اطلاع دارند.

ایجاد یک ساختار داده‌ی درختی: یک Node با گره‌های فرزند (Child Nodes)

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

Filename: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

ما می‌خواهیم که یک Node مالک فرزندان خود باشد و همچنین می‌خواهیم که این مالکیت با متغیرها به اشتراک گذاشته شود تا بتوانیم مستقیماً به هر Node در درخت دسترسی داشته باشیم. برای انجام این کار، آیتم‌های Vec<T> را به عنوان مقادیری از نوع Rc<Node> تعریف می‌کنیم. همچنین می‌خواهیم تغییر دهیم که کدام گره‌ها فرزندان یک گره دیگر باشند، بنابراین در children یک RefCell<T> در اطراف Vec<Rc<Node>> قرار می‌دهیم.

در ادامه، از تعریف struct خود استفاده می‌کنیم و یک نمونه از Node با نام leaf ایجاد می‌کنیم که مقدار آن 3 است و هیچ فرزندی ندارد، و یک نمونه‌ی دیگر با نام branch می‌سازیم که مقدار آن 5 است و leaf را به‌عنوان یکی از فرزندان خود دارد؛ همان‌طور که در لیستینگ 15-27 نشان داده شده است.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}
Listing 15-27: ایجاد یک گره leaf بدون فرزند و یک گره branch با leaf به عنوان یکی از فرزندان آن

ما Rc<Node> را در leaf کلون می‌کنیم و آن را در branch ذخیره می‌کنیم، به این معنی که Node در leaf اکنون دو مالک دارد: leaf و branch. ما می‌توانیم از branch به leaf از طریق branch.children برسیم، اما هیچ راهی برای رفتن از leaf به branch وجود ندارد. دلیل این است که leaf هیچ ارجاعی به branch ندارد و نمی‌داند که آن‌ها مرتبط هستند. ما می‌خواهیم که leaf بداند که branch والد آن است. این کار را در مرحله بعد انجام خواهیم داد.

افزودن یک ارجاع از فرزند به والد

برای آگاه کردن گره فرزند از والدش، باید یک فیلد parent به تعریف ساختار Node خود اضافه کنیم. مشکل در تصمیم‌گیری در مورد نوع parent است. می‌دانیم که نمی‌تواند شامل یک Rc<T> باشد، زیرا این امر باعث ایجاد چرخه ارجاعی می‌شود که در آن leaf.parent به branch اشاره می‌کند و branch.children به leaf، که باعث می‌شود مقادیر strong_count آن‌ها هرگز به 0 نرسد.

با در نظر گرفتن روابط از دیدگاهی دیگر، یک گره والد باید مالک فرزندان خود باشد: اگر یک گره والد حذف شود، گره‌های فرزند آن نیز باید حذف شوند. اما، یک فرزند نباید مالک والدش باشد: اگر یک گره فرزند حذف شود، والد باید همچنان وجود داشته باشد. این مورد برای استفاده از ارجاعات ضعیف (weak references) مناسب است!

بنابراین، به جای Rc<T>، نوع parent را از نوع Weak<T> انتخاب می‌کنیم، به طور خاص یک RefCell<Weak<Node>>. اکنون تعریف ساختار Node ما به این شکل است:

Filename: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

یک گره باید بتواند به گره والد خود اشاره کند، اما مالک آن نباشد. در لیستینگ 15-28، تابع main را به‌روزرسانی می‌کنیم تا از این تعریف جدید استفاده کند و گره leaf بتواند به گره والد خود، یعنی branch، اشاره کند.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
Listing 15-28: یک گره leaf با یک رفرنس ضعیف به گره والدش، branch

ایجاد گره leaf مشابه فهرست 15-27 است با این تفاوت که فیلد parent: leaf ابتدا بدون والد شروع می‌شود، بنابراین یک نمونه جدید و خالی از ارجاع Weak<Node> ایجاد می‌کنیم.

در این مرحله، وقتی سعی می‌کنیم با استفاده از متد upgrade به والد گره leaf دسترسی پیدا کنیم، یک مقدار None دریافت می‌کنیم. این مورد را در خروجی اولین دستور println! مشاهده می‌کنیم:

leaf parent = None

زمانی که گره branch را ایجاد می‌کنیم، این گره در فیلد parent خود دارای یک رفرنس جدید از نوع Weak<Node> خواهد بود، زیرا branch هیچ گره والد ندارد. همچنان leaf یکی از فرزندان branch است. پس از آن‌که نمونه‌ی Node را در branch ساختیم، می‌توانیم گره leaf را طوری تغییر دهیم که یک رفرنس Weak<Node> به والد خود داشته باشد. برای این کار، از متد borrow_mut روی RefCell<Weak<Node>> موجود در فیلد parent در leaf استفاده می‌کنیم، و سپس با استفاده از تابع Rc::downgrade یک رفرنس Weak<Node> به branch می‌سازیم که از Rc<Node> موجود در branch ساخته شده است.

وقتی والد گره leaf را دوباره چاپ می‌کنیم، این بار یک متغیر Some که branch را نگه می‌دارد دریافت می‌کنیم: اکنون leaf می‌تواند به والد خود دسترسی پیدا کند! هنگامی که leaf را چاپ می‌کنیم، همچنین از چرخه‌ای که نهایتاً به سرریز شدن استک مانند فهرست 15-26 منجر می‌شد اجتناب می‌کنیم؛ ارجاعات Weak<Node> به‌صورت (Weak) چاپ می‌شوند:

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

نبود خروجی بی‌نهایت نشان می‌دهد که این کد چرخه ارجاعی ایجاد نکرده است. همچنین می‌توانیم این را با مشاهده مقادیری که از فراخوانی Rc::strong_count و Rc::weak_count دریافت می‌کنیم، تأیید کنیم.

تجسم تغییرات در strong_count و weak_count

بیایید بررسی کنیم که چگونه مقادیر strong_count و weak_count در نمونه‌های Rc<Node> تغییر می‌کنند؛ برای این کار، یک بلاک داخلی جدید ایجاد می‌کنیم و ساخت گره branch را به درون این بلاک منتقل می‌کنیم. با این کار می‌توانیم مشاهده کنیم که چه اتفاقی می‌افتد زمانی که branch ایجاد می‌شود و سپس پس از خارج شدن از حوزه‌ی دید (scope) حذف می‌گردد. این تغییرات در لیستینگ 15-29 نشان داده شده‌اند.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}
Listing 15-29: ایجاد branch در یک دامنه داخلی و بررسی شمارش ارجاعات قوی و ضعیف

پس از ایجاد leaf، مقدار strong_count برای Rc<Node> آن برابر با ۱ و مقدار weak_count برابر با ۰ است. در بلاک داخلی، گره‌ی branch را ایجاد می‌کنیم و آن را به leaf مرتبط می‌سازیم؛ در این مرحله وقتی شمارنده‌ها را چاپ کنیم، Rc<Node> مربوط به branch دارای strong_count برابر با ۱ و weak_count برابر با ۱ خواهد بود (به‌خاطر اینکه leaf.parent به branch با یک Weak<Node> اشاره می‌کند). همچنین وقتی شمارنده‌ها را در leaf چاپ کنیم، مشاهده خواهیم کرد که strong_count آن برابر با ۲ است، زیرا branch اکنون یک کلون از Rc<Node> مربوط به leaf را در branch.children نگه می‌دارد، اما مقدار weak_count آن همچنان ۰ باقی می‌ماند.

وقتی دامنه داخلی به پایان می‌رسد، branch از دامنه خارج می‌شود و شمارش قوی Rc<Node> به 0 کاهش می‌یابد، بنابراین Node آن حذف می‌شود. شمارش ضعیف 1 از leaf.parent تأثیری بر اینکه آیا Node حذف می‌شود ندارد، بنابراین هیچ نشت حافظه‌ای نخواهیم داشت!

اگر بعد از پایان بلاک (scope) تلاش کنیم به والد leaf دسترسی پیدا کنیم، دوباره مقدار None دریافت خواهیم کرد. در انتهای برنامه، مقدار strong_count برای Rc<Node> در leaf برابر با ۱ و مقدار weak_count برابر با ۰ خواهد بود، زیرا متغیر leaf اکنون تنها رفرنس به آن Rc<Node> است.

تمام منطق مدیریت شمارش‌ها و حذف مقدار درون Rc<T> و Weak<T> و پیاده‌سازی‌های ویژگی Drop آن‌ها تعبیه شده است. با مشخص کردن اینکه رابطه از یک فرزند به والد آن باید یک ارجاع Weak<T> باشد در تعریف Node، می‌توانید گره‌های والد را به گره‌های فرزند و بالعکس ارجاع دهید بدون ایجاد یک چرخه ارجاعی و نشت حافظه.

خلاصه

این فصل نحوه استفاده از اسمارت پوینترها برای ارائه تضمین‌ها و مبادلات متفاوت از آنچه که راست به طور پیش‌فرض با ارجاع‌های معمولی ارائه می‌دهد را پوشش داد. نوع Box<T> دارای اندازه مشخصی است و به داده‌های تخصیص‌یافته در heap اشاره می‌کند. نوع Rc<T> تعداد ارجاع‌ها به داده‌ها در heap را پیگیری می‌کند تا داده‌ها بتوانند چندین مالک داشته باشند. نوع RefCell<T> با تغییرپذیری داخلی خود به ما نوعی می‌دهد که می‌توانیم زمانی که به یک نوع غیرقابل‌تغییر نیاز داریم اما باید مقدار درونی آن نوع را تغییر دهیم، استفاده کنیم؛ همچنین قوانین وام‌دهی را در زمان اجرا به جای زمان کامپایل اعمال می‌کند.

همچنین، ویژگی‌های Deref و Drop که بسیاری از قابلیت‌های اسمارت پوینترها را ممکن می‌سازند، مورد بحث قرار گرفتند. ما چرخه‌های ارجاعی که می‌توانند باعث نشت حافظه شوند و نحوه جلوگیری از آن‌ها با استفاده از Weak<T> را بررسی کردیم.

اگر این فصل علاقه شما را برانگیخته و می‌خواهید اسمارت پوینترهای خود را پیاده‌سازی کنید، به “The Rustonomicon” برای اطلاعات مفید بیشتر مراجعه کنید.

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

همزمانی بدون ترس

رسیدگی ایمن و کارآمد به برنامه‌نویسی هم‌زمان یکی دیگر از اهداف اصلی Rust است. برنامه‌نویسی هم‌زمان (concurrent programming)، که در آن بخش‌های مختلفی از یک برنامه به‌طور مستقل اجرا می‌شوند، و برنامه‌نویسی موازی (parallel programming)، که در آن بخش‌های مختلفی از برنامه به‌طور هم‌زمان اجرا می‌شوند، با توجه به استفاده‌ی روزافزون کامپیوترها از پردازنده‌های چند‌هسته‌ای، به‌طور فزاینده‌ای اهمیت یافته‌اند. به‌صورت تاریخی، برنامه‌نویسی در این زمینه‌ها دشوار و مستعد خطا بوده است. Rust امیدوار است این وضعیت را تغییر دهد.

در ابتدا، تیم Rust گمان می‌کرد که اطمینان از ایمنی حافظه و جلوگیری از مشکلات هم‌زمانی دو چالش مجزا هستند که باید با روش‌های متفاوتی حل شوند. با گذشت زمان، این تیم دریافت که سیستم مالکیت و سیستم نوع‌ها در Rust مجموعه‌ای قدرتمند از ابزارها هستند که می‌توانند هم برای مدیریت ایمنی حافظه و هم برای حل مشکلات هم‌زمانی مفید باشند! با بهره‌گیری از مالکیت و بررسی نوع‌ها، بسیاری از خطاهای هم‌زمانی در Rust به جای آن‌که خطاهایی در زمان اجرا باشند، در زمان کامپایل شناسایی می‌شوند. بنابراین، به‌جای صرف زمان زیاد برای بازتولید شرایط دقیق بروز یک باگ هم‌زمانی در زمان اجرا، کد نادرست اصلاً کامپایل نخواهد شد و خطایی با توضیح مشکل به شما نمایش داده می‌شود. در نتیجه، شما می‌توانید کد خود را همان موقع که روی آن کار می‌کنید اصلاح کنید، نه احتمالاً پس از آن‌که به مرحله‌ی تولید رسیده است. ما این ویژگی Rust را با لقب هم‌زمانی بی‌باکانه (fearless concurrency) توصیف کرده‌ایم. هم‌زمانی بی‌باکانه به شما این امکان را می‌دهد که کدی بدون باگ‌های ظریف بنویسید و آن را بدون ایجاد باگ‌های جدید، به‌راحتی بازسازی (refactor) کنید.

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

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

موضوعاتی که در این فصل پوشش خواهیم داد عبارت‌اند از:

  • نحوه ایجاد نخ‌ها برای اجرای همزمان چندین بخش از کد
  • همزمانی پیام‌رسانی، جایی که کانال‌ها پیام‌ها را بین نخ‌ها ارسال می‌کنند
  • همزمانی حالت اشتراکی، جایی که چندین نخ به بخشی از داده دسترسی دارند
  • صفات Sync و Send، که تضمین‌های همزمانی Rust را به انواع تعریف‌شده توسط کاربر و همچنین انواع ارائه‌شده توسط کتابخانه استاندارد گسترش می‌دهند

استفاده از نخ‌ها برای اجرای همزمان کد

در اغلب سیستم‌عامل‌های امروزی، کدی که در یک برنامه اجرا می‌شود در قالب یک پروسه (process) اجرا می‌شود، و سیستم‌عامل به‌طور هم‌زمان چندین پروسه را مدیریت می‌کند.
در درون یک برنامه، می‌توان بخش‌های مستقلی نیز داشت که به‌صورت هم‌زمان اجرا می‌شوند. ویژگی‌هایی که این بخش‌های مستقل را اجرا می‌کنند، ترد (thread) نام دارند.
برای مثال، یک وب‌سرور می‌تواند چندین ترد داشته باشد تا بتواند هم‌زمان به چندین درخواست پاسخ دهد.

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

  • شرایط رقابتی (Race conditions)، زمانی که تردها به داده‌ها یا منابع به‌صورت نامنظم و ناسازگار دسترسی پیدا می‌کنند
  • بن‌بست‌ها (Deadlocks)، زمانی که دو ترد منتظر یکدیگر هستند و هیچ‌کدام نمی‌توانند به اجرای خود ادامه دهند
  • باگ‌هایی که تنها در شرایط خاصی رخ می‌دهند و بازتولید و رفع آن‌ها به‌صورت قابل‌اعتماد دشوار است

Rust تلاش می‌کند اثرات منفی استفاده از نخ‌ها را کاهش دهد، اما برنامه‌نویسی در یک زمینه چندنخی همچنان نیاز به تفکر دقیق و ساختاری متفاوت از برنامه‌های تک‌نخی دارد.

زبان‌های برنامه‌نویسی، پیاده‌سازی تردها را به روش‌های مختلفی انجام می‌دهند و بسیاری از سیستم‌عامل‌ها یک API برای ایجاد تردهای جدید در اختیار زبان برنامه‌نویسی قرار می‌دهند. کتابخانه استاندارد Rust از مدل پیاده‌سازی ترد 1:1 استفاده می‌کند؛ به‌عبارت دیگر، هر ترد زبان، متناظر با یک ترد سیستم‌عامل است. کتابخانه‌هایی (crate) نیز وجود دارند که مدل‌های دیگری از تردینگ را پیاده‌سازی می‌کنند و نسبت به مدل 1:1، مصالحه‌ها و ویژگی‌های متفاوتی دارند. (سیستم async در Rust، که در فصل بعدی آن را خواهیم دید، نیز رویکردی دیگر برای هم‌زمانی ارائه می‌دهد.)

ایجاد یک نخ جدید با spawn

برای ایجاد یک ترد جدید، از تابع thread::spawn استفاده می‌کنیم و یک closure (که در فصل ۱۳ درباره آن صحبت کردیم) را به آن می‌دهیم که حاوی کدی است که می‌خواهیم در ترد جدید اجرا شود. مثال موجود در لیستینگ 16-1، متنی را از ترد اصلی چاپ می‌کند و متنی دیگر را از یک ترد جدید.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}
Listing 16-1: ایجاد یک نخ جدید برای چاپ یک چیز در حالی که نخ اصلی چیز دیگری چاپ می‌کند

توجه داشته باشید که وقتی نخ اصلی یک برنامه Rust تکمیل می‌شود، تمام نخ‌های ایجادشده متوقف می‌شوند، چه آن‌ها اجرای خود را تکمیل کرده باشند یا نه. خروجی این برنامه ممکن است هر بار کمی متفاوت باشد، اما به صورت مشابه زیر خواهد بود:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

فراخوانی‌های thread::sleep باعث می‌شوند یک ترد اجرای خود را برای مدت کوتاهی متوقف کند و به ترد دیگری اجازه اجرای کد را بدهد. احتمالاً تردها به نوبت اجرا خواهند شد، اما این موضوع تضمین‌شده نیست: این‌که کدام ترد اجرا شود بستگی به نحوه زمان‌بندی (scheduling) تردها توسط سیستم‌عامل دارد. در این اجرا، ترد اصلی زودتر چاپ کرد، با این‌که دستور چاپ ترد جدید زودتر در کد آمده است. و حتی با این‌که به ترد جدید گفتیم تا زمانی که مقدار i به 9 برسد چاپ کند، فقط تا مقدار 5 اجرا شد پیش از آن‌که ترد اصلی متوقف شود.

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

منتظر ماندن برای تکمیل همه نخ‌ها با استفاده از join Handles

کد موجود در لیستینگ 16-1 نه تنها بیشتر اوقات نخ ایجادشده را به دلیل پایان نخ اصلی زودتر از موعد متوقف می‌کند، بلکه به دلیل اینکه هیچ تضمینی برای ترتیب اجرای نخ‌ها وجود ندارد، نمی‌توانیم اطمینان حاصل کنیم که نخ ایجادشده اجرا خواهد شد!

ما می‌توانیم مشکل اجرا نشدن ترد جدید یا پایان زودهنگام آن را با ذخیره مقدار بازگشتی thread::spawn در یک متغیر حل کنیم. نوع بازگشتی thread::spawn برابر است با JoinHandle<T>. یک JoinHandle<T> یک مقدار مالک (owned) است که وقتی متد join را روی آن فراخوانی کنیم، منتظر می‌ماند تا اجرای ترد مربوطه به پایان برسد. در فهرست 16-2 نشان داده شده است که چگونه از JoinHandle<T> تردی که در فهرست 16-1 ایجاد کردیم استفاده کنیم و چگونه با فراخوانی join اطمینان حاصل کنیم که ترد جدید پیش از خروج main به پایان می‌رسد.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}
Listing 16-2: ذخیره یک JoinHandle<T> از thread::spawn برای تضمین اجرای کامل ترد

فراخوانی join روی handle نخ جاری را مسدود می‌کند تا زمانی که نخ نمایانده‌شده توسط handle خاتمه یابد. مسدود کردن یک نخ به این معناست که آن نخ از انجام کار یا خروج جلوگیری می‌شود. چون فراخوانی join را بعد از حلقه for نخ اصلی قرار داده‌ایم، اجرای لیستینگ 16-2 باید خروجی مشابه زیر تولید کند:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

دو نخ همچنان به صورت متناوب اجرا می‌شوند، اما نخ اصلی به دلیل فراخوانی handle.join() منتظر می‌ماند و تا زمانی که نخ ایجادشده تکمیل نشود پایان نمی‌یابد.

اما بیایید ببینیم چه اتفاقی می‌افتد اگر handle.join() را قبل از حلقه for در main منتقل کنیم، به این صورت:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

نخ اصلی منتظر می‌ماند تا نخ ایجادشده خاتمه یابد و سپس حلقه for خود را اجرا می‌کند، بنابراین خروجی دیگر به صورت متناوب نخواهد بود، همان‌طور که در اینجا نشان داده شده است:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

جزئیات کوچک، مانند مکان فراخوانی join، می‌توانند بر اینکه نخ‌های شما همزمان اجرا می‌شوند یا خیر تأثیر بگذارند.

استفاده از Closureهای move با نخ‌ها

ما اغلب از کلمه کلیدی move همراه با closuresهایی که به thread::spawn داده می‌شوند استفاده می‌کنیم، زیرا در این صورت closure مالکیت مقادیری که از محیط استفاده می‌کند را به خود می‌گیرد، و به این ترتیب مالکیت آن مقادیر از یک ترد به ترد دیگر منتقل می‌شود. در بخش «گرفتن رفرنس یا انتقال مالکیت» در فصل 13، move را در زمینه‌ی closures بررسی کردیم. اکنون تمرکز بیشتری بر تعامل بین move و thread::spawn خواهیم داشت.

در فهرست 16-1 توجه کنید که closureیی که به thread::spawn می‌دهیم هیچ آرگومانی نمی‌گیرد: ما در کد ترد ایجاد شده از هیچ داده‌ای از ترد اصلی استفاده نمی‌کنیم. برای استفاده از داده‌های ترد اصلی در ترد جدید، closure در ترد جدید باید مقادیری را که نیاز دارد capture کند. فهرست 16-3 تلاشی را برای ایجاد یک vector در ترد اصلی و استفاده از آن در ترد ایجاد شده نشان می‌دهد. با این حال، همان‌طور که در ادامه خواهید دید، این کد هنوز کار نخواهد کرد.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-3: تلاش برای استفاده از یک بردار ایجادشده توسط نخ اصلی در یک نخ دیگر

این closure از v استفاده می‌کند، بنابراین v را می‌گیرد و آن را بخشی از محیط closure می‌کند. از آنجا که thread::spawn این closure را در یک نخ جدید اجرا می‌کند، باید بتوانیم به v در داخل آن نخ جدید دسترسی داشته باشیم. اما وقتی این مثال را کامپایل می‌کنیم، خطای زیر را دریافت می‌کنیم:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

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

زبان Rust به صورت خودکار تشخیص می‌دهد که چگونه باید مقدار v را capture کند، و از آن‌جا که println! تنها به یک رفرنس به v نیاز دارد، closure تلاش می‌کند تا v را قرض بگیرد (borrow کند). اما مشکلی وجود دارد: Rust نمی‌تواند تشخیص دهد که ترد ایجادشده چه مدت اجرا خواهد شد، بنابراین نمی‌داند که آیا رفرنس به v همیشه معتبر خواهد ماند یا نه.

فهرست 16-4 سناریویی را نشان می‌دهد که احتمال نامعتبر بودن رفرنس به v در آن بیشتر است.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}
Listing 16-4: یک نخ با closureی که سعی می‌کند یک ارجاع به v را از نخ اصلی که v را حذف می‌کند بگیرد

اگر Rust اجازه اجرای این کد را به ما می‌داد، این احتمال وجود داشت که ترد ایجادشده بلافاصله به پس‌زمینه منتقل شود بدون آن‌که اجرا شود. این ترد ایجادشده، یک رفرنس به v در درون خود دارد، اما ترد اصلی بلافاصله v را drop می‌کند، با استفاده از تابع drop که در فصل ۱۵ درباره‌اش صحبت کردیم. سپس، زمانی که ترد ایجادشده شروع به اجرا کند، دیگر v وجود ندارد، بنابراین رفرنسی که به آن اشاره دارد نیز نامعتبر خواهد بود. اوه نه!

برای رفع خطای کامپایل در لیستینگ 16-3، می‌توانیم از مشاوره پیام خطا استفاده کنیم:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

با اضافه‌کردن کلمه‌ی کلیدی move قبل از closure، ما closure را مجبور می‌کنیم که مالکیت مقادیری را که استفاده می‌کند، بگیرد، به‌جای آن‌که اجازه دهیم Rust به‌طور ضمنی نتیجه بگیرد که باید آن مقادیر را قرض بگیرد. اصلاحات اعمال‌شده روی Listing 16-3 که در Listing 16-5 نشان داده شده‌اند، همان‌گونه که انتظار داریم کامپایل شده و اجرا خواهند شد.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-5: استفاده از کلمه کلیدی move برای مجبور کردن یک closure به گرفتن مالکیت مقادیری که استفاده می‌کند

ممکن است وسوسه شویم که همین کار را برای رفع کد در لیستینگ 16-4 که نخ اصلی drop را فراخوانی می‌کند با استفاده از یک closure move انجام دهیم. با این حال، این راه‌حل کار نخواهد کرد زیرا آنچه لیستینگ 16-4 تلاش می‌کند انجام دهد به دلیل دیگری مجاز نیست. اگر move را به closure اضافه کنیم، v را به محیط closure منتقل می‌کنیم و دیگر نمی‌توانیم drop را در نخ اصلی روی آن فراخوانی کنیم. در عوض، این خطای کامپایل را دریافت خواهیم کرد:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

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

قوانین مالکیت Rust باز هم ما را نجات دادند! در کد موجود در Listing 16-3 خطا دریافت کردیم، زیرا Rust به‌صورت محافظه‌کارانه عمل کرده و تنها v را برای thread قرض گرفته بود، که این یعنی thread اصلی می‌توانست به‌طور نظری رفرنسی که thread ایجادشده به آن نیاز دارد را نامعتبر کند. با گفتن این موضوع به Rust که مالکیت v را به thread جدید منتقل کند (move)، ما این تضمین را به Rust می‌دهیم که thread اصلی دیگر از v استفاده نخواهد کرد. اگر Listing 16-4 را هم به همین شکل تغییر دهیم، در واقع داریم قوانین مالکیت را با تلاش برای استفاده از v در thread اصلی نقض می‌کنیم. کلمه‌ی کلیدی move رفتار پیش‌فرض محافظه‌کارانه‌ی Rust را که قرض‌گیری است، لغو می‌کند؛ اما اجازه نمی‌دهد قوانین مالکیت را زیر پا بگذاریم.

اکنون که درک خوبی از چیستی threadها و متدهای ارائه‌شده توسط API مربوط به thread داریم، بیایید به بررسی برخی موقعیت‌ها بپردازیم که می‌توانیم در آن‌ها از threadها استفاده کنیم.

استفاده از پیام‌رسانی برای انتقال داده بین نخ‌ها

یکی از رویکردهای محبوب و فزاینده برای اطمینان از همزمانی ایمن، پیام‌رسانی است، جایی که نخ‌ها یا بازیگران با ارسال پیام‌های حاوی داده به یکدیگر ارتباط برقرار می‌کنند. ایده این رویکرد در یک شعار از مستندات زبان Go آمده است:
«با به اشتراک گذاشتن حافظه ارتباط برقرار نکنید؛ بلکه حافظه را با ارتباط برقرار کردن به اشتراک بگذارید.»

برای دستیابی به هم‌زمانی مبتنی بر ارسال پیام، کتابخانه‌ی استاندارد Rust پیاده‌سازی‌ای از مفهوم channel را فراهم کرده است. یک channel مفهومی کلی در برنامه‌نویسی است که از طریق آن داده‌ها از یک thread به thread دیگر ارسال می‌شوند.

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

یک channel دو بخش دارد: یک فرستنده (transmitter) و یک گیرنده (receiver). بخش فرستنده مانند نقطه‌ی بالادستی رودخانه‌ای است که در آن اردک پلاستیکی را داخل آب می‌اندازید، و بخش گیرنده جایی‌ست که اردک پلاستیکی در پایین‌دست به آن‌جا می‌رسد. بخشی از کد شما با متدهایی بر روی فرستنده، داده‌هایی را که می‌خواهید ارسال کنید قرار می‌دهد، و بخش دیگر کد بررسی می‌کند که آیا پیامی در سمت گیرنده دریافت شده است یا نه. وقتی که یا فرستنده یا گیرنده (یا هر دو) از بین بروند (drop شوند)، گفته می‌شود که channel بسته شده است.

در این‌جا، قصد داریم برنامه‌ای بسازیم که در آن یک thread وظیفه تولید مقادیر و ارسال آن‌ها از طریق یک channel را دارد، و thread دیگری این مقادیر را دریافت کرده و چاپ می‌کند. برای نمایش این قابلیت، مقادیر ساده‌ای را بین threadها ارسال خواهیم کرد. پس از آشنایی با این تکنیک، می‌توانید از channelها برای هر نوع ارتباط میان threadها استفاده کنید؛ مثلاً در یک سامانه‌ی چت یا سیستمی که در آن چند thread قسمت‌هایی از یک محاسبه را انجام می‌دهند و نتیجه‌ها را به یک thread مرکزی برای تجمیع ارسال می‌کنند.

ابتدا، در لیستینگ 16-6، یک کانال ایجاد می‌کنیم اما هنوز کاری با آن انجام نمی‌دهیم. توجه داشته باشید که این کد هنوز کامپایل نمی‌شود زیرا Rust نمی‌تواند نوع مقادیری که می‌خواهیم از طریق کانال ارسال کنیم را تعیین کند.

Filename: src/main.rs
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}
Listing 16-6: Creating a channel and assigning the two halves to tx and rx

ما یک کانال جدید با استفاده از تابع mpsc::channel ایجاد می‌کنیم؛ mpsc مخفف تولیدکننده‌های چندگانه، مصرف‌کننده تک‌گانه است. به طور خلاصه، نحوه پیاده‌سازی کانال‌ها توسط کتابخانه استاندارد Rust به این معناست که یک کانال می‌تواند چندین انتهای ارسال‌کننده داشته باشد که مقادیر تولید می‌کنند، اما فقط یک انتهای گیرنده که آن مقادیر را مصرف می‌کند. تصور کنید چندین جریان کوچک به یک رودخانه بزرگ می‌ریزند: هر چیزی که در هر یک از جریان‌ها ارسال شود، در نهایت به رودخانه بزرگ در انتها می‌رسد. فعلاً با یک تولیدکننده شروع می‌کنیم، اما وقتی این مثال کار کرد، چندین تولیدکننده اضافه خواهیم کرد.

تابع mpsc::channel یک تاپل برمی‌گرداند که عنصر اول آن، بخش ارسال‌کننده یا همان فرستنده (transmitter)، و عنصر دوم آن، بخش دریافت‌کننده یا همان گیرنده (receiver) است. در بسیاری از حوزه‌ها، اختصارات tx و rx به ترتیب برای transmitter و receiver به کار می‌روند، بنابراین ما نیز نام متغیرها را به همین صورت انتخاب می‌کنیم تا نقش هر بخش را مشخص کنیم. در این‌جا از یک دستور let همراه با یک الگو استفاده کرده‌ایم که تاپل را تجزیه (destructure) می‌کند؛ در فصل ۱۹ درباره‌ی استفاده از الگوها در دستورات let و تجزیه بیشتر صحبت خواهیم کرد. در حال حاضر، تنها کافی‌ست بدانید که استفاده از let به این صورت، روشی مناسب برای استخراج اجزای تاپلی است که تابع mpsc::channel بازمی‌گرداند.

بیایید انتهای ارسال‌کننده را به یک نخ ایجادشده منتقل کنیم و یک رشته ارسال کنیم تا نخ ایجادشده با نخ اصلی ارتباط برقرار کند، همان‌طور که در لیستینگ 16-7 نشان داده شده است. این شبیه به انداختن یک اردک پلاستیکی در رودخانه در بالادست یا ارسال یک پیام چت از یک نخ به نخ دیگر است.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}
Listing 16-7: انتقال tx به یک نخ ایجادشده و ارسال ‘hi’

باز هم از thread::spawn برای ایجاد یک نخ جدید استفاده می‌کنیم و سپس با استفاده از move، مالکیت tx را به داخل closure منتقل می‌کنیم تا نخ ایجادشده مالک tx شود. نخ ایجادشده باید مالک فرستنده باشد تا بتواند از طریق کانال پیام ارسال کند.

فرستنده دارای متدی به نام send است که مقداری را که می‌خواهیم ارسال کنیم دریافت می‌کند. متد send یک نوع Result<T, E> بازمی‌گرداند؛ بنابراین اگر گیرنده قبلاً حذف شده باشد و دیگر جایی برای ارسال مقدار وجود نداشته باشد، عملیات ارسال منجر به خطا خواهد شد. در این مثال، ما با فراخوانی unwrap در صورت بروز خطا باعث panick شدن برنامه می‌شویم. اما در یک برنامه واقعی، باید این خطا را به‌درستی مدیریت کنیم؛ برای مرور استراتژی‌های مدیریت خطا به فصل ۹ بازگردید.

در لیستینگ 16-8، مقداری را از گیرنده در نخ اصلی دریافت می‌کنیم. این شبیه به گرفتن اردک پلاستیکی از آب در انتهای رودخانه یا دریافت یک پیام چت است.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
Listing 16-8: Receiving the value "hi" in the main thread and printing it

گیرنده دو متد مفید دارد: recv و try_recv. ما از recv، که مخفف receive است، استفاده می‌کنیم. این متد اجرای نخ اصلی را مسدود کرده و منتظر می‌ماند تا مقداری از طریق کانال ارسال شود. هنگامی که مقداری ارسال شد، recv آن را در یک مقدار Result<T, E> بازمی‌گرداند. وقتی فرستنده بسته می‌شود، recv یک خطا برمی‌گرداند تا نشان دهد که هیچ مقدار دیگری نمی‌آید.

متد try_recv مسدود نمی‌کند، بلکه بلافاصله یک مقدار Result<T, E> بازمی‌گرداند: یک مقدار Ok حاوی یک پیام اگر موجود باشد، و یک مقدار Err اگر این بار هیچ پیامی موجود نباشد. استفاده از try_recv زمانی مفید است که این نخ کار دیگری برای انجام دارد در حالی که منتظر پیام‌ها است: می‌توانیم یک حلقه بنویسیم که هر چند وقت یک بار try_recv را فراخوانی کند، یک پیام را اگر موجود باشد پردازش کند، و در غیر این صورت کار دیگری را برای مدتی انجام دهد تا دوباره بررسی کند.

ما در این مثال برای سادگی از recv استفاده کرده‌ایم؛ نخ اصلی کار دیگری جز منتظر ماندن برای پیام‌ها ندارد، بنابراین مسدود کردن نخ اصلی مناسب است.

وقتی کد موجود در لیستینگ 16-8 را اجرا کنیم، مقدار چاپ‌شده از نخ اصلی را خواهیم دید:

Got: hi

عالی!

کانال‌ها و انتقال مالکیت

قوانین مالکیت نقش حیاتی‌ای در ارسال پیام ایفا می‌کنند، چرا که به شما کمک می‌کنند تا کدی ایمن و هم‌زمان (concurrent) بنویسید. جلوگیری از بروز خطا در برنامه‌نویسی هم‌زمان یکی از مزایای تفکر بر مبنای مالکیت در سراسر برنامه‌های Rust است. بیایید یک آزمایش انجام دهیم تا ببینیم چگونه کانال‌ها و مالکیت با هم همکاری می‌کنند تا از بروز مشکل جلوگیری شود: در این آزمایش، سعی می‌کنیم از متغیر val در نخ ایجاد‌شده بعد از این‌که آن را از طریق کانال ارسال کرده‌ایم، استفاده کنیم. سعی کنید کدی که در فهرست 16-9 آمده را کامپایل کنید تا ببینید چرا این کد مجاز نیست.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {val}");
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
Listing 16-9: تلاش برای استفاده از val پس از ارسال آن از طریق کانال

در اینجا، ما سعی می‌کنیم val را پس از ارسال آن از طریق tx.send چاپ کنیم. اجازه دادن به این کار ایده بدی خواهد بود: هنگامی که مقدار به نخ دیگری ارسال شده است، آن نخ می‌تواند قبل از اینکه سعی کنیم دوباره از مقدار استفاده کنیم، آن را تغییر دهد یا حذف کند. به طور بالقوه، تغییرات نخ دیگر می‌تواند باعث خطاها یا نتایج غیرمنتظره به دلیل داده‌های ناسازگار یا غیرموجود شود. با این حال، Rust اگر سعی کنیم کد موجود در لیستینگ 16-9 را کامپایل کنیم، به ما خطا می‌دهد:

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:26
   |
8  |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {val}");
   |                          ^^^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

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

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

ارسال مقادیر متعدد و مشاهده انتظار گیرنده

کدی که در لیستینگ 16-8 آمده بود کامپایل و اجرا شد، اما به‌صورت واضح به ما نشان نداد که دو نخ مجزا از طریق یک channel با یکدیگر در حال ارتباط هستند.

In Listing 16-10 we’ve made some modifications that will prove the code in Listing 16-8 is running concurrently: the spawned thread will now send multiple messages and pause for a second between each message.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }
}
Listing 16-10: Sending multiple messages and pausing between each one

این بار، نخی که ایجاد شده (spawned thread) یک وکتور از رشته‌ها (vector of strings) دارد که می‌خواهیم آن‌ها را به نخ اصلی ارسال کنیم. روی این رشته‌ها پیمایش می‌کنیم، هر کدام را به‌صورت جداگانه ارسال می‌کنیم، و بین ارسال هر کدام، با فراخوانی تابع thread::sleep و دادن یک مقدار Duration برابر با یک ثانیه، مکث می‌کنیم.

در نخ اصلی، دیگر تابع recv را به طور صریح فراخوانی نمی‌کنیم: در عوض، با rx به عنوان یک تکرارگر رفتار می‌کنیم. برای هر مقداری که دریافت می‌شود، آن را چاپ می‌کنیم. هنگامی که کانال بسته می‌شود، تکرار متوقف خواهد شد.

هنگام اجرای کد موجود در لیست 16-10، باید خروجی زیر را مشاهده کنید با یک ثانیه توقف بین هر خط:

Got: hi
Got: from
Got: the
Got: thread

از آنجا که هیچ کدی در حلقه for نخ اصلی نداریم که مکث یا تأخیری ایجاد کند، می‌توانیم بگوییم که نخ اصلی منتظر دریافت مقادیر از نخ ایجادشده است.

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

پیش‌تر اشاره کردیم که mpsc مخفف چند تولیدکننده، یک مصرف‌کننده است. بیایید از mpsc استفاده کنیم و کد موجود در لیست 16-10 را گسترش دهیم تا چندین ترد ایجاد کنیم که همگی مقادیر را به یک دریافت‌کننده ارسال می‌کنند. برای این کار می‌توانیم فرستنده را clone کنیم، همان‌طور که در لیست 16-11 نشان داده شده است.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }

    // --snip--
}
Listing 16-11: ارسال چندین پیام از چندین تولیدکننده

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

وقتی کد را اجرا می‌کنید، خروجی شما باید چیزی شبیه به این باشد:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

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

اکنون که دیدیم کانال‌ها چگونه کار می‌کنند، بیایید به یک روش دیگر همزمانی نگاهی بیندازیم.

همزمانی با حالت مشترک (Shared-State Concurrency)

ارسال پیام (Message passing) روش مناسبی برای مدیریت هم‌زمانی (concurrency) است، اما تنها روش موجود نیست. روش دیگری نیز وجود دارد که در آن چندین ترد (thread) به داده‌ی مشترک یکسانی دسترسی دارند. دوباره این بخش از شعار مستندات زبان Go را در نظر بگیرید: «با به‌اشتراک‌گذاری حافظه ارتباط برقرار نکنید.»

ارتباط با به اشتراک‌گذاری حافظه چگونه خواهد بود؟ علاوه بر این، چرا علاقه‌مندان به ارسال پیام هشدار می‌دهند که از اشتراک حافظه استفاده نکنید؟

به‌نوعی، کانال‌ها (channels) در هر زبان برنامه‌نویسی مشابه مالکیت تکی (single ownership) هستند، چرا که وقتی یک مقدار را از طریق کانال انتقال می‌دهید، دیگر نباید از آن مقدار استفاده کنید. هم‌زمانی با حافظه‌ی مشترک (shared-memory concurrency) شبیه به مالکیت چندگانه است: چندین ترد می‌توانند به‌طور هم‌زمان به یک محل حافظه دسترسی داشته باشند. همان‌طور که در فصل ۱۵ دیدید، جایی که smart pointerها امکان مالکیت چندگانه را فراهم کردند، مالکیت چندگانه می‌تواند پیچیدگی‌هایی را به همراه داشته باشد، چرا که این مالکان مختلف نیاز به مدیریت دارند. سیستم نوع‌دهی و قواعد مالکیت در Rust کمک شایانی به مدیریت درست این وضعیت می‌کنند. به عنوان یک مثال، بیایید به mutexها نگاه کنیم، که یکی از ابتدایی‌ترین سازوکارهای هم‌زمانی برای حافظه‌ی مشترک هستند.

استفاده از Mutex‌ها برای اجازه دسترسی به داده‌ها توسط یک نخ در هر زمان

واژه‌ی Mutex مخفف mutual exclusion به‌معنای «ممانعت متقابل» است؛ به این معنا که یک mutex فقط به یک ترد اجازه می‌دهد تا در هر لحظه به داده‌ای دسترسی داشته باشد. برای دسترسی به داده درون یک mutex، یک ترد ابتدا باید اعلام کند که قصد دسترسی دارد، با درخواست قفل (lock) آن mutex. Lock یک ساختار داده‌ای است که بخشی از mutex به‌شمار می‌رود و مسئول پیگیری این است که در حال حاضر چه کسی به‌صورت انحصاری به داده دسترسی دارد. بنابراین، mutex به‌عنوان ابزاری توصیف می‌شود که از داده‌ای که در خود نگه می‌دارد از طریق سیستم قفل‌گذاری محافظت می‌کند.

Mutex‌ها به دلیل این که باید دو قانون را به خاطر بسپارید، به سخت بودن شهرت دارند:

  1. پیش از استفاده از داده، باید تلاش کنید تا قفل (lock) آن را به‌دست آورید.
  2. زمانی که کارتان با داده‌ای که mutex از آن محافظت می‌کند تمام شد، باید قفل را آزاد (unlock) کنید تا سایر تردها بتوانند قفل را به‌دست آورند.

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

مدیریت mutex‌ها می‌تواند بسیار دشوار باشد، به همین دلیل است که بسیاری از افراد به کانال‌ها علاقه‌مند هستند. اما به لطف سیستم نوعی و قوانین مالکیت راست، شما نمی‌توانید در قفل کردن و باز کردن قفل اشتباه کنید.

API Mutex<T>

به‌عنوان مثالی از نحوه استفاده از mutex، بیایید با استفاده از یک mutex در یک زمینه تک‌ریسمانی شروع کنیم، همانطور که در فهرست 16-12 نشان داده شده است:

Filename: src/main.rs
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}
Listing 16-12: بررسی API Mutex<T> در یک زمینه تک‌ریسمانی برای سادگی

همان‌طور که با بسیاری از نوع‌ها مشاهده می‌شود، یک Mutex<T> را با استفاده از تابع وابسته new ایجاد می‌کنیم. برای دسترسی به داده داخل Mutex، از متد lock استفاده می‌کنیم تا قفل را به دست آوریم. این فراخوانی Thread فعلی را متوقف می‌کند، بنابراین نمی‌تواند کاری انجام دهد تا زمانی که نوبت ما برای گرفتن قفل برسد.

فراخوانی lock در صورتی که یک Thread دیگر که قفل را نگه داشته دچار وحشت (panic) شود، شکست می‌خورد. در چنین حالتی، هیچ‌کس دیگر نمی‌تواند قفل را به دست آورد، بنابراین انتخاب کرده‌ایم که از unwrap استفاده کنیم و اگر در چنین وضعیتی قرار گرفتیم، این Thread نیز دچار وحشت شود.

بعد از گرفتن قفل، می‌توانیم مقدار بازگردانده‌شده را، که در اینجا به نام num است، به عنوان یک مرجع قابل تغییر به داده داخل در نظر بگیریم. سیستم نوع تضمین می‌کند که قبل از استفاده از مقدار داخل m قفل را به دست آوریم. نوع m برابر با Mutex<i32> است، نه i32، بنابراین باید برای استفاده از مقدار i32، متد lock را فراخوانی کنیم. نمی‌توانیم فراموش کنیم؛ سیستم نوع اجازه دسترسی به مقدار داخلی i32 را به ما نمی‌دهد.

همان‌طور که احتمالاً حدس می‌زنید، Mutex<T> یک اشاره‌گر هوشمند است. دقیق‌تر، فراخوانی lock یک اشاره‌گر هوشمند به نام MutexGuard را بازمی‌گرداند، که در یک LockResult بسته‌بندی شده است و آن را با فراخوانی unwrap مدیریت کردیم. اشاره‌گر هوشمند MutexGuard ویژگی Deref را پیاده‌سازی می‌کند تا به داده داخلی ما اشاره کند. همچنین، این اشاره‌گر هوشمند یک پیاده‌سازی از Drop دارد که به‌طور خودکار قفل را زمانی که یک MutexGuard از محدوده خارج می‌شود، آزاد می‌کند، که این اتفاق در انتهای محدوده داخلی رخ می‌دهد. در نتیجه، خطر فراموش کردن آزاد کردن قفل و جلوگیری از استفاده دیگر Threadها از Mutex وجود ندارد، زیرا آزادسازی قفل به صورت خودکار انجام می‌شود.

پس از آزاد کردن قفل، می‌توانیم مقدار Mutex را چاپ کنیم و ببینیم که توانستیم مقدار داخلی i32 را به ۶ تغییر دهیم.

اشتراک‌گذاری یک Mutex<T> بین چندین Thread

حالا، بیایید تلاش کنیم یک مقدار را بین چندین Thread با استفاده از Mutex<T> به اشتراک بگذاریم. ما ۱۰ Thread ایجاد خواهیم کرد و هرکدام مقدار شمارنده را ۱ واحد افزایش می‌دهند، بنابراین شمارنده از ۰ به ۱۰ می‌رسد. مثال بعدی در لیست ۱۶-۱۳ دارای خطای کامپایل خواهد بود، و از آن خطا برای یادگیری بیشتر در مورد استفاده از Mutex<T> و اینکه چگونه Rust به ما کمک می‌کند از آن به درستی استفاده کنیم، استفاده خواهیم کرد.

Filename: src/main.rs
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-13: ده نخ که هر کدام مقدار شمارنده محافظت‌شده توسط یک Mutex<T> را افزایش می‌دهند

ما یک متغیر counter ایجاد می‌کنیم تا یک مقدار i32 را در یک Mutex<T> نگه دارد، همان‌طور که در لیست ۱۶-۱۲ انجام دادیم. سپس، با تکرار روی یک بازه عددی، ۱۰ Thread ایجاد می‌کنیم. از thread::spawn استفاده می‌کنیم و به تمام Threadها یک Closure یکسان می‌دهیم: یک Closure که متغیر counter را به Thread منتقل می‌کند، قفل Mutex<T> را با فراخوانی متد lock به دست می‌آورد، و سپس ۱ واحد به مقدار داخل Mutex اضافه می‌کند. وقتی یک Thread اجرای Closure خود را تمام می‌کند، num از محدوده خارج شده و قفل را آزاد می‌کند تا Thread دیگری بتواند آن را به دست آورد.

در Thread اصلی، تمام handleهای join را جمع‌آوری می‌کنیم. سپس، همان‌طور که در لیست ۱۶-۲ انجام دادیم، متد join را روی هر handle فراخوانی می‌کنیم تا مطمئن شویم تمام Threadها تمام شده‌اند. در آن نقطه، Thread اصلی قفل را به دست می‌آورد و نتیجه این برنامه را چاپ می‌کند.

ما اشاره کردیم که این مثال کامپایل نخواهد شد. حالا بیایید ببینیم چرا!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
8  |     for _ in 0..10 {
   |     -------------- inside of this loop
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
8  ~     let mut value = counter.lock();
9  ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

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

پیام خطا نشان می‌دهد که مقدار counter در تکرار قبلی حلقه منتقل شده است. Rust به ما می‌گوید که نمی‌توانیم مالکیت counter را به چندین Thread منتقل کنیم. بیایید این خطای کامپایلر را با استفاده از روش مالکیت چندگانه که در فصل ۱۵ بحث کردیم، برطرف کنیم.

مالکیت چندگانه با چندین Thread

در فصل ۱۵، ما با استفاده از اشاره‌گر هوشمند Rc<T> برای ایجاد یک مقدار شمارش‌شده توسط مرجع (reference-counted value) به یک مقدار چندین مالک دادیم. بیایید همین کار را اینجا انجام دهیم و ببینیم چه اتفاقی می‌افتد. ما Mutex<T> را در Rc<T> بسته‌بندی می‌کنیم (همان‌طور که در لیست ۱۶-۱۴ نشان داده شده است) و قبل از انتقال مالکیت به Thread، Rc<T> را کلون می‌کنیم.

Filename: src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-14: تلاش برای استفاده از Rc<T> برای اجازه مالکیت چندگانه Mutex<T> توسط چندین Thread

دوباره کامپایل می‌کنیم و… خطاهای متفاوتی دریافت می‌کنیم! کامپایلر چیزهای زیادی به ما یاد می‌دهد.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:728:1

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

وای، این پیام خطا واقعاً پرحرف است! اما بخش مهمی که باید روی آن تمرکز کنیم این است: Rc<Mutex<i32>>` cannot be sent between threads safely کامپایلر همچنین دلیل آن را نیز به ما می‌گوید: ```the trait Send is not implemented for Rc<Mutex<i32>>```` در بخش بعدی درباره‌ی Sendصحبت خواهیم کرد: این یکی ازtrait`هایی است که اطمینان حاصل می‌کند نوع‌هایی که با تردها استفاده می‌شوند، برای استفاده در موقعیت‌های هم‌زمان طراحی شده‌اند.

متأسفانه، استفاده از Rc<T> برای اشتراک‌گذاری داده‌ها بین تردها ایمن نیست. زمانی که Rc<T> شمارنده‌ی رفرنس را مدیریت می‌کند، با هر بار فراخوانی clone به شمارنده اضافه می‌شود و با از بین رفتن هر کلون، از شمارنده کم می‌شود. اما این عملیات از هیچ سازوکار هم‌زمانی‌ای استفاده نمی‌کند تا مطمئن شود که تغییرات روی شمارنده در میان اجرای ترد دیگری قطع نشوند. این موضوع می‌تواند منجر به شمارنده‌های اشتباه شود—باگ‌هایی ظریف که در ادامه ممکن است منجر به نشت حافظه یا از بین رفتن مقداری شوند در حالی که هنوز به آن نیاز داریم. چیزی که ما نیاز داریم، نوعی است که دقیقاً مانند Rc<T> عمل کند، اما تغییرات شمارنده‌ی رفرنس را به‌شکلی امن برای ترد انجام دهد.

شمارش ارجاع اتمی با Arc<T>

خوشبختانه، Arc<T> یک نوع مشابه Rc<T> است که برای استفاده در شرایط همزمان ایمن است. حرف a در Arc مخفف atomic است، به این معنا که یک نوع شمارش مرجع اتمی است. اتمیک‌ها نوع دیگری از عناصر ابتدایی همزمانی هستند که در اینجا به‌طور مفصل به آن‌ها نمی‌پردازیم؛ برای جزئیات بیشتر به مستندات کتابخانه استاندارد در مورد std::sync::atomic مراجعه کنید. در این مرحله، فقط باید بدانید که اتمیک‌ها مانند نوع‌های ابتدایی کار می‌کنند اما برای اشتراک‌گذاری بین Threadها ایمن هستند.

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

بیایید به مثال خود برگردیم: Arc<T> و Rc<T> API یکسانی دارند، بنابراین برنامه خود را با تغییر خط use، فراخوانی new، و فراخوانی clone اصلاح می‌کنیم. کد موجود در لیست ۱۶-۱۵ در نهایت کامپایل و اجرا می‌شود:

Filename: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-15: استفاده از Arc<T> برای بسته‌بندی Mutex<T> جهت اشتراک مالکیت بین چندین Thread

این کد خروجی زیر را چاپ خواهد کرد:

Result: 10

ما موفق شدیم! شمارنده را از ۰ به ۱۰ افزایش دادیم که ممکن است خیلی چشمگیر به نظر نرسد، اما چیزهای زیادی درباره Mutex<T> و ایمنی Thread یاد گرفتیم. همچنین می‌توانید از ساختار این برنامه برای انجام عملیات پیچیده‌تری به‌جز افزایش یک شمارنده استفاده کنید. با استفاده از این استراتژی، می‌توانید یک محاسبه را به بخش‌های مستقل تقسیم کنید، این بخش‌ها را بین Threadها تقسیم کنید، و سپس از یک Mutex<T> استفاده کنید تا هر Thread نتیجه نهایی را با بخش مربوط به خودش به‌روزرسانی کند.

توجه داشته باشید که اگر در حال انجام عملیات عددی ساده هستید، نوع‌های ساده‌تری نسبت به Mutex<T> در ماژول std::sync::atomic از کتابخانه استاندارد ارائه شده‌اند. این نوع‌ها دسترسی اتمی، ایمن و همزمان به نوع‌های ابتدایی فراهم می‌کنند. ما برای این مثال از Mutex<T> با یک نوع ابتدایی استفاده کردیم تا بتوانیم بر نحوه کار Mutex<T> تمرکز کنیم.

شباهت‌های بین RefCell<T>/Rc<T> و Mutex<T>/Arc<T>

ممکن است متوجه شده باشید که counter تغییرناپذیر است، اما توانستیم یک مرجع قابل تغییر به مقدار داخل آن بگیریم؛ این بدان معناست که Mutex<T> قابلیت تغییر داخلی (interior mutability) را فراهم می‌کند، همان‌طور که خانواده Cell این کار را می‌کنند. به همان شکلی که در فصل ۱۵ از RefCell<T> برای اجازه تغییر محتوا درون یک Rc<T> استفاده کردیم، از Mutex<T> برای تغییر محتوا درون یک Arc<T> استفاده می‌کنیم.

نکته دیگری که باید توجه کنید این است که Rust نمی‌تواند شما را از تمام انواع خطاهای منطقی هنگام استفاده از Mutex<T> محافظت کند. به یاد بیاورید که در فصل ۱۵ استفاده از Rc<T> با خطر ایجاد چرخه‌های مرجع همراه بود، جایی که دو مقدار Rc<T> به یکدیگر ارجاع می‌دادند و باعث نشت حافظه می‌شدند. به‌طور مشابه، Mutex<T> با خطر ایجاد بن‌بست (deadlock) همراه است. این وضعیت زمانی رخ می‌دهد که یک عملیات نیاز به قفل کردن دو منبع دارد و دو Thread هر کدام یکی از قفل‌ها را به دست آورده‌اند و باعث می‌شوند که برای همیشه منتظر یکدیگر بمانند. اگر به بن‌بست علاقه دارید، سعی کنید یک برنامه Rust ایجاد کنید که دچار بن‌بست شود؛ سپس استراتژی‌های کاهش بن‌بست برای Mutexها در هر زبانی را تحقیق کنید و آن‌ها را در Rust پیاده‌سازی کنید. مستندات API کتابخانه استاندارد برای Mutex<T> و MutexGuard اطلاعات مفیدی ارائه می‌دهد.

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

هم‌روندی توسعه‌پذیر با trait‌های Send و Sync

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

با این حال، در میان مفاهیم کلیدی هم‌روندی که در خود زبان (و نه در کتابخانه‌ی استاندارد) گنجانده شده‌اند، trait‌های std::marker یعنی Send و Sync قرار دارند.

اجازه انتقال مالکیت بین نخ‌ها با Send

trait نشانه‌گذاری‌شده‌ی Send مشخص می‌کند که مالکیت مقادیر نوعی که این trait را پیاده‌سازی کرده، می‌تواند بین نخ‌ها منتقل شود. تقریباً همه‌ی نوع‌های Rust، Send را پیاده‌سازی می‌کنند، اما برخی استثناها نیز وجود دارند؛ از جمله Rc<T>: این نوع نمی‌تواند Send را پیاده‌سازی کند، چرا که اگر یک مقدار Rc<T> را clone کنید و بخواهید مالکیت آن را به نخ دیگری منتقل کنید، ممکن است هر دو نخ هم‌زمان شمارنده‌ی رفرنس را به‌روزرسانی کنند. به همین دلیل، Rc<T> برای استفاده در موقعیت‌های تک‌نخی طراحی شده است، جایی که نمی‌خواهید هزینه‌ی عملکردی مرتبط با ایمنی نخ را بپردازید.

بنابراین، سیستم نوع Rust و محدودیت‌های trait تضمین می‌کنند که هرگز به‌طور ناخواسته نتوانید یک مقدار Rc<T> را به‌شکل ناایمن بین نخ‌ها منتقل کنید. زمانی که سعی کردیم این کار را در لیستینگ 16-14 انجام دهیم، خطایی دریافت کردیم با این مضمون که trait `Send` برای `Rc<Mutex<i32>>` پیاده‌سازی نشده است. اما زمانی که به Arc<T> تغییر دادیم، که Send را پیاده‌سازی می‌کند، کد با موفقیت کامپایل شد.

هر نوعی که به‌طور کامل از نوع‌های Send تشکیل شده باشد به‌طور خودکار به عنوان Send علامت‌گذاری می‌شود. تقریباً تمام نوع‌های اولیه Send هستند، به جز اشاره‌گر (Pointer)های خام، که در فصل 20 درباره آن‌ها صحبت خواهیم کرد.

اجازه دسترسی از چندین نخ با Sync

trait نشانه‌گذاری‌شده‌ی Sync مشخص می‌کند که ارجاع دادن به نوعی که این trait را پیاده‌سازی کرده از چندین نخ به‌صورت هم‌زمان بی‌خطر است. به‌عبارت دیگر، هر نوعی T زمانی Sync را پیاده‌سازی می‌کند که &T (یک رفرنس غیرقابل تغییر به T) Send را پیاده‌سازی کرده باشد، یعنی این رفرنس می‌تواند با اطمینان به نخ دیگری ارسال شود. مشابه Send، تمام نوع‌های اولیه (primitive types) Sync را پیاده‌سازی می‌کنند، و نوع‌هایی که به‌طور کامل از نوع‌هایی تشکیل شده‌اند که خود Sync هستند، نیز به‌طور خودکار Sync را پیاده‌سازی می‌کنند.

اشاره‌گر هوشمند Rc<T> نیز برای همان دلایلی که Send را پیاده‌سازی نمی‌کند، Sync را نیز پیاده‌سازی نمی‌کند. نوع RefCell<T> (که در فصل ۱۵ درباره‌ی آن صحبت کردیم) و خانواده‌ی نوع‌های مرتبط با Cell<T> نیز Sync را پیاده‌سازی نمی‌کنند. پیاده‌سازی بررسی وام‌گیری (borrow checking) که RefCell<T> در زمان اجرا انجام می‌دهد، برای نخ‌های مختلف ایمن نیست. اشاره‌گر هوشمند Mutex<T> Sync را پیاده‌سازی می‌کند و می‌تواند برای اشتراک‌گذاری دسترسی بین چندین نخ استفاده شود، همان‌طور که در [«اشتراک‌گذاری یک Mutex<T> بین چند نخ»][sharing-a-mutext-between-multiple-threads] مشاهده کردید.

پیاده‌سازی دستی Send و Sync ناایمن است

از آن‌جا که نوع‌هایی که به‌طور کامل از نوع‌های دیگری تشکیل شده‌اند که خودشان Send و Sync را پیاده‌سازی کرده‌اند، به‌صورت خودکار این دو trait را پیاده‌سازی می‌کنند، نیازی به پیاده‌سازی دستی آن‌ها نداریم. به‌عنوان marker trait‌ها، این trait‌ها حتی هیچ متدی برای پیاده‌سازی ندارند. آن‌ها فقط برای اعمال کردن محدودیت‌هایی مرتبط با هم‌زمانی مفید هستند.

پیاده‌سازی دستی این ویژگی‌ها شامل پیاده‌سازی کد ناایمن در راست می‌شود. ما در فصل 20 درباره استفاده از کد ناایمن در راست صحبت خواهیم کرد؛ فعلاً، اطلاعات مهم این است که ساخت نوع‌های همزمان جدید که از قسمت‌های Send و Sync تشکیل نشده‌اند نیاز به دقت زیادی دارد تا اصول ایمنی رعایت شوند. “The Rustonomicon” اطلاعات بیشتری درباره این اصول و نحوه رعایت آن‌ها ارائه می‌دهد.

خلاصه

این آخرین باری نیست که در این کتاب با هم‌زمانی (concurrency) روبه‌رو می‌شوید: فصل بعدی بر برنامه‌نویسی async تمرکز دارد و پروژه‌ی فصل ۲۱ مفاهیم این فصل را در یک موقعیت واقعی‌تر نسبت به مثال‌های کوچکی که در این‌جا بررسی شد به‌کار خواهد گرفت.

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

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

اصول برنامه‌نویسی ناهمزمان: Async، Await، Futures، و Streams

بسیاری از عملیات‌هایی که از کامپیوتر می‌خواهیم انجام دهد ممکن است مدتی طول بکشد تا کامل شوند. خوب می‌شد اگر می‌توانستیم در حالی که منتظر این فرآیندهای طولانی هستیم، کار دیگری انجام دهیم. کامپیوترهای مدرن دو تکنیک برای انجام هم‌زمان بیش از یک عملیات ارائه می‌دهند: موازی‌سازی و همزمانی. اما وقتی شروع به نوشتن برنامه‌هایی می‌کنیم که شامل عملیات موازی یا همزمان هستند، به سرعت با چالش‌های جدیدی مواجه می‌شویم که در ذات برنامه‌نویسی ناهمزمان هستند، جایی که عملیات‌ها ممکن است به ترتیب شروع‌شده تکمیل نشوند. این فصل بر اساس استفاده از Threadها برای موازی‌سازی و همزمانی که در فصل ۱۶ دیدیم، یک رویکرد جایگزین برای برنامه‌نویسی ناهمزمان معرفی می‌کند: Futures، Streams، سینتکس async و await در Rust، و ابزارهایی برای مدیریت و هماهنگی بین عملیات ناهمزمان.

بیایید یک مثال را بررسی کنیم. فرض کنید در حال خروجی گرفتن از یک ویدئو هستید که از یک جشن خانوادگی ساخته‌اید؛ این عملیات ممکن است از چند دقیقه تا چند ساعت طول بکشد. خروجی ویدئو تا جایی که ممکن است از قدرت CPU و GPU استفاده خواهد کرد. اگر فقط یک هسته CPU داشتید و سیستم‌عامل شما آن خروجی را تا پایان تکمیل متوقف نمی‌کرد—یعنی اگر آن را به صورت همزمان اجرا می‌کرد—در حالی که آن کار در حال اجرا بود نمی‌توانستید هیچ کار دیگری روی کامپیوتر خود انجام دهید. این تجربه بسیار ناامیدکننده‌ای می‌شد. خوشبختانه، سیستم‌عامل کامپیوتر شما می‌تواند و معمولاً هم می‌کند، به طور نامرئی خروجی را به اندازه کافی متوقف می‌کند تا بتوانید همزمان کارهای دیگری انجام دهید.

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

خروجی ویدئو یک مثال از یک عملیات وابسته به CPU یا وابسته به محاسبه (CPU-bound) است. این عملیات محدود به سرعت پردازش داده کامپیوتر در CPU یا GPU و میزان توانایی آن برای اختصاص این سرعت به عملیات است. دانلود ویدئو یک مثال از یک عملیات وابسته به ورودی و خروجی (IO-bound) است، زیرا محدود به سرعت ورودی و خروجی کامپیوتر است؛ این عملیات فقط به سرعتی که داده می‌تواند از طریق شبکه ارسال شود، وابسته است.

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

به عنوان مثال، اگر در حال ساخت یک ابزار برای مدیریت دانلود فایل‌ها هستید، باید بتوانید برنامه خود را طوری بنویسید که شروع یک دانلود، رابط کاربری را قفل نکند، و کاربران بتوانند به طور همزمان چندین دانلود را آغاز کنند. بسیاری از APIهای سیستم‌عامل برای تعامل با شبکه مسدودکننده (blocking) هستند؛ یعنی پیشرفت برنامه را تا زمانی که داده‌ای که پردازش می‌کنند کاملاً آماده باشد، متوقف می‌کنند.

نکته: این همان چیزی است که بیشتر فراخوانی‌های توابع انجام می‌دهند، اگر در مورد آن فکر کنید. با این حال، اصطلاح blocking معمولاً برای فراخوانی توابعی که با فایل‌ها، شبکه یا منابع دیگر روی کامپیوتر تعامل دارند استفاده می‌شود، زیرا این مواردی هستند که یک برنامه فردی می‌تواند از غیرمسدودکننده (non-blocking) بودن عملیات بهره‌مند شود.

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

let data = fetch_data_from(url).await;
println!("{data}");

این دقیقاً همان چیزی است که انتزاع async (مخفف asynchronous) در Rust به ما می‌دهد. در این فصل، همه چیز درباره async را یاد خواهید گرفت و موضوعات زیر را پوشش خواهیم داد:

  • نحوه استفاده از سینتکس async و await در Rust
  • نحوه استفاده از مدل async برای حل برخی از چالش‌هایی که در فصل ۱۶ بررسی کردیم
  • چگونگی ارائه راه‌حل‌های مکمل توسط multithreading و async، که در بسیاری از موارد می‌توانید آن‌ها را با هم ترکیب کنید

با این حال، قبل از اینکه ببینیم async در عمل چگونه کار می‌کند، باید یک توقف کوتاه برای بحث درباره تفاوت‌های بین موازی‌سازی و همزمانی داشته باشیم.

تفاوت بین موازی‌سازی و همزمانی

ما تاکنون همزمانی (concurrency) و موازی‌سازی (parallelism) را تقریباً به جای هم در نظر گرفته‌ایم. اکنون باید آن‌ها را به طور دقیق‌تر از هم متمایز کنیم، زیرا تفاوت‌هایشان در هنگام کار مشخص خواهد شد.

به روش‌های مختلفی که یک تیم می‌تواند کار بر روی یک پروژه نرم‌افزاری را تقسیم کند فکر کنید. می‌توانید چندین وظیفه را به یک عضو اختصاص دهید، به هر عضو یک وظیفه اختصاص دهید، یا ترکیبی از این دو روش را استفاده کنید.

وقتی یک فرد روی چندین وظیفه مختلف قبل از اتمام هر یک از آن‌ها کار می‌کند، این همزمانی است. شاید شما دو پروژه مختلف را روی کامپیوتر خود باز کرده‌اید و وقتی از یکی خسته یا در آن گیر کردید، به دیگری تغییر می‌دهید. شما فقط یک نفر هستید، بنابراین نمی‌توانید به طور همزمان روی هر دو وظیفه پیشرفت کنید، اما می‌توانید چندوظیفه‌ای (multi-tasking) کنید و با جابه‌جا شدن بین آن‌ها، یکی یکی پیشرفت کنید (نگاه کنید به شکل ۱۷-۱).

A diagram with boxes labeled Task A and Task B, with diamonds in them representing subtasks. There are arrows pointing from A1 to B1, B1 to A2, A2 to B2, B2 to A3, A3 to A4, and A4 to B3. The arrows between the subtasks cross the boxes between Task A and Task B.
شکل ۱۷-۱: یک جریان کاری همزمان، که بین وظیفه A و وظیفه B جابه‌جا می‌شود.

وقتی تیم گروهی از وظایف را به این صورت تقسیم می‌کند که هر عضو یک وظیفه را بر عهده می‌گیرد و به تنهایی روی آن کار می‌کند، این موازی‌سازی است. هر فرد در تیم می‌تواند دقیقاً به طور همزمان پیشرفت کند (نگاه کنید به شکل ۱۷-۲).

یک نمودار با جعبه‌هایی که با برچسب‌های وظیفه A و وظیفه B نام‌گذاری شده‌اند، و لوزی‌هایی درون آن‌ها که نمایانگر زیروظایف هستند. فلش‌هایی از A1 به A2، A2 به A3، A3 به A4، B1 به B2، و B2 به B3 اشاره می‌کنند. هیچ فلشی بین جعبه‌های وظیفه A و وظیفه B عبور نمی‌کند.
شکل ۱۷-۲: یک جریان کاری موازی، که در آن کار روی وظیفه A و وظیفه B به طور مستقل انجام می‌شود.

در هر دو این جریان‌های کاری، ممکن است نیاز به هماهنگی بین وظایف مختلف داشته باشید. شاید فکر می‌کردید وظیفه‌ای که به یک نفر اختصاص داده شده کاملاً مستقل از کار سایر اعضای تیم است، اما در واقع نیاز دارد که یک نفر دیگر در تیم ابتدا وظیفه خود را به پایان برساند. بخشی از کار می‌تواند به صورت موازی انجام شود، اما بخشی از آن در واقع سریالی است: فقط می‌تواند به صورت متوالی انجام شود، یک وظیفه پس از دیگری، همان‌طور که در شکل ۱۷-۳ نشان داده شده است.

یک نمودار با جعبه‌هایی که با برچسب وظیفه A و وظیفه B نام‌گذاری شده‌اند، و لوزی‌هایی درون آن‌ها که نمایانگر زیروظایف هستند. فلش‌هایی از A1 به A2، A2 به یک جفت خطوط عمودی ضخیم مانند نماد 'توقف'، از آن نماد به A3، B1 به B2، B2 به B3 (که در زیر آن نماد قرار دارد)، B3 به A3، و B3 به B4 اشاره می‌کنند.
شکل ۱۷-۳: یک جریان کاری نیمه موازی، که در آن کار روی وظیفه A و وظیفه B به طور مستقل انجام می‌شود تا زمانی که A3 به نتایج B3 وابسته باشد.

به همین ترتیب، ممکن است متوجه شوید که یکی از وظایف شما به وظیفه دیگری از کارهای شما بستگی دارد. اکنون کار همزمان شما نیز سریالی شده است.

موازی‌سازی و همزمانی می‌توانند با یکدیگر تقاطع داشته باشند. اگر متوجه شوید که یک همکار تا زمانی که یکی از وظایف شما به پایان نرسیده گیر کرده است، احتمالاً تمام تلاش خود را روی آن وظیفه متمرکز می‌کنید تا “همکارتان را از بن‌بست خارج کنید.” شما و همکارتان دیگر نمی‌توانید به صورت موازی کار کنید، و همچنین دیگر نمی‌توانید به صورت همزمان روی وظایف خودتان کار کنید.

همان دینامیک‌های اساسی در نرم‌افزار و سخت‌افزار نیز وجود دارند. روی ماشینی با یک هسته CPU، CPU فقط می‌تواند یک عملیات را در هر لحظه انجام دهد، اما همچنان می‌تواند به صورت همزمان کار کند. با استفاده از ابزارهایی مانند Threads، فرآیندها (processes) و async، کامپیوتر می‌تواند یک فعالیت را متوقف کند و به فعالیت‌های دیگر تغییر دهد، و در نهایت دوباره به فعالیت اول بازگردد. روی ماشینی با چندین هسته CPU، می‌تواند کارها را به صورت موازی نیز انجام دهد. یک هسته می‌تواند یک وظیفه را اجرا کند در حالی که هسته دیگری وظیفه‌ای کاملاً نامرتبط را اجرا می‌کند، و این عملیات‌ها واقعاً در یک زمان اتفاق می‌افتند.

هنگام کار با async در Rust، همیشه با همزمانی سر و کار داریم. بسته به سخت‌افزار، سیستم‌عامل، و Runtime async که استفاده می‌کنیم (که در ادامه درباره Runtimeهای async بیشتر صحبت خواهیم کرد)، این همزمانی ممکن است در پس‌زمینه از موازی‌سازی نیز استفاده کند.

حالا بیایید به این بپردازیم که برنامه‌نویسی async در Rust در عمل چگونه کار می‌کند.

Futures و سینتکس Async

عناصر کلیدی برنامه‌نویسی ناهمزمان در Rust شامل futures و کلمات کلیدی async و await هستند.

یک future مقداری است که ممکن است اکنون آماده نباشد، اما در آینده در نقطه‌ای آماده خواهد شد. (این مفهوم در بسیاری از زبان‌ها وجود دارد، گاهی با نام‌های دیگر مانند task یا promise.) Rust یک ویژگی Future به عنوان یک بلوک سازنده فراهم می‌کند تا عملیات‌های async مختلف با ساختارهای داده متفاوت اما با یک رابط مشترک پیاده‌سازی شوند. در Rust، futures نوع‌هایی هستند که ویژگی Future را پیاده‌سازی می‌کنند. هر future اطلاعات خود را در مورد پیشرفت و اینکه “آماده” به چه معناست نگه می‌دارد.

می‌توانید کلمه کلیدی async را به بلوک‌ها و توابع اعمال کنید تا مشخص کنید که می‌توانند متوقف شده و از سر گرفته شوند. درون یک بلوک async یا تابع async، می‌توانید از کلمه کلیدی await برای انتظار یک future (یعنی منتظر ماندن تا آماده شود) استفاده کنید. هر نقطه‌ای که در آن یک future را در یک بلوک یا تابع async انتظار می‌کشید، یک نقطه بالقوه برای متوقف و از سر گرفتن آن بلوک یا تابع async است. فرآیند بررسی یک future برای اینکه ببیند مقدار آن هنوز آماده است یا خیر، polling نامیده می‌شود.

برخی زبان‌های دیگر، مانند C# و JavaScript، نیز از کلمات کلیدی async و await برای برنامه‌نویسی ناهمزمان استفاده می‌کنند. اگر با این زبان‌ها آشنا هستید، ممکن است تفاوت‌های قابل توجهی در نحوه عملکرد Rust، از جمله نحوه مدیریت سینتکس آن، مشاهده کنید. این تفاوت‌ها دلایل خوبی دارند، همان‌طور که خواهیم دید!

هنگام نوشتن کد async در Rust، بیشتر اوقات از کلمات کلیدی async و await استفاده می‌کنیم. Rust آن‌ها را به کدی معادل با استفاده از ویژگی Future کامپایل می‌کند، همان‌طور که حلقه‌های for را به کدی معادل با استفاده از ویژگی Iterator کامپایل می‌کند. با این حال، از آنجا که Rust ویژگی Future را ارائه می‌دهد، می‌توانید آن را برای نوع‌های داده خودتان نیز پیاده‌سازی کنید. بسیاری از توابعی که در طول این فصل مشاهده خواهیم کرد نوع‌هایی را بازمی‌گردانند که پیاده‌سازی‌های خود از Future را دارند. در انتهای فصل به تعریف این ویژگی بازمی‌گردیم و بیشتر در مورد نحوه عملکرد آن بحث می‌کنیم، اما این توضیحات برای ادامه کافی است.

ممکن است این توضیحات کمی انتزاعی به نظر برسند، بنابراین بیایید اولین برنامه async خود را بنویسیم: یک web scraper کوچک. ما دو URL را از خط فرمان دریافت می‌کنیم، هر دو را به صورت همزمان دریافت می‌کنیم و نتیجه اولین URL که به پایان می‌رسد را بازمی‌گردانیم. این مثال دارای سینتکس جدیدی خواهد بود، اما نگران نباشید—همه چیزهایی که باید بدانید را در طول مسیر توضیح خواهیم داد.

اولین برنامه Async ما

برای تمرکز این فصل روی یادگیری async به جای مدیریت بخش‌های اکوسیستم، یک crate به نام trpl ایجاد کرده‌ایم (trpl مخفف “The Rust Programming Language” است). این crate همه نوع‌ها، ویژگی‌ها، و توابع مورد نیاز شما را بازصادر می‌کند، عمدتاً از crateهای futures و tokio. crate futures خانه رسمی برای آزمایش کد async در Rust است و در واقع جایی است که ویژگی Future در ابتدا طراحی شد. tokio امروز رایج‌ترین Runtime async در Rust است، به ویژه برای برنامه‌های وب. Runtimeهای عالی دیگری نیز وجود دارند که ممکن است برای اهداف شما مناسب‌تر باشند. ما از crate tokio در زیرساخت trpl استفاده می‌کنیم زیرا به خوبی تست شده و به طور گسترده استفاده می‌شود.

در برخی موارد، trpl همچنین APIهای اصلی را تغییر نام داده یا آن‌ها را پوشش می‌دهد تا شما را بر روی جزئیات مرتبط با این فصل متمرکز نگه دارد. اگر می‌خواهید بفهمید این crate چه می‌کند، ما شما را تشویق می‌کنیم که سورس کد آن را بررسی کنید. می‌توانید ببینید که هر بازصادر از کدام crate می‌آید، و توضیحات گسترده‌ای در مورد آنچه که crate انجام می‌دهد گذاشته‌ایم.

یک پروژه باینری جدید به نام hello-async ایجاد کنید و crate trpl را به عنوان وابستگی اضافه کنید:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

اکنون می‌توانیم از بخش‌های مختلف ارائه‌شده توسط trpl استفاده کنیم تا اولین برنامه async خود را بنویسیم. ما یک ابزار کوچک خط فرمان ایجاد خواهیم کرد که دو صفحه وب را دریافت می‌کند، عنصر <title> را از هرکدام استخراج می‌کند و عنوان صفحه‌ای که سریع‌تر کل این فرآیند را تکمیل می‌کند، چاپ می‌کند.

تعریف تابع page_title

بیایید با نوشتن یک تابع که یک URL صفحه را به عنوان پارامتر می‌گیرد، یک درخواست به آن ارسال می‌کند و متن عنصر <title> را بازمی‌گرداند شروع کنیم (نگاه کنید به لیست ۱۷-۱).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}
Listing 17-1: تعریف یک تابع async برای دریافت عنصر <title> از یک صفحه HTML

ابتدا یک تابع به نام page_title تعریف می‌کنیم و آن را با کلمه کلیدی async علامت‌گذاری می‌کنیم. سپس از تابع trpl::get برای دریافت هر URL که به آن ارسال می‌شود استفاده می‌کنیم و کلمه کلیدی await را اضافه می‌کنیم تا منتظر پاسخ بمانیم. برای دریافت متن پاسخ، متد text را فراخوانی می‌کنیم و دوباره با کلمه کلیدی await منتظر آن می‌مانیم. هر دو این مراحل ناهمزمان هستند. برای تابع get، باید منتظر باشیم تا سرور اولین قسمت از پاسخ خود را ارسال کند که شامل هدرهای HTTP، کوکی‌ها و غیره است و می‌تواند جدا از بدنه پاسخ ارسال شود. به ویژه اگر بدنه بسیار بزرگ باشد، ممکن است مدتی طول بکشد تا همه آن برسد. از آنجا که باید منتظر تمامیت پاسخ بمانیم، متد text نیز async است.

باید به‌صراحت منتظر هر دو future باشیم، زیرا futures در Rust تنبل هستند: تا زمانی که از آن‌ها با کلمه کلیدی await درخواست نشود، هیچ کاری انجام نمی‌دهند. (در واقع، Rust یک هشدار کامپایلر نمایش می‌دهد اگر از یک future استفاده نکنید.) این ممکن است شما را به یاد بحث فصل ۱۳ درباره iteratorها در بخش پردازش یک سری از آیتم‌ها با iteratorها بیندازد. iteratorها هیچ کاری انجام نمی‌دهند مگر اینکه متد next آن‌ها را فراخوانی کنید—چه به صورت مستقیم یا با استفاده از حلقه‌های for یا متدهایی مانند map که در پشت صحنه از next استفاده می‌کنند. به همین ترتیب، futures هیچ کاری انجام نمی‌دهند مگر اینکه به‌صراحت از آن‌ها درخواست شود. این ویژگی تنبلی به Rust اجازه می‌دهد تا کد async را تا زمانی که واقعاً مورد نیاز است، اجرا نکند.

توجه: این رفتار با چیزی که در فصل قبل هنگام استفاده از thread::spawn در ایجاد یک نخ جدید با spawn دیدیم متفاوت است، جایی که closure‌ای که به نخ دیگر منتقل کردیم بلافاصله شروع به اجرا کرد. همچنین این رفتار با رویکرد بسیاری از زبان‌های دیگر در مورد async نیز تفاوت دارد. اما این موضوع برای Rust اهمیت دارد تا بتواند تضمین‌های عملکردی خود را همانند کاری که با پیمایشگرها انجام می‌دهد، حفظ کند.

زمانی که response_text را دریافت کردیم، می‌توانیم آن را با استفاده از Html::parse به نمونه‌ای از نوع Html تبدیل کنیم. به جای یک رشته‌ی خام، اکنون یک نوع داده داریم که می‌توانیم از آن برای کار با HTML به‌عنوان یک ساختار داده‌ی غنی‌تر استفاده کنیم. به‌ویژه می‌توانیم از متد select_first برای یافتن اولین نمونه از یک سلکتور CSS مشخص استفاده کنیم. با ارسال رشته‌ی "title"، اولین عنصر <title> موجود در سند را دریافت خواهیم کرد، اگر عنصری وجود داشته باشد. از آن‌جایی که ممکن است هیچ عنصر مطابقت‌یافته‌ای وجود نداشته باشد، select_first یک Option<ElementRef> بازمی‌گرداند. در نهایت، از متد Option::map استفاده می‌کنیم که به ما اجازه می‌دهد اگر مقداری در Option وجود داشت با آن کار کنیم، و اگر وجود نداشت، هیچ کاری انجام ندهیم. (می‌توانستیم از یک عبارت match نیز استفاده کنیم، اما استفاده از map در این‌جا ایدیاتیک‌تر است.) در بدنه‌ی تابعی که به map می‌دهیم، متد inner_html را روی title فراخوانی می‌کنیم تا محتوای آن را به‌صورت یک String دریافت کنیم. در پایان، نتیجه‌ی ما یک Option<String> خواهد بود.

توجه داشته باشید که کلمه‌ی کلیدی await در Rust پس از عبارتی که منتظر آن هستید می‌آید، نه قبل از آن. به عبارت دیگر، این یک کلمه‌ی کلیدی پسوندی است. این ممکن است با چیزی که در زبان‌های دیگر هنگام استفاده از async تجربه کرده‌اید متفاوت باشد، اما در Rust این موضوع باعث می‌شود زنجیره‌های توابع خواناتر و قابل‌مدیریت‌تر شوند. بنابراین، می‌توانیم بدنه‌ی تابع page_title را طوری تغییر دهیم که توابع trpl::get و text را با استفاده از await بین آن‌ها به‌صورت زنجیره‌ای صدا بزنیم، همان‌طور که در لیست 17-2 نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}
Listing 17-2: زنجیره کردن با کلمه کلیدی await

با این توضیحات، ما اولین تابع async خود را با موفقیت نوشتیم! پیش از اضافه کردن کدی در main برای فراخوانی آن، بیایید کمی بیشتر درباره آنچه نوشته‌ایم و معنای آن صحبت کنیم.

هنگامی که Rust یک بلوک که با کلمه کلیدی async علامت‌گذاری شده است را می‌بیند، آن را به یک نوع داده منحصربه‌فرد و ناشناس که ویژگی Future را پیاده‌سازی می‌کند، کامپایل می‌کند. هنگامی که Rust یک تابع که با async علامت‌گذاری شده است را می‌بیند، آن را به یک تابع غیر-async که بدنه آن یک بلوک async است، کامپایل می‌کند. نوع بازگشتی یک تابع async نوع داده ناشناسی است که کامپایلر برای آن بلوک async ایجاد می‌کند.

بنابراین، نوشتن async fn معادل نوشتن تابعی است که یک future از نوع بازگشتی برمی‌گرداند. برای کامپایلر، یک تعریف تابع مانند async fn page_title در لیست ۱۷-۱ معادل یک تابع غیر-async به شکل زیر است:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}
}

بیایید هر بخش از نسخه تبدیل‌شده را بررسی کنیم:

  • این تابع از سینتکس impl Trait استفاده می‌کند که در فصل ۱۰ در بخش [«Traits به‌عنوان پارامتر»][impl-trait] بررسی کردیم.
  • trait بازگشتی یک Future است با نوع مرتبطی به نام Output. دقت کنید که نوع Output مقدار Option<String> است، که همان نوع بازگشتی نسخه‌ی اصلی async fn تابع page_title می‌باشد.
  • تمام کدی که در بدنه‌ی تابع اصلی فراخوانی می‌شد، اکنون درون یک بلاک async move قرار گرفته است. به یاد داشته باشید که بلاک‌ها در Rust یک عبارت محسوب می‌شوند. این بلاک به‌طور کامل همان عبارتی است که از تابع بازگردانده می‌شود.
  • این بلاک async یک مقدار با نوع Option<String> تولید می‌کند، همان‌طور که توصیف شد. این مقدار با نوع Output در نوع بازگشتی مطابقت دارد. این موضوع مشابه بلاک‌های دیگری است که تاکنون دیده‌اید.
  • بدنه‌ی تابع جدید یک بلاک async move است، به دلیل نحوه‌ی استفاده از پارامتر url درون بلاک. (در ادامه‌ی این فصل، به‌طور مفصل‌تر درباره‌ی تفاوت async و async move صحبت خواهیم کرد.)

حالا می‌توانیم page_title را در main فراخوانی کنیم.

تعیین عنوان یک صفحه

برای شروع، فقط عنوان یک صفحه را دریافت می‌کنیم. در لیست ۱۷-۳، همان الگویی که در فصل ۱۲ برای دریافت آرگومان‌های خط فرمان در بخش پذیرفتن آرگومان‌های خط فرمان استفاده کردیم را دنبال می‌کنیم. سپس URL اول را به page_title ارسال کرده و نتیجه را انتظار می‌کشیم. چون مقداری که توسط future تولید می‌شود یک Option<String> است، از یک عبارت match برای چاپ پیام‌های مختلف استفاده می‌کنیم تا مشخص شود آیا صفحه یک <title> داشته است یا خیر.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}
Listing 17-3: Calling the page_title function from main with a user-supplied argument

متأسفانه، این کد کامپایل نمی‌شود. تنها جایی که می‌توانیم از کلمه کلیدی await استفاده کنیم، در توابع یا بلوک‌های async است، و Rust اجازه نمی‌دهد تابع ویژه main را به‌عنوان async علامت‌گذاری کنیم.

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

دلیل اینکه نمی‌توان main را به‌عنوان async علامت‌گذاری کرد این است که کد async به یک runtime نیاز دارد: یک crate در Rust که جزئیات اجرای کد ناهمزمان را مدیریت می‌کند. تابع main یک برنامه می‌تواند یک runtime را مقداردهی اولیه کند، اما خودش یک runtime نیست. (در ادامه، بیشتر خواهیم دید که چرا این‌گونه است.) هر برنامه Rust که کد async اجرا می‌کند، حداقل یک مکان دارد که در آن یک runtime راه‌اندازی کرده و futures را اجرا می‌کند.

بیشتر زبان‌هایی که از async پشتیبانی می‌کنند، یک runtime همراه دارند، اما Rust این کار را نمی‌کند. در عوض، بسیاری از runtimeهای async مختلف موجود هستند که هرکدام موازنه‌های متفاوتی برای موارد استفاده خاص خود ارائه می‌دهند. برای مثال، یک وب سرور با توان عملیاتی بالا که دارای هسته‌های CPU متعدد و مقدار زیادی RAM است، نیازهای بسیار متفاوتی نسبت به یک میکروکنترلر با یک هسته، مقدار کمی RAM و بدون قابلیت تخصیص heap دارد. crateهایی که این runtimeها را فراهم می‌کنند اغلب نسخه‌های async از قابلیت‌های عمومی مانند I/O فایل یا شبکه را نیز ارائه می‌دهند.

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

می‌توانستیم future بازگردانده‌شده توسط page_title را مستقیماً به run ارسال کنیم، و وقتی کامل شد، می‌توانستیم بر اساس Option<String> نتیجه، یک match انجام دهیم، همان‌طور که در لیست ۱۷-۳ تلاش کردیم. با این حال، برای بیشتر مثال‌های این فصل (و بیشتر کد async در دنیای واقعی)، بیش از یک فراخوانی تابع async انجام خواهیم داد، بنابراین به‌جای آن یک بلوک async ارسال می‌کنیم و صراحتاً نتیجه فراخوانی page_title را انتظار می‌کشیم، همان‌طور که در لیست ۱۷-۴ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}
Listing 17-4: منتظر ماندن یک بلوک async با trpl::run

وقتی این کد را اجرا می‌کنیم، رفتاری را که ممکن است ابتدا انتظار داشتیم دریافت می‌کنیم:

$ cargo run -- https://www.rust-lang.org
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

پوووف—بالاخره مقداری کد async کارا داریم! اما قبل از اینکه کدی اضافه کنیم که دو سایت را در مقابل یکدیگر رقابت دهد، بیایید به‌طور مختصر دوباره به نحوه کار futures توجه کنیم.

هر نقطه انتظار—یعنی هر جایی که کد از کلمه کلیدی await استفاده می‌کند—نمایانگر جایی است که کنترل به runtime بازمی‌گردد. برای اینکه این کار انجام شود، Rust نیاز دارد وضعیت مربوط به بلوک async را پیگیری کند تا runtime بتواند کار دیگری را آغاز کند و سپس وقتی آماده شد دوباره برای پیشرفت بلوک اول بازگردد. این یک ماشین حالت نامرئی است، گویی که شما یک enum مانند این نوشته‌اید تا وضعیت فعلی را در هر نقطه انتظار ذخیره کند:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

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

در نهایت، چیزی باید این ماشین حالت را اجرا کند، و آن چیز یک runtime است. (به همین دلیل ممکن است در بررسی runtimeها به ارجاعاتی به executors برخورد کنید: یک executor بخشی از runtime است که مسئول اجرای کد async است.)

اکنون می‌توانید دلیل این‌که چرا کامپایلر اجازه نداد تابع main را در لیستینگ 17-3 به‌صورت async تعریف کنیم، بهتر درک کنید. اگر main یک تابع async بود، باید یک جزء دیگر مسئول مدیریت ماشین حالت برای futureای می‌بود که main بازمی‌گرداند؛ اما main نقطه‌ی شروع برنامه است! بنابراین، به‌جای آن در تابع main، تابع trpl::run را فراخوانی کردیم تا یک runtime راه‌اندازی کند و future بازگردانده‌شده از بلاک async را تا زمان اتمام اجرا کند.

نکته: برخی runtimeها ماکروهایی فراهم می‌کنند که به شما اجازه می‌دهند یک تابع main به‌صورت async بنویسید. این ماکروها عبارت async fn main() { ... } را بازنویسی می‌کنند به یک تابع fn main معمولی که همان کاری را انجام می‌دهد که ما در لیستینگ 17-4 به‌صورت دستی انجام دادیم: فراخوانی تابعی که یک future را تا تکمیل اجرا می‌کند، مانند کاری که trpl::run انجام می‌دهد.

حالا بیایید این بخش‌ها را کنار هم قرار دهیم و ببینیم چگونه می‌توان کدی همزمان نوشت.

رقابت بین دو URL

در لیست ۱۷-۵، ما page_title را با دو URL مختلف که از خط فرمان ارسال شده‌اند، فراخوانی کرده و آن‌ها را با یکدیگر رقابت می‌دهیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::race(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title was: '{title}'"),
            None => println!("It had no title."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let response_text = trpl::get(url).await.text().await;
    let title = Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}
Listing 17-5:

ما با فراخوانی page_title برای هر یک از URLهایی که توسط کاربر ارسال شده‌اند، شروع می‌کنیم. Futureهای حاصل را به نام‌های title_fut_1 و title_fut_2 ذخیره می‌کنیم. به یاد داشته باشید، این‌ها هنوز کاری انجام نمی‌دهند، زیرا futures تنبل هستند و هنوز منتظر آن‌ها نمانده‌ایم. سپس این futures را به trpl::race ارسال می‌کنیم، که مقداری بازمی‌گرداند تا نشان دهد کدام یک از futures ارسال‌شده به آن ابتدا کامل شده است.

نکته: در پشت صحنه، race بر اساس یک تابع عمومی‌تر به نام select ساخته شده است، که اغلب در کدهای واقعی Rust با آن مواجه خواهید شد. یک تابع select می‌تواند کارهایی انجام دهد که تابع trpl::race نمی‌تواند، اما همچنین دارای پیچیدگی‌های اضافی است که فعلاً می‌توانیم از آن صرف‌نظر کنیم.

هرکدام از futures می‌توانند به طور قانونی “برنده” شوند، بنابراین بازگرداندن یک Result منطقی نیست. در عوض، race نوعی را بازمی‌گرداند که قبلاً ندیده‌ایم: trpl::Either. نوع Either تا حدودی شبیه به Result است به این معنا که دو حالت دارد. اما برخلاف Result، هیچ مفهومی از موفقیت یا شکست در Either وجود ندارد. در عوض، از Left و Right برای نشان دادن “یکی یا دیگری” استفاده می‌کند:

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

تابع race در صورتی که اولین future‌ ارائه‌شده زودتر به پایان برسد، مقدار Left را همراه با خروجی آن بازمی‌گرداند، و اگر دومین future زودتر به پایان برسد، مقدار Right را همراه با خروجی آن بازمی‌گرداند. این رفتار با ترتیبی که آرگومان‌ها هنگام فراخوانی تابع ظاهر می‌شوند مطابقت دارد: آرگومان اول در سمت چپ آرگومان دوم قرار دارد.

همچنین تابع page_title را به‌روزرسانی می‌کنیم تا همان URL ارسال‌شده را بازگرداند. به این ترتیب، اگر صفحه‌ای که ابتدا بازمی‌گردد، دارای یک <title> نباشد که بتوانیم آن را استخراج کنیم، همچنان می‌توانیم یک پیام معنادار چاپ کنیم. با در دسترس بودن این اطلاعات، خروجی println! خود را به‌روزرسانی می‌کنیم تا مشخص کند کدام URL اول کامل شده است و <title> صفحه وب در آن URL چیست (اگر وجود داشته باشد).

شما اکنون یک web scraper کوچک و کارا ساخته‌اید! چند URL انتخاب کنید و ابزار خط فرمان را اجرا کنید. ممکن است متوجه شوید که برخی سایت‌ها به طور مداوم سریع‌تر از بقیه هستند، در حالی که در موارد دیگر، سایت سریع‌تر از اجرای به اجرای دیگر متفاوت است. مهم‌تر از همه، شما اصول کار با futures را آموخته‌اید، بنابراین حالا می‌توانیم عمیق‌تر به آنچه می‌توان با async انجام داد، بپردازیم.

Applying Concurrency with Async

در این بخش، async را به برخی از همان چالش‌های همزمانی که با نخ‌ها در فصل 16 انجام دادیم اعمال می‌کنیم. از آنجا که قبلاً درباره بسیاری از ایده‌های کلیدی در آنجا صحبت کرده‌ایم، در این بخش تمرکز بر تفاوت‌های بین نخ‌ها و آینده‌ها (futures) خواهیم داشت.

در بسیاری از موارد، APIها برای کار با همزمانی (concurrency) با استفاده از async بسیار شبیه به APIهایی هستند که برای استفاده از Threadها استفاده می‌شوند. در موارد دیگر، این APIها کاملاً متفاوت هستند. حتی زمانی که APIها بین Threadها و async شبیه به نظر می‌رسند، اغلب رفتار متفاوتی دارند—و تقریباً همیشه ویژگی‌های عملکردی متفاوتی دارند.

ایجاد یک Task جدید با spawn_task

اولین عملیاتی که در ایجاد یک Thread جدید با Spawn انجام دادیم، شمارش افزایشی در دو Thread جداگانه بود. بیایید همان کار را با استفاده از async انجام دهیم. crate trpl یک تابع spawn_task فراهم می‌کند که بسیار شبیه به API thread::spawn است، و یک تابع sleep که نسخه async از API thread::sleep است. می‌توانیم از این دو با هم استفاده کنیم تا مثال شمارش را پیاده‌سازی کنیم، همان‌طور که در لیست ۱۷-۶ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }
    });
}
Listing 17-6: ایجاد یک Task جدید برای چاپ یک چیز در حالی که Task اصلی چیز دیگری را چاپ می‌کند

به‌عنوان نقطه شروع، تابع main خود را با استفاده از trpl::run تنظیم می‌کنیم تا تابع سطح بالای ما بتواند async باشد.

نکته: از این نقطه به بعد در فصل، هر مثال این کد بسته‌بندی یکسان را با trpl::run در main شامل خواهد شد، بنابراین اغلب آن را مانند main نادیده می‌گیریم. فراموش نکنید که آن را در کد خود بگنجانید!

سپس دو حلقه درون آن بلوک می‌نویسیم که هر کدام شامل یک فراخوانی به trpl::sleep هستند، که قبل از ارسال پیام بعدی به مدت نیم ثانیه (۵۰۰ میلی‌ثانیه) منتظر می‌مانند. یکی از حلقه‌ها را در بدنه یک trpl::spawn_task قرار می‌دهیم و دیگری را در یک حلقه for در سطح بالا. همچنین پس از فراخوانی‌های sleep یک await اضافه می‌کنیم.

این کد رفتاری مشابه با پیاده‌سازی مبتنی بر Thread دارد—از جمله اینکه ممکن است پیام‌ها را در ترتیبی متفاوت در ترمینال خود هنگام اجرا مشاهده کنید:s

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!

این نسخه به محض اینکه حلقه for در بدنه بلوک async اصلی به پایان می‌رسد، متوقف می‌شود، زیرا taskی که توسط spawn_task ایجاد شده است با پایان یافتن تابع main متوقف می‌شود. اگر بخواهید تا اتمام کامل task اجرا شود، باید از یک handle join استفاده کنید تا منتظر بمانید اولین task به پایان برسد. با Threadها، از متد join برای “مسدود کردن” تا زمانی که Thread اجرا می‌شد، استفاده می‌کردیم. در لیست ۱۷-۷، می‌توانیم از await برای انجام همین کار استفاده کنیم، زیرا handle task خودش یک future است. نوع Output آن یک Result است، بنابراین پس از منتظر ماندن آن را unwrap می‌کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }

        handle.await.unwrap();
    });
}
Listing 17-7: استفاده از await با یک handle الحاقی برای اجرای تسک تا تکمیل

نسخه به‌روزرسانی‌شده تا زمانی که هر دو حلقه تمام شوند اجرا می‌شود.

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

تا اینجا، به نظر می‌رسد async و نخ‌ها نتایج اصلی یکسانی به ما می‌دهند، فقط با سینتکس متفاوت: استفاده از await به جای فراخوانی join روی handle الحاقی و انتظار برای فراخوانی‌های sleep.

تفاوت بزرگ‌تر این است که نیازی به ایجاد یک نخ سیستم‌عامل جداگانه برای این کار نداشتیم. در واقع، حتی نیازی به ایجاد یک تسک هم در اینجا نداریم. زیرا بلوک‌های async به آینده‌های ناشناس کامپایل می‌شوند، می‌توانیم هر حلقه را در یک بلوک async قرار دهیم و اجازه دهیم runtime هر دو را با استفاده از تابع trpl::join تا تکمیل اجرا کند.

در بخش انتظار برای اتمام تمام Threadها با استفاده از Handles join، نشان دادیم که چگونه می‌توان از متد join در نوع JoinHandle که هنگام فراخوانی std::thread::spawn بازگردانده می‌شود، استفاده کرد. تابع trpl::join مشابه است، اما برای futures طراحی شده است. وقتی دو future به آن می‌دهید، یک future جدید ایجاد می‌کند که خروجی آن یک tuple شامل خروجی هر یک از futureهایی است که به آن ارسال کرده‌اید، به شرطی که هر دو کامل شوند. بنابراین، در لیست ۱۷-۸، از trpl::join استفاده می‌کنیم تا منتظر بمانیم fut1 و fut2 به پایان برسند. ما نه برای fut1 و fut2، بلکه برای future جدیدی که توسط trpl::join تولید می‌شود، منتظر می‌مانیم. خروجی را نادیده می‌گیریم، زیرا فقط یک tuple شامل دو مقدار unit است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let fut1 = async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let fut2 = async {
            for i in 1..5 {
                println!("hi number {i} from the second task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        trpl::join(fut1, fut2).await;
    });
}
Listing 17-8: استفاده از trpl::join برای منتظر ماندن دو آینده ناشناس

وقتی این کد را اجرا می‌کنیم، می‌بینیم هر دو futures تا تکمیل اجرا می‌شوند:

hi number 1 from the first task!
hi number 1 from the second task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

اکنون، هر بار دقیقاً همان ترتیب را مشاهده خواهید کرد، که بسیار متفاوت از چیزی است که با Threadها دیدیم. دلیل این امر این است که تابع trpl::join منصفانه است، به این معنی که هر future را به یک اندازه بررسی می‌کند، بین آن‌ها تناوب می‌گذارد و هرگز اجازه نمی‌دهد یکی از آن‌ها جلو بیفتد اگر دیگری آماده باشد. با Threadها، سیستم‌عامل تصمیم می‌گیرد که کدام Thread بررسی شود و چه مدت به آن اجازه اجرا بدهد. با Rust async، runtime تصمیم می‌گیرد که کدام task بررسی شود. (در عمل، جزئیات پیچیده می‌شوند زیرا یک runtime async ممکن است از Threadهای سیستم‌عامل در پشت صحنه به‌عنوان بخشی از نحوه مدیریت همزمانی استفاده کند، بنابراین تضمین منصفانه بودن می‌تواند برای runtime بیشتر کار ببرد—اما همچنان ممکن است!) runtimeها نیازی به تضمین منصفانه بودن برای هر عملیات خاصی ندارند، و اغلب APIهای مختلفی ارائه می‌دهند که به شما اجازه می‌دهند انتخاب کنید آیا می‌خواهید منصفانه بودن را اعمال کنید یا خیر.

برخی از این تغییرات در انتظار برای futures را امتحان کنید و ببینید چه می‌کنند:

  • بلوک async را از اطراف یکی یا هر دو حلقه حذف کنید.
  • هر بلوک async را بلافاصله پس از تعریف آن منتظر بمانید.
  • فقط حلقه اول را در یک بلوک async قرار دهید و آینده حاصل را پس از بدنه حلقه دوم منتظر بمانید.

برای یک چالش اضافی، ببینید آیا می‌توانید پیش از اجرای کد پیش‌بینی کنید که خروجی چه خواهد بود!

شمارش افزایشی در دو Task با استفاده از ارسال پیام

اشتراک داده‌ها بین futures نیز آشنا خواهد بود: دوباره از ارسال پیام استفاده خواهیم کرد، اما این بار با نسخه‌های async از انواع و توابع. ما مسیری کمی متفاوت از استفاده از ارسال پیام برای انتقال داده‌ها بین Threadها خواهیم پیمود تا برخی از تفاوت‌های کلیدی بین همزمانی مبتنی بر Thread و همزمانی مبتنی بر futures را نشان دهیم. در لیست ۱۷-۹، فقط با یک بلوک async شروع می‌کنیم—و نه ایجاد یک task جداگانه، همان‌طور که یک Thread جداگانه ایجاد کردیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("hi");
        tx.send(val).unwrap();

        let received = rx.recv().await.unwrap();
        println!("received '{received}'");
    });
}
Listing 17-9: ایجاد یک کانال async و اختصاص دو نیمه به tx و rx

اینجا، از trpl::channel استفاده می‌کنیم، نسخه async از API کانال چندتولیدی، یک‌مصرفی که در فصل 16 با نخ‌ها استفاده کردیم. نسخه async از API فقط کمی با نسخه مبتنی بر نخ متفاوت است: به جای استفاده از یک گیرنده غیرقابل‌تغییر (immutable)، از یک گیرنده قابل‌تغییر (mutable) rx استفاده می‌کند، و متد recv آن یک آینده تولید می‌کند که باید منتظر آن بمانیم، به جای تولید مقدار به‌طور مستقیم. اکنون می‌توانیم پیام‌ها را از فرستنده به گیرنده ارسال کنیم. توجه کنید که نیازی به ایجاد یک نخ جداگانه یا حتی یک تسک نداریم؛ فقط باید فراخوانی rx.recv را منتظر بمانیم.

متد همگام Receiver::recv در std::mpsc::channel تا زمانی که پیامی دریافت شود مسدود می‌شود. متد trpl::Receiver::recv این کار را نمی‌کند، زیرا async است. به جای مسدود شدن، کنترل را به runtime بازمی‌گرداند تا زمانی که یا پیامی دریافت شود یا سمت ارسال کانال بسته شود. در مقابل، ما فراخوانی send را منتظر نمی‌مانیم، زیرا مسدود نمی‌شود. نیازی به این کار ندارد، زیرا کانالی که پیام را به آن ارسال می‌کنیم بدون حد است.

نکته: از آنجا که تمام این کد async در یک بلوک async درون یک فراخوانی trpl::run اجرا می‌شود، همه چیز درون آن می‌تواند از مسدود شدن اجتناب کند. با این حال، کد خارج از آن روی بازگشت تابع run مسدود می‌شود. این همان هدف اصلی تابع trpl::run است: به شما اجازه می‌دهد انتخاب کنید که کجا روی مجموعه‌ای از کد async مسدود شوید و بنابراین کجا بین کدهای sync و async انتقال دهید. در بیشتر runtimeهای async، run در واقع به همین دلیل block_on نامیده می‌شود.

دو نکته در مورد این مثال توجه کنید. اول، پیام بلافاصله خواهد رسید. دوم، اگرچه ما اینجا از یک future استفاده می‌کنیم، هنوز هم هیچ همزمانی (concurrency) وجود ندارد. همه چیز در این لیست به ترتیب انجام می‌شود، درست مانند اینکه هیچ futureای در کار نباشد.

بیایید به قسمت اول بپردازیم، با ارسال یک سری پیام و خوابیدن بین آن‌ها، همان‌طور که در لیست ۱۷-۱۰ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("future"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            trpl::sleep(Duration::from_millis(500)).await;
        }

        while let Some(value) = rx.recv().await {
            println!("received '{value}'");
        }
    });
}
Listing 17-10: ارسال و دریافت چندین پیام از طریق کانال async و استفاده از await بین هر پیام

علاوه بر ارسال پیام‌ها، باید آن‌ها را دریافت کنیم. در این مورد، چون می‌دانیم چند پیام قرار است دریافت شوند، می‌توانستیم این کار را به‌صورت دستی با چهار بار فراخوانی rx.recv().await انجام دهیم. اما در دنیای واقعی، معمولاً در انتظار یک تعداد نامعلوم از پیام‌ها خواهیم بود، بنابراین نیاز داریم تا زمانی که مشخص کنیم پیام دیگری وجود ندارد، به انتظار ادامه دهیم.

در لیست ۱۶-۱۰، از یک حلقه for برای پردازش تمام آیتم‌های دریافت‌شده از یک کانال همزمان استفاده کردیم. با این حال، Rust هنوز راهی برای نوشتن یک حلقه for روی یک سری آیتم ناهمزمان ندارد، بنابراین باید از حلقه‌ای استفاده کنیم که قبلاً ندیده‌ایم: حلقه شرطی while let. این حلقه نسخه حلقه‌ای از ساختار if let است که در بخش کنترل جریان مختصر با if let و let else دیدیم. این حلقه تا زمانی که الگوی مشخص‌شده آن همچنان با مقدار مطابقت داشته باشد، به اجرا ادامه می‌دهد.

فراخوانی rx.recv یک future تولید می‌کند که منتظر آن می‌مانیم. runtime تا زمانی که future آماده شود، آن را متوقف می‌کند. وقتی پیامی برسد، future به Some(message) حل می‌شود، به ازای هر باری که پیام برسد. وقتی کانال بسته شود، صرف‌نظر از اینکه آیا پیام‌هایی رسیده‌اند یا خیر، future به None حل می‌شود تا نشان دهد دیگر مقادیری وجود ندارد و بنابراین باید polling را متوقف کنیم—یعنی منتظر ماندن را متوقف کنیم.

حلقه while let همه این‌ها را کنار هم قرار می‌دهد. اگر نتیجه فراخوانی rx.recv().await برابر با Some(message) باشد، به پیام دسترسی پیدا می‌کنیم و می‌توانیم از آن در بدنه حلقه استفاده کنیم، همانطور که با if let می‌توانستیم. اگر نتیجه None باشد، حلقه متوقف می‌شود. هر بار که حلقه کامل می‌شود، به نقطه انتظار بازمی‌گردد، بنابراین runtime دوباره آن را متوقف می‌کند تا زمانی که پیام دیگری برسد.

کد اکنون با موفقیت تمام پیام‌ها را ارسال و دریافت می‌کند. با این حال، هنوز چند مشکل باقی مانده است. اول این‌که، پیام‌ها با فاصله‌های نیم‌ثانیه‌ای دریافت نمی‌شوند؛ بلکه همگی به‌طور هم‌زمان و پس از گذشت ۲ ثانیه (۲۰۰۰ میلی‌ثانیه) از شروع برنامه می‌رسند. مشکل دیگر این است که این برنامه هیچ‌گاه خاتمه نمی‌یابد! بلکه برای همیشه منتظر دریافت پیام‌های جدید باقی می‌ماند. برای متوقف‌کردن آن باید از ctrl-c استفاده کنید.

بیایید با بررسی دلیل اینکه چرا پیام‌ها پس از تأخیر کامل به‌یک‌باره می‌آیند، شروع کنیم، به‌جای اینکه با تأخیر بین هرکدام ظاهر شوند. در یک بلوک async خاص، ترتیب ظاهر شدن کلمات کلیدی await در کد، همان ترتیبی است که هنگام اجرای برنامه اجرا می‌شوند.

در فهرست 17-10 فقط یک بلوک async وجود دارد، بنابراین همه چیز در آن به‌صورت خطی اجرا می‌شود. هنوز هم هیچ همزمانی وجود ندارد. تمام فراخوانی‌های tx.send انجام می‌شوند، در میان تمام فراخوانی‌های trpl::sleep و نقاط انتظار مرتبط با آن‌ها. فقط پس از آن، حلقه while let به نقاط انتظار روی فراخوانی‌های recv می‌رسد.

برای به دست آوردن رفتار مورد نظر، که در آن تأخیر خواب بین هر پیام رخ می‌دهد، باید عملیات‌های tx و rx را در بلوک‌های async جداگانه قرار دهیم، همان‌طور که در لیست ۱۷-۱۱ نشان داده شده است. سپس runtime می‌تواند هر یک از آن‌ها را جداگانه با استفاده از trpl::join اجرا کند، دقیقاً مانند مثال شمارش. بار دیگر، منتظر نتیجه فراخوانی trpl::join می‌مانیم، نه futures فردی. اگر به صورت ترتیبی برای futures فردی منتظر می‌ماندیم، دوباره به جریان ترتیبی بازمی‌گشتیم—دقیقاً چیزی که تلاش می‌کنیم انجام ندهیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listing 17-11: جدا کردن send و recv در بلوک‌های async جداگانه و منتظر ماندن برای آینده‌های این بلوک‌ها

با کد به‌روزرسانی‌شده در لیست ۱۷-۱۱، پیام‌ها با فواصل ۵۰۰ میلی‌ثانیه چاپ می‌شوند، به‌جای اینکه همه با عجله پس از ۲ ثانیه ظاهر شوند.

برنامه هنوز هم هرگز خارج نمی‌شود، به دلیل نحوه تعامل حلقه while let با trpl::join:

  • Future بازگردانده‌شده از trpl::join تنها زمانی تکمیل می‌شود که هر دو future ارسال‌شده به آن تکمیل شده باشند.
  • Future مربوط به tx زمانی تکمیل می‌شود که پس از ارسال آخرین پیام در vals خوابیدن آن به پایان برسد.
  • Future مربوط به rx تا زمانی که حلقه while let به پایان نرسد تکمیل نخواهد شد.
  • حلقه while let تا زمانی که منتظر rx.recv باشد و مقدار None تولید شود، پایان نمی‌یابد.
  • منتظر شدن برای rx.recv تنها زمانی مقدار None بازمی‌گرداند که طرف دیگر کانال بسته شود.
  • کانال تنها در صورتی بسته می‌شود که rx.close را فراخوانی کنیم یا طرف فرستنده، یعنی tx، حذف شود.
  • ما هیچ‌جا rx.close را فراخوانی نمی‌کنیم، و tx تا زمانی که بیرونی‌ترین بلوک async ارسال‌شده به trpl::run به پایان نرسد، حذف نمی‌شود.
  • این بلوک نمی‌تواند به پایان برسد زیرا منتظر تکمیل شدن trpl::join است، که ما را دوباره به بالای این لیست بازمی‌گرداند.

ما می‌توانیم به‌صورت دستی با فراخوانی rx.close کانال را ببندیم، اما این کار چندان منطقی نیست. توقف پس از پردازش تعداد دلخواهی از پیام‌ها باعث می‌شود برنامه خاموش شود، اما ممکن است پیام‌ها را از دست بدهیم. ما به راه دیگری نیاز داریم تا مطمئن شویم که tx قبل از پایان تابع حذف می‌شود.

در حال حاضر، بلوک async که پیام‌ها را ارسال می‌کند فقط tx را قرض می‌گیرد زیرا ارسال پیام نیاز به مالکیت ندارد، اما اگر می‌توانستیم tx را به داخل آن بلوک async منتقل کنیم، پس از پایان آن بلوک حذف می‌شد. در بخش فصل ۱۳ گرفتن مراجع یا جابه‌جایی مالکیت یاد گرفتید چگونه از کلمه کلیدی move با closures استفاده کنید، و همان‌طور که در بخش فصل ۱۶ استفاده از closures move با Threadها بحث شد، اغلب هنگام کار با Threadها نیاز داریم داده‌ها را به داخل closures منتقل کنیم. همان دینامیک‌های اساسی برای بلوک‌های async اعمال می‌شود، بنابراین کلمه کلیدی move با بلوک‌های async همان‌طور کار می‌کند که با closures کار می‌کند.

در لیست ۱۷-۱۲، بلوک مورد استفاده برای ارسال پیام‌ها را از async به async move تغییر می‌دهیم. وقتی این نسخه از کد را اجرا می‌کنیم، برنامه پس از ارسال و دریافت آخرین پیام به‌طور مرتب خاتمه می‌یابد.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listing 17-12: نسخه بازبینی‌شده کد از لیست ۱۷-۱۱ که به‌درستی پس از اتمام خاتمه می‌یابد

این کانال async همچنین یک کانال چند-تولیدی (multiple-producer) است، بنابراین اگر بخواهیم پیام‌ها را از چندین future ارسال کنیم، می‌توانیم clone را روی tx فراخوانی کنیم، همان‌طور که در لیست ۱۷-۱۳ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(1500)).await;
            }
        };

        trpl::join3(tx1_fut, tx_fut, rx_fut).await;
    });
}
Listing 17-13: استفاده از تولیدکنندگان متعدد با بلوک‌های async

ابتدا، tx را clone کرده و tx1 را خارج از بلوک async اول ایجاد می‌کنیم. tx1 را همانند قبل با tx به داخل آن بلوک منتقل می‌کنیم. سپس، در ادامه، tx اصلی را به یک بلوک جدید async منتقل می‌کنیم، جایی که پیام‌های بیشتری با یک تأخیر کمی کندتر ارسال می‌کنیم. ما این بلوک async جدید را بعد از بلوک async برای دریافت پیام‌ها قرار می‌دهیم، اما می‌توانستیم به همان اندازه آن را قبل از آن قرار دهیم. نکته کلیدی ترتیب منتظر ماندن برای futures است، نه ترتیب ایجاد آن‌ها.

هر دو بلوک async برای ارسال پیام‌ها باید بلوک‌های async move باشند تا tx و tx1 هر دو پس از پایان آن بلوک‌ها حذف شوند. در غیر این صورت، دوباره به همان حلقه بی‌نهایت اولیه بازمی‌گردیم. در نهایت، از trpl::join به trpl::join3 تغییر می‌دهیم تا future اضافی را مدیریت کنیم.

اکنون تمام پیام‌های هر دو future ارسال را می‌بینیم، و چون futures ارسال از تأخیرهای کمی متفاوت پس از ارسال استفاده می‌کنند، پیام‌ها نیز در این فواصل مختلف دریافت می‌شوند.

received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'

این یک شروع خوب است، اما ما را به تعداد محدودی از futures محدود می‌کند: دو عدد با join یا سه عدد با join3. بیایید ببینیم چگونه می‌توانیم با تعداد بیشتری از futures کار کنیم.

کار با تعداد دلخواهی از Futures

وقتی در بخش قبلی از استفاده از دو future به سه future تغییر دادیم، مجبور شدیم به جای استفاده از join از join3 استفاده کنیم. این مسئله آزاردهنده خواهد بود اگر هر بار که تعداد futuresی که می‌خواهیم join کنیم تغییر می‌کند، مجبور به فراخوانی یک تابع متفاوت باشیم. خوشبختانه، یک فرم ماکروی join داریم که می‌توانیم به آن تعداد دلخواهی از آرگومان‌ها را ارسال کنیم. این ماکرو همچنین خودش مدیریت انتظار برای futures را انجام می‌دهد. بنابراین، می‌توانیم کد لیست ۱۷-۱۳ را بازنویسی کنیم تا به جای join3 از join! استفاده کنیم، همان‌طور که در لیست ۱۷-۱۴ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        trpl::join!(tx1_fut, tx_fut, rx_fut);
    });
}
Listing 17-14: استفاده از join! برای منتظر ماندن چندین آینده

این قطعاً نسبت به جابجایی بین join، join3، join4 و موارد دیگر بهبود یافته است! با این حال، حتی این فرم ماکرو نیز فقط زمانی کار می‌کند که تعداد futures را از قبل بدانیم. اما در دنیای واقعی Rust، اضافه کردن futures به یک مجموعه و سپس انتظار برای کامل شدن برخی یا تمام آن‌ها یک الگوی رایج است.

برای بررسی همه‌ی futureها در یک مجموعه، باید روی همه‌ی آن‌ها پیمایش کنیم و روی همه join کنیم. تابع trpl::join_all هر نوعی را می‌پذیرد که trait Iterator را پیاده‌سازی کرده باشد، که در فصل ۱۳ در بخش trait پیمایشگر و متد next درباره‌ی آن آموختید، پس به نظر می‌رسد که دقیقاً مناسب باشد. بیایید futureهایمان را در یک بردار قرار دهیم و join! را با join_all جایگزین کنیم، همان‌طور که در لیستینگ 17-15 نشان داده شده است.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures = vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}
Listing 17-15: ذخیره آینده‌های ناشناس در یک بردار و فراخوانی join_all

متأسفانه، این کد کامپایل نمی‌شود. در عوض، با این خطا مواجه می‌شویم:

error[E0308]: mismatched types
  --> src/main.rs:45:37
   |
10 |         let tx1_fut = async move {
   |                       ---------- the expected `async` block
...
24 |         let rx_fut = async {
   |                      ----- the found `async` block
...
45 |         let futures = vec![tx1_fut, rx_fut, tx_fut];
   |                                     ^^^^^^ expected `async` block, found a different `async` block
   |
   = note: expected `async` block `{async block@src/main.rs:10:23: 10:33}`
              found `async` block `{async block@src/main.rs:24:22: 24:27}`
   = note: no two async blocks, even if identical, have the same type
   = help: consider pinning your async block and casting it to a trait object

این ممکن است شگفت‌آور باشد. بالاخره، هیچ‌یک از بلوک‌های async چیزی بازنمی‌گردانند، بنابراین هر کدام یک Future<Output = ()> تولید می‌کنند. اما به یاد داشته باشید که Future یک ویژگی (trait) است و کامپایلر برای هر بلوک async یک enum منحصربه‌فرد ایجاد می‌کند. نمی‌توانید دو struct مختلف را که دستی نوشته شده‌اند در یک Vec قرار دهید، و همین قانون برای enumهای مختلفی که توسط کامپایلر تولید می‌شوند اعمال می‌شود.

توجه: در بخش استفاده از یک enum برای نگهداری چند مقدار در فصل ۸، روش دیگری برای گنجاندن چند نوع مختلف در یک Vec را بررسی کردیم: استفاده از یک enum برای نمایش هر نوعی که ممکن است در بردار وجود داشته باشد. اما در اینجا نمی‌توانیم این کار را انجام دهیم. اولاً، هیچ راهی برای نام‌گذاری نوع‌های مختلف نداریم چون آن‌ها ناشناس (anonymous) هستند. ثانیاً، دلیل اصلی استفاده‌ی ما از بردار و join_all این بود که بتوانیم با مجموعه‌ای پویا از futureها کار کنیم، جایی که تنها مهم است همه خروجی‌های آن‌ها یکسان باشند.

نکته: در بخش فصل ۸ استفاده از یک Enum برای ذخیره مقادیر متعدد، درباره یک روش دیگر برای شامل کردن چندین نوع در یک Vec صحبت کردیم: استفاده از یک enum برای نمایش هر نوعی که می‌تواند در وکتور ظاهر شود. اما نمی‌توانیم اینجا از آن استفاده کنیم. از یک طرف، هیچ راهی برای نام‌گذاری انواع مختلف نداریم، زیرا آن‌ها ناشناس هستند. از طرف دیگر، دلیلی که ما در وهله اول به دنبال یک وکتور و join_all رفتیم، این بود که بتوانیم با یک مجموعه پویا از futures کار کنیم، جایی که فقط به این اهمیت می‌دهیم که همه آن‌ها خروجی یکسانی دارند.

ابتدا هر future درون vec! را در یک Box::new بسته‌بندی می‌کنیم، همان‌طور که در لیست ۱۷-۱۶ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}
Listing 17-16: استفاده از Box::new برای تطبیق انواع futures در یک Vec

متأسفانه، این کد هنوز هم کامپایل نمی‌شود. در واقع، همان خطای پایه‌ای که قبلاً دریافت کردیم، برای فراخوانی‌های دوم و سوم Box::new نیز رخ می‌دهد، به همراه خطاهای جدیدی که به ویژگی Unpin اشاره دارند. به زودی به خطاهای مرتبط با Unpin بازمی‌گردیم. ابتدا، بیایید خطاهای نوع در فراخوانی‌های Box::new را با مشخص کردن صریح نوع متغیر futures رفع کنیم (نگاه کنید به لیست ۱۷-۱۷).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}
Listing 17-17: برطرف کردن بقیه خطاهای ناسازگاری نوع با استفاده از اعلان صریح نوع

این type declaration کمی پیچیده است، بنابراین بیایید آن را مرحله به مرحله بررسی کنیم:

  1. نوع داخلی‌ترین، خود future است. به‌طور صریح اعلام می‌کنیم که خروجی future نوع واحد () است، با نوشتن Future<Output = ()>.
  2. سپس ویژگی را با dyn علامت‌گذاری می‌کنیم تا به‌صورت دینامیک باشد.
  3. کل مرجع ویژگی در یک Box بسته‌بندی می‌شود.
  4. در نهایت، به‌طور صریح بیان می‌کنیم که futures یک Vec است که شامل این آیتم‌ها است.

این تغییر تأثیر قابل‌توجهی داشت. اکنون وقتی کامپایلر را اجرا می‌کنیم، فقط خطاهایی که به Unpin اشاره دارند باقی می‌مانند. اگرچه سه خطا وجود دارد، اما محتوای آن‌ها بسیار مشابه است.

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
   --> src/main.rs:49:24
    |
49  |         trpl::join_all(futures).await;
    |         -------------- ^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
    |         |
    |         required by a bound introduced by this call
    |
    = note: consider using the `pin!` macro
            consider using `Box::pin` if you need to access the pinned value outside of the current scope
    = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `join_all`
   --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:105:14
    |
102 | pub fn join_all<I>(iter: I) -> JoinAll<I::Item>
    |        -------- required by a bound in this function
...
105 |     I::Item: Future,
    |              ^^^^^^ required by this bound in `join_all`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:9
   |
49 |         trpl::join_all(futures).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:33
   |
49 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `async_await` (bin "async_await") due to 3 previous errors

این پیام حجم زیادی از اطلاعات را دارد، پس بیایید آن را بخش‌بندی کنیم. بخش اول پیام می‌گوید که اولین بلاک async (src/main.rs:8:23: 20:10) trait Unpin را پیاده‌سازی نکرده است و پیشنهاد می‌کند برای رفع این مشکل از pin! یا Box::pin استفاده کنیم. در ادامه‌ی فصل، به جزئیات بیشتری درباره‌ی Pin و Unpin خواهیم پرداخت. فعلاً می‌توانیم فقط از توصیه‌ی کامپایلر پیروی کنیم تا مشکل برطرف شود. در لیستینگ 17-18، ابتدا Pin را از std::pin وارد می‌کنیم. سپس نوع futures را به‌روزرسانی می‌کنیم، به‌طوری که هر Box داخل یک Pin قرار گیرد. در نهایت، از Box::pin برای pin کردن خود futureها استفاده می‌کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::pin::Pin;

// -- snip --

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Pin<Box<dyn Future<Output = ()>>>> =
            vec![Box::pin(tx1_fut), Box::pin(rx_fut), Box::pin(tx_fut)];

        trpl::join_all(futures).await;
    });
}
Listing 17-18: استفاده از Pin و Box::pin برای برطرف کردن نوع Vec

اگر این کد را کامپایل و اجرا کنیم، در نهایت خروجی موردنظر خود را دریافت می‌کنیم:

received 'hi'
received 'more'
received 'from'
received 'messages'
received 'the'
received 'for'
received 'future'
received 'you'

آه!

اینجا چیزهای بیشتری برای بررسی وجود دارد. برای یک مورد، استفاده از Pin<Box<T>> یک مقدار کمی سربار اضافه می‌کند، زیرا این futures را با Box روی heap قرار می‌دهیم—و ما فقط این کار را برای هم‌تراز کردن انواع انجام می‌دهیم. بعد از همه این‌ها، ما واقعاً نیازی به تخصیص heap نداریم: این futures به این تابع خاص محدود هستند. همان‌طور که قبلاً ذکر شد، Pin خودش یک نوع wrapper است، بنابراین می‌توانیم از مزیت داشتن یک نوع واحد در Vec بهره‌مند شویم—دلیل اصلی که به دنبال Box رفتیم—بدون انجام تخصیص heap. می‌توانیم مستقیماً از Pin با هر future استفاده کنیم، با استفاده از ماکروی std::pin::pin.

با این حال، همچنان باید به‌صورت صریح نوع رفرنس پین‌شده را مشخص کنیم؛
در غیر این صورت، Rust نمی‌داند که این‌ها باید به عنوان trait objectهای داینامیک تفسیر شوند،
که این همان چیزی است که در Vec به آن نیاز داریم.
بنابراین، pin را به لیست واردات‌مان از std::pin اضافه می‌کنیم.
سپس می‌توانیم هر future را هنگام تعریف آن با pin! پین کنیم
و futures را به‌صورت یک Vec شامل رفرنس‌های mutable پین‌شده به نوع dynamic future تعریف کنیم،
همان‌طور که در لیستینگ 17-19 نشان داده شده است.

با این حال، باید به‌صراحت نوع مرجع pinned را مشخص کنیم؛ در غیر این صورت، راست همچنان نمی‌داند که این‌ها را به‌عنوان شیءهای ویژگی دینامیک تفسیر کند، که همان چیزی است که برای قرار گرفتن در Vec نیاز داریم. بنابراین، هر آینده را وقتی تعریف می‌کنیم pin! می‌کنیم و futures را به‌عنوان یک Vec که شامل مراجع متغیر pinned به نوع ویژگی دینامیک Future است تعریف می‌کنیم، همانطور که در فهرست 17-19 نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::pin::{Pin, pin};

// -- snip --

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            // --snip--
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}
Listing 17-19: استفاده مستقیم از Pin با ماکروی pin! برای اجتناب از تخصیص‌های غیرضروری heap

تا اینجا با نادیده گرفتن این واقعیت که ممکن است نوع‌های Output مختلفی داشته باشیم، پیش رفتیم. برای مثال، در فهرست 17-20، آینده ناشناس برای a ویژگی Future<Output = u32> را پیاده‌سازی می‌کند، آینده ناشناس برای b ویژگی Future<Output = &str> را پیاده‌سازی می‌کند، و آینده ناشناس برای c ویژگی Future<Output = bool> را پیاده‌سازی می‌کند.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let a = async { 1u32 };
        let b = async { "Hello!" };
        let c = async { true };

        let (a_result, b_result, c_result) = trpl::join!(a, b, c);
        println!("{a_result}, {b_result}, {c_result}");
    });
}
Listing 17-20: سه آینده با نوع‌های متفاوت

می‌توانیم از trpl::join! برای منتظر ماندن استفاده کنیم، زیرا به ما اجازه می‌دهد چندین نوع future را ارسال کنیم و یک tuple از آن انواع تولید می‌کند. اما نمی‌توانیم از trpl::join_all استفاده کنیم، زیرا این تابع نیاز دارد که همه futures ارسال‌شده نوع یکسانی داشته باشند. به یاد داشته باشید، همین خطا بود که ما را به این ماجراجویی با Pin کشاند!

این یک معاوضه بنیادی است: می‌توانیم با تعداد پویایی از futures با استفاده از join_all کار کنیم، به شرطی که همه آن‌ها نوع یکسانی داشته باشند، یا می‌توانیم با تعداد مشخصی از futures با توابع join یا ماکروی join! کار کنیم، حتی اگر آن‌ها انواع مختلفی داشته باشند. این همان شرایطی است که هنگام کار با هر نوع دیگری در Rust با آن مواجه می‌شویم. Futures خاص نیستند، حتی اگر سینتکس مناسبی برای کار با آن‌ها داشته باشیم، و این یک نکته مثبت است.

Racing Futures

وقتی آینده‌ها را با خانواده توابع و ماکروهای join “منتظر می‌مانیم”، نیاز داریم همه آن‌ها تمام شوند قبل از اینکه به مرحله بعدی برویم. گاهی اوقات، اما، فقط نیاز داریم یکی از آینده‌ها از مجموعه‌ای تمام شود قبل از اینکه به مرحله بعدی برویم—کمی شبیه به مسابقه دادن یک آینده در برابر دیگری.

در لیست ۱۷-۲۱، ما دوباره از trpl::race استفاده می‌کنیم تا دو future، یعنی slow و fast، را در برابر یکدیگر اجرا کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            println!("'slow' started.");
            trpl::sleep(Duration::from_millis(100)).await;
            println!("'slow' finished.");
        };

        let fast = async {
            println!("'fast' started.");
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'fast' finished.");
        };

        trpl::race(slow, fast).await;
    });
}
Listing 17-21: استفاده از race برای دریافت نتیجه اولین آینده‌ای که تمام می‌شود

هر future یک پیام هنگام شروع اجرا چاپ می‌کند، با فراخوانی و انتظار برای sleep به مدت مشخصی مکث می‌کند، و سپس یک پیام دیگر هنگام اتمام چاپ می‌کند. سپس، هر دو future یعنی slow و fast را به trpl::race ارسال می‌کنیم و منتظر می‌مانیم تا یکی از آن‌ها به پایان برسد. (نتیجه اینجا چندان شگفت‌آور نیست: fast برنده می‌شود.) برخلاف زمانی که در “اولین برنامه Async ما” از race استفاده کردیم، اینجا به نمونه Either که بازمی‌گرداند توجه نمی‌کنیم، زیرا تمام رفتار جالب در بدنه بلوک‌های async رخ می‌دهد.

توجه کنید که اگر ترتیب آرگومان‌ها به race را جابه‌جا کنید، ترتیب پیام‌های “started” تغییر می‌کند، حتی اگر future fast همیشه زودتر به پایان برسد. دلیل این است که پیاده‌سازی این تابع خاص race منصفانه نیست. این تابع همیشه futures ارسال‌شده را به ترتیب آرگومان‌ها اجرا می‌کند. سایر پیاده‌سازی‌ها منصفانه هستند و به صورت تصادفی انتخاب می‌کنند که کدام future را ابتدا poll کنند. با این حال، صرف‌نظر از اینکه پیاده‌سازی race ما منصفانه باشد یا نه، یکی از futures تا اولین await در بدنه‌اش اجرا می‌شود قبل از اینکه task دیگری بتواند شروع شود.

به یاد بیاورید از اولین برنامه Async ما که در هر نقطه await، Rust به runtime اجازه می‌دهد تا task را متوقف کند و به task دیگری سوئیچ کند اگر future در حال انتظار آماده نباشد. عکس این موضوع هم صادق است: Rust فقط بلوک‌های async را متوقف می‌کند و کنترل را به runtime بازمی‌گرداند در یک نقطه await.

این بدان معناست که اگر در یک بلوک async بدون نقطه await مقدار زیادی کار انجام دهید، آن future دیگر futures را از پیشرفت باز می‌دارد. گاهی اوقات ممکن است به این موضوع اشاره شود که یک future دیگر futures را گرسنه می‌کند. در برخی موارد، این ممکن است مشکل بزرگی نباشد. با این حال، اگر در حال انجام برخی تنظیمات پرهزینه یا کار طولانی‌مدت هستید، یا اگر futureای دارید که به طور نامحدود یک کار خاص را انجام می‌دهد، باید به این فکر کنید که چه زمانی و کجا کنترل را به runtime بازگردانید.

به همان اندازه، اگر عملیات‌های مسدودکننده طولانی‌مدت دارید، async می‌تواند ابزاری مفید برای ارائه راه‌هایی باشد که بخش‌های مختلف برنامه بتوانند با یکدیگر تعامل داشته باشند.

اما در این موارد چگونه کنترل را به runtime بازمی‌گردانید؟

Yielding Control to the Runtime

بیایید یک عملیات طولانی‌مدت را شبیه‌سازی کنیم. لیست ۱۷-۲۲ یک تابع به نام slow معرفی می‌کند.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        // We will call `slow` here later
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-22: استفاده از thread::sleep برای شبیه‌سازی عملیات کند

این کد از std::thread::sleep به جای trpl::sleep استفاده می‌کند، به طوری که فراخوانی slow، Thread فعلی را برای مدت مشخصی از میلی‌ثانیه‌ها مسدود می‌کند. می‌توانیم از slow به عنوان جایگزینی برای عملیات‌های واقعی که هم طولانی‌مدت هستند و هم مسدودکننده، استفاده کنیم.

در لیست ۱۷-۲۳، از slow برای شبیه‌سازی انجام این نوع کارهای CPU-bound در یک جفت future استفاده می‌کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            slow("a", 10);
            slow("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            slow("b", 10);
            slow("b", 15);
            slow("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-23: استفاده از thread::sleep برای شبیه‌سازی عملیات کند

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

'a' started.
'a' ran for 30ms
'a' ran for 10ms
'a' ran for 20ms
'b' started.
'b' ran for 75ms
'b' ran for 10ms
'b' ran for 15ms
'b' ran for 350ms
'a' finished.

همان‌طور که در مثال قبلی دیدیم، race همچنان به محض اینکه a تمام شود، کار را تمام می‌کند. اما بین دو future هیچ تداخل یا جابه‌جایی وجود ندارد. future a تمام کار خود را انجام می‌دهد تا زمانی که فراخوانی trpl::sleep منتظر بماند، سپس future b تمام کار خود را انجام می‌دهد تا زمانی که فراخوانی trpl::sleep خودش منتظر بماند، و در نهایت future a کامل می‌شود. برای اینکه هر دو future بتوانند بین taskهای کند خود پیشرفت کنند، به نقاط await نیاز داریم تا بتوانیم کنترل را به runtime بازگردانیم. این به این معناست که به چیزی نیاز داریم که بتوانیم برای آن منتظر بمانیم!

هم‌اکنون می‌توانیم این نوع انتقال کنترل را در لیست ۱۷-۲۳ مشاهده کنیم: اگر trpl::sleep در انتهای future a را حذف کنیم، این future بدون اجرای future b به طور کامل به پایان می‌رسد. بیایید از تابع sleep به‌عنوان نقطه شروعی برای اجازه دادن به عملیات‌ها برای جابه‌جا شدن و پیشرفت استفاده کنیم، همان‌طور که در لیست ۱۷-۲۴ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let one_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::sleep(one_ms).await;
            slow("a", 10);
            trpl::sleep(one_ms).await;
            slow("a", 20);
            trpl::sleep(one_ms).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::sleep(one_ms).await;
            slow("b", 10);
            trpl::sleep(one_ms).await;
            slow("b", 15);
            trpl::sleep(one_ms).await;
            slow("b", 350);
            trpl::sleep(one_ms).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-24: استفاده از sleep برای اجازه دادن به عملیات‌ها برای پیشرفت متناوب

در فهرست 17-24، فراخوانی‌های trpl::sleep با نقاط انتظار بین هر فراخوانی به slow اضافه می‌کنیم. اکنون کار دو آینده درهم‌تنیده شده است:

'a' started.
'a' ran for 30ms
'b' started.
'b' ran for 75ms
'a' ran for 10ms
'b' ran for 10ms
'a' ran for 20ms
'b' ran for 15ms
'a' finished.

future a هنوز برای مدتی اجرا می‌شود قبل از اینکه کنترل را به b منتقل کند، زیرا ابتدا slow را فراخوانی می‌کند قبل از اینکه trpl::sleep را فراخوانی کند. اما پس از آن، futures هر بار که یکی از آن‌ها به یک نقطه await می‌رسد، به صورت متناوب جابه‌جا می‌شوند. در این مورد، ما این کار را پس از هر فراخوانی به slow انجام داده‌ایم، اما می‌توانستیم کار را به هر شکلی که برای ما منطقی‌تر است تقسیم کنیم.

با این حال، واقعاً نمی‌خواهیم اینجا sleep کنیم؛ می‌خواهیم به سریع‌ترین شکلی که می‌توانیم پیشرفت کنیم. فقط نیاز داریم کنترل را به runtime بازگردانیم. می‌توانیم این کار را به‌طور مستقیم با استفاده از تابع yield_now انجام دهیم. در فهرست 17-25، تمام این فراخوانی‌های sleep را با yield_now جایگزین می‌کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::yield_now().await;
            slow("a", 10);
            trpl::yield_now().await;
            slow("a", 20);
            trpl::yield_now().await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::yield_now().await;
            slow("b", 10);
            trpl::yield_now().await;
            slow("b", 15);
            trpl::yield_now().await;
            slow("b", 350);
            trpl::yield_now().await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-25: استفاده از yield_now برای اجازه دادن به عملیات‌ها برای پیشرفت متناوب

این کد هم از نظر بیان هدف واقعی واضح‌تر است و هم می‌تواند به طور قابل‌توجهی سریع‌تر از استفاده از sleep باشد، زیرا تایمرهایی مانند آنچه که توسط sleep استفاده می‌شود اغلب محدودیت‌هایی در دقت خود دارند. نسخه‌ای از sleep که ما استفاده می‌کنیم، برای مثال، همیشه حداقل به مدت یک میلی‌ثانیه می‌خوابد، حتی اگر یک Duration یک نانوثانیه‌ای به آن بدهیم. دوباره، کامپیوترهای مدرن سریع هستند: آن‌ها می‌توانند در یک میلی‌ثانیه کارهای زیادی انجام دهند!

می‌توانید خودتان این را ببینید با راه‌اندازی یک بنچمارک کوچک، مانند آنچه در لیست ۱۷-۲۶ نشان داده شده است. (این روش به‌ویژه دقیقی برای انجام تست عملکرد نیست، اما برای نشان دادن تفاوت در اینجا کافی است.)

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::{Duration, Instant};

fn main() {
    trpl::run(async {
        let one_ns = Duration::from_nanos(1);
        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::sleep(one_ns).await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'sleep' version finished after {} seconds.",
            time.as_secs_f32()
        );

        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::yield_now().await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'yield' version finished after {} seconds.",
            time.as_secs_f32()
        );
    });
}
Listing 17-26: مقایسه عملکرد sleep و yield_now

در اینجا، تمام چاپ وضعیت را کنار می‌گذاریم، یک Duration یک نانوثانیه‌ای به trpl::sleep می‌دهیم و اجازه می‌دهیم هر future به‌صورت مستقل اجرا شود، بدون هیچ جابه‌جایی بین futures. سپس ۱,۰۰۰ بار این عملیات را تکرار می‌کنیم و می‌بینیم که futureی که از trpl::sleep استفاده می‌کند در مقایسه با futureی که از trpl::yield_now استفاده می‌کند چقدر زمان می‌برد.

نسخه‌ای که از yield_now استفاده می‌کند، بسیار سریع‌تر است!

این بدان معناست که async حتی برای وظایف وابسته به CPU می‌تواند مفید باشد، بسته به اینکه برنامه شما چه کار دیگری انجام می‌دهد، زیرا ابزاری مفید برای ساختاردهی روابط بین بخش‌های مختلف برنامه فراهم می‌کند. این نوعی از چندوظیفه‌گی مشارکتی است، جایی که هر آینده قدرت تصمیم‌گیری درباره زمان واگذاری کنترل از طریق نقاط انتظار را دارد. بنابراین، هر آینده نیز مسئولیت دارد که از مسدود کردن بیش از حد طولانی اجتناب کند. در برخی سیستم‌عامل‌های مبتنی بر راست برای سیستم‌های تعبیه‌شده، این تنها نوع چندوظیفه‌گی است!

در کد واقعی، معمولاً فراخوانی توابع را با نقاط await در هر خط متناوب نمی‌کنید، البته. در حالی که واگذاری کنترل به این روش نسبتاً کم‌هزینه است، اما رایگان نیست. در بسیاری از موارد، تلاش برای تقسیم یک task که CPU-bound است ممکن است آن را به‌طور قابل توجهی کندتر کند، بنابراین گاهی اوقات برای عملکرد کلی بهتر است که اجازه دهید یک عملیات به‌طور مختصر مسدود شود. همیشه اندازه‌گیری کنید تا ببینید تنگناهای عملکرد واقعی کد شما کجا هستند. اما، این دینامیک اساسی را باید در ذهن داشته باشید، به‌ویژه اگر واقعاً شاهد انجام مقدار زیادی کار به‌صورت ترتیبی باشید، در حالی که انتظار داشتید به‌طور همزمان انجام شود!

ساخت انتزاعات Async خودمان

ما همچنین می‌توانیم futures را با هم ترکیب کنیم تا الگوهای جدیدی ایجاد کنیم. برای مثال، می‌توانیم یک تابع timeout با استفاده از بلوک‌های سازنده async که از قبل داریم، بسازیم. هنگامی که کارمان تمام شد، نتیجه یک بلوک سازنده دیگر خواهد بود که می‌توانیم برای ایجاد انتزاعات async بیشتری از آن استفاده کنیم.

فهرست 17-27 نشان می‌دهد که چگونه انتظار داریم این timeout با یک آینده کند کار کند.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_millis(100)).await;
            "I finished!"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}
Listing 17-27: تعریف نحوه کار timeout با یک آینده کند

بیایید این را پیاده‌سازی کنیم! برای شروع، بیایید به API مورد نیاز برای timeout فکر کنیم:

  • باید خودش یک تابع async باشد تا بتوانیم منتظر آن بمانیم.
  • پارامتر اول آن باید یک آینده برای اجرا باشد. می‌توانیم آن را عمومی کنیم تا بتواند با هر آینده‌ای کار کند.
  • پارامتر دوم آن مدت‌زمان حداکثری برای انتظار خواهد بود. اگر از یک Duration استفاده کنیم، این کار ارسال آن به trpl::sleep را آسان می‌کند.
  • باید یک Result بازگرداند. اگر آینده با موفقیت کامل شود، Result شامل Ok با مقدار تولیدشده توسط آینده خواهد بود. اگر زمان محدودیت زودتر سپری شود، Result شامل Err با مدت‌زمانی که زمان محدودیت برای آن منتظر ماند خواهد بود.

فهرست 17-28 این اعلان را نشان می‌دهد.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    // Here is where our implementation will go!
}
Listing 17-28: تعریف امضای timeout

این اهداف ما برای نوع‌ها را برآورده می‌کند. حالا بیایید به رفتاری که نیاز داریم فکر کنیم: می‌خواهیم آینده ارسال‌شده به آن را در برابر مدت‌زمان محدودیت مسابقه دهیم. می‌توانیم از trpl::sleep برای ساختن یک آینده تایمر از مدت‌زمان استفاده کنیم و از trpl::race برای اجرای آن تایمر با آینده‌ای که کاربر ارسال می‌کند استفاده کنیم.

ما همچنین می‌دانیم که race منصفانه نیست و آرگومان‌ها را به ترتیب ارسال‌شده poll می‌کند. بنابراین، ابتدا future_to_try را به race ارسال می‌کنیم تا حتی اگر max_time مدت زمان بسیار کوتاهی باشد، فرصتی برای تکمیل شدن داشته باشد. اگر future_to_try زودتر تمام شود، race مقدار Left را با خروجی future_to_try بازمی‌گرداند. اگر timer زودتر تمام شود، race مقدار Right را با خروجی () تایمر بازمی‌گرداند.

در لیست ۱۷-۲۹، نتیجه انتظار برای trpl::race را match می‌کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

use trpl::Either;

// --snip--

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    match trpl::race(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}
Listing 17-29: تعریف timeout با استفاده از race و sleep

اگر future_to_try موفق شود و مقدار Left(output) دریافت کنیم، مقدار Ok(output) را بازمی‌گردانیم. اگر به جای آن تایمر خواب منقضی شود و مقدار Right(()) دریافت کنیم، () را با _ نادیده گرفته و به جای آن مقدار Err(max_time) را بازمی‌گردانیم.

با این کار، یک timeout عملیاتی داریم که از دو ابزار کمکی async دیگر ساخته شده است. اگر کد خود را اجرا کنیم، پس از انقضای timeout، حالت شکست را چاپ خواهد کرد:

Failed after 2 seconds

از آنجا که futures می‌توانند با دیگر futures ترکیب شوند، می‌توانید ابزارهای بسیار قدرتمندی با استفاده از بلوک‌های سازنده کوچک‌تر async بسازید. برای مثال، می‌توانید از همین رویکرد برای ترکیب timeoutها با retries استفاده کنید و به نوبه خود از آن‌ها با عملیاتی مانند تماس‌های شبکه (یکی از مثال‌های ابتدای فصل) استفاده کنید.

در عمل، معمولاً مستقیماً با async و await کار می‌کنید و به طور ثانویه از توابع و ماکروهایی مانند join، join_all، race و غیره استفاده می‌کنید. فقط گاهی نیاز خواهید داشت از pin برای استفاده از futures با آن APIها استفاده کنید.

اکنون روش‌های متعددی برای کار با چندین future به طور همزمان دیده‌ایم. در ادامه، بررسی خواهیم کرد که چگونه می‌توانیم با چندین future به صورت متوالی در طول زمان با streams کار کنیم. با این حال، در ابتدا ممکن است بخواهید به چند نکته دیگر توجه کنید:

  • ما از یک Vec همراه با join_all استفاده کردیم تا منتظر بمانیم تمام futures در یک گروه به پایان برسند. چگونه می‌توانید از یک Vec برای پردازش یک گروه از futures به صورت متوالی استفاده کنید؟ معاوضه‌های انجام این کار چیست؟

  • به نوع futures::stream::FuturesUnordered از crate futures نگاهی بیندازید. استفاده از آن چگونه می‌تواند با استفاده از یک Vec متفاوت باشد؟ (نگران این نباشید که این نوع از بخش stream crate آمده است؛ با هر مجموعه‌ای از futures به خوبی کار می‌کند.)

Stream‌ها: Futures به صورت متوالی

تا اینجا در این فصل، بیشتر به آینده‌های فردی (individual futures) پایبند بوده‌ایم. یک استثنای بزرگ استفاده از کانال async بود. به یاد بیاورید چگونه در ابتدای این فصل در بخش “ارسال پیام” از گیرنده کانال async استفاده کردیم. متد async به نام recv یک دنباله از آیتم‌ها را در طول زمان تولید می‌کند. این یک نمونه از یک الگوی کلی‌تر به نام stream است.

ما پیش‌تر در فصل ۱۳ با یک توالی از آیتم‌ها مواجه شدیم، زمانی که به Iterator و متد next آن در بخش ویژگی Iterator و متد next پرداختیم، اما بین Iteratorها و گیرنده‌ی ناهمگام کانال‌ها دو تفاوت وجود دارد. تفاوت اول مربوط به زمان است: Iteratorها همگام (synchronous) هستند، در حالی که گیرنده‌ی کانال ناهمگام (asynchronous) است. تفاوت دوم در رابط برنامه‌نویسی کاربردی (API) است. وقتی به‌صورت مستقیم با Iterator کار می‌کنیم، از متد همگام next استفاده می‌کنیم. در stream‌ مربوط به trpl::Receiver، ما به جای آن متد ناهمگام recv را فراخوانی کردیم. با این وجود، این APIها از لحاظ کارکرد بسیار مشابه هستند، و این شباهت اتفاقی نیست. یک stream در واقع شکل ناهمگام پیمایش (iteration) است. در حالی که trpl::Receiver به‌طور خاص منتظر دریافت پیام می‌ماند، API عمومی‌تر stream بسیار گسترده‌تر است: این API، آیتم بعدی را به همان شیوه‌ای که Iterator فراهم می‌کند، ولی به‌صورت ناهمگام ارائه می‌دهد.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}
Listing 17-30: ایجاد یک stream از یک iterator و چاپ مقادیر آن

ما با یک آرایه از اعداد شروع می‌کنیم، آن را به یک iterator تبدیل کرده و سپس متد map را فراخوانی می‌کنیم تا تمام مقادیر را دو برابر کنیم. سپس با استفاده از تابع trpl::stream_from_iter، این iterator را به یک stream تبدیل می‌کنیم. در ادامه، با استفاده از حلقه while let، بر روی آیتم‌های موجود در stream که به مرور می‌رسند، حلقه می‌زنیم.

متأسفانه، وقتی سعی می‌کنیم این کد را اجرا کنیم، کامپایل نمی‌شود و به جای آن گزارش می‌دهد که متد next در دسترس نیست:

error[E0599]: no method named `next` found for struct `Iter` in the current scope
  --> src/main.rs:10:40
   |
10 |         while let Some(value) = stream.next().await {
   |                                        ^^^^
   |
   = note: the full type name has been written to 'file:///projects/async-await/target/debug/deps/async_await-575db3dd3197d257.long-type-14490787947592691573.txt'
   = note: consider using `--verbose` to print the full type name to the console
   = help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
   |
1  + use crate::trpl::StreamExt;
   |
1  + use futures_util::stream::stream::StreamExt;
   |
1  + use std::iter::Iterator;
   |
1  + use std::str::pattern::Searcher;
   |
help: there is a method `try_next` with a similar name
   |
10 |         while let Some(value) = stream.try_next().await {
   |                                        ~~~~~~~~

همان‌طور که این خروجی توضیح می‌دهد، دلیل خطای کامپایلر این است که برای استفاده از متد next باید ویژگی مناسب در دامنه باشد. با توجه به بحث‌هایی که تاکنون داشته‌ایم، ممکن است منطقی باشد که انتظار داشته باشید این ویژگی Stream باشد، اما در واقع StreamExt است. Ext که مخفف extension است، یک الگوی رایج در جامعه Rust برای گسترش یک ویژگی با ویژگی دیگر است.

ما در انتهای این فصل ویژگی‌های Stream و StreamExt را با جزئیات بیشتری توضیح خواهیم داد، اما فعلاً تنها چیزی که باید بدانید این است که ویژگی Stream یک رابط سطح پایین تعریف می‌کند که به طور مؤثری ویژگی‌های Iterator و Future را ترکیب می‌کند. StreamExt مجموعه‌ای از APIهای سطح بالاتر را روی Stream ارائه می‌دهد، از جمله متد next و همچنین متدهای کاربردی دیگر مشابه آنچه ویژگی Iterator ارائه می‌دهد. Stream و StreamExt هنوز بخشی از کتابخانه استاندارد Rust نیستند، اما بیشتر crateهای اکوسیستم از همین تعریف استفاده می‌کنند.

برای رفع خطای کامپایل، باید یک دستور use برای trpl::StreamExt اضافه کنیم، همان‌طور که در فهرست 17-31 آمده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}
Listing 17-31: استفاده موفق از یک iterator به‌عنوان پایه‌ای برای یک stream

با قرار دادن همه این قطعات در کنار هم، این کد به همان روشی که می‌خواهیم کار می‌کند! مهم‌تر از همه، اکنون که StreamExt در دامنه داریم، می‌توانیم از تمام متدهای کاربردی آن استفاده کنیم، درست مانند iteratorها. برای مثال، در فهرست 17-32، از متد filter برای فیلتر کردن همه چیز به جز مضرب‌های سه و پنج استفاده می‌کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = 1..101;
        let iter = values.map(|n| n * 2);
        let stream = trpl::stream_from_iter(iter);

        let mut filtered =
            stream.filter(|value| value % 3 == 0 || value % 5 == 0);

        while let Some(value) = filtered.next().await {
            println!("The value was: {value}");
        }
    });
}
Listing 17-32: فیلتر کردن یک Stream با استفاده از متد StreamExt::filter

البته این خیلی جالب نیست، چون می‌توانستیم همین کار را با iteratorهای معمولی و بدون هیچ async انجام دهیم. بیایید ببینیم چه کاری می‌توانیم انجام دهیم که منحصربه‌فرد برای stream‌ها باشد.

ترکیب Stream‌ها

بسیاری از مفاهیم به طور طبیعی به‌عنوان stream‌ها نمایش داده می‌شوند: آیتم‌هایی که در یک صف در دسترس می‌شوند، بخش‌هایی از داده که به صورت تدریجی از سیستم فایل خوانده می‌شوند وقتی مجموعه داده کامل برای حافظه کامپیوتر بیش از حد بزرگ است، یا داده‌هایی که به مرور زمان از طریق شبکه می‌رسند. چون stream‌ها نیز futures هستند، می‌توانیم از آن‌ها با هر نوع دیگر future استفاده کنیم و آن‌ها را به روش‌های جالبی ترکیب کنیم. برای مثال، می‌توانیم رویدادها را به صورت دسته‌ای جمع کنیم تا از ایجاد تعداد زیادی فراخوانی شبکه جلوگیری کنیم، تایم‌اوت‌هایی روی دنباله‌ای از عملیات‌های طولانی تنظیم کنیم، یا رویدادهای رابط کاربری را کنترل کنیم تا از انجام کارهای غیرضروری اجتناب کنیم.

بیایید با ساخت یک stream کوچک از پیام‌ها شروع کنیم که به‌عنوان یک جایگزین برای یک stream از داده‌هایی که ممکن است از یک WebSocket یا یک پروتکل ارتباطی بلادرنگ دیگر ببینیم، همان‌طور که در لیست ۱۷-۳۳ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages = get_messages();

        while let Some(message) = messages.next().await {
            println!("{message}");
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}
Listing 17-33: استفاده از گیرنده rx به‌عنوان یک ReceiverStream

ابتدا یک تابع به نام get_messages ایجاد می‌کنیم که impl Stream<Item = String> را بازمی‌گرداند. برای پیاده‌سازی آن، یک کانال async ایجاد می‌کنیم، بر روی ۱۰ حرف اول الفبای انگلیسی حلقه می‌زنیم، و آن‌ها را از طریق کانال ارسال می‌کنیم.

همچنین از یک نوع جدید به نام ReceiverStream استفاده می‌کنیم، که rx گیرنده از trpl::channel را به یک Stream با متد next تبدیل می‌کند. دوباره در main، از یک حلقه while let برای چاپ تمام پیام‌ها از stream استفاده می‌کنیم.

وقتی این کد را اجرا می‌کنیم، دقیقاً نتایجی را که انتظار داریم دریافت می‌کنیم:

Message: 'a'
Message: 'b'
Message: 'c'
Message: 'd'
Message: 'e'
Message: 'f'
Message: 'g'
Message: 'h'
Message: 'i'
Message: 'j'

دوباره، می‌توانستیم این کار را با API معمولی Receiver یا حتی API معمولی Iterator انجام دهیم، اما بیایید ویژگی‌ای اضافه کنیم که نیاز به streams داشته باشد: اضافه کردن یک تایم‌اوت که برای هر آیتم در stream اعمال شود، و یک تأخیر روی آیتم‌هایی که ارسال می‌کنیم، همان‌طور که در لیست ۱۷-۳۴ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};
use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}
Listing 17-34: استفاده از متد StreamExt::timeout برای تعیین یک محدودیت زمانی برای آیتم‌های موجود در یک stream

ابتدا یک تایم‌اوت به stream با استفاده از متد timeout اضافه می‌کنیم، که از ویژگی StreamExt می‌آید. سپس بدنه حلقه while let را به‌روزرسانی می‌کنیم، زیرا اکنون stream یک Result بازمی‌گرداند. حالت Ok نشان‌دهنده این است که یک پیام به‌موقع رسیده است؛ حالت Err نشان می‌دهد که تایم‌اوت قبل از رسیدن هر پیامی منقضی شده است. روی این نتیجه یک match انجام می‌دهیم و یا پیام را وقتی با موفقیت دریافت می‌کنیم چاپ می‌کنیم، یا اخطاری درباره تایم‌اوت چاپ می‌کنیم. در نهایت، توجه کنید که پس از اعمال تایم‌اوت به پیام‌ها، آن‌ها را pin می‌کنیم، زیرا ابزار تایم‌اوت یک stream تولید می‌کند که باید pin شود تا بتوان آن را poll کرد.

با این حال، چون بین پیام‌ها تأخیری وجود ندارد، این تایم‌اوت رفتار برنامه را تغییر نمی‌دهد. بیایید یک تأخیر متغیر به پیام‌هایی که ارسال می‌کنیم اضافه کنیم، همان‌طور که در لیست ۱۷-۳۵ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-35: ارسال پیام‌ها از طریق tx با یک تأخیر async بدون تبدیل get_messages به یک تابع async

برای خوابیدن بین پیام‌ها در تابع get_messages بدون مسدود کردن، باید از async استفاده کنیم. با این حال، نمی‌توانیم خود get_messages را به یک تابع async تبدیل کنیم، زیرا در این صورت یک Future<Output = Stream<Item = String>> به جای یک Stream<Item = String> بازمی‌گرداند. کاربر باید خود get_messages را منتظر بماند تا به stream دسترسی پیدا کند. اما به یاد داشته باشید: هر چیزی در یک آینده مشخص به‌صورت خطی اتفاق می‌افتد؛ همزمانی بین آینده‌ها اتفاق می‌افتد. انتظار برای get_messages نیاز دارد که تمام پیام‌ها را ارسال کند، از جمله خوابیدن بین ارسال هر پیام، قبل از بازگرداندن stream گیرنده. در نتیجه، زمان محدود بی‌فایده می‌شود. هیچ تأخیری در خود stream وجود نخواهد داشت: تمام تأخیرها قبل از در دسترس قرار گرفتن stream اتفاق می‌افتد.

در عوض، get_messages را به‌عنوان یک تابع معمولی که یک stream بازمی‌گرداند باقی می‌گذاریم و یک تسک برای مدیریت فراخوانی‌های async sleep ایجاد می‌کنیم.

نکته: فراخوانی spawn_task به این روش کار می‌کند زیرا ما از قبل runtime خود را تنظیم کرده‌ایم. فراخوانی این پیاده‌سازی خاص از spawn_task بدون تنظیم اولیه یک runtime باعث panic می‌شود. پیاده‌سازی‌های دیگر معاملات متفاوتی انتخاب می‌کنند: ممکن است یک runtime جدید ایجاد کنند و بنابراین از panic اجتناب کنند، اما با کمی سربار اضافی مواجه شوند، یا به سادگی راهی مستقل برای ایجاد تسک‌ها بدون ارجاع به یک runtime ارائه ندهند. باید مطمئن شوید که می‌دانید runtime شما چه معامله‌ای انتخاب کرده است و کد خود را بر این اساس بنویسید!

اکنون کد ما نتیجه بسیار جالب‌تری دارد! بین هر جفت پیام، یک خطا گزارش می‌شود: Problem: Elapsed(()).

Message: 'a'
Problem: Elapsed(())
Message: 'b'
Message: 'c'
Problem: Elapsed(())
Message: 'd'
Message: 'e'
Problem: Elapsed(())
Message: 'f'
Message: 'g'
Problem: Elapsed(())
Message: 'h'
Message: 'i'
Problem: Elapsed(())
Message: 'j'

تایم‌اوت از رسیدن پیام‌ها در نهایت جلوگیری نمی‌کند. ما همچنان تمام پیام‌های اصلی را دریافت می‌کنیم، زیرا کانال ما بدون محدودیت است: می‌تواند به اندازه‌ای که در حافظه جا شود پیام‌ها را نگه دارد. اگر پیام قبل از تایم‌اوت نرسد، handler stream ما آن را مدیریت می‌کند، اما وقتی دوباره stream را poll کند، ممکن است پیام اکنون رسیده باشد.

اگر به رفتار متفاوتی نیاز دارید، می‌توانید از انواع دیگر کانال‌ها یا به طور کلی انواع دیگر streamها استفاده کنید. بیایید یکی از این موارد را در عمل ببینیم، با ترکیب یک stream از فواصل زمانی با این stream از پیام‌ها.

ترکیب Streamها

ابتدا، یک stream دیگر ایجاد می‌کنیم که اگر به طور مستقیم اجرا شود، هر میلی‌ثانیه یک آیتم ارسال می‌کند. برای سادگی، می‌توانیم از تابع sleep برای ارسال یک پیام با تأخیر استفاده کنیم و آن را با همان روشی که در get_messages استفاده کردیم—ایجاد یک stream از یک کانال—ترکیب کنیم. تفاوت این است که این بار، می‌خواهیم تعداد فواصل زمانی که گذشته‌اند را بازگردانیم، بنابراین نوع بازگشتی impl Stream<Item = u32> خواهد بود، و می‌توانیم تابع را get_intervals بنامیم (نگاه کنید به لیست ۱۷-۳۶).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-36: ایجاد یک stream با یک شمارنده که هر میلی‌ثانیه یک بار ارسال می‌شود

ابتدا یک متغیر count را درون task تعریف می‌کنیم. (می‌توانستیم آن را خارج از task نیز تعریف کنیم، اما محدود کردن دامنه هر متغیر داده‌شده واضح‌تر است.) سپس یک حلقه بی‌نهایت ایجاد می‌کنیم. در هر تکرار حلقه، به صورت ناهمزمان به مدت یک میلی‌ثانیه می‌خوابد، مقدار count را افزایش می‌دهد و سپس آن را از طریق کانال ارسال می‌کند. از آنجا که همه این‌ها درون taskی که توسط spawn_task ایجاد شده است قرار دارد، همه آن—از جمله حلقه بی‌نهایت—همراه با runtime پاک‌سازی می‌شود.

این نوع حلقه بی‌نهایت، که تنها زمانی به پایان می‌رسد که کل runtime از بین برود، در async Rust نسبتاً رایج است: بسیاری از برنامه‌ها نیاز دارند که به طور نامحدود اجرا شوند. با async، این کار چیزی دیگر را مسدود نمی‌کند، تا زمانی که حداقل یک نقطه انتظار (await point) در هر تکرار از حلقه وجود داشته باشد.

حالا، درون بلوک async تابع اصلی ما، می‌توانیم تلاش کنیم که streamهای messages و intervals را با هم ترکیب کنیم، همان‌طور که در لیست ۱۷-۳۷ نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals();
        let merged = messages.merge(intervals);

        while let Some(result) = merged.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-37: تلاش برای ترکیب streamهای messages و intervals

ابتدا get_intervals را فراخوانی می‌کنیم. سپس streamهای messages و intervals را با استفاده از متد merge ترکیب می‌کنیم. این متد چندین stream را به یک stream ترکیب می‌کند که آیتم‌ها را از هر یک از streamهای منبع، به محض در دسترس بودن، تولید می‌کند، بدون اینکه ترتیب خاصی را اعمال کند. در نهایت، به جای اینکه روی messages حلقه بزنیم، روی این stream ترکیبی حلقه می‌زنیم.

در این مرحله، نه messages و نه intervals نیازی به pin یا mutable بودن ندارند، زیرا هر دو در یک stream واحد به نام merged ترکیب می‌شوند. با این حال، این فراخوانی به merge کامپایل نمی‌شود! (فراخوانی next در حلقه while let هم کامپایل نمی‌شود، اما به آن برمی‌گردیم.) دلیل آن این است که این دو stream انواع مختلفی دارند. stream messages نوع Timeout<impl Stream<Item = String>> دارد، جایی که Timeout نوعی است که ویژگی Stream را برای فراخوانی timeout پیاده‌سازی می‌کند. stream intervals نوع impl Stream<Item = u32> دارد. برای ترکیب این دو stream، باید یکی از آن‌ها را به نوع دیگری تبدیل کنیم. ما stream intervals را بازبینی می‌کنیم، زیرا messages قبلاً در قالب اصلی مورد نظر ما است و باید خطاهای timeout را مدیریت کند (نگاه کنید به لیست ۱۷-۳۸).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-38: هماهنگ کردن نوع‌های stream intervals با نوع stream messages

ابتدا می‌توانیم از متد کمکی map برای تبدیل intervals به یک رشته استفاده کنیم. دوم، نیاز داریم که Timeout از messages را مدیریت کنیم. با این حال، چون واقعاً نمی‌خواهیم تایم‌اوتی برای intervals داشته باشیم، می‌توانیم یک تایم‌اوت ایجاد کنیم که طولانی‌تر از مدت‌های دیگر مورد استفاده ما باشد. در اینجا، یک تایم‌اوت ۱۰ ثانیه‌ای با استفاده از Duration::from_secs(10) ایجاد می‌کنیم. در نهایت، نیاز داریم که stream را متغیر (mutable) کنیم تا فراخوانی‌های next در حلقه while let بتوانند روی stream تکرار کنند و آن را pin کنیم تا این کار ایمن باشد. این ما را تقریباً به جایی که باید برسیم می‌رساند. همه چیز از نظر نوع بررسی می‌شود. اما اگر این کد را اجرا کنید، دو مشکل وجود خواهد داشت. اول، هیچ‌گاه متوقف نمی‌شود! باید با زدن ctrl-c آن را متوقف کنید. دوم، پیام‌های الفبای انگلیسی در میان تمام پیام‌های شمارنده interval دفن خواهند شد:

--snip--
Interval: 38
Interval: 39
Interval: 40
Message: 'a'
Interval: 41
Interval: 42
Interval: 43
--snip--

لیست ۱۷-۳۹ یک روش برای حل این دو مشکل آخر را نشان می‌دهد.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .throttle(Duration::from_millis(100))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-39: استفاده از throttle و take برای مدیریت streams ترکیب‌شده

ابتدا از متد throttle روی stream intervals استفاده می‌کنیم تا این stream باعث غرق شدن stream messages نشود. Throttling روشی برای محدود کردن نرخ فراخوانی یک تابع است—یا در این مورد، محدود کردن نرخ poll کردن یک stream. یک بار در هر ۱۰۰ میلی‌ثانیه کافی خواهد بود، زیرا تقریباً به همان اندازه پیام‌های ما می‌رسند.

برای محدود کردن تعداد آیتم‌هایی که از یک stream قبول می‌کنیم، متد take را روی stream merged اعمال می‌کنیم، زیرا می‌خواهیم خروجی نهایی را محدود کنیم، نه فقط یکی از streamها را.

اکنون وقتی برنامه را اجرا می‌کنیم، پس از دریافت ۲۰ آیتم از stream متوقف می‌شود و intervals باعث غرق شدن messages نمی‌شود. همچنین، ما دیگر Interval: 100 یا Interval: 200 و موارد مشابه را نمی‌بینیم، بلکه به جای آن Interval: 1، Interval: 2 و به همین ترتیب دریافت می‌کنیم—حتی اگر یک stream منبع داریم که می‌تواند هر میلی‌ثانیه یک رویداد تولید کند. دلیل این است که فراخوانی throttle یک stream جدید تولید می‌کند که stream اصلی را بسته‌بندی می‌کند تا stream اصلی فقط با نرخ throttle و نه با نرخ “ذاتی” خود poll شود. ما یک سری پیام interval غیرقابل پردازش نداریم که انتخاب کرده باشیم آن‌ها را نادیده بگیریم. بلکه، ما هرگز آن پیام‌های interval را در وهله اول تولید نمی‌کنیم! این همان “تنبلی” ذاتی futures در Rust است که دوباره به کار گرفته می‌شود و به ما اجازه می‌دهد ویژگی‌های عملکردی خود را انتخاب کنیم.

Interval: 1
Message: 'a'
Interval: 2
Interval: 3
Problem: Elapsed(())
Interval: 4
Message: 'b'
Interval: 5
Message: 'c'
Interval: 6
Interval: 7
Problem: Elapsed(())
Interval: 8
Message: 'd'
Interval: 9
Message: 'e'
Interval: 10
Interval: 11
Problem: Elapsed(())
Interval: 12

تنها یک مورد باقی مانده که باید مدیریت کنیم: خطاها! با هر دو stream مبتنی بر کانال، فراخوانی‌های send ممکن است در صورتی که طرف دیگر کانال بسته شود، با شکست مواجه شوند—و این به نحوه اجرای runtime برای futures که stream را تشکیل می‌دهند بستگی دارد. تاکنون این احتمال را با فراخوانی unwrap نادیده گرفته‌ایم، اما در یک برنامه با رفتار مناسب، باید به‌طور صریح خطا را مدیریت کنیم، حداقل با پایان دادن به حلقه تا دیگر پیام ارسال نکنیم. لیست ۱۷-۴۰ یک استراتژی ساده برای مدیریت خطا را نشان می‌دهد: چاپ مشکل و سپس break از حلقه‌ها.

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-40: مدیریت خطاها و خاتمه دادن به حلقه‌ها

همان‌طور که معمول است، روش درست برای مدیریت یک خطای ارسال پیام می‌تواند متفاوت باشد؛ فقط مطمئن شوید که یک استراتژی دارید.

اکنون که مقدار زیادی از کد async را در عمل مشاهده کردیم، بیایید کمی به عقب برگردیم و به جزئیات نحوه کارکرد Future، Stream و ویژگی‌های کلیدی دیگر که Rust برای اجرای async استفاده می‌کند، بپردازیم.

بررسی دقیق‌تر ویژگی‌ها برای Async

در طول این فصل، از ویژگی‌های Future، Pin، Unpin، Stream، و StreamExt به روش‌های مختلفی استفاده کرده‌ایم. تاکنون، از ورود بیش از حد به جزئیات نحوه کارکرد یا چگونگی تطبیق آن‌ها با یکدیگر اجتناب کرده‌ایم، که برای بیشتر کارهای روزمره شما با Rust کافی است. با این حال، گاهی اوقات با موقعیت‌هایی مواجه می‌شوید که نیاز دارید کمی بیشتر از این جزئیات را بفهمید. در این بخش، به اندازه‌ای به این موضوع می‌پردازیم که در این سناریوها کمک کند، در حالی که بررسی عمیق‌تر را به مستندات دیگر می‌سپاریم.

ویژگی Future

بیایید با بررسی دقیق‌تر نحوه عملکرد ویژگی Future شروع کنیم. در اینجا نحوه تعریف آن در Rust آمده است:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

این تعریف Trait شامل چندین نوع جدید و همچنین نحوی است که قبلاً ندیده‌ایم، بنابراین بیایید قطعه به قطعه آن را بررسی کنیم.

ابتدا، نوع وابسته Output در ویژگی Future مشخص می‌کند که نتیجه future چه خواهد بود. این شبیه به نوع وابسته Item در ویژگی Iterator است. دوم، ویژگی Future همچنین متد poll را دارد که یک مرجع خاص Pin برای پارامتر self و یک مرجع متغیر به نوع Context می‌گیرد و یک Poll<Self::Output> بازمی‌گرداند. در ادامه درباره Pin و Context بیشتر صحبت خواهیم کرد. فعلاً بیایید روی چیزی که متد بازمی‌گرداند، یعنی نوع Poll، تمرکز کنیم:

#![allow(unused)]
fn main() {
enum Poll<T> {
    Ready(T),
    Pending,
}
}

نوع Poll شبیه به یک Option است. این نوع دو حالت دارد: یکی Ready(T) که شامل یک مقدار است و دیگری Pending که شامل مقدار نیست. با این حال، Poll معنای کاملاً متفاوتی از Option دارد! حالت Pending نشان می‌دهد که future هنوز کارهایی برای انجام دادن دارد، بنابراین فراخواننده باید بعداً دوباره بررسی کند. حالت Ready نشان می‌دهد که future کار خود را به پایان رسانده و مقدار T در دسترس است.

نکته: برای بیشتر futures، فراخواننده نباید پس از اینکه future مقدار Ready بازگرداند، دوباره poll را فراخوانی کند. بسیاری از futures اگر پس از آماده شدن دوباره poll شوند، دچار وحشت (panic) می‌شوند. futuresی که ایمن برای poll دوباره هستند، به‌طور صریح این موضوع را در مستندات خود ذکر خواهند کرد. این شبیه به نحوه رفتار Iterator::next است.

وقتی کدی را می‌بینید که از await استفاده می‌کند، Rust آن را در پشت صحنه به کدی که poll را فراخوانی می‌کند کامپایل می‌کند. اگر به لیست ۱۷-۴ که در آن عنوان صفحه برای یک URL واحد پس از حل‌شدن چاپ شد، نگاهی بیندازید، Rust آن را به چیزی که (اگرچه دقیقاً نه، اما تقریباً) شبیه به این است کامپایل می‌کند:

match page_title(url).poll() {
    Ready(page_title) => match page_title {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
    Pending => {
        // But what goes here?
    }
}

وقتی که future هنوز در حالت Pending است، چه کاری باید انجام دهیم؟ نیاز داریم به نوعی دوباره امتحان کنیم، و این کار را بارها تکرار کنیم، تا زمانی که future در نهایت آماده شود. به عبارت دیگر، نیاز به یک حلقه داریم:

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // continue
        }
    }
}

اگر Rust دقیقاً این کد را کامپایل می‌کرد، هر await مسدودکننده (blocking) می‌شد—دقیقاً برعکس چیزی که می‌خواستیم! در عوض، Rust اطمینان حاصل می‌کند که حلقه بتواند کنترل را به چیزی واگذار کند که بتواند کار روی این future را متوقف کرده، روی futures دیگر کار کند، و سپس دوباره این یکی را بررسی کند. همان‌طور که دیدیم، این وظیفه یک runtime async است، و این برنامه‌ریزی و هماهنگی یکی از وظایف اصلی آن است.

در ابتدای فصل، درباره انتظار برای rx.recv صحبت کردیم. فراخوانی recv یک future بازمی‌گرداند و منتظر شدن برای future آن را poll می‌کند. اشاره کردیم که یک runtime future را تا زمانی که آماده شود—چه با Some(message) یا با None در صورت بسته شدن کانال—متوقف می‌کند. با درک عمیق‌تر از ویژگی Future و به‌طور خاص Future::poll، می‌توانیم ببینیم این چگونه کار می‌کند. وقتی future مقدار Poll::Pending بازمی‌گرداند، runtime می‌داند که آماده نیست. برعکس، وقتی poll مقدار Poll::Ready(Some(message)) یا Poll::Ready(None) بازمی‌گرداند، runtime می‌داند که future آماده است و آن را پیش می‌برد.

جزئیات دقیق نحوه انجام این کار توسط یک runtime فراتر از محدوده این کتاب است، اما نکته کلیدی این است که مکانیک پایه‌ای futures را ببینیم: یک runtime هر future که مسئول آن است را poll می‌کند و وقتی هنوز آماده نیست، future را دوباره به حالت خواب می‌برد.

ویژگی‌های Pin و Unpin

وقتی مفهوم pinning را در لیست ۱۷-۱۶ معرفی کردیم، با یک پیام خطای بسیار پیچیده مواجه شدیم. در اینجا بخش مرتبط با آن دوباره آمده است:

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

این پیام خطا نه تنها به ما می‌گوید که باید مقادیر را pin کنیم، بلکه دلیل نیاز به pinning را نیز توضیح می‌دهد. تابع trpl::join_all یک ساختار به نام JoinAll بازمی‌گرداند. این ساختار به نوعی عمومی به نام F وابسته است که محدود به پیاده‌سازی ویژگی Future است. منتظر شدن مستقیم یک future با await، future را به‌طور ضمنی pin می‌کند. به همین دلیل نیازی نیست که از pin! در همه جاهایی که می‌خواهیم برای futures منتظر بمانیم، استفاده کنیم.

با این حال، ما اینجا مستقیماً منتظر یک future نیستیم. در عوض، یک future جدید به نام JoinAll می‌سازیم با ارسال مجموعه‌ای از futures به تابع join_all. امضای join_all نیاز دارد که نوع آیتم‌های مجموعه، ویژگی Future را پیاده‌سازی کنند، و Box<T> فقط در صورتی ویژگی Future را پیاده‌سازی می‌کند که T که بسته‌بندی می‌کند، یک future باشد که ویژگی Unpin را پیاده‌سازی کرده است.

این اطلاعات زیادی برای هضم کردن است! برای درک واقعی آن، بیایید کمی بیشتر به نحوه کار واقعی ویژگی Future، به‌ویژه در ارتباط با pinning، بپردازیم.

دوباره به تعریف ویژگی Future نگاه کنید:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // متد مورد نیاز
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

پارامتر cx و نوع آن، Context، کلید اصلی برای این است که یک runtime چگونه می‌داند چه زمانی یک future خاص را بررسی کند، در حالی که همچنان تنبلی (lazy) باقی می‌ماند. باز هم، جزئیات نحوه کار این فرآیند فراتر از محدوده این فصل است، و معمولاً تنها زمانی که بخواهید یک پیاده‌سازی سفارشی برای Future بنویسید، نیاز به فکر کردن به این موضوع دارید. در عوض، ما بر روی نوع self تمرکز می‌کنیم، زیرا این اولین باری است که یک متد با یک نوع مشخص برای self روبرو می‌شویم. یک نوع مشخص برای self مانند نوع‌های مشخص برای سایر پارامترهای تابع عمل می‌کند، اما با دو تفاوت کلیدی:

  • به Rust می‌گوید که نوع self برای فراخوانی متد باید چه باشد.

  • نمی‌تواند هر نوعی باشد. این نوع به نوعی که متد روی آن پیاده‌سازی شده است، یا یک مرجع یا اشاره‌گر هوشمند به آن نوع، یا یک Pin که یک مرجع به آن نوع را بسته‌بندی می‌کند، محدود است.

در فصل ۱۸ بیشتر درباره این سینتکس صحبت خواهیم کرد. فعلاً کافی است بدانیم که اگر بخواهیم یک future را poll کنیم تا بررسی کنیم که آیا Pending یا Ready(Output) است، به یک مرجع متغیر بسته‌بندی‌شده در Pin برای آن نوع نیاز داریم.

Pin یک بسته‌بندی برای انواع اشاره‌گر مانند &، &mut، Box، و Rc است. (به‌طور فنی، Pin با نوع‌هایی کار می‌کند که ویژگی‌های Deref یا DerefMut را پیاده‌سازی می‌کنند، اما این به طور مؤثر معادل کار با اشاره‌گرها است.) Pin خودش یک اشاره‌گر نیست و هیچ رفتاری مانند Rc و Arc که شمارش مرجع انجام می‌دهند ندارد؛ این صرفاً یک ابزار است که کامپایلر می‌تواند برای اعمال محدودیت‌ها در استفاده از اشاره‌گرها استفاده کند.

به یاد آوردن این که await بر اساس فراخوانی‌های poll پیاده‌سازی شده است، شروع به توضیح پیام خطایی که قبلاً دیدیم می‌کند، اما آن پیام در مورد Unpin بود، نه Pin. پس دقیقاً چگونه Pin با Unpin مرتبط است، و چرا Future نیاز دارد که self در یک نوع Pin باشد تا بتواند poll را فراخوانی کند؟

به یاد بیاورید که در اوایل این فصل، یک سری از نقاط انتظار (await points) در یک future به یک ماشین حالت کامپایل می‌شوند، و کامپایلر اطمینان حاصل می‌کند که این ماشین حالت تمام قوانین معمول ایمنی Rust، از جمله قرض‌گیری و مالکیت، را دنبال می‌کند. برای اینکه این کار انجام شود، Rust بررسی می‌کند که چه داده‌ای بین یک نقطه انتظار و یا نقطه انتظار بعدی یا پایان بلوک async مورد نیاز است. سپس یک حالت متناظر در ماشین حالت کامپایل‌شده ایجاد می‌کند. هر حالت دسترسی لازم به داده‌هایی که در آن بخش از کد منبع استفاده می‌شوند را دریافت می‌کند، چه با گرفتن مالکیت آن داده‌ها یا با دریافت یک مرجع متغیر یا غیرمتغیر به آن.

تا اینجا خوب است: اگر در مورد مالکیت یا مراجع در یک بلوک async خطایی داشته باشیم، borrow checker به ما اطلاع می‌دهد. اما وقتی بخواهیم futureای که به آن بلوک مربوط می‌شود را جابه‌جا کنیم—مثلاً آن را به یک ساختار داده push کنیم تا به‌عنوان یک iterator با join_all استفاده شود یا آن را از یک تابع بازگردانیم—مسائل پیچیده‌تر می‌شوند.

وقتی یک future را جابه‌جا می‌کنیم—چه با push کردن آن به یک ساختار داده برای استفاده به‌عنوان iterator با join_all یا با بازگرداندن آن از یک تابع—این در واقع به معنای جابه‌جا کردن ماشین حالتی است که Rust برای ما ایجاد می‌کند. و برخلاف بیشتر انواع دیگر در Rust، futureهایی که Rust برای بلوک‌های async ایجاد می‌کند، می‌توانند در فیلدهای هر حالت معین، دارای مراجع به خودشان باشند، همان‌طور که در تصویر ساده‌شده‌ای که در شکل ۱۷-۴ نشان داده شده است.

A single-column, three-row table representing a future, fut1, which has data values 0 and 1 in the first two rows and an arrow pointing from the third row back to the second row, representing an internal reference within the future.
شکل 17-4: یک نوع داده خودارجاعی.

به‌طور پیش‌فرض، هر شیئی که مرجعی به خودش دارد، جابه‌جا کردن آن ناایمن است، زیرا مراجع همیشه به آدرس حافظه واقعی چیزی که به آن اشاره می‌کنند اشاره دارند (نگاه کنید به شکل ۱۷-۵). اگر خود ساختار داده را جابه‌جا کنید، آن مراجع داخلی همچنان به مکان قدیمی اشاره می‌کنند. با این حال، آن مکان حافظه اکنون نامعتبر است. از یک طرف، مقدار آن هنگام ایجاد تغییرات در ساختار داده به‌روزرسانی نمی‌شود. از طرف دیگر—و مهم‌تر—کامپیوتر اکنون می‌تواند آن مکان حافظه را برای مقاصد دیگر بازاستفاده کند! ممکن است بعداً داده‌هایی کاملاً نامرتبط بخوانید.

Two tables, depicting two futures, fut1 and fut2, each of which has one column and three rows, representing the result of having moved a future out of fut1 into fut2. The first, fut1, is grayed out, with a question mark in each index, representing unknown memory. The second, fut2, has 0 and 1 in the first and second rows and an arrow pointing from its third row back to the second row of fut1, representing a pointer that is referencing the old location in memory of the future before it was moved.
شکل ۱۷-۵: نتیجه ناایمن جابه‌جایی یک نوع داده که به خودش ارجاع دارد

از نظر تئوری، کامپایلر Rust می‌تواند سعی کند هر مرجع به یک شیء را هر زمان که جابه‌جا می‌شود، به‌روزرسانی کند، اما این کار می‌تواند سربار عملکرد زیادی ایجاد کند، به‌ویژه اگر یک شبکه کامل از مراجع نیاز به به‌روزرسانی داشته باشد. اگر بتوانیم به جای آن مطمئن شویم که ساختار داده مورد نظر در حافظه جابه‌جا نمی‌شود، نیازی به به‌روزرسانی مراجع نخواهیم داشت. این دقیقاً همان چیزی است که borrow checker در Rust نیاز دارد: در کد ایمن، از جابه‌جا کردن هر آیتمی که مرجع فعالی به آن دارد جلوگیری می‌کند.

Pin بر اساس این اصل عمل می‌کند و تضمین دقیقی که نیاز داریم را ارائه می‌دهد. وقتی یک مقدار را با بسته‌بندی یک اشاره‌گر به آن مقدار در Pin pin می‌کنیم، دیگر نمی‌تواند جابه‌جا شود. بنابراین، اگر Pin<Box<SomeType>> داشته باشید، در واقع مقدار SomeType را pin می‌کنید، نه اشاره‌گر Box. شکل ۱۷-۶ این فرآیند را نشان می‌دهد.

Three boxes laid out side by side. The first is labeled “Pin”, the second “b1”, and the third “pinned”. Within “pinned” is a table labeled “fut”, with a single column; it represents a future with cells for each part of the data structure. Its first cell has the value “0”, its second cell has an arrow coming out of it and pointing to the fourth and final cell, which has the value “1” in it, and the third cell has dashed lines and an ellipsis to indicate there may be other parts to the data structure. All together, the “fut” table represents a future which is self-referential. An arrow leaves the box labeled “Pin”, goes through the box labeled “b1” and has terminates inside the “pinned” box at the “fut” table.
شکل 17-6: pin کردن یک `Box` که به یک نوع آینده خودارجاعی اشاره می‌کند.

در واقع، اشاره‌گر (Pointer) Box هنوز می‌تواند به‌طور آزاد جابه‌جا شود. به یاد داشته باشید: ما به مطمئن شدن از اینکه داده‌ای که در نهایت به آن ارجاع داده می‌شود در جای خود باقی می‌ماند اهمیت می‌دهیم. اگر یک اشاره‌گر (Pointer) جابه‌جا شود اما داده‌ای که به آن اشاره می‌کند در همان مکان باقی بماند، همانطور که در شکل 17-7 نشان داده شده است، هیچ مشکلی پیش نمی‌آید. (چگونگی انجام این کار با یک Pin که یک Box را می‌پیچد فراتر از بحث این بخش خاص است، اما می‌تواند تمرین خوبی باشد! اگر به مستندات نوع‌ها و همچنین ماژول std::pin نگاه کنید، ممکن است بتوانید بفهمید چگونه این کار را انجام دهید.) نکته کلیدی این است که نوع خودارجاعی خود نمی‌تواند جابه‌جا شود، زیرا همچنان pin شده است.

Four boxes laid out in three rough columns, identical to the previous diagram with a change to the second column. Now there are two boxes in the second column, labeled “b1” and “b2”, “b1” is grayed out, and the arrow from “Pin” goes through “b2” instead of “b1”, indicating that the pointer has moved from “b1” to “b2”, but the data in “pinned” has not moved.
شکل 17-7: جابه‌جایی یک `Box` که به یک نوع آینده خودارجاعی اشاره می‌کند.

با این حال، اکثر نوع‌ها کاملاً ایمن هستند که در حافظه جابه‌جا شوند، حتی اگر درون یک پوشش Pin قرار داشته باشند. ما فقط زمانی نیاز داریم به موضوع pinning فکر کنیم که آیتم‌ها دارای رفرانس‌های داخلی باشند. مقادیر اولیه‌ای مثل اعداد و Booleanها ایمن هستند، چون به‌وضوح هیچ رفرانس داخلی‌ای ندارند. بیشتر نوع‌هایی که معمولاً در Rust با آن‌ها کار می‌کنید نیز رفرانس داخلی ندارند. برای مثال، شما می‌توانید یک Vec را بدون نگرانی جابه‌جا کنید.

با توجه به چیزهایی که تا این‌جا دیده‌ایم، اگر یک Pin<Vec<String>> داشته باشید، مجبورید تمام عملیات را از طریق APIهای امن اما محدودکننده‌ای که Pin ارائه می‌دهد انجام دهید، حتی با این‌که یک Vec<String> همیشه ایمن است که جابه‌جا شود—مشروط به این‌که رفرانس دیگری به آن وجود نداشته باشد. ما به روشی نیاز داریم تا به کامپایلر اعلام کنیم که در چنین مواردی جابه‌جایی آیتم‌ها مشکلی ندارد—و این دقیقاً جایی است که Unpin وارد می‌شود.

Unpin یک ویژگی علامت‌گذار (marker trait) است، مشابه ویژگی‌های Send و Sync که در فصل ۱۶ دیدیم، و بنابراین هیچ عملکردی از خود ندارد. ویژگی‌های علامت‌گذار فقط برای این وجود دارند که به کامپایلر بگویند استفاده از نوعی که یک ویژگی خاص را پیاده‌سازی می‌کند در یک زمینه خاص ایمن است. Unpin به کامپایلر اطلاع می‌دهد که یک نوع خاص نیازی به تضمین اینکه مقدار مربوطه به‌صورت ایمن جابه‌جا می‌شود، ندارد.

مشابه Send و Sync، کامپایلر به‌طور خودکار Unpin را برای تمام انواعی که می‌تواند ثابت کند ایمن هستند، پیاده‌سازی می‌کند. یک مورد خاص، دوباره مشابه Send و Sync، این است که Unpin برای یک نوع پیاده‌سازی نمی‌شود. نشانه‌گذاری برای این حالت به شکل impl !Unpin for SomeType است، که در آن SomeType نام نوعی است که باید آن تضمین‌ها را برای ایمن بودن، هر زمان که اشاره‌گری به آن نوع در یک Pin استفاده می‌شود، حفظ کند.

به عبارت دیگر، دو نکته در مورد رابطه بین Pin و Unpin باید در نظر داشته باشید. اول، Unpin حالت “معمولی” است و !Unpin حالت خاص. دوم، اینکه آیا یک نوع ویژگی Unpin یا !Unpin را پیاده‌سازی می‌کند فقط زمانی اهمیت دارد که در حال استفاده از یک اشاره‌گر pin شده به آن نوع مانند Pin<&mut SomeType> باشید.

برای روشن‌تر کردن این موضوع، به یک String فکر کنید: این نوع دارای طول و کاراکترهای Unicode است که آن را تشکیل می‌دهند. ما می‌توانیم یک String را در Pin بسته‌بندی کنیم، همان‌طور که در شکل ۱۷-۸ دیده می‌شود. با این حال، String به طور خودکار ویژگی Unpin را پیاده‌سازی می‌کند، همان‌طور که بیشتر انواع دیگر در Rust این کار را انجام می‌دهند.

Concurrent work flow
شکل ۱۷-۸: Pin کردن یک `String`؛ خط نقطه‌چین نشان می‌دهد که `String` ویژگی `Unpin` را پیاده‌سازی می‌کند و بنابراین pin نشده است.

در نتیجه، می‌توانیم کارهایی انجام دهیم که اگر String ویژگی !Unpin را پیاده‌سازی می‌کرد غیرقانونی بود، مانند جایگزین کردن یک رشته با رشته‌ای دیگر در همان مکان حافظه، همان‌طور که در شکل ۱۷-۹ نشان داده شده است. این کار قرارداد Pin را نقض نمی‌کند، زیرا String هیچ مرجع داخلی ندارد که جابه‌جایی آن را ناایمن کند! این دقیقاً دلیلی است که ویژگی Unpin را به جای !Unpin پیاده‌سازی می‌کند.

Concurrent work flow
شکل 17-9: جایگزینی یک String با یک String کاملاً متفاوت در حافظه.

اکنون به‌اندازه کافی می‌دانیم تا خطاهایی که برای آن فراخوانی join_all در فهرست 17-17 گزارش شدند را درک کنیم. ما در ابتدا سعی کردیم آینده‌های تولیدشده توسط بلوک‌های async را به یک Vec<Box<dyn Future<Output = ()>>> منتقل کنیم، اما همان‌طور که دیدیم، این آینده‌ها ممکن است ارجاعات داخلی داشته باشند، بنابراین ویژگی Unpin را پیاده‌سازی نمی‌کنند. آن‌ها نیاز به pin شدن دارند، و سپس می‌توانیم نوع Pin را به Vec ارسال کنیم، با اطمینان از اینکه داده‌های زیربنایی در آینده‌ها جابه‌جا نخواهند شد.

Pin و Unpin بیشتر برای ساخت کتابخانه‌های سطح پایین یا وقتی که خودتان یک runtime می‌سازید مهم هستند، نه برای کد روزمره راست. وقتی این Traits را در پیام‌های خطا مشاهده می‌کنید، اکنون ایده بهتری از نحوه رفع کد خواهید داشت!

نکته: این ترکیب Pin و Unpin اجازه می‌دهد که یک کلاس کامل از نوع‌های پیچیده در راست ایمن باشند که در غیر این صورت به دلیل خودارجاعی بودن دشوار برای پیاده‌سازی هستند. نوع‌هایی که نیاز به Pin دارند بیشتر در راست async امروزی ظاهر می‌شوند، اما ممکن است—بسیار به‌ندرت!—در زمینه‌های دیگر نیز ببینید.

جزئیات نحوه کار Pin و Unpin و قوانینی که باید رعایت کنند، به‌طور گسترده در مستندات API برای std::pin پوشش داده شده‌اند، بنابراین اگر می‌خواهید آن‌ها را عمیق‌تر درک کنید، این مکان خوبی برای شروع است.

اگر می‌خواهید بفهمید که “در پشت صحنه” چگونه کار می‌کنند، کتاب رسمی برنامه‌نویسی ناهمگام در راست پاسخگوی شماست:

The Stream Trait

اکنون که درک عمیق‌تری از Traits‌های Future، Pin، و Unpin داریم، می‌توانیم توجه خود را به Trait Stream معطوف کنیم. همانطور که در بخش معرفی streams توضیح داده شد، streams مشابه iteratorهای ناهمگام هستند. برخلاف Iterator و Future، در زمان نگارش این متن، تعریف Stream در کتابخانه استاندارد وجود ندارد، اما یک تعریف بسیار رایج از crate futures وجود دارد که در سراسر اکوسیستم استفاده می‌شود.

بیایید تعاریف Traits‌های Iterator و Future را مرور کنیم تا بتوانیم تصور کنیم یک Trait Stream که این دو را ترکیب می‌کند چگونه ممکن است به نظر برسد. از Iterator، مفهوم یک توالی را داریم: متد next آن یک Option<Self::Item> فراهم می‌کند. از Future، مفهوم آماده شدن در طول زمان را داریم: متد poll آن یک Poll<Self::Output> فراهم می‌کند. برای نمایش یک توالی از آیتم‌هایی که در طول زمان آماده می‌شوند، یک Trait Stream تعریف می‌کنیم که این ویژگی‌ها را ترکیب می‌کند:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Trait Stream یک نوع مرتبط به نام Item برای نوع آیتم‌هایی که توسط stream تولید می‌شوند تعریف می‌کند. این مشابه با Iterator است: ممکن است تعداد این آیتم‌ها صفر تا بی‌نهایت باشد، برخلاف Future که همیشه یک Output واحد دارد (حتی اگر نوع واحد () باشد).

Stream همچنین یک متد برای دریافت این آیتم‌ها تعریف می‌کند. ما آن را poll_next می‌نامیم تا واضح باشد که این متد به همان روشی که Future::poll بررسی می‌کند، آیتم‌ها را بررسی می‌کند و به همان روشی که Iterator::next یک توالی از آیتم‌ها تولید می‌کند، آیتم‌ها را تولید می‌کند. نوع بازگشتی آن Poll را با Option ترکیب می‌کند. نوع خارجی Poll است، زیرا باید برای آماده بودن بررسی شود، همان‌طور که یک آینده بررسی می‌شود. نوع داخلی Option است، زیرا باید نشان دهد که آیا پیام‌های بیشتری وجود دارد یا نه، همان‌طور که یک iterator انجام می‌دهد.

چیزی بسیار مشابه با این احتمالاً در نهایت به‌عنوان بخشی از کتابخانه استاندارد راست استانداردسازی خواهد شد. در حال حاضر، این Trait بخشی از ابزار اکثر runtime‌ها است، بنابراین می‌توانید روی آن حساب کنید و همه چیزهایی که در ادامه می‌بینید عموماً قابل اعمال هستند!

با این حال، در مثالی که در بخش مربوط به streams دیدیم، ما از poll_next یا Stream استفاده نکردیم، بلکه از next و StreamExt استفاده کردیم. البته می‌توانیم مستقیماً از API poll_next استفاده کنیم و ماشین‌های حالت Stream خود را با دست بنویسیم، همان‌طور که می‌توانیم مستقیماً از طریق متد poll با آینده‌ها کار کنیم. اما استفاده از await بسیار دلپذیرتر است، بنابراین Trait StreamExt متد next را فراهم می‌کند تا بتوانیم دقیقاً این کار را انجام دهیم.

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}
}

نکته: تعریف واقعی که قبلاً در این فصل استفاده کردیم کمی متفاوت به نظر می‌رسد، زیرا از نسخه‌هایی از راست پشتیبانی می‌کند که هنوز از استفاده از توابع async در Traits پشتیبانی نمی‌کنند. در نتیجه، این‌گونه به نظر می‌رسد:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

نوع Next یک struct است که Future را پیاده‌سازی می‌کند و راهی برای نام‌گذاری طول عمر ارجاع به self با Next<'_, Self> فراهم می‌کند، به‌طوری که await بتواند با این متد کار کند!

Trait StreamExt همچنین محل تمام متدهای جالبی است که می‌توان با streams استفاده کرد. StreamExt به‌طور خودکار برای هر نوعی که Stream را پیاده‌سازی کند، پیاده‌سازی می‌شود، اما این Traits به‌طور جداگانه تعریف شده‌اند تا جامعه بتواند به‌صورت جداگانه روی Trait بنیادی و API‌های راحتی کار کند.

در نسخه StreamExt استفاده‌شده در crate trpl، این Trait نه تنها متد next را تعریف می‌کند، بلکه یک پیاده‌سازی از next ارائه می‌دهد که جزئیات فراخوانی Stream::poll_next را به‌درستی مدیریت می‌کند. این بدان معناست که حتی زمانی که نیاز دارید نوع داده‌های جریان خود را بنویسید، فقط کافی است Stream را پیاده‌سازی کنید، و سپس هرکسی که از نوع داده شما استفاده کند، می‌تواند به‌طور خودکار از StreamExt و متدهای آن با آن استفاده کند.

این تمام چیزی است که درباره جزئیات سطح پایین این Traits پوشش خواهیم داد. برای جمع‌بندی، بیایید در نظر بگیریم که چگونه آینده‌ها (شامل streams)، تسک‌ها، و نخ‌ها همگی با هم سازگار هستند!

جمع‌بندی: Futures، Tasks، و Threads

همان‌طور که در فصل ۱۶ دیدیم، Threads یکی از روش‌های همزمانی را فراهم می‌کنند. در این فصل با روش دیگری آشنا شدیم: استفاده از async با Futures و Streams. اگر برایتان سؤال پیش آمده که چه زمانی باید یکی از این روش‌ها را انتخاب کنید، پاسخ این است: بستگی دارد! و در بسیاری از موارد، انتخاب فقط بین Threads یا async نیست، بلکه ترکیبی از Threads و async است.

بسیاری از سیستم‌عامل‌ها مدل‌های همزمانی مبتنی بر Threads را دهه‌هاست که فراهم کرده‌اند و بسیاری از زبان‌های برنامه‌نویسی از این مدل‌ها پشتیبانی می‌کنند. با این حال، این مدل‌ها بدون نقاط ضعف نیستند. در بسیاری از سیستم‌عامل‌ها، هر Thread مقدار زیادی حافظه استفاده می‌کند و راه‌اندازی و خاموش کردن آن‌ها نیز هزینه‌ای به همراه دارد. Threads همچنین فقط زمانی قابل استفاده هستند که سیستم‌عامل و سخت‌افزار شما از آن‌ها پشتیبانی کنند. برخلاف کامپیوترهای دسکتاپ و موبایل اصلی، برخی از سیستم‌های تعبیه‌شده (embedded systems) هیچ سیستم‌عاملی ندارند و بنابراین Threads هم ندارند.

مدل async مجموعه‌ای متفاوت و در نهایت مکمل از مصالحه‌ها را فراهم می‌کند. در مدل async، عملیات همزمان نیازی به Threadهای جداگانه ندارند. در عوض، می‌توانند بر روی Tasks اجرا شوند، همان‌طور که در بخش Streams از trpl::spawn_task برای شروع کار از یک تابع همزمان استفاده کردیم. یک Task مشابه یک Thread است، اما به جای اینکه توسط سیستم‌عامل مدیریت شود، توسط کد سطح کتابخانه‌ای یعنی Runtime مدیریت می‌شود.

در بخش قبلی، دیدیم که می‌توانیم یک Stream با استفاده از یک کانال async و ایجاد یک Task async که می‌توانیم از کد همزمان فراخوانی کنیم، بسازیم. می‌توانیم همین کار را با یک Thread انجام دهیم. در لیست ۱۷-۴۰ از trpl::spawn_task و trpl::sleep استفاده کردیم. در لیست ۱۷-۴۱، این موارد را با APIهای thread::spawn و thread::sleep از کتابخانه استاندارد در تابع get_intervals جایگزین می‌کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, thread, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    // This is *not* `trpl::spawn` but `std::thread::spawn`!
    thread::spawn(move || {
        let mut count = 0;
        loop {
            // Likewise, this is *not* `trpl::sleep` but `std::thread::sleep`!
            thread::sleep(Duration::from_millis(1));
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-41: استفاده از APIهای std::thread به جای APIهای async trpl برای تابع get_intervals

اگر این کد را اجرا کنید، خروجی آن دقیقاً مشابه لیست ۱۷-۴۰ خواهد بود. و توجه کنید که از دید کدی که فراخوانی انجام می‌دهد، تغییرات بسیار کمی وجود دارد. علاوه بر این، حتی اگر یکی از توابع ما یک Task async را روی Runtime ایجاد کرده و دیگری یک Thread سیستم‌عامل را ایجاد کرده باشد، Streamهای حاصل از این تفاوت‌ها تأثیری نمی‌گیرند.

با وجود شباهت‌هایشان، این دو رویکرد رفتارهای بسیار متفاوتی دارند، اگرچه ممکن است در این مثال بسیار ساده سخت باشد این تفاوت‌ها را اندازه‌گیری کنیم. می‌توانیم میلیون‌ها Task async را روی هر کامپیوتر شخصی مدرن ایجاد کنیم. اما اگر بخواهیم همین کار را با Threads انجام دهیم، واقعاً از حافظه خارج خواهیم شد!

اما دلیلی وجود دارد که این APIها این‌قدر مشابه هستند. نخ‌ها به عنوان مرزی برای مجموعه‌ای از عملیات همزمان عمل می‌کنند؛ همزمانی بین نخ‌ها ممکن است. tasks به عنوان مرزی برای مجموعه‌ای از عملیات غیرهمزمان عمل می‌کنند؛ همزمانی هم بین و هم درون tasks ممکن است، زیرا یک task می‌تواند بین futures در بدنه خود جابه‌جا شود. در نهایت، futures کوچک‌ترین واحد همزمانی در Rust هستند و هر future ممکن است یک درخت از futures دیگر را نمایندگی کند. runtime—به‌ویژه، executor آن—tasks را مدیریت می‌کند و tasks futures را مدیریت می‌کنند. از این نظر، tasks شبیه نخ‌های سبک و مدیریت‌شده توسط runtime هستند که قابلیت‌های بیشتری دارند زیرا توسط runtime به جای سیستم‌عامل مدیریت می‌شوند.

این بدان معنا نیست که Taskهای async همیشه بهتر از Threads هستند (یا برعکس). همزمانی با Threads از برخی جهات مدل برنامه‌نویسی ساده‌تری نسبت به همزمانی با async است. این می‌تواند یک نقطه قوت یا ضعف باشد. Threads تا حدودی “آتش و فراموشی” (fire and forget) هستند؛ آن‌ها معادل ذاتی برای یک Future ندارند، بنابراین بدون اینکه جز توسط خود سیستم‌عامل متوقف شوند، تا انتها اجرا می‌شوند. به عبارت دیگر، آن‌ها پشتیبانی داخلی برای همزمانی درون وظیفه‌ای (intratask concurrency) مانند Futures ندارند. همچنین، Threads در Rust هیچ مکانیزمی برای لغو ندارند—موضوعی که به‌طور صریح در این فصل به آن پرداخته نشده است، اما از این واقعیت که هر زمان یک Future به پایان می‌رسید، وضعیت آن به درستی پاک‌سازی می‌شد، به‌طور ضمنی بیان شده است.

این محدودیت‌ها همچنین باعث می‌شوند Threads سخت‌تر از Futures ترکیب شوند. برای مثال، استفاده از Threads برای ساخت ابزارهایی مانند متدهای timeout و throttle که قبلاً در این فصل ساخته‌ایم، بسیار دشوارتر است. این واقعیت که Futures ساختار داده غنی‌تری هستند به این معناست که آن‌ها می‌توانند به‌طور طبیعی‌تر با هم ترکیب شوند، همان‌طور که دیده‌ایم.

Tasks، در نتیجه، کنترل اضافه‌ای بر روی Futures به ما می‌دهند و به ما اجازه می‌دهند که انتخاب کنیم کجا و چگونه آن‌ها را گروه‌بندی کنیم. و معلوم می‌شود که Threads و Tasks اغلب به خوبی با هم کار می‌کنند، زیرا Tasks می‌توانند (حداقل در برخی Runtimeها) بین Threads جابه‌جا شوند. در واقع، در پس‌زمینه، Runtimeی که استفاده کرده‌ایم—از جمله توابع spawn_blocking و spawn_task—به طور پیش‌فرض چند Threadی (multithreaded) است! بسیاری از Runtimeها از رویکردی به نام دزدیدن کار (work stealing) استفاده می‌کنند تا Tasks را به‌طور شفاف بین Threads جابه‌جا کنند، بر اساس اینکه چگونه Threads در حال حاضر استفاده می‌شوند، تا عملکرد کلی سیستم را بهبود بخشند. این رویکرد در واقع به Threads و Tasks، و بنابراین Futures نیاز دارد.

وقتی در مورد استفاده از روش‌های مختلف فکر می‌کنید، این قوانین کلی را در نظر بگیرید:

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

و اگر به هر دو موازی‌سازی و همزمانی نیاز دارید، لازم نیست بین Threads و async یکی را انتخاب کنید. می‌توانید از هر دو به طور آزادانه استفاده کنید و اجازه دهید هر کدام نقشی که در آن بهتر هستند را بازی کنند. برای مثال، لیست ۱۷-۴۲ یک نمونه نسبتاً رایج از این نوع ترکیب در کد Rust دنیای واقعی را نشان می‌دهد.

Filename: src/main.rs
extern crate trpl; // for mdbook test

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::run(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}
Listing 17-42: ارسال پیام‌ها با کد مسدودکننده در یک نخ و انتظار برای پیام‌ها در یک بلوک async

ما با ایجاد یک کانال async شروع می‌کنیم، سپس یک Thread ایجاد می‌کنیم که مالکیت بخش ارسال‌کننده کانال را به دست می‌گیرد. درون Thread، اعداد ۱ تا ۱۰ را ارسال می‌کنیم و بین هر ارسال یک ثانیه می‌خوابیم. در نهایت، یک Future که با یک بلوک async ایجاد شده و به trpl::run ارسال شده است را اجرا می‌کنیم، درست همان‌طور که در طول این فصل انجام داده‌ایم. در آن Future، منتظر دریافت پیام‌ها می‌مانیم، دقیقاً مانند سایر مثال‌های ارسال پیام که دیده‌ایم.

برای بازگشت به سناریویی که فصل را با آن آغاز کردیم، تصور کنید که مجموعه‌ای از وظایف کدگذاری ویدئو را با استفاده از یک Thread اختصاصی (زیرا کدگذاری ویدئو به شدت وابسته به پردازش است) اجرا می‌کنید، اما با استفاده از یک کانال async به رابط کاربری اطلاع می‌دهید که آن عملیات به پایان رسیده‌اند. در موارد استفاده واقعی، بی‌شمار نمونه از این نوع ترکیب‌ها وجود دارد.

خلاصه

این آخرین باری نیست که در این کتاب با همزمانی مواجه می‌شوید. پروژه موجود در فصل ۲۱ این مفاهیم را در یک موقعیت واقعی‌تر از مثال‌های ساده‌ای که در اینجا بحث شد، به کار خواهد گرفت و حل مسئله با استفاده از Threadها در مقابل Tasks را به طور مستقیم‌تر مقایسه خواهد کرد.

صرف‌نظر از اینکه کدام یک از این رویکردها را انتخاب می‌کنید، Rust ابزارهای لازم برای نوشتن کدی ایمن، سریع و همزمان را در اختیار شما قرار می‌دهد—چه برای یک وب سرور با توان عملیاتی بالا و چه برای یک سیستم‌عامل تعبیه‌شده.

در ادامه، درباره روش‌های ایدئوماتیک برای مدل‌سازی مشکلات و ساختاردهی راه‌حل‌ها به‌عنوان برنامه‌های Rust شما بزرگ‌تر می‌شوند صحبت خواهیم کرد. علاوه بر این، درباره اینکه ایدئوم‌های Rust چگونه با آن‌هایی که ممکن است از برنامه‌نویسی شی‌گرا با آن‌ها آشنا باشید مرتبط هستند بحث خواهیم کرد.

ویژگی‌های برنامه‌نویسی شی‌گرا

برنامه‌نویسی شی‌گرا (OOP) روشی برای مدل‌سازی برنامه‌ها است. مفهوم شی به عنوان یک مفهوم برنامه‌نویسی نخستین‌بار در زبان Simula در دهه ۱۹۶۰ معرفی شد. این اشیاء بر معماری برنامه‌نویسی آلن کی تأثیر گذاشتند؛ معماری‌ای که در آن اشیاء به یکدیگر پیام ارسال می‌کنند. برای توصیف این معماری، او در سال ۱۹۶۷ اصطلاح برنامه‌نویسی شی‌گرا را ابداع کرد. تعاریف متعددی از برنامه‌نویسی شی‌گرا وجود دارد که گاه با یکدیگر رقابت می‌کنند، و بر اساس برخی از این تعاریف، Rust زبانی شی‌گرا به شمار می‌رود، در حالی‌که بر اساس برخی دیگر چنین نیست. در این فصل، برخی ویژگی‌هایی را بررسی خواهیم کرد که معمولاً شی‌گرا تلقی می‌شوند و خواهیم دید چگونه این ویژگی‌ها به صورت ایدیوما‌تیک در Rust پیاده‌سازی می‌شوند. سپس به شما نشان خواهیم داد چگونه یک الگوی طراحی شی‌گرا را در Rust پیاده‌سازی کنید و در مورد مزایا و معایب این کار، در مقایسه با پیاده‌سازی راه‌حلی با استفاده از نقاط قوت Rust، بحث خواهیم کرد.

ویژگی‌های زبان‌های شی‌گرا

در جامعه برنامه‌نویسی هیچ توافقی درباره اینکه یک زبان باید چه ویژگی‌هایی داشته باشد تا به‌عنوان شی‌گرا در نظر گرفته شود، وجود ندارد. Rust تحت تأثیر بسیاری از پارادایم‌های برنامه‌نویسی قرار گرفته است، از جمله OOP؛ برای مثال، ما ویژگی‌هایی که از برنامه‌نویسی تابعی آمده بودند را در فصل 13 بررسی کردیم. می‌توان گفت که زبان‌های شی‌گرا برخی ویژگی‌های مشترک دارند، یعنی اشیاء، کپسوله‌سازی (encapsulation) و وراثت (inheritance). بیایید بررسی کنیم که هر یک از این ویژگی‌ها چه معنایی دارند و آیا Rust از آن‌ها پشتیبانی می‌کند یا خیر.

اشیاء شامل داده‌ها و رفتار هستند

کتاب Design Patterns: Elements of Reusable Object-Oriented Software نوشته Erich Gamma، Richard Helm، Ralph Johnson و John Vlissides (انتشارات Addison-Wesley Professional، 1994)، که به طور غیررسمی به عنوان کتاب Gang of Four شناخته می‌شود، یک فهرست از الگوهای طراحی شی‌گرا است. این کتاب OOP را به این صورت تعریف می‌کند:

برنامه‌های شی‌گرا از اشیاء تشکیل شده‌اند. یک شیء شامل داده‌ها و روش‌هایی که بر روی آن داده‌ها عمل می‌کنند، است. این روش‌ها معمولاً به نام متدها یا عملیات شناخته می‌شوند.

با استفاده از این تعریف، Rust یک زبان شی‌گرا است: structها و enumها داده دارند، و بلوک‌های impl متدهایی را برای structها و enumها ارائه می‌دهند. حتی اگر structها و enumها با متدهایی که دارند اشیاء نامیده نشوند، بر اساس تعریف Gang of Four، آن‌ها همان عملکرد را ارائه می‌دهند.

کپسوله‌سازی برای مخفی کردن جزئیات پیاده‌سازی

یکی دیگر از جنبه‌هایی که معمولاً با OOP مرتبط است، مفهوم کپسوله‌سازی است، که به این معناست که جزئیات پیاده‌سازی یک شیء برای کدی که از آن شیء استفاده می‌کند قابل دسترسی نیست. بنابراین تنها راه تعامل با یک شیء از طریق API عمومی آن است؛ کدی که از شیء استفاده می‌کند نباید بتواند به جزئیات داخلی شیء دسترسی پیدا کند و داده‌ها یا رفتار را به صورت مستقیم تغییر دهد. این امکان را به برنامه‌نویس می‌دهد که جزئیات داخلی شیء را تغییر داده و بازسازی کند بدون اینکه نیازی به تغییر کدی که از آن شیء استفاده می‌کند، داشته باشد.

ما در فصل ۷ درباره‌ی نحوه‌ی کنترل کپسوله‌سازی (encapsulation) صحبت کردیم: می‌توانیم از کلیدواژه‌ی pub استفاده کنیم تا مشخص کنیم کدام ماژول‌ها، نوع‌ها، توابع و متدها در کد ما باید عمومی باشند، و به‌صورت پیش‌فرض سایر اعضا خصوصی هستند. برای مثال، می‌توانیم یک ساختار AveragedCollection تعریف کنیم که یک فیلد شامل یک بردار (vector) از مقادیر i32 دارد. این ساختار همچنین می‌تواند فیلدی داشته باشد که میانگین مقادیر موجود در بردار را نگه می‌دارد، به این معنا که لازم نیست هر بار که کسی به میانگین نیاز دارد، آن را به‌صورت پویا محاسبه کنیم. به عبارت دیگر، AveragedCollection میانگین محاسبه‌شده را برای ما کش (ذخیره) می‌کند. لیستینگ 18-1 تعریف ساختار AveragedCollection را نشان می‌دهد.

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
Listing 18-1: An AveragedCollection struct that maintains a list of integers and the average of the items in the collection

ساختار struct با کلمه کلیدی pub علامت‌گذاری شده است تا کدهای دیگر بتوانند از آن استفاده کنند، اما فیلدهای داخل struct همچنان خصوصی باقی می‌مانند. این نکته در این مثال مهم است، زیرا می‌خواهیم اطمینان حاصل کنیم که هر زمان مقداری به لیست اضافه یا از آن حذف می‌شود، میانگین نیز به‌روزرسانی می‌شود. این کار را با پیاده‌سازی متدهای add، remove و average روی struct انجام می‌دهیم، همان‌طور که در لیستینگ 18-2 نشان داده شده است:

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}
Listing 18-2: پیاده‌سازی متدهای عمومی add، remove و average در AveragedCollection

متدهای عمومی add، remove و average تنها راه‌های دسترسی یا تغییر داده‌ها در یک نمونه از AveragedCollection هستند. زمانی که یک آیتم با استفاده از متد add به list اضافه می‌شود یا با استفاده از متد remove از آن حذف می‌شود، پیاده‌سازی هر یک از آن‌ها متد خصوصی update_average را فراخوانی می‌کند که به‌روزرسانی فیلد average را مدیریت می‌کند.

ما فیلدهای list و average را خصوصی نگه می‌داریم تا هیچ راهی برای کد خارجی وجود نداشته باشد که مستقیماً آیتم‌ها را به list اضافه یا از آن حذف کند. در غیر این صورت، فیلد average ممکن است با تغییرات list هماهنگ نباشد. متد average مقدار موجود در فیلد average را بازمی‌گرداند و به کد خارجی اجازه می‌دهد تا مقدار میانگین را بخواند اما آن را تغییر ندهد.

از آنجایی که جزئیات پیاده‌سازی ساختار AveragedCollection را کپسوله کرده‌ایم، می‌توانیم به راحتی جنبه‌هایی از آن را در آینده تغییر دهیم. برای مثال، می‌توانیم به جای استفاده از Vec<i32> برای فیلد list، از یک HashSet<i32> استفاده کنیم. تا زمانی که امضای متدهای عمومی add، remove و average یکسان باقی بماند، کدی که از AveragedCollection استفاده می‌کند نیازی به تغییر برای کامپایل شدن نخواهد داشت. اگر list عمومی بود، این موضوع لزوماً صادق نبود: HashSet<i32> و Vec<i32> متدهای متفاوتی برای اضافه کردن و حذف آیتم‌ها دارند، بنابراین کد خارجی احتمالاً باید تغییر کند اگر مستقیماً list را تغییر می‌داد.

اگر کپسوله‌سازی یکی از جنبه‌های ضروری برای در نظر گرفتن یک زبان به عنوان شی‌گرا باشد، Rust این نیاز را برآورده می‌کند. امکان استفاده یا عدم استفاده از pub برای بخش‌های مختلف کد، کپسوله‌سازی جزئیات پیاده‌سازی را ممکن می‌سازد.

وراثت به‌عنوان سیستم نوع و به‌عنوان اشتراک‌گذاری کد

وراثت مکانیزمی است که به یک شیء اجازه می‌دهد عناصر را از تعریف یک شیء دیگر به ارث ببرد و در نتیجه داده‌ها و رفتار شیء والد را بدون نیاز به تعریف مجدد آن‌ها به دست آورد.

اگر وراثت باید برای یک زبان وجود داشته باشد تا شی‌گرا در نظر گرفته شود، Rust یک زبان شی‌گرا نیست. در Rust، نمی‌توانید یک struct تعریف کنید که فیلدها و پیاده‌سازی متدهای struct والد را بدون استفاده از یک ماکرو به ارث ببرد.

با این حال، اگر به استفاده از وراثت در ابزارهای برنامه‌نویسی خود عادت کرده‌اید، می‌توانید بسته به دلیل خود برای استفاده از وراثت، از راه‌حل‌های دیگری در Rust استفاده کنید.

دو دلیل اصلی برای انتخاب وراثت وجود دارد. یکی برای استفاده مجدد از کد: می‌توانید یک رفتار خاص را برای یک نوع پیاده‌سازی کنید و وراثت این امکان را فراهم می‌کند که از آن پیاده‌سازی برای یک نوع دیگر استفاده مجدد کنید. در Rust، این کار را به صورت محدود با استفاده از پیاده‌سازی‌های پیش‌فرض متدهای صفت (trait) انجام دهید، همان‌طور که در لیستینگ 10-14 دیدیم که یک پیاده‌سازی پیش‌فرض برای متد summarize در صفت Summary اضافه کردیم. هر نوعی که صفت Summary را پیاده‌سازی کند، متد summarize را بدون نیاز به کد اضافی خواهد داشت. این شبیه به این است که یک کلاس والد یک پیاده‌سازی از یک متد داشته باشد و یک کلاس فرزند ارث‌برده نیز آن پیاده‌سازی متد را داشته باشد. همچنین می‌توانیم پیاده‌سازی پیش‌فرض متد summarize را زمانی که صفت Summary را پیاده‌سازی می‌کنیم، بازنویسی کنیم که شبیه به بازنویسی پیاده‌سازی یک متد ارث‌برده شده در کلاس فرزند است.

دلیل دیگر استفاده از وراثت مربوط به سیستم نوع است: برای این که یک نوع فرزند بتواند در همان مکان‌هایی که نوع والد استفاده می‌شود، مورد استفاده قرار گیرد. این مفهوم چندریختی (polymorphism) نیز نامیده می‌شود، که به این معناست که می‌توانید چندین شیء را در زمان اجرا جایگزین یکدیگر کنید اگر آن‌ها ویژگی‌های خاصی را به اشتراک بگذارند.

چندریختی (Polymorphism)

برای بسیاری از افراد، چندریختی مترادف با وراثت است. اما در واقع یک مفهوم عمومی‌تر است که به کدی اشاره دارد که می‌تواند با داده‌هایی از انواع مختلف کار کند. در مورد وراثت، این انواع معمولاً زیرکلاس‌ها هستند.

در مقابل، Rust از جنریک‌ها برای انتزاع انواع ممکن مختلف استفاده می‌کند و محدودیت‌های صفت (trait bounds) را برای تحمیل این که این انواع باید چه ویژگی‌هایی ارائه دهند، اعمال می‌کند. این رویکرد گاهی چندریختی پارامتریک محدودشده نامیده می‌شود.

وراثت اخیراً به‌عنوان یک راه‌حل طراحی برنامه‌نویسی در بسیاری از زبان‌ها محبوبیت خود را از دست داده است زیرا اغلب خطر اشتراک‌گذاری بیش از حد کد را به همراه دارد. زیرکلاس‌ها نباید همیشه تمام ویژگی‌های کلاس والد خود را به اشتراک بگذارند، اما با وراثت این اتفاق می‌افتد. این می‌تواند طراحی برنامه را کمتر انعطاف‌پذیر کند. همچنین امکان فراخوانی متدهایی روی زیرکلاس‌ها را فراهم می‌کند که معنا ندارند یا باعث خطا می‌شوند زیرا متدها برای زیرکلاس اعمال نمی‌شوند. علاوه بر این، برخی زبان‌ها فقط اجازه وراثت تک (single inheritance) را می‌دهند (یعنی یک زیرکلاس فقط می‌تواند از یک کلاس ارث ببرد)، که انعطاف‌پذیری طراحی برنامه را بیشتر محدود می‌کند.

به این دلایل، Rust رویکرد متفاوتی را با استفاده از اشیاء صفت (trait objects) به جای وراثت اتخاذ می‌کند. بیایید ببینیم که چگونه اشیاء صفت در Rust چندریختی را ممکن می‌سازند.

استفاده از اشیاء صفت برای مقادیر با انواع مختلف

در فصل 8، اشاره کردیم که یکی از محدودیت‌های وکتورها این است که می‌توانند فقط عناصر یک نوع را ذخیره کنند. در لیستینگ 8-9، راه‌حلی ایجاد کردیم که در آن یک enum به نام SpreadsheetCell تعریف کردیم که انواع مختلفی مانند اعداد صحیح، اعداد اعشاری و متن را در خود جای می‌داد. این به ما اجازه می‌داد داده‌های مختلفی را در هر سلول ذخیره کنیم و همچنان یک وکتور داشته باشیم که نمایانگر یک ردیف از سلول‌ها باشد. این راه‌حل زمانی مناسب است که آیتم‌های قابل تعویض ما مجموعه‌ای ثابت از انواع باشد که هنگام کامپایل کد می‌دانیم.

با این حال، گاهی اوقات می‌خواهیم کاربران کتابخانه ما بتوانند مجموعه‌ای از انواع معتبر در یک وضعیت خاص را گسترش دهند. برای نشان دادن نحوه انجام این کار، یک ابزار رابط کاربری گرافیکی (GUI) نمونه ایجاد می‌کنیم که از طریق یک لیست از آیتم‌ها تکرار می‌کند و متدی به نام draw را برای هر آیتم فراخوانی می‌کند تا آن را روی صفحه رسم کند—یک تکنیک رایج برای ابزارهای GUI. یک crate کتابخانه‌ای به نام gui ایجاد می‌کنیم که ساختار یک کتابخانه GUI را شامل می‌شود. این crate ممکن است شامل برخی انواع باشد که افراد از آن‌ها استفاده کنند، مانند Button یا TextField. علاوه بر این، کاربران gui می‌خواهند انواع خود را که می‌توانند رسم شوند ایجاد کنند: برای مثال، یک برنامه‌نویس ممکن است یک Image اضافه کند و دیگری ممکن است یک SelectBox اضافه کند.

ما برای این مثال یک کتابخانه GUI کامل پیاده‌سازی نخواهیم کرد، اما نشان خواهیم داد که قطعات چگونه به هم متصل می‌شوند. هنگام نوشتن این کتابخانه، نمی‌توانیم تمام انواعی که برنامه‌نویسان دیگر ممکن است بخواهند ایجاد کنند را بدانیم و تعریف کنیم. اما می‌دانیم که gui باید مقادیر زیادی از انواع مختلف را پیگیری کند و باید متدی به نام draw را برای هر یک از این مقادیر با نوع متفاوت فراخوانی کند. نیازی به دانستن دقیق آنچه هنگام فراخوانی متد draw اتفاق می‌افتد نداریم، فقط اینکه مقدار باید این متد را داشته باشد.

برای انجام این کار در یک زبان با وراثت، ممکن است یک کلاس به نام Component تعریف کنیم که یک متد به نام draw داشته باشد. سایر کلاس‌ها، مانند Button، Image و SelectBox، از Component ارث می‌برند و به این ترتیب متد draw را به ارث می‌برند. آن‌ها می‌توانند متد draw را بازنویسی کنند تا رفتار سفارشی خود را تعریف کنند، اما فریم‌ورک می‌تواند تمام این انواع را به گونه‌ای مدیریت کند که گویی نمونه‌هایی از Component هستند و متد draw را روی آن‌ها فراخوانی کند. اما چون Rust وراثت ندارد، باید راه دیگری برای ساختاردهی کتابخانه gui پیدا کنیم تا به کاربران اجازه دهد آن را با انواع جدید گسترش دهند.

تعریف یک صفت برای رفتار مشترک

برای پیاده‌سازی رفتاری که می‌خواهیم gui داشته باشد، یک صفت به نام Draw تعریف می‌کنیم که یک متد به نام draw خواهد داشت. سپس می‌توانیم یک وکتور تعریف کنیم که یک شیء صفت را بگیرد. یک شیء صفت به یک نمونه از یک نوع که صفت مشخصی را پیاده‌سازی کرده اشاره می‌کند و همچنین یک جدول برای جستجوی متدهای صفت روی آن نوع در زمان اجرا را شامل می‌شود. برای ایجاد یک شیء صفت، باید نوع اشاره‌گر (Pointer) (مانند یک ارجاع & یا یک اشاره‌گر (Pointer) هوشمند Box<T>)، کلمه کلیدی dyn و سپس صفت مربوطه را مشخص کنیم. (در فصل 20، بخش “انواع با اندازه پویا و صفت Sized دلیل اینکه اشیاء صفت باید از یک اشاره‌گر (Pointer) استفاده کنند را توضیح خواهیم داد.) می‌توانیم از اشیاء صفت به جای یک نوع جنریک یا نوع مشخص استفاده کنیم. هر جا که از یک شیء صفت استفاده کنیم، سیستم نوع Rust در زمان کامپایل تضمین می‌کند که هر مقداری که در آن زمینه استفاده شود، صفت شیء صفت را پیاده‌سازی می‌کند. بنابراین نیازی به دانستن تمام انواع ممکن در زمان کامپایل نداریم.

اشاره کردیم که در Rust از استفاده از اصطلاح “اشیاء” برای structها و enumها اجتناب می‌کنیم تا آن‌ها را از اشیاء سایر زبان‌ها متمایز کنیم. در یک struct یا enum، داده‌ها در فیلدهای struct و رفتار در بلوک‌های impl جدا شده‌اند، در حالی که در سایر زبان‌ها داده‌ها و رفتار معمولاً در یک مفهوم واحد به نام شیء ترکیب می‌شوند. اما اشیاء صفت در Rust بیشتر شبیه اشیاء در سایر زبان‌ها هستند، زیرا داده‌ها و رفتار را ترکیب می‌کنند. با این حال، اشیاء صفت از اشیاء سنتی متفاوت هستند زیرا نمی‌توان داده‌ای به یک شیء صفت اضافه کرد. اشیاء صفت به اندازه اشیاء در سایر زبان‌ها عمومی نیستند: هدف خاص آن‌ها فراهم کردن انتزاع در رفتار مشترک است.

لیستینگ 18-3 نشان می‌دهد چگونه می‌توان یک صفت به نام Draw با یک متد به نام draw تعریف کرد:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 18-3: Definition of the Draw trait

این نحو باید از بحث‌های ما در فصل 10 در مورد نحوه تعریف صفات آشنا باشد. حالا به نحو جدیدی می‌رسیم: لیستینگ 18-4 یک ساختار به نام Screen را تعریف می‌کند که یک بردار به نام components دارد. این بردار از نوع Box<dyn Draw> است، که یک شیء صفت است؛ این به‌عنوان جایگزینی برای هر نوع داخل یک Box که صفت Draw را پیاده‌سازی کرده عمل می‌کند.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
Listing 18-4: تعریف ساختار Screen با یک فیلد components که یک بردار از اشیاء صفت را نگه می‌دارد که صفت Draw را پیاده‌سازی کرده‌اند

روی ساختار Screen، متدی به نام run تعریف می‌کنیم که متد draw را روی هر یک از components خود فراخوانی می‌کند، همان‌طور که در لیستینگ 18-5 نشان داده شده است:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-5: متد run روی Screen که متد draw را روی هر کامپوننت فراخوانی می‌کند

این روش متفاوت از تعریف ساختاری است که از یک پارامتر نوع جنریک با محدودیت‌های صفت استفاده می‌کند. یک پارامتر نوع جنریک فقط می‌تواند یک نوع مشخص را در هر زمان جایگزین کند، در حالی که اشیاء صفت به ما اجازه می‌دهند چندین نوع مشخص را در زمان اجرا به جای اشیاء صفت قرار دهیم. برای مثال، می‌توانستیم ساختار Screen را با استفاده از یک نوع جنریک و یک محدودیت صفت به صورت لیستینگ 18-6 تعریف کنیم:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-6: یک پیاده‌سازی جایگزین برای ساختار Screen و متد run آن با استفاده از جنریک‌ها و محدودیت‌های صفت

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

از طرف دیگر، با استفاده از روش مبتنی بر اشیاء صفت، یک نمونه Screen می‌تواند یک Vec<T> داشته باشد که شامل یک Box<Button> و همچنین یک Box<TextField> باشد. بیایید ببینیم که چگونه این کار می‌کند، سپس درباره پیامدهای عملکرد در زمان اجرا صحبت کنیم.

پیاده‌سازی صفت

حالا برخی از انواعی که صفت Draw را پیاده‌سازی می‌کنند اضافه می‌کنیم. نوع Button را ارائه می‌دهیم. دوباره، پیاده‌سازی یک کتابخانه GUI کامل فراتر از محدوده این کتاب است، بنابراین متد draw هیچ پیاده‌سازی مفیدی در بدنه خود نخواهد داشت. برای تصور اینکه پیاده‌سازی ممکن است چگونه باشد، یک ساختار Button ممکن است فیلدهایی برای width، height و label داشته باشد، همان‌طور که در لیستینگ 18-7 نشان داده شده است:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}
Listing 18-7: یک ساختار Button که صفت Draw را پیاده‌سازی می‌کند

فیلدهای width، height و label در Button با فیلدهای کامپوننت‌های دیگر متفاوت خواهند بود. برای مثال، یک نوع TextField ممکن است همان فیلدها به‌علاوه یک فیلد placeholder داشته باشد. هر یک از انواعی که می‌خواهیم روی صفحه رسم شوند، صفت Draw را پیاده‌سازی می‌کنند اما از کد متفاوتی در متد draw برای تعریف نحوه رسم آن نوع خاص استفاده می‌کنند، همان‌طور که در اینجا برای Button آمده است (بدون کد GUI واقعی، همان‌طور که ذکر شد). نوع Button، برای مثال، ممکن است یک بلوک impl اضافی شامل متدهایی مرتبط با آنچه هنگام کلیک کاربر روی دکمه اتفاق می‌افتد داشته باشد. این نوع متدها برای انواعی مانند TextField اعمال نمی‌شوند.

اگر کسی که از کتابخانه‌ی ما استفاده می‌کند بخواهد یک ساختار SelectBox تعریف کند که شامل فیلدهای width، height و options باشد، او همچنین باید trait Draw را برای نوع SelectBox پیاده‌سازی کند، همان‌طور که در لیستینگ 18-8 نشان داده شده است.

Filename: src/main.rs
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}
Listing 18-8: یک crate دیگر که از gui استفاده می‌کند و صفت Draw را روی یک ساختار SelectBox پیاده‌سازی می‌کند

اکنون کاربر کتابخانه ما می‌تواند تابع main خود را بنویسد تا یک نمونه Screen ایجاد کند. به نمونه Screen، آن‌ها می‌توانند یک SelectBox و یک Button اضافه کنند، با قرار دادن هر یک در یک Box<T> تا به یک شیء صفت تبدیل شوند. سپس می‌توانند متد run را روی نمونه Screen فراخوانی کنند، که متد draw را روی هر یک از کامپوننت‌ها فراخوانی می‌کند. لیستینگ 18-9 این پیاده‌سازی را نشان می‌دهد:

Filename: src/main.rs
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}
Listing 18-9: استفاده از اشیاء صفت برای ذخیره مقادیری با انواع مختلف که یک صفت یکسان را پیاده‌سازی می‌کنند

وقتی کتابخانه را نوشتیم، نمی‌دانستیم که کسی ممکن است نوع SelectBox را اضافه کند، اما پیاده‌سازی Screen ما توانست روی نوع جدید عمل کند و آن را رسم کند زیرا SelectBox صفت Draw را پیاده‌سازی کرده است، که به این معناست که متد draw را پیاده‌سازی کرده است.

این مفهوم—فقط به پیام‌هایی که یک مقدار به آن‌ها پاسخ می‌دهد اهمیت داده می‌شود، نه نوع دقیق مقدار—مشابه مفهوم duck typing در زبان‌های با نوع‌دهی پویا است: اگر مانند اردک حرکت می‌کند و مانند اردک صدا می‌کند، پس حتماً یک اردک است! در پیاده‌سازی متد run روی Screen در لیستینگ 18-5، run نیازی ندارد بداند نوع دقیق هر کامپوننت چیست. نیازی ندارد بررسی کند که آیا یک کامپوننت نمونه‌ای از Button یا SelectBox است؛ فقط متد draw را روی کامپوننت فراخوانی می‌کند. با مشخص کردن Box<dyn Draw> به‌عنوان نوع مقادیر در بردار components، ما تعریف کرده‌ایم که Screen به مقادیری نیاز دارد که بتوانیم متد draw را روی آن‌ها فراخوانی کنیم.

مزیت استفاده از اشیاء صفت و سیستم نوع Rust برای نوشتن کدی مشابه با duck typing این است که هرگز نیازی به بررسی نداریم که آیا یک مقدار متدی خاص را در زمان اجرا پیاده‌سازی کرده است یا خیر، یا نگران خطاهایی باشیم اگر یک مقدار متدی را پیاده‌سازی نکرده اما ما آن را فراخوانی کنیم. Rust کد ما را کامپایل نمی‌کند اگر مقادیر صفاتی را که اشیاء صفت نیاز دارند پیاده‌سازی نکنند.

برای مثال، لیستینگ 18-10 نشان می‌دهد چه اتفاقی می‌افتد اگر بخواهیم یک Screen با یک String به‌عنوان یک کامپوننت ایجاد کنیم:

Filename: src/main.rs
use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}
Listing 18-10: تلاش برای استفاده از نوعی که صفت شیء صفت را پیاده‌سازی نکرده است

ما این خطا را دریافت خواهیم کرد زیرا String صفت Draw را پیاده‌سازی نکرده است:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

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

این خطا به ما اطلاع می‌دهد که یا چیزی را به Screen پاس داده‌ایم که منظورمان نبوده و باید نوع متفاوتی پاس دهیم، یا باید trait Draw را برای نوع String پیاده‌سازی کنیم تا Screen بتواند متد draw را روی آن فراخوانی کند.

اشیاء صفت اجرای Dispatch پویا را انجام می‌دهند

در بخش «کارایی کدی که از Genericها استفاده می‌کند» در فصل ۱۰، درباره‌ی فرآیند مونومورفیزاسیون (monomorphization) که توسط کامپایلر روی genericها انجام می‌شود صحبت کردیم: کامپایلر پیاده‌سازی‌های غیر generic از توابع و متدها را برای هر نوع مشخصی که به‌جای پارامتر generic استفاده می‌کنیم تولید می‌کند. کدی که در نتیجه‌ی مونومورفیزاسیون به‌دست می‌آید از ارسال ایستا (static dispatch) استفاده می‌کند، به این معنا که کامپایلر در زمان کامپایل می‌داند کدام متد را فراخوانی می‌کنید. این در مقابل ارسال پویا (dynamic dispatch) است، که در آن کامپایلر نمی‌تواند در زمان کامپایل تشخیص دهد کدام متد فراخوانی خواهد شد. در حالت ارسال پویا، کامپایلر کدی تولید می‌کند که در زمان اجرا تشخیص می‌دهد کدام متد را باید فراخوانی کند.

زمانی که از trait objectها استفاده می‌کنیم، Rust مجبور است از ارسال پویا استفاده کند. کامپایلر نمی‌داند همه‌ی نوع‌هایی که ممکن است با کدی که از trait object استفاده می‌کند به کار روند، کدامند؛ بنابراین نمی‌تواند مشخص کند کدام متد روی کدام نوع باید فراخوانی شود. در عوض، Rust در زمان اجرا از اشاره‌گرهای درون trait object استفاده می‌کند تا بداند کدام متد را باید فراخوانی کند. این جستجو هزینه‌ای در زمان اجرا دارد که در ارسال ایستا رخ نمی‌دهد. همچنین ارسال پویا مانع از این می‌شود که کامپایلر کد متد را inline کند که این موضوع باعث جلوگیری از برخی بهینه‌سازی‌ها می‌شود. Rust قوانینی در مورد محل‌هایی که می‌توان و نمی‌توان از ارسال پویا استفاده کرد دارد که به آن‌ها هماهنگی dyn (dyn compatibility) گفته می‌شود. این قوانین فراتر از محدوده‌ی این بحث هستند، اما می‌توانید درباره‌ی آن‌ها بیشتر در مستندات مرجع بخوانید.

با این حال، کدی که در لیستینگ 18-5 نوشتیم و در لیستینگ 18-9 پشتیبانی کردیم، انعطاف‌پذیری بیشتری داشت؛ بنابراین این یک معامله‌ی قابل توجه است که باید در نظر گرفته شود.

پیاده‌سازی یک الگوی طراحی شی‌گرا

الگوی وضعیت یک الگوی طراحی شی‌گرا است. هسته این الگو این است که مجموعه‌ای از وضعیت‌ها را که یک مقدار می‌تواند به‌طور داخلی داشته باشد، تعریف کنیم. این وضعیت‌ها با مجموعه‌ای از اشیای وضعیت نمایش داده می‌شوند و رفتار مقدار بر اساس وضعیت آن تغییر می‌کند. قصد داریم مثالی از یک ساختار blog post (پست وبلاگ) را بررسی کنیم که یک فیلد برای نگه‌داشتن وضعیت دارد. این وضعیت یک شیء از مجموعه “پیش‌نویس” (draft)، “در حال بررسی” (review)، یا “منتشرشده” (published) خواهد بود.

اشیای وضعیت قابلیت‌هایی را به اشتراک می‌گذارند: در Rust، البته، ما از ساختارها (structs) و صفت‌ها (traits) به جای اشیا و ارث‌بری استفاده می‌کنیم. هر شیء وضعیت مسئول رفتار خود و مدیریت زمانی است که باید به وضعیت دیگری تغییر کند. مقداری که یک شیء وضعیت را نگه می‌دارد، هیچ اطلاعی از رفتارهای مختلف وضعیت‌ها یا زمان تغییر وضعیت ندارد.

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

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

قابلیت نهایی به این شکل خواهد بود:

  1. یک پست وبلاگ به‌صورت یک پیش‌نویس خالی شروع می‌شود.
  2. وقتی پیش‌نویس تمام شد، بررسی پست درخواست می‌شود.
  3. وقتی پست تأیید شد، منتشر می‌شود.
  4. تنها پست‌های وبلاگی که منتشر شده‌اند متن را برای چاپ بازمی‌گردانند، بنابراین پست‌های تأییدنشده نمی‌توانند به‌طور تصادفی منتشر شوند.

هر تغییر دیگری که روی یک پست تلاش شود نباید تأثیری داشته باشد. برای مثال، اگر بخواهیم یک پست وبلاگ پیش‌نویس را قبل از درخواست بررسی تأیید کنیم، پست باید به‌عنوان پیش‌نویس منتشرنشده باقی بماند.

یک تلاش سنتی شیء‌گرایانه

راه‌های بی‌شماری برای ساختاردهی کد به‌منظور حل یک مسئله وجود دارد که هرکدام با معایب و مزایای متفاوتی همراه‌اند. پیاده‌سازی این بخش بیشتر به سبک سنتی شیءگرایانه نزدیک است، که در Rust قابل نوشتن است، اما از برخی نقاط قوت Rust بهره نمی‌برد. در ادامه، راه‌حل متفاوتی را نشان خواهیم داد که هنوز از الگوی طراحی شیءگرایانه استفاده می‌کند اما به‌گونه‌ای ساختار یافته که ممکن است برای برنامه‌نویسان با تجربه‌ی شیءگرایی کمتر آشنا باشد. این دو راه‌حل را با هم مقایسه خواهیم کرد تا تفاوت‌ها و معایب و مزایای طراحی کد Rust به شکلی متفاوت نسبت به زبان‌های دیگر را تجربه کنیم.

لیستینگ 18-11 این روند کاری را به‌صورت کد نشان می‌دهد: این یک نمونه استفاده از API است که در کتابخانه‌ای به نام blog پیاده‌سازی خواهیم کرد. این کد هنوز کامپایل نمی‌شود، زیرا crate مربوط به blog را پیاده‌سازی نکرده‌ایم.

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}
Listing 18-11: کدی که رفتار مورد نظر ما برای crate blog را نشان می‌دهد

ما می‌خواهیم به کاربر اجازه دهیم یک پست وبلاگ پیش‌نویس جدید با Post::new ایجاد کند. می‌خواهیم امکان اضافه کردن متن به پست وبلاگ را فراهم کنیم. اگر فوراً بخواهیم محتوای پست را دریافت کنیم، قبل از تأیید، نباید هیچ متنی دریافت کنیم، زیرا پست هنوز یک پیش‌نویس است. ما از assert_eq! در کد برای اهداف نمایشی استفاده کرده‌ایم. یک تست واحد عالی برای این مورد این است که تأیید کنیم یک پست وبلاگ پیش‌نویس یک رشته خالی از متد content بازمی‌گرداند، اما قصد نداریم برای این مثال تست بنویسیم.

سپس می‌خواهیم امکان درخواست بررسی برای پست فراهم شود و می‌خواهیم content در حین انتظار برای بررسی یک رشته خالی بازگرداند. وقتی پست تأیید شود، باید منتشر شود، به این معنی که متن پست هنگام فراخوانی content بازگردانده خواهد شد.

توجه کنید که تنها نوعی که از crate با آن تعامل داریم، نوع Post است. این نوع از الگوی حالت (state pattern) استفاده خواهد کرد و یک مقدار نگه می‌دارد که یکی از سه شیء حالت مختلف را نمایندگی می‌کند—حالت‌های پیش‌نویس (draft)، بازبینی (review) یا منتشر شده (published). تغییر از یک حالت به حالت دیگر به‌صورت داخلی و درون نوع Post مدیریت می‌شود. این حالت‌ها در پاسخ به متدهایی که کاربران کتابخانه روی نمونه‌ی Post فراخوانی می‌کنند تغییر می‌کنند، اما کاربران نیازی به مدیریت مستقیم تغییرات حالت ندارند. همچنین، کاربران نمی‌توانند در مدیریت حالت‌ها اشتباه کنند، مثلاً ارسال پستی قبل از بازبینی آن.

تعریف Post و ایجاد یک نمونه‌ی جدید در حالت پیش‌نویس (Draft)

بیایید پیاده‌سازی کتابخانه را شروع کنیم! می‌دانیم که به یک ساختار Post عمومی نیاز داریم که مقداری محتوا را نگه می‌دارد، بنابراین با تعریف این ساختار و یک تابع مرتبط new عمومی برای ایجاد یک نمونه از Post شروع می‌کنیم. این تعاریف در لیستینگ 18-12 آمده‌اند. همچنین، یک صفت خصوصی State ایجاد خواهیم کرد که رفتاری را که تمام اشیای وضعیت برای Post باید داشته باشند تعریف می‌کند.

سپس، Post یک شیء صفت Box<dyn State> را درون یک Option<T> در یک فیلد خصوصی به نام state نگه خواهد داشت تا شیء وضعیت را مدیریت کند. در ادامه خواهید دید که چرا Option<T> ضروری است.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-12: تعریف یک ساختار Post و یک تابع new که یک نمونه جدید از Post ایجاد می‌کند، یک صفت State، و یک ساختار Draft

صفت State رفتاری را که وضعیت‌های مختلف پست‌ها به اشتراک می‌گذارند تعریف می‌کند. اشیای وضعیت شامل Draft, PendingReview و Published هستند و همه آن‌ها صفت State را پیاده‌سازی خواهند کرد. فعلاً صفت هیچ متدی ندارد و ما با تعریف تنها وضعیت Draft شروع می‌کنیم، زیرا این وضعیت است که می‌خواهیم پست در آن شروع شود.

وقتی یک Post جدید ایجاد می‌کنیم، فیلد state آن را به یک مقدار Some تنظیم می‌کنیم که یک Box را نگه می‌دارد. این Box به یک نمونه جدید از ساختار Draft اشاره می‌کند. این کار تضمین می‌کند که هرگاه یک نمونه جدید از Post ایجاد شود، به‌عنوان یک پیش‌نویس شروع شود. از آنجا که فیلد state در Post خصوصی است، هیچ راهی برای ایجاد یک Post در وضعیت دیگری وجود ندارد! در تابع Post::new، فیلد content را به یک String جدید و خالی تنظیم می‌کنیم.

ذخیره متن محتوای پست

در لیستینگ 18-11 دیدیم که می‌خواهیم بتوانیم یک متد به نام add_text فراخوانی کنیم و یک &str به آن بدهیم که به عنوان محتوای متنی پست وبلاگ اضافه شود. این کار را به‌صورت یک متد پیاده‌سازی می‌کنیم تا فیلد content را به‌جای تعریف آن به‌صورت pub کنترل کنیم و بتوانیم در آینده متدی برای کنترل چگونگی خواندن داده فیلد content پیاده‌سازی کنیم. متد add_text نسبتاً ساده است، بنابراین بیایید پیاده‌سازی آن را به بلوک impl Post در لیستینگ 18-13 اضافه کنیم:

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-13: پیاده‌سازی متد add_text برای افزودن متن به content یک پست

متد add_text یک رفرنس قابل‌تغییر به self می‌گیرد، زیرا ما در حال تغییر نمونه‌ی Post هستیم که روی آن این متد را فراخوانی می‌کنیم. سپس متد push_str را روی رشته‌ی درون content فراخوانی می‌کنیم و آرگومان text را به آن می‌دهیم تا به محتوای ذخیره‌شده اضافه شود. این رفتار به وضعیت فعلی پست بستگی ندارد، بنابراین بخشی از الگوی حالت نیست. متد add_text اصلاً با فیلد state تعامل ندارد، اما بخشی از رفتار کلی است که می‌خواهیم پشتیبانی کنیم.

اطمینان از اینکه محتوای یک پست پیش‌نویس خالی است

حتی پس از آن‌که add_text را فراخوانی کردیم و مقداری محتوا به پست اضافه نمودیم، همچنان می‌خواهیم متد content یک رشته‌ی خالی برگرداند، زیرا پست هنوز در حالت پیش‌نویس (draft) قرار دارد، همان‌طور که در خط ۷ لیستینگ 18-11 نشان داده شده است. فعلاً، متد content را به ساده‌ترین شکل ممکن پیاده‌سازی می‌کنیم که این نیاز را برآورده کند: همیشه یک برش رشته‌ی خالی بازگرداند. بعداً، زمانی که توانایی تغییر حالت پست برای منتشر شدن را پیاده‌سازی کنیم، این متد را تغییر خواهیم داد. تا اینجا، پست‌ها تنها می‌توانند در حالت پیش‌نویس باشند، پس محتوای پست همیشه باید خالی باشد. لیستینگ 18-14 این پیاده‌سازی جایگزین را نشان می‌دهد.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-14: افزودن یک پیاده‌سازی موقت برای متد content در Post که همیشه یک برش رشته خالی بازمی‌گرداند

با افزودن این متد content، تمام موارد تا خط 7 لیستینگ 18-11 به درستی کار می‌کنند.

درخواست بازبینی، وضعیت پست را تغییر می‌دهد

در مرحله‌ی بعد، باید قابلیت درخواست بازبینی برای یک پست را اضافه کنیم، که باید وضعیت آن را از Draft به PendingReview تغییر دهد. لیستینگ 18-15 این کد را نشان می‌دهد.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-15: پیاده‌سازی متدهای request_review برای Post و صفت State

ما یک متد عمومی به نام request_review به Post اضافه می‌کنیم که یک ارجاع متغیر به self می‌گیرد. سپس یک متد داخلی request_review را روی وضعیت فعلی Post فراخوانی می‌کنیم، و این متد دوم وضعیت فعلی را مصرف کرده و یک وضعیت جدید بازمی‌گرداند.

ما متد request_review را به صفت State اضافه می‌کنیم؛ تمام انواعی که این صفت را پیاده‌سازی می‌کنند اکنون باید متد request_review را پیاده‌سازی کنند. توجه داشته باشید که به جای self، &self یا &mut self به‌عنوان اولین پارامتر متد، از self: Box<Self> استفاده کرده‌ایم. این نحو به این معنی است که متد فقط زمانی معتبر است که روی یک Box نگه‌دارنده نوع فراخوانی شود. این نحو مالکیت Box<Self> را می‌گیرد و وضعیت قدیمی را باطل می‌کند تا مقدار وضعیت Post بتواند به یک وضعیت جدید تبدیل شود.

برای مصرف وضعیت قدیمی، متد request_review باید مالکیت مقدار وضعیت (state) را بگیرد. اینجا است که استفاده از Option در فیلد state ساختار Post اهمیت پیدا می‌کند: ما متد take را فراخوانی می‌کنیم تا مقدار Some را از فیلد state بیرون بکشیم و در عوض آن None قرار دهیم، زیرا Rust اجازه نمی‌دهد فیلدهای بدون مقدار (unpopulated) در ساختارها وجود داشته باشد. این کار به ما اجازه می‌دهد مقدار state را به‌جای قرض‌گرفتن، به بیرون منتقل کنیم (move). سپس مقدار state پست را به نتیجه‌ی این عملیات اختصاص می‌دهیم.

باید به‌طور موقت state را به None تنظیم کنیم، نه اینکه مستقیماً آن را با کدی مانند self.state = self.state.request_review(); تنظیم کنیم، تا مالکیت مقدار state را بدست آوریم. این کار اطمینان می‌دهد که Post نمی‌تواند از مقدار قدیمی state پس از تبدیل آن به یک وضعیت جدید استفاده کند.

The request_review method on Draft returns a new, boxed instance of a new PendingReview struct, which represents the state when a post is waiting for a review. The PendingReview struct also implements the request_review method but doesn’t do any transformations. Rather, it returns itself because when we request a review on a post already in the PendingReview state, it should stay in the PendingReview state.

اکنون می‌توانیم مزایای الگوی وضعیت را مشاهده کنیم: متد request_review در Post بدون توجه به مقدار state آن یکسان است. هر وضعیت مسئول قوانین خاص خود است.

ما متد content در Post را به همان صورت باقی می‌گذاریم که یک برش رشته خالی بازمی‌گرداند. اکنون می‌توانیم یک Post در وضعیت PendingReview و همچنین در وضعیت Draft داشته باشیم، اما می‌خواهیم همان رفتار در وضعیت PendingReview نیز باشد. لیستینگ 18-11 اکنون تا خط 10 کار می‌کند!

افزودن متد approve برای تغییر رفتار content

متد approve شبیه به متد request_review خواهد بود: این متد فیلد state را به مقداری تغییر می‌دهد که وضعیت فعلی (state فعلی) تعیین می‌کند که هنگام تأیید آن وضعیت، باید به آن تغییر یابد؛ همان‌طور که در لیستینگ 18-16 نشان داده شده است.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-16: پیاده‌سازی متد approve در Post و صفت State

ما متد approve را به صفت State اضافه می‌کنیم و یک ساختار جدید که صفت State را پیاده‌سازی می‌کند، یعنی وضعیت Published، اضافه می‌کنیم.

مشابه روشی که متد request_review در حالت PendingReview کار می‌کند، اگر متد approve را روی حالت Draft فراخوانی کنیم، هیچ تأثیری نخواهد داشت زیرا approve در این حالت خودِ self را باز می‌گرداند. زمانی که متد approve را روی حالت PendingReview فراخوانی کنیم، یک نمونه‌ی جدید از ساختار Published را به‌صورت جعبه‌شده (boxed) باز می‌گرداند. ساختار Published نیز trait مربوط به State را پیاده‌سازی می‌کند، و برای هر دو متد request_review و approve، خودِ self را برمی‌گرداند، زیرا در این موارد پست باید در وضعیت Published باقی بماند.

اکنون باید متد content را در ساختار Post به‌روز کنیم. ما می‌خواهیم مقداری که از content بازگردانده می‌شود، به وضعیت فعلیِ (state) پست بستگی داشته باشد. بنابراین از متد content تعریف‌شده در وضعیت (state) فعلیِ پست استفاده خواهیم کرد، همان‌طور که در لیستینگ 18-17 نشان داده شده است.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-17: به‌روزرسانی متد content در Post برای ارجاع به متد content در State

چون هدف این است که تمام این قوانین در داخل ساختارهایی که صفت State را پیاده‌سازی می‌کنند باقی بماند، ما یک متد content را روی مقدار state فراخوانی می‌کنیم و نمونه پست (یعنی self) را به‌عنوان آرگومان به آن می‌دهیم. سپس مقداری که از متد content روی مقدار state بازمی‌گردد را بازمی‌گردانیم.

ما متد as_ref را روی Option فراخوانی می‌کنیم زیرا می‌خواهیم یک ارجاع به مقدار داخل Option داشته باشیم، نه مالکیت مقدار. چون state یک Option<Box<dyn State>> است، وقتی as_ref را فراخوانی می‌کنیم، یک Option<&Box<dyn State>> بازمی‌گردد. اگر as_ref را فراخوانی نکنیم، با یک خطا مواجه می‌شویم زیرا نمی‌توانیم state را از &self که به‌عنوان پارامتر به تابع داده شده است خارج کنیم.

سپس متد unwrap را فراخوانی می‌کنیم که می‌دانیم هرگز وحشت (panic) نخواهد کرد، زیرا می‌دانیم متدهای Post تضمین می‌کنند که state همیشه یک مقدار Some دارد وقتی این متدها کارشان را تمام می‌کنند. این یکی از مواردی است که در بخش “مواردی که شما اطلاعات بیشتری نسبت به کامپایلر دارید” در فصل 9 در مورد آن صحبت کردیم، زمانی که می‌دانیم یک مقدار None هرگز ممکن نیست، حتی اگر کامپایلر نتواند این موضوع را درک کند.

در این مرحله، وقتی content را روی &Box<dyn State> فراخوانی می‌کنیم، تبدیل خودکار به نوع ارجاع (deref coercion) روی & و Box اعمال می‌شود تا متد content در نهایت روی نوعی که صفت State را پیاده‌سازی می‌کند، فراخوانی شود. این بدان معناست که باید content را به تعریف صفت State اضافه کنیم، و اینجا جایی است که منطق مربوط به بازگرداندن محتوا بر اساس وضعیت فعلی قرار خواهد گرفت، همان‌طور که در لیستینگ 18-18 نشان داده شده است: چون هدف این است که تمام این قوانین را درون ساختارهایی که State را پیاده‌سازی می‌کنند نگه داریم، متد content را روی مقداری که در فیلد state است فراخوانی می‌کنیم و نمونه‌ی فعلی پست (یعنی self) را به‌عنوان آرگومان ارسال می‌کنیم. سپس مقدار بازگشتیِ متد content روی مقدارِ state را برمی‌گردانیم.

ما متد as_ref را روی Option فراخوانی می‌کنیم، زیرا می‌خواهیم یک رفرنس به مقداری که درون Option قرار دارد بگیریم و نه مالکیت آن مقدار را. به‌دلیل اینکه state از نوع Option<Box<dyn State>> است، وقتی as_ref را فراخوانی می‌کنیم، نوع بازگشتی ما Option<&Box<dyn State>> خواهد بود. اگر as_ref را فراخوانی نمی‌کردیم، دچار خطا می‌شدیم زیرا نمی‌توانیم مقدار state را از رفرنس قرضیِ (&self) که به تابع داده شده بیرون بکشیم.

سپس متد unwrap را فراخوانی می‌کنیم که می‌دانیم هرگز باعث panic نمی‌شود، زیرا متدهایی که روی Post تعریف کرده‌ایم تضمین می‌کنند که پس از اجرای این متدها، مقدار state همیشه Some باشد. این مورد یکی از همان حالت‌هایی است که در بخش «حالتی که شما اطلاعات بیشتری نسبت به کامپایلر دارید» در فصل ۹ اشاره کردیم، یعنی زمانی که می‌دانیم مقدار None هرگز رخ نمی‌دهد ولی کامپایلر قادر به تشخیص آن نیست.

در این مرحله، وقتی متد content را روی &Box<dyn State> فراخوانی می‌کنیم، عمل deref coercion روی & و Box اتفاق می‌افتد و در نهایت متد content روی نوعی که trait مربوط به State را پیاده‌سازی کرده است فراخوانی خواهد شد. این بدان معناست که باید متد content را به تعریف trait مربوط به State اضافه کنیم؛ و این همان جایی است که منطق بازگرداندن محتوا را، بر اساس وضعیت فعلیِ پست، قرار می‌دهیم. این موضوع در لیستینگ 18-18 نشان داده شده است.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}
Listing 18-18: افزودن متد content به trait مربوط به State

ما یک پیاده‌سازی پیش‌فرض (default implementation) برای متد content اضافه می‌کنیم که یک برش رشته‌ی خالی ("") برمی‌گرداند. این یعنی دیگر نیازی نداریم متد content را برای ساختارهای Draft و PendingReview پیاده‌سازی کنیم. ساختار Published متد content را override می‌کند و مقداری که در post.content وجود دارد را برمی‌گرداند. گرچه این روش راحت است، اما باعث می‌شود مرز بین مسئولیت‌های State و مسئولیت‌های Post کمی مبهم شود.

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

و تمام—اکنون تمام لیستینگ 18-11 کار می‌کند! ما الگوی وضعیت را با قوانین مربوط به فرآیند کاری پست وبلاگ پیاده‌سازی کرده‌ایم. منطق مربوط به قوانین در اشیای وضعیت قرار دارد، نه اینکه در سراسر Post پراکنده باشد.

چرا از enum استفاده نکردیم؟

شاید برایتان این سؤال پیش آمده باشد که چرا از یک enum با حالت‌های ممکن مختلف پست به‌عنوان واریانت‌ها استفاده نکردیم.
قطعاً این راه‌حل نیز قابل استفاده است؛ آن را امتحان کنید و نتایج نهایی را مقایسه کنید تا ببینید کدام روش را ترجیح می‌دهید! یکی از معایب استفاده از enum این است که در هر جایی که نیاز دارید مقدار آن enum را بررسی کنید، مجبورید از یک عبارت match (یا چیزی مشابه آن) استفاده کنید تا تمام حالت‌های ممکن را مدیریت کنید. این موضوع می‌تواند در مقایسه با راه‌حل trait object که اینجا استفاده کردیم، منجر به تکرار کد بیشتری شود.

معایب و مزایای الگوی حالت (State Pattern)

ما نشان داده‌ایم که Rust قادر است الگوی وضعیت شی‌گرا را برای کپسوله کردن رفتارهای مختلف یک پست در هر حالت پیاده‌سازی کند. متدهای Post هیچ اطلاعی از رفتارهای مختلف ندارند. با روشی که کد را سازمان‌دهی کرده‌ایم، تنها باید در یک مکان به‌دنبال راه‌های مختلف رفتار یک پست منتشرشده بگردیم: پیاده‌سازی صفت State روی ساختار Published.

اگر می‌خواستیم یک پیاده‌سازی جایگزین ایجاد کنیم که از الگوی حالت (state pattern) استفاده نکند، احتمالاً مجبور بودیم در متدهای ساختار Post و یا حتی در کد تابع main، از عبارات match استفاده کنیم تا وضعیت فعلی پست را بررسی کرده و رفتار مناسب را در آن‌جا انتخاب کنیم. در این حالت، برای درک تمام اثرات ناشی از قرار گرفتن پست در وضعیت منتشرشده (published)، باید چندین نقطه‌ی مختلف از کد را بررسی می‌کردیم.

اما با استفاده از الگوی حالت، متدهای ساختار Post و مکان‌هایی که از Post استفاده می‌کنیم، نیازی به عبارات match ندارند. همچنین، برای افزودن یک حالت جدید تنها کافی است یک ساختار جدید ایجاد کرده و متدهای trait را فقط برای همان ساختار جدید و در یک محل واحد پیاده‌سازی کنیم.

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

  • یک متد reject اضافه کنید که وضعیت پست را از PendingReview به Draft تغییر دهد.
  • دو فراخوانی به approve نیاز داشته باشید تا وضعیت به Published تغییر کند.
  • اجازه دهید کاربران فقط زمانی که یک پست در حالت Draft است متن محتوا اضافه کنند. نکته: بگذارید شیء وضعیت مسئول تغییراتی باشد که ممکن است در محتوا ایجاد شود، اما مسئول اصلاح مستقیم Post نباشد.

یکی از معایب الگوی وضعیت این است که به دلیل اینکه وضعیت‌ها انتقال بین حالت‌ها را پیاده‌سازی می‌کنند، برخی از وضعیت‌ها به یکدیگر وابسته هستند. اگر یک حالت دیگر بین PendingReview و Published اضافه کنیم، مانند Scheduled، باید کد در PendingReview را تغییر دهیم تا به Scheduled منتقل شود. اگر نیازی نبود که PendingReview با اضافه شدن یک حالت جدید تغییر کند، کار کمتری می‌داشتیم، اما این به معنای تغییر به یک الگوی طراحی دیگر خواهد بود.

نکته‌ی منفی دیگر این است که ما در اینجا منطق‌هایی را تکرار کرده‌ایم. برای حذف بخشی از این تکرارها، ممکن است تلاش کنیم پیاده‌سازی پیش‌فرضی برای متدهای request_review و approve در trait مربوط به State ایجاد کنیم که مقدار self را بازگردانند. اما این رویکرد جواب نمی‌دهد: وقتی از State به عنوان یک trait object استفاده می‌کنیم، آن trait دقیقاً نمی‌داند که نوع مشخص self چیست، بنابراین نوع بازگشتی در زمان کامپایل قابل تشخیص نیست. (این یکی از همان قواعد مربوط به سازگاری با dyn است که پیش‌تر به آن اشاره شد.)

سایر موارد تکرار شامل پیاده‌سازی‌های مشابه متدهای request_review و approve در Post است. هر دو متد از Option::take با فیلد state از ساختار Post استفاده می‌کنند و اگر مقدار state برابر با Some باشد، عملیات به پیاده‌سازی همان متد در مقدار درون آن منتقل می‌شود و نتیجه‌ی آن را به فیلد state اختصاص می‌دهد. اگر متدهای زیادی در Post داشته باشیم که از این الگو تبعیت می‌کنند، ممکن است بخواهیم برای حذف تکرار، یک ماکرو تعریف کنیم (به بخش «ماکروها» در فصل ۲۰ مراجعه کنید).

با پیاده‌سازی الگوی حالت دقیقاً همان‌طور که برای زبان‌های شیءگرای سنتی تعریف شده است، ما به‌طور کامل از نقاط قوت زبان Rust استفاده نمی‌کنیم. بیایید تغییراتی را بررسی کنیم که می‌توانیم در crate مربوط به blog ایجاد کنیم تا حالت‌ها و انتقال‌های نادرست را به خطاهای زمان کامپایل تبدیل کنیم.

کدگذاری حالت‌ها و رفتارها به عنوان نوع‌ها (Encoding States and Behavior as Types)

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

ابتدا قسمت اول main در لیستینگ 18-11 را در نظر بگیرید:

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

ما همچنان اجازه می‌دهیم که پست‌های جدید در حالت پیش‌نویس (draft) توسط متد Post::new ایجاد شوند و امکان اضافه کردن متن به محتوای پست را نیز خواهیم داشت. اما به جای این‌که یک متد content روی پست پیش‌نویس تعریف کنیم که یک رشته‌ی خالی برمی‌گرداند، کاری می‌کنیم که پست‌های پیش‌نویس اصلاً متد content نداشته باشند. به این ترتیب، اگر تلاش کنیم محتوای یک پست پیش‌نویس را بخوانیم، کامپایلر به ما خطا می‌دهد و اعلام می‌کند که چنین متدی وجود ندارد. در نتیجه، غیرممکن خواهد شد که به‌صورت تصادفی محتوای یک پست پیش‌نویس را در محیط نهایی (production) نمایش دهیم، زیرا اساساً چنین کدی کامپایل نمی‌شود. لیستینگ 18-19 تعریف ساختارهای Post و DraftPost و همچنین متدهای مربوط به هرکدام را نشان می‌دهد.

Filename: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}
Listing 18-19: یک Post با یک متد content و یک DraftPost بدون متد content

هر دو ساختار Post و DraftPost دارای یک فیلد خصوصی به نام content هستند که متن پست وبلاگ را ذخیره می‌کند. این ساختارها دیگر فیلد state ندارند زیرا کدگذاری وضعیت را به انواع ساختارها منتقل کرده‌ایم. ساختار Post نماینده یک پست منتشرشده است و دارای متد content است که مقدار content را بازمی‌گرداند.

ما همچنان یک تابع Post::new داریم، اما به‌جای بازگرداندن نمونه‌ای از Post، یک نمونه از DraftPost بازمی‌گرداند. از آنجا که content خصوصی است و هیچ تابعی وجود ندارد که Post را بازگرداند، در حال حاضر امکان ایجاد نمونه‌ای از Post وجود ندارد.

ساختار DraftPost یک متد add_text دارد، بنابراین می‌توانیم همانند قبل متن را به content اضافه کنیم، اما توجه کنید که DraftPost متد content تعریف‌شده ندارد! بنابراین اکنون برنامه تضمین می‌کند که تمام پست‌ها به‌صورت پست‌های پیش‌نویس شروع می‌شوند و پست‌های پیش‌نویس محتوای خود را برای نمایش در دسترس ندارند. هر تلاشی برای دور زدن این محدودیت‌ها منجر به خطای کامپایلر خواهد شد.

حال چگونه یک پست منتشرشده خواهیم داشت؟ می‌خواهیم این قانون را اجباری کنیم که یک پستِ پیش‌نویس حتماً باید پیش از انتشار، بازبینی و تأیید شود. همچنین پستی که در وضعیت انتظار بازبینی (PendingReview) است، همچنان نباید هیچ محتوایی را نمایش دهد. بیایید این محدودیت‌ها را با اضافه کردن یک ساختار دیگر به نام PendingReviewPost پیاده‌سازی کنیم. سپس متد request_review را در ساختار DraftPost طوری تعریف می‌کنیم که یک PendingReviewPost بازگرداند و همچنین متد approve را در ساختار PendingReviewPost تعریف می‌کنیم تا یک Post بازگرداند. این روند در لیستینگ 18-20 نشان داده شده است.

Filename: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}
Listing 18-20: یک PendingReviewPost که با فراخوانی request_review روی DraftPost ایجاد می‌شود و یک متد approve که یک PendingReviewPost را به یک Post منتشرشده تبدیل می‌کند

متدهای request_review و approve مالکیت self را می‌گیرند، بنابراین نمونه‌های DraftPost و PendingReviewPost را مصرف کرده و آن‌ها را به‌ترتیب به یک PendingReviewPost و یک Post منتشرشده تبدیل می‌کنند. به این ترتیب، پس از فراخوانی request_review روی یک DraftPost و به همین ترتیب، هیچ نمونه‌ای از DraftPost باقی نمی‌ماند. ساختار PendingReviewPost متد content تعریف‌شده‌ای ندارد، بنابراین تلاش برای خواندن محتوای آن منجر به خطای کامپایلر می‌شود، همان‌طور که در مورد DraftPost اتفاق می‌افتد. چون تنها راه برای گرفتن یک نمونه از Post منتشرشده که متد content تعریف‌شده‌ای دارد، فراخوانی متد approve روی یک PendingReviewPost است، و تنها راه برای گرفتن یک PendingReviewPost فراخوانی متد request_review روی یک DraftPost است، ما اکنون فرآیند کاری پست وبلاگ را به سیستم نوع کدگذاری کرده‌ایم.

اما همچنین باید تغییرات کوچکی در main ایجاد کنیم. متدهای request_review و approve نمونه‌های جدیدی بازمی‌گردانند به‌جای اینکه ساختاری که روی آن فراخوانی شده‌اند را تغییر دهند، بنابراین باید تخصیص‌های مجدد با let post = اضافه کنیم تا نمونه‌های بازگشتی را ذخیره کنیم. همچنین نمی‌توانیم تأییدیه‌های مربوط به خالی بودن محتوای پست‌های پیش‌نویس و در انتظار بررسی را داشته باشیم، و نیازی به آن‌ها نیست: دیگر نمی‌توانیم کدی که سعی می‌کند محتوای پست‌های در این حالت‌ها را استفاده کند، کامپایل کنیم. کد به‌روزشده در main در لیستینگ 18-21 نشان داده شده است:

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}
Listing 18-21: تغییرات در main برای استفاده از پیاده‌سازی جدید فرآیند کاری پست وبلاگ

تغییراتی که باید در main برای تخصیص مجدد post انجام می‌دادیم، به این معناست که این پیاده‌سازی دیگر کاملاً از الگوی وضعیت شی‌گرا پیروی نمی‌کند: انتقالات بین حالت‌ها دیگر به‌طور کامل در پیاده‌سازی Post کپسوله نشده‌اند. اما، مزیت ما این است که اکنون وضعیت‌های نامعتبر به دلیل سیستم نوع و بررسی نوعی که در زمان کامپایل انجام می‌شود، غیرممکن هستند! این تضمین می‌کند که برخی از باگ‌ها، مانند نمایش محتوای یک پست منتشرنشده، قبل از رسیدن به تولید کشف شوند.

تکالیف پیشنهادی در ابتدای این بخش را روی crate blog همان‌طور که پس از لیستینگ 18-21 است امتحان کنید تا ببینید درباره طراحی این نسخه از کد چه نظری دارید. توجه داشته باشید که برخی از تکالیف ممکن است در این طراحی از پیش انجام شده باشند.

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

خلاصه

فارغ از اینکه پس از خواندن این فصل فکر می‌کنید Rust یک زبان شی‌گرا است یا نه، اکنون می‌دانید که می‌توانید از اشیای صفت برای دریافت برخی ویژگی‌های شی‌گرایی در Rust استفاده کنید. تخصیص پویا (Dynamic Dispatch) می‌تواند انعطاف‌پذیری به کد شما بدهد، اما در ازای آن کمی از عملکرد زمان اجرا را قربانی می‌کند. می‌توانید از این انعطاف‌پذیری برای پیاده‌سازی الگوهای شی‌گرا که می‌توانند به نگه‌داری کد شما کمک کنند، استفاده کنید. Rust همچنین دارای ویژگی‌های دیگری مانند مالکیت است که زبان‌های شی‌گرا ندارند. یک الگوی شی‌گرا همیشه بهترین راه برای بهره‌بردن از نقاط قوت Rust نخواهد بود، اما به‌عنوان یک گزینه در دسترس است.

در ادامه، به بررسی الگوها خواهیم پرداخت که یکی دیگر از ویژگی‌های Rust است که انعطاف‌پذیری زیادی را فراهم می‌کنند. در طول کتاب به‌طور مختصر به آن‌ها اشاره کرده‌ایم، اما هنوز به‌طور کامل توانایی آن‌ها را ندیده‌ایم. برویم!

الگوها و Match

الگوها یک نحو خاص در Rust هستند که برای مطابقت با ساختار انواع، چه پیچیده و چه ساده، استفاده می‌شوند. استفاده از الگوها همراه با عبارات match و سایر سازه‌ها به شما کنترل بیشتری بر جریان کنترل برنامه می‌دهد. یک الگو از ترکیبی از موارد زیر تشکیل شده است:

  • مقادیر ثابت (Literals)
  • آرایه‌ها، enumها، structها یا tupleهای تخریب‌شده
  • متغیرها
  • کاراکترهای عمومی (Wildcards)
  • جای‌نگهدارها (Placeholders)

برخی از نمونه الگوها عبارتند از x، (a, 3) و Some(Color::Red). در زمینه‌هایی که الگوها معتبر هستند، این مؤلفه‌ها شکل داده‌ها را توصیف می‌کنند. سپس برنامه ما مقادیر را با الگوها مقایسه می‌کند تا مشخص شود آیا داده‌ها شکل درستی دارند تا یک قطعه خاص از کد اجرا شود یا خیر.

برای استفاده از یک الگو، آن را با یک مقدار مقایسه می‌کنیم. اگر الگو با مقدار مطابقت داشته باشد، از قطعات مقدار در کد خود استفاده می‌کنیم. به یاد آورید که در فصل 6 از عبارات match که از الگوها استفاده می‌کردند، مانند مثال ماشین مرتب‌سازی سکه، بحث کردیم. اگر مقدار با شکل الگو تطابق داشته باشد، می‌توانیم از قسمت‌های نام‌گذاری‌شده استفاده کنیم. اگر مطابقت نداشته باشد، کد مرتبط با آن الگو اجرا نخواهد شد.

این فصل یک مرجع جامع در مورد هر چیزی است که به الگوها مربوط می‌شود. ما مکان‌های معتبری که می‌توان از الگوها استفاده کرد، تفاوت بین الگوهای قابل‌رد (refutable) و غیرقابل‌رد (irrefutable)، و انواع مختلف نحوی الگو که ممکن است ببینید را پوشش خواهیم داد. تا پایان فصل، خواهید دانست که چگونه از الگوها برای بیان بسیاری از مفاهیم به روشی واضح استفاده کنید.

All the Places Patterns Can Be Used

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

match Arms

همان‌طور که در فصل 6 بحث شد، ما از الگوها در بازوهای (arms) عبارات match استفاده می‌کنیم. به‌طور رسمی، عبارات match به‌صورت کلمه کلیدی match، یک مقدار برای مطابقت، و یک یا چند بازوی match که از یک الگو و یک عبارت برای اجرا در صورت مطابقت مقدار با الگوی آن بازو تشکیل شده‌اند، تعریف می‌شوند، مانند این:

#![allow(unused)]
fn main() {
match <em>VALUE</em> {
    <em>PATTERN</em> => <em>EXPRESSION</em>,
    <em>PATTERN</em> => <em>EXPRESSION</em>,
    <em>PATTERN</em> => <em>EXPRESSION</em>,
}
}

برای مثال، در این‌جا عبارت match از لیستینگ 6-5 را داریم که روی یک مقدار از نوع Option<i32> در متغیر x تطبیق انجام می‌دهد:

match x {
    None => None,
    Some(i) => Some(i + 1),
}

الگوها در این عبارت match شامل None و Some(i) هستند که در سمت چپ هر پیکان قرار دارند.

یکی از نیازمندی‌های عبارات match این است که باید به‌صورت کامل باشند، به این معنا که تمام حالات ممکن برای مقدار در عبارت match باید پوشش داده شوند. یکی از راه‌های اطمینان از اینکه همه حالات را پوشش داده‌اید این است که یک الگوی عمومی (catchall) برای بازوی آخر داشته باشید: برای مثال، یک نام متغیر که هر مقداری را مطابقت می‌دهد هرگز شکست نمی‌خورد و بنابراین تمام موارد باقی‌مانده را پوشش می‌دهد.

الگوی خاص _ هر چیزی را مطابقت می‌دهد، اما هرگز به یک متغیر متصل نمی‌شود، بنابراین اغلب در بازوی آخر match استفاده می‌شود. الگوی _ می‌تواند زمانی مفید باشد که بخواهید هر مقداری که مشخص نشده است را نادیده بگیرید، برای مثال. ما الگوی _ را در بخش “Ignoring Values in a Pattern” بعداً در این فصل به‌طور مفصل بررسی خواهیم کرد.

let Statements

پیش از این فصل، تنها به‌طور صریح از الگوها در match و if let استفاده کرده بودیم، اما در واقع در بخش‌های دیگری نیز از الگوها استفاده کرده‌ایم، از جمله در دستورات let. برای مثال، این انتساب ساده متغیر با استفاده از let را در نظر بگیرید:

#![allow(unused)]
fn main() {
let x = 5;
}

هر بار که از یک دستور let مانند این استفاده کرده‌اید، در حال استفاده از یک الگو بوده‌اید، حتی اگر متوجه آن نشده باشید! به‌طور رسمی‌تر، یک دستور let به این شکل است:

let PATTERN = EXPRESSION;

در دستوراتی مانند let x = 5; که یک نام متغیر در جایگاه PATTERN قرار دارد، آن نام متغیر در واقع شکل ساده‌ای از یک الگو است. Rust عبارت را با الگو مقایسه می‌کند و هر نامی را که پیدا کند اختصاص می‌دهد. بنابراین، در مثال let x = 5;، x الگویی است که به این معناست: «هر چیزی که این‌جا تطابق دارد را به متغیر x متصل کن.» از آن‌جا که نام x کل الگو را تشکیل می‌دهد، این الگو عملاً به این معناست: «هر چیزی که باشد، آن را به متغیر x متصل کن.»

برای دیدن جنبه‌ی تطبیق الگو در let به‌طور واضح‌تر، به کد موجود در لیست ۱۹-۱ نگاه کنید که از یک الگو با let برای تجزیه‌ی یک tuple استفاده می‌کند.

fn main() {
    let (x, y, z) = (1, 2, 3);
}
Listing 19-1: استفاده از یک الگو برای تجزیه‌ی یک tuple و ساختن سه متغیر به‌صورت هم‌زمان

در این‌جا، ما یک tuple را با یک الگو تطبیق می‌دهیم. Rust مقدار (1, 2, 3) را با الگوی (x, y, z) مقایسه می‌کند و می‌بیند که این مقدار با الگو تطابق دارد، از این جهت که تعداد عناصر در هر دو یکی است. بنابراین Rust مقدار 1 را به x، مقدار 2 را به y، و مقدار 3 را به z اختصاص می‌دهد. می‌توانید این الگوی tuple را به‌عنوان تو در تویی از سه الگوی متغیر مجزا در نظر بگیرید.

اگر تعداد عناصر در الگو با تعداد عناصر در tuple تطابق نداشته باشد، نوع کلی تطابق نخواهد داشت و با خطای کامپایل مواجه خواهیم شد. برای مثال، لیست ۱۹-۲ تلاشی برای تجزیه‌ی یک tuple با سه عنصر به دو متغیر را نشان می‌دهد، که کار نخواهد کرد.

fn main() {
    let (x, y) = (1, 2, 3);
}
Listing 19-2: ساخت نادرست الگویی که تعداد متغیرهای آن با تعداد عناصر در tuple هم‌خوانی ندارد

تلاش برای کامپایل این کد منجر به این خطای نوع می‌شود:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^   --------- this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

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

برای رفع این خطا، می‌توانیم یک یا چند مقدار را با استفاده از _ یا .. نادیده بگیریم، همان‌طور که در بخش [«نادیده گرفتن مقادیر در یک الگو»][ignoring-values-in-a-pattern] خواهید دید. اگر مشکل این باشد که متغیرهای زیادی در الگو داریم، راه‌حل این است که برخی متغیرها را حذف کنیم تا تعداد متغیرها با تعداد عناصر در tuple برابر شود.

عبارات شرطی if let

در فصل ۶، در مورد نحوه‌ی استفاده از عبارات if let صحبت کردیم، عمدتاً به‌عنوان راهی کوتاه‌تر برای نوشتن معادل match که تنها یک حالت را تطبیق می‌دهد. به‌صورت اختیاری، if let می‌تواند یک بخش else نیز داشته باشد که در صورت عدم تطابق الگو، کدی را اجرا کند.

لیست ۱۹-۳ نشان می‌دهد که همچنین می‌توانیم if let، else if، و else if let را ترکیب کنیم. این کار انعطاف‌پذیری بیشتری نسبت به یک عبارت match به ما می‌دهد، که در آن تنها می‌توانیم یک مقدار را با الگوها مقایسه کنیم. همچنین، Rust الزام نمی‌کند که شرایط در زنجیره‌ی if let، else if، و else if let به یکدیگر مرتبط باشند.

کد موجود در لیست ۱۹-۳ تعیین می‌کند که پس‌زمینه‌ی شما بر اساس مجموعه‌ای از بررسی‌ها چه رنگی باشد. برای این مثال، متغیرهایی با مقادیر hardcoded ایجاد کرده‌ایم که یک برنامه واقعی ممکن است از ورودی کاربر دریافت کند.

Filename: src/main.rs
fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {color}, as the background");
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}
Listing 19-3: ترکیب if let، else if، else if let و else

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

این ساختار شرطی به ما امکان پشتیبانی از نیازهای پیچیده را می‌دهد. با مقادیر سخت‌کدشده‌ای که در اینجا داریم، این مثال پیام Using purple as the background color را چاپ خواهد کرد.

می‌توانید ببینید که if let نیز می‌تواند متغیرهای جدیدی را معرفی کند که متغیرهای موجود را به همان روشی که بازوهای match انجام می‌دهند، پوشش می‌دهند: خط if let Ok(age) = age یک متغیر جدید به نام age معرفی می‌کند که حاوی مقدار داخل حالت Ok است و متغیر موجود age را پوشش می‌دهد. این بدان معناست که باید شرط if age > 30 را در داخل آن بلوک قرار دهیم: نمی‌توانیم این دو شرط را به‌صورت if let Ok(age) = age && age > 30 ترکیب کنیم. متغیر جدید age که می‌خواهیم با 30 مقایسه کنیم تا شروع محدوده جدید با آکولاد معتبر نیست.

نقطه ضعف استفاده از عبارات if let این است که کامپایلر بررسی نمی‌کند که آیا همه حالات پوشش داده شده‌اند یا خیر، در حالی که با عبارات match این کار را انجام می‌دهد. اگر بلوک آخر else را حذف کنیم و بنابراین برخی موارد را پوشش ندهیم، کامپایلر به ما در مورد باگ احتمالی منطقی هشدار نمی‌دهد.

while let Conditional Loops

ساختار while let مشابه if let است و به ما این امکان را می‌دهد که یک حلقه‌ی while تا زمانی که یک الگو با موفقیت تطبیق یابد، اجرا شود. در لیست ۱۹-۴، یک حلقه‌ی while let را نشان می‌دهیم که منتظر دریافت پیام‌هایی است که بین نخ‌ها (threads) ارسال می‌شوند، اما در این مورد به جای بررسی یک مقدار از نوع Option، یک مقدار از نوع Result را بررسی می‌کنیم.

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for val in [1, 2, 3] {
            tx.send(val).unwrap();
        }
    });

    while let Ok(value) = rx.recv() {
        println!("{value}");
    }
}
Listing 19-4: استفاده از یک حلقه‌ی while let برای چاپ مقادیر تا زمانی که rx.recv() مقدار Ok برگرداند

این مثال مقادیر 1، 2، و سپس 3 را چاپ می‌کند. متد recv اولین پیام را از سمت گیرنده‌ی کانال دریافت کرده و یک Ok(value) برمی‌گرداند. زمانی که در فصل ۱۶ با recv آشنا شدیم، یا مستقیماً خطا را unwrap می‌کردیم، یا از آن به‌عنوان یک پیمایشگر (iterator) در یک حلقه‌ی for استفاده می‌کردیم. اما همان‌طور که در لیست ۱۹-۴ نشان داده شده است، می‌توانیم از while let نیز استفاده کنیم، زیرا متد recv هر بار که پیامی دریافت شود، یک Ok برمی‌گرداند، تا زمانی که فرستنده هنوز وجود داشته باشد، و زمانی که سمت فرستنده قطع شود، یک Err تولید می‌کند.

حلقه‌های for

In a for loop, the value that directly follows the keyword for is a pattern. For example, in for x in y, the x is the pattern. Listing 19-5 demonstrates how to use a pattern in a for loop to destructure, or break apart, a tuple as part of the for loop.

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}
Listing 19-5: Using a pattern in a for loop to destructure a tuple

کدی که در لیست ۱۹-۵ آمده است، خروجی زیر را چاپ خواهد کرد:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/patterns`
a is at index 0
b is at index 1
c is at index 2

ما با استفاده از متد enumerate یک پیمایشگر (iterator) را تطبیق می‌دهیم تا همراه با هر مقدار، اندیس آن مقدار را نیز تولید کند؛ این دو مقدار در قالب یک tuple قرار می‌گیرند. اولین مقداری که تولید می‌شود tupleای به شکل (0, 'a') است. هنگامی که این مقدار با الگوی (index, value) تطبیق داده می‌شود، index برابر با 0 و value برابر با 'a' خواهد بود، و خط اول از خروجی چاپ می‌شود.

Function Parameters

پارامترهای تابع نیز می‌توانند الگو باشند. کد در فهرست 19-6، که تابعی به نام foo را تعریف می‌کند که یک پارامتر به نام x از نوع i32 می‌گیرد، باید تا الان آشنا به نظر برسد.

fn foo(x: i32) {
    // code goes here
}

fn main() {}
Listing 19-6: یک امضای تابع از الگوها در پارامترها استفاده می‌کند

قسمت x یک الگو است! همان‌طور که با let انجام دادیم، می‌توانیم یک tuple را در آرگومان‌های یک تابع با الگو مطابقت دهیم. فهرست 19-7 مقادیر یک tuple را هنگام ارسال به یک تابع تجزیه می‌کند.

Filename: src/main.rs
fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({x}, {y})");
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}
Listing 19-7: یک تابع با پارامترهایی که یک tuple را تخریب می‌کنند

این کد پیام Current location: (3, 5) را چاپ می‌کند. مقادیر &(3, 5) با الگوی &(x, y) مطابقت دارند، بنابراین x مقدار 3 و y مقدار 5 است.

ما همچنین می‌توانیم از الگوها در لیست پارامترهای closureها به همان روشی که در لیست پارامترهای تابع استفاده می‌کنیم، استفاده کنیم، زیرا closureها شبیه به توابع هستند، همان‌طور که در فصل 13 بحث شد.

تا اینجا، چندین روش برای استفاده از الگوها را دیده‌اید، اما الگوها در هر جایی که از آن‌ها استفاده کنیم به یک شکل کار نمی‌کنند. در برخی مکان‌ها، الگوها باید غیرقابل‌رد (irrefutable) باشند؛ در شرایط دیگر، می‌توانند قابل‌رد (refutable) باشند. در بخش بعدی این دو مفهوم را بررسی خواهیم کرد.

Refutability: Whether a Pattern Might Fail to Match

الگوها به دو شکل هستند: قابل‌رد (refutable) و غیرقابل‌رد (irrefutable). الگوهایی که برای هر مقدار ممکن مطابقت دارند غیرقابل‌رد هستند. به‌عنوان مثال، x در عبارت let x = 5;، زیرا x با هر چیزی مطابقت دارد و بنابراین نمی‌تواند از تطابق باز بماند. الگوهایی که ممکن است برای برخی مقادیر ممکن مطابقت نداشته باشند قابل‌رد هستند. به‌عنوان مثال، Some(x) در عبارت if let Some(x) = a_value، زیرا اگر مقدار در متغیر a_value None باشد به‌جای Some، الگوی Some(x) مطابقت نخواهد داشت.

پارامترهای تابع، عبارات let، و حلقه‌های for فقط می‌توانند الگوهای غیرقابل‌رد بپذیرند، زیرا برنامه نمی‌تواند کاری معنادار انجام دهد وقتی مقادیر مطابقت ندارند. عبارات if let و while let و عبارت let-else الگوهای قابل‌رد و غیرقابل‌رد را می‌پذیرند، اما کامپایلر درباره الگوهای غیرقابل‌رد هشدار می‌دهد زیرا به‌طور تعریف‌شده برای مدیریت شکست احتمالی طراحی شده‌اند: عملکرد شرطی در توانایی آن است که بسته به موفقیت یا شکست به‌طور متفاوت عمل کند.

به‌طور کلی، نباید نیازی به نگرانی در مورد تمایز بین الگوهای قابل‌رد و غیرقابل‌رد داشته باشید؛ با این حال، باید با مفهوم قابل‌رد بودن آشنا باشید تا بتوانید زمانی که آن را در یک پیام خطا می‌بینید، واکنش نشان دهید. در این موارد، باید یا الگو را تغییر دهید یا ساختاری که الگو را با آن استفاده می‌کنید، بسته به رفتار موردنظر کد تغییر دهید.

بیایید به مثالی نگاه کنیم که وقتی سعی می‌کنیم از یک الگوی قابل‌رد جایی که راست نیاز به یک الگوی غیرقابل‌رد دارد استفاده کنیم، و برعکس، چه اتفاقی می‌افتد. فهرست 19-8 یک عبارت let را نشان می‌دهد، اما برای الگو ما Some(x)، یک الگوی قابل‌رد مشخص کرده‌ایم. همان‌طور که ممکن است انتظار داشته باشید، این کد کامپایل نخواهد شد.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value;
}
Listing 19-8: تلاش برای استفاده از یک الگوی قابل‌رد با let

اگر مقدار some_option_value None باشد، مطابقت با الگوی Some(x) شکست خواهد خورد، به این معنا که الگو قابل‌رد است. با این حال، عبارت let فقط می‌تواند یک الگوی غیرقابل‌رد بپذیرد زیرا چیزی معتبر وجود ندارد که کد بتواند با مقدار None انجام دهد. در زمان کامپایل، راست شکایت می‌کند که ما سعی کرده‌ایم از یک الگوی قابل‌رد جایی که یک الگوی غیرقابل‌رد نیاز است استفاده کنیم:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(x) = some_option_value;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let else` to handle the variant that isn't matched
  |
3 |     let Some(x) = some_option_value else { todo!() };
  |                                     ++++++++++++++++

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

زیرا ما هر مقدار معتبری را با الگوی Some(x) پوشش ندادیم (و نمی‌توانستیم پوشش دهیم!)، راست به‌درستی یک خطای کامپایلر تولید می‌کند.

اگر یک الگوی قابل‌رد داشته باشیم جایی که یک الگوی غیرقابل‌رد نیاز است، می‌توانیم با تغییر کدی که از الگو استفاده می‌کند آن را رفع کنیم: به‌جای استفاده از let، می‌توانیم از if let استفاده کنیم. سپس اگر الگو مطابقت نداشته باشد، کد به‌سادگی از اجرای کد داخل آکولادها صرف‌نظر می‌کند و راهی برای ادامه معتبر فراهم می‌کند. فهرست 19-9 نشان می‌دهد که چگونه کد در فهرست 19-8 را رفع کنیم.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value else {
        return;
    };
}
Listing 19-9: استفاده از if let و یک بلوک با الگوهای قابل‌رد به‌جای let

ما به کد یک مسیر خروجی دادیم! این کد اکنون کاملاً معتبر است. با این حال، اگر به if let یک الگوی غیرقابل‌رد (الگویی که همیشه مطابقت دارد)، مانند x، بدهیم، همان‌طور که در فهرست 19-10 نشان داده شده است، کامپایلر یک هشدار خواهد داد.

fn main() {
    let x = 5 else {
        return;
    };
}
Listing 19-10: تلاش برای استفاده از یک الگوی غیرقابل‌رد با if let

راست شکایت می‌کند که استفاده از if let با یک الگوی غیرقابل‌رد منطقی نیست:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `let...else` pattern
 --> src/main.rs:2:5
  |
2 |     let x = 5 else {
  |     ^^^^^^^^^
  |
  = note: this pattern will always match, so the `else` clause is useless
  = help: consider removing the `else` clause
  = note: `#[warn(irrefutable_let_patterns)]` on by default

warning: `patterns` (bin "patterns") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/patterns`

به این دلیل، بازوهای match باید از الگوهای قابل‌رد استفاده کنند، به‌جز بازوی آخر که باید با یک الگوی غیرقابل‌رد هر مقدار باقی‌مانده را مطابقت دهد. راست به ما اجازه می‌دهد از یک الگوی غیرقابل‌رد در یک match با تنها یک بازو استفاده کنیم، اما این نحو به‌ویژه مفید نیست و می‌تواند با یک عبارت ساده‌تر let جایگزین شود.

اکنون که می‌دانید کجا می‌توان از الگوها استفاده کرد و تفاوت بین الگوهای قابل‌رد و غیرقابل‌رد چیست، بیایید تمام نحوهایی که می‌توانیم برای ایجاد الگوها استفاده کنیم را بررسی کنیم.

Pattern Syntax

In this section, we gather all the syntax that is valid in patterns and discuss why and when you might want to use each one.

Matching Literals

همان‌طور که در فصل 6 دیدید، می‌توانید الگوها را مستقیماً با مقادیر ثابت (literals) تطبیق دهید. کد زیر برخی از مثال‌ها را نشان می‌دهد:

fn main() {
    let x = 1;

    match x {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

این کد one را چاپ می‌کند زیرا مقدار در x برابر با 1 است. این نحو زمانی مفید است که بخواهید کد شما در صورت دریافت یک مقدار مشخص خاص اقدامی انجام دهد.

Matching Named Variables

متغیرهای نام‌گذاری‌شده الگوهای غیرقابل‌رد هستند که با هر مقداری مطابقت دارند، و ما بارها در این کتاب از آن‌ها استفاده کرده‌ایم. با این حال، زمانی که از متغیرهای نام‌گذاری‌شده در عبارات match، if let، یا while let استفاده می‌کنید، یک پیچیدگی وجود دارد. زیرا هر یک از این نوع عبارات یک دامنه جدید را شروع می‌کنند، متغیرهایی که به‌عنوان بخشی از یک الگو در داخل عبارت تعریف می‌شوند، متغیرهایی با همان نام در خارج را پوشش می‌دهند، همان‌طور که برای همه متغیرها صدق می‌کند. در فهرست 19-11، یک متغیر به نام x با مقدار Some(5) و یک متغیر y با مقدار 10 تعریف می‌کنیم. سپس یک عبارت match روی مقدار x ایجاد می‌کنیم. به الگوها در بازوهای match و دستور println! در انتها نگاه کنید و سعی کنید قبل از اجرای این کد یا خواندن بیشتر، حدس بزنید که کد چه چیزی را چاپ خواهد کرد.

Filename: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(y) => println!("Matched, y = {y}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}
Listing 19-11: یک عبارت match با بازویی که یک متغیر جدید معرفی می‌کند که متغیر موجود y را پوشش می‌دهد

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

الگوی موجود در بازوی دوم match یک متغیر جدید به نام y معرفی می‌کند که با هر مقداری درون یک Some مطابقت خواهد داشت. از آنجا که ما در یک دامنه جدید داخل عبارت match هستیم، این یک متغیر جدید y است، نه متغیری که در ابتدا با مقدار 10 تعریف کردیم. این binding جدید y با هر مقداری درون یک Some مطابقت دارد، که همان چیزی است که ما در x داریم. بنابراین، این y جدید به مقدار داخلی Some در x متصل می‌شود. آن مقدار 5 است، بنابراین عبارت برای آن بازو اجرا می‌شود و Matched, y = 5 را چاپ می‌کند.

اگر x به جای Some(5) یک مقدار None بود، الگوهای موجود در دو بازوی اول مطابقت نداشتند، بنابراین مقدار به علامت زیرخط (_) مطابقت داده می‌شد. ما متغیر x را در الگوی بازوی زیرخط معرفی نکردیم، بنابراین x در عبارت همچنان همان x خارجی است که پوشش داده نشده است. در این حالت فرضی، عبارت match پیام Default case, x = None را چاپ می‌کرد.

وقتی عبارت match تمام می‌شود، دامنه آن نیز پایان می‌یابد، و همین‌طور دامنه y داخلی. دستور println! آخر پیام at the end: x = Some(5), y = 10 را تولید می‌کند.

برای ایجاد یک عبارت match که مقادیر x و y بیرونی را مقایسه کند، به‌جای معرفی یک متغیر جدید که متغیر y موجود را سایه‌بان (shadow) کند، باید از یک شرط نگهبان match (match guard) استفاده کنیم. درباره‌ی نگهبان‌های match بعداً در بخش «شرط‌های اضافی با Match Guards» صحبت خواهیم کرد.

چند الگو (Multiple Patterns)

در عبارات match می‌توانید چندین الگو را با استفاده از سینتکس | که عملگر یا (or) برای الگوها است، با هم مقایسه کنید. برای مثال، در کد زیر مقدار x را با شاخه‌های match مقایسه می‌کنیم که اولین شاخه شامل گزینه‌ی یا است، یعنی اگر مقدار x با هر یک از مقادیر موجود در آن شاخه مطابقت داشته باشد، کد آن شاخه اجرا خواهد شد:

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

این کد one or two را چاپ می‌کند.

Matching Ranges of Values with ..=

نحو ..= به ما اجازه می‌دهد یک بازه شامل مقادیر را مطابقت دهیم. در کد زیر، وقتی یک الگو با هر کدام از مقادیر در بازه داده‌شده مطابقت داشته باشد، آن بازو اجرا خواهد شد:

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

اگر مقدار x برابر با 1، 2، 3، 4 یا 5 باشد، شاخه‌ی اول در match تطابق خواهد داشت. این نحو برای مقادیر متعدد در match بسیار راحت‌تر از استفاده‌ی مکرر از عملگر | است؛ زیرا اگر از | استفاده کنیم، باید به صورت 1 | 2 | 3 | 4 | 5 آن‌ها را مشخص کنیم. استفاده از بازه (range) بسیار کوتاه‌تر است، به‌ویژه اگر بخواهیم مثلاً هر عددی بین ۱ تا ۱۰۰۰ را تطبیق دهیم!

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

در اینجا یک مثال با استفاده از بازه‌هایی از مقادیر char آمده است:

fn main() {
    let x = 'c';

    match x {
        'a'..='j' => println!("early ASCII letter"),
        'k'..='z' => println!("late ASCII letter"),
        _ => println!("something else"),
    }
}

راست می‌تواند تشخیص دهد که 'c' در بازه الگوی اول است و پیام early ASCII letter را چاپ می‌کند.

Destructuring to Break Apart Values

ما همچنین می‌توانیم از الگوها برای تخریب (destructure) ساختارها (structs)، enums، و tuple‌ها استفاده کنیم تا از بخش‌های مختلف این مقادیر استفاده کنیم. بیایید به هر نوع مقدار نگاهی بیندازیم.

Destructuring Structs

فهرست 19-12 یک struct به نام Point را با دو فیلد، x و y نشان می‌دهد که می‌توانیم با استفاده از یک الگو در یک عبارت let آن را تخریب کنیم.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}
Listing 19-12: تخریب فیلدهای یک struct به متغیرهای جداگانه

این کد متغیرهای a و b را ایجاد می‌کند که با مقادیر فیلدهای x و y از struct p مطابقت دارند. این مثال نشان می‌دهد که نام متغیرها در الگو نیازی به مطابقت با نام فیلدهای struct ندارند. با این حال، معمولاً نام متغیرها با نام فیلدها مطابقت داده می‌شوند تا یادآوری اینکه کدام متغیرها از کدام فیلدها آمده‌اند آسان‌تر شود. به‌دلیل این استفاده معمول و به‌دلیل اینکه نوشتن let Point { x: x, y: y } = p; تکرار زیادی دارد، راست یک نحو کوتاه برای الگوهایی که فیلدهای struct را مطابقت می‌دهند فراهم می‌کند: فقط کافی است نام فیلد struct را لیست کنید و متغیرهایی که از الگو ایجاد می‌شوند همان نام‌ها را خواهند داشت. فهرست 19-13 به همان روشی که کد در فهرست 19-12 عمل می‌کند، اما متغیرهای ایجادشده در الگوی let به‌جای a و b، x و y هستند.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}
Listing 19-13: تخریب فیلدهای struct با استفاده از نحو کوتاه فیلد struct

این کد متغیرهای x و y را ایجاد می‌کند که با فیلدهای x و y از متغیر p مطابقت دارند. نتیجه این است که متغیرهای x و y مقادیر از ساختار p را شامل می‌شوند.

ما همچنین می‌توانیم با مقادیر ثابت (literals) به‌عنوان بخشی از الگوی struct تخریب کنیم، به‌جای ایجاد متغیرهایی برای همه فیلدها. انجام این کار به ما اجازه می‌دهد برخی از فیلدها را برای مقادیر خاصی تست کنیم، در حالی که متغیرهایی برای تخریب فیلدهای دیگر ایجاد می‌کنیم.

در فهرست 19-14، یک عبارت match داریم که مقادیر Point را به سه حالت تقسیم می‌کند: نقاطی که مستقیماً روی محور x قرار دارند (که در صورتی درست است که y = 0)، روی محور y (x = 0)، یا هیچ‌کدام.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {x}"),
        Point { x: 0, y } => println!("On the y axis at {y}"),
        Point { x, y } => {
            println!("On neither axis: ({x}, {y})");
        }
    }
}
Listing 19-14: تخریب و تطبیق مقادیر ثابت در یک الگو

بازوی اول هر نقطه‌ای که روی محور x قرار دارد را با مشخص کردن اینکه فیلد y در صورتی مطابقت دارد که مقدار آن با مقدار ثابت 0 مطابقت داشته باشد، تطبیق می‌دهد. الگو همچنان یک متغیر x ایجاد می‌کند که می‌توانیم در کد این بازو از آن استفاده کنیم.

به‌طور مشابه، بازوی دوم هر نقطه روی محور y را با مشخص کردن اینکه فیلد x در صورتی که مقدار آن 0 باشد مطابقت دارد و یک متغیر y برای مقدار فیلد y ایجاد می‌کند. بازوی سوم هیچ مقدار ثابتی را مشخص نمی‌کند، بنابراین هر Point دیگری را مطابقت می‌دهد و متغیرهایی برای هر دو فیلد x و y ایجاد می‌کند.

در این مثال، مقدار p به لطف x که مقدار 0 دارد، با بازوی دوم مطابقت دارد، بنابراین این کد پیام On the y axis at 7 را چاپ می‌کند.

به یاد داشته باشید که یک عبارت match پس از یافتن اولین الگوی مطابقت متوقف می‌شود، بنابراین حتی اگر Point { x: 0, y: 0 } روی محور x و محور y باشد، این کد فقط پیام On the x axis at 0 را چاپ خواهد کرد.

Destructuring Enums

ما در این کتاب enums را تخریب کرده‌ایم (برای مثال، فهرست 6-5 در فصل 6)، اما هنوز به‌طور خاص بحث نکرده‌ایم که الگوی تخریب یک enum مطابق با نحوه تعریف داده‌های ذخیره‌شده درون enum است. به‌عنوان مثال، در فهرست 19-15 از enum Message از فهرست 6-2 استفاده می‌کنیم و یک match با الگوهایی می‌نویسیم که هر مقدار داخلی را تخریب می‌کنند.

Filename: src/main.rs
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::ChangeColor(0, 160, 255);

    match msg {
        Message::Quit => {
            println!("The Quit variant has no data to destructure.");
        }
        Message::Move { x, y } => {
            println!("Move in the x direction {x} and in the y direction {y}");
        }
        Message::Write(text) => {
            println!("Text message: {text}");
        }
        Message::ChangeColor(r, g, b) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
    }
}
Listing 19-15: تخریب متغیرهای enum که مقادیر مختلفی دارند

این کد پیام Change the color to red 0, green 160, and blue 255 را چاپ می‌کند. مقدار msg را تغییر دهید تا کد از بازوهای دیگر اجرا شود.

برای متغیرهای enum بدون هیچ داده‌ای، مانند Message::Quit، نمی‌توان مقدار را بیشتر تخریب کرد. فقط می‌توان روی مقدار ثابت Message::Quit مطابقت داد، و هیچ متغیری در آن الگو وجود ندارد.

برای متغیرهای enum شبیه به struct، مانند Message::Move، می‌توانیم از الگویی مشابه الگوی مشخص‌شده برای تطبیق structs استفاده کنیم. پس از نام متغیر، آکولاد باز می‌کنیم و سپس فیلدها را با متغیرها لیست می‌کنیم تا بخش‌ها را برای استفاده در کد این بازو تجزیه کنیم. در اینجا از فرم کوتاه همان‌طور که در فهرست 19-13 استفاده کردیم استفاده می‌کنیم.

برای متغیرهای enum شبیه به tuple، مانند Message::Write که یک tuple با یک عنصر دارد و Message::ChangeColor که یک tuple با سه عنصر دارد، الگو مشابه الگویی است که برای تطبیق tuple‌ها مشخص می‌کنیم. تعداد متغیرها در الگو باید با تعداد عناصر در متغیر که تطبیق می‌دهیم مطابقت داشته باشد.

Destructuring Nested Structs and Enums

تاکنون، مثال‌های ما همه تطبیق ساختارها یا enums در یک سطح عمیق بوده‌اند، اما تطبیق می‌تواند روی آیتم‌های تو در تو نیز کار کند! برای مثال، می‌توانیم کد در فهرست 19-15 را بازسازی کنیم تا از رنگ‌های RGB و HSV در پیام ChangeColor پشتیبانی کند، همان‌طور که در فهرست 19-16 نشان داده شده است.

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!("Change color to hue {h}, saturation {s}, value {v}");
        }
        _ => (),
    }
}
Listing 19-16: تطبیق روی enums تو در تو

الگوی بازوی اول در عبارت match یک متغیر enum به نام Message::ChangeColor را تطبیق می‌دهد که شامل یک متغیر Color::Rgb است؛ سپس الگو به سه مقدار داخلی i32 متصل می‌شود. الگوی بازوی دوم نیز یک متغیر enum به نام Message::ChangeColor را تطبیق می‌دهد، اما enum داخلی به جای آن Color::Hsv را مطابقت می‌دهد. ما می‌توانیم این شرایط پیچیده را در یک عبارت match مشخص کنیم، حتی اگر دو enum درگیر باشند.

Destructuring Structs and Tuples

ما می‌توانیم الگوهای تخریب را به روش‌های پیچیده‌تر ترکیب، تطبیق و تو در تو کنیم. مثال زیر یک تخریب پیچیده را نشان می‌دهد که در آن ساختارها و tuple‌ها را داخل یک tuple تو در تو می‌کنیم و تمام مقادیر اولیه را تخریب می‌کنیم:

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }

    let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}

این کد به ما اجازه می‌دهد انواع پیچیده را به اجزای سازنده آن‌ها بشکنیم تا بتوانیم مقادیری که به آن‌ها علاقه داریم را جداگانه استفاده کنیم.

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

Ignoring Values in a Pattern

گاهی اوقات مفید است که مقادیر را در یک الگو نادیده بگیرید، مانند بازوی آخر یک match، برای دریافت یک catchall که هیچ کاری انجام نمی‌دهد اما تمام مقادیر باقی‌مانده ممکن را در نظر می‌گیرد. چندین روش برای نادیده گرفتن مقادیر کامل یا بخش‌هایی از مقادیر در یک الگو وجود دارد: استفاده از الگوی _ (که دیده‌اید)، استفاده از الگوی _ درون یک الگوی دیگر، استفاده از نامی که با یک زیرخط شروع می‌شود، یا استفاده از .. برای نادیده گرفتن بخش‌های باقی‌مانده یک مقدار. بیایید بررسی کنیم چگونه و چرا از هر یک از این الگوها استفاده کنیم.

An Entire Value with _

ما از زیرخط به‌عنوان یک الگوی wildcard استفاده کرده‌ایم که با هر مقداری مطابقت دارد اما به مقدار متصل نمی‌شود. این به‌ویژه به‌عنوان بازوی آخر در یک عبارت match مفید است، اما ما همچنین می‌توانیم آن را در هر الگویی استفاده کنیم، از جمله پارامترهای تابع، همان‌طور که در فهرست 19-17 نشان داده شده است.

Filename: src/main.rs
fn foo(_: i32, y: i32) {
    println!("This code only uses the y parameter: {y}");
}

fn main() {
    foo(3, 4);
}
Listing 19-17: استفاده از _ در یک امضای تابع

این کد مقدار 3 را که به‌عنوان آرگومان اول ارسال شده است، کاملاً نادیده می‌گیرد و پیام This code only uses the y parameter: 4 را چاپ می‌کند.

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

Parts of a Value with a Nested _

ما همچنین می‌توانیم از _ در داخل یک الگوی دیگر استفاده کنیم تا فقط بخشی از یک مقدار را نادیده بگیریم. برای مثال، وقتی می‌خواهیم فقط بخشی از یک مقدار را تست کنیم اما نیازی به استفاده از بخش‌های دیگر در کدی که می‌خواهیم اجرا کنیم نداریم. فهرست 19-18 کدی را نشان می‌دهد که مسئول مدیریت مقدار یک تنظیم است. نیازمندی‌های تجاری این است که کاربر نباید اجازه داشته باشد یک سفارشی‌سازی موجود برای یک تنظیم را بازنویسی کند، اما می‌تواند تنظیم را لغو کند و به آن یک مقدار بدهد اگر در حال حاضر لغو شده باشد.

fn main() {
    let mut setting_value = Some(5);
    let new_setting_value = Some(10);

    match (setting_value, new_setting_value) {
        (Some(_), Some(_)) => {
            println!("Can't overwrite an existing customized value");
        }
        _ => {
            setting_value = new_setting_value;
        }
    }

    println!("setting is {setting_value:?}");
}
Listing 19-18: استفاده از یک زیرخط در داخل الگوهایی که با متغیرهای Some مطابقت دارند وقتی نیازی به استفاده از مقدار داخل Some نداریم

این کد پیام Can't overwrite an existing customized value را چاپ می‌کند و سپس setting is Some(5) را چاپ می‌کند. در بازوی اول match، نیازی به مطابقت یا استفاده از مقادیر داخل هر یک از متغیرهای Some نداریم، اما باید حالت‌هایی را که در آن‌ها setting_value و new_setting_value در حالت Some هستند، تست کنیم. در این صورت، دلیل تغییر ندادن setting_value را چاپ می‌کنیم و این مقدار تغییر نمی‌کند.

در تمام موارد دیگر (اگر setting_value یا new_setting_value مقدار None داشته باشند) که توسط الگوی _ در بازوی دوم بیان شده است، می‌خواهیم اجازه دهیم new_setting_value به setting_value تبدیل شود.

ما همچنین می‌توانیم از زیرخط‌ها در مکان‌های مختلف در یک الگو برای نادیده گرفتن مقادیر خاص استفاده کنیم. فهرست 19-19 مثالی از نادیده گرفتن مقادیر دوم و چهارم در یک tuple پنج آیتمی را نشان می‌دهد.

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, _, third, _, fifth) => {
            println!("Some numbers: {first}, {third}, {fifth}");
        }
    }
}
Listing 19-19: نادیده گرفتن بخش‌های مختلف یک tuple

این کد پیام Some numbers: 2, 8, 32 را چاپ می‌کند و مقادیر 4 و 16 نادیده گرفته می‌شوند.

An Unused Variable by Starting Its Name with _

اگر یک متغیر ایجاد کنید اما از آن در هیچ جایی استفاده نکنید، راست معمولاً یک هشدار صادر می‌کند زیرا یک متغیر استفاده‌نشده ممکن است یک باگ باشد. با این حال، گاهی اوقات مفید است که بتوانید متغیری ایجاد کنید که هنوز از آن استفاده نمی‌کنید، مانند زمانی که در حال نمونه‌سازی یا تازه شروع یک پروژه هستید. در این وضعیت، می‌توانید به راست بگویید که درباره متغیر استفاده‌نشده هشدار ندهد، با شروع نام متغیر با یک زیرخط. در فهرست 19-20، دو متغیر استفاده‌نشده ایجاد می‌کنیم، اما وقتی این کد را کامپایل می‌کنیم، باید فقط یک هشدار درباره یکی از آن‌ها دریافت کنیم.

Filename: src/main.rs
fn main() {
    let _x = 5;
    let y = 10;
}
Listing 19-20: شروع نام متغیر با یک زیرخط برای جلوگیری از هشدارهای متغیر استفاده‌نشده

اینجا درباره استفاده نکردن از متغیر y یک هشدار دریافت می‌کنیم، اما درباره استفاده نکردن از _x هشدار نمی‌گیریم.

توجه داشته باشید که تفاوت ظریفی بین استفاده از فقط _ و استفاده از نامی که با یک زیرخط شروع می‌شود وجود دارد. نحو _x همچنان مقدار را به متغیر متصل می‌کند، در حالی که _ اصلاً متصل نمی‌شود. برای نشان دادن موردی که این تفاوت اهمیت دارد، فهرست 19-21 به ما یک خطا ارائه می‌دهد.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_s) = s {
        println!("found a string");
    }

    println!("{s:?}");
}
Listing 19-21: یک متغیر استفاده‌نشده که با یک زیرخط شروع می‌شود همچنان مقدار را متصل می‌کند، که ممکن است مالکیت مقدار را بگیرد

ما یک خطا دریافت خواهیم کرد زیرا مقدار s همچنان به _s منتقل می‌شود، که مانع از استفاده دوباره از s می‌شود. با این حال، استفاده از زیرخط به‌تنهایی هرگز به مقدار متصل نمی‌شود. فهرست 19-22 بدون هیچ خطایی کامپایل خواهد شد زیرا s به _ منتقل نمی‌شود.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_) = s {
        println!("found a string");
    }

    println!("{s:?}");
}
Listing 19-22: استفاده از یک زیرخط مقدار را متصل نمی‌کند

این کد به‌خوبی کار می‌کند زیرا ما هرگز s را به چیزی متصل نمی‌کنیم؛ بنابراین انتقال داده نمی‌شود.

Remaining Parts of a Value with ..

برای مقادیری که بخش‌های زیادی دارند، می‌توانیم از نحو .. برای استفاده از بخش‌های خاص و نادیده گرفتن باقی بخش‌ها استفاده کنیم، و نیازی به لیست کردن زیرخط‌ها برای هر مقدار نادیده گرفته‌شده نخواهیم داشت. الگوی .. هر بخشی از یک مقدار را که به‌طور صریح در بقیه الگو مطابقت داده نشده نادیده می‌گیرد. در فهرست 19-23، یک struct به نام Point داریم که یک مختصات در فضای سه‌بعدی نگه می‌دارد. در عبارت match، می‌خواهیم فقط روی مختصات x عمل کنیم و مقادیر موجود در فیلدهای y و z را نادیده بگیریم.

fn main() {
    struct Point {
        x: i32,
        y: i32,
        z: i32,
    }

    let origin = Point { x: 0, y: 0, z: 0 };

    match origin {
        Point { x, .. } => println!("x is {x}"),
    }
}
Listing 19-23: نادیده گرفتن تمام فیلدهای یک Point به‌جز x با استفاده از ..

ما مقدار x را فهرست می‌کنیم و سپس فقط الگوی .. را اضافه می‌کنیم. این سریع‌تر از این است که y: _ و z: _ را فهرست کنیم، به‌ویژه زمانی که با ساختارهایی کار می‌کنیم که فیلدهای زیادی دارند و فقط یکی یا دو فیلد مهم هستند.

نحو .. به هر تعداد مقداری که نیاز باشد گسترش می‌یابد. فهرست 19-24 نشان می‌دهد که چگونه از .. با یک tuple استفاده کنیم.

Filename: src/main.rs
fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, .., last) => {
            println!("Some numbers: {first}, {last}");
        }
    }
}
Listing 19-24: تطبیق فقط اولین و آخرین مقادیر در یک tuple و نادیده گرفتن تمام مقادیر دیگر

در این کد، مقدار اول و آخر با first و last مطابقت داده می‌شوند. الگوی .. تمام مقادیر میانی را مطابقت داده و نادیده می‌گیرد.

با این حال، استفاده از .. باید بدون ابهام باشد. اگر مشخص نباشد کدام مقادیر برای تطبیق و کدام برای نادیده گرفتن در نظر گرفته شده‌اند، راست به ما خطا می‌دهد. فهرست 19-25 مثالی از استفاده از .. به شکلی مبهم را نشان می‌دهد، بنابراین کامپایل نخواهد شد.

Filename: src/main.rs
fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (.., second, ..) => {
            println!("Some numbers: {second}")
        },
    }
}
Listing 19-25: تلاشی برای استفاده از .. به شکلی مبهم

وقتی این مثال را کامپایل می‌کنیم، این خطا را دریافت می‌کنیم:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error: `..` can only be used once per tuple pattern
 --> src/main.rs:5:22
  |
5 |         (.., second, ..) => {
  |          --          ^^ can only be used once per tuple pattern
  |          |
  |          previously used here

error: could not compile `patterns` (bin "patterns") due to 1 previous error

برای راست امکان‌پذیر نیست که تعیین کند چند مقدار در tuple باید نادیده گرفته شود قبل از اینکه یک مقدار را با second تطبیق دهد و سپس چند مقدار دیگر را بعد از آن نادیده بگیرد. این کد می‌تواند به این معنا باشد که می‌خواهیم 2 را نادیده بگیریم، second را به 4 متصل کنیم، و سپس 8، 16 و 32 را نادیده بگیریم؛ یا اینکه می‌خواهیم 2 و 4 را نادیده بگیریم، second را به 8 متصل کنیم، و سپس 16 و 32 را نادیده بگیریم؛ و غیره. نام متغیر second برای راست معنی خاصی ندارد، بنابراین به دلیل استفاده از .. در دو مکان به این شکل مبهم، خطای کامپایل دریافت می‌کنیم.

Extra Conditionals with Match Guards

یک match guard یک شرط اضافی if است که پس از الگو در یک بازوی match مشخص می‌شود و باید برای انتخاب آن بازو نیز مطابقت داشته باشد. Match guardها برای بیان ایده‌های پیچیده‌تر از آنچه که یک الگو به‌تنهایی اجازه می‌دهد، مفید هستند. این قابلیت فقط در عبارات match در دسترس است، نه در عبارات if let یا while let.

شرط می‌تواند از متغیرهایی که در الگو ایجاد شده‌اند استفاده کند. فهرست 19-26 یک match را نشان می‌دهد که بازوی اول آن دارای الگوی Some(x) است و همچنین دارای یک match guard if x % 2 == 0 است (که در صورتی که عدد زوج باشد، true خواهد بود).

fn main() {
    let num = Some(4);

    match num {
        Some(x) if x % 2 == 0 => println!("The number {x} is even"),
        Some(x) => println!("The number {x} is odd"),
        None => (),
    }
}
Listing 19-26: افزودن یک match guard به یک الگو

این مثال پیام The number 4 is even را چاپ می‌کند. وقتی num با الگوی بازوی اول مقایسه می‌شود، مطابقت دارد، زیرا Some(4) با Some(x) مطابقت دارد. سپس match guard بررسی می‌کند که آیا باقی‌مانده تقسیم x بر 2 برابر با 0 است یا نه، و چون این شرط برقرار است، بازوی اول انتخاب می‌شود.

اگر مقدار num برابر با Some(5) بود، match guard در بازوی اول false می‌شد زیرا باقی‌مانده تقسیم 5 بر 2 برابر با 1 است که برابر با 0 نیست. راست سپس به بازوی دوم می‌رود که مطابقت دارد زیرا بازوی دوم match guard ندارد و بنابراین با هر متغیر Some مطابقت دارد.

هیچ راهی برای بیان شرط if x % 2 == 0 در داخل یک الگو وجود ندارد، بنابراین match guard به ما امکان بیان این منطق را می‌دهد. نقطه ضعف این قابلیت اضافی این است که کامپایلر سعی نمی‌کند بررسی کند که آیا تمام موارد پوشش داده شده‌اند یا نه وقتی که match guardها درگیر هستند.

در فهرست 19-11 اشاره کردیم که می‌توانیم از match guardها برای حل مشکل shadowing الگو استفاده کنیم. به یاد بیاورید که ما یک متغیر جدید در داخل الگو در عبارت match ایجاد کردیم به جای استفاده از متغیر بیرون از match. آن متغیر جدید به این معنا بود که نمی‌توانستیم مقدار متغیر بیرونی را تست کنیم. فهرست 19-27 نشان می‌دهد که چگونه می‌توانیم از یک match guard برای رفع این مشکل استفاده کنیم.

Filename: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {n}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}
Listing 19-27: استفاده از یک match guard برای آزمایش برابری با یک متغیر بیرونی

این کد اکنون پیام Default case, x = Some(5) را چاپ می‌کند. الگوی بازوی دوم match یک متغیر جدید y که متغیر بیرونی y را shadow کند معرفی نمی‌کند، به این معنا که می‌توانیم از متغیر بیرونی y در match guard استفاده کنیم. به جای مشخص کردن الگو به‌عنوان Some(y) که متغیر بیرونی y را shadow می‌کرد، ما Some(n) را مشخص می‌کنیم. این یک متغیر جدید n ایجاد می‌کند که هیچ چیزی را shadow نمی‌کند زیرا هیچ متغیر n در خارج از match وجود ندارد.

Match guard if n == y یک الگو نیست و بنابراین متغیرهای جدیدی را معرفی نمی‌کند. این y همان متغیر بیرونی y است و یک متغیر جدید که آن را shadow کند نیست، و می‌توانیم با مقایسه n با y به دنبال مقداری باشیم که با مقدار بیرونی y یکسان باشد.

همچنین می‌توانید از عملگر یا | در یک match guard استفاده کنید تا چندین الگو مشخص کنید؛ شرط match guard برای تمام الگوها اعمال خواهد شد. فهرست 19-28 تقدم هنگام ترکیب یک الگو که از | استفاده می‌کند با یک match guard را نشان می‌دهد. بخش مهم این مثال این است که match guard if y برای 4، 5، و 6 اعمال می‌شود، حتی اگر ممکن است به نظر برسد که if y فقط برای 6 اعمال می‌شود.

fn main() {
    let x = 4;
    let y = false;

    match x {
        4 | 5 | 6 if y => println!("yes"),
        _ => println!("no"),
    }
}
Listing 19-28: ترکیب چندین الگو با یک match guard

شرط مطابقت بیان می‌کند که بازو فقط زمانی مطابقت دارد که مقدار x برابر با 4، 5، یا 6 و مقدار y برابر با true باشد. وقتی این کد اجرا می‌شود، الگوی بازوی اول مطابقت دارد زیرا x برابر با 4 است، اما match guard if y برابر با false است، بنابراین بازوی اول انتخاب نمی‌شود. کد به بازوی دوم می‌رود که مطابقت دارد، و این برنامه no را چاپ می‌کند. دلیل این است که شرط if برای کل الگوی 4 | 5 | 6 اعمال می‌شود، نه فقط برای مقدار آخر 6. به عبارت دیگر، تقدم یک match guard نسبت به یک الگو به این شکل رفتار می‌کند:

(4 | 5 | 6) if y => ...

و نه به این شکل:

4 | 5 | (6 if y) => ...

بعد از اجرای کد، رفتار تقدم آشکار می‌شود: اگر match guard فقط برای مقدار نهایی در لیست مقادیر مشخص‌شده با استفاده از عملگر | اعمال می‌شد، بازو مطابقت می‌داشت و برنامه پیام yes را چاپ می‌کرد.

@ Bindings

عملگر at (@) به ما امکان می‌دهد یک متغیر ایجاد کنیم که یک مقدار را نگه می‌دارد و همزمان آن مقدار را برای تطبیق با الگو آزمایش می‌کند. در فهرست 19-29، ما می‌خواهیم بررسی کنیم که آیا فیلد id در Message::Hello در بازه 3..=7 قرار دارد یا نه. همچنین می‌خواهیم مقدار را به متغیر id_variable متصل کنیم تا بتوانیم در کد مرتبط با بازو از آن استفاده کنیم. می‌توانستیم این متغیر را id بنامیم، مشابه فیلد، اما برای این مثال از نام متفاوتی استفاده خواهیم کرد.

fn main() {
    enum Message {
        Hello { id: i32 },
    }

    let msg = Message::Hello { id: 5 };

    match msg {
        Message::Hello { id: id @ 3..=7 } => {
            println!("Found an id in range: {id}")
        }
        Message::Hello { id: 10..=12 } => {
            println!("Found an id in another range")
        }
        Message::Hello { id } => println!("Found some other id: {id}"),
    }
}
Listing 19-29: استفاده از @ برای اتصال به یک مقدار در یک الگو و همزمان آزمایش آن

این مثال پیام Found an id in range: 5 را چاپ می‌کند. با مشخص کردن id_variable @ قبل از بازه 3..=7، ما هر مقداری که با بازه مطابقت داشت را ذخیره می‌کنیم و همزمان بررسی می‌کنیم که آیا مقدار با الگوی بازه مطابقت دارد.

در بازوی دوم، جایی که فقط یک بازه در الگو مشخص شده است، کدی که با بازو مرتبط است متغیری ندارد که مقدار واقعی فیلد id را شامل شود. مقدار فیلد id می‌توانست 10، 11، یا 12 باشد، اما کدی که با آن الگو مرتبط است نمی‌داند مقدار چیست. کد بازو نمی‌تواند از مقدار فیلد id استفاده کند، زیرا ما مقدار id را در یک متغیر ذخیره نکرده‌ایم.

در بازوی آخر، جایی که یک متغیر بدون بازه مشخص کرده‌ایم، مقدار برای استفاده در کد بازو در متغیری به نام id در دسترس است. دلیل این است که ما از نحو کوتاه فیلدهای struct استفاده کرده‌ایم. اما در این بازو هیچ آزمایشی برای مقدار در فیلد id اعمال نکرده‌ایم، همان‌طور که در دو بازوی اول انجام دادیم: هر مقداری با این الگو مطابقت خواهد داشت.

استفاده از @ به ما امکان می‌دهد یک مقدار را آزمایش کنیم و همزمان آن را در یک متغیر ذخیره کنیم، همه در یک الگو.

Summary

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

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

Advanced Features

تا اینجا، با پرکاربردترین بخش‌های زبان برنامه‌نویسی Rust آشنا شده‌اید. قبل از این‌که به یک پروژه‌ی دیگر بپردازیم، در فصل ۲۱ به چند جنبه از زبان نگاه خواهیم کرد که ممکن است گهگاه با آن‌ها مواجه شوید، اما شاید هر روز از آن‌ها استفاده نکنید. می‌توانید این فصل را به عنوان یک مرجع زمانی استفاده کنید که با موارد ناشناخته‌ای روبه‌رو می‌شوید. ویژگی‌های مطرح شده در این فصل در موقعیت‌های بسیار خاص کاربرد دارند. اگرچه ممکن است به‌ندرت به سراغ آن‌ها بروید، اما می‌خواهیم اطمینان حاصل کنیم که بر تمام قابلیت‌هایی که Rust ارائه می‌دهد تسلط دارید.

در این فصل، ما به موضوعات زیر خواهیم پرداخت:

  • Unsafe Rust: چگونه می‌توان از برخی از تضمین‌های راست چشم‌پوشی کرد و مسئولیت تضمین دستی این موارد را بر عهده گرفت.
  • Advanced traits: نوع‌های مرتبط (associated types)، پارامترهای نوع پیش‌فرض، نحو کاملاً واجد شرایط، ابر traits (supertraits)، و الگوی newtype در رابطه با traits.
  • Advanced types: بیشتر درباره الگوی newtype، نام مستعار نوع (type aliases)، نوع never، و نوع‌های با اندازه پویا.
  • Advanced functions and closures: اشاره‌گر (Pointer)های تابع و بازگرداندن closures.
  • Macros: روش‌هایی برای تعریف کدی که در زمان کامپایل کد بیشتری تعریف می‌کند.

این یک مجموعه گسترده از ویژگی‌های راست است که برای همه چیزی در آن وجود دارد! بیایید شروع کنیم!

Unsafe Rust

تمام کدی که تا به حال بررسی کرده‌ایم دارای تضمین‌های ایمنی حافظه راست بوده است که در زمان کامپایل اعمال می‌شوند. با این حال، راست دارای یک زبان دوم مخفی درون خود است که این تضمین‌های ایمنی حافظه را اعمال نمی‌کند: این زبان Unsafe Rust نامیده می‌شود و درست مانند راست معمولی کار می‌کند، اما به ما قدرت‌های فوق‌العاده‌ای می‌دهد.

وجود Unsafe Rust به این دلیل است که تحلیل ایستا ذاتاً محافظه‌کارانه است. وقتی کامپایلر سعی می‌کند تعیین کند که آیا کد تضمین‌ها را رعایت می‌کند یا نه، بهتر است برخی از برنامه‌های معتبر را رد کند تا اینکه برخی از برنامه‌های نامعتبر را بپذیرد. اگرچه ممکن است کد درست باشد، اما اگر کامپایلر راست اطلاعات کافی برای اطمینان نداشته باشد، کد را رد خواهد کرد. در این موارد، می‌توانید از کد ناامن برای گفتن به کامپایلر استفاده کنید: «به من اعتماد کن، من می‌دانم چه کار می‌کنم.» اما هشدار داده شود که شما از کد ناامن به مسئولیت خودتان استفاده می‌کنید: اگر از کد ناامن به‌طور نادرست استفاده کنید، مشکلاتی ممکن است به دلیل ناامنی حافظه ایجاد شوند، مانند dereferencing اشاره‌گر (Pointer) null.

دلیل دیگر وجود یک همزاد ناامن برای راست این است که سخت‌افزار کامپیوتر در ذات خود ناامن است. اگر راست به شما اجازه انجام عملیات ناامن را نمی‌داد، نمی‌توانستید برخی از وظایف را انجام دهید. راست باید به شما اجازه دهد تا برنامه‌نویسی سطح پایین سیستم، مانند تعامل مستقیم با سیستم‌عامل یا حتی نوشتن سیستم‌عامل خودتان را انجام دهید. کار با برنامه‌نویسی سطح پایین سیستم یکی از اهداف این زبان است. بیایید بررسی کنیم که با Unsafe Rust چه می‌توانیم انجام دهیم و چگونه باید این کار را انجام دهیم.

Unsafe Superpowers

برای تغییر به Unsafe Rust، از کلیدواژه unsafe استفاده کنید و سپس یک بلوک جدید که کد ناامن را نگه می‌دارد شروع کنید. در Unsafe Rust می‌توانید پنج عمل را انجام دهید که در راست امن نمی‌توانید، و ما این‌ها را قدرت‌های فوق‌العاده ناامن می‌نامیم. این قدرت‌ها شامل توانایی‌های زیر هستند:

  • Dereference a raw pointer
  • Call an unsafe function or method
  • Access or modify a mutable static variable
  • Implement an unsafe trait
  • Access fields of a union

It’s important to understand that unsafe doesn’t turn off the borrow checker or disable any other of Rust’s safety checks: if you use a reference in unsafe code, it will still be checked. The unsafe keyword only gives you access to these five features that are then not checked by the compiler for memory safety. You’ll still get some degree of safety inside of an unsafe block.

In addition, unsafe does not mean the code inside the block is necessarily dangerous or that it will definitely have memory safety problems: the intent is that as the programmer, you’ll ensure the code inside an unsafe block will access memory in a valid way.

People are fallible, and mistakes will happen, but by requiring these five unsafe operations to be inside blocks annotated with unsafe you’ll know that any errors related to memory safety must be within an unsafe block. Keep unsafe blocks small; you’ll be thankful later when you investigate memory bugs.

To isolate unsafe code as much as possible, it’s best to enclose unsafe code within a safe abstraction and provide a safe API, which we’ll discuss later in the chapter when we examine unsafe functions and methods. Parts of the standard library are implemented as safe abstractions over unsafe code that has been audited. Wrapping unsafe code in a safe abstraction prevents uses of unsafe from leaking out into all the places that you or your users might want to use the functionality implemented with unsafe code, because using a safe abstraction is safe.

Let’s look at each of the five unsafe superpowers in turn. We’ll also look at some abstractions that provide a safe interface to unsafe code.

Dereferencing a Raw Pointer

In Chapter 4, in the “Dangling References” section, we mentioned that the compiler ensures references are always valid. Unsafe Rust has two new types called raw pointers that are similar to references. As with references, raw pointers can be immutable or mutable and are written as *const T and *mut T, respectively. The asterisk isn’t the dereference operator; it’s part of the type name. In the context of raw pointers, immutable means that the pointer can’t be directly assigned to after being dereferenced.

Different from references and smart pointers, raw pointers:

  • مجاز به نادیده گرفتن قوانین borrowing هستند، به این صورت که می‌توانند هم اشاره‌گر (Pointer)های immutable و هم اشاره‌گر (Pointer)های mutable به همان مکان داشته باشند.
  • تضمینی برای اشاره به حافظه معتبر ندارند.
  • می‌توانند null باشند.
  • هیچ پاکسازی خودکاری را پیاده‌سازی نمی‌کنند.

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

فهرست 20-1 نشان می‌دهد که چگونه یک اشاره‌گر (Pointer) خام immutable و یک اشاره‌گر (Pointer) خام mutable ایجاد کنیم.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}
Listing 20-1: ایجاد اشاره‌گر (Pointer)های خام با عملگرهای raw borrow

Notice that we don’t include the unsafe keyword in this code. We can create raw pointers in safe code; we just can’t dereference raw pointers outside an unsafe block, as you’ll see in a bit.

We’ve created raw pointers by using the raw borrow operators: &raw const num creates a *const i32 immutable raw pointer, and &raw mut num creates a *mut i32 mutable raw pointer. Because we created them directly from a local variable, we know these particular raw pointers are valid, but we can’t make that assumption about just any raw pointer.

To demonstrate this, next we’ll create a raw pointer whose validity we can’t be so certain of, using as to cast a value instead of using the raw reference operators. Listing 20-2 shows how to create a raw pointer to an arbitrary location in memory. Trying to use arbitrary memory is undefined: there might be data at that address or there might not, the compiler might optimize the code so there is no memory access, or the program might error with a segmentation fault. Usually, there is no good reason to write code like this, especially in cases where you can use a raw borrow operator instead, but it is possible.

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}
Listing 20-2: ایجاد یک اشاره‌گر (Pointer) خام به یک آدرس حافظه دلخواه

به یاد داشته باشید که می‌توانیم اشاره‌گر (Pointer)های خام را در کد امن ایجاد کنیم، اما نمی‌توانیم اشاره‌گر (Pointer)های خام را dereference کنیم و داده‌ای که به آن اشاره شده را بخوانیم. در فهرست 20-3، ما از عملگر dereference (*) روی یک اشاره‌گر (Pointer) خام استفاده می‌کنیم که به یک بلوک unsafe نیاز دارد.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}
Listing 20-3: Dereferencing اشاره‌گر (Pointer)های خام درون یک بلوک unsafe

Creating a pointer does no harm; it’s only when we try to access the value that it points at that we might end up dealing with an invalid value.

Note also that in Listing 20-1 and 20-3, we created *const i32 and *mut i32 raw pointers that both pointed to the same memory location, where num is stored. If we instead tried to create an immutable and a mutable reference to num, the code would not have compiled because Rust’s ownership rules don’t allow a mutable reference at the same time as any immutable references. With raw pointers, we can create a mutable pointer and an immutable pointer to the same location and change data through the mutable pointer, potentially creating a data race. Be careful!

With all of these dangers, why would you ever use raw pointers? One major use case is when interfacing with C code, as you’ll see in the next section, “Calling an Unsafe Function or Method.” Another case is when building up safe abstractions that the borrow checker doesn’t understand. We’ll introduce unsafe functions and then look at an example of a safe abstraction that uses unsafe code.

Calling an Unsafe Function or Method

The second type of operation you can perform in an unsafe block is calling unsafe functions. Unsafe functions and methods look exactly like regular functions and methods, but they have an extra unsafe before the rest of the definition. The unsafe keyword in this context indicates the function has requirements we need to uphold when we call this function, because Rust can’t guarantee we’ve met these requirements. By calling an unsafe function within an unsafe block, we’re saying that we’ve read this function’s documentation and take responsibility for upholding the function’s contracts.

Here is an unsafe function named dangerous that doesn’t do anything in its body:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

ما باید تابع dangerous را در یک بلوک unsafe جداگانه فراخوانی کنیم. اگر سعی کنیم بدون بلوک unsafe تابع dangerous را فراخوانی کنیم، با خطا مواجه خواهیم شد:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

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

With the unsafe block, we’re asserting to Rust that we’ve read the function’s documentation, we understand how to use it properly, and we’ve verified that we’re fulfilling the contract of the function.

To perform unsafe operations in the body of an unsafe function, you still need to use an unsafe block just as within a regular function, and the compiler will warn you if you forget. This helps to keep unsafe blocks as small as possible, as unsafe operations may not be needed across the whole function body.

Creating a Safe Abstraction over Unsafe Code

فقط به این دلیل که یک تابع حاوی کد ناامن است به این معنا نیست که باید کل تابع را به‌عنوان ناامن علامت‌گذاری کنیم. در واقع، محصور کردن کد ناامن در یک تابع ایمن یک انتزاع رایج است. به‌عنوان مثال، بیایید تابع split_at_mut از کتابخانه استاندارد را بررسی کنیم که به کد ناامن نیاز دارد. ما بررسی خواهیم کرد که چگونه ممکن است آن را پیاده‌سازی کنیم. این متد ایمن روی برش‌های قابل تغییر (mutable slices) تعریف شده است: این تابع یک برش را می‌گیرد و آن را به دو قسمت تقسیم می‌کند با تقسیم کردن برش در ایندکسی که به‌عنوان آرگومان داده شده است. فهرست 20-4 نشان می‌دهد که چگونه از split_at_mut استفاده کنیم.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}
Listing 20-4: استفاده از تابع ایمن split_at_mut

ما نمی‌توانیم این تابع را فقط با استفاده از راست ایمن پیاده‌سازی کنیم. یک تلاش ممکن است چیزی شبیه به فهرست 20-5 باشد، که کامپایل نخواهد شد. برای سادگی، ما split_at_mut را به‌عنوان یک تابع پیاده‌سازی می‌کنیم نه یک متد، و فقط برای برش‌های i32 به‌جای یک نوع generic T.

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-5: تلاش برای پیاده‌سازی split_at_mut فقط با استفاده از راست ایمن

This function first gets the total length of the slice. Then it asserts that the index given as a parameter is within the slice by checking whether it’s less than or equal to the length. The assertion means that if we pass an index that is greater than the length to split the slice at, the function will panic before it attempts to use that index.

Then we return two mutable slices in a tuple: one from the start of the original slice to the mid index and another from mid to the end of the slice.

When we try to compile the code in Listing 20-5, we’ll get an error.

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

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

Rust’s borrow checker نمی‌تواند بفهمد که ما در حال قرض گرفتن قسمت‌های مختلفی از یک برش هستیم؛ تنها چیزی که می‌داند این است که ما دو بار از همان برش قرض گرفته‌ایم. قرض گرفتن قسمت‌های مختلف یک برش اصولاً اشکالی ندارد، زیرا این دو برش با یکدیگر هم‌پوشانی ندارند، اما Rust به‌اندازه کافی هوشمند نیست که این موضوع را بداند. وقتی می‌دانیم کد مشکلی ندارد، اما Rust نمی‌داند، زمان استفاده از کد ناامن فرا می‌رسد.

فهرست 20-6 نشان می‌دهد که چگونه از یک بلوک unsafe، یک اشاره‌گر (Pointer) خام، و چند فراخوانی به توابع ناامن برای اجرای تابع split_at_mut استفاده کنیم.

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-6: استفاده از کد ناامن در پیاده‌سازی تابع split_at_mut

Recall from “The Slice Type” section in Chapter 4 that slices are a pointer to some data and the length of the slice. We use the len method to get the length of a slice and the as_mut_ptr method to access the raw pointer of a slice. In this case, because we have a mutable slice to i32 values, as_mut_ptr returns a raw pointer with the type *mut i32, which we’ve stored in the variable ptr.

We keep the assertion that the mid index is within the slice. Then we get to the unsafe code: the slice::from_raw_parts_mut function takes a raw pointer and a length, and it creates a slice. We use this function to create a slice that starts from ptr and is mid items long. Then we call the add method on ptr with mid as an argument to get a raw pointer that starts at mid, and we create a slice using that pointer and the remaining number of items after mid as the length.

The function slice::from_raw_parts_mut is unsafe because it takes a raw pointer and must trust that this pointer is valid. The add method on raw pointers is also unsafe, because it must trust that the offset location is also a valid pointer. Therefore, we had to put an unsafe block around our calls to slice::from_raw_parts_mut and add so we could call them. By looking at the code and by adding the assertion that mid must be less than or equal to len, we can tell that all the raw pointers used within the unsafe block will be valid pointers to data within the slice. This is an acceptable and appropriate use of unsafe.

Note that we don’t need to mark the resulting split_at_mut function as unsafe, and we can call this function from safe Rust. We’ve created a safe abstraction to the unsafe code with an implementation of the function that uses unsafe code in a safe way, because it creates only valid pointers from the data this function has access to.

In contrast, the use of slice::from_raw_parts_mut in Listing 20-7 would likely crash when the slice is used. This code takes an arbitrary memory location and creates a slice 10,000 items long.

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
Listing 20-7: ایجاد یک برش از یک مکان حافظه دلخواه

ما مالک حافظه در این مکان دلخواه نیستیم و هیچ تضمینی وجود ندارد که برشی که این کد ایجاد می‌کند حاوی مقادیر معتبر i32 باشد. تلاش برای استفاده از values به‌عنوان اینکه یک برش معتبر است منجر به رفتار تعریف‌نشده می‌شود.

Using extern Functions to Call External Code

Sometimes, your Rust code might need to interact with code written in another language. For this, Rust has the keyword extern that facilitates the creation and use of a Foreign Function Interface (FFI). An FFI is a way for a programming language to define functions and enable a different (foreign) programming language to call those functions.

Listing 20-8 demonstrates how to set up an integration with the abs function from the C standard library. Functions declared within extern blocks are usually unsafe to call from Rust code, so they must also be marked unsafe. The reason is that other languages don’t enforce Rust’s rules and guarantees, and Rust can’t check them, so responsibility falls on the programmer to ensure safety.

Filename: src/main.rs
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
Listing 20-8: اعلام و فراخوانی یک تابع extern تعریف‌شده در زبان دیگر

Within the unsafe extern "C" block, we list the names and signatures of external functions from another language we want to call. The "C" part defines which application binary interface (ABI) the external function uses: the ABI defines how to call the function at the assembly level. The "C" ABI is the most common and follows the C programming language’s ABI.

This particular function does not have any memory safety considerations, though. In fact, we know that any call to abs will always be safe for any i32, so we can use the safe keyword to say that this specific function is safe to call even though it is in an unsafe extern block. Once we make that change, calling it no longer requires an unsafe block, as shown in Listing 20-9.

Filename: src/main.rs
unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}
Listing 20-9: علامت‌گذاری صریح یک تابع به‌عنوان safe درون یک بلوک unsafe extern و فراخوانی ایمن آن

Marking a function as safe does not inherently make it safe! Instead, it is like a promise you are making to Rust that it is safe. It is still your responsibility to make sure that promise is kept!

Calling Rust Functions from Other Languages

We can also use extern to create an interface that allows other languages to call Rust functions. Instead of creating a whole extern block, we add the extern keyword and specify the ABI to use just before the fn keyword for the relevant function. We also need to add a #[unsafe(no_mangle)] annotation to tell the Rust compiler not to mangle the name of this function. Mangling is when a compiler changes the name we’ve given a function to a different name that contains more information for other parts of the compilation process to consume but is less human readable. Every programming language compiler mangles names slightly differently, so for a Rust function to be nameable by other languages, we must disable the Rust compiler’s name mangling. This is unsafe because there might be name collisions across libraries without the built-in mangling, so it is our responsibility to make sure the name we have exported is safe to export without mangling.

In the following example, we make the call_from_c function accessible from C code, after it’s compiled to a shared library and linked from C:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

This usage of extern does not require unsafe.

Accessing or Modifying a Mutable Static Variable

In this book, we’ve not yet talked about global variables, which Rust does support but can be problematic with Rust’s ownership rules. If two threads are accessing the same mutable global variable, it can cause a data race.

در راست، متغیرهای جهانی static نامیده می‌شوند. فهرست 20-10 یک مثال از اعلام و استفاده از یک متغیر static با یک string slice به‌عنوان مقدار را نشان می‌دهد.

Filename: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("value is: {HELLO_WORLD}");
}
Listing 20-10: تعریف و استفاده از یک متغیر static غیرقابل تغییر

Static variables are similar to constants, which we discussed in the “Constants” section in Chapter 3. The names of static variables are in SCREAMING_SNAKE_CASE by convention. Static variables can only store references with the 'static lifetime, which means the Rust compiler can figure out the lifetime and we aren’t required to annotate it explicitly. Accessing an immutable static variable is safe.

A subtle difference between constants and immutable static variables is that values in a static variable have a fixed address in memory. Using the value will always access the same data. Constants, on the other hand, are allowed to duplicate their data whenever they’re used. Another difference is that static variables can be mutable. Accessing and modifying mutable static variables is unsafe. Listing 20-11 shows how to declare, access, and modify a mutable static variable named COUNTER.

Filename: src/main.rs
static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}
Listing 20-11: Reading from or writing to a mutable static variable is unsafe

As with regular variables, we specify mutability using the mut keyword. Any code that reads or writes from COUNTER must be within an unsafe block. The code in Listing 20-11 compiles and prints COUNTER: 3 as we would expect because it’s single threaded. Having multiple threads access COUNTER would likely result in data races, so it is undefined behavior. Therefore, we need to mark the entire function as unsafe, and document the safety limitation, so anyone calling the function knows what they are and are not allowed to do safely.

Whenever we write an unsafe function, it is idiomatic to write a comment starting with SAFETY and explaining what the caller needs to do to call the function safely. Likewise, whenever we perform an unsafe operation, it is idiomatic to write a comment starting with SAFETY to explain how the safety rules are upheld.

Additionally, the compiler will not allow you to create references to a mutable static variable. You can only access it via a raw pointer, created with one of the raw borrow operators. That includes in cases where the reference is created invisibly, as when it is used in the println! in this code listing. The requirement that references to static mutable variables can only be created via raw pointers helps make the safety requirements for using them more obvious.

With mutable data that is globally accessible, it’s difficult to ensure there are no data races, which is why Rust considers mutable static variables to be unsafe. Where possible, it’s preferable to use the concurrency techniques and thread-safe smart pointers we discussed in Chapter 16 so the compiler checks that data accessed from different threads is done safely.

Implementing an Unsafe Trait

می‌توانیم از unsafe برای پیاده‌سازی یک trait ناامن استفاده کنیم. یک trait زمانی ناامن است که حداقل یکی از متدهای آن دارای یک قاعده (invariant) باشد که کامپایلر نمی‌تواند آن را تأیید کند. ما با افزودن کلیدواژه unsafe قبل از trait اعلام می‌کنیم که یک trait ناامن است و پیاده‌سازی آن trait را نیز به‌عنوان unsafe علامت‌گذاری می‌کنیم، همان‌طور که در فهرست 20-12 نشان داده شده است.

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}
Listing 20-12: تعریف و پیاده‌سازی یک trait ناامن

By using unsafe impl, we’re promising that we’ll uphold the invariants that the compiler can’t verify.

As an example, recall the Sync and Send marker traits we discussed in the [“Extensible Concurrency with the Sync and Send Traits”][extensible-concurrency-with-the-sync-and-send-traits] section in Chapter 16: the compiler implements these traits automatically if our types are composed entirely of Send and Sync types. If we implement a type that contains a type that is not Send or Sync, such as raw pointers, and we want to mark that type as Send or Sync, we must use unsafe. Rust can’t verify that our type upholds the guarantees that it can be safely sent across threads or accessed from multiple threads; therefore, we need to do those checks manually and indicate as such with unsafe.

Accessing Fields of a Union

The final action that works only with unsafe is accessing fields of a union. A union is similar to a struct, but only one declared field is used in a particular instance at one time. Unions are primarily used to interface with unions in C code. Accessing union fields is unsafe because Rust can’t guarantee the type of the data currently being stored in the union instance. You can learn more about unions in [the Rust Reference][reference].

Using Miri to Check Unsafe Code

When writing unsafe code, you might want to check that what you have written actually is safe and correct. One of the best ways to do that is to use Miri, an official Rust tool for detecting undefined behavior. Whereas the borrow checker is a static tool which works at compile time, Miri is a dynamic tool which works at runtime. It checks your code by running your program, or its test suite, and detecting when you violate the rules it understands about how Rust should work.

Using Miri requires a nightly build of Rust (which we talk about more in Appendix G: How Rust is Made and “Nightly Rust”). You can install both a nightly version of Rust and the Miri tool by typing rustup +nightly component add miri. This does not change what version of Rust your project uses; it only adds the tool to your system so you can use it when you want to. You can run Miri on a project by typing cargo +nightly miri run or cargo +nightly miri test.

For an example of how helpful this can be, consider what happens when we run it against Listing 20-11:

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
warning: integer-to-pointer cast
 --> src/main.rs:5:13
  |
5 |     let r = address as *mut i32;
  |             ^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
  |
  = help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program
  = help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation
  = help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead
  = help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics
  = help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning
  = note: BACKTRACE:
  = note: inside `main` at src/main.rs:5:13: 5:32

error: Undefined Behavior: pointer not dereferenceable: pointer must be dereferenceable for 40000 bytes, but got 0x1234[noalloc] which is a dangling pointer (it has no provenance)
 --> src/main.rs:7:35
  |
7 |     let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
  |                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
  |
  = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
  = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
  = note: BACKTRACE:
  = note: inside `main` at src/main.rs:7:35: 7:70

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error; 1 warning emitted

It helpfully and correctly notices that we have shared references to mutable data, and warns about it. In this case, it does not tell us how to fix the problem, but it means that we know there is a possible issue and can think about how to make sure it is safe. In other cases, it can actually tell us that some code is sure to be wrong and make recommendations about how to fix it.

Miri doesn’t catch everything you might get wrong when writing unsafe code. For one thing, since it is a dynamic check, it only catches problems with code that actually gets run. That means you will need to use it in conjunction with good testing techniques to increase your confidence about the unsafe code you have written. For another thing, it does not cover every possible way your code can be unsound. If Miri does catch a problem, you know there’s a bug, but just because Miri doesn’t catch a bug doesn’t mean there isn’t a problem. Miri can catch a lot, though. Try running it on the other examples of unsafe code in this chapter and see what it says!

When to Use Unsafe Code

Using unsafe to take one of the five actions (superpowers) just discussed isn’t wrong or even frowned upon. But it is trickier to get unsafe code correct because the compiler can’t help uphold memory safety. When you have a reason to use unsafe code, you can do so, and having the explicit unsafe annotation makes it easier to track down the source of problems when they occur. Whenever you write unsafe code, you can use Miri to help you be more confident that the code you have written upholds Rust’s rules.

For a much deeper exploration of how to work effectively with unsafe Rust, read Rust’s official guide to the subject, the Rustonomicon.

Advanced Traits

ما ابتدا traitها را در بخش «Traits: تعریف رفتار مشترک» در فصل ۱۰ بررسی کردیم، اما وارد جزئیات پیشرفته‌تر آن نشدیم. اکنون که با Rust بیشتر آشنا شده‌اید، می‌توانیم به نکات دقیق‌تر و تخصصی‌تر بپردازیم.

Associated Types

نوع‌های مرتبط (Associated types) یک نوع جایگزین را با یک trait متصل می‌کنند، به‌گونه‌ای که تعریف‌های متد trait می‌توانند از این نوع‌های جایگزین در امضاهای خود استفاده کنند. پیاده‌ساز یک trait نوع خاصی را برای جایگزینی نوع جایگزین برای پیاده‌سازی خاص مشخص می‌کند. به این ترتیب، می‌توانیم یک trait تعریف کنیم که از برخی نوع‌ها استفاده می‌کند بدون اینکه نیاز داشته باشیم دقیقاً بدانیم این نوع‌ها چه هستند تا زمانی که trait پیاده‌سازی شود.

بیشتر ویژگی‌های پیشرفته‌ای که در این فصل توضیح داده شده است، به‌ندرت مورد نیاز هستند. نوع‌های مرتبط در حد وسط قرار دارند: آن‌ها کمتر از ویژگی‌های توضیح داده‌شده در بقیه کتاب استفاده می‌شوند، اما بیشتر از بسیاری از ویژگی‌های دیگر مورد بحث در این فصل به کار می‌روند.

یکی از مثال‌های یک trait با یک نوع مرتبط، trait Iterator است که کتابخانه استاندارد فراهم می‌کند. نوع مرتبط با نام Item مشخص شده و به‌جای نوع مقادیری که نوع پیاده‌سازی‌کننده Iterator از روی آن‌ها تکرار می‌کند قرار می‌گیرد. تعریف trait Iterator همان‌طور که در فهرست 20-13 نشان داده شده است:

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
Listing 20-13: تعریف trait Iterator که دارای یک نوع مرتبط به نام Item است

نوع Item یک جایگزین است و تعریف متد next نشان می‌دهد که مقادیری از نوع Option<Self::Item> را بازمی‌گرداند. پیاده‌سازان trait Iterator نوع خاصی را برای Item مشخص می‌کنند و متد next یک Option حاوی مقدار از آن نوع خاص بازمی‌گرداند.

نوع‌های مرتبط ممکن است مفهومی مشابه با genericها به نظر برسند، به این معنا که genericها به ما اجازه می‌دهند یک تابع بدون مشخص کردن نوع‌هایی که می‌تواند با آن‌ها کار کند، تعریف کنیم. برای بررسی تفاوت بین این دو مفهوم، به یک پیاده‌سازی trait Iterator روی یک نوع به نام Counter نگاه خواهیم کرد که نوع Item را به‌عنوان u32 مشخص می‌کند:

Filename: src/lib.rs
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

این سینتکس با سینتکس genericها قابل مقایسه به نظر می‌رسد. پس چرا به جای این کار، trait Iterator را با استفاده از genericها تعریف نکنیم، همان‌طور که در فهرست 20-14 نشان داده شده است؟

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Listing 20-14: یک تعریف فرضی از trait Iterator با استفاده از genericها

تفاوت این است که هنگام استفاده از genericها، همان‌طور که در فهرست 20-14 نشان داده شده است، ما باید نوع‌ها را در هر پیاده‌سازی حاشیه‌نویسی کنیم. زیرا می‌توانیم همچنین Iterator<String> for Counter یا هر نوع دیگری را پیاده‌سازی کنیم، به‌طوری که بتوانیم پیاده‌سازی‌های متعددی از Iterator برای Counter داشته باشیم. به عبارت دیگر، زمانی که یک trait یک پارامتر generic دارد، می‌تواند برای یک نوع چندین بار پیاده‌سازی شود و نوع‌های خاص پارامترهای generic را هر بار تغییر دهد. زمانی که ما از متد next بر روی Counter استفاده می‌کنیم، باید حاشیه‌نویسی نوع ارائه دهیم تا مشخص کنیم کدام پیاده‌سازی Iterator را می‌خواهیم استفاده کنیم.

با استفاده از نوع‌های وابسته (associated types)، نیازی به مشخص‌کردن نوع‌ها نداریم، زیرا نمی‌توان یک trait را چند بار برای یک نوع پیاده‌سازی کرد. در لیستینگ 20-13، با تعریفی که از نوع‌های وابسته استفاده می‌کند، تنها یک‌بار می‌توانیم مشخص کنیم که نوع Item چه چیزی خواهد بود، چرا که تنها یک impl Iterator for Counter می‌تواند وجود داشته باشد. بنابراین، لازم نیست هر بار که روی Counter تابع next را صدا می‌زنیم، مشخص کنیم که می‌خواهیم یک iterator از نوع u32 داشته باشیم.

نوع‌های وابسته همچنین بخشی از قرارداد trait محسوب می‌شوند: پیاده‌سازان یک trait باید نوعی را به‌جای جای‌نگهدار (placeholder) نوع وابسته ارائه دهند. معمولاً نام نوع‌های وابسته به‌گونه‌ای انتخاب می‌شود که نشان دهد چگونه از آن نوع استفاده خواهد شد، و مستندسازی نوع‌های وابسته در مستندات API یک کار بسیار خوب و توصیه‌شده است.

Default Generic Type Parameters and Operator Overloading

وقتی که از پارامترهای generic type استفاده می‌کنیم، می‌توانیم یک نوع خاص پیش‌فرض برای پارامتر generic تعیین کنیم. این نیاز به مشخص کردن یک نوع خاص توسط پیاده‌سازان trait را در صورتی که نوع پیش‌فرض کار کند، از بین می‌برد. شما می‌توانید هنگام اعلام یک نوع generic، یک نوع پیش‌فرض با سینتکس <PlaceholderType=ConcreteType> مشخص کنید.

یک مثال عالی از وضعیتی که این تکنیک مفید است، بارگذاری مجدد عملگرها است، جایی که شما رفتار یک عملگر (مانند +) را در شرایط خاص شخصی‌سازی می‌کنید.

زبان Rust اجازه نمی‌دهد که عملگرهای دلخواه خودتان را ایجاد کرده یا هر عملگری را به‌دلخواه overload کنید. اما می‌توانید عملیات و traitهای متناظر فهرست‌شده در std::ops را با پیاده‌سازی traitهای مربوط به آن عملگر overload کنید. برای مثال، در لیستینگ 20-15 عملگر + را overload می‌کنیم تا دو نمونه از Point را با یکدیگر جمع کنیم. این کار را با پیاده‌سازی trait Add برای ساختار Point انجام می‌دهیم.

Filename: src/main.rs
use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}
Listing 20-15: پیاده‌سازی trait Add برای بارگذاری مجدد عملگر + برای نمونه‌های Point

متد add مقادیر x دو نمونه Point و مقادیر y دو نمونه Point را اضافه می‌کند تا یک نمونه جدید از Point ایجاد کند. trait Add دارای یک نوع مرتبط با نام Output است که نوع بازگشتی از متد add را تعیین می‌کند.

نوع generic پیش‌فرض در این کد در داخل trait Add است. در اینجا تعریف آن آمده است:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

این کد باید به‌طور کلی آشنا به نظر برسد: یک trait با یک متد و یک نوع وابسته. بخش جدید Rhs=Self است؛ این نحوی به پارامتر نوع پیش‌فرض (default type parameters) معروف است. پارامتر نوع generic با نام Rhs (مخفف “right-hand side”) نوع پارامتر rhs در متد add را تعریف می‌کند. اگر هنگام پیاده‌سازی trait Add نوع مشخصی برای Rhs تعیین نکنیم، مقدار پیش‌فرض Rhs برابر با Self خواهد بود، یعنی همان نوعی که در حال پیاده‌سازی Add برای آن هستیم.

هنگامی که Add را برای Point پیاده‌سازی کردیم، از پیش‌فرض برای Rhs استفاده کردیم زیرا می‌خواستیم دو نمونه Point را به هم اضافه کنیم. حال، بیایید به مثالی از پیاده‌سازی trait Add نگاه کنیم که در آن می‌خواهیم نوع Rhs را شخصی‌سازی کنیم و از پیش‌فرض استفاده نکنیم.

ما دو struct به نام‌های Millimeters و Meters داریم که مقادیر را در واحدهای مختلف نگهداری می‌کنند. این بسته‌بندی نازک یک نوع موجود درون یک struct دیگر به الگوی newtype معروف است، که در بخش [«استفاده از الگوی Newtype برای پیاده‌سازی Traitهای خارجی»][newtype] به‌صورت دقیق‌تر توضیح داده‌ایم. ما می‌خواهیم مقادیر millimeters را با مقادیر meters جمع کنیم و پیاده‌سازی Add تبدیل واحد را به‌درستی انجام دهد. می‌توانیم Add را برای Millimeters پیاده‌سازی کنیم به‌گونه‌ای که Meters به‌عنوان Rhs استفاده شود، همان‌طور که در لیستینگ 20-16 نشان داده شده است.

Filename: src/lib.rs
use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}
Listing 20-16: پیاده‌سازی trait Add برای Millimeters جهت افزودن Millimeters به Meters

برای افزودن Millimeters و Meters، ما impl Add<Meters> را مشخص می‌کنیم تا مقدار پارامتر نوع Rhs را به جای استفاده از پیش‌فرض Self تنظیم کنیم.

شما از پارامترهای نوع پیش‌فرض در دو حالت اصلی استفاده خواهید کرد:

۱. برای گسترش یک نوع بدون آن‌که کد موجود را دچار شکست کنیم ۲. برای فراهم‌کردن امکان سفارشی‌سازی در موارد خاصی که اکثر کاربران به آن نیازی نخواهند داشت

trait Add در کتابخانه استاندارد یک مثال از هدف دوم است: معمولاً شما دو نوع مشابه را اضافه خواهید کرد، اما trait Add قابلیت شخصی‌سازی فراتر از آن را فراهم می‌کند. استفاده از پارامتر نوع پیش‌فرض در تعریف trait Add به این معناست که شما بیشتر اوقات نیازی به مشخص کردن پارامتر اضافی ندارید. به عبارت دیگر، مقدار کمی از کد اضافی حذف می‌شود و استفاده از trait آسان‌تر می‌شود.

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

Disambiguating Between Methods with the Same Name

در راست هیچ محدودیتی برای داشتن یک متد با همان نام در یک trait و در نوعی دیگر وجود ندارد و همچنین راست مانع نمی‌شود که هر دو trait را بر روی یک نوع پیاده‌سازی کنید. همچنین می‌توانید متدی را مستقیماً بر روی نوعی پیاده‌سازی کنید که همان نام متدهای مربوط به traits را دارد.

هنگام فراخوانی متدهایی با همان نام، باید به راست بگویید که کدام یک را می‌خواهید استفاده کنید. کد زیر در فهرست 20-17 را در نظر بگیرید که در آن دو trait به نام‌های Pilot و Wizard تعریف شده‌اند که هر دو دارای متدی به نام fly هستند. سپس هر دو trait بر روی نوع Human پیاده‌سازی می‌شوند که قبلاً متدی به نام fly نیز بر روی آن پیاده‌سازی شده است. هر متد fly کاری متفاوت انجام می‌دهد.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {}
Listing 20-17: دو trait تعریف شده‌اند که یک متد مشترک دارند و بر روی نوع Human پیاده‌سازی شده‌اند، و یک متد fly به‌طور مستقیم بر روی Human پیاده‌سازی شده است

وقتی متد fly را بر روی یک نمونه از Human فراخوانی می‌کنیم، کامپایلر به طور پیش‌فرض متدی را که مستقیماً بر روی نوع پیاده‌سازی شده است، فراخوانی می‌کند، همان‌طور که در فهرست 20-18 نشان داده شده است.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}
Listing 20-18: فراخوانی fly بر روی یک نمونه از Human

اجرای این کد متن *waving arms furiously* را چاپ می‌کند و نشان می‌دهد که راست متد fly پیاده‌سازی‌شده بر روی Human را مستقیماً فراخوانی کرده است.

برای فراخوانی متدهای fly از Pilot یا Wizard، باید از سینتکس صریح‌تری برای مشخص کردن متدی که منظور ماست، استفاده کنیم. فهرست 20-19 این سینتکس را نشان می‌دهد.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}
Listing 20-19: مشخص کردن متد fly مربوط به کدام trait را می‌خواهیم فراخوانی کنیم

مشخص کردن نام trait قبل از نام متد، به راست مشخص می‌کند که کدام پیاده‌سازی متد fly را می‌خواهیم فراخوانی کنیم. همچنین می‌توانیم Human::fly(&person) بنویسیم که معادل با person.fly() است که در فهرست 20-19 استفاده کرده‌ایم، اما اگر نیازی به رفع ابهام نباشد، این روش کمی طولانی‌تر است.

اجرای این کد خروجی زیر را چاپ می‌کند:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

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

با این حال، توابع وابسته‌ای (associated functions) که متد نیستند، پارامتر self ندارند. زمانی که چندین نوع یا trait توابع غیرمتدی با نام یکسان تعریف می‌کنند، Rust همیشه نمی‌تواند تشخیص دهد که منظور شما کدام نوع است، مگر آن‌که از نحوی به‌نام fully qualified syntax استفاده کنید. برای مثال، در لیستینگ 20-20 یک trait برای یک پناهگاه حیوانات ایجاد می‌کنیم که می‌خواهد نام تمام توله‌سگ‌ها را Spot بگذارد. یک trait به‌نام Animal تعریف می‌کنیم که شامل یک تابع وابسته غیرمتدی baby_name است. این trait برای structی به‌نام Dog پیاده‌سازی می‌شود، و بر روی خود Dog نیز مستقیماً یک تابع وابسته غیرمتدی به‌نام baby_name ارائه می‌دهیم.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}
Listing 20-20: یک trait با یک تابع مرتبط و یک نوع با یک تابع مرتبط با همان نام که همچنین trait را پیاده‌سازی می‌کند

ما کدی برای نام‌گذاری تمام سگ‌های کوچک به نام Spot در تابع مرتبط baby_name که بر روی Dog تعریف شده است، پیاده‌سازی می‌کنیم. نوع Dog همچنین trait Animal را پیاده‌سازی می‌کند، که ویژگی‌هایی که تمام حیوانات دارند را توصیف می‌کند. سگ‌های کوچک به نام puppy شناخته می‌شوند و این در پیاده‌سازی trait Animal بر روی Dog در تابع baby_name مرتبط با trait Animal بیان شده است.

در تابع main، ما تابع Dog::baby_name را فراخوانی می‌کنیم، که تابع مرتبط تعریف شده بر روی Dog را مستقیماً فراخوانی می‌کند. این کد خروجی زیر را چاپ می‌کند:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

این خروجی آن چیزی نیست که ما می‌خواستیم. ما می‌خواهیم تابع baby_name که بخشی از trait Animal است و بر روی Dog پیاده‌سازی شده است را فراخوانی کنیم تا کد A baby dog is called a puppy را چاپ کند. تکنیکی که در فهرست 20-19 برای مشخص کردن نام trait استفاده کردیم، اینجا کمکی نمی‌کند. اگر main را به کد موجود در فهرست 20-21 تغییر دهیم، خطای کامپایل دریافت خواهیم کرد.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}
Listing 20-21: تلاش برای فراخوانی تابع baby_name از trait Animal، اما راست نمی‌داند که از کدام پیاده‌سازی استفاده کند

از آنجا که Animal::baby_name پارامتر self ندارد، و ممکن است انواع دیگری وجود داشته باشند که trait Animal را پیاده‌سازی کرده باشند، راست نمی‌تواند تشخیص دهد که کدام پیاده‌سازی از Animal::baby_name مورد نظر ما است. در نتیجه این خطای کامپایلر را دریافت خواهیم کرد:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

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

برای رفع ابهام و مشخص کردن اینکه ما می‌خواهیم از پیاده‌سازی trait Animal برای Dog استفاده کنیم، به جای پیاده‌سازی trait Animal برای نوع دیگری، باید از fully qualified syntax استفاده کنیم. فهرست 20-22 نشان می‌دهد چگونه از fully qualified syntax استفاده کنیم.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
Listing 20-22: استفاده از fully qualified syntax برای مشخص کردن اینکه می‌خواهیم تابع baby_name از trait Animal که بر روی Dog پیاده‌سازی شده است، فراخوانی کنیم

ما با استفاده از یک اعلان نوع در داخل angle brackets به راست می‌گوییم که می‌خواهیم متد baby_name از trait Animal که بر روی Dog پیاده‌سازی شده است، فراخوانی شود، با این کار مشخص می‌کنیم که می‌خواهیم نوع Dog را برای این فراخوانی تابع به‌عنوان یک Animal در نظر بگیریم. این کد اکنون خروجی مورد نظر ما را چاپ می‌کند:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

به طور کلی، fully qualified syntax به صورت زیر تعریف می‌شود:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

برای توابع مرتبطی که متد نیستند، receiver وجود نخواهد داشت: فقط لیستی از آرگومان‌های دیگر خواهد بود. شما می‌توانید fully qualified syntax را در هر جایی که توابع یا متدها را فراخوانی می‌کنید، استفاده کنید. با این حال، مجاز هستید هر بخشی از این سینتکس را که راست می‌تواند از اطلاعات دیگر برنامه تشخیص دهد، حذف کنید. شما فقط در مواردی که چندین پیاده‌سازی با نام یکسان وجود دارد و راست به کمک نیاز دارد تا مشخص کند کدام پیاده‌سازی را می‌خواهید فراخوانی کنید، نیاز به استفاده از این سینتکس دقیق‌تر دارید.

استفاده از Supertraitها

گاهی ممکن است بخواهید یک تعریف trait بنویسید که به trait دیگری وابسته باشد: برای آن‌که یک نوع بتواند trait اول را پیاده‌سازی کند، لازم است آن نوع همچنین trait دوم را نیز پیاده‌سازی کرده باشد. این کار را برای آن انجام می‌دهید که تعریف trait شما بتواند از اعضای وابسته‌ی (associated items) trait دوم استفاده کند. traitای که تعریف شما به آن وابسته است، supertrait نامیده می‌شود.

برای مثال، فرض کنید می‌خواهیم یک trait به‌نام OutlinePrint ایجاد کنیم با یک متد outline_print که مقدار داده‌شده را به‌صورتی فرمت‌شده چاپ می‌کند که درون قاب ستاره‌ای قرار گیرد. یعنی، اگر یک struct به‌نام Point داشته باشیم که trait استاندارد Display را پیاده‌سازی کرده باشد و خروجی آن (x, y) باشد، وقتی outline_print را روی یک نمونه از Point با x برابر با 1 و y برابر با 3 فراخوانی کنیم، باید چیزی مشابه زیر چاپ شود:

**********
*        *
* (1, 3) *
*        *
**********

در پیاده‌سازی متد outline_print، می‌خواهیم از قابلیت‌های trait Display استفاده کنیم. بنابراین، نیاز داریم مشخص کنیم که trait OutlinePrint فقط برای انواعی کار خواهد کرد که همچنین trait Display را پیاده‌سازی کرده باشند و قابلیت‌های مورد نیاز OutlinePrint را ارائه دهند. می‌توانیم این کار را در تعریف trait با مشخص کردن OutlinePrint: Display انجام دهیم. این تکنیک شبیه به اضافه کردن یک محدودیت trait به trait است. فهرست 20-23 یک پیاده‌سازی از trait OutlinePrint را نشان می‌دهد.

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}
Listing 20-23: پیاده‌سازی trait OutlinePrint که نیاز به قابلیت‌های Display دارد

از آنجایی که مشخص کرده‌ایم که OutlinePrint به trait Display نیاز دارد، می‌توانیم از تابع to_string استفاده کنیم که به طور خودکار برای هر نوعی که Display را پیاده‌سازی کرده باشد، پیاده‌سازی شده است. اگر سعی کنیم to_string را بدون اضافه کردن دو نقطه و مشخص کردن trait Display بعد از نام trait استفاده کنیم، خطایی دریافت خواهیم کرد که می‌گوید هیچ متدی به نام to_string برای نوع &Self در محدوده فعلی یافت نشد.

حالا ببینیم چه اتفاقی می‌افتد اگر بخواهیم OutlinePrint را برای یک نوعی که Display را پیاده‌سازی نکرده است، مانند ساختار Point، پیاده‌سازی کنیم:

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

خطایی دریافت می‌کنیم که می‌گوید Display مورد نیاز است ولی پیاده‌سازی نشده است:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4  |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

برای رفع این مشکل، باید Display را برای Point پیاده‌سازی کنیم و محدودیت مورد نیاز OutlinePrint را برآورده کنیم، به این صورت:

Filename: src/main.rs
trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

سپس، پیاده‌سازی trait به‌نام OutlinePrint برای Point با موفقیت کامپایل خواهد شد، و می‌توانیم متد outline_print را روی یک نمونه از Point فراخوانی کنیم تا آن را درون یک قاب ستاره‌ای نمایش دهد.

استفاده از الگوی Newtype برای پیاده‌سازی Traitهای خارجی

در بخش «پیاده‌سازی یک Trait برای یک نوع» در فصل ۱۰، به قانونی به نام قانون یتیم (orphan rule) اشاره کردیم که می‌گوید تنها در صورتی اجازه داریم یک trait را برای یک نوع پیاده‌سازی کنیم که یا خود trait، یا آن نوع، یا هر دو، محلی (local) به crate ما باشند. می‌توان با استفاده از الگوی newtype این محدودیت را دور زد. این الگو شامل ایجاد یک نوع جدید در قالب یک tuple struct است. (در فصل ۵ در بخش «استفاده از Tuple Structها بدون فیلدهای نام‌گذاری‌شده برای ایجاد انواع مختلف» به این موضوع پرداختیم.) این tuple struct فقط یک فیلد خواهد داشت و در واقع یک بسته‌بندی نازک روی نوعی است که می‌خواهیم trait را برای آن پیاده‌سازی کنیم. از آن‌جایی که نوع بسته‌بندی‌شده محلی به crate ما خواهد بود، می‌توانیم trait مورد نظر را روی آن پیاده‌سازی کنیم. واژه‌ی newtype از زبان برنامه‌نویسی Haskell گرفته شده است. استفاده از این الگو هیچ‌گونه هزینه‌ای در زمان اجرا ندارد، زیرا نوع بسته‌بندی‌شده در زمان کامپایل حذف می‌شود.

به‌عنوان مثال، فرض کنید می‌خواهیم Display را روی Vec<T> پیاده‌سازی کنیم، که قانون orphan مانع انجام این کار به‌صورت مستقیم می‌شود زیرا trait Display و نوع Vec<T> خارج از crate ما تعریف شده‌اند. می‌توانیم یک ساختار Wrapper بسازیم که شامل یک نمونه از Vec<T> باشد؛ سپس می‌توانیم Display را روی Wrapper پیاده‌سازی کنیم و از مقدار Vec<T> استفاده کنیم، همانطور که در فهرست 20-24 نشان داده شده است.

Filename: src/main.rs
use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}
Listing 20-24: ایجاد نوع Wrapper دور Vec<String> برای پیاده‌سازی Display

پیاده‌سازی Display از self.0 برای دسترسی به Vec<T> داخلی استفاده می‌کند، زیرا Wrapper یک tuple struct است و Vec<T> در موقعیت اندیس ۰ این tuple قرار دارد. سپس می‌توانیم از قابلیت‌های trait Display روی Wrapper استفاده کنیم.

نکته‌ی منفی در استفاده از این تکنیک این است که Wrapper یک نوع جدید است، بنابراین متدهای نوعی که درون خود نگه می‌دارد را ندارد. باید تمام متدهای Vec<T> را مستقیماً روی Wrapper پیاده‌سازی کنیم به‌گونه‌ای که این متدها به self.0 ارجاع دهند؛ این کار به ما اجازه می‌دهد که با Wrapper مانند یک Vec<T> رفتار کنیم. اگر بخواهیم نوع جدید همه‌ی متدهای نوع درونی را داشته باشد، پیاده‌سازی trait Deref برای Wrapper که نوع درونی را بازمی‌گرداند، یک راه‌حل خواهد بود (در فصل ۱۵ در بخش «رفتار دادن به Smart Pointerها مانند رفرنس‌های معمولی با Deref» درباره‌ی پیاده‌سازی Deref صحبت کردیم). اما اگر نخواهیم نوع Wrapper همه‌ی متدهای نوع درونی را داشته باشد—برای مثال، برای محدود کردن رفتار نوع Wrapper—باید فقط متدهایی را که نیاز داریم، به‌صورت دستی پیاده‌سازی کنیم.

این الگوی newtype حتی زمانی که traits درگیر نیستند نیز مفید است. حالا بیایید تمرکز خود را تغییر دهیم و به برخی از روش‌های پیشرفته برای تعامل با سیستم نوع Rust بپردازیم.

انواع (Typeهای) پیشرفته

سیستم نوع‌بندی Rust شامل ویژگی‌هایی است که تاکنون فقط به آن‌ها اشاره کرده‌ایم و هنوز به‌طور کامل مورد بحث قرار نگرفته‌اند. ابتدا به بررسی الگوی newtype می‌پردازیم تا بفهمیم چرا این الگو به‌عنوان انواع مفید است. سپس به aliasهای نوع می‌پردازیم، که ویژگی مشابهی با newtype دارند اما با تفاوت‌هایی در معناشناسی. همچنین، نوع ! و انواع پویا (dynamically sized types) را نیز بررسی خواهیم کرد.

استفاده از الگوی Newtype برای ایمنی نوع و انتزاع

این بخش فرض می‌کند که پیش‌تر بخش «استفاده از الگوی Newtype برای پیاده‌سازی Traitهای خارجی» را خوانده‌اید. الگوی newtype برای کارهایی فراتر از آن‌چه تاکنون بحث کردیم نیز مفید است، از جمله اعمال محدودیت‌های ایستا (statically) برای جلوگیری از اشتباه گرفتن مقادیر و مشخص‌کردن واحد یک مقدار. مثالی از استفاده‌ی newtype برای مشخص‌کردن واحدها را در لیستینگ 20-16 مشاهده کردید: به خاطر بیاورید که ساختارهای Millimeters و Meters مقادیر u32 را درون یک newtype می‌پیچیدند. اگر تابعی با پارامتری از نوع Millimeters بنویسیم، برنامه‌ای که به‌اشتباه سعی کند آن تابع را با مقداری از نوع Meters یا یک u32 معمولی فراخوانی کند، کامپایل نخواهد شد.

ما همچنین می‌توانیم از الگوی newtype برای انتزاع جزئیات پیاده‌سازی یک نوع استفاده کنیم: نوع جدید می‌تواند یک API عمومی ارائه دهد که با API نوع داخلی خصوصی متفاوت است.

الگوی newtype همچنین می‌تواند پیاده‌سازی داخلی را پنهان کند. برای مثال، می‌توانیم یک نوع People ارائه دهیم که یک HashMap<i32, String> را در خود بپیچد؛ این ساختار شناسه‌ی هر فرد را با نام او نگه می‌دارد. کدی که از People استفاده می‌کند، تنها با API عمومی‌ای که ما ارائه می‌دهیم تعامل خواهد داشت، مانند متدی برای افزودن یک رشته‌ی نام به مجموعه‌ی People؛ این کد نیازی ندارد بداند که ما به‌صورت داخلی برای نام‌ها یک شناسه‌ی 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);
}

اکنون نام مستعار 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 جایگزین کنیم.

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 نمی‌تواند هیچ‌گاه مقداری بازگرداند.

اما چه فایده‌ای دارد نوعی که نمی‌توانید هیچ مقداری از آن بسازید؟ کدی را به یاد آورید که در لیست ۲-۵، بخشی از بازی حدس عدد بود؛ ما بخشی از آن را این‌جا در لیست 20-27 بازتولید کرده‌ایم.

use std::cmp::Ordering;
use std::io;

use rand::Rng;

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

بیایید وارد جزئیات نوعی با اندازه‌ی پویا (Dynamically Sized Type یا DST) به نام 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 تغییر دهیم. به یاد دارید از بخش “برش‌های رشته‌ای” در فصل ۴ که ساختار داده‌ی slice فقط موقعیت شروع و طول برش را ذخیره می‌کند. بنابراین، اگرچه یک &T یک مقدار منفرد است که آدرس حافظه‌ای که T در آن قرار دارد را ذخیره می‌کند، یک &str دو مقدار دارد: آدرس str و طول آن.

از این رو، می‌توانیم اندازه‌ی مقدار &str را در زمان کامپایل بدانیم: این اندازه دو برابر طول یک usize است. یعنی، همیشه اندازه‌ی &str را می‌دانیم، فارغ از اینکه طول رشته‌ای که به آن اشاره می‌کند چقدر باشد. به طور کلی، این همان روشی است که نوع‌های با اندازه‌ی پویا در Rust استفاده می‌شوند: آن‌ها یک قطعه اضافی از فراداده (metadata) دارند که اندازه‌ی اطلاعات پویا را ذخیره می‌کند. قانون طلایی نوع‌های با اندازه‌ی پویا این است که همیشه باید مقادیر این نوع‌ها را پشت یک نوع اشاره‌گر (pointer) قرار دهیم.

ما می‌توانیم str را با انواع مختلفی از اشاره‌گرها ترکیب کنیم: برای مثال، Box<str> یا Rc<str>. در واقع، قبلاً نیز این را دیده‌اید اما با نوعی دیگر از نوع‌های با اندازه‌ی پویا: traits. هر trait یک نوع با اندازه‌ی پویا است که می‌توانیم با استفاده از نام trait به آن ارجاع دهیم. در بخش “استفاده از trait objectها برای انتزاع‌سازی روی رفتار مشترک” در فصل ۱۸، گفتیم که برای استفاده از traits به عنوان trait object، باید آن‌ها را پشت یک اشاره‌گر قرار دهیم، مانند &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ها صحبت خواهیم کرد!

توابع پیشرفته و Closureها

این بخش به بررسی برخی از ویژگی‌های پیشرفته مربوط به توابع و Closureها می‌پردازد، از جمله Pointerهای تابع و بازگرداندن Closureها.

Pointerهای تابع

ما درباره نحوهٔ ارسال کلوزرها به توابع صحبت کردیم؛ همچنین می‌توانید توابع معمولی را نیز به توابع دیگر ارسال کنید! این تکنیک زمانی مفید است که بخواهید تابعی را که از قبل تعریف کرده‌اید ارسال کنید، به‌جای آن‌که یک کلوزر جدید تعریف کنید. توابع به نوع fn (با f کوچک) تبدیل می‌شوند، که نباید با Fn که یک trait برای کلوزرهاست، اشتباه گرفته شود. نوع fn یک پویتر به تابع نامیده می‌شود. ارسال توابع با استفاده از پویترهای تابع این امکان را می‌دهد که از توابع به‌عنوان آرگومان به توابع دیگر استفاده کنید.

نحو مشخص‌کردن اینکه یک پارامتر از نوع پویتر تابع است، مشابه نحوهٔ تعریف کلوزرهاست؛ همان‌طور که در لیست 20-28 نشان داده شده است. در آنجا تابعی به نام add_one تعریف کرده‌ایم که ۱ واحد به پارامتر خود اضافه می‌کند. تابع do_twice دو پارامتر می‌گیرد: یک پویتر به تابعی که یک پارامتر از نوع i32 می‌گیرد و مقدار i32 برمی‌گرداند، و یک مقدار i32. تابع do_twice تابع f را دو بار با مقدار arg فراخوانی می‌کند، سپس نتایج این دو فراخوانی را با هم جمع می‌زند. تابع main تابع do_twice را با آرگومان‌های add_one و 5 فراخوانی می‌کند.

Filename: src/main.rs
fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}
Listing 20-28: استفاده از نوع fn برای پذیرش یک اشاره‌گر (Pointer) تابع به عنوان آرگومان

این کد مقدار The answer is: 12 را چاپ می‌کند. ما مشخص کرده‌ایم که پارامتر f در do_twice یک fn است که یک پارامتر از نوع i32 می‌گیرد و یک i32 باز می‌گرداند. سپس می‌توانیم f را در بدنه تابع do_twice فراخوانی کنیم. در main، می‌توانیم نام تابع add_one را به عنوان آرگومان اول به do_twice ارسال کنیم.

برخلاف Closureها fn یک نوع است و نه یک ویژگی، بنابراین ما fn را به طور مستقیم به عنوان نوع پارامتر مشخص می‌کنیم، به جای اعلام یک پارامتر جنریک با یکی از ویژگی‌های Fn به عنوان محدودیت ویژگی.

Pointerهای تابع تمام سه ویژگی Closureها (Fn، FnMut، و FnOnce) را پیاده‌سازی می‌کنند، به این معنی که شما همیشه می‌توانید یک اشاره‌گر (Pointer) تابع را به عنوان آرگومان برای یک تابع که انتظار یک Closureها را دارد ارسال کنید. بهتر است توابع را با استفاده از یک نوع جنریک و یکی از ویژگی‌های Closureها بنویسید تا توابع شما بتوانند هم توابع و هم Closureها را بپذیرند.

با این حال، یک مثال از جایی که ممکن است بخواهید فقط fn را بپذیرید و نه Closureها زمانی است که با کد خارجی که Closureها ندارد تعامل می‌کنید: توابع C می‌توانند توابع را به عنوان آرگومان بپذیرند، اما C Closureها ندارد.

به‌عنوان مثالی از جایی که می‌توانید از یک کلوزر تعریف‌شده به‌صورت درجا یا از یک تابع نام‌گذاری‌شده استفاده کنید، بیایید نگاهی بیندازیم به یک استفاده از متد map که توسط Iterator در کتابخانه استاندارد فراهم شده است. برای استفاده از متد map به‌منظور تبدیل یک vector از اعداد به یک vector از رشته‌ها، می‌توانیم از یک کلوزر استفاده کنیم، همان‌طور که در لیست 20-29 نشان داده شده است.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(|i| i.to_string()).collect();
}
Listing 20-29: استفاده از یک کلوزر با متد map برای تبدیل اعداد به رشته‌ها

یا می‌توانیم به‌جای کلوزر، یک تابع را به‌عنوان آرگومان به map بدهیم. لیست 20-30 نشان می‌دهد که این کار چگونه انجام می‌شود.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(ToString::to_string).collect();
}
Listing 20-30: استفاده از تابع String::to_string با متد map برای تبدیل اعداد به رشته‌ها

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

در این‌جا، از تابع to_string استفاده می‌کنیم که در trait به نام ToString تعریف شده و کتابخانه استاندارد آن را برای هر نوعی که Display را پیاده‌سازی کرده باشد، پیاده‌سازی کرده است.

به یاد بیاورید که در بخش «مقادیر enum» از فصل ۶ اشاره کردیم که نام هر واریانت enum که تعریف می‌کنیم، همچنین تبدیل به یک تابع سازنده (initializer function) می‌شود. ما می‌توانیم این توابع سازنده را به‌عنوان فانکشن‌پوینترهایی که closure trait‌ها را پیاده‌سازی می‌کنند استفاده کنیم؛ این یعنی می‌توانیم این توابع سازنده را به‌عنوان آرگومان برای متدهایی که کلوزر دریافت می‌کنند مشخص کنیم، همان‌طور که در لیست 31-20 مشاهده می‌کنید.

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}
Listing 20-31: استفاده از سازنده enum با متد map برای ایجاد نمونه‌ای از Status از روی اعداد

در این‌جا، با استفاده از تابع سازنده Status::Value، برای هر مقدار u32 در بازه‌ای که map روی آن فراخوانی شده، نمونه‌هایی از Status::Value ایجاد می‌کنیم. برخی افراد این سبک را ترجیح می‌دهند و برخی دیگر ترجیح می‌دهند از کلوزرها استفاده کنند. هر دو روش به یک کد کامپایل می‌شوند، پس از هر سبکی که برای شما واضح‌تر است استفاده کنید.

بازگرداندن کلوزرها (closures) (Returning Closures)

کلوزرها توسط traitها نمایش داده می‌شوند، به این معنا که نمی‌توان آن‌ها را مستقیماً به عنوان مقدار بازگشتی برگرداند. در بیشتر مواردی که ممکن است بخواهید یک trait را برگردانید، می‌توانید به جای آن از نوع مشخصی که آن trait را پیاده‌سازی می‌کند به‌عنوان مقدار بازگشتی تابع استفاده کنید. اما معمولاً نمی‌توانید این کار را با کلوزرها انجام دهید، چون آن‌ها نوع مشخصی ندارند که قابل بازگشت باشد؛ برای مثال، اگر کلوزری مقداری از اسکوپ خود را کپچر کند، مجاز به استفاده از نوع اشاره‌گر تابع fn به‌عنوان نوع بازگشتی نیستید.

در عوض، معمولاً از سینتکس impl Trait که در فصل ۱۰ یاد گرفتیم استفاده می‌شود. می‌توانید هر نوع تابعی را با استفاده از Fn، FnOnce و FnMut بازگردانید. برای مثال، کدی که در لیست 20-32 آمده است، بدون مشکل کامپایل می‌شود.

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}
Listing 20-32: بازگرداندن یک کلوزر از تابع با استفاده از سینتکس impl Trait

با این حال، همان‌طور که در بخش “استنباط نوع کلوزر و حاشیه‌نویسی” در فصل ۱۳ اشاره شد، هر کلوزر همچنین نوع خاص خودش را دارد. اگر نیاز دارید با چندین تابع که امضای یکسانی دارند اما پیاده‌سازی متفاوتی دارند کار کنید، باید از یک آبجکت trait برای آن‌ها استفاده کنید. ببینید چه اتفاقی می‌افتد اگر کدی مشابه لیست 20-33 بنویسید.

Filename: src/main.rs
fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}
Listing 20-33: ایجاد یک Vec<T> از کلوزرهایی که توسط توابعی بازگردانده می‌شوند که نوع impl Fn دارند

در این‌جا دو تابع داریم به نام‌های returns_closure و returns_initialized_closure، که هر دو مقدار impl Fn(i32) -> i32 را بازمی‌گردانند. توجه داشته باشید که کلوزرهایی که این توابع بازمی‌گردانند با یکدیگر متفاوت‌اند، حتی اگر هر دو trait یکسانی را پیاده‌سازی کنند. اگر سعی کنیم این کد را کامپایل کنیم، Rust به ما اطلاع می‌دهد که این کار امکان‌پذیر نیست:

$ cargo build
   Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0308]: mismatched types
  --> src/main.rs:2:44
   |
2  |     let handlers = vec![returns_closure(), returns_initialized_closure(123)];
   |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
9  | fn returns_closure() -> impl Fn(i32) -> i32 {
   |                         ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
   |                                              ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:9:25>)
              found opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:13:46>)
   = note: distinct uses of `impl Trait` result in different opaque types

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

پیام خطا به ما می‌گوید که زمانی که ما یک impl Trait را بازمی‌گردانیم، Rust یک نوع مبهم (opaque type) منحصربه‌فرد ایجاد می‌کند؛ نوعی که نمی‌توانیم جزئیات آن را ببینیم و همچنین نمی‌توانیم نوع تولیدشده توسط Rust را حدس بزنیم یا خودمان بنویسیم. بنابراین، حتی اگر این توابع کلوزرهایی را بازگردانند که trait یکسانی مانند Fn(i32) -> i32 را پیاده‌سازی می‌کنند، نوع‌های مبهم تولیدشده توسط Rust برای هر کدام متفاوت‌اند. (این مشابه نحوه‌ای است که Rust نوع‌های مشخص مختلفی برای بلوک‌های async متمایز تولید می‌کند، حتی اگر خروجی آن‌ها یکسان باشد، همان‌طور که در بخش “کار با هر تعداد future” در فصل ۱۷ دیدیم.) ما پیش از این نیز چندین بار راه‌حل این مشکل را دیده‌ایم: می‌توانیم از یک trait object استفاده کنیم، مانند نمونه‌ای که در Listing 20-34 نشان داده شده است.

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}
Listing 20-34: ساخت یک Vec<T> از کلوزرهایی که توسط توابعی بازمی‌گردند که مقدار Box<dyn Fn> را برمی‌گردانند تا نوع آن‌ها یکسان باشد

این کد بدون مشکل کامپایل خواهد شد. برای اطلاعات بیشتر در مورد trait object‌ها، به بخش “استفاده از trait objectهایی که اجازه استفاده از مقادیر با نوع‌های متفاوت را می‌دهند” در فصل ۱۸ مراجعه کنید.

حال بیایید نگاهی به ماکروها بیندازیم!

ماکروها (Macros)

ما در طول این کتاب از ماکروهایی مانند println! استفاده کرده‌ایم، اما هنوز به طور کامل بررسی نکرده‌ایم که یک ماکرو چیست و چگونه کار می‌کند. اصطلاح ماکرو به مجموعه‌ای از قابلیت‌ها در Rust اشاره دارد: ماکروهای اعلانی (declarative) با macro_rules! و سه نوع ماکرو رویه‌ای (procedural):

  • ماکروهای سفارشی #[derive] که کدی را که با ویژگی derive برای ساختارها (structs) و شمارش‌ها (enums) اضافه می‌شود مشخص می‌کنند.
  • ماکروهای شبیه ویژگی (Attribute-like) که ویژگی‌های سفارشی تعریف می‌کنند که می‌توانند روی هر آیتمی استفاده شوند.
  • ماکروهای شبیه تابع (Function-like) که مانند فراخوانی تابع به نظر می‌رسند اما روی توکن‌هایی که به عنوان آرگومان مشخص شده‌اند عمل می‌کنند.

ما به نوبت درباره هر یک از این‌ها صحبت خواهیم کرد، اما ابتدا بیایید نگاهی بیندازیم که چرا اصلاً به ماکروها نیاز داریم وقتی قبلاً توابع را داریم.

تفاوت بین ماکروها و توابع

در اصل، ماکروها روشی برای نوشتن کدی هستند که کد دیگری را می‌نویسد، که به عنوان فرابرنامه‌نویسی (metaprogramming) شناخته می‌شود. در پیوست C، ما ویژگی derive را بررسی می‌کنیم که پیاده‌سازی ویژگی‌های مختلف را برای شما تولید می‌کند. همچنین ما از ماکروهای println! و vec! در طول کتاب استفاده کرده‌ایم. همه این ماکروها توسعه پیدا می‌کنند تا کدی بیشتر از کدی که به صورت دستی نوشته‌اید تولید کنند.

برنامه‌نویسی فراداده (Metaprogramming) برای کاهش میزان کدی که باید بنویسید و نگهداری کنید مفید است، که این نیز یکی از وظایف توابع است. با این حال، ماکروها توانایی‌های اضافی‌ای دارند که توابع از آن‌ها برخوردار نیستند.

یک امضای تابع باید تعداد و نوع پارامترهایی که تابع دارد را مشخص کند. از سوی دیگر، ماکروها می‌توانند تعداد متغیری از پارامترها را بپذیرند: می‌توانیم println!("hello") را با یک آرگومان یا println!("hello {}", name) را با دو آرگومان فراخوانی کنیم. همچنین، ماکروها قبل از اینکه کامپایلر معنی کد را تفسیر کند گسترش می‌یابند، بنابراین یک ماکرو می‌تواند، به عنوان مثال، یک ویژگی را روی یک نوع مشخص پیاده‌سازی کند. اما یک تابع نمی‌تواند، زیرا در زمان اجرا فراخوانی می‌شود و یک ویژگی باید در زمان کامپایل پیاده‌سازی شود.

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

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

ماکروهای اعلانی با macro_rules! برای فرابرنامه‌نویسی عمومی

رایج‌ترین شکل استفاده از ماکروها در Rust، ماکروهای اعلامی (declarative macro) هستند. این نوع ماکروها گاهی با عنوان‌هایی مانند “ماکروهای بر پایه‌ی مثال”، “ماکروهای macro_rules!”، یا به‌سادگی “ماکروها” شناخته می‌شوند. در هسته‌ی خود، ماکروهای اعلامی به شما اجازه می‌دهند چیزی مشابه یک عبارت match در Rust بنویسید. همان‌طور که در فصل ۶ بیان شد، عبارات match ساختارهای کنترلی‌ای هستند که یک عبارت را می‌گیرند، مقدار حاصل از آن را با الگوها مقایسه می‌کنند، و سپس کدی که با الگوی مطابق مرتبط است را اجرا می‌کنند. ماکروها نیز یک مقدار را با الگوهایی مقایسه می‌کنند که با کد خاصی مرتبط‌اند: در این حالت، مقدار، کد منبع Rust است که به ماکرو داده می‌شود؛ الگوها با ساختار این کد منبع مقایسه می‌شوند؛ و کدی که با هر الگو مرتبط است، در صورت تطابق، جایگزین کدی می‌شود که به ماکرو داده شده است. تمام این فرآیند در زمان کامپایل اتفاق می‌افتد.

برای تعریف یک ماکرو، از ساختار macro_rules! استفاده می‌کنید. بیایید بررسی کنیم چگونه از macro_rules! استفاده کنیم با نگاهی به نحوه تعریف ماکروی vec!. فصل ۸ پوشش داد که چگونه می‌توانیم از ماکروی vec! برای ایجاد یک بردار جدید با مقادیر خاص استفاده کنیم. به عنوان مثال، ماکروی زیر یک بردار جدید حاوی سه عدد صحیح ایجاد می‌کند:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

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

فهرست 20-35 نسخه‌ای کمی ساده‌شده از تعریف ماکروی vec! را نشان می‌دهد.

Filename: src/lib.rs
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
Listing 20-29: یک نسخه ساده‌شده از تعریف ماکروی vec!

نکته: تعریف واقعی ماکروی vec! در کتابخانه استاندارد شامل کدی برای پیش‌اختصاص دادن مقدار مناسبی از حافظه به‌صورت اولیه است. این کد یک بهینه‌سازی است که برای ساده‌تر شدن مثال، در این‌جا گنجانده نشده است.

حاشیه‌نویسی #[macro_export] نشان می‌دهد که این ماکرو باید هر زمان که crate‌ای که ماکرو در آن تعریف شده است به دامنه آورده شود، در دسترس قرار گیرد. بدون این حاشیه‌نویسی، ماکرو نمی‌تواند به دامنه آورده شود.

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

ساختار بدنه vec! مشابه ساختار یک عبارت match است. در اینجا یک بازو با الگوی ( $( $x:expr ),* ) داریم، که با => و بلوک کدی که با این الگو مرتبط است دنبال می‌شود. اگر الگو تطابق یابد، بلوک کد مرتبط گسترش می‌یابد. با توجه به اینکه این تنها الگو در این ماکرو است، تنها یک روش معتبر برای تطابق وجود دارد؛ هر الگوی دیگری باعث خطا خواهد شد. ماکروهای پیچیده‌تر ممکن است بیش از یک بازو داشته باشند.

سینتکس الگوهای معتبر در تعریف ماکروها با سینتکس الگوهایی که در فصل ۱۹ بررسی کردیم متفاوت است، زیرا الگوهای ماکرو در برابر ساختار کد راست مطابقت داده می‌شوند، نه در برابر مقادیر. بیایید قدم‌به‌قدم بررسی کنیم که اجزای الگو در لیست ۲۰-۲۹ چه معنایی دارند؛ برای مشاهده‌ی کامل سینتکس الگوهای ماکرو، به مرجع رسمی Rust مراجعه کنید.

ابتدا از یک جفت پرانتز برای در بر گرفتن کل الگو استفاده می‌کنیم. از علامت دلار ($) برای تعریف یک متغیر در سیستم ماکرو استفاده می‌شود که کد راستی را که با الگو مطابقت دارد، در خود نگه می‌دارد. علامت دلار نشان می‌دهد که این یک متغیر ماکرو است و نه یک متغیر معمولی در راست. سپس یک جفت پرانتز می‌آید که مقادیری را که با الگو مطابقت دارند، در خود می‌گیرد تا در کد جایگزین مورد استفاده قرار گیرند. درون $()، عبارت $x:expr قرار دارد، که هر عبارت راست را مطابقت می‌دهد و به آن نام $x می‌دهد.

کامی که بعد از $() آمده است نشان می‌دهد که باید بین هر نمونه از کدی که با الگوی درون $() مطابقت دارد، یک کاراکتر ویرگول قرار داشته باشد. علامت * مشخص می‌کند که الگو صفر یا تعداد بیشتری از موردی را که قبل از * آمده، مطابقت می‌دهد.

وقتی این ماکرو را با vec![1, 2, 3]; فراخوانی می‌کنیم، الگوی $x سه بار با سه عبارت 1، 2 و 3 تطابق پیدا می‌کند.

حالا بیایید به الگویی که در بدنه کد مرتبط با این بازو وجود دارد نگاه کنیم: temp_vec.push() درون $()* برای هر بخشی که با $() در الگو تطابق دارد، صفر یا بیشتر بار بسته به اینکه الگو چند بار تطابق پیدا می‌کند، تولید می‌شود. $x با هر عبارتی که تطابق پیدا کند جایگزین می‌شود. وقتی این ماکرو را با vec![1, 2, 3]; فراخوانی می‌کنیم، کدی که جایگزین این فراخوانی ماکرو می‌شود به شکل زیر خواهد بود:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

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

برای یادگیری بیشتر در مورد نحوه نوشتن ماکروها، به مستندات آنلاین یا منابع دیگر مانند “The Little Book of Rust Macros” که توسط Daniel Keep آغاز و توسط Lukas Wirth ادامه داده شده است، مراجعه کنید.

ماکروهای رویه‌ای (Procedural) برای تولید کد از ویژگی‌ها (Attributes)

نوع دوم ماکروها، ماکروهای رویه‌ای (procedural macros) هستند که رفتاری شبیه به توابع دارند (و در واقع نوعی رویه محسوب می‌شوند). ماکروهای رویه‌ای قطعه‌ای از کد را به عنوان ورودی دریافت می‌کنند، روی آن کد پردازش انجام می‌دهند و کدی را به عنوان خروجی تولید می‌کنند، در حالی که ماکروهای اعلامی (declarative) با الگوها مطابقت داده و کد را با کدی دیگر جایگزین می‌کنند. سه نوع از ماکروهای رویه‌ای وجود دارد: derive سفارشی، ماکروهای شبیه-صفت (attribute-like)، و ماکروهای شبیه-تابع (function-like)، و همگی به شکلی مشابه عمل می‌کنند.

هنگام ایجاد ماکروهای رویه‌ای، تعریف آن‌ها باید در یک crate جداگانه قرار گیرد که نوع crate آن به‌صورت ویژه مشخص شده باشد. این الزام به دلایل فنی پیچیده‌ای است که امیدواریم در آینده برطرف شوند. در لیست 20-36، نحوه تعریف یک ماکرو رویه‌ای را نشان می‌دهیم که در آن some_attribute یک جایگزین برای نوع خاصی از ماکرو است.

Filename: src/lib.rs
use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Listing 20-36: نمونه‌ای از تعریف یک ماکرو رویه‌ای

تابعی که یک ماکروی رویه‌ای را تعریف می‌کند، یک TokenStream را به عنوان ورودی می‌گیرد و یک TokenStream را به عنوان خروجی تولید می‌کند. نوع TokenStream توسط crate به نام proc_macro تعریف شده است که با Rust همراه است و نمایانگر یک توالی از توکن‌ها است. این هسته ماکرو است: کد منبعی که ماکرو روی آن عمل می‌کند ورودی TokenStream را تشکیل می‌دهد و کدی که ماکرو تولید می‌کند خروجی TokenStream است. این تابع همچنین دارای یک ویژگی (attribute) متصل به خود است که مشخص می‌کند کدام نوع از ماکروی رویه‌ای را ایجاد می‌کنیم. ما می‌توانیم چندین نوع از ماکروهای رویه‌ای را در یک crate داشته باشیم.

بیایید نگاهی به انواع مختلف ماکروهای رویه‌ای بیندازیم. ابتدا با یک ماکرو derive سفارشی شروع می‌کنیم و سپس تفاوت‌های جزئی که باعث تمایز شکل‌های دیگر می‌شوند را توضیح خواهیم داد.

نحوه نوشتن یک ماکروی derive سفارشی

بیایید یک crate به نام hello_macro ایجاد کنیم که یک trait به نام HelloMacro را تعریف می‌کند با یک تابع مرتبط به نام hello_macro. به‌جای آن‌که کاربرانمان مجبور باشند trait‌ مربوطه را برای هرکدام از نوع‌هایشان پیاده‌سازی کنند، ما یک ماکروی روندی فراهم خواهیم کرد تا کاربران بتوانند نوع خود را با #[derive(HelloMacro)] مشخص کنند و به‌طور خودکار یک پیاده‌سازی پیش‌فرض از تابع hello_macro دریافت کنند. این پیاده‌سازی پیش‌فرض، عبارت Hello, Macro! My name is TypeName! را چاپ خواهد کرد، جایی که TypeName نام نوعی است که این trait روی آن تعریف شده است. به بیان دیگر، ما یک crate خواهیم نوشت که به برنامه‌نویس دیگری اجازه می‌دهد کدی شبیه لیست ۲۰-۳۷ را با استفاده از crate ما بنویسد.

Filename: src/main.rs
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}
Listing 20-37: کدی که کاربر crate ما می‌تواند هنگام استفاده از ماکروی روندی ما بنویسد

این کد متن Hello, Macro! My name is Pancakes! را چاپ می‌کند وقتی کار ما تمام شود. اولین قدم این است که یک crate جدید از نوع کتابخانه بسازیم، به این صورت:

$ cargo new hello_macro --lib

سپس، در لیست ۲۰-۳۸، trait مربوط به HelloMacro و تابع مرتبط با آن را تعریف خواهیم کرد.

Filename: src/lib.rs
pub trait HelloMacro {
    fn hello_macro();
}
Listing 20-38: A simple trait that we will use with the derive macro

ما یک trait و تابع مربوط به آن داریم. در این مرحله، کاربر crate ما می‌تواند این trait را به صورت دستی پیاده‌سازی کند تا به عملکرد مورد نظر دست یابد، همان‌طور که در لیست ۲۰-۳۹ نشان داده شده است.

Filename: src/main.rs
use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}
Listing 20-39: نحوه‌ای که کاربران می‌توانند پیاده‌سازی دستی trait به نام HelloMacro را انجام دهند

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

علاوه بر این، ما هنوز نمی‌توانیم برای تابع hello_macro یک پیاده‌سازی پیش‌فرض ارائه دهیم که نام نوعی که ویژگی روی آن پیاده‌سازی شده است را چاپ کند: Rust قابلیت‌های بازتاب (reflection) ندارد، بنابراین نمی‌تواند نام نوع را در زمان اجرا جستجو کند. ما به یک ماکرو نیاز داریم تا کد را در زمان کامپایل تولید کند.

گام بعدی تعریف ماکروی رویه‌ای است. در زمان نگارش این مطلب، ماکروهای رویه‌ای باید در یک کرِیت جداگانه قرار داشته باشند. ممکن است این محدودیت در آینده برداشته شود. قرارداد ساختاردهی کرِیت‌ها و کرِیت‌های ماکرو به این صورت است: برای کرِیتی به نام foo، کرِیت ماکروی derive سفارشی با نام foo_derive شناخته می‌شود. بیایید یک کرِیت جدید با نام hello_macro_derive درون پروژه hello_macro خود ایجاد کنیم:

$ cargo new hello_macro_derive --lib

دو crate ما به شدت به هم مرتبط هستند، بنابراین ما crate ماکروی رویه‌ای را درون دایرکتوری crate hello_macro ایجاد می‌کنیم. اگر تعریف ویژگی را در hello_macro تغییر دهیم، باید پیاده‌سازی ماکروی رویه‌ای در hello_macro_derive را نیز تغییر دهیم. این دو crate باید به طور جداگانه منتشر شوند و برنامه‌نویسانی که از این جعبه‌ها (crates) استفاده می‌کنند باید هر دو را به عنوان وابستگی اضافه کرده و آن‌ها را به دامنه بیاورند. در عوض، می‌توانستیم crate hello_macro از hello_macro_derive به عنوان یک وابستگی استفاده کند و کد ماکروی رویه‌ای را دوباره صادر کند. با این حال، روشی که پروژه را ساختاربندی کرده‌ایم، این امکان را فراهم می‌کند که برنامه‌نویسان از hello_macro حتی اگر عملکرد derive را نخواهند، استفاده کنند.

ما باید crate hello_macro_derive را به عنوان یک crate ماکروی رویه‌ای اعلام کنیم. همچنین به عملکردهایی از جعبه‌ها (crates)ی syn و quote نیاز خواهیم داشت، همان‌طور که به زودی خواهید دید، بنابراین باید آن‌ها را به عنوان وابستگی اضافه کنیم. موارد زیر را به فایل Cargo.toml برای hello_macro_derive اضافه کنید:

Filename: hello_macro_derive/Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

برای شروع تعریف ماکروی رویه‌ای، کد موجود در لیست 20-40 را در فایل src/lib.rs کرِیت hello_macro_derive قرار دهید. توجه داشته باشید که این کد تا زمانی که یک تعریف برای تابع impl_hello_macro اضافه نکنیم، کامپایل نخواهد شد.

Filename: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate.
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation.
    impl_hello_macro(&ast)
}
Listing 20-40: کدی که اکثر کرِیت‌های ماکروی رویه‌ای برای پردازش کد Rust نیاز دارند

توجه کنید که کد را به دو تابع تقسیم کرده‌ایم: hello_macro_derive که مسئول پردازش TokenStream است، و impl_hello_macro که مسئول تبدیل درخت نحوی است. این کار نوشتن یک ماکروی رویه‌ای را آسان‌تر می‌کند. کد تابع بیرونی (hello_macro_derive در اینجا) تقریباً برای تمام جعبه‌ها (crates)ی ماکروی رویه‌ای که می‌بینید یا ایجاد می‌کنید یکسان خواهد بود. کدی که در بدنه تابع داخلی (impl_hello_macro در اینجا) مشخص می‌کنید بسته به هدف ماکروی رویه‌ای شما متفاوت خواهد بود.

ما سه crate جدید معرفی کرده‌ایم: proc_macro، [syn]، و [quote]. crate proc_macro همراه با Rust ارائه می‌شود، بنابراین نیازی به افزودن آن به وابستگی‌ها در Cargo.toml نداریم. crate proc_macro API کامپایلر است که به ما اجازه می‌دهد کد Rust را از کد خود بخوانیم و دستکاری کنیم.

crate syn کد Rust را از یک رشته به یک ساختار داده‌ای تبدیل می‌کند که می‌توانیم عملیات روی آن انجام دهیم. crate quote ساختارهای داده syn را دوباره به کد Rust تبدیل می‌کند. این جعبه‌ها (crates) پردازش هر نوع کد Rust که بخواهیم مدیریت کنیم را بسیار ساده‌تر می‌کنند: نوشتن یک تجزیه‌کننده کامل برای کد Rust کار ساده‌ای نیست.

تابع hello_macro_derive زمانی فراخوانی می‌شود که یک کاربر از کتابخانه ما ویژگی #[derive(HelloMacro)] را روی یک نوع مشخص کند. این امر به این دلیل ممکن است که ما تابع hello_macro_derive را با proc_macro_derive حاشیه‌نویسی کرده‌ایم و نام HelloMacro را مشخص کرده‌ایم، که با نام ویژگی ما مطابقت دارد؛ این روش معمولی‌ای است که بیشتر ماکروهای رویه‌ای دنبال می‌کنند.

تابع hello_macro_derive ابتدا input را از یک TokenStream به یک ساختار داده تبدیل می‌کند که سپس می‌توانیم آن را تفسیر کرده و عملیات‌هایی روی آن انجام دهیم. اینجاست که crate syn به کار می‌آید. تابع parse در syn یک TokenStream می‌گیرد و یک ساختار DeriveInput را که نمایانگر کد Rust تجزیه‌شده است، بازمی‌گرداند. لیست ۲۰-۳۳ بخش‌های مرتبط از ساختار DeriveInput را نشان می‌دهد که هنگام تجزیه کد struct Pancakes; دریافت می‌کنیم:

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}
Listing 20-41: نمونه‌ای از DeriveInput که هنگام تجزیه کدی که ویژگی ماکرو در لیست 20-37 را دارد، دریافت می‌کنیم

فیلدهای این struct نشان می‌دهند که کدی که در Rust تجزیه کرده‌ایم یک ساختار واحد (unit struct) با ident (شناساگر، یعنی نام) به نام Pancakes است. فیلدهای بیشتری نیز در این struct وجود دارند که برای توصیف انواع مختلفی از کدهای Rust استفاده می‌شوند؛ برای اطلاعات بیشتر به مستندات syn درباره‌ی DeriveInput مراجعه کنید.

به‌زودی تابع impl_hello_macro را تعریف خواهیم کرد؛ این تابع جایی است که کد جدید Rust را که می‌خواهیم به کد اضافه کنیم، تولید خواهیم کرد. اما پیش از آن، توجه داشته باشید که خروجی ماکروی derive ما نیز یک TokenStream است. این TokenStream بازگشتی به کدی که کاربران crate ما نوشته‌اند اضافه می‌شود، بنابراین زمانی که crate خود را کامپایل می‌کنند، عملکرد اضافه‌ای که ما در TokenStream تغییر‌یافته فراهم کرده‌ایم به کدشان افزوده خواهد شد.

ممکن است متوجه شده باشید که ما از unwrap استفاده می‌کنیم تا در صورتی که فراخوانی تابع syn::parse شکست بخورد، تابع hello_macro_derive به وحشت بیفتد (panic). لازم است ماکروی رویه‌ای ما در صورت بروز خطا به وحشت بیفتد، زیرا توابع proc_macro_derive باید به جای Result یک TokenStream بازگردانند تا با API ماکروهای رویه‌ای سازگار باشند. برای ساده کردن این مثال از unwrap استفاده کرده‌ایم؛ در کد تولیدی، بهتر است پیام‌های خطای خاص‌تری درباره مشکل ایجاد شده با استفاده از panic! یا expect ارائه دهید.

اکنون که کدی داریم که کد Rust حاشیه‌نویسی‌شده را از یک TokenStream به نمونه‌ای از DeriveInput تبدیل می‌کند، بیایید کدی را تولید کنیم که trait مربوط به HelloMacro را برای نوع حاشیه‌نویسی‌شده پیاده‌سازی می‌کند، همان‌طور که در لیستینگ 20-42 نشان داده شده است.

Filename: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}
Listing 20-42: پیاده‌سازی trait مربوط به HelloMacro با استفاده از کد Rust تجزیه‌شده

با استفاده از ast.ident، یک نمونه از ساختار Ident دریافت می‌کنیم که شامل نام (identifier) نوعی است که با ماکرو حاشیه‌نویسی شده است. ساختار نشان‌داده‌شده در لیستینگ 20-41 نشان می‌دهد که زمانی که تابع impl_hello_macro را بر روی کد موجود در لیستینگ 20-37 اجرا کنیم، فیلد ident که دریافت می‌کنیم، دارای مقدار "Pancakes" خواهد بود. بنابراین، متغیر name در لیستینگ 20-42 شامل یک نمونه از ساختار Ident خواهد بود که هنگام چاپ، رشته "Pancakes" را نشان می‌دهد؛ یعنی نام struct موجود در لیستینگ 20-37.

ماکروی quote! به ما اجازه می‌دهد کد Rust مورد نظر خود برای بازگرداندن را تعریف کنیم. کامپایلر به چیزی متفاوت از نتیجه مستقیم اجرای ماکروی quote! نیاز دارد، بنابراین باید آن را به یک TokenStream تبدیل کنیم. این کار را با فراخوانی متد into انجام می‌دهیم که این نمایش میانی را مصرف کرده و مقداری از نوع TokenStream مورد نیاز بازمی‌گرداند.

ماکروی quote! همچنین برخی قابلیت‌های جالب الگوگذاری (templating) ارائه می‌دهد: می‌توانیم #name را وارد کنیم و quote! آن را با مقدار موجود در متغیر name جایگزین می‌کند. حتی می‌توانید تکرارهایی مشابه با نحوه کار ماکروهای معمولی انجام دهید. برای مقدمه‌ای جامع به مستندات crate quote مراجعه کنید.

ما می‌خواهیم ماکروی رویه‌ای ما یک پیاده‌سازی از ویژگی HelloMacro برای نوعی که کاربر حاشیه‌نویسی کرده است تولید کند، که می‌توانیم با استفاده از #name به آن دسترسی پیدا کنیم. پیاده‌سازی ویژگی شامل یک تابع به نام hello_macro است که بدنه آن قابلیت مورد نظر ما، یعنی چاپ Hello, Macro! My name is و سپس نام نوع حاشیه‌نویسی‌شده، را ارائه می‌دهد.

ماکروی stringify! که در این‌جا استفاده شده، یکی از ماکروهای توکار در Rust است. این ماکرو یک عبارت Rust، مانند 1 + 2 را می‌گیرد و در زمان کامپایل آن را به یک رشتهٔ متنی (string literal)، مانند "1 + 2" تبدیل می‌کند. این رفتار با ماکروهایی مانند format! یا println! متفاوت است؛ چرا که آن‌ها ابتدا مقدار عبارت را ارزیابی می‌کنند و سپس نتیجه را به یک String تبدیل می‌کنند. از آن‌جا که امکان دارد ورودی #name یک عبارت باشد که باید به صورت متنی چاپ شود، از stringify! استفاده می‌کنیم. همچنین استفاده از stringify! باعث صرفه‌جویی در حافظه می‌شود زیرا #name را در زمان کامپایل به یک رشتهٔ متنی تبدیل می‌کند.

در این مرحله، اجرای دستور cargo build باید در هر دو crate یعنی hello_macro و hello_macro_derive با موفقیت کامل شود. حال بیایید این دو crate را به کدی که در لیستینگ 20-37 آمده متصل کنیم تا عملکرد ماکروی procedural را در عمل ببینیم! در دایرکتوری projects خود، یک پروژهٔ باینری جدید با دستور cargo new pancakes ایجاد کنید. سپس باید hello_macro و hello_macro_derive را به عنوان وابستگی در فایل Cargo.toml مربوط به crate پروژهٔ pancakes اضافه کنید. اگر قصد دارید نسخه‌های خود از hello_macro و hello_macro_derive را در crates.io منتشر کنید، آن‌ها را به‌صورت وابستگی معمولی اضافه کنید؛ در غیر این صورت، می‌توانید آن‌ها را به‌صورت وابستگی مسیر (path) مانند نمونهٔ زیر مشخص کنید:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

کد موجود در لیستینگ 20-37 را در فایل src/main.rs قرار دهید و سپس دستور cargo run را اجرا کنید؛ باید خروجی زیر را مشاهده کنید:

Hello, Macro! My name is Pancakes!

پیاده‌سازی trait مربوط به HelloMacro توسط ماکروی procedural اضافه شده است، بدون این‌که crate مربوط به pancakes نیاز داشته باشد آن را خودش پیاده‌سازی کند؛ استفاده از #[derive(HelloMacro)] باعث شد پیاده‌سازی trait به کد اضافه شود.

اکنون، بیایید بررسی کنیم که سایر انواع ماکروهای procedural چه تفاوتی با ماکروهای سفارشی derive دارند.

ماکروهای شبیه به صفت (Attribute-Like Macros)

ماکروهای شبیه به صفت شباهت زیادی به ماکروهای سفارشی derive دارند، با این تفاوت که به‌جای تولید کد برای صفت derive، به شما اجازه می‌دهند که صفت‌های جدید ایجاد کنید. این نوع ماکروها انعطاف‌پذیرتر نیز هستند: derive فقط برای structها و enumها کار می‌کند؛ در حالی که صفات می‌توانند روی سایر آیتم‌ها نیز اعمال شوند، مانند توابع. در ادامه، نمونه‌ای از استفاده از یک ماکروی شبیه به صفت را مشاهده می‌کنید. فرض کنید صفتی به نام route دارید که برای علامت‌گذاری توابع در یک فریم‌ورک برنامه‌های وب استفاده می‌شود:

#[route(GET, "/")]
fn index() {

این ویژگی #[route] توسط فریم‌ورک به عنوان یک ماکروی رویه‌ای تعریف می‌شود. امضای تابع تعریف ماکرو به این صورت خواهد بود:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

در اینجا، دو پارامتر از نوع TokenStream داریم. پارامتر اول برای محتوای ویژگی است: بخش GET, "/". پارامتر دوم برای بدنه آیتمی است که ویژگی به آن متصل شده است: در این مورد، fn index() {} و باقی بدنه تابع.

به‌جز این تفاوت، ماکروهای شبیه به صفت دقیقاً به همان روشی کار می‌کنند که ماکروهای سفارشی derive کار می‌کنند: شما یک crate با نوع proc-macro ایجاد می‌کنید و تابعی را پیاده‌سازی می‌کنید که کدی را تولید می‌کند که می‌خواهید!

ماکروهای شبیه تابع (Function-Like Macros)

ماکروهای شبیه تابع، ماکروهایی هستند که شبیه به فراخوانی تابع به نظر می‌رسند. مشابه ماکروهای macro_rules!، این ماکروها نسبت به توابع انعطاف‌پذیرتر هستند؛ برای مثال، می‌توانند تعداد نامشخصی از آرگومان‌ها را بپذیرند. با این حال، ماکروهای macro_rules! فقط می‌توانند با استفاده از نحوی مشابه match که قبلاً در بخش [«ماکروهای اعلامی با macro_rules! برای فرا-برنامه‌نویسی عمومی»][decl] بحث شد، تعریف شوند. ماکروهای شبیه تابع یک پارامتر از نوع TokenStream می‌گیرند و تعریف آن‌ها با استفاده از کد Rust، مانند دو نوع دیگر از ماکروهای procedural، آن TokenStream را پردازش می‌کند. برای مثال، یک ماکروی sql! را در نظر بگیرید که ممکن است به صورت زیر فراخوانی شود:

let sql = sql!(SELECT * FROM posts WHERE id=1);

این ماکرو عبارت SQL داخل خود را تجزیه کرده و بررسی می‌کند که از نظر نحوی درست باشد، که پردازش بسیار پیچیده‌تری نسبت به آنچه یک ماکروی macro_rules! می‌تواند انجام دهد، دارد. ماکروی sql! به این صورت تعریف می‌شود:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

این تعریف مشابه امضای ماکروی سفارشی derive است: توکن‌هایی را که داخل پرانتز قرار دارند دریافت می‌کنیم و کدی را که می‌خواهیم تولید کنیم بازمی‌گردانیم.

خلاصه

ووف! حالا شما با برخی از ویژگی‌های Rust آشنا شده‌اید که احتمالاً زیاد از آن‌ها استفاده نخواهید کرد، اما می‌دانید که در شرایط خاصی در دسترس هستند. ما چندین موضوع پیچیده را معرفی کردیم تا زمانی که در پیام‌های خطا یا در کد دیگران با آن‌ها روبه‌رو شدید، بتوانید این مفاهیم و نحو را تشخیص دهید. از این فصل به‌عنوان یک مرجع استفاده کنید تا شما را به‌سمت راه‌حل‌ها راهنمایی کند.

در ادامه، همه چیزهایی که در طول کتاب بحث کردیم را در عمل پیاده‌سازی می‌کنیم و یک پروژه دیگر انجام خواهیم داد!

پروژه نهایی: ساخت یک وب سرور چندنخی

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

برای پروژه نهایی، یک وب سرور ایجاد می‌کنیم که عبارت “hello” را نمایش دهد و در یک مرورگر وب شبیه شکل 21-1 به نظر برسد.

hello from rust

شکل 21-1: پروژه نهایی ما

برنامه ما برای ساخت وب سرور به این صورت است:

  • کمی درباره TCP و HTTP یاد می‌گیریم.
  • گوش دادن به اتصالات TCP روی یک سوکت را پیاده‌سازی می‌کنیم.
  • تعداد کمی از درخواست‌های HTTP را تجزیه می‌کنیم.
  • یک پاسخ HTTP مناسب ایجاد می‌کنیم.
  • با استفاده از یک مجموعه نخ (thread pool) توان عملیاتی سرور را بهبود می‌بخشیم.

hello from rust

شکل ۲۱-۱: پروژه نهایی مشترک ما

پیش از شروع، باید به دو نکته اشاره کنیم. اول اینکه روشی که در این فصل استفاده خواهیم کرد، بهترین راه برای ساخت یک وب‌سرور با Rust نیست. اعضای جامعه کاربری، کرِیت‌هایی در سطح تولید (production-ready) منتشر کرده‌اند که در crates.io در دسترس‌اند و پیاده‌سازی‌های کامل‌تری از وب‌سرور و استخر نخ (thread pool) نسبت به چیزی که ما خواهیم ساخت ارائه می‌دهند. با این حال، هدف ما در این فصل آموزش دادن است، نه انتخاب ساده‌ترین مسیر. از آن‌جا که Rust یک زبان برنامه‌نویسی سیستمی است، می‌توانیم سطح انتزاعی که می‌خواهیم با آن کار کنیم را انتخاب کنیم و حتی به سطحی پایین‌تر برویم؛ چیزی که در زبان‌های دیگر یا ممکن نیست یا در عمل کاربردی ندارد.

قبل از شروع، باید به دو نکته اشاره کنیم: اول، روشی که استفاده خواهیم کرد بهترین روش برای ساخت یک وب سرور با Rust نخواهد بود. اعضای جامعه Rust تعداد زیادی crate آماده تولید در crates.io منتشر کرده‌اند که پیاده‌سازی‌های کامل‌تری از وب سرور و مجموعه نخ نسبت به آنچه که ما خواهیم ساخت ارائه می‌دهند. با این حال، هدف ما در این فصل کمک به یادگیری شماست، نه انتخاب مسیر آسان. از آنجا که Rust یک زبان برنامه‌نویسی سیستمی است، می‌توانیم سطح انتزاعی که می‌خواهیم با آن کار کنیم را انتخاب کنیم و به سطح پایین‌تری از آنچه در زبان‌های دیگر ممکن یا عملی است برویم.

دوم، ما اینجا از async و await استفاده نخواهیم کرد. ساخت یک مجموعه نخ به اندازه کافی چالش‌برانگیز است، بدون اینکه به ایجاد یک runtime async اضافه شود! با این حال، اشاره خواهیم کرد که async و await چگونه ممکن است برای برخی از همان مشکلاتی که در این فصل خواهیم دید کاربرد داشته باشند. در نهایت، همان‌طور که در فصل 17 ذکر کردیم، بسیاری از runtime‌های async از مجموعه نخ برای مدیریت کارهای خود استفاده می‌کنند.

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

ساخت یک وب سرور Single-Threaded

ما با راه‌اندازی یک وب سرور Single-Threaded شروع خواهیم کرد. پیش از شروع، بیایید یک مرور سریع بر پروتکل‌های مرتبط با ساخت وب سرورها داشته باشیم. جزئیات این پروتکل‌ها خارج از محدوده این کتاب است، اما یک نمای کلی اطلاعات لازم را به شما می‌دهد.

دو پروتکل اصلی که در وب سرورها درگیر هستند، Hypertext Transfer Protocol (HTTP) و Transmission Control Protocol (TCP) هستند. هر دو پروتکل request-response هستند، به این معنی که یک client درخواست‌ها را آغاز می‌کند و یک server به درخواست‌ها گوش می‌دهد و پاسخی به client ارائه می‌دهد. محتوای این درخواست‌ها و پاسخ‌ها توسط پروتکل‌ها تعریف شده است.

TCP یک پروتکل سطح پایین‌تر است که جزئیات چگونگی انتقال اطلاعات از یک سرور به سرور دیگر را توصیف می‌کند اما مشخص نمی‌کند که آن اطلاعات چیست. HTTP بر روی TCP ساخته شده است و محتوای درخواست‌ها و پاسخ‌ها را تعریف می‌کند. از لحاظ فنی امکان استفاده از HTTP با سایر پروتکل‌ها وجود دارد، اما در اکثر موارد، HTTP داده‌های خود را بر روی TCP ارسال می‌کند. ما با بایت‌های خام درخواست‌ها و پاسخ‌های TCP و HTTP کار خواهیم کرد.

گوش دادن به اتصال TCP

وب سرور ما نیاز دارد به اتصال TCP گوش دهد، بنابراین این اولین بخشی است که روی آن کار می‌کنیم. کتابخانه استاندارد یک ماژول std::net ارائه می‌دهد که به ما این امکان را می‌دهد این کار را انجام دهیم. بیایید یک پروژه جدید به روش معمول ایجاد کنیم:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

حالا کد لیست ۲۱-۱ را در فایل src/main.rs وارد کنید تا شروع کنیم. این کد به آدرس محلی 127.0.0.1:7878 برای جریان‌های ورودی TCP گوش می‌دهد. وقتی یک جریان ورودی دریافت می‌کند، پیام Connection established! را چاپ می‌کند.

Filename: src/main.rs
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}
Listing 21-1: گوش دادن به جریان‌های ورودی و چاپ یک پیام هنگام دریافت یک جریان

با استفاده از TcpListener، می‌توانیم به اتصالات TCP در آدرس 127.0.0.1:7878 گوش دهیم. در این آدرس، بخش قبل از دونقطه یک آدرس IP است که نمایانگر کامپیوتر شما است (این آدرس روی همه کامپیوترها یکسان است و نمایانگر کامپیوتر نویسندگان نیست) و 7878 پورت است. این پورت را به دو دلیل انتخاب کرده‌ایم: HTTP معمولاً روی این پورت پذیرفته نمی‌شود، بنابراین احتمالاً سرور ما با هیچ وب سرور دیگری که ممکن است روی دستگاه شما اجرا شود تداخل نخواهد داشت، و 7878 روی تلفن به صورت rust تایپ می‌شود.

تابع bind در این سناریو مانند تابع new عمل می‌کند به این صورت که یک نمونه جدید از TcpListener بازمی‌گرداند. این تابع bind نامیده می‌شود زیرا در شبکه، اتصال به یک پورت برای گوش دادن به آن به عنوان “binding to a port” شناخته می‌شود.

تابع bind مقداری از نوع Result<T, E> برمی‌گرداند، که نشان می‌دهد امکان شکست در عملیات bind وجود دارد. برای مثال، اگر دو نمونه از برنامه‌ی ما به‌طور هم‌زمان اجرا شوند و هر دو بخواهند به یک پورت گوش دهند، این عملیات ممکن است شکست بخورد. از آن‌جا که ما در حال نوشتن یک سرور ساده فقط برای اهداف آموزشی هستیم، نگران مدیریت این نوع خطاها نخواهیم بود؛ در عوض، از تابع unwrap استفاده می‌کنیم تا در صورت بروز خطا، برنامه متوقف شود.

متد incoming روی TcpListener یک iterator بازمی‌گرداند که به ما دنباله‌ای از جریان‌ها (streams) می‌دهد (به طور خاص، جریان‌هایی از نوع TcpStream). یک stream نشان‌دهنده یک اتصال باز بین client و server است. یک connection به فرآیند کامل درخواست و پاسخ گفته می‌شود که در آن یک client به سرور متصل می‌شود، سرور یک پاسخ تولید می‌کند، و سپس اتصال توسط سرور بسته می‌شود. بنابراین، ما از TcpStream برای خواندن آنچه client ارسال کرده استفاده می‌کنیم و سپس پاسخ خود را به جریان می‌نویسیم تا داده‌ها را به client بازگردانیم. به طور کلی، این حلقه for هر اتصال را به نوبت پردازش کرده و یک سری جریان‌ها را برای مدیریت به ما می‌دهد.

فعلاً، مدیریت ما روی جریان به فراخوانی unwrap محدود می‌شود تا در صورتی که جریان دارای خطا باشد، برنامه متوقف شود. اگر خطایی وجود نداشته باشد، برنامه یک پیام چاپ می‌کند. در لیست بعدی، عملکرد بیشتری برای حالت موفقیت اضافه خواهیم کرد. دلیل اینکه ممکن است از متد incoming هنگام اتصال یک client به سرور خطا دریافت کنیم این است که ما در واقع روی اتصالات تکرار نمی‌کنیم، بلکه روی تلاش‌های اتصال تکرار می‌کنیم. اتصال ممکن است به دلایل مختلف موفقیت‌آمیز نباشد که بسیاری از آن‌ها مربوط به سیستم‌عامل هستند. برای مثال، بسیاری از سیستم‌عامل‌ها محدودیتی برای تعداد اتصالات همزمان باز دارند؛ تلاش‌های اتصال جدیدی که بیش از این تعداد باشند، تا زمانی که برخی از اتصالات باز بسته نشوند، خطا تولید خواهند کرد.

بیایید اجرای این کد را امتحان کنیم! دستور cargo run را در ترمینال اجرا کنید و سپس آدرس 127.0.0.1:7878 را در یک مرورگر وب بارگذاری کنید. مرورگر باید پیامی مانند «Connection reset» نمایش دهد، چون سرور در حال حاضر هیچ داده‌ای ارسال نمی‌کند. اما وقتی به ترمینال خود نگاه کنید، باید چندین پیام ببینید که هنگام اتصال مرورگر به سرور چاپ شده‌اند!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

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

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

مرورگرها همچنین گاهی بدون ارسال هیچ درخواستی، چندین اتصال به سرور باز می‌کنند تا اگر بعداً بخواهند درخواستی ارسال کنند، آن درخواست‌ها سریع‌تر انجام شوند. وقتی این اتفاق می‌افتد، سرور ما هر اتصال را مشاهده می‌کند، صرف‌نظر از این‌که آیا درخواستی از طریق آن اتصال وجود دارد یا نه. بسیاری از نسخه‌های مرورگرهای مبتنی بر Chrome این کار را انجام می‌دهند؛ می‌توانید این بهینه‌سازی را با استفاده از حالت مرور خصوصی (private browsing) یا استفاده از مرورگر متفاوت غیرفعال کنید.

نکته‌ی مهم این است که ما موفق شده‌ایم به یک اتصال TCP دسترسی پیدا کنیم!

به یاد داشته باشید که پس از اجرای یک نسخه خاص از کد، برنامه را با فشردن ctrl+C متوقف کنید. سپس، پس از ایجاد هر مجموعه از تغییرات در کد، با اجرای دستور cargo run برنامه را دوباره اجرا کنید تا مطمئن شوید جدیدترین نسخه‌ی کد را اجرا می‌کنید.

خواندن درخواست

بیایید عملکرد خواندن درخواست از مرورگر را پیاده‌سازی کنیم! برای جدا کردن نگرانی‌ها از اتصال اولیه و سپس انجام برخی اقدامات با اتصال، یک تابع جدید برای پردازش اتصالات ایجاد می‌کنیم. در این تابع جدید handle_connection، داده‌ها را از جریان TCP می‌خوانیم و آن‌ها را چاپ می‌کنیم تا بتوانیم داده‌هایی که از مرورگر ارسال می‌شوند را ببینیم. کد را تغییر دهید تا شبیه لیست ۲۱-۲ شود.

Filename: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}
Listing 21-2: خواندن از TcpStream و چاپ داده‌ها

ما std::io::prelude و std::io::BufReader را وارد دامنه می‌کنیم تا به ویژگی‌ها و نوع‌هایی که به ما اجازه خواندن و نوشتن از جریان را می‌دهند دسترسی داشته باشیم. در حلقه for در تابع main، به جای چاپ یک پیام که می‌گوید یک اتصال برقرار کردیم، حالا تابع جدید handle_connection را فراخوانی می‌کنیم و stream را به آن ارسال می‌کنیم.

در تابع handle_connection، یک نمونه‌ی جدید از BufReader ایجاد می‌کنیم که یک رفرنس به stream را درون خود نگه می‌دارد. BufReader با مدیریت فراخوانی‌های متدهای trait مربوط به std::io::Read، به ما امکان می‌دهد تا به‌صورت بهینه‌تر از بافر استفاده کنیم.

ما یک متغیر به نام http_request ایجاد می‌کنیم تا خطوط درخواست ارسالی مرورگر به سرور را جمع‌آوری کنیم. مشخص می‌کنیم که می‌خواهیم این خطوط را در یک بردار جمع‌آوری کنیم با اضافه کردن نوع Vec<_> به عنوان حاشیه‌نویسی.

BufReader ویژگی std::io::BufRead را پیاده‌سازی می‌کند که متد lines را ارائه می‌دهد. متد lines یک iterator از نوع Result<String, std::io::Error> بازمی‌گرداند، که با هر بار مشاهده یک بایت newline جریان داده را تقسیم می‌کند. برای دریافت هر String، هر Result را map و unwrap می‌کنیم. اگر داده‌ها UTF-8 معتبری نباشند یا اگر مشکلی در خواندن از جریان وجود داشته باشد، ممکن است Result خطایی باشد. باز هم، یک برنامه تولیدی باید این خطاها را به صورت کارآمدتری مدیریت کند، اما برای سادگی، ما انتخاب می‌کنیم که در حالت خطا برنامه متوقف شود.

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

بیایید این کد را امتحان کنیم! برنامه را اجرا کنید و دوباره یک درخواست در مرورگر وب ارسال کنید. توجه داشته باشید که همچنان در مرورگر یک صفحه خطا خواهیم دید، اما خروجی برنامه در ترمینال اکنون مشابه این خواهد بود:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

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

بیایید این داده‌های درخواست را تجزیه کنیم تا متوجه شویم مرورگر از برنامه ما چه چیزی می‌خواهد.

نگاهی دقیق‌تر به یک درخواست HTTP

HTTP یک پروتکل مبتنی بر متن است و یک درخواست فرمت زیر را دارد:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

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

بخش بعدی از خط درخواست، / است که نشان‌دهنده‌ی شناسه‌ی یکنواخت منبع یا (uniform resource identifier) است که کلاینت آن را درخواست کرده است؛ یک URI تقریباً، اما نه کاملاً، همانند نشانی یکنواخت منبع یا (uniform resource locator) یا همان URL است. تفاوت بین URI و URL برای اهداف ما در این فصل اهمیت خاصی ندارد، اما مشخصات HTTP از اصطلاح URI استفاده می‌کند، بنابراین می‌توانیم در ذهن خود به‌جای URI از URL استفاده کنیم.

آخرین بخش نسخه‌ی HTTP است که کلاینت استفاده می‌کند، و سپس خط درخواست با یک دنباله‌ی CRLF به پایان می‌رسد. (CRLF مخفف carriage return و line feed است، که اصطلاحاتی مربوط به دوران ماشین تحریر هستند!) دنباله‌ی CRLF همچنین به‌صورت \r\n نیز نوشته می‌شود، جایی که \r به معنای carriage return و \n به معنای line feed است. دنباله‌ی CRLF خط درخواست را از باقی داده‌های درخواست جدا می‌کند. توجه داشته باشید که هنگام چاپ CRLF، ما شروع یک خط جدید را می‌بینیم، نه \r\n.

با نگاه کردن به داده‌های خط درخواست که تاکنون از اجرای برنامه خود دریافت کرده‌ایم، می‌بینیم که GET متد است، / شناسه URI درخواست‌شده است، و HTTP/1.1 نسخه است.

بعد از خط درخواست، خطوط باقی‌مانده از Host: به بعد هدرها هستند. درخواست‌های GET بدنه ندارند.

سعی کنید یک درخواست از مرورگری دیگر یا آدرسی متفاوت، مانند 127.0.0.1:7878/test، ارسال کنید تا ببینید داده‌های درخواست چگونه تغییر می‌کنند.

اکنون که می‌دانیم مرورگر چه چیزی می‌خواهد، بیایید داده‌ای را به آن بازگردانیم!

نوشتن یک پاسخ

ما قصد داریم ارسال داده در پاسخ به یک درخواست client را پیاده‌سازی کنیم. پاسخ‌ها فرمت زیر را دارند:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

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

در اینجا یک مثال پاسخ آورده شده است که از نسخه HTTP 1.1 استفاده می‌کند، کد وضعیت 200 دارد، عبارت دلیل “OK” است، هیچ هدر و بدنه‌ای ندارد:

HTTP/1.1 200 OK\r\n\r\n

کد وضعیت 200 پاسخ استاندارد موفقیت است. این متن یک پاسخ HTTP کوچک و موفقیت‌آمیز است. بیایید این را به عنوان پاسخ خود به یک درخواست موفق به جریان بنویسیم! از تابع handle_connection، println! که داده‌های درخواست را چاپ می‌کرد، حذف کنید و آن را با کد موجود در لیست ۲۱-۳ جایگزین کنید.

Filename: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-3: نوشتن یک پاسخ HTTP کوچک و موفقیت‌آمیز به جریان

خط جدید اول متغیر response را تعریف می‌کند که داده پیام موفقیت را در خود نگه می‌دارد. سپس با فراخوانی as_bytes روی response داده رشته‌ای را به بایت‌ها تبدیل می‌کنیم. متد write_all روی stream یک &[u8] می‌گیرد و آن بایت‌ها را مستقیماً به اتصال ارسال می‌کند. از آنجا که عملیات write_all ممکن است شکست بخورد، مانند قبل، روی هر نتیجه خطا از unwrap استفاده می‌کنیم. باز هم، در یک برنامه واقعی باید اینجا مدیریت خطا اضافه کنید.

با این تغییرات، بیایید کد خود را اجرا کنیم و یک درخواست ارسال کنیم. دیگر هیچ داده‌ای به ترمینال چاپ نمی‌کنیم، بنابراین هیچ خروجی‌ای به غیر از خروجی Cargo نخواهید دید. وقتی آدرس 127.0.0.1:7878 را در یک مرورگر وب بارگذاری می‌کنید، باید یک صفحه خالی به جای یک خطا دریافت کنید. شما اکنون دریافت یک درخواست HTTP و ارسال یک پاسخ را به صورت دستی کدنویسی کرده‌اید!

بازگرداندن HTML واقعی (Returning Real HTML)

بیایید عملکرد بازگرداندن چیزی بیش از یک صفحه خالی را پیاده‌سازی کنیم. فایل جدیدی به نام hello.html در ریشه دایرکتوری پروژه خود ایجاد کنید، نه در دایرکتوری src. می‌توانید هر HTML که می‌خواهید وارد کنید؛ لیست ۲۱-۴ یک نمونه را نشان می‌دهد.

Filename: hello.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>
Listing 21-4: یک فایل نمونه HTML برای بازگرداندن در پاسخ

این یک سند HTML5 حداقلی با یک عنوان و مقداری متن است. برای بازگرداندن این فایل از سرور هنگام دریافت یک درخواست، کد handle_connection را همان‌طور که در لیست ۲۱-۵ نشان داده شده است تغییر می‌دهیم تا فایل HTML را بخواند، آن را به عنوان بدنه پاسخ اضافه کند و ارسال کند.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-5: ارسال محتوای hello.html به عنوان بدنه پاسخ

ما fs را به دستور use اضافه کرده‌ایم تا ماژول سیستم فایل کتابخانه استاندارد را وارد دامنه کنیم. کدی که محتوای یک فایل را به یک رشته می‌خواند باید آشنا به نظر برسد؛ ما در فصل ۱۲ زمانی که محتوای یک فایل را برای پروژه ورودی/خروجی خود خواندیم از آن استفاده کردیم (لیست ۱۲-۴).

سپس، از format! برای اضافه کردن محتوای فایل به عنوان بدنه پاسخ موفقیت استفاده می‌کنیم. برای اطمینان از یک پاسخ HTTP معتبر، هدر Content-Length را اضافه می‌کنیم که به اندازه بدنه پاسخ ما تنظیم شده است، که در این مورد اندازه فایل hello.html است.

این کد را با دستور cargo run اجرا کنید و آدرس 127.0.0.1:7878 را در مرورگر خود بارگذاری کنید؛ باید HTML خود را که به درستی رندر شده است ببینید!

در حال حاضر، ما داده‌های درخواست در http_request را نادیده می‌گیریم و فقط محتوای فایل HTML را بدون شرط بازمی‌گردانیم. این بدان معناست که اگر در مرورگر خود آدرس 127.0.0.1:7878/something-else را درخواست کنید، همچنان همین پاسخ HTML را دریافت خواهید کرد. در این لحظه، سرور ما بسیار محدود است و کارهایی که اکثر وب سرورها انجام می‌دهند را انجام نمی‌دهد. ما می‌خواهیم پاسخ‌های خود را بر اساس درخواست سفارشی کنیم و فقط فایل HTML را برای یک درخواست خوش‌ساخت به / بازگردانیم.

اعتبارسنجی درخواست و پاسخ‌دهی انتخابی

در حال حاضر، وب سرور ما محتوای فایل HTML را بدون توجه به درخواست client بازمی‌گرداند. بیایید عملکردی اضافه کنیم تا بررسی کند که مرورگر / را درخواست کرده باشد، سپس فایل HTML را بازگرداند و اگر مرورگر چیز دیگری درخواست کرد، یک خطا بازگرداند. برای این کار باید تابع handle_connection را همان‌طور که در لیست ۲۱-۶ نشان داده شده است تغییر دهیم. این کد جدید محتوای درخواست دریافتی را با آنچه که می‌دانیم یک درخواست برای / باید به نظر برسد مقایسه می‌کند و بلوک‌های if و else را اضافه می‌کند تا درخواست‌ها به صورت متفاوتی مدیریت شوند.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}
Listing 21-6: مدیریت درخواست‌های / به صورت متفاوت با سایر درخواست‌ها

ما فقط به خط اول درخواست HTTP نگاه خواهیم کرد، بنابراین به جای خواندن کل درخواست در یک بردار، از next استفاده می‌کنیم تا اولین آیتم از iterator را بگیریم. اولین unwrap مقدار Option را مدیریت می‌کند و اگر iterator هیچ آیتمی نداشته باشد برنامه را متوقف می‌کند. دومین unwrap مقدار Result را مدیریت می‌کند و همان اثری را دارد که unwrap در map اضافه‌شده در لیست ۲۱-۲ داشت.

سپس، مقدار request_line را بررسی می‌کنیم تا ببینیم آیا برابر با خط درخواست یک درخواست GET به مسیر / است یا خیر. اگر این‌طور باشد، بلوک if محتوای فایل HTML ما را بازمی‌گرداند.

اگر مقدار request_line برابر با درخواست GET به مسیر / نباشد، به این معنی است که یک درخواست دیگر دریافت کرده‌ایم. در بلوک else کدی اضافه خواهیم کرد تا به سایر درخواست‌ها پاسخ دهد.

این کد را اجرا کنید و آدرس 127.0.0.1:7878 را درخواست کنید؛ باید HTML موجود در فایل hello.html را دریافت کنید. اگر درخواست دیگری مانند 127.0.0.1:7878/something-else ارسال کنید، یک خطای اتصال مشابه آنچه در اجرای کدهای لیست ۲۱-۱ و ۲۱-۲ دیدید دریافت خواهید کرد.

حالا کد موجود در لیست ۲۱-۷ را به بلوک else اضافه کنید تا پاسخی با کد وضعیت 404 بازگرداند، که نشان می‌دهد محتوای درخواست‌شده پیدا نشد. همچنین مقداری HTML برای یک صفحه خطا بازمی‌گردانیم تا در مرورگر به کاربر نهایی نمایش داده شود.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}
Listing 21-7: پاسخ‌دهی با کد وضعیت 404 و یک صفحه خطا اگر چیزی به غیر از / درخواست شود

در اینجا، پاسخ ما یک خط وضعیت با کد وضعیت 404 و عبارت دلیل NOT FOUND دارد. بدنه پاسخ HTML موجود در فایل 404.html خواهد بود. باید فایل 404.html را در کنار فایل hello.html برای صفحه خطا ایجاد کنید؛ باز هم، می‌توانید هر HTML که می‌خواهید استفاده کنید یا از HTML نمونه موجود در لیست ۲۱-۸ استفاده کنید.

Filename: 404.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>
Listing 21-8: محتوای نمونه برای صفحه‌ای که با هر پاسخ 404 بازگردانده می‌شود

با این تغییرات، سرور خود را دوباره اجرا کنید. درخواست آدرس 127.0.0.1:7878 باید محتوای فایل hello.html را بازگرداند، و هر درخواست دیگری مانند 127.0.0.1:7878/foo باید HTML خطا از فایل 404.html را بازگرداند.

کمی بازسازی (Refactoring)

در حال حاضر، بلوک‌های if و else مقدار زیادی تکرار دارند: هر دو فایل‌ها را می‌خوانند و محتوای فایل‌ها را به جریان می‌نویسند. تنها تفاوت‌ها خط وضعیت و نام فایل هستند. بیایید کد را مختصرتر کنیم و این تفاوت‌ها را به خطوط جداگانه if و else انتقال دهیم که مقادیر خط وضعیت و نام فایل را به متغیرها اختصاص دهند؛ سپس می‌توانیم از این متغیرها به طور شرطی برای خواندن فایل و نوشتن پاسخ استفاده کنیم. لیست ۲۱-۹ کد نتیجه‌شده پس از جایگزینی بلوک‌های بزرگ if و else را نشان می‌دهد.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-9: بازسازی بلوک‌های if و else برای شامل شدن تنها کدی که بین دو حالت متفاوت است

اکنون بلوک‌های if و else تنها مقادیر مناسب برای خط وضعیت و نام فایل را در یک تاپل بازمی‌گردانند؛ سپس با استفاده از یک الگو در دستور let، این دو مقدار به status_line و filename تخصیص داده می‌شوند، همان‌طور که در فصل ۱۹ بحث شد.

کدی که قبلاً تکراری بود اکنون خارج از بلوک‌های if و else قرار دارد و از متغیرهای status_line و filename استفاده می‌کند. این کار تشخیص تفاوت بین دو حالت را آسان‌تر می‌کند و به این معنی است که اگر بخواهیم نحوه خواندن فایل و نوشتن پاسخ را تغییر دهیم، تنها یک مکان برای به‌روزرسانی کد داریم. رفتار کد در لیست ۲۱-۹ با لیست ۲۱-۷ یکسان خواهد بود.

عالی! اکنون یک وب سرور ساده با تقریباً ۴۰ خط کد Rust داریم که به یک درخواست با یک صفحه محتوا پاسخ می‌دهد و به تمام درخواست‌های دیگر یک پاسخ 404 می‌دهد.

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

تبدیل سرور Single-Threaded به یک سرور Multithreaded

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

شبیه‌سازی یک درخواست کند

می‌خواهیم ببینیم چگونه یک درخواست با پردازش کند می‌تواند بر درخواست‌های دیگر به سرور فعلی ما تأثیر بگذارد.
لیستینگ 21-10 نحوه‌ی رسیدگی به درخواستی به مسیر /sleep را پیاده‌سازی می‌کند
که با یک پاسخ شبیه‌سازی شده‌ی کند، باعث می‌شود سرور پنج ثانیه قبل از پاسخ دادن بخوابد.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    // --snip--

    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --snip--

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-10: شبیه‌سازی یک درخواست کند با خوابیدن به مدت پنج ثانیه

حالا که سه حالت داریم، به جای if از match استفاده کرده‌ایم. باید صریحاً روی یک slice از request_line تطبیق الگو (pattern matching) انجام دهیم تا بتوانیم با مقادیر رشته‌ای literal تطبیق دهیم؛ چون match مانند متد برابری به‌صورت خودکار رفرنس‌گذاری و dereference نمی‌کند.

بازوی اول مشابه بلاک if در لیستینگ 21-9 است. بازوی دوم با درخواستی به مسیر /sleep مطابقت دارد. وقتی این درخواست دریافت شود، سرور به مدت پنج ثانیه می‌خوابد و سپس صفحه‌ی HTML موفقیت‌آمیز را رندر می‌کند. بازوی سوم همانند بلاک else در لیستینگ 21-9 است.

می‌توانید ببینید که سرور ما چقدر ابتدایی است: کتابخانه‌های واقعی مدیریت تشخیص درخواست‌های متعدد را به روشی بسیار کمتر پرحرف انجام می‌دهند!

سرور را با دستور cargo run اجرا کنید.
سپس دو پنجره‌ی مرورگر باز کنید: یکی برای آدرس http://127.0.0.1:7878 و دیگری برای http://127.0.0.1:7878/sleep.
اگر چند بار آدرس / را وارد کنید، مانند قبل پاسخ سریع دریافت خواهید کرد.
اما اگر ابتدا آدرس /sleep را وارد کنید و سپس آدرس / را بارگذاری کنید،
خواهید دید که / تا پایان پنج ثانیه خوابیدن sleep منتظر می‌ماند و سپس بارگذاری می‌شود.

بهبود توان عملیاتی با یک Thread Pool

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

ما تعداد Threadهای موجود در Pool را به یک عدد کوچک محدود خواهیم کرد تا از حملات Denial of Service (DoS) محافظت کنیم؛ اگر برنامه ما برای هر درخواست جدید یک Thread ایجاد کند، کسی که ۱۰ میلیون درخواست به سرور ما ارسال کند می‌تواند با استفاده از تمام منابع سرور، پردازش درخواست‌ها را متوقف کند.

برای محافظت در برابر حملات DoS، تعداد threadهای موجود در thread pool را محدود می‌کنیم؛ زیرا اگر برنامه‌ی ما برای هر درخواست یک thread جدید بسازد، کسی که ۱۰ میلیون درخواست به سرور ما ارسال کند می‌تواند با مصرف همه‌ی منابع سرور، عملیات پردازش درخواست‌ها را کاملاً متوقف کند.

به جای ایجاد threadهای نامحدود، یک تعداد ثابت thread در pool خواهیم داشت که منتظر می‌مانند. درخواست‌های ورودی به این pool ارسال می‌شوند تا پردازش شوند. pool صفی از درخواست‌های ورودی را نگه می‌دارد. هر یک از threadهای pool یک درخواست را از صف بیرون می‌کشد، آن را پردازش می‌کند، و سپس درخواست بعدی را از صف دریافت می‌کند. با این طراحی، می‌توانیم تا N درخواست را به‌صورت همزمان پردازش کنیم، که N برابر با تعداد threadها است. اگر هر thread در حال پاسخ دادن به یک درخواست طولانی باشد، درخواست‌های بعدی می‌توانند در صف منتظر بمانند، اما ما تعداد درخواست‌های طولانی که می‌توانیم قبل از رسیدن به این نقطه پردازش کنیم را افزایش داده‌ایم.

این تکنیک یکی از روش‌های متعددی است که برای افزایش throughput یک وب سرور وجود دارد. گزینه‌های دیگری که می‌توانید بررسی کنید عبارت‌اند از مدل fork/join، مدل async I/O تک‌نخی، و مدل async I/O چندنخی. اگر به این موضوع علاقه‌مند هستید، می‌توانید درباره‌ی راه‌حل‌های دیگر مطالعه کنید و سعی کنید آن‌ها را پیاده‌سازی کنید؛ با زبانی سطح پایین مانند Rust، تمام این گزینه‌ها ممکن هستند.

مشابه روش توسعه مبتنی بر تست که در پروژه فصل ۱۲ استفاده کردیم، اینجا از توسعه مبتنی بر کامپایلر استفاده می‌کنیم. کدی را که توابع مورد نظرمان را فراخوانی می‌کند، می‌نویسیم و سپس به خطاهای کامپایلر نگاه می‌کنیم تا مشخص کنیم چه تغییراتی باید انجام دهیم تا کد کار کند. با این حال، پیش از انجام این کار، روش دیگری را که قرار نیست استفاده کنیم، به عنوان نقطه شروع بررسی خواهیم کرد.

ایجاد یک thread برای هر درخواست

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

لیستینگ 21-11 تغییرات لازم در تابع main را نشان می‌دهد تا برای هر stream در حلقه‌ی for یک thread جدید ایجاد کند و آن را مدیریت نماید.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-11: ایجاد یک Thread جدید برای هر جریان

همان‌طور که در فصل ۱۶ یاد گرفتید، thread::spawn یک Thread جدید ایجاد کرده و سپس کد موجود در کلوزر را در Thread جدید اجرا می‌کند. اگر این کد را اجرا کنید و در مرورگر خود /sleep را باز کنید، سپس / را در دو تب دیگر باز کنید، خواهید دید که درخواست‌های / لازم نیست منتظر پایان درخواست /sleep باشند. با این حال، همان‌طور که ذکر شد، این روش در نهایت سیستم را تحت فشار قرار می‌دهد زیرا شما تعداد نامحدودی Thread بدون محدودیت ایجاد می‌کنید.

ممکن است به یاد بیاورید که این دقیقاً همان شرایطی است که async و await در آن می‌درخشند! این نکته را در ذهن داشته باشید در حالی که Thread Pool را می‌سازیم و به این فکر کنید که چگونه این شرایط با async متفاوت یا مشابه خواهد بود.

Creating a Finite Number of Threads

می‌خواهیم thread pool ما به روشی مشابه و آشنا کار کند،
به‌طوری که تغییر از استفاده‌ی مستقیم از threadها به استفاده از thread pool
نیاز به تغییرات بزرگ در کدی که از API ما استفاده می‌کند نداشته باشد.
لیستینگ 21-12 رابط فرضی ساختار ThreadPool را نشان می‌دهد
که می‌خواهیم به جای thread::spawn از آن استفاده کنیم.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-12: رابط ایده‌آل برای ThreadPool

ما با استفاده از ThreadPool::new یک thread pool جدید با تعداد قابل تنظیم thread ایجاد می‌کنیم، در این مثال تعداد چهار thread است. سپس در حلقه‌ی for، متد pool.execute رابطی مشابه با thread::spawn دارد، که یک closure می‌گیرد و pool باید آن را برای هر stream اجرا کند. ما باید pool.execute را پیاده‌سازی کنیم تا این closure را بگیرد و به یک thread در pool بدهد تا اجرا شود. این کد هنوز کامپایل نخواهد شد، اما این کار را انجام می‌دهیم تا کامپایلر ما را در رفع خطاها راهنمایی کند.

ساخت ThreadPool با استفاده از توسعه‌ی مبتنی بر خطاهای کامپایلر

تغییرات لیستینگ 21-12 را در فایل src/main.rs اعمال کنید، سپس اجازه دهید خطاهای کامپایلر از دستور cargo check روند توسعه‌ی ما را هدایت کنند. در اینجا اولین خطایی که دریافت می‌کنیم آمده است:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0433]: failed to resolve: use of undeclared type `ThreadPool`
  --> src/main.rs:11:16
   |
11 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^ use of undeclared type `ThreadPool`

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

عالی! این خطا به ما می‌گوید که به یک نوع یا ماژول ThreadPool نیاز داریم، پس اکنون یکی می‌سازیم. پیاده‌سازی ThreadPool ما مستقل از نوع کاری است که وب‌سرور انجام می‌دهد. بنابراین، بیایید crate hello را از یک crate دودویی به یک crate کتابخانه‌ای تبدیل کنیم تا پیاده‌سازی ThreadPool را در آن قرار دهیم. پس از تبدیل به crate کتابخانه‌ای، می‌توانیم از این کتابخانه‌ی thread pool جداگانه برای هر کاری که می‌خواهیم با thread pool انجام دهیم استفاده کنیم، نه فقط برای سرویس‌دهی به درخواست‌های وب.

یک فایل src/lib.rs ایجاد کنید که شامل کد زیر باشد، که ساده‌ترین تعریف ممکن برای ThreadPool است که فعلاً می‌توانیم داشته باشیم:

Filename: src/lib.rs
pub struct ThreadPool;

سپس فایل main.rs را ویرایش کنید تا با افزودن کد زیر به بالای فایل src/main.rs، ThreadPool را از crate کتابخانه‌ای وارد حوزه (scope) کنید:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

این کد همچنان کار نخواهد کرد، اما بیایید دوباره آن را بررسی کنیم تا خطای بعدی که باید برطرف کنیم را ببینیم:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no function or associated item named `new` found for struct `ThreadPool` in the current scope
  --> src/main.rs:12:28
   |
12 |     let pool = ThreadPool::new(4);
   |                            ^^^ function or associated item not found in `ThreadPool`

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

این خطا نشان می‌دهد که باید تابع وابسته‌ای به نام new برای ThreadPool ایجاد کنیم. همچنین می‌دانیم که new باید یک پارامتر داشته باشد که بتواند مقدار 4 را به عنوان آرگومان بپذیرد و یک نمونه از ThreadPool بازگرداند. بیایید ساده‌ترین تابع new که این خصوصیات را دارد پیاده‌سازی کنیم:

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

ما نوع پارامتر size را usize انتخاب کردیم چون می‌دانیم تعداد منفی thread منطقی نیست. همچنین می‌دانیم که این مقدار 4 را به‌عنوان تعداد عناصر در یک مجموعه از threadها استفاده خواهیم کرد، که برای همین منظور نوع usize مناسب است، همان‌طور که در بخش “انواع عدد صحیح” در فصل ۳ توضیح داده شده است.

بیایید دوباره کد را بررسی کنیم:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
  --> src/main.rs:17:14
   |
17 |         pool.execute(|| {
   |         -----^^^^^^^ method not found in `ThreadPool`

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

اکنون خطا رخ می‌دهد چون متد execute روی ThreadPool تعریف نشده است.
به یاد بیاورید از بخش “ایجاد تعداد محدودی thread”
که تصمیم گرفتیم رابط کاربری thread pool ما شبیه به thread::spawn باشد.
علاوه بر این، متد execute را پیاده‌سازی خواهیم کرد تا closure دریافت‌شده را بگیرد
و آن را به یک thread بیکار در pool بدهد تا اجرا کند.

متد execute را روی ThreadPool تعریف می‌کنیم تا یک closure را به‌عنوان پارامتر بگیرد.
به یاد بیاورید از بخش “انتقال مقادیر گرفته‌شده از closure و traitهای Fn در فصل ۱۳
که می‌توانیم closureها را با سه trait مختلف به‌عنوان پارامتر بگیریم: Fn، FnMut و FnOnce.
باید تصمیم بگیریم در اینجا از کدام نوع closure استفاده کنیم.
می‌دانیم که قرار است کاری مشابه پیاده‌سازی thread::spawn در کتابخانه استاندارد انجام دهیم،
پس می‌توانیم به محدودیت‌هایی که امضای تابع thread::spawn روی پارامترش دارد نگاه کنیم.
مستندات به ما موارد زیر را نشان می‌دهد:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

نوع پارامتر F همان چیزی است که در اینجا به آن توجه داریم؛ پارامتر نوع T مربوط به مقدار بازگشتی است و ما به آن توجه نداریم. می‌توانیم ببینیم که spawn از FnOnce به عنوان محدودیت ویژگی روی F استفاده می‌کند. این احتمالاً چیزی است که ما نیز می‌خواهیم، زیرا در نهایت آرگومان دریافتی در execute را به spawn پاس می‌دهیم. ما اطمینان بیشتری داریم که FnOnce همان ویژگی مورد نظر ما است، زیرا Thread برای اجرای یک درخواست فقط Closure مربوط به آن درخواست را یک بار اجرا می‌کند، که با “Once” در FnOnce مطابقت دارد.

پارامتر نوع F همچنین دارای محدودیت ویژگی Send و محدودیت طول عمر 'static است، که در وضعیت ما مفید هستند: ما به Send نیاز داریم تا Closure را از یک Thread به Thread دیگر منتقل کنیم و به 'static نیاز داریم زیرا نمی‌دانیم اجرای Thread چه مدت طول می‌کشد. بیایید یک متد execute روی ThreadPool ایجاد کنیم که یک پارامتر عمومی از نوع F با این محدودیت‌ها بپذیرد:

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

ما همچنان از () پس از FnOnce استفاده می‌کنیم زیرا این FnOnce نشان‌دهنده یک Closure است که هیچ پارامتری نمی‌گیرد و نوع () را بازمی‌گرداند. درست مانند تعریف توابع، می‌توان نوع بازگشتی را از امضا حذف کرد، اما حتی اگر هیچ پارامتری نداشته باشیم، همچنان به پرانتزها نیاز داریم.

دوباره، این ساده‌ترین پیاده‌سازی متد execute است: هیچ کاری انجام نمی‌دهد، اما هدف ما فقط این است که کدمان کامپایل شود. بیایید دوباره آن را بررسی کنیم:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s

کد کامپایل می‌شود! اما توجه داشته باشید که اگر cargo run را اجرا کنید و در مرورگر یک درخواست ارسال کنید، خطاهایی را در مرورگر خواهید دید که در ابتدای فصل دیده بودیم. کتابخانه ما هنوز Closure پاس‌داده‌شده به execute را فراخوانی نمی‌کند!

نکته: یک ضرب‌المثل درباره زبان‌هایی با کامپایلرهای سخت‌گیر، مانند Haskell و Rust، این است که “اگر کد کامپایل شود، کار می‌کند.” اما این ضرب‌المثل همیشه درست نیست. پروژه ما کامپایل می‌شود، اما هیچ کاری انجام نمی‌دهد! اگر در حال ساخت یک پروژه واقعی و کامل بودیم، اکنون زمان خوبی برای شروع نوشتن تست‌های واحد بود تا بررسی کنیم که کد هم کامپایل می‌شود و رفتار مورد نظر ما را دارد.

فرض کنید: اگر قرار بود به جای closure، یک future اجرا کنیم، چه تفاوت‌هایی در اینجا وجود داشت؟

اعتبارسنجی تعداد Threadها در new

فعلاً با پارامترهای new و execute کاری انجام نمی‌دهیم. بیایید بدنه‌ی این توابع را با رفتار مورد نظر پیاده‌سازی کنیم. برای شروع، به تابع new فکر کنیم. قبلاً برای پارامتر size نوع بدون علامت (unsigned) را انتخاب کردیم، چون یک pool با تعداد منفی thread منطقی نیست. اما یک pool با صفر thread نیز منطقی نیست، با این‌که صفر یک مقدار معتبر از نوع usize است. ما کدی اضافه خواهیم کرد که بررسی کند مقدار size بزرگ‌تر از صفر باشد، و اگر صفر دریافت شد، با استفاده از ماکروی assert! برنامه panic کند، همان‌طور که در لیستینگ 21-13 نشان داده شده است.

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listing 21-13: پیاده‌سازی ThreadPool::new برای توقف برنامه در صورت صفر بودن size

ما همچنین برخی مستندات برای ThreadPool خود با استفاده از نظرات داکیومنت (doc comments) اضافه کرده‌ایم. توجه داشته باشید که ما از اصول خوب مستندسازی پیروی کرده‌ایم و بخشی را اضافه کرده‌ایم که شرایطی که تابع ما ممکن است به وحشت بیفتد (panic) را توضیح می‌دهد، همان‌طور که در فصل ۱۴ مورد بحث قرار گرفت. دستور cargo doc --open را اجرا کنید و روی ساختار ThreadPool کلیک کنید تا ببینید مستندات تولیدشده برای new چگونه به نظر می‌رسند!

به جای اضافه کردن ماکروی assert! همان‌طور که اینجا انجام دادیم، می‌توانستیم new را به build تغییر دهیم و یک Result بازگردانیم، مانند آنچه با Config::build در پروژه I/O در لیست ۱۲-۹ انجام دادیم. اما در این مورد تصمیم گرفته‌ایم که تلاش برای ایجاد یک Thread Pool بدون هیچ Threadی باید یک خطای غیرقابل بازیابی باشد. اگر احساس جاه‌طلبی می‌کنید، سعی کنید تابعی به نام build با امضای زیر بنویسید تا با تابع new مقایسه کنید:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

ایجاد فضایی برای ذخیره Threadها

اکنون که روشی برای اطمینان از تعداد معتبر Threadهایی که در Pool ذخیره می‌شوند داریم، می‌توانیم این Threadها را ایجاد کرده و آن‌ها را در ساختار ThreadPool قبل از بازگرداندن ساختار ذخیره کنیم. اما چگونه می‌توانیم یک Thread را “ذخیره” کنیم؟ بیایید دوباره به امضای thread::spawn نگاه کنیم:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

تابع spawn یک JoinHandle<T> بازمی‌گرداند، جایی که T نوعی است که Closure بازمی‌گرداند. بیایید ما هم از JoinHandle استفاده کنیم و ببینیم چه اتفاقی می‌افتد. در مورد ما، Closureهایی که به Thread Pool ارسال می‌کنیم اتصال را مدیریت کرده و چیزی بازنمی‌گردانند، بنابراین T برابر با نوع واحد () خواهد بود.

کد موجود در لیست ۲۱-۱۴ کامپایل می‌شود اما هنوز هیچ Threadی ایجاد نمی‌کند. ما تعریف ThreadPool را تغییر داده‌ایم تا یک بردار از نمونه‌های thread::JoinHandle<()> را نگه دارد، بردار را با ظرفیتی برابر با size مقداردهی اولیه کرده‌ایم، یک حلقه for تنظیم کرده‌ایم که کدی برای ایجاد Threadها اجرا می‌کند، و یک نمونه از ThreadPool که آن‌ها را در خود دارد بازمی‌گرداند.

Filename: src/lib.rs
use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool { threads }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listing 21-14: ایجاد یک بردار برای ThreadPool برای نگهداری Threadها

ما std::thread را در crate کتابخانه‌ای وارد حوزه کرده‌ایم، چون از thread::JoinHandle به‌عنوان نوع آیتم‌های موجود در بردار داخل ThreadPool استفاده می‌کنیم.

پس از دریافت مقدار معتبر برای size، ThreadPool ما یک بردار جدید ایجاد می‌کند که می‌تواند size آیتم را در خود نگه دارد. تابع with_capacity همان کاری را انجام می‌دهد که Vec::new انجام می‌دهد، اما با یک تفاوت مهم: فضای لازم را از پیش در بردار تخصیص می‌دهد. از آن‌جایی که می‌دانیم باید size عنصر در بردار ذخیره کنیم، انجام این تخصیص پیشاپیش کمی کارآمدتر از استفاده از Vec::new است، که هنگام وارد کردن عناصر، اندازه‌ی خود را تغییر می‌دهد.

وقتی دوباره cargo check را اجرا کنید، باید با موفقیت انجام شود.

ساختار Worker مسئول ارسال کد از ThreadPool به یک Thread

در حلقه for در لیست ۲۱-۱۴، نظری در مورد ایجاد Threadها گذاشتیم. در اینجا بررسی خواهیم کرد که چگونه واقعاً Threadها را ایجاد می‌کنیم. کتابخانه استاندارد thread::spawn را به عنوان روشی برای ایجاد Threadها ارائه می‌دهد، و thread::spawn انتظار دارد کدی دریافت کند که Thread بلافاصله پس از ایجاد اجرا کند. با این حال، در مورد ما، می‌خواهیم Threadها را ایجاد کنیم و آن‌ها را منتظر نگه داریم تا کدی که بعداً ارسال می‌کنیم را اجرا کنند. پیاده‌سازی Threadها در کتابخانه استاندارد هیچ راهی برای انجام این کار ارائه نمی‌دهد؛ بنابراین باید آن را به صورت دستی پیاده‌سازی کنیم.

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

این را مانند افرادی در آشپزخانه‌ی یک رستوران تصور کنید: کارگران منتظر می‌مانند تا سفارش‌ها از مشتریان برسد، سپس مسئول پذیرش و آماده‌سازی آن سفارش‌ها هستند.

به جای نگه‌داشتن یک بردار از نمونه‌های JoinHandle<()> در thread pool، نمونه‌های Worker را ذخیره خواهیم کرد. هر Worker یک نمونه‌ی تک JoinHandle<()> نگه می‌دارد. سپس متدی روی Worker پیاده‌سازی می‌کنیم که یک closure از کد برای اجرا بگیرد و آن را به thread در حال اجرای مربوطه برای اجرا ارسال کند. همچنین به هر Worker یک id اختصاص می‌دهیم تا بتوانیم هنگام لاگ‌گیری یا اشکال‌زدایی، بین نمونه‌های مختلف Worker در pool تمایز قائل شویم.

این فرآیند جدیدی است که هنگام ایجاد یک ThreadPool اتفاق می‌افتد. کدی که Closure را به Thread ارسال می‌کند، پس از تنظیم Worker به این شکل پیاده‌سازی خواهد شد:

۱. یک struct به نام Worker تعریف کنید که شامل یک فیلد id و یک JoinHandle<()> باشد. ۲. ساختار ThreadPool را تغییر دهید تا یک بردار از نمونه‌های Worker نگه دارد. ۳. تابعی به نام Worker::new تعریف کنید که یک شماره‌ی id بگیرد و یک نمونه Worker بازگرداند که شامل آن id و یک thread ساخته شده با یک closure خالی باشد. ۴. در تابع ThreadPool::new، از شمارنده حلقه‌ی for برای تولید id استفاده کنید، یک Worker جدید با آن id بسازید و آن را در بردار ذخیره کنید.

اگر آماده یک چالش هستید، سعی کنید این تغییرات را خودتان پیاده‌سازی کنید قبل از اینکه به کد موجود در لیست ۲۱-۱۵ نگاه کنید.

آماده‌اید؟ در اینجا لیست ۲۱-۱۵ با یک روش برای انجام اصلاحات قبلی آورده شده است.

Filename: src/lib.rs
use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listing 21-15: تغییر ThreadPool برای نگهداری نمونه‌های Worker به جای نگهداری مستقیم Threadها

ما نام فیلد موجود در ThreadPool را از threads به workers تغییر داده‌ایم زیرا اکنون نمونه‌های Worker را نگه می‌دارد، نه نمونه‌های JoinHandle<()>. از شمارنده حلقه for به عنوان آرگومان برای Worker::new استفاده می‌کنیم و هر Worker جدید را در بردار به نام workers ذخیره می‌کنیم.

کد خارجی (مانند سرور ما در src/main.rs) نیازی ندارد جزئیات پیاده‌سازی مربوط به استفاده از ساختار Worker در داخل ThreadPool را بداند، بنابراین ساختار Worker و تابع new آن را خصوصی می‌کنیم. تابع Worker::new از id داده‌شده استفاده کرده و یک نمونه JoinHandle<()> ایجاد می‌کند که با ایجاد یک Thread جدید با یک Closure خالی تولید می‌شود.

نکته: اگر سیستم‌عامل نتواند به دلیل کمبود منابع سیستم، یک Thread ایجاد کند، thread::spawn به وحشت خواهد افتاد (panic). این باعث می‌شود کل سرور ما به وحشت بیفتد، حتی اگر ایجاد برخی Threadها موفق باشد. برای سادگی، این رفتار مشکلی ندارد، اما در یک پیاده‌سازی تولیدی برای Thread Pool، احتمالاً از std::thread::Builder و متد spawn که یک Result بازمی‌گرداند، استفاده می‌کنید.

این کد کامپایل خواهد شد و تعداد نمونه‌های Worker را که به عنوان آرگومان به ThreadPool::new مشخص کرده‌ایم ذخیره می‌کند. اما ما هنوز Closureی که در execute دریافت می‌کنیم را پردازش نمی‌کنیم. بیایید بررسی کنیم چگونه این کار را انجام دهیم.

ارسال درخواست‌ها به Threadها از طریق Channelها

مشکل بعدی که به آن می‌پردازیم این است که Closureهایی که به thread::spawn داده شده‌اند، هیچ کاری انجام نمی‌دهند. در حال حاضر، Closureی که می‌خواهیم اجرا کنیم را در متد execute دریافت می‌کنیم. اما نیاز داریم که یک Closure به thread::spawn بدهیم تا در هنگام ایجاد هر Worker در حین ایجاد ThreadPool اجرا شود.

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

Channelهایی که در فصل ۱۶ یاد گرفتیم—راهی ساده برای ارتباط بین دو Thread—برای این مورد استفاده مناسب هستند. ما از یک Channel به عنوان صف کارها استفاده خواهیم کرد و execute یک کار را از ThreadPool به نمونه‌های Worker ارسال می‌کند، که این کار را به Thread خود ارسال می‌کنند. برنامه به شرح زیر خواهد بود:

  1. ThreadPool یک Channel ایجاد کرده و نگهدارنده sender آن خواهد بود.
  2. هر Worker نگهدارنده receiver خواهد بود.
  3. یک ساختار Job جدید ایجاد خواهیم کرد که Closureهایی که می‌خواهیم از طریق Channel ارسال کنیم را نگه می‌دارد.
  4. متد execute کاری که می‌خواهد اجرا کند را از طریق sender ارسال خواهد کرد.
  5. در Thread خود، Worker بر receiver خود حلقه زده و Closureهای هر کاری که دریافت می‌کند را اجرا خواهد کرد.

بیایید با ایجاد یک Channel در ThreadPool::new و نگهداری sender در نمونه ThreadPool شروع کنیم، همان‌طور که در لیست ۲۱-۱۶ نشان داده شده است. ساختار Job در حال حاضر چیزی نگه نمی‌دارد، اما نوع آیتمی خواهد بود که از طریق Channel ارسال می‌کنیم.

Filename: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listing 21-16: تغییر ThreadPool برای ذخیره sender یک Channel که نمونه‌های Job را منتقل می‌کند

در ThreadPool::new، یک Channel جدید ایجاد می‌کنیم و Pool نگهدارنده sender خواهد بود. این کد با موفقیت کامپایل می‌شود.

بیایید هنگام ایجاد کانال توسط thread pool، receiver کانال را به هر Worker ارسال کنیم. می‌دانیم که می‌خواهیم از receiver در threadی که نمونه‌های Worker ایجاد می‌کنند استفاده کنیم، پس در closure به پارامتر receiver رفرنس می‌دهیم. کد موجود در لیستینگ 21-17 هنوز به‌طور کامل کامپایل نمی‌شود.

Filename: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--


struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-17: ارسال receiver به هر Worker

ما تغییرات کوچک و واضحی ایجاد کرده‌ایم: receiver را به Worker::new ارسال کرده‌ایم و سپس از آن در داخل Closure استفاده کرده‌ایم.

هنگامی که تلاش می‌کنیم این کد را بررسی کنیم، با این خطا مواجه می‌شویم:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:26:42
   |
21 |         let (sender, receiver) = mpsc::channel();
   |                      -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 |         for id in 0..size {
   |         ----------------- inside of this loop
26 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here, in previous iteration of loop
   |
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
  --> src/lib.rs:47:33
   |
47 |     fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
   |        --- in this method       ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
help: consider moving the expression out of the loop so it is only moved once
   |
25 ~         let mut value = Worker::new(id, receiver);
26 ~         for id in 0..size {
27 ~             workers.push(value);
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hello` (lib) due to 1 previous error

کد در تلاش است تا receiver را به چند نمونه‌ی مختلف از Worker ارسال کند.
این کار عملی نیست، همان‌طور که در فصل ۱۶ یاد گرفتیم: پیاده‌سازی کانال در Rust به صورت multiple producer و single consumer است.
یعنی نمی‌توانیم انتهای مصرف‌کننده‌ی کانال را clone کنیم تا این کد را اصلاح کنیم.
همچنین نمی‌خواهیم یک پیام را چند بار به چند مصرف‌کننده ارسال کنیم؛
هدف این است که یک لیست از پیام‌ها داشته باشیم که چند نمونه Worker آن را دریافت کنند،
طوری که هر پیام فقط یک بار پردازش شود.

علاوه بر این، برداشتن یک کار از صف کانال شامل تغییر receiver می‌شود، بنابراین Threadها به یک روش امن برای اشتراک و تغییر receiver نیاز دارند؛ در غیر این صورت، ممکن است با شرایط رقابتی (race conditions) مواجه شویم (همان‌طور که در فصل ۱۶ توضیح داده شد).

به یاد بیاورید اشاره‌گرهای هوشمند ایمن در برابر thread که در فصل ۱۶ بحث شدند: برای اشتراک مالکیت بین چند thread و اجازه دادن به تغییر مقدار توسط threadها، باید از Arc<Mutex<T>> استفاده کنیم. نوع Arc اجازه می‌دهد چند نمونه‌ی Worker مالک receiver باشند، و Mutex تضمین می‌کند که در هر لحظه فقط یک Worker بتواند از receiver یک کار دریافت کند. لیستینگ 21-18 تغییراتی را که باید انجام دهیم نشان می‌دهد.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --snip--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-18: اشتراک‌گذاری receiver بین نمونه‌های Worker با استفاده از Arc و Mutex

در تابع ThreadPool::new، receiver را داخل یک Arc و یک Mutex قرار می‌دهیم. برای هر نمونه‌ی جدید از Worker، Arc را clone می‌کنیم تا شمارنده‌ی رفرنس افزایش یابد، به‌طوری که نمونه‌های Worker بتوانند مالکیت مشترک receiver را داشته باشند.

با این تغییرات، کد کامپایل می‌شود! به نتیجه نزدیک‌تر می‌شویم!

پیاده‌سازی متد execute

بیایید در نهایت متد execute را روی ThreadPool پیاده‌سازی کنیم.
همچنین Job را از یک struct به یک type alias برای یک trait object تبدیل می‌کنیم
که نوع closure ای را که execute دریافت می‌کند نگه می‌دارد.
همان‌طور که در بخش “ایجاد مترادف‌های نوع با type alias” در فصل ۲۰ بحث شد،
type alias به ما اجازه می‌دهد تا انواع طولانی را کوتاه‌تر کنیم و استفاده از آن‌ها را آسان‌تر سازیم.
به لیستینگ 21-19 نگاه کنید.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-19: ایجاد یک نام مستعار Job برای یک Box که هر Closure را نگه می‌دارد و سپس ارسال کار از طریق کانال

پس از ایجاد یک نمونه جدید Job با استفاده از Closureی که در execute دریافت می‌کنیم، آن کار را از طریق بخش ارسال‌کننده کانال ارسال می‌کنیم. ما برای حالتی که ارسال شکست بخورد، روی send از unwrap استفاده می‌کنیم. این حالت ممکن است رخ دهد، اگر مثلاً همه Threadهای ما از اجرا متوقف شوند، به این معنی که بخش دریافت‌کننده دیگر پیام‌های جدید را دریافت نمی‌کند. در حال حاضر، نمی‌توانیم Threadهای خود را از اجرا متوقف کنیم: Threadهای ما تا زمانی که Pool وجود دارد اجرا می‌شوند. دلیل استفاده از unwrap این است که می‌دانیم حالت شکست رخ نخواهد داد، اما کامپایلر این موضوع را نمی‌داند.

اما هنوز کار تمام نشده است! در Worker، closure که به thread::spawn داده می‌شود
هنوز فقط به انتهای دریافت‌کننده‌ی کانال رفرنس می‌دهد.
در عوض، نیاز داریم که closure به‌طور پیوسته در حلقه‌ای بی‌نهایت اجرا شود،
از انتهای دریافت‌کننده‌ی کانال درخواست کار کند و هرگاه کار دریافت کرد آن را اجرا نماید.
بیایید تغییرات نشان‌داده شده در لیستینگ 21-20 را در Worker::new اعمال کنیم.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-20: دریافت و اجرای کارها در thread نمونه‌ی Worker

در اینجا، ابتدا lock را روی receiver فراخوانی می‌کنیم تا mutex را به دست آوریم، و سپس unwrap را فراخوانی می‌کنیم تا در صورت بروز هرگونه خطا، برنامه متوقف شود. به دست آوردن یک قفل ممکن است شکست بخورد اگر mutex در یک وضعیت poisoned باشد، که ممکن است اتفاق بیفتد اگر یک Thread دیگر در حالی که قفل را نگه داشته است به جای آزاد کردن آن متوقف شده باشد. در این شرایط، فراخوانی unwrap برای متوقف کردن این Thread اقدام درستی است. می‌توانید این unwrap را به یک expect با یک پیام خطای معنادار برای خود تغییر دهید.

اگر قفل روی mutex را به دست آوریم، recv را فراخوانی می‌کنیم تا یک Job را از کانال دریافت کنیم. یک unwrap نهایی نیز در اینجا هر گونه خطا را برطرف می‌کند، که ممکن است رخ دهد اگر Threadی که sender را نگه داشته است خاموش شود، مشابه نحوه‌ای که متد send در صورت خاموش شدن receiver یک Err بازمی‌گرداند.

فراخوانی recv مسدود می‌شود، بنابراین اگر هنوز هیچ کاری وجود نداشته باشد، Thread فعلی منتظر می‌ماند تا یک کار در دسترس قرار گیرد. Mutex<T> تضمین می‌کند که در هر لحظه فقط یک Thread Worker در تلاش برای درخواست یک کار است.

Thread Pool ما اکنون در وضعیت کاری قرار دارد! دستور cargo run را اجرا کنید و چندین درخواست ارسال کنید:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field `workers` is never read
 --> src/lib.rs:7:5
  |
6 | pub struct ThreadPool {
  |            ---------- field in this struct
7 |     workers: Vec<Worker>,
  |     ^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: fields `id` and `thread` are never read
  --> src/lib.rs:48:5
   |
47 | struct Worker {
   |        ------ fields in this struct
48 |     id: usize,
   |     ^^
49 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^

warning: `hello` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

موفقیت! اکنون یک Thread Pool داریم که اتصالات را به صورت همزمان اجرا می‌کند. هرگز بیش از چهار Thread ایجاد نمی‌شود، بنابراین اگر سرور درخواست‌های زیادی دریافت کند، سیستم ما بارگذاری بیش از حد نخواهد شد. اگر یک درخواست به /sleep ارسال کنیم، سرور می‌تواند با استفاده از یک Thread دیگر به سایر درخواست‌ها پاسخ دهد.

توجه: اگر مسیر /sleep را هم‌زمان در چندین پنجره‌ی مرورگر باز کنید، ممکن است درخواست‌ها به‌صورت پشت سر هم و با فاصله‌های پنج ثانیه‌ای بارگذاری شوند. برخی مرورگرها به دلایل کش، چندین نمونه از یک درخواست مشابه را به‌صورت ترتیبی اجرا می‌کنند. این محدودیت ناشی از سرور وب ما نیست.

این زمان خوبی است که مکث کنیم و بررسی کنیم چگونه کدهای لیست‌های ۲۱-۱۸، ۲۱-۱۹ و ۲۱-۲۰ اگر به جای Closure از futures برای انجام کار استفاده می‌کردیم، متفاوت می‌بود. چه نوع‌هایی تغییر می‌کردند؟ آیا امضاهای متدها تغییر می‌کردند؟ کدام بخش‌های کد همان‌گونه باقی می‌ماندند؟

بعد از آشنایی با حلقه‌ی while let در فصل‌های ۱۷ و ۱۹، ممکن است این سؤال برایتان پیش آمده باشد که چرا کد thread مربوط به Worker را مانند آنچه در لیستینگ 21-21 نشان داده شده ننوشته‌ایم.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}
// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-21: یک پیاده‌سازی جایگزین برای Worker::new با استفاده از while let

این کد کامپایل می‌شود و اجرا می‌شود، اما منجر به رفتار مورد نظر برای threading نمی‌شود: یک درخواست کند همچنان باعث می‌شود سایر درخواست‌ها برای پردازش منتظر بمانند. دلیل آن کمی ظریف است: ساختار Mutex متد عمومی unlock ندارد، زیرا مالکیت قفل بر اساس طول عمر MutexGuard<T> درون LockResult<MutexGuard<T>> که متد lock بازمی‌گرداند است. در زمان کامپایل، بررسی‌کننده وام می‌تواند این قانون را اعمال کند که منبعی که توسط یک Mutex محافظت می‌شود نمی‌تواند دسترسی پیدا کند مگر اینکه قفل را نگه داشته باشیم. با این حال، این پیاده‌سازی همچنین می‌تواند منجر به نگه‌داشتن قفل بیش از حد انتظار شود اگر به طول عمر MutexGuard<T> توجه نکنیم.

کدی که در لیستینگ 21-20 با عبارت let job = receiver.lock().unwrap().recv().unwrap(); نوشته شده است، کار می‌کند زیرا در استفاده از let، هر مقدار موقتی که در سمت راست علامت مساوی به کار رفته باشد، بلافاصله پس از پایان دستور let رها (drop) می‌شود. اما در while let (و همچنین if let و match) مقدارهای موقتی تا پایان بلاک مربوطه رها نمی‌شوند. در لیستینگ 21-21، قفل (lock) تا پایان فراخوانی job() نگه داشته می‌شود، که این یعنی سایر نمونه‌های Worker نمی‌توانند در آن مدت کار دریافت کنند.

خاموشی و پاک‌سازی منظم

کدی که در فهرست 21-20 آمده است، همان‌طور که انتظار داشتیم، با استفاده از یک thread pool به‌صورت asynchronous به درخواست‌ها پاسخ می‌دهد. در این میان، هشدارهایی در مورد فیلدهای workers، id و thread دریافت می‌کنیم که به‌طور مستقیم از آن‌ها استفاده نمی‌شود و این موضوع به ما یادآوری می‌کند که عملیات پاک‌سازی یا مدیریت پایانی انجام نشده است. زمانی که از روش نه‌چندان ظریف Ctrl+C برای متوقف کردن thread اصلی استفاده می‌کنیم، تمام threadهای دیگر نیز بلافاصله متوقف می‌شوند، حتی اگر در حال پردازش یک درخواست باشند.

سپس، ما Drop trait را پیاده‌سازی خواهیم کرد تا join را روی هر یک از نخ‌های موجود در مجموعه نخ فراخوانی کنیم تا بتوانند درخواست‌هایی که در حال کار روی آن‌ها هستند را قبل از بسته‌شدن تکمیل کنند. سپس روشی برای اطلاع به نخ‌ها که نباید درخواست‌های جدید بپذیرند و باید خاموش شوند، پیاده‌سازی خواهیم کرد. برای مشاهده عملکرد این کد، سرور خود را تغییر می‌دهیم تا فقط دو درخواست را قبل از خاموشی منظم مجموعه نخ‌ها بپذیرد.

چیزی که باید توجه داشته باشید این است که هیچ‌کدام از این موارد بخش‌هایی از کد را که مدیریت اجرای closureها را بر عهده دارند، تحت تأثیر قرار نمی‌دهند، بنابراین همه چیز در اینجا همان‌طور باقی می‌ماند اگر از یک مجموعه نخ برای یک runtime غیرهمزمان استفاده می‌کردیم.

پیاده‌سازی Drop Trait روی ThreadPool

بیایید با پیاده‌سازی Drop روی مجموعه نخ شروع کنیم. وقتی مجموعه نخ حذف می‌شود، تمام نخ‌های ما باید به یکدیگر ملحق شوند تا مطمئن شویم کار خود را تکمیل می‌کنند. لیستینگ 21-22 اولین تلاش برای پیاده‌سازی Drop را نشان می‌دهد؛ این کد هنوز به درستی کار نخواهد کرد.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-22: ملحق کردن هر نخ وقتی مجموعه نخ از محدوده خارج می‌شود

ابتدا، از میان تمام workerهای موجود در thread pool یک حلقه اجرا می‌کنیم. از &mut استفاده می‌کنیم، زیرا self یک ارجاع قابل‌تغییر است و همچنین باید بتوانیم worker را نیز تغییر دهیم. برای هر worker، پیامی چاپ می‌کنیم که نشان دهد این نمونه‌ی خاص از Worker در حال خاموش شدن است، و سپس روی thread مربوط به همان Worker تابع join را فراخوانی می‌کنیم. اگر فراخوانی join با شکست مواجه شود، از unwrap استفاده می‌کنیم تا باعث panic در برنامه شود و خاموش شدن برنامه به‌صورت نامناسب انجام گیرد.

اینجا خطایی که هنگام کامپایل این کد دریافت می‌کنیم آمده است:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
  --> src/lib.rs:52:13
   |
52 |             worker.thread.join().unwrap();
   |             ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
   |             |
   |             move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
   |
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:1876:17

For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error

این خطا به ما می‌گوید که نمی‌توانیم join را فراخوانی کنیم زیرا فقط یک قرض قابل تغییر از هر worker داریم و join مالکیت آرگومان خود را می‌گیرد. برای حل این مشکل، باید نخ را از نمونه Worker که مالک thread است خارج کنیم تا join بتواند نخ را مصرف کند. یک راه برای انجام این کار استفاده از همان رویکردی است که در لیستینگ 18-15 استفاده کردیم. اگر Worker یک Option<thread::JoinHandle<()>> نگه می‌داشت، می‌توانستیم با استفاده از متد take مقدار را از نوع Some به نوع None منتقل کنیم.

با این حال، تنها زمانی که این مسئله مطرح می‌شود زمانی است که Worker حذف می‌شود. در عوض، باید با یک Option<thread::JoinHandle<()>> در همه جا سر و کار داشته باشیم. Rust ایدئوماتیک اغلب از Option استفاده می‌کند، اما زمانی که متوجه شوید چیزی را در Option قرار می‌دهید به عنوان یک راه‌حل موقت، حتی اگر بدانید آن مورد همیشه حضور دارد، ایده خوبی است که به دنبال روش‌های جایگزین باشید.

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

بنابراین باید پیاده‌سازی drop در ThreadPool را به این صورت به‌روزرسانی کنیم:

Filename: src/lib.rs
#![allow(unused)]
fn main() {
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
}

این کار خطای کامپایلر را برطرف می‌کند و نیازی به هیچ تغییر دیگری در کد ما ندارد. توجه داشته باشید که از آن‌جا که drop می‌تواند هنگام panic فراخوانی شود، تابع unwrap نیز ممکن است panic ایجاد کند و باعث double panic شود که در نتیجه، برنامه بلافاصله crash می‌کند و هرگونه عملیات پاک‌سازی در حال انجام را متوقف می‌سازد. این موضوع برای یک برنامه‌ی نمونه قابل قبول است، اما برای کدهای محصولی توصیه نمی‌شود.

علامت‌دهی به نخ‌ها برای توقف گوش دادن به وظایف

با تمام تغییراتی که اعمال کرده‌ایم، کد ما بدون هیچ هشداری کامپایل می‌شود. اما خبر بد این است که این کد هنوز آن‌طور که می‌خواهیم عمل نمی‌کند. نکته‌ی کلیدی در منطق closureهایی است که توسط threadهای نمونه‌های Worker اجرا می‌شوند: در حال حاضر ما تابع join را فراخوانی می‌کنیم، اما این باعث خاموش شدن threadها نمی‌شود، چون آن‌ها به‌صورت بی‌پایان در حال loop برای یافتن job هستند. اگر سعی کنیم ThreadPool را با پیاده‌سازی فعلی تابع drop حذف کنیم، thread اصلی برای همیشه در حالت انتظار باقی می‌ماند تا اولین thread به پایان برسد.

برای حل این مشکل، باید تغییری در پیاده‌سازی drop در ThreadPool و سپس تغییری در حلقه Worker ایجاد کنیم.

ابتدا پیاده‌سازی تابع drop برای ThreadPool را تغییر می‌دهیم تا پیش از منتظر ماندن برای پایان یافتن threadها، به‌صورت صریح sender را حذف کند. فهرست 21-23 تغییرات اعمال‌شده روی ThreadPool را نشان می‌دهد که در آن sender به‌طور صریح حذف می‌شود. برخلاف thread، در این‌جا نیاز داریم که از یک Option استفاده کنیم تا بتوانیم sender را با استفاده از Option::take از ساختار ThreadPool بیرون بکشیم.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        // --snip--

        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-23: Explicitly dropping sender before joining the Worker threads

حذف کردن sender باعث بسته شدن channel می‌شود،
که این موضوع نشان می‌دهد دیگر هیچ پیامی ارسال
نخواهد شد. در این حالت، تمام فراخوانی‌های recv
که نمونه‌های Worker درون حلقه‌ی بی‌نهایت انجام
می‌دهند با خطا بازمی‌گردند. در فهرست 21-24،
حلقه‌ی Worker را طوری تغییر می‌دهیم که
در چنین حالتی به‌صورت مناسب از حلقه خارج شود،
که به این معناست threadها زمانی پایان می‌یابند که
تابع drop مربوط به ThreadPool تابع join را
روی آن‌ها فراخوانی کند.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}
Listing 21-24: خروج صریح از حلقه زمانی که recv با خطا بازمی‌گردد

برای دیدن این کد در عمل، بیایید main را تغییر دهیم تا فقط دو درخواست را قبل از خاموش‌شدن منظم سرور بپذیرد، همان‌طور که در لیستینگ 21-25 نشان داده شده است.

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-25: خاموش کردن سرور پس از پاسخ‌گویی به دو درخواست از طریق خروج از حلقه

شما نمی‌خواهید یک سرور وب واقعی پس از فقط دو درخواست خاموش شود. این کد فقط نشان می‌دهد که خاموشی منظم و پاک‌سازی به درستی کار می‌کند.

متد take که در trait Iterator تعریف شده است، تکرار را به حداکثر دو آیتم محدود می‌کند. ThreadPool در انتهای main از محدوده خارج می‌شود و پیاده‌سازی drop اجرا خواهد شد.

سرور را با دستور cargo run راه‌اندازی کنید و سه درخواست ارسال کنید. درخواست سوم باید با خطا مواجه شود و در ترمینال خود باید خروجی مشابه زیر را ببینید:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

ممکن است ترتیب نمایش شناسه‌های Worker و پیام‌های چاپ‌شده متفاوت باشد. از طریق این پیام‌ها می‌توانیم بفهمیم کد چگونه کار می‌کند: نمونه‌های Worker با شناسه‌های 0 و 3 اولین دو درخواست را دریافت کرده‌اند. سرور پس از اتصال دوم، پذیرفتن ارتباط‌های جدید را متوقف کرده و پیاده‌سازی Drop برای ThreadPool پیش از آن‌که Worker 3 کار خود را آغاز کند اجرا شده است. حذف کردن sender باعث قطع ارتباط تمامی نمونه‌های Worker می‌شود و به آن‌ها اطلاع می‌دهد که باید خاموش شوند. هر Worker هنگام قطع اتصال، پیامی چاپ می‌کند و سپس thread pool تابع join را فراخوانی می‌کند تا منتظر پایان thread مربوط به هر Worker بماند.

به نکته‌ای جالب در این اجرای خاص توجه کنید: ThreadPool ابتدا sender را حذف کرده و پیش از آن‌که هیچ‌کدام از Workerها خطایی دریافت کنند، تلاش کرده‌ایم تا Worker 0 را join کنیم. در آن لحظه، Worker 0 هنوز خطایی از recv دریافت نکرده بود، بنابراین thread اصلی منتظر ماند تا Worker 0 به کار خود پایان دهد. در این فاصله، Worker 3 یک job دریافت کرد و سپس همه‌ی threadها خطا دریافت کردند. وقتی Worker 0 به پایان رسید، thread اصلی منتظر پایان سایر Workerها ماند. در آن لحظه، همه‌ی آن‌ها از حلقه‌ی خود خارج شده و متوقف شده بودند.

تبریک می‌گویم! پروژه خود را کامل کردید؛ ما یک سرور وب ساده داریم که از یک مجموعه نخ برای پاسخ‌دهی غیرهمزمان استفاده می‌کند. ما توانستیم سرور را به صورت منظم خاموش کنیم و تمام نخ‌ها در مجموعه را پاک‌سازی کنیم.

در اینجا کد کامل برای مرجع آورده شده است:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

می‌توانستیم بیشتر اینجا انجام دهیم! اگر می‌خواهید این پروژه را بیشتر گسترش دهید، اینجا چند ایده آمده است:

  • مستندات بیشتری به ThreadPool و متدهای عمومی آن اضافه کنید.
  • تست‌هایی برای عملکرد کتابخانه اضافه کنید.
  • فراخوانی‌های unwrap را به مدیریت خطای قوی‌تر تغییر دهید.
  • از ThreadPool برای انجام برخی کارها به غیر از ارائه درخواست‌های وب استفاده کنید.
  • یک crate مجموعه نخ از crates.io پیدا کنید و یک سرور وب مشابه با استفاده از آن crate پیاده‌سازی کنید. سپس API و مقاومت آن را با مجموعه نخی که ما پیاده‌سازی کردیم مقایسه کنید.

خلاصه

آفرین! شما تا پایان این کتاب پیش آمده‌اید! از اینکه در این سفر با ما همراه بودید صمیمانه سپاسگزاریم. اکنون آماده‌اید تا پروژه‌های Rust خودتان را پیاده‌سازی کنید و در پروژه‌های دیگران نیز مشارکت داشته باشید. فراموش نکنید که جامعه‌ای گرم و صمیمی از دیگر Rustaceanها وجود دارد که با آغوش باز آماده‌اند در مسیر یادگیری Rust به شما کمک کنند.

ضمیمه

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

ضمیمه الف: کلمات کلیدی

لیست زیر شامل کلمات کلیدی است که برای استفاده فعلی یا آینده توسط زبان Rust رزرو شده‌اند. به همین دلیل، نمی‌توان از آن‌ها به عنوان شناسه استفاده کرد (مگر به عنوان شناسه خام، همان‌طور که در بخش “شناسه‌های خام” توضیح داده خواهد شد). شناسه‌ها نام‌هایی هستند که برای توابع، متغیرها، پارامترها، فیلدهای ساختار، ماژول‌ها، جعبه‌ها (crates)، ثابت‌ها، ماکروها، مقادیر استاتیک، ویژگی‌ها، انواع، ویژگی‌ها، یا طول عمرها استفاده می‌شوند.

کلمات کلیدی در حال استفاده

فهرست زیر شامل کلمات کلیدی است که در حال حاضر استفاده می‌شوند، همراه با توضیح عملکرد آن‌ها:

کلمات کلیدی رزرو شده برای استفاده در آینده

کلمات کلیدی زیر هنوز هیچ کاربردی ندارند اما توسط Rust برای استفاده احتمالی در آینده رزرو شده‌اند:

شناسه‌های خام

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

برای مثال، match یک کلمه کلیدی است. اگر بخواهید تابع زیر را که از match به عنوان نام خود استفاده می‌کند کامپایل کنید:

Filename: src/main.rs

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

شما این خطا را دریافت خواهید کرد:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

این خطا نشان می‌دهد که نمی‌توانید از کلمه کلیدی match به عنوان شناسه تابع استفاده کنید. برای استفاده از match به عنوان نام تابع، باید از سینتکس شناسه خام به این شکل استفاده کنید:

#![allow(unused)]
fn main() {
fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}
}

Filename: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

این کد بدون هیچ خطایی کامپایل خواهد شد. به پیشوند r# روی نام تابع در تعریف آن و همچنین جایی که تابع در main فراخوانی می‌شود، توجه کنید.

شناسه‌های خام (Raw identifiers) به شما این امکان را می‌دهند که از هر کلمه‌ای که انتخاب می‌کنید به عنوان شناسه استفاده کنید، حتی اگر آن کلمه به‌طور معمول یک کلمه کلیدی رزرو‌شده باشد. این ویژگی آزادی بیشتری برای انتخاب نام شناسه‌ها فراهم می‌کند و همچنین امکان ادغام با برنامه‌هایی که به زبانی نوشته شده‌اند که این کلمات در آن زبان کلمات کلیدی نیستند، را می‌دهد. علاوه بر این، شناسه‌های خام به شما اجازه می‌دهند از کتابخانه‌هایی استفاده کنید که با نسخه‌ای از Rust نوشته شده‌اند که با نسخه مورد استفاده شما متفاوت است.

برای مثال، try در نسخه ۲۰۱۵ کلمه کلیدی نیست، اما در نسخه‌های ۲۰۱۸، ۲۰۲۱ و ۲۰۲۴ کلمه کلیدی است. اگر به کتابخانه‌ای وابسته باشید که با نسخه ۲۰۱۵ نوشته شده و یک تابع به نام try دارد، باید از سینتکس شناسه خام، یعنی r#try، برای فراخوانی آن تابع از کد نسخه ۲۰۱۸ خود استفاده کنید. برای اطلاعات بیشتر در مورد نسخه‌ها به ضمیمه ه مراجعه کنید.

شناسه‌های خام (Raw identifiers) به شما این امکان را می‌دهند که از هر واژه‌ای به‌عنوان یک شناسه استفاده کنید، حتی اگر آن واژه یک کلمه‌ی رزرو‌شده باشد. این قابلیت، آزادی عمل بیشتری برای انتخاب نام شناسه‌ها به ما می‌دهد و همچنین امکان یکپارچه‌سازی با برنامه‌هایی که به زبانی نوشته شده‌اند که این کلمات در آن‌ها رزرو‌شده نیستند را فراهم می‌کند. علاوه بر این، شناسه‌های خام به شما اجازه می‌دهند تا از کتابخانه‌هایی استفاده کنید که با نگارشی متفاوت از crate شما نوشته شده‌اند. برای مثال، try در نگارش ۲۰۱۵ کلمه‌ی کلیدی محسوب نمی‌شود، اما در نگارش‌های ۲۰۱۸، ۲۰۲۱ و ۲۰۲۴ یک کلمه‌ی کلیدی است. اگر به کتابخانه‌ای وابسته باشید که با نگارش ۲۰۱۵ نوشته شده و تابعی با نام try دارد، برای فراخوانی این تابع در کد خود (با نگارش‌های جدید)، باید از نحو شناسه‌ی خام استفاده کنید، یعنی r#try. برای اطلاعات بیشتر درباره‌ی نگارش‌ها به ضمیمه‌ی ه مراجعه کنید.

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

این ضمیمه شامل واژه‌نامه‌ای از سینتکس زبان Rust است، از جمله عملگرها و سایر نمادهایی که به تنهایی یا در زمینه مسیرها، جنریک‌ها، محدودیت‌های ویژگی، ماکروها، ویژگی‌ها، نظرات، تاپل‌ها و براکت‌ها ظاهر می‌شوند.

عملگرها

جدول B-1 عملگرهای موجود در Rust، یک مثال از چگونگی ظاهر شدن عملگر در زمینه، توضیح کوتاه و اینکه آیا آن عملگر قابل اضافه‌بارگذاری است یا نه را نشان می‌دهد. اگر یک عملگر قابل اضافه‌بارگذاری باشد، ویژگی مرتبط برای اضافه‌بارگذاری آن عملگر ذکر شده است.

جدول B-1: عملگرها

عملگرمثالتوضیحقابلیت بارگذاری مجدد (Overload)?
!ident!(...), ident!{...}, ident![...]گسترش ماکرو
!!exprمکمل منطقی یا بیتیNot
!=expr != exprمقایسه نابرابریPartialEq
%expr % exprباقی‌مانده تقسیمRem
%=var %= exprباقی‌مانده تقسیم و اختصاصRemAssign
&&expr, &mut exprقرض‌گیری
&&type, &mut type, &'a type, &'a mut typeنوع اشاره‌گر قرض‌گرفته‌شده
&expr & exprAND بیتیBitAnd
&=var &= exprAND بیتی و اختصاصBitAndAssign
&&expr && exprAND منطقی با قطع کوتاه
*expr * exprضرب عددیMul
*=var *= exprضرب عددی و اختصاصMulAssign
**exprdereferenceDeref
**const type, *mut typeاشاره‌گر خام
+trait + trait, 'a + traitمحدودیت ترکیبی برای نوع
+expr + exprجمع عددیAdd
+=var += exprجمع عددی و اختصاصAddAssign
,expr, exprجداکننده آرگومان یا عنصر
-- exprمنفی کردن عددNeg
-expr - exprتفریق عددیSub
-=var -= exprتفریق عددی و اختصاصSubAssign
->fn(...) -> type, `-> type`نوع بازگشتی تابع یا closure
.expr.identدسترسی به فیلد
.expr.ident(expr, ...)فراخوانی متد
.expr.0, expr.1, …ایندکس‌گذاری tuple
...., expr.., ..expr, expr..exprبازه‌ی راست-بازPartialOrd
..=..=expr, expr..=exprبازه‌ی راست-بستهPartialOrd
....exprبه‌روزرسانی literal ساختار
..variant(x, ..), struct_type { x, .. }الگوی «و بقیه» در pattern binding
...expr...expr(منسوخ‌شده، به‌جای آن از ..= استفاده کنید) الگوی بازه‌ی بسته
/expr / exprتقسیم عددیDiv
/=var /= exprتقسیم عددی و اختصاصDivAssign
:pat: type, ident: typeمحدودیت نوع
:ident: exprمقداردهی به فیلد ساختار
:'a: loop {...}برچسب حلقه
;expr;پایان‌دهنده عبارت یا آیتم
;[...; len]بخشی از نحوه تعریف آرایه با اندازه ثابت
<<expr << exprشیفت به چپ بیتیShl
<<=var <<= exprشیفت به چپ بیتی و اختصاصShlAssign
<expr < exprمقایسه کوچکتر ازPartialOrd
<=expr <= exprمقایسه کوچکتر مساویPartialOrd
=var = expr, ident = typeتخصیص یا تساوی
==expr == exprمقایسه برابریPartialEq
=>pat => exprبخشی از نگارش match
>expr > exprمقایسه بزرگتر ازPartialOrd
>=expr >= exprمقایسه بزرگتر مساویPartialOrd
>>expr >> exprشیفت به راست بیتیShr
>>=var >>= exprشیفت به راست بیتی و اختصاصShrAssign
@ident @ patpattern binding
^expr ^ exprXOR بیتیBitXor
^=var ^= exprXOR بیتی و اختصاصBitXorAssign
```patpat`جایگزین‌های الگو (pattern alternatives)
```exprexpr`OR بیتیBitOr
`=``var= expr`OR بیتی و اختصاصBitOrAssign
```exprexpr`OR منطقی با قطع کوتاه
?expr?انتشار خطا (error propagation)

نمادهای غیرعملگری

لیست زیر شامل تمام نمادهایی است که به عنوان عملگر عمل نمی‌کنند؛ یعنی مانند یک تابع یا فراخوانی متد رفتار نمی‌کنند.

جدول B-2 نمادهایی را نشان می‌دهد که به تنهایی ظاهر می‌شوند و در مکان‌های مختلف معتبر هستند.

جدول B-2: سینتکس مستقل

نمادتوضیح
'identlifetime نام‌گذاری‌شده یا برچسب حلقه
اعداد به‌همراه پسوندهایی مثل u8، i32، f64، usize و …عدد litteral با نوع مشخص
"..."رشته litteral
r"..."، r#"..."#، r##"..."## و غیرهرشته خام؛ کاراکترهای escape تفسیر نمی‌شوند
b"..."رشته byte؛ آرایه‌ای از بایت می‌سازد به‌جای رشته
br"..."، br#"..."#، br##"..."## و غیرهرشته byte خام؛ ترکیبی از رشته byte و رشته خام
'...'litteral کاراکتری
b'...'litteral بایت ASCII
exprclosure
!نوع تهی همواره خالی برای توابع واگرا (diverging)
_الگوی “نادیده‌گرفته‌شده”؛ همچنین برای خوانایی بهتر litteralهای عددی

جدول B-3 نمادهایی را نشان می‌دهد که در زمینه مسیریابی از سلسله‌مراتب ماژول به یک آیتم ظاهر می‌شوند.

جدول B-3: سینتکس مرتبط با مسیر

نمادتوضیح
ident::identمسیر فضای نام
::pathمسیر نسبی به پیش‌لود خارجی، جایی که تمام جعبه‌ها (crates)ی دیگر ریشه دارند (یعنی یک مسیر مطلق که به وضوح شامل نام جعبه (crate) است)
self::pathمسیر نسبی به ماژول جاری (یعنی یک مسیر نسبی به وضوح مشخص‌شده).
super::pathمسیر نسبی به والد ماژول جاری
type::ident, <type as trait>::identثابت‌ها، توابع، و انواع مرتبط
<type>::...آیتم مرتبط برای نوعی که نمی‌توان به طور مستقیم آن را نام‌گذاری کرد (مثلاً <&T>::...، <[T]>::...، و غیره)
trait::method(...)مشخص کردن فراخوانی متد با نام‌گذاری ویژگی‌ای که آن را تعریف کرده است
type::method(...)مشخص کردن فراخوانی متد با نام‌گذاری نوعی که برای آن تعریف شده است
<type as trait>::method(...)مشخص کردن فراخوانی متد با نام‌گذاری ویژگی و نوع

جدول B-4 نمادهایی را نشان می‌دهد که در زمینه استفاده از پارامترهای نوع جنریک ظاهر می‌شوند.

جدول B-4: جنریک‌ها

نمادتوضیح
path<...>مشخص کردن پارامترها برای نوع جنریک در یک نوع (مثلاً Vec<u8>)
path::<...>, method::<...>مشخص کردن پارامترها برای نوع جنریک، تابع، یا متد در یک عبارت؛ که معمولاً به آن turbofish می‌گویند (مثلاً "42".parse::<i32>())
fn ident<...> ...تعریف تابع جنریک
struct ident<...> ...تعریف ساختار جنریک
enum ident<...> ...تعریف شمارش جنریک
impl<...> ...تعریف پیاده‌سازی جنریک
for<...> typeمحدودیت طول عمر با رتبه بالاتر
type<ident=type>نوع جنریک که یک یا چند نوع مرتبط با آن دارای مقادیر مشخصی هستند (مثلاً Iterator<Item=T>)

جدول B-5 نمادهایی را نشان می‌دهد که در زمینه محدود کردن پارامترهای نوع جنریک با محدودیت‌های ویژگی ظاهر می‌شوند.

جدول B-5: محدودیت‌های ویژگی

نمادتوضیح
T: Uپارامتر جنریک T محدود به انواع که U را پیاده‌سازی می‌کنند
T: 'aنوع جنریک T باید طول عمر بیشتری از طول عمر 'a داشته باشد (یعنی نوع نمی‌تواند به صورت گذرا شامل ارجاعاتی با طول عمر کوتاه‌تر از 'a باشد)
T: 'staticنوع جنریک T شامل ارجاعات قرض‌گرفته‌شده‌ای به جز ارجاعات 'static نیست
'b: 'aطول عمر جنریک 'b باید طول عمر بیشتری از طول عمر 'a داشته باشد
T: ?Sizedاجازه دادن به پارامتر نوع جنریک برای اینکه نوعی با اندازه پویا باشد
'a + trait, trait + traitمحدودیت نوع ترکیبی

جدول B-6: ماکروها و ویژگی‌ها

نمادتوضیح
#[meta]attribute بیرونی
#![meta]attribute درونی
$identجای‌گذاری در ماکرو
$ident:kindmetavariable ماکرو
$(...)...تکرار در ماکرو
ident!(...)، ident!{...}، ident![...]فراخوانی ماکرو

جدول B-7: نظرات

نمادتوضیح
//نظر تک‌خطی
//!نظر مستند داخلی تک‌خطی
///نظر مستند خارجی تک‌خطی
/*...*/نظر بلوکی
/*!...*/نظر مستند داخلی بلوکی
/**...*/نظر مستند خارجی بلوکی

جدول B-8: تاپل‌ها

جدول B-8 زمینه‌هایی را نشان می‌دهد که در آن‌ها از پرانتز استفاده می‌شود.

جدول B-8: پرانتزها

نمادتوضیح
()tuple تهی (یا همان unit)، هم به‌صورت litteral و هم به‌صورت نوع
(expr)عبارت داخل پرانتز
(expr,)عبارت tuple تک‌عضوی
(type,)نوع tuple تک‌عضوی
(expr, ...)عبارت tuple
(type, ...)نوع tuple
expr(expr, ...)فراخوانی تابع؛ همچنین برای مقداردهی به tuple structها و variantهای tuple enum به‌کار می‌رود

جدول B-10: براکت‌ها

زمینهتوضیح
[...]لیترال آرایه
[expr; len]لیترال آرایه که شامل len نسخه از expr است
[type; len]نوع آرایه که شامل len نمونه از type است
expr[expr]اندیس‌گذاری مجموعه. قابل اضافه‌بارگذاری (Index, IndexMut)
expr[..], expr[a..], expr[..b], expr[a..b]اندیس‌گذاری مجموعه که شبیه به برش مجموعه عمل می‌کند، با استفاده از Range، RangeFrom، RangeTo، یا RangeFull به عنوان “اندیس”
زمینهتوضیح
{...}عبارت block
Type {...}literal ساختار

جدول B-10 زمینه‌هایی را نشان می‌دهد که در آن‌ها از کروشه (براکت مربعی) استفاده می‌شود.

جدول B-10: کروشه‌ها (براکت‌های مربعی)

زمینهتوضیح
[...]literal آرایه
[expr; len]literal آرایه شامل len نسخه از expr
[type; len]نوع آرایه شامل len نمونه از type
expr[expr]ایندکس‌گذاری روی مجموعه‌ها. قابل بارگذاری مجدد (Index، IndexMut)
expr[..]، expr[a..]، expr[..b]، expr[a..b]ایندکس‌گذاری روی مجموعه‌ها به‌شکل شبیه‌سازی‌شده‌ی برش (slicing)، با استفاده از Range، RangeFrom، RangeTo، یا RangeFull

ضمیمه ج: ویژگی‌های قابل اشتقاق

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

در این ضمیمه، مرجعی از تمام ویژگی‌های موجود در کتابخانه استاندارد که می‌توانید با derive استفاده کنید ارائه می‌شود. هر بخش شامل موارد زیر است:

اگر رفتاری متفاوت از آنچه ویژگی derive ارائه می‌دهد می‌خواهید، به مستندات کتابخانه استاندارد برای هر trait مراجعه کنید تا جزئیات نحوه‌ی پیاده‌سازی دستی آن‌ها را ببینید.

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

مثالی از یک ویژگی که نمی‌تواند مشتق شود، Display است که فرمت‌دهی برای کاربران نهایی را مدیریت می‌کند. شما باید همیشه راه مناسب برای نمایش یک نوع به کاربر نهایی را در نظر بگیرید. چه بخش‌هایی از نوع باید به کاربر نهایی نشان داده شود؟ چه بخش‌هایی برای او مرتبط است؟ چه فرمتی از داده برای او بیشترین اهمیت را دارد؟ کامپایلر Rust این بینش را ندارد، بنابراین نمی‌تواند رفتار پیش‌فرض مناسب را برای شما فراهم کند.

لیست ویژگی‌های قابل اشتقاق ارائه‌شده در این ضمیمه جامع نیست: کتابخانه‌ها می‌توانند derive را برای ویژگی‌های خود پیاده‌سازی کنند و لیست ویژگی‌هایی که می‌توانید با derive استفاده کنید را به‌طور واقعی باز بگذارند. پیاده‌سازی derive شامل استفاده از یک ماکروی فرآیندی است که در بخش “ماکروها” از فصل 20 پوشش داده شده است.

Debug برای خروجی برنامه‌نویسی

ویژگی Debug فرمت‌دهی دیباگ را در رشته‌های فرمت فعال می‌کند که با افزودن :? درون نگه‌دارنده‌های {} مشخص می‌کنید.

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

برای مثال، trait Debug در استفاده از ماکروی assert_eq! الزامی است. این ماکرو در صورتی که بررسی تساوی با شکست مواجه شود، مقادیر نمونه‌هایی را که به‌عنوان آرگومان دریافت کرده است چاپ می‌کند تا برنامه‌نویس بتواند دلیل نابرابری دو نمونه را مشاهده کند.

PartialEq و Eq برای مقایسه برابری

ویژگی PartialEq به شما اجازه می‌دهد نمونه‌های یک نوع را برای بررسی برابری مقایسه کنید و استفاده از عملگرهای == و != را ممکن می‌سازد.

مشتق‌سازی PartialEq متد eq را پیاده‌سازی می‌کند. وقتی PartialEq روی struct‌ها مشتق می‌شود، دو نمونه فقط زمانی برابر هستند که تمام فیلدها برابر باشند و نمونه‌ها برابر نیستند اگر هر یک از فیلدها برابر نباشند. وقتی روی enum‌ها مشتق می‌شود، هر واریانت با خودش برابر است و با سایر واریانت‌ها برابر نیست.

ویژگی PartialEq، برای مثال، با استفاده از ماکروی assert_eq! مورد نیاز است که باید بتواند دو نمونه از یک نوع را برای برابری مقایسه کند.

ویژگی Eq هیچ متدی ندارد. هدف آن این است که نشان دهد برای هر مقدار از نوع حاشیه‌نویسی‌شده، مقدار با خودش برابر است. ویژگی Eq فقط می‌تواند به نوع‌هایی اعمال شود که همچنین PartialEq را پیاده‌سازی کرده باشند، اگرچه همه نوع‌هایی که PartialEq را پیاده‌سازی کرده‌اند نمی‌توانند Eq را پیاده‌سازی کنند. مثالی از این مورد نوع‌های عدد ممیز شناور هستند: پیاده‌سازی اعداد ممیز شناور بیان می‌کند که دو نمونه از مقدار غیرعدد (NaN) برابر نیستند.

مثالی از زمانی که Eq مورد نیاز است، برای کلیدها در HashMap<K, V> است تا HashMap<K, V> بتواند تعیین کند که آیا دو کلید یکسان هستند یا نه.

PartialOrd و Ord برای مقایسه مرتب‌سازی

مشتق‌گیری از PartialOrd باعث پیاده‌سازی متد partial_cmp می‌شود، که یک Option<Ordering> بازمی‌گرداند؛ این مقدار در صورتی None خواهد بود که مقادیر داده‌شده نتوانند ترتیب مشخصی تولید کنند. مثالی از مقداری که ترتیب‌پذیر نیست، هرچند بیشتر مقادیر آن نوع قابل مقایسه‌اند، مقدار NaN در اعداد اعشاری (floating point) است. فراخوانی partial_cmp با هر عدد اعشاری و مقدار NaN منجر به بازگشت None می‌شود.

مشتق‌سازی PartialOrd متد partial_cmp را پیاده‌سازی می‌کند، که یک Option<Ordering> را برمی‌گرداند که در صورتی که مقادیر داده‌شده ترتیب‌بندی تولید نکنند، None خواهد بود. مثالی از مقداری که ترتیب‌بندی تولید نمی‌کند، حتی اگر بیشتر مقادیر آن نوع قابل مقایسه باشند، مقدار نقطه شناور غیرعدد (NaN) است. فراخوانی partial_cmp با هر عدد شناور و مقدار NaN نقطه شناور None را برمی‌گرداند.

وقتی روی struct‌ها مشتق می‌شود، PartialOrd دو نمونه را با مقایسه مقدار هر فیلد به ترتیب ظاهر شدن فیلدها در تعریف struct مقایسه می‌کند. وقتی روی enum‌ها مشتق می‌شود، واریانت‌های enum که زودتر در تعریف enum اعلام شده‌اند، کمتر از واریانت‌هایی در نظر گرفته می‌شوند که بعداً فهرست شده‌اند.

ویژگی PartialOrd، برای مثال، برای متد gen_range از crate rand مورد نیاز است که یک مقدار تصادفی در محدوده مشخص‌شده توسط یک عبارت محدوده تولید می‌کند.

ویژگی Ord به شما امکان می‌دهد بدانید که برای هر دو مقدار از نوع حاشیه‌نویسی‌شده، یک ترتیب‌بندی معتبر وجود خواهد داشت. ویژگی Ord متد cmp را پیاده‌سازی می‌کند، که به جای Option<Ordering>، یک Ordering را برمی‌گرداند زیرا یک ترتیب‌بندی معتبر همیشه ممکن خواهد بود. شما فقط می‌توانید ویژگی Ord را به نوع‌هایی اعمال کنید که همچنین PartialOrd و Eq را پیاده‌سازی کرده باشند (و Eq نیازمند PartialEq است). وقتی روی struct‌ها و enum‌ها مشتق می‌شود، cmp به همان شکلی عمل می‌کند که پیاده‌سازی مشتق‌شده برای partial_cmp در PartialOrd عمل می‌کند.

مثالی از زمانی که Ord مورد نیاز است، هنگام ذخیره مقادیر در BTreeSet<T> است، یک ساختار داده که داده‌ها را بر اساس ترتیب مرتب‌سازی مقادیر ذخیره می‌کند.

trait Clone به شما امکان می‌دهد که به‌صورت صریح یک کپی عمیق از یک مقدار ایجاد کنید، و این فرایند تکثیر ممکن است شامل اجرای کد دلخواه و کپی‌کردن داده‌ها از حافظه heap باشد. برای اطلاعات بیشتر درباره‌ی Clone به بخش «متغیرها و داده‌ها در تعامل با Clone» در فصل ۴ مراجعه کنید.

مثالی از جایی که Clone مورد نیاز است، هنگام فراخوانی متد to_vec روی یک slice می‌باشد. slice مالک نمونه‌های نوعی که در خود دارد نیست، اما برداری که از to_vec بازمی‌گردد باید مالک این نمونه‌ها باشد، بنابراین to_vec روی هر آیتم تابع clone را فراخوانی می‌کند. از این رو، نوعی که درون slice ذخیره شده باید trait Clone را پیاده‌سازی کرده باشد.

trait Copy به شما اجازه می‌دهد که یک مقدار را تنها با کپی‌کردن بیت‌های ذخیره‌شده در stack تکثیر کنید؛ هیچ کد دلخواهی اجرا نمی‌شود. برای اطلاعات بیشتر درباره‌ی Copy به بخش «داده‌های فقط-روی-استک: Copy» در فصل ۴ مراجعه کنید.

ویژگی Copy به شما امکان می‌دهد یک مقدار را با کپی کردن بیت‌های ذخیره‌شده روی stack تکثیر کنید؛ هیچ کد دلخواهی لازم نیست. برای اطلاعات بیشتر درباره Copy، به بخش “داده‌های فقط stack: Copy” در فصل 4 مراجعه کنید.

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

شما می‌توانید Copy را روی هر نوعی مشتق کنید که تمام اجزای آن Copy را پیاده‌سازی می‌کنند. نوعی که Copy را پیاده‌سازی می‌کند باید همچنین Clone را پیاده‌سازی کند، زیرا نوعی که Copy را پیاده‌سازی می‌کند دارای پیاده‌سازی ساده‌ای از Clone است که همان وظیفه را به عنوان Copy انجام می‌دهد.

ویژگی Copy به ندرت مورد نیاز است؛ نوع‌هایی که Copy را پیاده‌سازی می‌کنند بهینه‌سازی‌هایی در دسترس دارند، به این معنا که شما نیازی به فراخوانی clone ندارید، که کد را مختصرتر می‌کند.

هر چیزی که با Copy ممکن است را می‌توانید با Clone نیز انجام دهید، اما کد ممکن است کندتر باشد یا نیاز به استفاده از clone در مکان‌های مختلف داشته باشد.

Hash برای نگاشت مقدار به مقدار با اندازه ثابت

ویژگی Hash به شما امکان می‌دهد یک نمونه از نوعی با اندازه دلخواه بگیرید و آن نمونه را با استفاده از یک تابع هش به مقدار با اندازه ثابت نگاشت کنید. مشتق‌سازی Hash متد hash را پیاده‌سازی می‌کند. پیاده‌سازی مشتق‌شده متد hash نتیجه فراخوانی hash روی هر یک از بخش‌های نوع را ترکیب می‌کند، به این معنی که تمام فیلدها یا مقادیر نیز باید Hash را پیاده‌سازی کنند تا Hash مشتق شود.

مثالی از زمانی که Hash مورد نیاز است، هنگام ذخیره کلیدها در HashMap<K, V> برای ذخیره داده‌ها به صورت کارآمد است.

تابع Default::default معمولاً همراه با نگارش به‌روزرسانی ساختار (struct update syntax) که در بخش «ایجاد نمونه‌هایی از نمونه‌های دیگر با استفاده از نگارش به‌روزرسانی ساختار» در فصل ۵ توضیح داده شده، استفاده می‌شود. می‌توانید تنها چند فیلد از یک ساختار را شخصی‌سازی کنید و سپس برای فیلدهای باقی‌مانده از مقدار پیش‌فرض با استفاده از ..Default::default() بهره ببرید.

تابع Default::default معمولاً به همراه سینتکس به‌روزرسانی ساختار که در بخش “ایجاد نمونه‌ها از نمونه‌های دیگر با سینتکس به‌روزرسانی ساختار” در فصل 5 مورد بحث قرار گرفته است، استفاده می‌شود. می‌توانید چند فیلد از یک ساختار را سفارشی کنید و سپس یک مقدار پیش‌فرض برای بقیه فیلدها با استفاده از ..Default::default() تنظیم و استفاده کنید.

ویژگی Default، برای مثال، زمانی مورد نیاز است که از متد unwrap_or_default روی نمونه‌های Option<T> استفاده می‌کنید. اگر Option<T> برابر با None باشد، متد unwrap_or_default نتیجه Default::default را برای نوع T ذخیره‌شده در Option<T> برمی‌گرداند.

ضمیمه د - ابزارهای مفید توسعه

در این ضمیمه، ما درباره برخی ابزارهای مفید توسعه که پروژه Rust ارائه می‌دهد صحبت می‌کنیم. به فرمت‌دهی خودکار، روش‌های سریع برای اعمال اصلاحات هشدارها، یک تحلیلگر کد (linter) و یکپارچه‌سازی با محیط‌های توسعه یکپارچه (IDE) خواهیم پرداخت.

فرمت‌دهی خودکار با rustfmt

نصب‌های Rust به‌صورت پیش‌فرض شامل rustfmt هستند، بنابراین احتمالاً هم‌اکنون برنامه‌های rustfmt و cargo-fmt روی سیستم شما نصب شده‌اند. این دو دستور همانند rustc و cargo هستند؛ به این صورت که rustfmt کنترل دقیق‌تری ارائه می‌دهد و cargo-fmt با ساختار و قراردادهای پروژه‌های مبتنی بر Cargo آشنایی دارد. برای قالب‌بندی هر پروژه‌ی Cargo، دستور زیر را وارد کنید:

$ cargo fmt

اجرای این دستور تمام کدهای Rust در crate فعلی را مجدداً فرمت می‌کند. این کار باید فقط سبک کدنویسی را تغییر دهد، نه معنای کد را. برای اطلاعات بیشتر در مورد rustfmt، به مستندات آن مراجعه کنید.

اصلاح کد شما با rustfix

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

Filename: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

در اینجا، متغیر x را به‌صورت قابل‌تغییر (mutable)
تعریف کرده‌ایم، اما در عمل هیچ‌گاه آن را تغییر نمی‌دهیم.
Rust در این مورد به ما هشدار می‌دهد:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut x = 0;
  |         ----^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

هشدار پیشنهاد می‌دهد که کلمه‌ی کلیدی mut را حذف کنیم. می‌توانیم این پیشنهاد را به‌صورت خودکار با استفاده از ابزار rustfix و اجرای دستور cargo fix اعمال کنیم:

$ cargo fix
    Checking myprogram v0.1.0 (file:///projects/myprogram)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

وقتی دوباره فایل src/main.rs را بررسی کنیم، خواهیم دید که cargo fix کد را تغییر داده است:

Filename: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

متغیر x اکنون غیرقابل‌تغییر (immutable) شده است و هشدار نیز دیگر نمایش داده نمی‌شود.

همچنین می‌توانید از دستور cargo fix برای انتقال کد خود بین نسخه‌های مختلف Rust استفاده کنید. نسخه‌ها در ضمیمه ه پوشش داده شده‌اند.

لینت‌های بیشتر با Clippy

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

برای اجرای تحلیلگرهای Clippy روی هر پروژه Cargo، دستور زیر را وارد کنید:

$ cargo clippy

به عنوان مثال، فرض کنید برنامه‌ای می‌نویسید که از یک مقدار تقریبی برای یک ثابت ریاضی، مانند pi، استفاده می‌کند، همانطور که این برنامه انجام می‌دهد:

Filename: src/main.rs
fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

اجرای cargo clippy روی این پروژه منجر به این خطا می‌شود:

error: approximate value of `f{32, 64}::consts::PI` found
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: `#[deny(clippy::approx_constant)]` on by default
  = help: consider using the constant directly
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant

این خطا به شما اطلاع می‌دهد که Rust از پیش ثابت PI دقیق‌تری را تعریف کرده است، و استفاده از این ثابت در برنامه‌تان باعث درستی بیشتر کد می‌شود. بنابراین باید کد خود را طوری تغییر دهید که از ثابت PI استفاده کند.

کد زیر هیچ خطا یا هشداری از Clippy ایجاد نمی‌کند:

Filename: src/main.rs
fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

برای اطلاعات بیشتر درباره Clippy، به مستندات آن مراجعه کنید.

یکپارچه‌سازی IDE با استفاده از rust-analyzer

برای یکپارچه‌سازی بهتر با محیط‌های توسعه (IDE)، جامعه‌ی Rust استفاده از rust-analyzer را توصیه می‌کند. این ابزار مجموعه‌ای از ابزارهای وابسته به کامپایلر است که با پروتکل زبان سرور (LSP) ارتباط برقرار می‌کند؛ این پروتکل مشخصاتی است برای ارتباط میان IDEها و زبان‌های برنامه‌نویسی. کلاینت‌های مختلفی می‌توانند از rust-analyzer استفاده کنند، مانند افزونه‌ی Rust Analyzer برای Visual Studio Code.

برای دریافت دستورالعمل نصب، به صفحه‌ی اصلی پروژه‌ی rust-analyzer مراجعه کنید، سپس پشتیبانی از language server را در IDE خود نصب نمایید. پس از آن، امکاناتی مانند تکمیل خودکار، پرش به تعریف، و نمایش خطاها به‌صورت درون‌خطی به IDE شما اضافه خواهد شد.

ضمیمه ه - نسخه‌ها

در فصل 1 دیدید که cargo new کمی متاداده به فایل Cargo.toml شما اضافه می‌کند که درباره نسخه است. این ضمیمه توضیح می‌دهد که این به چه معناست!

زبان Rust و کامپایلر آن یک چرخه انتشار شش‌هفته‌ای دارند، به این معنی که کاربران به طور مداوم به ویژگی‌های جدید دسترسی پیدا می‌کنند. زبان‌های برنامه‌نویسی دیگر تغییرات بزرگ را کمتر منتشر می‌کنند؛ در حالی که Rust به طور مرتب به‌روزرسانی‌های کوچک ارائه می‌دهد. پس از مدتی، همه این تغییرات کوچک جمع می‌شوند. اما از انتشار به انتشار، ممکن است سخت باشد که بگویید: «وای، بین Rust 1.10 و Rust 1.31، Rust خیلی تغییر کرده است!»

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

نسخه‌ها اهداف مختلفی برای افراد مختلف دارند:

در زمان نگارش این متن، چهار نسخه از Rust در دسترس هستند: Rust 2015، Rust 2018، Rust 2021، و Rust 2024. این کتاب با استفاده از الگوها و قواعد نسخه Rust 2024 نوشته شده است.

کلید edition در فایل Cargo.toml نشان می‌دهد که کامپایلر باید از کدام نسخه برای کد شما استفاده کند. اگر این کلید وجود نداشته باشد، Rust به دلایل سازگاری با نسخه‌های قبلی از مقدار 2015 به‌عنوان نسخه پیش‌فرض استفاده می‌کند.

هر پروژه می‌تواند به یک نسخه غیر از نسخه پیش‌فرض 2015 مهاجرت کند. نسخه‌ها می‌توانند تغییرات ناسازگار داشته باشند، مانند اضافه کردن یک کلمه کلیدی جدید که با شناسه‌ها در کد تداخل دارد. اما تا زمانی که به این تغییرات مهاجرت نکنید، کد شما همچنان کامپایل خواهد شد حتی اگر نسخه کامپایلر Rust خود را ارتقا دهید.

تمام نسخه‌های کامپایلر Rust از هر نسخه‌ای که پیش از انتشار آن کامپایلر وجود داشته باشد پشتیبانی می‌کنند و می‌توانند کتابخانه‌هایی از هر نسخه پشتیبانی‌شده را به یکدیگر لینک کنند. تغییرات نسخه فقط بر نحوه تجزیه اولیه کد توسط کامپایلر تأثیر می‌گذارد. بنابراین، اگر از Rust 2015 استفاده می‌کنید و یکی از وابستگی‌های شما از Rust 2018 استفاده می‌کند، پروژه شما کامپایل خواهد شد و می‌تواند از آن وابستگی استفاده کند. وضعیت معکوس، جایی که پروژه شما از Rust 2018 و یک وابستگی از Rust 2015 استفاده می‌کند، نیز کار می‌کند.

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

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

ضمیمه و: ترجمه‌های کتاب

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

ضمیمه ی - چگونگی توسعه Rust و “Rust Nightly”

این ضمیمه درباره چگونگی توسعه Rust و تأثیر آن بر شما به عنوان یک توسعه‌دهنده Rust است.

ثبات بدون رکود

به عنوان یک زبان، Rust به ثبات کد شما بسیار اهمیت می‌دهد. ما می‌خواهیم Rust یک پایه محکم و قابل اعتماد باشد که بتوانید بر روی آن بسازید، و اگر همه چیز به طور مداوم تغییر می‌کرد، این امکان‌پذیر نبود. در عین حال، اگر نتوانیم با ویژگی‌های جدید آزمایش کنیم، ممکن است مشکلات مهمی را تا بعد از انتشار آن‌ها کشف نکنیم، زمانی که دیگر نمی‌توان تغییراتی ایجاد کرد.

راه‌حل ما برای این مشکل چیزی است که ما آن را “ثبات بدون رکود” می‌نامیم، و اصل راهنمای ما این است: شما هرگز نباید از ارتقاء به یک نسخه جدید از Rust پایدار بترسید. هر ارتقاء باید بدون دردسر باشد، اما همچنین ویژگی‌های جدید، باگ‌های کمتر، و زمان‌های کامپایل سریع‌تر را برای شما به ارمغان بیاورد.

چو، چو! کانال‌های انتشار و حرکت قطارها

توسعه Rust بر اساس یک برنامه زمانی قطار عمل می‌کند. یعنی تمام توسعه‌ها در شاخه master مخزن Rust انجام می‌شود. انتشارها از مدل قطار انتشار نرم‌افزار پیروی می‌کنند، مدلی که توسط Cisco IOS و پروژه‌های نرم‌افزاری دیگر استفاده شده است. سه کانال انتشار برای Rust وجود دارد:

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

در اینجا مثالی از نحوه کار فرآیند توسعه و انتشار آورده شده است: فرض کنید تیم Rust روی انتشار نسخه Rust 1.5 کار می‌کند. آن انتشار در دسامبر 2015 اتفاق افتاد، اما اعداد نسخه‌ای واقعی به ما ارائه می‌دهد. یک ویژگی جدید به Rust اضافه می‌شود: یک commit جدید به شاخه master اضافه می‌شود. هر شب، یک نسخه جدید nightly از Rust تولید می‌شود. هر روز یک روز انتشار است، و این نسخه‌ها به طور خودکار توسط زیرساخت انتشار ما ایجاد می‌شوند. بنابراین با گذشت زمان، انتشارهای ما به این صورت خواهند بود، هر شب:

nightly: * - - * - - *

هر شش هفته، زمان آماده‌سازی یک انتشار جدید است! شاخه beta مخزن Rust از شاخه master که برای nightly استفاده می‌شود منشعب می‌شود. اکنون دو نسخه وجود دارد:

nightly: * - - * - - *
                     |
beta:                *

بیشتر کاربران Rust به طور فعال از نسخه‌های beta استفاده نمی‌کنند، اما در سیستم CI خود علیه beta تست می‌گیرند تا به Rust کمک کنند که مشکلات احتمالی را شناسایی کند. در همین حال، هنوز هر شب یک نسخه nightly منتشر می‌شود:

nightly: * - - * - - * - - * - - *
                     |
beta:                *

فرض کنید یک مشکل (regression) پیدا شود. خوشبختانه ما زمانی برای تست نسخه beta داشتیم قبل از اینکه مشکل وارد نسخه پایدار شود! اصلاح به شاخه master اعمال می‌شود، بنابراین nightly اصلاح می‌شود، و سپس این اصلاح به شاخه beta بازگردانده می‌شود، و یک نسخه جدید از beta تولید می‌شود:

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

شش هفته پس از ایجاد اولین نسخه beta، زمان انتشار نسخه پایدار است! شاخه stable از شاخه beta تولید می‌شود:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

هورا! Rust 1.5 آماده است! اما یک چیز را فراموش کرده‌ایم: چون شش هفته گذشته است، ما به نسخه beta جدیدی از نسخه بعدی Rust، یعنی 1.6، نیاز داریم. بنابراین پس از اینکه شاخه stable از beta جدا شد، نسخه بعدی beta دوباره از nightly منشعب می‌شود:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

این مدل “قطار” نامیده می‌شود، زیرا هر شش هفته، یک انتشار “ایستگاه را ترک می‌کند”، اما همچنان باید از کانال beta عبور کند تا به یک انتشار پایدار تبدیل شود.

انتشارهای Rust هر شش هفته، مانند ساعت دقیق انجام می‌شوند. اگر تاریخ یک انتشار Rust را بدانید، می‌توانید تاریخ انتشار بعدی را بدانید: شش هفته بعد. یکی از جنبه‌های خوب داشتن انتشارهای برنامه‌ریزی‌شده هر شش هفته این است که قطار بعدی به زودی می‌آید. اگر یک ویژگی به طور اتفاقی یک انتشار خاص را از دست بدهد، نیازی به نگرانی نیست: انتشار بعدی در مدت کوتاهی اتفاق می‌افتد! این امر به کاهش فشار برای افزودن ویژگی‌های احتمالاً ناقص نزدیک به مهلت انتشار کمک می‌کند.

با تشکر از این فرآیند، شما همیشه می‌توانید نسخه بعدی Rust را بررسی کرده و برای خود تأیید کنید که ارتقاء به آن آسان است: اگر یک نسخه beta مطابق انتظار عمل نکند، می‌توانید آن را به تیم گزارش دهید و قبل از اینکه انتشار پایدار بعدی انجام شود، آن را اصلاح کنید! شکستن در یک نسخه beta نسبتاً نادر است، اما rustc همچنان یک نرم‌افزار است و باگ‌ها وجود دارند.

زمان نگهداری

پروژه Rust از آخرین نسخه پایدار پشتیبانی می‌کند. وقتی یک نسخه پایدار جدید منتشر می‌شود، نسخه قدیمی به پایان عمر خود (EOL) می‌رسد. این به این معنی است که هر نسخه برای شش هفته پشتیبانی می‌شود.

ویژگی‌های ناپایدار

یک نکته دیگر در این مدل انتشار وجود دارد: ویژگی‌های ناپایدار. Rust از تکنیکی به نام “پرچم‌های ویژگی” (feature flags) استفاده می‌کند تا تعیین کند چه ویژگی‌هایی در یک انتشار فعال هستند. اگر یک ویژگی جدید تحت توسعه فعال باشد، روی شاخه master قرار می‌گیرد و بنابراین، در nightly، اما پشت یک پرچم ویژگی قرار می‌گیرد. اگر به‌عنوان کاربر، مایلید ویژگی در حال توسعه را امتحان کنید، می‌توانید این کار را انجام دهید، اما باید از نسخه nightly Rust استفاده کرده و کد منبع خود را با پرچم مناسب برای فعال‌سازی آن علامت‌گذاری کنید.

اگر از نسخه beta یا پایدار Rust استفاده می‌کنید، نمی‌توانید از پرچم‌های ویژگی استفاده کنید. این نکته‌ای است که به ما اجازه می‌دهد از ویژگی‌های جدید به صورت عملی استفاده کنیم قبل از اینکه آن‌ها را برای همیشه پایدار اعلام کنیم. کسانی که مایلند از ویژگی‌های پیشرفته استفاده کنند، می‌توانند این کار را انجام دهند، و کسانی که تجربه‌ای پایدار و قابل اعتماد می‌خواهند می‌توانند با نسخه پایدار بمانند و مطمئن باشند که کد آن‌ها خراب نخواهد شد. ثبات بدون رکود.

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

Rustup و نقش Rust Nightly

ابزار Rustup تغییر بین کانال‌های مختلف انتشار Rust را، به صورت جهانی یا بر اساس هر پروژه، آسان می‌کند. به طور پیش‌فرض، Rust پایدار نصب خواهد بود. برای نصب نسخه nightly، به عنوان مثال:

$ rustup toolchain install nightly

همچنین می‌توانید تمام ابزارهای موجود (نسخه‌های Rust و اجزای مرتبط) که با rustup نصب کرده‌اید را ببینید. در اینجا مثالی از یک کامپیوتر ویندوزی یکی از نویسندگان آورده شده است:

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

همان‌طور که می‌بینید، ابزار stable به طور پیش‌فرض تنظیم شده است. بیشتر کاربران Rust بیشتر وقت خود از stable استفاده می‌کنند. ممکن است بخواهید بیشتر وقت خود از stable استفاده کنید، اما در یک پروژه خاص از nightly استفاده کنید، زیرا به یک ویژگی پیشرفته علاقه دارید. برای انجام این کار، می‌توانید از rustup override در دایرکتوری آن پروژه استفاده کنید تا ابزار nightly را به‌عنوان ابزار مورد استفاده rustup در آن دایرکتوری تنظیم کنید:

$ cd ~/projects/needs-nightly
$ rustup override set nightly

اکنون، هر بار که در دایرکتوری ~/projects/needs-nightly دستور rustc یا cargo را فراخوانی کنید، rustup اطمینان حاصل می‌کند که شما از Rust nightly استفاده می‌کنید، نه نسخه پایدار پیش‌فرض. این ویژگی زمانی که پروژه‌های زیادی با Rust دارید، بسیار مفید است!

فرآیند RFC و تیم‌ها

چگونه می‌توانید درباره این ویژگی‌های جدید اطلاعات کسب کنید؟ مدل توسعه Rust از یک فرآیند درخواست نظرات (RFC) پیروی می‌کند. اگر بهبود خاصی در Rust می‌خواهید، می‌توانید یک پیشنهاد بنویسید که به آن RFC گفته می‌شود.

هر کسی می‌تواند RFC بنویسد تا Rust را بهبود دهد، و این پیشنهادها توسط تیم Rust که از چندین زیرتیم موضوعی تشکیل شده است، بررسی و بحث می‌شوند. لیست کامل تیم‌ها در وب‌سایت Rust موجود است، که شامل تیم‌هایی برای هر بخش از پروژه می‌شود: طراحی زبان، پیاده‌سازی کامپایلر، زیرساخت، مستندات و موارد دیگر. تیم مربوطه پیشنهاد و نظرات را می‌خواند، نظرات خود را می‌نویسد، و در نهایت، توافقی برای پذیرش یا رد ویژگی حاصل می‌شود.

اگر ویژگی پذیرفته شود، یک issue در مخزن Rust باز می‌شود و کسی می‌تواند آن را پیاده‌سازی کند. فردی که آن را پیاده‌سازی می‌کند، ممکن است همان فردی نباشد که ویژگی را ابتدا پیشنهاد داده است! وقتی پیاده‌سازی آماده شد، روی شاخه master پشت یک پرچم ویژگی قرار می‌گیرد، همان‌طور که در بخش “ویژگی‌های ناپایدار” بحث شد.

پس از مدتی، زمانی که توسعه‌دهندگان Rust که از نسخه‌های nightly استفاده می‌کنند توانسته‌اند ویژگی جدید را امتحان کنند، اعضای تیم درباره این ویژگی، نحوه عملکرد آن در nightly و تصمیم‌گیری می‌کنند که آیا باید وارد Rust پایدار شود یا نه. اگر تصمیم بر ادامه باشد، پرچم ویژگی حذف می‌شود و ویژگی اکنون پایدار تلقی می‌شود! سپس این ویژگی وارد نسخه پایدار جدید Rust می‌شود.