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

نوشته استیو کلابنیک، کارول نیکولز، و کریس کریچو، با مشارکت اعضای جامعه راست

این نسخه از متن فرض می‌کند که شما از راست نسخه 1.82.0 (منتشر شده در تاریخ 17-10-2024) یا نسخه‌های جدیدتر استفاده می‌کنید. برای نصب یا به‌روزرسانی راست به بخش “نصب” از فصل 1 مراجعه کنید.

فرمت 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، دستگاه‌های تعبیه‌شده، تحلیل و رمزگذاری صدا و تصویر، ارزهای دیجیتال، زیست‌اطلاعات، موتورهای جستجو، برنامه‌های اینترنت اشیاء، یادگیری ماشین و حتی بخش‌های اصلی مرورگر وب فایرفاکس.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

در فصل ۱۶، مدل‌های مختلف برنامه‌نویسی هم‌زمان را بررسی می‌کنیم و درباره اینکه چگونه راست به شما کمک می‌کند بدون ترس با چندین رشته (string) کار کنید صحبت خواهیم کرد. در فصل ۱۷، این موضوع را با بررسی syntax async و await و مدل هم‌زمانی سبک‌وزنی که پشتیبانی می‌کنند، گسترش خواهیم داد.

فصل ۱۸ نگاهی به چگونگی مقایسه اصطلاحات راست با اصول برنامه‌نویسی شیءگرا می‌اندازد که ممکن است با آن‌ها آشنا باشید.

فصل ۱۹ مرجعی درباره الگوها و الگویابی (pattern matching) است که راه‌های قدرتمندی برای بیان ایده‌ها در سراسر برنامه‌های راست ارائه می‌دهد. فصل ۲۰ شامل مجموعه‌ای از موضوعات پیشرفته جالب، از جمله راست ناامن، ماکروها، و مباحث بیشتر درباره lifetimes، traits، انواع، توابع و closures است.

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

در نهایت، پیوست‌هایی شامل اطلاعات مفید درباره زبان به شکلی مرجع‌گونه ارائه می‌شوند. ضمیمه الف کلمات کلیدی راست، ضمیمه ب عملگرها و نمادهای راست، ضمیمه ج traits قابل اشتقاق ارائه‌شده توسط کتابخانه استاندارد، ضمیمه د برخی از ابزارهای توسعه مفید، و ضمیمه ه نسخه‌های راست را توضیح می‌دهد. در ضمیمه و می‌توانید ترجمه‌های کتاب را پیدا کنید و در ضمیمه ی درباره چگونگی ساخت راست و راست 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 را نصب کرده‌اید، وقت آن است که اولین برنامه‌ی 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.exe
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 را در فصل 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 = "2021"

# برای مشاهده کلیدها و تعاریف بیشتر به https://doc.rust-lang.org/cargo/reference/manifest.html مراجعه کنید

[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 = "2021"

[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.20s
     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 بازمی‌گرداند که یک نوع برای مدیریت ورودی استاندارد ترمینال شما است.

در خط بعدی، متد .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 16 packages to latest compatible versions
      Adding wasi v0.11.0+wasi-snapshot-preview1 (latest: v0.13.3+wasi-0.2.2)
      Adding zerocopy v0.7.35 (latest: v0.8.9)
      Adding zerocopy-derive v0.7.35 (latest: v0.8.9)
  Downloaded syn v2.0.87
  Downloaded 1 crate (278.1 KB) in 0.16s
   Compiling proc-macro2 v1.0.89
   Compiling unicode-ident v1.0.13
   Compiling libc v0.2.161
   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.37
   Compiling syn v2.0.87
   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 3.69s
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
    Updating rand v0.8.5 -> v0.8.6

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 rand::Rng;
use std::cmp::Ordering;
use std::io;

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:22:21
    |
22  |     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
   --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/core/src/cmp.rs:838:8
    |
838 |     fn cmp(&self, other: &Self) -> Ordering;
    |        ^^^

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

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

    // --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 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

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

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

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

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

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

    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 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 {
        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: انواع اعداد صحیح در راست

طولبا علامتبدون علامت
8 بیتi8u8
16 بیتi16u16
32 بیتi32u32
64 بیتi64u64
128 بیتi128u128
معماریisizeusize

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

هر حالت با علامت می‌تواند اعداد را از -(2n - 1) تا 2n - 1 - 1 شامل شود، جایی که n تعداد بیت‌هایی است که آن حالت استفاده می‌کند. بنابراین یک i8 می‌تواند اعداد را از -(27) تا 27 - 1 ذخیره کند، که برابر است با -128 تا 127. حالت‌های بدون علامت می‌توانند اعداد را از 0 تا 2n - 1 ذخیره کنند، بنابراین یک u8 می‌تواند اعداد را از 0 تا 28 - 1 ذخیره کند، که برابر است با 0 تا 255.

علاوه بر این، نوع‌های isize و usize به معماری رایانه‌ای که برنامه شما روی آن اجرا می‌شود بستگی دارند، که در جدول به عنوان “معماری” مشخص شده است: 64 بیت اگر روی معماری 64 بیتی باشید و 32 بیت اگر روی معماری 32 بیتی باشید.

شما می‌توانید اعداد صحیح را به هر یک از اشکال نشان داده شده در جدول 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.
  • بازگرداندن مقدار None اگر سرریز رخ دهد با روش‌های checked_*.
  • بازگرداندن مقدار و یک بولین که نشان‌دهنده سرریز است با روش‌های overflowing_*.
  • اشباع در مقادیر حداقل یا حداکثر مقدار نوع با روش‌های 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 = '😻';
}

توجه داشته باشید که مقادیر char با استفاده از علامت نقل قول تکی مشخص می‌شوند، در حالی که مقادیر رشته‌ای از علامت نقل قول دوتایی استفاده می‌کنند. نوع char در راست چهار بایت اندازه دارد و نمایانگر یک مقدار اسکالر یونیکد است، به این معنی که می‌تواند خیلی بیشتر از فقط ASCII را نمایان کند. حروف با لهجه؛ حروف چینی، ژاپنی و کره‌ای؛ ایموجی؛ و فاصله‌های بدون عرض همگی مقادیر char معتبر در راست هستند. مقادیر اسکالر یونیکد در بازه U+0000 تا U+D7FF و U+E000 تا U+10FFFF قرار دارند. با این حال، “کاراکتر” واقعاً یک مفهوم در یونیکد نیست، بنابراین درک انسانی شما از آنچه یک “کاراکتر” است ممکن است با آنچه یک char در راست است همخوانی نداشته باشد. ما این موضوع را به تفصیل در بخش “ذخیره متن رمزگذاری‌شده UTF-8 با رشته‌ها” در فصل 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];
}

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

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

#![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 فراخوانی کردیم، خروجی برنامه شامل این مقادیر است.

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

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

  • اظهارات دستورالعمل‌هایی هستند که یک عمل انجام می‌دهند و هیچ مقداری باز نمی‌گردانند.
  • عبارات به یک مقدار نتیجه‌گیری می‌رسند. بیایید چند مثال را بررسی کنیم.

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

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

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

اظهارات هیچ مقداری باز نمی‌گردانند. بنابراین، نمی‌توانید یک اظهار 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 را فشار داده‌اید. ممکن است کلمه again! پس از ^C چاپ شود یا نشود، بسته به اینکه کد در حلقه در چه مرحله‌ای بوده است که سیگنال قطع دریافت شده است.

خوشبختانه، 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 برای اجرای کد تا زمانی که شرط برقرار باشد

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

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

شما همچنین می‌توانید از ساختار while برای تکرار در عناصر یک مجموعه مانند یک آرایه استفاده کنید. به عنوان مثال، حلقه در Listing 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

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

با استفاده از حلقه 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 را منحصر به فرد می‌کنند خواهید داشت. در این فصل، مالکیت را با کار بر روی چند مثال که بر یک ساختار داده بسیار رایج تمرکز دارند یاد خواهید گرفت: رشته‌ها.

پشته و حافظه

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

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

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

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

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

وقتی کد شما یک تابع را فراخوانی می‌کند، مقادیری که به تابع منتقل می‌شوند (از جمله، احتمالاً، اشاره‌گر (Pointer)هایی به داده در حافظه) و متغیرهای محلی تابع به پشته پوش می‌شوند. وقتی تابع تمام می‌شود، آن مقادیر از پشته پاپ می‌شوند.

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

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

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

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

دامنه متغیر

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

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

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

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

fn main() {
    {                      // s is not valid here, 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,
    println!("{}", x);              // so it's okay to use x afterward

} // Here, x goes out of scope, then s. But 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 one
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 value 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!("{}, {}, and {}", r1, r2, 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!("{}, {}, and {}", r1, r2, 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. 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

Slice ها به شما اجازه می‌دهند تا به یک توالی پیوسته از عناصر در یک مجموعه ارجاع دهید، به جای کل مجموعه. یک slice نوعی ارجاع است، بنابراین مالکیت ندارد.

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

بیایید بررسی کنیم که چگونه می‌توانیم امضای این تابع را بدون استفاده از slices بنویسیم تا مسئله‌ای که slices حل می‌کنند را بهتر درک کنیم:

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 است و به این شکل به نظر می‌رسد:

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

    let hello = &s[0..5];
    let world = &s[6..11];
}

به جای یک ارجاع به کل String، hello یک ارجاع به بخشی از String است که در بخش اضافی [0..5] مشخص شده است. ما با استفاده از یک محدوده در داخل کروشه‌ها برش‌ها را ایجاد می‌کنیم، با مشخص کردن [starting_index..ending_index] که در آن starting_index اولین موقعیت در برش و ending_index یکی بیشتر از آخرین موقعیت در برش است. به صورت داخلی، ساختار داده برش موقعیت شروع و طول برش را ذخیره می‌کند که متناظر با ending_index منهای starting_index است. بنابراین، در حالت let world = &s[6..11];، world یک برش است که شامل یک اشاره‌گر (Pointer) به بایت در شاخص 6 از 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[..];
}

توجه: شاخص‌های محدوده برش رشته باید در مرزهای معتبر کاراکتر UTF-8 رخ دهند. اگر بخواهید یک برش رشته در وسط یک کاراکتر چندبایتی ایجاد کنید، برنامه شما با یک خطا خاتمه خواهد یافت. برای مقاصد معرفی برش‌های رشته‌ای، ما فقط ASCII را در این بخش در نظر گرفته‌ایم؛ بحث دقیق‌تری در مورد مدیریت UTF-8 در بخش “ذخیره متن رمزگذاری شده UTF-8 با رشته‌ها” در فصل 8 وجود دارد.

با در نظر گرفتن این اطلاعات، بیایید 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

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

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

اغلب مفید است که یک نمونه جدید از یک ساختار ایجاد کنیم که شامل اکثر مقادیر از یک نمونه دیگر است، اما برخی از آن‌ها تغییر کرده‌اند. شما می‌توانید این کار را با استفاده از نحو به‌روزرسانی 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 از = مانند یک عملگر انتساب استفاده می‌کند؛ این به این دلیل است که داده‌ها را جابه‌جا می‌کند، همان‌طور که در بخش «تعامل متغیرها و داده‌ها با انتقال» مورد بحث قرار گرفت. در این مثال، دیگر نمی‌توانیم از user1 به عنوان یک کل پس از ایجاد user2 استفاده کنیم، زیرا String در فیلد username از user1 به user2 منتقل شد. اگر ما به user2 مقادیر جدید String برای هر دو email و username داده بودیم و بنابراین فقط از مقادیر active و sign_in_count از user1 استفاده کرده بودیم، user1 پس از ایجاد user2 همچنان معتبر باقی می‌ماند. هم active و هم sign_in_count از انواعی هستند که ویژگی Copy را پیاده‌سازی می‌کنند، بنابراین رفتار مورد بحث در بخش «داده‌های فقط روی پشته: Copy» اعمال می‌شود. در این مثال، همچنان می‌توانیم از user1.email استفاده کنیم، زیرا مقدار آن منتقل نشده است.

استفاده از ساختارهای 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 انواع متفاوتی دارند زیرا آن‌ها نمونه‌هایی از ساختارهای Tuple متفاوت هستند. هر ساختاری که تعریف می‌کنید نوع خودش را دارد، حتی اگر فیلدهای درون ساختار نوع یکسانی داشته باشند. برای مثال، یک تابع که پارامتری از نوع Color می‌گیرد نمی‌تواند یک Point را به عنوان آرگومان بگیرد، حتی اگر هر دو نوع از سه مقدار i32 تشکیل شده باشند. در غیر این صورت، نمونه‌های ساختار Tuple مشابه تاپل‌ها هستند به این معنا که می‌توانید آن‌ها را به اجزای فردی تجزیه کنید و می‌توانید از یک . به همراه ایندکس برای دسترسی به مقدار خاصی استفاده کنید. برخلاف تاپل‌ها، ساختارهای Tuple نیاز دارند که هنگام تجزیه آن‌ها نوع ساختار را مشخص کنید. برای مثال، می‌توانیم بنویسیم let Point(x, y, z) = point.

ساختارهای شبیه به 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

در اینجا یک ساختار تعریف کرده‌ایم و نام آن را Rectangle گذاشته‌ایم. داخل آکولادها، فیلدهایی به نام‌های width و height تعریف کرده‌ایم که هر دو از نوع u32 هستند. سپس، در main، یک نمونه خاص از Rectangle ایجاد کرده‌ایم که عرض آن 30 و ارتفاع آن 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 و یک نام تعریف می‌کنیم، می‌توانند پارامترها و یک مقدار بازگشتی داشته باشند و شامل کدی هستند که وقتی متد از جایی دیگر فراخوانی می‌شود، اجرا می‌شود. برخلاف توابع، متدها در زمینه یک ساختار (یا یک Enum یا یک Trait Object، که آن‌ها را به ترتیب در فصل ۶ و فصل ۱۷ پوشش می‌دهیم) تعریف می‌شوند و پارامتر اول آن‌ها همیشه self است که نمونه‌ای از ساختاری که متد روی آن فراخوانی شده است را نمایش می‌دهد.

تعریف متدها

بیایید تابع 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.

در ارائه سال 2009 خود به نام “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>`:
            `&'a i8` implements `Add<i8>`
            `&i8` implements `Add<&i8>`
            `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
   --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/core/src/option.rs:571:1
    |
571 | pub enum Option<T> {
    | ^^^^^^^^^^^^^^^^^^
...
575 |     None,
    |     ---- 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 به معنای تایپ کمتر، تورفتگی کمتر و کد اضافی کمتر است. با این حال، شما بررسی کامل که 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، اگر می‌خواستیم چیزی خنده‌دار بگوییم که بسته به سن ایالت بر روی سکه بود، ممکن است متدی برای بررسی سن ایالت ایجاد کنیم، مانند این:

#[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 نشان داده شده است.

Filename: src/main.rs
#[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 نیز انجام دهید!)

Filename: src/main.rs
#[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 دارد. اگر الگو تطابق داشته باشد، مقدار الگو را در دامنه خارجی بایند می‌کند. اگر الگو تطابق نداشته باشد، برنامه به شاخه else منتقل می‌شود که باید از تابع بازگردد.

در لیستینگ 6-9، می‌توانید ببینید که لیستینگ 6-8 چگونه با استفاده از let else به جای if let به نظر می‌رسد. توجه کنید که این روش “در مسیر خوشحال” در بدنه اصلی تابع باقی می‌ماند، بدون اینکه کنترل جریان برای دو شاخه به طور قابل توجهی متفاوت باشد همان‌طور که if let انجام داد.

Filename: src/main.rs
#[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 برای واضح‌تر کردن جریان درون تابع.

اگر در موقعیتی هستید که منطق برنامه شما برای استفاده از یک match بسیار پرحجم است، به یاد داشته باشید که if let و let else نیز در ابزارهای Rust شما موجود هستند.

خلاصه

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

برنامه‌های Rust شما اکنون می‌توانند مفاهیمی را در حوزه خود بیان کنند و از ساختارها و enumها استفاده کنند. ایجاد انواع سفارشی برای استفاده در API شما ایمنی نوع را تضمین می‌کند: کامپایلر مطمئن می‌شود که توابع شما فقط مقادیری از نوعی که هر تابع انتظار دارد دریافت می‌کنند.

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

مدیریت پروژه‌های بزرگ با بسته‌ها، جعبه‌ها (crates) و ماژول‌ها

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

برنامه‌هایی که تاکنون نوشته‌ایم در یک ماژول و یک فایل بوده‌اند. همان‌طور که پروژه رشد می‌کند، باید کد را با تقسیم آن به ماژول‌های مختلف و سپس فایل‌های مختلف سازماندهی کنید. یک بسته می‌تواند شامل چندین جعبه (crate) باینری و به صورت اختیاری یک جعبه (crate) کتابخانه باشد. همان‌طور که بسته رشد می‌کند، می‌توانید بخش‌هایی را به جعبه‌ها (crates)ی جداگانه‌ای که به عنوان وابستگی‌های خارجی عمل می‌کنند استخراج کنید. این فصل تمام این تکنیک‌ها را پوشش می‌دهد. برای پروژه‌های بسیار بزرگ که شامل مجموعه‌ای از بسته‌های مرتبط است که با یکدیگر تکامل می‌یابند، Cargo ویژگی‌هایی به نام فضای کاری ارائه می‌دهد که در بخش «فضای کاری Cargo» فصل ۱۴ به آن می‌پردازیم.

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

یک مفهوم مرتبط، محدوده (scope) است: زمینه‌ای که در آن کد نوشته شده است و مجموعه‌ای از نام‌ها که به عنوان «در محدوده» تعریف می‌شوند. هنگام خواندن، نوشتن و کامپایل کد، برنامه‌نویسان و کامپایلرها باید بدانند که آیا یک نام خاص در یک مکان خاص به متغیر، تابع، ساختار، enum، ماژول، ثابت یا مورد دیگری اشاره دارد و معنای آن مورد چیست. شما می‌توانید محدوده‌ها ایجاد کنید و مشخص کنید که کدام نام‌ها در محدوده هستند یا خارج از آن. نمی‌توانید دو مورد با نام یکسان در یک محدوده داشته باشید؛ ابزارهایی برای رفع تعارض نام‌ها در دسترس هستند.

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

  • بسته‌ها: ویژگی‌ای در Cargo که به شما امکان ساخت، تست و اشتراک‌گذاری جعبه‌ها (crates) را می‌دهد.
  • جعبه‌ها (crates): درختی از ماژول‌ها که یک کتابخانه یا یک اجرایی تولید می‌کنند.
  • ماژول‌ها و use: به شما اجازه می‌دهند سازماندهی، محدوده و حریم خصوصی مسیرها را کنترل کنید.
  • مسیرها: راهی برای نام‌گذاری یک مورد مانند یک ساختار، تابع یا ماژول.

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

بسته‌ها و جعبه‌ها (crates)

اولین بخش‌هایی که در سیستم ماژول بررسی خواهیم کرد، بسته‌ها و جعبه‌ها (crates) هستند.

یک جعبه (crate) کوچک‌ترین واحد کدی است که کامپایلر Rust در یک زمان در نظر می‌گیرد. حتی اگر به جای cargo از rustc استفاده کنید و یک فایل کد منبع را ارسال کنید (همان‌طور که در بخش «نوشتن و اجرای یک برنامه Rust» در فصل ۱ انجام دادیم)، کامپایلر آن فایل را به عنوان یک جعبه (crate) در نظر می‌گیرد. جعبه‌ها (crates) می‌توانند شامل ماژول‌ها باشند، و این ماژول‌ها ممکن است در فایل‌های دیگری تعریف شوند که همراه با جعبه (crate) کامپایل می‌شوند، همان‌طور که در بخش‌های آینده خواهیم دید.

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

جعبه‌ها (crates)ی کتابخانه‌ای تابع main ندارند و به یک فایل اجرایی کامپایل نمی‌شوند. بلکه، آن‌ها عملکردهایی را تعریف می‌کنند که برای اشتراک‌گذاری میان چندین پروژه طراحی شده‌اند. به عنوان مثال، جعبه (crate) rand که در فصل ۲ از آن استفاده کردیم، قابلیت تولید اعداد تصادفی را فراهم می‌کند. اغلب اوقات وقتی Rustaceanها می‌گویند “جعبه (crate)”، منظورشان جعبه (crate) کتابخانه‌ای است، و آن را به صورت متناوب با مفهوم عمومی برنامه‌نویسی “کتابخانه” استفاده می‌کنند.

ریشه جعبه (crate) یک فایل منبع است که کامپایلر Rust از آن شروع می‌کند و ریشه ماژول جعبه (crate) را تشکیل می‌دهد (ماژول‌ها را در بخش «تعریف ماژول‌ها برای کنترل محدوده و حریم خصوصی» به طور کامل بررسی خواهیم کرد).

یک بسته مجموعه‌ای از یک یا چند جعبه (crate) است که مجموعه‌ای از عملکردها را فراهم می‌کند. یک بسته شامل یک فایل Cargo.toml است که توضیح می‌دهد چگونه باید این جعبه‌ها (crates) ساخته شوند. Cargo خود یک بسته است که شامل جعبه (crate) باینری ابزار خط فرمانی که از آن برای ساخت کدتان استفاده کرده‌اید می‌شود. بسته Cargo همچنین شامل یک جعبه (crate) کتابخانه‌ای است که جعبه (crate) باینری به آن وابسته است. پروژه‌های دیگر می‌توانند به جعبه (crate) کتابخانه‌ای Cargo وابسته شوند تا از همان منطقی که ابزار خط فرمان Cargo استفاده می‌کند بهره‌مند شوند. یک بسته می‌تواند شامل هر تعداد جعبه (crate) باینری باشد که می‌خواهید، اما در بیشترین حالت تنها یک جعبه (crate) کتابخانه‌ای می‌تواند داشته باشد. یک بسته باید حداقل یک جعبه (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 انجام داده‌ایم. ماژول‌ها همچنین می‌توانند تعاریف آیتم‌های دیگر را نگه دارند، مانند ساختارها، enumها، ثابت‌ها، traits و—همان‌طور که در لیستینگ 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() {}
    }
}

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:9:37
  |
9 |     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:12:30
   |
12 |     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() {}
    }
}

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 فراخوانی کنیم

حالا کد کامپایل می‌شود! برای اینکه ببینیم چرا اضافه کردن کلمه کلیدی pub به ما اجازه می‌دهد از این مسیرها در eat_at_restaurant استفاده کنیم، بیایید به مسیرهای مطلق و نسبی نگاه کنیم.

در مسیر مطلق، با crate، ریشه درخت ماژول جعبه (crate) خود شروع می‌کنیم. ماژول front_of_house در ریشه جعبه (crate) تعریف شده است. اگرچه front_of_house عمومی نیست، از آنجا که تابع eat_at_restaurant در همان ماژول به عنوان front_of_house تعریف شده است (یعنی eat_at_restaurant و front_of_house هم‌سطح هستند)، می‌توانیم از eat_at_restaurant به front_of_house ارجاع دهیم. بعد، ماژول hosting که با pub علامت‌گذاری شده است قرار دارد. ما می‌توانیم به ماژول والد hosting دسترسی داشته باشیم، بنابراین می‌توانیم به hosting دسترسی داشته باشیم. در نهایت، تابع add_to_waitlist با pub علامت‌گذاری شده است و می‌توانیم به ماژول والد آن دسترسی داشته باشیم، بنابراین این فراخوانی تابع کار می‌کند!

در مسیر نسبی، منطق همان مسیر مطلق است با این تفاوت که مرحله اول متفاوت است: به جای شروع از ریشه جعبه (crate)، مسیر از front_of_house شروع می‌شود. ماژول front_of_house در همان ماژولی که eat_at_restaurant تعریف شده است قرار دارد، بنابراین مسیر نسبی که از ماژولی که eat_at_restaurant در آن تعریف شده است شروع می‌شود کار می‌کند. سپس، از آنجا که hosting و add_to_waitlist با pub علامت‌گذاری شده‌اند، بقیه مسیر کار می‌کند و این فراخوانی تابع معتبر است!

اگر قصد دارید جعبه (crate) کتابخانه خود را به اشتراک بگذارید تا پروژه‌های دیگر بتوانند از کد شما استفاده کنند، API عمومی شما قرارداد شما با کاربران جعبه (crate) است که تعیین می‌کند چگونه می‌توانند با کد شما تعامل داشته باشند. نکات زیادی در مورد مدیریت تغییرات API عمومی شما وجود دارد که به افراد کمک می‌کند به جعبه (crate) شما وابسته باشند. این ملاحظات خارج از دامنه این کتاب هستند؛ اگر به این موضوع علاقه‌مند هستید، به راهنمای API Rust مراجعه کنید.

بهترین شیوه‌ها برای بسته‌هایی که یک جعبه (crate) باینری و یک جعبه (crate) کتابخانه‌ای دارند

ما اشاره کردیم که یک بسته می‌تواند هم یک ریشه جعبه (crate) باینری در src/main.rs و هم یک ریشه جعبه (crate) کتابخانه‌ای در src/lib.rs داشته باشد، و هر دو جعبه (crate) به صورت پیش‌فرض نام بسته را خواهند داشت. معمولاً بسته‌هایی که این الگو را دنبال می‌کنند فقط به اندازه کافی کد در جعبه (crate) باینری دارند تا یک فایل اجرایی ایجاد کنند که کدی درون جعبه (crate) کتابخانه‌ای را فراخوانی کند. این کار به پروژه‌های دیگر اجازه می‌دهد از بیشتر عملکردهایی که بسته ارائه می‌دهد بهره‌مند شوند، زیرا کد جعبه (crate) کتابخانه‌ای می‌تواند به اشتراک گذاشته شود.

درخت ماژول باید در src/lib.rs تعریف شود. سپس، هر آیتم عمومی را می‌توان در جعبه (crate) باینری با شروع مسیرها با نام بسته استفاده کرد. جعبه (crate) باینری به یک کاربر از جعبه (crate) کتابخانه‌ای تبدیل می‌شود، درست مثل اینکه یک جعبه (crate) کاملاً خارجی از جعبه (crate) کتابخانه‌ای استفاده می‌کند: تنها می‌تواند از API عمومی استفاده کند. این کار به شما کمک می‌کند یک API خوب طراحی کنید؛ نه تنها نویسنده آن هستید، بلکه یک کاربر نیز هستید!

در فصل ۱۲، ما این شیوه سازمان‌دهی را با یک برنامه خط فرمان که هم یک جعبه (crate) باینری و هم یک جعبه (crate) کتابخانه‌ای دارد نشان خواهیم داد.

شروع مسیرهای نسبی با super

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

کد موجود در لیستینگ 7-8 را در نظر بگیرید که موقعیتی را مدل‌سازی می‌کند که در آن یک آشپز سفارش نادرست را اصلاح کرده و شخصاً آن را به مشتری می‌آورد. تابع fix_incorrect_order که در ماژول back_of_house تعریف شده است، تابع deliver_order را که در ماژول والد تعریف شده است، فراخوانی می‌کند و مسیر deliver_order را با شروع از 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: عمومی کردن یک enum تمام متغیرهای آن را عمومی می‌کند

از آنجایی که 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 فقط در محدوده‌ای که در آن قرار دارد اعمال می‌شود

خطای کامپایلر نشان می‌دهد که میانبر دیگر در ماژول 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 را ترکیب کنیم. این تکنیک دوباره صادر کردن نامیده می‌شود زیرا ما یک آیتم را وارد محدوده می‌کنیم و همچنین آن را برای دیگران در دسترس قرار می‌دهیم تا وارد محدوده خودشان کنند.

لیستینگ 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() استفاده کند.

دوباره صادر کردن زمانی مفید است که ساختار داخلی کد شما با نحوه فکر کردن برنامه‌نویسانی که کد شما را فراخوانی می‌کنند در مورد دامنه متفاوت باشد. برای مثال، در این استعاره از رستوران، افرادی که رستوران را مدیریت می‌کنند در مورد “جلوی خانه” و “پشت خانه” فکر می‌کنند. اما مشتریانی که به رستوران می‌آیند احتمالاً در این قالب به بخش‌های رستوران فکر نمی‌کنند. با استفاده از 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 را در پروژه ما در دسترس قرار دهد.

سپس، برای وارد کردن تعاریف rand به محدوده بسته خود، یک خط use اضافه کردیم که با نام جعبه (crate)، rand شروع می‌شد و آیتم‌هایی را که می‌خواستیم وارد محدوده کنیم فهرست کردیم. به یاد بیاورید که در بخش «تولید یک عدد تصادفی» فصل ۲، ما ویژگی 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 وارد شود؛ در بخش «چگونه تست بنویسیم» در فصل 11 در مورد این موضوع صحبت خواهیم کرد. عملگر 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 شامل تعدادی ساختار داده بسیار مفید به نام مجموعه‌ها می‌باشد. اکثر انواع داده‌ها نماینده یک مقدار مشخص هستند، اما مجموعه‌ها می‌توانند شامل مقادیر متعددی باشند. برخلاف انواع داخلی آرایه و تاپل، داده‌ای که این مجموعه‌ها به آن اشاره می‌کنند در 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 در بخش “دنبال کردن اشاره‌گر (Pointer) به مقدار با عملگر 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 را نمی‌گیرد، س2 پس از این عملیات همچنان یک 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("hello");
    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`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`, which is required by `String: Index<_>`
  = 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<[_]>` 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 یک ورودی برای کلید نداشته باشد.

می‌توانیم بر روی هر جفت کلید–مقدار در یک هش مپ مشابه کاری که با بردارها انجام می‌دهیم، با استفاده از یک حلقه 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، دیگر نمی‌توانیم از آن‌ها استفاده کنیم.

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

به‌روزرسانی یک هش مپ

اگرچه تعداد جفت‌های کلید و مقدار قابل افزایش است، هر کلید یکتا فقط می‌تواند یک مقدار مرتبط داشته باشد (اما نه بالعکس: برای مثال، هر دو تیم Blue و Yellow می‌توانند مقدار 10 را در هش مپ scores ذخیره کنند).

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

بازنویسی یک مقدار

اگر یک کلید و مقدار را به یک هش مپ وارد کنیم و سپس همان کلید را با یک مقدار متفاوت وارد کنیم، مقداری که با آن کلید مرتبط است جایگزین خواهد شد. حتی اگر کد در لیست ۸-۲۳ دوبار insert را فراخوانی کند، هش مپ فقط یک جفت کلید–مقدار را شامل خواهد شد زیرا ما مقدار مرتبط با کلید تیم 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} را چاپ خواهد کرد. ممکن است همین جفت‌های کلید–مقدار را به ترتیب دیگری مشاهده کنید: به بخش “دسترسی به مقادیر در یک هش مپ” رجوع کنید که توضیح می‌دهد پیمایش بر روی یک هش مپ به صورت دلخواه انجام می‌شود.

متد 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 کتابخانه استاندارد متدهایی را که بردارها، رشته‌ها، و هش مپ‌ها دارند و برای این تمرین‌ها مفید خواهند بود توصیف می‌کنند!

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

مدیریت خطاها

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

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، کد کتابخانه استاندارد، یا کرایت‌هایی که استفاده می‌کنید باشند. بیایید با تنظیم متغیر محیطی 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/f6e511eec7342f59a25f7c0534f1dbea00d01b14/library/std/src/panicking.rs:662:5
   1: core::panicking::panic_fmt
             at /rustc/f6e511eec7342f59a25f7c0534f1dbea00d01b14/library/core/src/panicking.rs:74:14
   2: core::panicking::panic_bounds_check
             at /rustc/f6e511eec7342f59a25f7c0534f1dbea00d01b14/library/core/src/panicking.rs:276:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/f6e511eec7342f59a25f7c0534f1dbea00d01b14/library/core/src/slice/index.rs:302:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/f6e511eec7342f59a25f7c0534f1dbea00d01b14/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/f6e511eec7342f59a25f7c0534f1dbea00d01b14/library/alloc/src/vec/mod.rs:2920:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at /rustc/f6e511eec7342f59a25f7c0534f1dbea00d01b14/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:?}"),
            },
            other_error => {
                panic!("Problem opening the file: {other_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)

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

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

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) است که در بخش “Using Trait Objects that Allow for Values of Different Types” در فصل ۱۸ درباره آن صحبت خواهیم کرد. در حال حاضر، می‌توانید 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 دقیقاً همان چیزی است که باید اتفاق بیفتد.

مواردی که شما اطلاعات بیشتری نسبت به کامپایلر دارید

همچنین مناسب است که unwrap یا expect را فراخوانی کنید وقتی منطق دیگری دارید که تضمین می‌کند مقدار Result دارای یک مقدار Ok خواهد بود، اما این منطق چیزی نیست که کامپایلر آن را بفهمد. شما همچنان یک مقدار Result دارید که باید مدیریت کنید: عملیاتی که فراخوانی می‌کنید همچنان امکان شکست خوردن دارد، حتی اگر به صورت منطقی در وضعیت خاص شما غیرممکن باشد. اگر می‌توانید با بازرسی دستی کد تضمین کنید که هرگز یک حالت Err نخواهید داشت، کاملاً قابل قبول است که unwrap را فراخوانی کنید و حتی بهتر است که دلیل خود را در متن 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! شود. در این زمینه، یک وضعیت نامناسب زمانی رخ می‌دهد که برخی فرضیات، تضمین‌ها، قراردادها، یا تغییرناپذیری‌ها شکسته شوند، مانند زمانی که مقادیر نامعتبر، مقادیر متناقض، یا مقادیر گمشده به کد شما پاس داده می‌شوند—به علاوه یکی یا بیشتر از شرایط زیر:

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

اگر کسی کد شما را فراخوانی کند و مقادیری که منطقی نیستند را پاس دهد، بهتر است که یک خطا بازگردانید تا کاربر کتابخانه بتواند تصمیم بگیرد که در آن مورد چه کاری انجام دهد. با این حال، در مواردی که ادامه دادن می‌تواند ناامن یا مضر باشد، بهترین انتخاب ممکن است فراخوانی 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 بین ۱ و ۱۰۰ است.

با این حال، این یک راه‌حل ایده‌آل نیست: اگر بسیار حیاتی باشد که برنامه فقط بر روی مقادیر بین ۱ و ۱۰۰ عمل کند، و برنامه توابع زیادی با این نیاز داشته باشد، داشتن چنین بررسی‌هایی در هر تابع خسته‌کننده خواهد بود (و ممکن است عملکرد را تحت تأثیر قرار دهد).

در عوض، می‌توانیم یک نوع جدید ایجاد کنیم و اعتبارسنجی‌ها را در یک تابع برای ایجاد یک نمونه از نوع جدید قرار دهیم به جای تکرار اعتبارسنجی‌ها در همه‌جا. به این ترتیب، استفاده از نوع جدید در امضاهای توابع ایمن است و می‌توان با اطمینان از مقادیری که دریافت می‌کنند استفاده کرد. لیست ۹-۱۳ یک روش برای تعریف یک نوع Guess را نشان می‌دهد که فقط یک نمونه از Guess ایجاد می‌کند اگر تابع new مقداری بین ۱ و ۱۰۰ دریافت کند.

#![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 که فقط با مقادیر بین ۱ و ۱۰۰ ادامه می‌دهد

ابتدا یک ساختار داده به نام 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 ساختار داده Guess خصوصی است. مهم است که فیلد value خصوصی باشد تا کدی که از ساختار Guess استفاده می‌کند مجاز نباشد مقدار value را مستقیماً تنظیم کند: کدی که خارج از ماژول است باید از تابع 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 را در یک بخش پیدا می‌کند. بدنه توابع دارای کد یکسانی هستند، بنابراین با معرفی یک پارامتر نوع جنریک در یک تابع واحد، تکرار را حذف می‌کنیم.

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

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

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

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

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

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

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

    largest
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    let p3 = p1.mixup(p2);

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

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

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

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

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

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

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

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

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

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

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

enum Option_f64 {
    Some(f64),
    None,
}

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

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

ویژگی‌ها (Traits): تعریف رفتار مشترک

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

توجه: ویژگی‌ها شبیه به مفهومی هستند که اغلب در زبان‌های دیگر به نام interfaces شناخته می‌شود، البته با برخی تفاوت‌ها.

تعریف یک trait

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

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

ما می‌خواهیم یک کتابخانه گردآورنده رسانه به نام aggregator ایجاد کنیم که بتواند خلاصه‌هایی از داده‌هایی که ممکن است در یک نمونه از NewsArticle یا Tweet ذخیره شده باشند، نمایش دهد. برای این کار، نیاز به خلاصه‌ای از هر نوع داریم و این خلاصه را با فراخوانی متد summarize روی یک نمونه درخواست خواهیم کرد. لیست ۱۰-۱۲ تعریف یک ویژگی عمومی 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) روی یک نوع

اکنون که امضاهای مورد نظر متدهای ویژگی Summary را تعریف کرده‌ایم، می‌توانیم آن را روی نوع‌های موجود در گردآورنده رسانه خود پیاده‌سازی کنیم. لیست ۱۰-۱۳ یک پیاده‌سازی از ویژگی Summary روی ساختار NewsArticle را نشان می‌دهد که از تیتر، نویسنده، و مکان برای ایجاد مقدار بازگشتی summarize استفاده می‌کند. برای ساختار Tweet، متد 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 Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-13: پیاده‌سازی ویژگی Summary روی نوع‌های NewsArticle و Tweet

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

حالا که کتابخانه ویژگی Summary را روی NewsArticle و Tweet پیاده‌سازی کرده است، کاربران این کرایت می‌توانند متدهای ویژگی را روی نمونه‌های NewsArticle و Tweet فراخوانی کنند، به همان روشی که متدهای معمولی را فراخوانی می‌کنیم. تنها تفاوت این است که کاربر باید ویژگی را به همراه نوع‌ها به محدوده وارد کند. در اینجا مثالی از اینکه چگونه یک کرایت باینری می‌تواند از کرایت کتابخانه aggregator ما استفاده کند آورده شده است:

use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

این کد 1 new tweet: horse_ebooks: of course, as you probably already know, people را چاپ می‌کند.

کرایت‌های دیگری که به کرایت aggregator وابسته هستند نیز می‌توانند ویژگی Summary را به محدوده وارد کنند تا Summary را روی نوع‌های خودشان پیاده‌سازی کنند. یکی از محدودیت‌هایی که باید به آن توجه داشت این است که ما فقط می‌توانیم یک ویژگی را روی یک نوع پیاده‌سازی کنیم اگر یا ویژگی یا نوع، یا هر دو، به کرایت ما محلی باشند. برای مثال، ما می‌توانیم ویژگی‌هایی از کتابخانه استاندارد مانند Display را روی یک نوع سفارشی مانند Tweet به عنوان بخشی از عملکرد کرایت aggregator پیاده‌سازی کنیم زیرا نوع Tweet به کرایت aggregator محلی است. همچنین می‌توانیم Summary را روی Vec<T> در کرایت aggregator پیاده‌سازی کنیم زیرا ویژگی Summary به کرایت aggregator محلی است.

اما نمی‌توانیم ویژگی‌های خارجی را روی نوع‌های خارجی پیاده‌سازی کنیم. برای مثال، نمی‌توانیم ویژگی 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 Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    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...) را چاپ می‌کند.

ایجاد یک پیاده‌سازی پیش‌فرض نیازی به تغییر چیزی در پیاده‌سازی ویژگی Summary روی Tweet در لیست ۱۰-۱۳ ندارد. دلیل آن این است که نحو برای بازنویسی یک پیاده‌سازی پیش‌فرض همانند نحو برای پیاده‌سازی یک متد ویژگی است که پیاده‌سازی پیش‌فرض ندارد.

پیاده‌سازی‌های پیش‌فرض می‌توانند متدهای دیگر را در همان ویژگی فراخوانی کنند، حتی اگر آن متدهای دیگر پیاده‌سازی پیش‌فرض نداشته باشند. به این روش، یک ویژگی می‌تواند مقدار زیادی عملکرد مفید ارائه دهد و فقط از پیاده‌سازان بخواهد که بخشی از آن را مشخص کنند. برای مثال، می‌توانیم ویژگی 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 Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    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 Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

بعد از اینکه summarize_author را تعریف کردیم، می‌توانیم متد summarize را روی نمونه‌های ساختار Tweet فراخوانی کنیم، و پیاده‌سازی پیش‌فرض summarize، تعریف متد summarize_author که ارائه داده‌ایم را فراخوانی خواهد کرد. از آنجا که ما summarize_author را پیاده‌سازی کرده‌ایم، ویژگی Summary رفتار متد summarize را بدون نیاز به نوشتن کد اضافی به ما داده است. به این شکل عمل می‌کند:

use aggregator::{self, Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

این کد 1 new tweet: (Read more from @horse_ebooks...) را چاپ می‌کند.

توجه داشته باشید که امکان فراخوانی پیاده‌سازی پیش‌فرض از یک پیاده‌سازی بازنویسی شده از همان متد وجود ندارد.

ویژگی‌ها (traits) به عنوان پارامترها

اکنون که می‌دانید چگونه ویژگی‌ها را تعریف و پیاده‌سازی کنید، می‌توانیم بررسی کنیم که چگونه از ویژگی‌ها برای تعریف توابعی که انواع مختلفی را می‌پذیرند استفاده کنیم. ما از ویژگی Summary که روی نوع‌های NewsArticle و Tweet در لیست ۱۰-۱۳ پیاده‌سازی کردیم استفاده خواهیم کرد تا تابعی به نام notify تعریف کنیم که متد summarize را روی پارامتر item خود فراخوانی می‌کند، که از نوعی است که ویژگی 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 Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

به جای یک نوع مشخص برای پارامتر item، کلمه کلیدی impl و نام ویژگی را مشخص می‌کنیم. این پارامتر هر نوعی را که ویژگی مشخص‌شده را پیاده‌سازی می‌کند می‌پذیرد. در بدنه notify، می‌توانیم هر متدی روی item که از ویژگی Summary آمده باشد، مانند summarize را فراخوانی کنیم. می‌توانیم notify را فراخوانی کرده و هر نمونه‌ای از NewsArticle یا Tweet را به آن پاس دهیم. کدی که تابع را با هر نوع دیگری، مانند یک String یا یک i32 فراخوانی کند، کامپایل نمی‌شود زیرا آن نوع‌ها ویژگی 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 Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

با استفاده از impl Summary برای نوع بازگشتی، مشخص می‌کنیم که تابع returns_summarizable مقداری از نوعی که ویژگی Summary را پیاده‌سازی می‌کند بازمی‌گرداند، بدون نیاز به نام بردن از نوع مشخص. در این مورد، returns_summarizable یک Tweet بازمی‌گرداند، اما کدی که این تابع را فراخوانی می‌کند نیازی به دانستن این موضوع ندارد.

توانایی مشخص کردن یک نوع بازگشتی تنها بر اساس ویژگی‌ای که پیاده‌سازی می‌کند، به ویژه در زمینه closures و iterators مفید است، که در فصل ۱۳ به آن‌ها می‌پردازیم. closures و iterators نوع‌هایی ایجاد می‌کنند که تنها کامپایلر آن‌ها را می‌شناسد یا نوع‌هایی که بسیار طولانی هستند تا مشخص شوند. نحو impl Trait به شما اجازه می‌دهد که به طور مختصر مشخص کنید یک تابع نوعی که ویژگی Iterator را پیاده‌سازی می‌کند بازمی‌گرداند، بدون نیاز به نوشتن یک نوع بسیار طولانی.

با این حال، فقط زمانی می‌توانید از impl Trait استفاده کنید که یک نوع بازگردانده شود. برای مثال، این کد که یا یک NewsArticle یا یک Tweet بازمی‌گرداند و نوع بازگشتی به عنوان 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 Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    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 {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

بازگرداندن یا یک NewsArticle یا یک Tweet مجاز نیست به دلیل محدودیت‌هایی در نحوه پیاده‌سازی نحو impl Trait در کامپایلر. ما نحوه نوشتن یک تابع با این رفتار را در بخش “استفاده از اشیاء ویژگی که مقادیر از نوع‌های مختلف را مجاز می‌سازد” در فصل ۱۸ بررسی خواهیم کرد.

استفاده از محدودیت‌های ویژگی برای پیاده‌سازی شرطی متدها

با استفاده از یک محدودیت ویژگی در یک بلوک 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 دارای یک طول عمر است، که محدوده‌ای است که آن مرجع در آن معتبر است. بیشتر اوقات، طول عمرها ضمنی و استنتاج‌شده هستند، دقیقاً مانند انواع. ما فقط زمانی نیاز داریم که نوع‌ها را حاشیه‌نویسی کنیم که چندین نوع ممکن باشند. به طور مشابه، ما فقط زمانی نیاز داریم که طول عمرها را حاشیه‌نویسی کنیم که طول عمر مراجع بتوانند به چند روش مختلف مرتبط باشند. Rust ما را ملزم می‌کند تا روابط را با استفاده از پارامترهای جنریک طول عمر حاشیه‌نویسی کنیم تا اطمینان حاصل کنیم که مراجع واقعی استفاده‌شده در زمان اجرا قطعاً معتبر خواهند بود.

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

جلوگیری از مراجع آویزان (Dangling References) با طول عمرها

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

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}
Listing 10-16: تلاشی برای استفاده از مرجعی که مقدار آن از محدوده خارج شده است

توجه: مثال‌های لیست ۱۰-۱۶، ۱۰-۱۷، و ۱۰-۲۳ متغیرهایی را بدون مقدار اولیه اعلام می‌کنند، بنابراین نام متغیر در محدوده خارجی وجود دارد. در نگاه اول، این ممکن است در تضاد با عدم وجود مقادیر 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 معتبر است، معتبر خواهد بود.

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

طول عمرهای جنریک در توابع

ما تابعی خواهیم نوشت که طولانی‌ترین قطعه رشته (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 را فراخوانی می‌کند تا طولانی‌ترین قطعه رشته را پیدا کند

توجه داشته باشید که می‌خواهیم تابع قطعه رشته‌ها، که مراجع هستند، بگیرد نه رشته‌ها، زیرا نمی‌خواهیم تابع longest مالکیت پارامترهای خود را بگیرد. برای بحث بیشتر درباره اینکه چرا پارامترهایی که در لیست ۱۰-۱۹ استفاده می‌کنیم همان‌هایی هستند که می‌خواهیم، به بخش “قطعه رشته‌ها به عنوان پارامترها” در فصل ۴ مراجعه کنید.

اگر سعی کنیم تابع 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) نامیده می‌شوند. این‌ها قوانینی نیستند که برنامه‌نویسان باید رعایت کنند؛ بلکه مجموعه‌ای از موارد خاص هستند که کامپایلر آن‌ها را در نظر می‌گیرد و اگر کد شما با این موارد مطابقت داشته باشد، نیازی به نوشتن طول عمرها به صورت صریح نخواهید داشت.

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

طول عمرهای روی پارامترهای تابع یا متد طول عمر ورودی (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 نیستند. پس از عبور از تمام سه قانون، هنوز طول عمر نوع بازگشتی را تعیین نکرده‌ایم. به همین دلیل است که هنگام تلاش برای کامپایل کد در لیست ۱۰-۲۰ خطا گرفتیم: کامپایلر قوانین حذف طول عمر را مرور کرد اما همچنان نتوانست تمام طول عمرهای مراجع در امضا را تعیین کند.

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

حاشیه‌نویسی طول عمر در تعریف متدها

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

نام‌های طول عمر برای فیلدهای ساختار همیشه باید بعد از کلمه کلیدی 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 بنویسید تا مطمئن شوید کد شما همانطور که باید کار می‌کند.

نوشتن تست‌های خودکار

در مقاله‌ای در سال ۱۹۷۲ به نام “The Humble Programmer”، Edsger W. Dijkstra گفت:
«آزمایش برنامه می‌تواند راهی بسیار مؤثر برای نشان دادن وجود باگ‌ها باشد، اما برای نشان دادن عدم وجود آن‌ها کاملاً ناکافی است.»
این به این معنی نیست که نباید تلاش کنیم تا جایی که ممکن است آزمایش کنیم!

درستی در برنامه‌های ما میزان انطباق کد ما با آنچه که قصد انجامش را داریم، است. 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 (file:///projects/adder/target/debug/deps/adder-7acb243c25ffd9dc)

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 را می‌خواند تعداد تست‌هایی که پاس شده‌اند یا ناموفق بوده‌اند را نشان می‌دهد.

این امکان وجود دارد که یک تست را به عنوان نادیده‌گرفته‌شده علامت‌گذاری کنیم تا در یک نمونه خاص اجرا نشود؛ ما این مورد را در بخش “نادیده‌گرفتن برخی تست‌ها مگر اینکه صریحاً درخواست شوند” در ادامه این فصل پوشش خواهیم داد. چون اینجا این کار را انجام نداده‌ایم، خلاصه 0 ignored را نشان می‌دهد.

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

ما می‌توانیم یک آرگومان به فرمان cargo test بدهیم تا فقط تست‌هایی که نام آن‌ها با یک رشته مطابقت دارد اجرا شوند؛ این به فیلتر کردن معروف است و ما آن را در بخش “اجرای زیرمجموعه‌ای از تست‌ها با نام” پوشش خواهیم داد. اینجا ما تست‌های در حال اجرا را فیلتر نکرده‌ایم، بنابراین پایان خلاصه 0 filtered out را نشان می‌دهد.

قسمت بعدی خروجی تست که با 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. دو بخش جدید بین نتایج فردی و خلاصه ظاهر می‌شود: بخش اول دلیل دقیق شکست هر تست را نشان می‌دهد. در این مورد، ما جزئیات را دریافت می‌کنیم که another به دلیل panicked at 'Make this test fail' در خط ۱۷ فایل src/lib.rs شکست خورده است. بخش بعدی فقط نام تمام تست‌های شکست‌خورده را لیست می‌کند، که وقتی تعداد زیادی تست و خروجی‌های شکست‌خورده زیاد هستند مفید است. ما می‌توانیم نام یک تست شکست‌خورده را برای اجرای فقط همان تست استفاده کنیم تا راحت‌تر آن را اشکال‌زدایی کنیم؛ ما در بخش “کنترل نحوه اجرای تست‌ها” بیشتر در مورد روش‌های اجرای تست‌ها صحبت خواهیم کرد.

خط خلاصه در انتها نمایش داده می‌شود: به طور کلی، نتیجه تست ما FAILED است. یک تست موفق شد و یک تست شکست خورد.

حالا که دیدید نتایج تست در سناریوهای مختلف چگونه به نظر می‌رسند، بیایید به برخی از ماکروهای دیگر به جز 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: usize) -> usize {
    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 نشان می‌دهد که تست ما پاس شده است!

بیایید یک باگ به کد خود وارد کنیم تا ببینیم ماکروی assert_eq! وقتی شکست می‌خورد چگونه به نظر می‌رسد. پیاده‌سازی تابع add_two را تغییر می‌دهیم تا به جای ۲ مقدار ۳ را اضافه کند:

pub fn add_two(a: usize) -> usize {
    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`

تست ما باگ را پیدا کرد! تست it_adds_two شکست خورد، و پیام به ما می‌گوید assertion `left == right` failed و مقادیر left و right چیستند. این پیام به ما کمک می‌کند اشکال‌زدایی را شروع کنیم: آرگومان left، جایی که نتیجه فراخوانی add_two(2) را داشتیم، مقدار 5 بود، اما آرگومان right مقدار 4 بود. می‌توانید تصور کنید که این موضوع وقتی تعداد زیادی تست داشته باشیم بسیار مفید خواهد بود.

توجه داشته باشید که در برخی زبان‌ها و چارچوب‌های تست، پارامترهای توابع بررسی برابری expected و actual نامیده می‌شوند و ترتیب مشخص کردن آرگومان‌ها مهم است. اما در Rust، آن‌ها left و right نامیده می‌شوند، و ترتیب مشخص کردن مقداری که انتظار داریم و مقداری که کد تولید می‌کند مهم نیست. می‌توانیم ادعا را در این تست به صورت assert_eq!(4, result) بنویسیم، که همان پیام شکست را که assertion failed: `(left == right)` نمایش می‌دهد، تولید می‌کند.

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

در پس‌زمینه، ماکروهای assert_eq! و assert_ne! به ترتیب از عملگرهای == و != استفاده می‌کنند. وقتی ادعا شکست می‌خورد، این ماکروها آرگومان‌های خود را با استفاده از قالب‌بندی دیباگ چاپ می‌کنند، که به این معنی است که مقادیر مقایسه‌شده باید ویژگی‌های PartialEq و Debug را پیاده‌سازی کنند. تمام نوع‌های اولیه و بیشتر نوع‌های کتابخانه استاندارد این ویژگی‌ها را پیاده‌سازی می‌کنند. برای ساختارها و انوم‌هایی که خودتان تعریف می‌کنید، باید PartialEq را برای تأیید برابری این نوع‌ها پیاده‌سازی کنید. همچنین باید Debug را برای چاپ مقادیر زمانی که ادعا شکست می‌خورد پیاده‌سازی کنید. از آنجا که هر دو ویژگی قابل اشتقاق هستند، همانطور که در لیست ۵-۱۲ فصل ۵ اشاره شد، این معمولاً به سادگی افزودن حاشیه‌نویسی #[derive(PartialEq, Debug)] به تعریف ساختار یا انوم شما است. برای جزئیات بیشتر در مورد این ویژگی‌ها و سایر ویژگی‌های قابل اشتقاق، به ضمیمه ج، “ویژگی‌های قابل اشتقاق” مراجعه کنید.

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

همچنین می‌توانید یک پیام سفارشی برای چاپ همراه با پیام شکست به عنوان آرگومان‌های اختیاری به ماکروهای assert!، assert_eq! و assert_ne! اضافه کنید. هر آرگومانی که بعد از آرگومان‌های اجباری مشخص شده باشد به ماکروی format! (که در فصل ۸ در بخش “ادغام با عملگر + یا ماکروی format! بحث شد) پاس داده می‌شود، بنابراین می‌توانید یک رشته قالب که شامل نگهدارنده‌های {} است و مقادیری که در آن نگهدارنده‌ها قرار می‌گیرند را پاس دهید. پیام‌های سفارشی برای مستندسازی معنای یک ادعا مفید هستند؛ وقتی یک تست شکست می‌خورد، ایده بهتری از مشکل کد خواهید داشت.

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

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: usize) -> usize {
    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 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: usize) -> usize {
    internal_adder(a, 2)
}

fn internal_adder(left: usize, right: usize) -> usize {
    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 فقط یک ماژول دیگر است. همانطور که در بخش “مسیرها برای اشاره به یک مورد در درخت ماژول” بحث شد، آیتم‌های موجود در ماژول‌های فرزند می‌توانند از آیتم‌های موجود در ماژول‌های والد خود استفاده کنند. در این تست، تمام آیتم‌های والد ماژول tests را با use super::* به دامنه وارد می‌کنیم، و سپس تست می‌تواند 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 را به عنوان یک فایل تست یکپارچه در نظر نگیرد. وقتی کد تابع 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 چندین مسئولیت دارد: به طور کلی، توابع واضح‌تر و آسان‌تر برای نگهداری هستند اگر هر تابع فقط مسئول یک ایده باشد. مشکل دیگر این است که ما خطاها را به خوبی مدیریت نمی‌کنیم. برنامه هنوز کوچک است، بنابراین این مشکلات مشکل بزرگی نیستند، اما با رشد برنامه، رفع آن‌ها به صورت تمیز سخت‌تر خواهد شد. بهتر است که زودتر در فرایند توسعه برنامه شروع به بازسازی کنیم، زیرا بازسازی کدهای کمتر بسیار آسان‌تر است. در مرحله بعد این کار را انجام خواهیم داد.

بازسازی برای بهبود ماژولار بودن و مدیریت خطاها

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

  • تک‌مسئولیتی کردن تابع main: در حال حاضر، تابع main دو وظیفه را انجام می‌دهد: تجزیه آرگومان‌ها و خواندن فایل‌ها. با رشد برنامه، تعداد وظایف جداگانه‌ای که تابع main باید مدیریت کند افزایش خواهد یافت. هرچه یک تابع مسئولیت‌های بیشتری داشته باشد، درک آن سخت‌تر می‌شود، تست کردن آن پیچیده‌تر خواهد شد و تغییر آن بدون آسیب به بخش‌های دیگر دشوارتر می‌شود. بهتر است قابلیت‌ها را جدا کنیم تا هر تابع فقط مسئول یک وظیفه باشد.
  • گروه‌بندی متغیرهای پیکربندی: متغیرهایی مانند query و file_path متغیرهای پیکربندی برای برنامه ما هستند، در حالی که متغیرهایی مانند contents برای اجرای منطق برنامه استفاده می‌شوند. هرچه تابع main طولانی‌تر شود، به متغیرهای بیشتری نیاز خواهد داشت که وارد دامنه شوند؛ و هرچه تعداد متغیرها بیشتر شود، پیگیری هدف هر متغیر دشوارتر خواهد شد. بهتر است متغیرهای پیکربندی را در یک ساختار گروه‌بندی کنیم تا هدف آن‌ها واضح‌تر باشد.
  • بهبود پیام‌های خطا: هنگام شکست در خواندن فایل، از expect برای چاپ پیام خطا استفاده کرده‌ایم، اما پیام خطا فقط Should have been able to read the file را چاپ می‌کند. خواندن یک فایل می‌تواند به دلایل مختلفی شکست بخورد: مثلاً ممکن است فایل وجود نداشته باشد یا ممکن است اجازه دسترسی به آن را نداشته باشیم. در حال حاضر، بدون توجه به شرایط، همان پیام خطا برای همه چیز چاپ می‌شود که اطلاعاتی به کاربر نمی‌دهد.
  • یکپارچه‌سازی مدیریت خطاها: اگر کاربر برنامه ما را بدون مشخص کردن تعداد کافی آرگومان اجرا کند، یک خطای index out of bounds از Rust دریافت می‌کنند که به وضوح مشکل را توضیح نمی‌دهد. بهتر است تمام کد مدیریت خطاها در یک مکان قرار گیرد تا نگهداری‌کنندگان آینده تنها یک مکان را برای بررسی تغییرات در منطق مدیریت خطا داشته باشند. این کار همچنین اطمینان حاصل می‌کند که پیام‌هایی که چاپ می‌شوند برای کاربران نهایی معنادار هستند.

جداسازی وظایف برای پروژه‌های دودویی

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

  • برنامه خود را به فایل‌های _main.rs_ و _lib.rs_ تقسیم کرده و منطق برنامه را به _lib.rs_ منتقل کنید.
  • تا زمانی که منطق تجزیه آرگومان‌های خط فرمان کوچک است، می‌تواند در _main.rs_ باقی بماند.
  • وقتی منطق تجزیه آرگومان‌ها پیچیده شد، آن را از _main.rs_ جدا کرده و به _lib.rs_ منتقل کنید.

وظایفی که پس از این فرایند در تابع main باقی می‌مانند باید محدود به موارد زیر باشند:

  • فراخوانی منطق تجزیه آرگومان‌های خط فرمان با مقادیر آرگومان‌ها
  • تنظیم هرگونه پیکربندی دیگر
  • فراخوانی یک تابع `run` در _lib.rs_
  • مدیریت خطاها در صورت بازگرداندن خطا توسط `run`

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

استخراج تجزیه‌کننده آرگومان‌ها

ما قابلیت تجزیه آرگومان‌ها را به یک تابع جداگانه استخراج می‌کنیم که تابع main آن را فراخوانی خواهد کرد تا برای انتقال منطق تجزیه آرگومان خط فرمان به فایل src/lib.rs آماده شویم. لیست ۱۲-۵ شروع جدید تابع main را نشان می‌دهد که یک تابع جدید به نام parse_config را فراخوانی می‌کند، که در حال حاضر در src/main.rs تعریف خواهیم کرد.

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

عالی! این خروجی برای کاربران ما بسیار دوستانه‌تر است.

جداسازی منطق از main

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

لیست ۱۲-۱۱ تابع استخراج‌شده run را نشان می‌دهد. فعلاً فقط بهبود کوچکی انجام می‌دهیم که تابع را استخراج کنیم. همچنان تابع را در فایل 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 در هر دو حالت یکسان هستند: ما خطا را چاپ کرده و خارج می‌شویم.

تقسیم کد به یک کتابخانه

پروژه minigrep ما تا اینجا خوب پیش می‌رود! اکنون کد فایل src/main.rs را تقسیم کرده و برخی از کد را به فایل src/lib.rs منتقل می‌کنیم. به این ترتیب، می‌توانیم کد را تست کنیم و فایل src/main.rs مسئولیت‌های کمتری داشته باشد.

بیایید تمام کدی که در تابع main نیست از src/main.rs به src/lib.rs منتقل کنیم:

  • تعریف تابع `run`
  • دستورات `use` مرتبط
  • تعریف `Config`
  • تعریف تابع `Config::build`

محتویات فایل src/lib.rs باید امضاهایی که در لیست ۱۲-۱۳ آمده است را داشته باشد (بدنه توابع برای اختصار حذف شده است). توجه داشته باشید که این کد تا زمانی که src/main.rs را همانطور که در لیست ۱۲-۱۴ نشان داده شده است تغییر ندهیم کامپایل نمی‌شود.

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> {
        // --snip--
        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>> {
    // --snip--
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}
Listing 12-13: انتقال Config و run به src/lib.rs

ما به طور گسترده از کلمه کلیدی pub استفاده کرده‌ایم: در Config، فیلدهای آن، متد build و همچنین تابع run. اکنون یک crate کتابخانه‌ای داریم که یک API عمومی دارد و می‌توانیم آن را تست کنیم!

حالا باید کدی که به src/lib.rs منتقل کرده‌ایم را به محدوده crate باینری در src/main.rs بیاوریم، همانطور که در لیست ۱۲-۱۴ نشان داده شده است.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

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) = minigrep::run(config) {
        // --snip--
        println!("Application error: {e}");
        process::exit(1);
    }
}
Listing 12-14: استفاده از crate کتابخانه‌ای minigrep در src/main.rs

ما خط use minigrep::Config را اضافه کرده‌ایم تا نوع Config را از crate کتابخانه‌ای به محدوده crate باینری بیاوریم، و تابع run را با پیشوند نام crate فراخوانی کرده‌ایم. اکنون همه قابلیت‌ها باید متصل شوند و کار کنند. برنامه را با cargo run اجرا کنید و مطمئن شوید که همه چیز به درستی کار می‌کند.

وای! این یک کار سخت بود، اما ما خودمان را برای موفقیت در آینده آماده کردیم. اکنون مدیریت خطاها بسیار آسان‌تر شده است و کد ما ماژولارتر شده است. از اینجا به بعد تقریباً تمام کارهای ما در فایل src/lib.rs انجام خواهد شد.

بیایید از این ماژولاریت جدید برای انجام کاری استفاده کنیم که با کد قبلی دشوار بود اما با کد جدید آسان است: نوشتن چند تست!

توسعه قابلیت‌های کتابخانه با توسعه آزمون‌محور (TDD) یا همان (Test-Driven Development)

اکنون که منطق را به src/lib.rs استخراج کرده‌ایم و جمع‌آوری آرگومان‌ها و مدیریت خطاها را در src/main.rs باقی گذاشته‌ایم، نوشتن تست برای قابلیت‌های اصلی کد ما بسیار آسان‌تر شده است. می‌توانیم مستقیماً توابع را با آرگومان‌های مختلف فراخوانی کرده و مقادیر بازگشتی را بررسی کنیم، بدون اینکه نیاز باشد از باینری ما از خط فرمان استفاده کنیم.

در این بخش، منطق جستجو را با استفاده از فرآیند توسعه آزمون‌محور (TDD) به برنامه minigrep اضافه خواهیم کرد. مراحل این فرآیند به شرح زیر است:

  1. نوشتن یک تست که شکست می‌خورد و اجرای آن برای اطمینان از اینکه به دلیلی که انتظار داشتید شکست می‌خورد.
  2. نوشتن یا تغییر کد به اندازه‌ای که تست جدید پاس شود.
  3. بازسازی کدی که به تازگی اضافه یا تغییر داده شده و اطمینان از اینکه تست‌ها همچنان پاس می‌شوند.
  4. تکرار از مرحله ۱!

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

ما با استفاده از TDD پیاده‌سازی قابلیت جستجوی رشته کوئری در محتوای فایل و تولید لیستی از خطوط مطابق با کوئری را توسعه خواهیم داد. این قابلیت را در تابعی به نام search اضافه خواهیم کرد.

نوشتن یک تست که شکست می‌خورد

از آنجا که دیگر به آن‌ها نیاز نداریم، بیایید عبارت‌های println! را از src/lib.rs و src/main.rs که برای بررسی رفتار برنامه استفاده می‌کردیم حذف کنیم. سپس، در src/lib.rs، یک ماژول tests با یک تابع تست اضافه خواهیم کرد، همانطور که در فصل ۱۱ انجام دادیم. تابع تست، رفتاری که می‌خواهیم تابع 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)?;

    Ok(())
}

#[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
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)?;

    Ok(())
}

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 تا تست ما کامپایل شود

متوجه می‌شوید که ما نیاز داریم یک طول عمر صریح '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:28:51
   |
28 | 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
   |
28 | 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 به هیچ وجه نمی‌تواند بداند کدام یک از دو آرگومان مورد نیاز است، بنابراین ما باید به صورت صریح به آن بگوییم. از آنجایی که contents آرگومانی است که شامل تمام متن ما است و ما می‌خواهیم قسمت‌هایی از آن متن که مطابقت دارند را بازگردانیم، می‌دانیم که contents آرگومانی است که باید با استفاده از نحو طول عمر به مقدار بازگشتی متصل شود.

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

اکنون بیایید تست را اجرا کنیم:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----
thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

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`

عالی است، تست دقیقا همانطور که انتظار داشتیم شکست می‌خورد. بیایید تست را پاس کنیم!

نوشتن کدی برای پاس کردن تست

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

  1. تکرار از طریق هر خط از محتوای فایل.
  2. بررسی اینکه آیا خط شامل رشته کوئری ما هست یا نه.
  3. اگر خط شامل کوئری بود، آن را به لیست مقادیر بازگشتی اضافه کنیم.
  4. اگر نبود، کاری انجام ندهیم.
  5. لیست نتایجی که مطابقت دارند را بازگردانیم.

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

تکرار از طریق خطوط با متد lines

Rust یک متد مفید برای مدیریت تکرار خط به خط در رشته‌ها ارائه می‌دهد که به طور مناسبی lines نامیده شده است و همانطور که در لیست ۱۲-۱۷ نشان داده شده کار می‌کند. توجه داشته باشید که این کد هنوز کامپایل نخواهد شد.

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

    Ok(())
}

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

    Ok(())
}

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

    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));
    }
}
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 اضافه می‌کنیم که زمانی که متغیر محیطی دارای مقدار باشد، فراخوانی خواهد شد. ما همچنان از فرآیند TDD پیروی می‌کنیم، بنابراین اولین گام، نوشتن یک تست شکست‌خورده است. یک تست جدید برای تابع search_case_insensitive اضافه می‌کنیم و تست قدیمی خود را از one_result به case_sensitive تغییر نام می‌دهیم تا تفاوت بین این دو تست مشخص شود، همان‌طور که در لیستینگ 12-20 نشان داده شده است.

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

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 را کوچک‌حرف می‌کنیم و آن را در یک متغیر جدید با همان نام ذخیره می‌کنیم، جایگزین متغیر اصلی می‌شود. فراخوانی to_lowercase بر روی عبارت جستجو ضروری است تا صرف‌نظر از اینکه عبارت جستجو "rust"، "RUST"، "Rust" یا "rUsT" باشد، به گونه‌ای عمل کنیم که انگار عبارت جستجو "rust" است و به حروف کوچک و بزرگ حساس نباشد. در حالی که to_lowercase یونیکد پایه‌ای را مدیریت می‌کند، اما 100٪ دقیق نخواهد بود. اگر ما یک برنامه واقعی می‌نوشتیم، می‌خواستیم در اینجا کمی بیشتر کار کنیم، اما این بخش درباره متغیرهای محیطی است، نه یونیکد، بنابراین در اینجا به همین میزان بسنده می‌کنیم.

توجه کنید که اکنون 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/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        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
}

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

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/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        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
}

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-22: Calling either search or search_case_insensitive based on the value in config.ignore_case

توابع مربوط به کار با متغیرهای محیطی در ماژول env در کتابخانه استاندارد قرار دارند. بنابراین در بالای فایل src/lib.rs این ماژول را وارد محدوده (scope) می‌کنیم. سپس از تابع var از ماژول env استفاده خواهیم کرد تا بررسی کنیم آیا مقدار خاصی برای یک متغیر محیطی به نام IGNORE_CASE تنظیم شده است یا خیر، همان‌طور که در لیستینگ 12-23 نشان داده شده است.

Filename: src/lib.rs
use std::env;
// --snip--

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub 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(())
}

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-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::process;

use minigrep::Config;

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) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
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ها_، یک ساختار شبیه به تابع که می‌توان آن را در یک متغیر ذخیره کرد.
  • _تکرارگرها_، روشی برای پردازش یک سری عناصر.
  • نحوه استفاده از closureها و تکرارگرها برای بهبود پروژه I/O در فصل 12.
  • عملکرد closureها و تکرارگرها (هشدار: آن‌ها سریع‌تر از چیزی هستند که ممکن است تصور کنید!)

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

closureها: توابع ناشناسی که محیط خود را می‌گیرند

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

گرفتن محیط با closureها

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

راه‌های زیادی برای پیاده‌سازی این سناریو وجود دارد. در این مثال، ما از یک enum به نام ShirtColor استفاده می‌کنیم که شامل مقادیر Red و Blue است (برای سادگی تعداد رنگ‌های موجود را محدود کرده‌ایم). موجودی شرکت را با یک ساختار 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
     Locking 1 package to latest compatible version
      Adding closure-example v0.1.0 (/Users/chris/dev/rust-lang/book/tmp/listings/ch13-functional-features/listing-13-04)
   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
     Locking 1 package to latest compatible version
      Adding closure-example v0.1.0 (/Users/chris/dev/rust-lang/book/tmp/listings/ch13-functional-features/listing-13-05)
   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 برای نخ

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

نخ جدید ممکن است قبل از تکمیل نخ اصلی تمام شود، یا نخ اصلی ممکن است زودتر تمام شود. اگر نخ اصلی مالکیت list را حفظ می‌کرد اما قبل از نخ جدید به پایان می‌رسید و list را حذف می‌کرد، ارجاع غیرقابل تغییر در نخ دیگر معتبر نبود. بنابراین، کامپایلر نیاز دارد که list به داخل closure داده‌شده به نخ جدید منتقل شود تا ارجاع معتبر باقی بماند. سعی کنید کلمه کلیدی move را حذف کنید یا از list در نخ اصلی پس از تعریف closure استفاده کنید تا ببینید چه خطاهای کامپایلری دریافت می‌کنید!

انتقال مقادیر گرفته‌شده به خارج از closureها و صفات Fn

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

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

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

  1. FnOnce: برای closureهایی که می‌توانند فقط یک بار فراخوانی شوند اعمال می‌شود. همه closureها حداقل این صفت را پیاده‌سازی می‌کنند، زیرا همه closureها قابل فراخوانی هستند. closureی که مقادیر گرفته‌شده را از بدنه خود انتقال می‌دهد فقط صفت FnOnce را پیاده‌سازی می‌کند و هیچ‌یک از دیگر صفات Fn را پیاده‌سازی نمی‌کند، زیرا فقط یک بار قابل فراخوانی است.
  2. FnMut: برای closureهایی که مقادیر گرفته‌شده را از بدنه خود انتقال نمی‌دهند اما ممکن است مقادیر گرفته‌شده را تغییر دهند اعمال می‌شود. این closureها می‌توانند بیش از یک بار فراخوانی شوند.
  3. Fn: برای closureهایی که مقادیر گرفته‌شده را از بدنه خود انتقال نمی‌دهند و مقادیر گرفته‌شده را تغییر نمی‌دهند، همچنین closureهایی که هیچ چیزی از محیط نمی‌گیرند اعمال می‌شود. این closureها می‌توانند بیش از یک بار بدون تغییر محیط خود فراخوانی شوند، که در مواردی مانند فراخوانی یک closure به طور همزمان چندین بار مهم است.

بیایید تعریف متد 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 از نام یک تابع استفاده کنیم. به عنوان مثال، می‌توانیم unwrap_or_else(Vec::new) را روی یک مقدار Option<Vec<T>> فراخوانی کنیم تا اگر مقدار None بود، یک وکتور جدید و خالی دریافت کنیم. کامپایلر به طور خودکار هر کدام از صفات Fn که برای تعریف تابع کاربرد دارد را پیاده‌سازی می‌کند.

اکنون بیایید به متد استاندارد کتابخانه 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

این iterator در متغیر v1_iter ذخیره شده است. پس از ایجاد یک iterator، می‌توانیم از آن به روش‌های مختلف استفاده کنیم. در لیست 3-5 از فصل 3، ما روی یک آرایه با استفاده از یک حلقه for تکرار کردیم تا کدی را روی هر یک از آیتم‌های آن اجرا کنیم. در پشت صحنه، این کار به طور ضمنی یک iterator ایجاد و سپس مصرف می‌کرد، اما تا کنون دقیقاً توضیح ندادیم که چگونه کار می‌کند.

در مثال لیست 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، استفاده از آن متغیر برای شاخص‌گذاری در وکتور برای دریافت یک مقدار و افزایش مقدار متغیر در یک حلقه تا زمانی که به تعداد کل آیتم‌ها در وکتور برسد، می‌نوشتید.

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

صفت 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;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub 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(())
}

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 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::process;

use minigrep::Config;

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) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

ابتدا شروع تابع main که در لیست 12-24 داشتیم را به کدی که در لیست 13-18 است تغییر می‌دهیم، که این بار از یک iterator استفاده می‌کند. این کد تا زمانی که Config::build را نیز به‌روزرسانی کنیم، کامپایل نخواهد شد.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

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) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
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;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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

    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));
    }
}
Listing 13-21: پیاده‌سازی تابع search از لیست 12-19

ما می‌توانیم این کد را با استفاده از متدهای تطبیق‌دهنده iterator به شکلی مختصرتر بنویسیم. این کار همچنین به ما اجازه می‌دهد که از داشتن یک وکتور میانی قابل تغییر به نام results اجتناب کنیم. سبک برنامه‌نویسی تابعی ترجیح می‌دهد مقدار حالت‌های قابل تغییر را به حداقل برساند تا کد واضح‌تر شود. حذف حالت قابل تغییر ممکن است امکان ارتقاء آینده را فراهم کند تا جستجو به صورت موازی انجام شود، زیرا نیازی به مدیریت دسترسی همزمان به وکتور results نخواهیم داشت. لیست 13-22 این تغییر را نشان می‌دهد:

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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 در یک وکتور دیگر جمع‌آوری می‌کنیم. بسیار ساده‌تر! اگر تمایل دارید، می‌توانید همین تغییر را برای استفاده از متدهای iterator در تابع search_case_insensitive نیز انجام دهید.

انتخاب بین حلقه‌ها یا Iteratorها

سؤال منطقی بعدی این است که کدام سبک را باید در کد خود انتخاب کنید و چرا: پیاده‌سازی اصلی در لیست 13-21 یا نسخه‌ای که از iteratorها در لیست 13-22 استفاده می‌کند. اکثر برنامه‌نویسان Rust ترجیح می‌دهند از سبک iterator استفاده کنند. یادگیری آن در ابتدا کمی سخت‌تر است، اما وقتی با تطبیق‌دهنده‌های مختلف iterator و کارهایی که انجام می‌دهند آشنا شوید، استفاده از iteratorها می‌تواند آسان‌تر شود. به جای دست‌و‌پنجه نرم کردن با بخش‌های مختلف حلقه‌ها و ساخت وکتورهای جدید، کد بر هدف سطح بالا حلقه تمرکز می‌کند. این کار برخی از کدهای عمومی را پنهان می‌کند، بنابراین مفاهیمی که مختص این کد هستند، مانند شرط فیلتر کردن که هر عنصر در iterator باید پاس کند، واضح‌تر دیده می‌شوند.

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

مقایسه عملکرد: حلقه‌ها در برابر 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 از اصل بدون هزینه اضافی پیروی می‌کنند: چیزی که استفاده نمی‌کنید، هزینه‌ای برای شما ندارد. و علاوه بر این: چیزی که استفاده می‌کنید، نمی‌توانید بهتر از این دستی کدنویسی کنید.

به‌عنوان یک مثال دیگر، کد زیر از یک دیکودر صوتی گرفته شده است. الگوریتم دیکودینگ از عملیات ریاضی پیش‌بینی خطی برای تخمین مقادیر آینده بر اساس یک تابع خطی از نمونه‌های قبلی استفاده می‌کند. این کد از یک زنجیره iterator برای انجام برخی محاسبات بر روی سه متغیر در محدوده استفاده می‌کند: یک برش داده‌ای buffer، یک آرایه از ۱۲ coefficients، و مقداری برای جابجایی داده‌ها در qlp_shift. ما متغیرها را در این مثال تعریف کرده‌ایم اما به آن‌ها مقداری نداده‌ایم؛ اگرچه این کد خارج از زمینه خود معنای زیادی ندارد، اما همچنان یک مثال مختصر و واقعی از نحوه تبدیل ایده‌های سطح بالا به کد سطح پایین در Rust است.

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

برای محاسبه مقدار prediction، این کد از طریق هر یک از ۱۲ مقدار در coefficients پیمایش می‌کند و از متد zip برای جفت کردن مقادیر coefficients با ۱۲ مقدار قبلی در buffer استفاده می‌کند. سپس، برای هر جفت، مقادیر را در هم ضرب می‌کنیم، تمام نتایج را جمع می‌کنیم، و بیت‌های حاصل را به اندازه qlp_shift بیت به سمت راست جابجا می‌کنیم.

محاسبات در برنامه‌هایی مانند دیکودرهای صوتی اغلب عملکرد را در اولویت قرار می‌دهند. در اینجا، ما یک iterator ایجاد می‌کنیم، از دو تطبیق‌دهنده استفاده می‌کنیم، و سپس مقدار را مصرف می‌کنیم. کد اسمبلی که این کد Rust به آن کامپایل می‌شود چیست؟ خب، در زمان نگارش این متن، این کد به همان اسمبلی‌ای که ممکن است دستی بنویسید کامپایل می‌شود. هیچ حلقه‌ای وجود ندارد که با پیمایش روی مقادیر در coefficients مطابقت داشته باشد: Rust می‌داند که ۱۲ تکرار وجود دارد، بنابراین حلقه را “بازمی‌پیچد”. بازپیچیدن یک بهینه‌سازی است که سربار کد کنترل‌کننده حلقه را حذف می‌کند و به جای آن کد تکراری برای هر تکرار حلقه تولید می‌کند.

تمام مقادیر coefficients در ثبات‌ها ذخیره می‌شوند، به این معنی که دسترسی به مقادیر بسیار سریع است. در زمان اجرا هیچ بررسی حدودی برای دسترسی به آرایه انجام نمی‌شود. تمام این بهینه‌سازی‌هایی که Rust می‌تواند اعمال کند کد نهایی را به شدت کارآمد می‌سازد. حالا که این را می‌دانید، می‌توانید از iteratorها و closureها بدون ترس استفاده کنید! آن‌ها باعث می‌شوند کد سطح بالاتر به نظر برسد اما هیچ هزینه عملکردی در زمان اجرا اعمال نمی‌کنند.

خلاصه

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 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 روی کد شما اعمال می‌کند را کنترل می‌کند و محدوده‌ای از 0 تا 3 دارد. اعمال بهینه‌سازی‌های بیشتر زمان کامپایل را افزایش می‌دهد، بنابراین اگر در حال توسعه هستید و کد خود را اغلب کامپایل می‌کنید، بهینه‌سازی‌های کمتری می‌خواهید تا سریع‌تر کامپایل شود حتی اگر کد نهایی کندتر اجرا شود. بنابراین مقدار پیش‌فرض opt-level برای dev برابر 0 است. وقتی آماده انتشار کد خود هستید، بهتر است زمان بیشتری برای کامپایل صرف کنید. شما فقط یک بار در حالت انتشار کامپایل خواهید کرد، اما برنامه کامپایل‌شده را بارها اجرا خواهید کرد. بنابراین حالت انتشار زمان کامپایل طولانی‌تر را با اجرای سریع‌تر کد معامله می‌کند. به همین دلیل مقدار پیش‌فرض opt-level برای پروفایل release برابر 3 است.

شما می‌توانید یک تنظیم پیش‌فرض را با افزودن یک مقدار متفاوت برای آن در فایل Cargo.toml بازنویسی کنید. برای مثال، اگر بخواهیم از سطح بهینه‌سازی 1 در پروفایل توسعه استفاده کنیم، می‌توانیم این دو خط را به فایل Cargo.toml پروژه خود اضافه کنیم:

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 قرار می‌دهد.

برای راحتی، اجرای دستور cargo doc --open مستندات HTML را برای crate فعلی شما (و همچنین مستندات همه وابستگی‌های crate شما) می‌سازد و نتیجه را در مرورگر وب باز می‌کند. به تابع add_one بروید و خواهید دید که چگونه متن موجود در نظرات مستندات نمایش داده می‌شود، همانطور که در شکل 14-1 نشان داده شده است:

مستندات 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 بررسی خواهیم کرد)، باید بخشی توضیح دهد که چرا تابع ناامن است و اصولی را که تابع از فراخوانان انتظار دارد رعایت کنند پوشش دهد.

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

نظرات مستندات به عنوان تست

اضافه کردن بلوک‌های کد مثال به نظرات مستندات شما می‌تواند به نمایش نحوه استفاده از کتابخانه شما کمک کند، و انجام این کار یک مزیت اضافی دارد: اجرای دستور cargo test، مثال‌های کد در مستندات شما را به عنوان تست اجرا خواهد کرد! هیچ چیزی بهتر از مستندات با مثال نیست. اما هیچ چیزی بدتر از مثال‌هایی نیست که کار نمی‌کنند زیرا کد از زمان نوشته شدن مستندات تغییر کرده است. اگر cargo test را با مستندات تابع add_one از لیستینگ 14-1 اجرا کنیم، بخشی در نتایج تست مانند زیر خواهیم دید:

   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

اکنون، اگر تابع یا مثال را تغییر دهیم به طوری که assert_eq! در مثال باعث panic شود و دوباره cargo test را اجرا کنیم، خواهیم دید که تست‌های مستندات تشخیص می‌دهند که مثال و کد با یکدیگر همگام نیستند!

مستندسازی آیتم‌های شامل شده

سبک نظر مستند //! مستندات را به آیتمی که نظرات را شامل می‌شود اضافه می‌کند، به جای آیتم‌هایی که بعد از نظرات قرار دارند. ما معمولاً از این نظرات مستند در فایل اصلی crate (src/lib.rs بر اساس قرارداد) یا در داخل یک ماژول برای مستندسازی کل crate یا ماژول استفاده می‌کنیم.

برای مثال، برای اضافه کردن مستنداتی که هدف crate my_crate را که شامل تابع add_one است توضیح می‌دهد، نظرات مستندی که با //! شروع می‌شوند را به ابتدای فایل src/lib.rs اضافه می‌کنیم، همان‌طور که در لیستینگ 14-2 نشان داده شده است:

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 را توضیح می‌دهند.

وقتی cargo doc --open را اجرا می‌کنیم، این نظرات در صفحه اول مستندات crate my_crate بالای لیست آیتم‌های عمومی در crate نمایش داده می‌شوند، همان‌طور که در شکل 14-2 نشان داده شده است:

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 شما دارای یک سلسله‌مراتب ماژول بزرگ باشد، دچار مشکل شوند.

در فصل 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 نشان داده شده است:

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 سازماندهی شده‌اند

شکل 14-3 نشان می‌دهد که صفحه اول مستندات این crate که توسط cargo doc تولید شده است چگونه به نظر می‌رسد:

مستندات تولید شده برای crate `art` که ماژول‌های `kinds` و `utils` را لیست می‌کند

شکل 14-3: صفحه اول مستندات crate art که ماژول‌های kinds و utils را لیست می‌کند

توجه کنید که انواع PrimaryColor و SecondaryColor در صفحه اول لیست نشده‌اند، و تابع mix نیز لیست نشده است. برای دیدن آن‌ها باید روی kinds و utils کلیک کنیم.

یک crate دیگر که به این کتابخانه وابسته است نیاز دارد که بیانیه‌های use مشخص کنند که آیتم‌ها را از art به scope می‌آورند، و ساختار ماژول تعریف‌شده کنونی را بیان کنند. لیستینگ 14-4 یک مثال از crate‌ای که آیتم‌های PrimaryColor و mix را از crate art استفاده می‌کند نشان می‌دهد:

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 مشخص کنند.

برای حذف سازمان‌دهی داخلی از API عمومی، می‌توانیم کد crate art را در لیستینگ 14-3 تغییر دهیم تا بیانیه‌های pub use را برای صادرات مجدد آیتم‌ها در سطح بالا اضافه کنیم، همان‌طور که در لیستینگ 14-5 نشان داده شده است:

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 که صادرات‌های مجدد را لیست می‌کند

کاربران crate art همچنان می‌توانند ساختار داخلی را از لیستینگ 14-3 ببینند و استفاده کنند، همان‌طور که در لیستینگ 14-4 نشان داده شده است، یا می‌توانند از ساختار راحت‌تر در لیستینگ 14-5 استفاده کنند، همان‌طور که در لیستینگ 14-6 نشان داده شده است:

Filename: src/main.rs
use art::mix;
use art::PrimaryColor;

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

این دستور Cargo را از توکن API شما مطلع کرده و آن را به صورت محلی در فایل ~/.cargo/credentials ذخیره می‌کند. توجه داشته باشید که این توکن یک راز است: آن را با هیچ‌کس دیگری به اشتراک نگذارید. اگر به هر دلیلی این توکن را با کسی به اشتراک گذاشتید، باید آن را لغو کنید و یک توکن جدید در crates.io ایجاد کنید.

افزودن متادیتا به یک 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"

حتی اگر یک نام منحصر به فرد انتخاب کرده باشید، زمانی که cargo publish را برای انتشار crate در این مرحله اجرا کنید، یک هشدار و سپس یک خطا دریافت خواهید کرد:

$ 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 field

این خطا به دلیل این است که شما برخی اطلاعات حیاتی را از دست داده‌اید: یک توضیح و یک مجوز مورد نیاز است تا افراد بدانند crate شما چه کاری انجام می‌دهد و تحت چه شرایطی می‌توانند از آن استفاده کنند. در فایل Cargo.toml، یک توضیح اضافه کنید که فقط یک یا دو جمله باشد، زیرا این توضیح همراه crate شما در نتایج جستجو ظاهر خواهد شد. برای فیلد license، باید یک مقدار شناسگر مجوز ارائه دهید. پروژه Software Package Data Exchange (SPDX) لیستی از شناسگرهایی که می‌توانید برای این مقدار استفاده کنید را ارائه می‌دهد. برای مثال، برای مشخص کردن اینکه crate خود را با استفاده از مجوز MIT منتشر کرده‌اید، شناسگر MIT را اضافه کنید:

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 = "2021"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

مستندات Cargo سایر متادیتاهایی که می‌توانید مشخص کنید تا دیگران بتوانند crate شما را راحت‌تر پیدا کرده و استفاده کنند توضیح می‌دهد.

انتشار در Crates.io

اکنون که یک حساب ایجاد کرده‌اید، توکن API خود را ذخیره کرده‌اید، نامی برای crate خود انتخاب کرده‌اید، و متادیتای مورد نیاز را مشخص کرده‌اید، آماده انتشار هستید! انتشار یک crate نسخه‌ای خاص از آن را در crates.io آپلود می‌کند تا دیگران بتوانند از آن استفاده کنند.

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

دستور cargo publish را دوباره اجرا کنید. اکنون باید موفق شود:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
   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)

تبریک می‌گویم! شما اکنون کد خود را با جامعه Rust به اشتراک گذاشته‌اید و هر کسی می‌تواند به راحتی crate شما را به عنوان یک وابستگی به پروژه خود اضافه کند.

انتشار نسخه جدیدی از یک Crate موجود

وقتی تغییراتی در crate خود ایجاد کرده‌اید و آماده انتشار یک نسخه جدید هستید، مقدار version مشخص‌شده در فایل Cargo.toml خود را تغییر داده و دوباره منتشر کنید. از قوانین نسخه‌بندی معنایی (Semantic Versioning) استفاده کنید تا تصمیم بگیرید که بر اساس نوع تغییراتی که ایجاد کرده‌اید، چه شماره نسخه‌ای مناسب است. سپس دستور cargo publish را اجرا کنید تا نسخه جدید آپلود شود.

از رده خارج کردن نسخه‌ها از Crates.io با استفاده از cargo yank

اگرچه نمی‌توانید نسخه‌های قبلی یک crate را حذف کنید، می‌توانید از اضافه شدن آن‌ها به عنوان وابستگی جدید در پروژه‌های آینده جلوگیری کنید. این ویژگی زمانی مفید است که یک نسخه از crate به هر دلیلی خراب باشد. در چنین مواردی، Cargo از یَنک کردن (yanking) یک نسخه از crate پشتیبانی می‌کند.

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

برای یَنک کردن یک نسخه از یک 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 ارائه می‌دهد که می‌تواند به مدیریت پکیج‌های مرتبط که به صورت همزمان توسعه داده می‌شوند کمک کند.

ایجاد یک Workspace

یک workspace مجموعه‌ای از پکیج‌ها است که یک فایل Cargo.lock و دایرکتوری خروجی مشترک دارند. بیایید یک پروژه با استفاده از workspace ایجاد کنیم—ما از کد ساده‌ای استفاده خواهیم کرد تا بتوانیم بر ساختار workspace تمرکز کنیم. راه‌های متعددی برای ساختن یک workspace وجود دارد، بنابراین فقط یک روش رایج را نشان خواهیم داد. ما یک workspace شامل یک باینری و دو کتابخانه خواهیم داشت. باینری که عملکرد اصلی را فراهم خواهد کرد، به دو کتابخانه وابسته خواهد بود. یک کتابخانه تابع add_one و کتابخانه دیگر تابع add_two ارائه خواهد داد. این سه crate بخشی از یک workspace خواهند بود. ابتدا با ایجاد یک دایرکتوری جدید برای workspace شروع می‌کنیم:

$ mkdir add
$ cd add

سپس، در دایرکتوری add، فایل Cargo.toml را ایجاد می‌کنیم که کل workspace را پیکربندی می‌کند. این فایل بخش [package] نخواهد داشت. در عوض، با یک بخش [workspace] شروع می‌شود که به ما اجازه می‌دهد اعضا را به workspace اضافه کنیم. همچنین نسخه جدیدتر الگوریتم resolver Cargo را با تنظیم resolver به "2" استفاده می‌کنیم.

Filename: Cargo.toml

[workspace]
resolver = "2"

سپس، crate باینری adder را با اجرای cargo new در دایرکتوری add ایجاد می‌کنیم:

$ cargo new adder
    Creating binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

اجرای cargo new داخل یک workspace به صورت خودکار پکیج تازه ایجاد شده را به کلید members در تعریف [workspace] در فایل Cargo.toml workspace اضافه می‌کند، به این صورت:

[workspace]
resolver = "2"
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

حالا، بیایید یک پکیج عضو دیگر در workspace ایجاد کنیم و آن را add_one بنامیم. فایل Cargo.toml در سطح بالا را تغییر دهید تا مسیر add_one را در لیست members مشخص کنید:

Filename: Cargo.toml

[workspace]
resolver = "2"
members = ["adder", "add_one"]

سپس یک crate کتابخانه‌ای جدید به نام add_one ایجاد کنید:

$ cargo new add_one --lib
    Creating library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

دایرکتوری 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`

برای رفع این مشکل، فایل Cargo.toml پکیج adder را ویرایش کرده و مشخص کنید که rand برای آن نیز یک وابستگی است. ساختن پکیج adder، rand را به لیست وابستگی‌های adder در فایل Cargo.lock اضافه می‌کند، اما هیچ نسخه اضافی از rand دانلود نخواهد شد. Cargo اطمینان حاصل می‌کند که هر crate در هر پکیجی از workspace که از پکیج rand استفاده می‌کند، از همان نسخه استفاده کند، به شرطی که نسخه‌های سازگار از rand را مشخص کنند. این کار فضای ما را ذخیره کرده و تضمین می‌کند که crate‌های workspace با یکدیگر سازگار خواهند بود.

اگر crate‌های workspace نسخه‌های ناسازگار از یک وابستگی را مشخص کنند، Cargo هر یک از آن‌ها را جداگانه حل خواهد کرد، اما همچنان تلاش می‌کند که تعداد نسخه‌های حل‌شده را به حداقل برساند.

افزودن یک تست به یک 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-f0253159197f7841)

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-49979ff40686fa8e)

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-b3235fea9a156f74)

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 را اجرا نکرده است.

اگر crate‌های موجود در workspace را در crates.io منتشر کنید، هر crate در workspace باید به صورت جداگانه منتشر شود. مشابه با cargo test، می‌توانیم یک crate خاص را در workspace خود با استفاده از گزینه -p و مشخص کردن نام crate‌ای که می‌خواهیم منتشر کنیم، منتشر کنیم.

برای تمرین بیشتر، یک crate جدید به نام add_two به این workspace اضافه کنید، به شیوه‌ای مشابه crate add_one!

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

نصب باینری‌ها با استفاده از 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 v13.0.0
  Downloaded 1 crate (243.3 KB) in 0.88s
  Installing ripgrep v13.0.0
--snip--
   Compiling ripgrep v13.0.0
    Finished `release` profile [optimized + debuginfo] target(s) in 10.64s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v13.0.0` (executable `rg`)

خط دوم به آخر خروجی نشان می‌دهد که باینری نصب‌شده در کجا و با چه نامی قرار دارد؛ که در مورد ripgrep این باینری rg نام دارد. تا زمانی که مسیر نصب در متغیر $PATH شما باشد، همان‌طور که قبلاً ذکر شد، می‌توانید با اجرای rg --help استفاده از این ابزار سریع‌تر و مرتبط با Rust برای جستجوی فایل‌ها را شروع کنید!

گسترش Cargo با دستورات سفارشی

Cargo به گونه‌ای طراحی شده است که می‌توانید آن را با زیرفرمان‌های جدید گسترش دهید، بدون اینکه نیاز به تغییر در Cargo باشد. اگر یک باینری در مسیر `$PATH` شما با نام `cargo-something` وجود داشته باشد، می‌توانید آن را به گونه‌ای اجرا کنید که گویی یک زیرفرمان Cargo است، با اجرای `cargo something`. دستورات سفارشی مانند این نیز زمانی که `cargo --list` را اجرا می‌کنید، لیست می‌شوند. امکان استفاده از `cargo install` برای نصب افزونه‌ها و سپس اجرای آن‌ها مانند ابزارهای داخلی Cargo یکی از مزایای بسیار راحت طراحی Cargo است!

خلاصه

اشتراک‌گذاری کد با 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 با مفهوم مالکیت و قرض گرفتن خود، تفاوت اضافی بین ارجاعات و اشاره‌گر (Pointer)های هوشمند دارد: در حالی که ارجاعات فقط داده‌ها را قرض می‌گیرند، در بسیاری از موارد اشاره‌گر (Pointer)های هوشمند مالک داده‌ای هستند که به آن اشاره می‌کنند.

اگرچه در آن زمان آن‌ها را به این صورت نام نبردیم، اما قبلاً با چند اشاره‌گر (Pointer) هوشمند در این کتاب آشنا شده‌ایم، از جمله String و Vec<T> در فصل ۸. هر دوی این نوع‌ها به‌عنوان اشاره‌گر (Pointer)های هوشمند در نظر گرفته می‌شوند زیرا آن‌ها مقداری حافظه را مالک می‌شوند و به شما امکان می‌دهند آن را دست‌کاری کنید. آن‌ها همچنین دارای فرا داده و قابلیت‌ها یا تضمین‌های اضافی هستند. برای مثال، String ظرفیت خود را به‌عنوان فرا داده ذخیره می‌کند و دارای قابلیت اضافی برای اطمینان از این است که داده‌های آن همیشه یک UTF-8 معتبر خواهد بود.

اشاره‌گر (Pointer)های هوشمند معمولاً با استفاده از ساختارها (structs) پیاده‌سازی می‌شوند. برخلاف یک ساختار عادی، اشاره‌گر (Pointer)های هوشمند ویژگی‌های Deref و Drop را پیاده‌سازی می‌کنند. ویژگی Deref به نمونه‌ای از ساختار اشاره‌گر (Pointer) هوشمند امکان می‌دهد که مانند یک ارجاع عمل کند، بنابراین می‌توانید کد خود را بنویسید تا با ارجاعات یا اشاره‌گر (Pointer)های هوشمند کار کند. ویژگی Drop به شما امکان می‌دهد کدی را که هنگام خارج شدن یک نمونه از اشاره‌گر (Pointer) هوشمند از محدوده اجرا می‌شود، سفارشی‌سازی کنید. در این فصل، هر دو ویژگی را بررسی خواهیم کرد و نشان خواهیم داد که چرا برای اشاره‌گر (Pointer)های هوشمند مهم هستند.

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

  • Box<T> برای تخصیص مقادیر در heap
  • Rc<T>، یک نوع شمارش ارجاعات که امکان مالکیت چندگانه را فراهم می‌کند
  • Ref<T> و RefMut<T>، که از طریق RefCell<T> قابل دسترسی هستند، نوعی که قوانین قرض گرفتن را در زمان اجرا به‌جای زمان کامپایل اعمال می‌کند

علاوه بر این، الگوی تغییرپذیری داخلی را پوشش خواهیم داد، جایی که یک نوع غیرقابل تغییر یک API برای تغییر یک مقدار داخلی ارائه می‌دهد. ما همچنین در مورد حلقه‌های ارجاع بحث خواهیم کرد: چگونه می‌توانند حافظه را نشت دهند و چگونه می‌توان از آن‌ها جلوگیری کرد.

بیایید شروع کنیم!

استفاده از 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,
}

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 بی‌نهایت

شکل ۱۵-۱: یک 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 به‌علاوه فضای مورد نیاز برای ذخیره داده‌های اشاره‌گر (Pointer) جعبه نیاز دارد. متغیر Nil هیچ مقداری را ذخیره نمی‌کند، بنابراین به فضای کمتری نسبت به متغیر Cons نیاز دارد. اکنون می‌دانیم که هر مقدار List به اندازه یک i32 به‌علاوه اندازه داده‌های اشاره‌گر (Pointer) جعبه فضا نیاز دارد. با استفاده از جعبه، زنجیره بازگشتی بی‌نهایت را شکسته‌ایم، بنابراین کامپایلر می‌تواند بفهمد چه مقدار فضا برای ذخیره یک مقدار List نیاز دارد. شکل ۱۵-۲ نشان می‌دهد که متغیر Cons اکنون چگونه به نظر می‌رسد.

یک لیست Cons محدود

شکل ۱۵-۲: یک List که بی‌نهایت نیست زیرا Cons یک Box نگه می‌دارد

جعبه‌ها تنها غیرمستقیم‌سازی و تخصیص heap را فراهم می‌کنند؛ آن‌ها هیچ قابلیت خاص دیگری ندارند، مانند آنچه با دیگر انواع اشاره‌گر (Pointer) هوشمند خواهیم دید. آن‌ها همچنین سربار عملکردی که این قابلیت‌های خاص ایجاد می‌کنند را ندارند، بنابراین می‌توانند در مواردی مانند لیست cons مفید باشند که غیرمستقیم‌سازی تنها ویژگی مورد نیاز است. ما موارد استفاده بیشتری از جعبه‌ها را نیز در فصل ۱۸ بررسی خواهیم کرد.

نوع Box<T> یک اشاره‌گر (Pointer) هوشمند است زیرا ویژگی Deref را پیاده‌سازی می‌کند، که به مقادیر Box<T> اجازه می‌دهد مانند ارجاعات رفتار کنند. وقتی یک مقدار Box<T> از دامنه خارج می‌شود، داده‌های heap که جعبه به آن اشاره می‌کند نیز به دلیل پیاده‌سازی ویژگی Drop پاک‌سازی می‌شود. این دو ویژگی برای عملکرد انواع دیگر اشاره‌گر (Pointer)های هوشمند که در بقیه این فصل مورد بحث قرار می‌دهیم، اهمیت بیشتری خواهند داشت. بیایید این دو ویژگی را با جزئیات بیشتری بررسی کنیم.

رفتار اشاره‌گر (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)
help: consider dereferencing here
 --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/core/src/macros/mod.rs:46:35
  |
46|                 if !(*left_val == **right_val) {
  |                                   +

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) هوشمند خودمان

بیایید یک اشاره‌گر (Pointer) هوشمند مشابه نوع Box<T> که توسط کتابخانه استاندارد ارائه شده است بسازیم تا تجربه کنیم که چگونه اشاره‌گر (Pointer)های هوشمند به طور پیش‌فرض متفاوت از ارجاعات رفتار می‌کنند. سپس به نحوه اضافه کردن قابلیت استفاده از عملگر اشاره‌گر (Pointer)‌زدایی می‌پردازیم.

نوع Box<T> در نهایت به عنوان یک ساختار tuple با یک عنصر تعریف شده است، بنابراین لیستینگ ۱۵-۸ نوع 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

همان‌طور که در بخش “پیاده‌سازی یک ویژگی روی یک نوع” فصل ۱۰ بحث شد، برای پیاده‌سازی یک ویژگی، باید پیاده‌سازی‌هایی برای متدهای مورد نیاز ویژگی ارائه دهیم. ویژگی Deref که توسط کتابخانه استاندارد ارائه شده است، از ما می‌خواهد که یک متد به نام deref را پیاده‌سازی کنیم که self را قرض بگیرد و یک ارجاع به داده داخلی بازگرداند. لیستینگ ۱۵-۱۰ شامل یک پیاده‌سازی از Deref است که به تعریف MyBox اضافه شده است:

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 بدون فیلدهای نام‌گذاری‌شده برای ایجاد انواع مختلف” در فصل ۵ که .0 به اولین مقدار در یک ساختار tuple دسترسی پیدا می‌کند. تابع main در لیستینگ ۱۵-۹ که * را روی مقدار MyBox<T> فراخوانی می‌کند اکنون کامپایل می‌شود و تاییدها موفق خواهند شد!

بدون ویژگی Deref، کامپایلر تنها می‌تواند ارجاعات & را اشاره‌گر (Pointer)‌زدایی کند. متد deref به کامپایلر امکان می‌دهد که یک مقدار از هر نوعی که Deref را پیاده‌سازی می‌کند بگیرد و متد deref را فراخوانی کند تا یک ارجاع & دریافت کند که می‌داند چگونه آن را اشاره‌گر (Pointer)‌زدایی کند.

وقتی که در لیستینگ ۱۵-۹ *y وارد کردیم، پشت صحنه Rust در واقع این کد را اجرا کرد:

*(y.deref())

Rust عملگر * را با یک فراخوانی به متد deref و سپس یک اشاره‌گر (Pointer)زدایی ساده جایگزین می‌کند، بنابراین لازم نیست درباره این فکر کنیم که آیا نیاز به فراخوانی متد deref داریم یا نه. این ویژگی Rust به ما اجازه می‌دهد کدی بنویسیم که خواه ارجاع معمولی باشد یا نوعی که Deref را پیاده‌سازی کرده باشد، به طور یکسان عمل کند.

دلیل اینکه متد deref یک ارجاع به مقدار بازمی‌گرداند و اشاره‌گر (Pointer)زدایی ساده در بیرون از پرانتز در *(y.deref()) همچنان لازم است، به سیستم مالکیت مرتبط است. اگر متد deref به‌جای یک ارجاع به مقدار، مقدار را مستقیماً بازمی‌گرداند، مقدار از self منتقل می‌شد. در این حالت یا در بیشتر مواردی که از عملگر اشاره‌گر (Pointer)زدایی استفاده می‌کنیم، نمی‌خواهیم مالکیت مقدار داخلی درون 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)های هوشمند کار کند.

برای دیدن فشار اشاره‌گر (Pointer)زدایی در عمل، بیایید از نوع MyBox<T> که در لیستینگ ۱۵-۸ تعریف کردیم به همراه پیاده‌سازی Deref که در لیستینگ ۱۵-۱۰ اضافه کردیم استفاده کنیم. لیستینگ ۱۵-۱۱ تعریف یک تابع که یک پارامتر از نوع اسلایس رشته دارد را نشان می‌دهد:

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> باشد

دو حالت اول مشابه یکدیگر هستند با این تفاوت که حالت دوم قابلیت تغییرپذیری را پیاده‌سازی می‌کند. حالت اول بیان می‌کند که اگر شما یک &T داشته باشید و T ویژگی Deref را به نوعی U پیاده‌سازی کند، می‌توانید به‌صورت شفاف یک &U دریافت کنید. حالت دوم بیان می‌کند که همین فشار اشاره‌گر (Pointer)زدایی برای ارجاعات قابل تغییر نیز اتفاق می‌افتد.

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

اجرای کد هنگام پاکسازی با ویژگی Drop

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

ما ویژگی Drop را در زمینه اشاره‌گر (Pointer)های هوشمند معرفی می‌کنیم زیرا عملکرد ویژگی Drop تقریباً همیشه هنگام پیاده‌سازی یک اشاره‌گر (Pointer) هوشمند استفاده می‌شود. برای مثال، وقتی یک Box<T> حذف می‌شود، فضای موجود روی پشته‌ای که باکس به آن اشاره می‌کند، آزاد خواهد شد.

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

شما کدی که باید هنگام خروج مقدار از دامنه اجرا شود را با پیاده‌سازی ویژگی Drop مشخص می‌کنید. ویژگی Drop نیازمند این است که یک متد به نام drop را پیاده‌سازی کنید که یک مرجع متغیر به self می‌گیرد. برای دیدن زمانی که Rust فراخوانی drop را انجام می‌دهد، بیایید drop را با جملات println! برای اکنون پیاده‌سازی کنیم.

فهرست 15-14 یک ساختار CustomSmartPointer را نشان می‌دهد که تنها قابلیت سفارشی آن این است که وقتی نمونه‌ای از آن از دامنه خارج می‌شود، 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 را پیاده‌سازی می‌کند و در آن کد پاکسازی خود را قرار می‌دهیم

ویژگی Drop در پیش‌درآمد (prelude) گنجانده شده است، بنابراین نیازی به وارد کردن آن به دامنه نداریم. ما ویژگی Drop را روی CustomSmartPointer پیاده‌سازی می‌کنیم و یک پیاده‌سازی برای متد drop ارائه می‌دهیم که println! را فراخوانی می‌کند. بدنه تابع drop جایی است که هر منطقی که بخواهید هنگام خروج یک نمونه از نوع شما از دامنه اجرا شود، قرار می‌دهید. ما در اینجا متنی را چاپ می‌کنیم تا به صورت بصری نشان دهیم که چه زمانی 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 به شما بدهد؛ معمولاً شما کد پاکسازی که نوع شما نیاز دارد را مشخص می‌کنید نه یک پیام چاپ.

حذف زودهنگام یک مقدار با استفاده از std::mem::drop

متأسفانه، غیرفعال کردن عملکرد خودکار drop ساده نیست. در اغلب موارد، نیازی به غیرفعال کردن drop نیست؛ هدف اصلی ویژگی Drop این است که این کار به‌طور خودکار انجام شود. با این حال، گاهی ممکن است بخواهید یک مقدار را زودتر از زمان خود تمیز کنید. یک مثال در این زمینه، استفاده از اشاره‌گر (Pointer)های هوشمندی است که قفل‌ها را مدیریت می‌کنند: ممکن است بخواهید متد drop که قفل را آزاد می‌کند را به زور اجرا کنید تا کد دیگری در همان حوزه بتواند قفل را بدست آورد.
Rust به شما اجازه نمی‌دهد متد drop متعلق به ویژگی Drop را به صورت دستی فراخوانی کنید؛ در عوض، باید از تابع std::mem::drop که توسط کتابخانه استاندارد فراهم شده است، استفاده کنید اگر می‌خواهید مقداری را زودتر از زمان معمول حذف کنید.

اگر بخواهیم متد drop مربوط به ویژگی 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 در ویژگی Drop متفاوت است. این تابع را با ارسال مقداری که می‌خواهیم به‌زور حذف کنیم به‌عنوان آرگومان فراخوانی می‌کنیم. این تابع در پیش‌فرض (Prelude) قرار دارد، بنابراین می‌توانیم تابع main را در لیست 15-15 تغییر دهیم تا تابع 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 به نظر می‌رسد:

دو لیست که مالکیت یک لیست سوم را به اشتراک می‌گذارند

شکل 15-3: دو لیست، b و c، که مالکیت یک لیست سوم، a را به اشتراک می‌گذارند

ما لیست a را ایجاد می‌کنیم که شامل 5 و سپس 10 است. سپس دو لیست دیگر ایجاد می‌کنیم: b که با 3 شروع می‌شود و c که با 4 شروع می‌شود. هر دو لیست b و c سپس ادامه می‌دهند به لیست اول a که شامل 5 و 10 است. به عبارت دیگر، هر دو لیست مالکیت لیست اول که شامل 5 و 10 است را به اشتراک می‌گذارند.

تلاش برای پیاده‌سازی این سناریو با استفاده از تعریف ما از List با Box<T> کار نخواهد کرد، همان‌طور که در لیست 15-17 نشان داده شده است:

Filename: src/main.rs
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));
}
Listing 15-17: نشان دادن اینکه نمی‌توانیم دو لیست با استفاده از Box<T> داشته باشیم که سعی در اشتراک‌گذاری مالکیت یک لیست سوم دارند

هنگامی که این کد را کامپایل می‌کنیم، با این خطا مواجه می‌شویم:

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

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> متمایز می‌کند؟ قوانین وام‌دهی‌ای که در فصل 4 یاد گرفتید را به یاد آورید:

  • در هر زمان معین، شما می‌توانید یا (اما نه هر دو) یک ارجاع متغیر یا تعداد زیادی ارجاع غیرقابل‌تغییر داشته باشید.
  • ارجاع‌ها باید همیشه معتبر باشند.

با استفاده از ارجاع‌ها و Box<T>، ثابت‌های قوانین وام‌دهی در زمان کامپایل اعمال می‌شوند. اما با RefCell<T>، این ثابت‌ها در زمان اجرا اعمال می‌شوند. با ارجاع‌ها، اگر این قوانین را بشکنید، یک خطای کامپایل دریافت خواهید کرد. اما با RefCell<T>، اگر این قوانین را بشکنید، برنامه شما دچار وحشت (panic) می‌شود و متوقف می‌شود.

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

مزیت بررسی قوانین وام‌دهی در زمان اجرا این است که سناریوهایی که ایمن از نظر حافظه هستند اجازه می‌یابند، در حالی که ممکن است توسط بررسی‌های زمان کامپایل مجاز نباشند. تحلیل ایستا (static analysis)، مانند کامپایلر راست، به‌طور ذاتی محافظه‌کارانه است. برخی خصوصیات کد غیرممکن است که با تحلیل کد شناسایی شوند: معروف‌ترین مثال، مشکل توقف (Halting Problem) است که فراتر از محدوده این کتاب است اما موضوع جالبی برای تحقیق می‌باشد.

زیرا برخی تحلیل‌ها غیرممکن هستند، اگر کامپایلر راست نتواند مطمئن شود که کد با قوانین مالکیت سازگار است، ممکن است یک برنامه درست را رد کند؛ به این ترتیب، محافظه‌کارانه عمل می‌کند. اگر راست یک برنامه نادرست را بپذیرد، کاربران نمی‌توانند به تضمین‌هایی که راست می‌دهد، اعتماد کنند. اما اگر راست یک برنامه درست را رد کند، برنامه‌نویس ناراحت خواهد شد، اما هیچ چیز فاجعه‌باری رخ نخواهد داد. نوع 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

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

راست اشیاء را به همان شکلی که زبان‌های دیگر دارند، ندارد و قابلیت‌های اشیاء 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: یک کتابخانه برای پیگیری نزدیکی یک مقدار به یک مقدار حداکثری و هشدار در زمانی که مقدار در سطوح خاصی است

یکی از بخش‌های مهم این کد این است که ویژگی Messenger یک متد به نام send دارد که یک ارجاع غیرقابل‌تغییر به self و متن پیام را می‌گیرد. این ویژگی رابطی است که شیء Mock ما باید برای استفاده به همان شیوه که یک شیء واقعی استفاده می‌شود، پیاده‌سازی کند. بخش مهم دیگر این است که ما می‌خواهیم رفتار متد set_value را روی LimitTracker تست کنیم. ما می‌توانیم چیزی را که به عنوان پارامتر به value می‌دهیم تغییر دهیم، اما set_value چیزی برای ما برنمی‌گرداند که بتوانیم روی آن ادعا کنیم. ما می‌خواهیم بتوانیم بگوییم اگر یک LimitTracker با چیزی که ویژگی Messenger را پیاده‌سازی کرده و مقدار خاصی برای max ایجاد کنیم، زمانی که مقادیر مختلفی برای value ارسال می‌کنیم، پیام‌رسان گفته شده است که پیام‌های مناسب را ارسال کند.

ما به یک شیء 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 را به چیزی تنظیم کند که بیش از 75 درصد مقدار max است، چه اتفاقی می‌افتد. ابتدا یک MockMessenger جدید ایجاد می‌کنیم که با یک لیست خالی از پیام‌ها شروع می‌شود. سپس یک LimitTracker جدید ایجاد می‌کنیم و یک ارجاع به MockMessenger جدید و یک مقدار max برابر 100 به آن می‌دهیم. متد set_value را روی LimitTracker با مقدار 80 که بیش از 75 درصد 100 است، فراخوانی می‌کنیم. سپس ادعا می‌کنیم که لیست پیام‌هایی که 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) بپذیریم. ما نمی‌خواهیم فقط به خاطر تست، ویژگی Messenger را تغییر دهیم. در عوض، باید راهی پیدا کنیم که کد تست ما با طراحی موجود به درستی کار کند.

این یک موقعیت است که در آن تغییرپذیری داخلی می‌تواند کمک کند! ما فیلد 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> از دامنه خارج می‌شود، شمارش وام‌دهی‌های غیرقابل‌تغییر یک عدد کاهش می‌یابد. دقیقاً مثل قوانین وام‌دهی در زمان کامپایل، 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، می‌خواهیم 10 به مقدار درون value اضافه کنیم. این کار را با فراخوانی borrow_mut روی value انجام می‌دهیم، که از ویژگی بازارجاع خودکار (automatic dereferencing) که در فصل 5 بحث کردیم (به بخش «عملگر -> کجاست؟» مراجعه کنید) برای بازارجاع Rc<T> به مقدار داخلی RefCell<T> استفاده می‌کند. متد borrow_mut یک اسمارت پوینتر RefMut<T> برمی‌گرداند، و ما از عملگر بازارجاع روی آن استفاده می‌کنیم و مقدار داخلی را تغییر می‌دهیم.

وقتی 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> که دسترسی به تغییرپذیری داخلی آن را فراهم می‌کنند استفاده کنیم تا داده‌های خود را هر وقت که نیاز داشتیم تغییر دهیم. بررسی‌های زمان اجرا برای قوانین وام‌دهی ما را از رقابت‌های داده (data races) محافظت می‌کند، و گاهی اوقات ارزش آن را دارد که مقداری سرعت را برای این انعطاف‌پذیری در ساختار داده‌هایمان معامله کنیم. توجه داشته باشید که RefCell<T> برای کد چندریسمانی کار نمی‌کند! نسخه امن برای نخ (thread-safe) از RefCell<T>، نوع Mutex<T> است که در فصل 16 در مورد آن صحبت خواهیم کرد.

چرخه‌های ارجاعی می‌توانند منجر به نشت حافظه شوند

تضمین‌های ایمنی حافظه راست ایجاد حافظه‌ای که هرگز پاک نمی‌شود (که به عنوان نشت حافظه شناخته می‌شود) را دشوار می‌کنند، اما غیرممکن نمی‌کنند. جلوگیری کامل از نشت حافظه یکی از تضمین‌های راست نیست، به این معنی که نشت حافظه در راست ایمن است. ما می‌توانیم ببینیم که راست اجازه نشت حافظه را می‌دهد با استفاده از Rc<T> و RefCell<T>: امکان ایجاد ارجاع‌هایی وجود دارد که آیتم‌ها در آن به یکدیگر در یک چرخه ارجاع می‌دهند. این باعث نشت حافظه می‌شود، زیرا شمارش ارجاع هر آیتم در چرخه هرگز به 0 نمی‌رسد و مقادیر هرگز حذف نمی‌شوند.

ایجاد یک چرخه ارجاعی

بیایید نگاهی بیندازیم که چگونه یک چرخه ارجاعی ممکن است اتفاق بیفتد و چگونه می‌توان از آن جلوگیری کرد، با تعریف 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> دیگر ایجاد می‌کنیم که مقدار دیگری از 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 برابر با 2 است. در پایان تابع main، راست متغیر b را حذف می‌کند، که شمارش ارجاع نمونه Rc<List> در b را از 2 به 1 کاهش می‌دهد. حافظه‌ای که Rc<List> در heap اشغال کرده است در این نقطه حذف نخواهد شد، زیرا شمارش ارجاع آن برابر با 1 است و نه 0. سپس راست متغیر a را حذف می‌کند، که شمارش ارجاع نمونه Rc<List> در a را نیز از 2 به 1 کاهش می‌دهد. حافظه این نمونه نیز نمی‌تواند حذف شود، زیرا نمونه دیگر Rc<List> همچنان به آن ارجاع می‌دهد. حافظه تخصیص‌یافته به این لیست برای همیشه غیرقابل جمع‌آوری باقی خواهد ماند. برای تجسم این چرخه ارجاع، نموداری در شکل 15-4 ایجاد کرده‌ایم.

چرخه ارجاعی لیست‌ها

شکل 15-4: یک چرخه ارجاعی از لیست‌های a و b که به یکدیگر اشاره می‌کنند

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

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

ایجاد چرخه‌های ارجاعی کار آسانی نیست، اما غیرممکن هم نیست. اگر مقادیر RefCell<T> داشته باشید که مقادیر Rc<T> یا ترکیبات مشابهی از انواع با تغییرپذیری داخلی و شمارش ارجاع را در خود جای دهند، باید مطمئن شوید که چرخه‌ای ایجاد نمی‌کنید؛ نمی‌توانید به راست اعتماد کنید که آن‌ها را شناسایی کند. ایجاد چرخه ارجاعی یک اشکال منطقی در برنامه شما خواهد بود که باید با استفاده از تست‌های خودکار، بررسی کد، و دیگر شیوه‌های توسعه نرم‌افزار، آن را به حداقل برسانید.

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

جلوگیری از چرخه‌های ارجاعی: تبدیل یک Rc<T> به یک Weak<T>

تا اینجا، نشان داده‌ایم که فراخوانی Rc::clone شمارش strong_count یک نمونه Rc<T> را افزایش می‌دهد، و یک نمونه Rc<T> تنها زمانی پاک‌سازی می‌شود که شمارش strong_count آن 0 باشد. همچنین می‌توانید با فراخوانی Rc::downgrade و ارسال یک ارجاع به Rc<T>، یک ارجاع ضعیف به مقدار درون یک نمونه Rc<T> ایجاد کنید. ارجاعات قوی به شما اجازه می‌دهند مالکیت یک نمونه Rc<T> را به اشتراک بگذارید. ارجاعات ضعیف یک رابطه مالکیت را بیان نمی‌کنند، و شمارش آن‌ها تأثیری در زمان پاک‌سازی یک نمونه Rc<T> ندارد. آن‌ها باعث ایجاد چرخه ارجاعی نمی‌شوند، زیرا هر چرخه‌ای که شامل برخی ارجاعات ضعیف باشد، وقتی شمارش ارجاع قوی مقادیر درگیر 0 شود، شکسته می‌شود.

وقتی 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> به آن ارجاع می‌دهد ممکن است حذف شده باشد، برای انجام هر کاری با مقداری که یک Weak<T> به آن اشاره می‌کند، باید مطمئن شوید که مقدار هنوز وجود دارد. این کار را با فراخوانی متد upgrade روی یک نمونه Weak<T> انجام دهید، که یک Option<Rc<T>> را برمی‌گرداند. اگر مقدار Rc<T> هنوز حذف نشده باشد، نتیجه Some خواهد بود و اگر مقدار Rc<T> حذف شده باشد، نتیجه None خواهد بود. از آنجا که upgrade یک Option<Rc<T>> را برمی‌گرداند، راست تضمین می‌کند که حالت Some و حالت None مدیریت می‌شوند و هیچ اشاره‌گر (Pointer) نامعتبری وجود نخواهد داشت.

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

ایجاد یک ساختار داده درخت: یک Node با گره‌های فرزند

برای شروع، ما یک درخت با گره‌هایی ایجاد خواهیم کرد که درباره گره‌های فرزند خود اطلاع دارند. ما یک ساختار به نام 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>> قرار می‌دهیم.

سپس، تعریف ساختار خود را استفاده می‌کنیم و یک نمونه 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 را ایجاد می‌کنیم، آن نیز یک ارجاع جدید Weak<Node> در فیلد parent خواهد داشت، زیرا 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 ایجاد و سپس هنگام خارج شدن از دامنه حذف می‌شود. تغییرات در فهرست 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، Rc<Node> آن دارای شمارش قوی 1 و شمارش ضعیف 0 است. در دامنه داخلی، ما branch را ایجاد می‌کنیم و آن را با leaf مرتبط می‌کنیم، در این نقطه وقتی شمارش‌ها را چاپ می‌کنیم، Rc<Node> در branch دارای شمارش قوی 1 و شمارش ضعیف 1 خواهد بود (برای leaf.parent که به branch با یک Weak<Node> اشاره می‌کند). وقتی شمارش‌ها را در leaf چاپ می‌کنیم، می‌بینیم که شمارش قوی آن 2 خواهد بود، زیرا branch اکنون یک کلون از Rc<Node> در leaf که در branch.children ذخیره شده است، دارد، اما همچنان شمارش ضعیف 0 خواهد بود.

وقتی دامنه داخلی به پایان می‌رسد، branch از دامنه خارج می‌شود و شمارش قوی Rc<Node> به 0 کاهش می‌یابد، بنابراین Node آن حذف می‌شود. شمارش ضعیف 1 از leaf.parent تأثیری بر اینکه آیا Node حذف می‌شود ندارد، بنابراین هیچ نشت حافظه‌ای نخواهیم داشت!

اگر سعی کنیم پس از پایان دامنه به والد leaf دسترسی پیدا کنیم، دوباره مقدار None دریافت خواهیم کرد. در پایان برنامه، Rc<Node> در leaf دارای شمارش قوی 1 و شمارش ضعیف 0 است، زیرا متغیر 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 است. برنامه‌نویسی همزمان، جایی که بخش‌های مختلف یک برنامه به صورت مستقل اجرا می‌شوند، و برنامه‌نویسی موازی، جایی که بخش‌های مختلف یک برنامه به صورت همزمان اجرا می‌شوند، در حال تبدیل شدن به جنبه‌های فزاینده‌ای مهم هستند زیرا تعداد بیشتری از کامپیوترها از پردازنده‌های چندگانه خود استفاده می‌کنند. به طور تاریخی، برنامه‌نویسی در این زمینه‌ها سخت و مستعد خطا بوده است: Rust امیدوار است این موضوع را تغییر دهد.

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

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

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

موضوعاتی که در این فصل پوشش خواهیم داد عبارت‌اند از:

  • نحوه ایجاد نخ‌ها برای اجرای همزمان چندین بخش از کد
  • همزمانی پیام‌رسانی، جایی که کانال‌ها پیام‌ها را بین نخ‌ها ارسال می‌کنند
  • همزمانی حالت اشتراکی، جایی که چندین نخ به بخشی از داده دسترسی دارند
  • صفات Sync و Send، که تضمین‌های همزمانی Rust را به انواع تعریف‌شده توسط کاربر و همچنین انواع ارائه‌شده توسط کتابخانه استاندارد گسترش می‌دهند

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

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

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

  • شرایط رقابتی (Race conditions)، جایی که نخ‌ها داده‌ها یا منابع را به ترتیب ناسازگار دسترسی دارند
  • بن‌بست‌ها (Deadlocks)، جایی که دو نخ منتظر یکدیگر هستند و مانع از ادامه کار هر دو نخ می‌شوند
  • باگ‌هایی که فقط در شرایط خاص رخ می‌دهند و به سختی قابل بازتولید و رفع هستند

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

زبان‌های برنامه‌نویسی نخ‌ها را به چندین روش مختلف پیاده‌سازی می‌کنند و بسیاری از سیستم‌عامل‌ها API‌هایی ارائه می‌دهند که زبان می‌تواند برای ایجاد نخ‌های جدید فراخوانی کند. کتابخانه استاندارد Rust از یک مدل پیاده‌سازی نخ 1:1 استفاده می‌کند، به این معنا که برنامه یک نخ سیستم‌عامل به ازای هر نخ زبان استفاده می‌کند. جعبه‌ها (crates)یی وجود دارند که مدل‌های دیگر نخ را پیاده‌سازی می‌کنند و مبادله‌های متفاوتی نسبت به مدل 1:1 ارائه می‌دهند. (سیستم async در Rust، که در فصل بعدی آن را خواهیم دید، روش دیگری برای همزمانی ارائه می‌دهد.)

ایجاد یک نخ جدید با spawn

برای ایجاد یک نخ جدید، تابع thread::spawn را فراخوانی می‌کنیم و یک closure (که در فصل 13 در مورد آن صحبت کردیم) شامل کدی که می‌خواهیم در نخ جدید اجرا کنیم، به آن پاس می‌دهیم. مثال در لیستینگ 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 باعث می‌شوند یک نخ اجرای خود را برای مدت کوتاهی متوقف کند و به نخ دیگری اجازه اجرا دهد. احتمالاً نخ‌ها نوبتی اجرا می‌شوند، اما این موضوع تضمین‌شده نیست: به نحوه زمان‌بندی نخ‌ها توسط سیستم‌عامل شما بستگی دارد. در این اجرای برنامه، نخ اصلی ابتدا چاپ کرد، حتی با اینکه دستور چاپ از نخ ایجادشده در کد ابتدا ظاهر می‌شود. و با اینکه به نخ ایجادشده گفتیم تا زمانی که مقدار i به 9 برسد چاپ کند، فقط تا مقدار 5 رسید قبل از اینکه نخ اصلی خاموش شود.

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

منتظر ماندن برای تکمیل همه نخ‌ها با استفاده از join Handles

کد موجود در لیستینگ 16-1 نه تنها بیشتر اوقات نخ ایجادشده را به دلیل پایان نخ اصلی زودتر از موعد متوقف می‌کند، بلکه به دلیل اینکه هیچ تضمینی برای ترتیب اجرای نخ‌ها وجود ندارد، نمی‌توانیم اطمینان حاصل کنیم که نخ ایجادشده اجرا خواهد شد!

می‌توانیم مشکل اجرا نشدن یا پایان زودهنگام نخ ایجادشده را با ذخیره مقدار بازگشتی thread::spawn در یک متغیر رفع کنیم. نوع بازگشتی thread::spawn یک JoinHandle است. یک JoinHandle یک مقدار مالکیت‌دار است که وقتی متد join را روی آن فراخوانی می‌کنیم، منتظر می‌ماند تا نخ مرتبط با آن تکمیل شود. لیستینگ 16-2 نشان می‌دهد چگونه از JoinHandle نخ ایجادشده در لیستینگ 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 از 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 با closureهایی که به thread::spawn پاس داده می‌شوند استفاده می‌کنیم، زیرا این closure سپس مالکیت مقادیری را که از محیط استفاده می‌کند، می‌گیرد و بنابراین مالکیت آن مقادیر را از یک نخ به نخ دیگر منتقل می‌کند. در بخش “گرفتن ارجاع‌ها یا انتقال مالکیت” در فصل 13، move را در زمینه closureها مورد بحث قرار دادیم. اکنون بیشتر روی تعامل بین move و thread::spawn تمرکز خواهیم کرد.

توجه کنید که در لیستینگ 16-1، closureی که به thread::spawn پاس می‌دهیم هیچ آرگومانی نمی‌گیرد: ما از هیچ داده‌ای از نخ اصلی در کد نخ ایجادشده استفاده نمی‌کنیم. برای استفاده از داده‌های نخ اصلی در نخ ایجادشده، closure نخ ایجادشده باید مقادیری که نیاز دارد را بگیرد. لیستینگ 16-3 تلاشی برای ایجاد یک بردار در نخ اصلی و استفاده از آن در نخ ایجادشده را نشان می‌دهد. با این حال، این کد هنوز کار نخواهد کرد، همان‌طور که در لحظه‌ای خواهید دید.

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 را بگیرد، و چون println! فقط به یک ارجاع به v نیاز دارد، closure سعی می‌کند v را قرض بگیرد. با این حال، مشکلی وجود دارد: 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 که در فصل 15 مورد بحث قرار گرفت. سپس، وقتی نخ ایجادشده شروع به اجرا می‌کند، 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 اجازه دهیم استنتاج کند که باید مقادیر را قرض بگیرد. تغییرات اعمال‌شده به لیستینگ 16-3 که در لیستینگ 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 دوباره ما را نجات دادند! ما از کد موجود در لیستینگ 16-3 خطا گرفتیم زیرا Rust محافظه‌کار بود و فقط v را برای نخ قرض گرفت، که به این معنا بود که نخ اصلی می‌توانست به‌صورت نظری مرجع نخ ایجادشده را نامعتبر کند. با گفتن به Rust که مالکیت v را به نخ ایجادشده منتقل کند، ما به Rust تضمین می‌دهیم که نخ اصلی دیگر از v استفاده نخواهد کرد. اگر لیستینگ 16-4 را به همان روش تغییر دهیم، آنگاه هنگام تلاش برای استفاده از v در نخ اصلی، قوانین مالکیت را نقض می‌کنیم. کلمه کلیدی move رفتار محافظه‌کارانه پیش‌فرض Rust در قرض‌گیری را لغو می‌کند؛ اما اجازه نمی‌دهد قوانین مالکیت را نقض کنیم.

با داشتن درک اولیه‌ای از نخ‌ها و API مربوط به نخ‌ها، بیایید ببینیم که با نخ‌ها چه کاری می‌توانیم انجام دهیم.

استفاده از پیام‌رسانی برای انتقال داده بین نخ‌ها

یکی از رویکردهای محبوب و فزاینده برای اطمینان از همزمانی ایمن، پیام‌رسانی است، جایی که نخ‌ها یا بازیگران با ارسال پیام‌های حاوی داده به یکدیگر ارتباط برقرار می‌کنند. ایده این رویکرد در یک شعار از مستندات زبان Go آمده است:
«با به اشتراک گذاشتن حافظه ارتباط برقرار نکنید؛ بلکه حافظه را با ارتباط برقرار کردن به اشتراک بگذارید.»

برای رسیدن به همزمانی مبتنی بر ارسال پیام، کتابخانه استاندارد Rust یک پیاده‌سازی از کانال‌ها ارائه می‌دهد. کانال یک مفهوم عمومی برنامه‌نویسی است که داده‌ها را از یک نخ به نخ دیگر ارسال می‌کند.

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

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

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

ابتدا، در لیستینگ 16-6، یک کانال ایجاد می‌کنیم اما هنوز کاری با آن انجام نمی‌دهیم. توجه داشته باشید که این کد هنوز کامپایل نمی‌شود زیرا Rust نمی‌تواند نوع مقادیری که می‌خواهیم از طریق کانال ارسال کنیم را تعیین کند.

Filename: src/main.rs

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}

لیستینگ 16-6: ایجاد یک کانال و اختصاص دو نیمه آن به tx و rx

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

تابع mpsc::channel یک جفت را برمی‌گرداند که عنصر اول آن انتهای ارسال‌کننده (فرستنده) و عنصر دوم آن انتهای گیرنده (گیرنده) است. اختصارات tx و rx در بسیاری از حوزه‌ها به ترتیب برای فرستنده و گیرنده استفاده می‌شوند، بنابراین متغیرهای خود را به این نام‌ها می‌نامیم تا هر انتها را نشان دهیم. ما از یک دستور let با یک الگو که جفت را تخریب می‌کند استفاده می‌کنیم؛ در فصل 19 درباره استفاده از الگوها در دستورات 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 را برای panic در صورت خطا فراخوانی می‌کنیم. اما در یک برنامه واقعی، باید آن را به درستی مدیریت کنیم: برای مرور استراتژی‌های مدیریت خطای مناسب به فصل 9 بازگردید.

در لیستینگ 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

عالی!

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

قوانین مالکیت نقش حیاتی در ارسال پیام دارند زیرا به شما کمک می‌کنند کد ایمن و همزمان بنویسید. جلوگیری از خطاها در برنامه‌نویسی همزمان مزیت فکر کردن به مالکیت در سراسر برنامه‌های 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 کامپایل و اجرا شد، اما به وضوح نشان نمی‌داد که دو نخ جداگانه از طریق کانال با یکدیگر صحبت می‌کنند. در لیستینگ 16-10 تغییراتی اعمال کرده‌ایم که ثابت می‌کند کد موجود در لیستینگ 16-8 به صورت همزمان اجرا می‌شود: نخ ایجادشده اکنون چندین پیام ارسال می‌کند و بین هر پیام یک ثانیه مکث می‌کند.

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

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

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

وقتی کد موجود در لیستینگ 16-10 را اجرا می‌کنید، باید خروجی زیر را ببینید، با یک مکث 1 ثانیه‌ای بین هر خط:

Got: hi
Got: from
Got: the
Got: thread

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

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

قبلاً اشاره کردیم که mpsc مخفف چندین تولیدکننده، یک مصرف‌کننده است. بیایید از mpsc استفاده کنیم و کد موجود در لیستینگ 16-10 را گسترش دهیم تا چندین نخ ایجاد کنیم که همگی مقادیر را به همان گیرنده ارسال می‌کنند. می‌توانیم این کار را با کلون کردن فرستنده انجام دهیم، همان‌طور که در لیستینگ 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)

ارسال پیام یک روش مناسب برای مدیریت همزمانی است، اما تنها روش نیست. روش دیگر این است که چندین نخ به یک داده مشترک دسترسی داشته باشند. دوباره این بخش از شعار مستندات زبان Go را در نظر بگیرید: «با به اشتراک‌گذاری حافظه ارتباط برقرار نکنید.»

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

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

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

Mutex مخفف mutual exclusion (حذف متقابل) است، به این معنا که یک mutex فقط به یک نخ اجازه می‌دهد در هر لحظه به برخی داده‌ها دسترسی داشته باشد. برای دسترسی به داده‌های یک mutex، یک نخ باید ابتدا سیگنال دهد که می‌خواهد دسترسی داشته باشد با درخواست قفل کردن (acquire the lock) mutex. قفل یک ساختار داده است که بخشی از mutex است و پیگیری می‌کند که چه کسی در حال حاضر به‌طور انحصاری به داده‌ها دسترسی دارد. بنابراین، mutex به‌عنوان نگهبانی از داده‌هایی که نگه می‌دارد توصیف می‌شود که از طریق سیستم قفل کار می‌کند.

Mutex‌ها به دلیل این که باید دو قانون را به خاطر بسپارید، به سخت بودن شهرت دارند:

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

برای یک تمثیل دنیای واقعی برای 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>>`, which is required by `{closure@src/main.rs:11:36: 11:43}: Send`
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`
   --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/std/src/thread/mod.rs:675:8
    |
672 | pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    |        ----- required by a bound in this function
...
675 |     F: Send + 'static,
    |        ^^^^ required by this bound in `spawn`

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 صحبت خواهیم کرد: یکی از ویژگی‌هایی که اطمینان می‌دهد نوع‌هایی که با Threadها استفاده می‌کنیم برای استفاده در شرایط همزمان طراحی شده‌اند.

متأسفانه، Rc<T> برای اشتراک‌گذاری بین Threadها ایمن نیست. وقتی Rc<T> شمارش مرجع را مدیریت می‌کند، برای هر فراخوانی به clone به شمارش اضافه می‌کند و وقتی هر کلون حذف می‌شود، از شمارش کم می‌کند. اما از هیچ ابزار همزمانی استفاده نمی‌کند تا مطمئن شود که تغییرات در شمارش نمی‌توانند توسط یک Thread دیگر قطع شوند. این می‌تواند به شمارش‌های اشتباه منجر شود—باگ‌های ظریفی که ممکن است باعث نشت حافظه یا حذف یک مقدار قبل از اتمام کار ما با آن شوند. چیزی که نیاز داریم، نوعی دقیقاً مانند Rc<T> است، اما یکی که تغییرات شمارش مرجع را به صورت ایمن در برابر Thread مدیریت کند.

شمارش ارجاع اتمی با 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 و نحوه استفاده از آن‌ها با نوع‌های سفارشی تکمیل خواهیم کرد.

همزمانی قابل‌توسعه با ویژگی‌های Sync و Send

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

با این حال، دو مفهوم همزمانی در زبان تعبیه شده‌اند: ویژگی‌های std::marker به نام‌های Sync و Send.

اجازه انتقال مالکیت بین نخ‌ها با Send

ویژگی نشانگر Send نشان می‌دهد که مالکیت مقادیر نوعی که Send را پیاده‌سازی می‌کند می‌تواند بین نخ‌ها منتقل شود. تقریباً هر نوعی در راست Send است، اما برخی استثناها وجود دارند، از جمله Rc<T>: این نوع نمی‌تواند Send باشد زیرا اگر یک مقدار Rc<T> را کلون کنید و سعی کنید مالکیت کلون را به نخ دیگری منتقل کنید، هر دو نخ ممکن است شمارش ارجاع را هم‌زمان به‌روزرسانی کنند. به این دلیل، Rc<T> برای استفاده در شرایط تک‌ریسمانی طراحی شده است که نمی‌خواهید جریمه عملکرد ایمنی نخ را پرداخت کنید.

بنابراین، سیستم نوعی و محدودیت‌های ویژگی راست تضمین می‌کنند که هرگز به‌طور ناخواسته یک مقدار Rc<T> را به صورت ناایمن بین نخ‌ها ارسال نکنید. وقتی سعی کردیم این کار را در فهرست 16-14 انجام دهیم، خطای the trait Send is not implemented for Rc<Mutex<i32>> دریافت کردیم. وقتی به Arc<T> که Send است تغییر دادیم، کد کامپایل شد.

هر نوعی که به‌طور کامل از نوع‌های Send تشکیل شده باشد به‌طور خودکار به عنوان Send علامت‌گذاری می‌شود. تقریباً تمام نوع‌های اولیه Send هستند، به جز اشاره‌گر (Pointer)های خام، که در فصل 20 درباره آن‌ها صحبت خواهیم کرد.

اجازه دسترسی از چندین نخ با Sync

ویژگی نشانگر Sync نشان می‌دهد که نوعی که Sync را پیاده‌سازی می‌کند می‌تواند از چندین نخ به آن ارجاع داده شود. به عبارت دیگر، هر نوع T، Sync است اگر &T (یک ارجاع غیرقابل‌تغییر به T) Send باشد، به این معنی که ارجاع می‌تواند به صورت ایمن به نخ دیگری ارسال شود. مشابه Send، نوع‌های اولیه Sync هستند و نوع‌هایی که به طور کامل از نوع‌های Sync تشکیل شده‌اند نیز Sync هستند.

اسمارت پوینتر Rc<T> نیز به همان دلایلی که Send نیست، Sync هم نیست. نوع RefCell<T> (که در فصل 15 درباره آن صحبت کردیم) و خانواده نوع‌های مرتبط Cell<T> نیز Sync نیستند. پیاده‌سازی بررسی وام‌دهی که RefCell<T> در زمان اجرا انجام می‌دهد، برای نخ ایمن نیست. اسمارت پوینتر Mutex<T>، Sync است و می‌تواند برای اشتراک‌گذاری دسترسی بین چندین نخ استفاده شود، همانطور که در بخش «اشتراک یک Mutex<T> بین چندین نخ» مشاهده کردید.

پیاده‌سازی دستی Send و Sync ناایمن است

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

پیاده‌سازی دستی این ویژگی‌ها شامل پیاده‌سازی کد ناایمن در راست می‌شود. ما در فصل 20 درباره استفاده از کد ناایمن در راست صحبت خواهیم کرد؛ فعلاً، اطلاعات مهم این است که ساخت نوع‌های همزمان جدید که از قسمت‌های Send و Sync تشکیل نشده‌اند نیاز به دقت زیادی دارد تا اصول ایمنی رعایت شوند. “The Rustonomicon” اطلاعات بیشتری درباره این اصول و نحوه رعایت آن‌ها ارائه می‌دهد.

خلاصه

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

همانطور که قبلاً اشاره شد، به دلیل اینکه بخش بسیار کمی از نحوه مدیریت همزمانی در راست بخشی از زبان است، بسیاری از راه‌حل‌های همزمانی به‌عنوان 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_element| title_element.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 در ایجاد یک Thread جدید با spawn مشاهده کردیم، جایی که Closureی که به یک Thread دیگر ارسال کردیم بلافاصله شروع به اجرا کرد. همچنین، این رفتار با نحوه استفاده بسیاری از زبان‌های دیگر از async متفاوت است. اما این برای Rust مهم است و بعداً خواهیم دید چرا.

وقتی response_text را داریم، می‌توانیم آن را با استفاده از Html::parse به یک نمونه از نوع Html تجزیه کنیم. به جای یک رشته خام، اکنون یک نوع داده داریم که می‌توانیم از آن برای کار با HTML به عنوان یک ساختار داده غنی‌تر استفاده کنیم. به طور خاص، می‌توانیم از متد select_first برای پیدا کردن اولین نمونه از یک انتخابگر CSS خاص استفاده کنیم. با ارسال رشته "title"، اولین عنصر <title> در سند را دریافت خواهیم کرد، اگر وجود داشته باشد. چون ممکن است هیچ عنصر مطابقتی وجود نداشته باشد، select_first یک Option<ElementRef> بازمی‌گرداند. در نهایت، از متد Option::map استفاده می‌کنیم که به ما اجازه می‌دهد با آیتم موجود در Option کار کنیم، اگر موجود باشد، و اگر موجود نباشد، هیچ کاری انجام ندهیم. (می‌توانستیم از یک عبارت match هم استفاده کنیم، اما map بیشتر idiomatic است.) در بدنه تابعی که به map می‌دهیم، متد inner_html را روی title_element فراخوانی می‌کنیم تا محتوای آن را که یک String است، دریافت کنیم. وقتی همه چیز انجام شد، یک Option<String> خواهیم داشت.

توجه کنید که کلمه کلیدی await در Rust بعد از عبارت مورد انتظار قرار می‌گیرد، نه قبل از آن. یعنی این یک کلمه کلیدی postfix است. این ممکن است با چیزی که به آن عادت دارید اگر از async در زبان‌های دیگر استفاده کرده باشید، متفاوت باشد، اما در Rust این کار زنجیره‌ای از متدها را بسیار راحت‌تر می‌کند. در نتیجه، می‌توانیم بدنه page_url_for را تغییر دهیم تا فراخوانی‌های تابع trpl::get و text را با await بین آن‌ها به هم زنجیر کنیم، همان‌طور که در لیست ۱۷-۲ نشان داده شده است.

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_element| title_element.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 که در فصل ۱۰ در بخش “ویژگی‌ها به عنوان پارامتر” بحث کردیم، استفاده می‌کند.
  • ویژگی بازگردانده‌شده یک Future با یک نوع وابسته به نام Output است. توجه کنید که نوع Output برابر با Option<String> است، که همان نوع بازگشتی نسخه اصلی async fn تابع page_title است.
  • تمام کدی که در بدنه تابع اصلی فراخوانی شده است، در یک بلوک async move بسته‌بندی شده است. به یاد داشته باشید که بلوک‌ها بیان (expression) هستند. این بلوک کامل، بیانی است که از تابع بازگردانده می‌شود.
  • این بلوک async یک مقداری با نوع Option<String> تولید می‌کند، همان‌طور که توضیح داده شد. این مقدار با نوع Output در نوع بازگشتی مطابقت دارد. این درست مانند بلوک‌های دیگری است که قبلاً دیده‌اید.
  • بدنه جدید تابع یک بلوک async move است به دلیل نحوه استفاده از پارامتر url. (در ادامه فصل بیشتر درباره تفاوت async و async move صحبت خواهیم کرد.)
  • نسخه جدید تابع دارای نوعی طول عمر است که قبلاً ندیده‌ایم: '_. از آنجا که تابع یک future بازمی‌گرداند که به یک مرجع اشاره می‌کند—در این مورد، مرجعی که از پارامتر url آمده است—باید به Rust بگوییم که می‌خواهیم آن مرجع شامل شود. نیازی نیست طول عمر را اینجا نام‌گذاری کنیم، زیرا Rust به اندازه کافی هوشمند است که بفهمد فقط یک مرجع می‌تواند درگیر باشد، اما باید صراحتاً مشخص کنیم که future حاصل به آن طول عمر محدود شده است.

حالا می‌توانیم 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_element| title_element.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_element| title_element.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 خودش به عنوان یک تابع async در لیست ۱۷-۳ تعریف شود. اگر main یک تابع async بود، چیزی دیگری باید ماشین حالت را برای futureی که main بازمی‌گرداند مدیریت می‌کرد، اما main نقطه شروع برنامه است! در عوض، ما تابع trpl::run را در main فراخوانی کردیم تا یک runtime راه‌اندازی کند و future بازگردانده‌شده توسط بلوک async را تا زمانی که Ready بازگرداند، اجرا کند.

نکته: برخی runtimeها ماکروهایی ارائه می‌دهند که به شما اجازه می‌دهند یک تابع async برای main بنویسید. این ماکروها async fn main() { ... } را به یک fn main عادی تبدیل می‌کنند، که همان کاری را انجام می‌دهد که ما به صورت دستی در لیست ۱۷-۵ انجام دادیم: فراخوانی یک تابع که یک 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 is: '{title}'"),
            None => println!("Its title could not be parsed."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let text = trpl::get(url).await.text().await;
    let title = Html::parse(&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 مقدار Left را با خروجی future اول بازمی‌گرداند اگر آرگومان اول برنده شود، و مقدار Right را با خروجی future دوم بازمی‌گرداند اگر آن یکی برنده شود. این ترتیب با ترتیبی که آرگومان‌ها هنگام فراخوانی تابع ظاهر می‌شوند مطابقت دارد: آرگومان اول در سمت چپ آرگومان دوم قرار دارد.

همچنین تابع 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!("Got: {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 به یک مجموعه و سپس انتظار برای کامل شدن برخی یا تمام آن‌ها یک الگوی رایج است.

برای بررسی تمام futures در یک مجموعه، باید روی همه آن‌ها حلقه بزنیم و آن‌ها را join کنیم. تابع trpl::join_all هر نوعی را که ویژگی Iterator را پیاده‌سازی می‌کند قبول می‌کند، که در فصل ۱۳ در بخش ویژگی Iterator و متد next درباره آن یاد گرفتید، بنابراین به نظر می‌رسد دقیقاً همان چیزی است که نیاز داریم. بیایید سعی کنیم futures خود را در یک وکتور قرار دهیم و join! را با join_all جایگزین کنیم، همان‌طور که در لیست ۱۷-۱۵ نشان داده شده است.

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های مختلفی که توسط کامپایلر تولید می‌شوند اعمال می‌شود.

برای اینکه این کار انجام شود، باید از اشیاء ویژگی (trait objects) استفاده کنیم، همان‌طور که در “بازگرداندن خطاها از تابع run” در فصل ۱۲ انجام دادیم. (ما اشیاء ویژگی را در فصل ۱۸ به‌طور مفصل پوشش خواهیم داد.) استفاده از اشیاء ویژگی به ما اجازه می‌دهد هر یک از futureهای ناشناس تولیدشده توسط این انواع را به‌عنوان یک نوع یکسان در نظر بگیریم، زیرا همه آن‌ها ویژگی 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::{future::Future, 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[E0308]: mismatched types
   --> src/main.rs:46:46
    |
10  |         let tx1_fut = async move {
    |                       ---------- the expected `async` block
...
24  |         let rx_fut = async {
    |                      ----- the found `async` block
...
46  |             vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];
    |                                     -------- ^^^^^^ expected `async` block, found a different `async` block
    |                                     |
    |                                     arguments to this function are incorrect
    |
    = 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
note: associated function defined here
   --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/alloc/src/boxed.rs:255:12
    |
255 |     pub fn new(x: T) -> Self {
    |            ^^^

error[E0308]: mismatched types
   --> src/main.rs:46:64
    |
10  |         let tx1_fut = async move {
    |                       ---------- the expected `async` block
...
30  |         let tx_fut = async move {
    |                      ---------- the found `async` block
...
46  |             vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];
    |                                                       -------- ^^^^^^ expected `async` block, found a different `async` block
    |                                                       |
    |                                                       arguments to this function are incorrect
    |
    = note: expected `async` block `{async block@src/main.rs:10:23: 10:33}`
               found `async` block `{async block@src/main.rs:30:22: 30:32}`
    = 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
note: associated function defined here
   --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/alloc/src/boxed.rs:255:12
    |
255 |     pub fn new(x: T) -> Self {
    |            ^^^

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
   --> src/main.rs:48:24
    |
48  |         trpl::join_all(futures).await;
    |         -------------- ^^^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`, which is required by `Box<{async block@src/main.rs:10:23: 10:33}>: Future`
    |         |
    |         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<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `join_all`
   --> file:///home/.cargo/registry/src/index.crates.io-6f17d22bba15001f/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]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:9
   |
48 |         trpl::join_all(futures).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`, which is required by `Box<{async block@src/main.rs:10:23: 10:33}>: Future`
   |
   = 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-6f17d22bba15001f/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]: `{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}`, which is required by `Box<{async block@src/main.rs:10:23: 10:33}>: Future`
   |
   = 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-6f17d22bba15001f/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`

این پیام اطلاعات زیادی برای هضم کردن دارد، بنابراین بیایید آن را تجزیه کنیم. بخش اول پیام به ما می‌گوید که اولین بلوک async (src/main.rs:8:23: 20:10) ویژگی Unpin را پیاده‌سازی نمی‌کند و پیشنهاد می‌دهد از pin! یا Box::pin برای حل آن استفاده کنیم. در ادامه این فصل، جزئیات بیشتری درباره Pin و Unpin بررسی خواهیم کرد. با این حال، فعلاً می‌توانیم فقط از توصیه کامپایلر پیروی کنیم تا از این مشکل عبور کنیم. در لیست ۱۷-۱۸، ابتدا با به‌روزرسانی اعلان نوع برای futures شروع می‌کنیم، به طوری که هر Box درون یک Pin قرار بگیرد. دوم، از Box::pin برای pin کردن خود futures استفاده می‌کنیم.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{
    future::Future,
    pin::{pin, Pin},
    time::Duration,
};

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(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 = pin!(async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(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.

با این حال، هنوز باید نوع مرجع pin شده را به‌صراحت مشخص کنیم؛ در غیر این صورت، Rust هنوز نمی‌داند که این‌ها را به‌عنوان اشیاء ویژگی دینامیک تفسیر کند، که دقیقاً همان چیزی است که ما در Vec به آن نیاز داریم. بنابراین، هر future را زمانی که تعریف می‌کنیم با pin! pin می‌کنیم، و futures را به‌عنوان یک Vec که شامل مراجع متغیر pin شده به نوع future دینامیک است تعریف می‌کنیم، همان‌طور که در لیست ۱۷-۱۹ نشان داده شده است.

با این حال، باید به‌صراحت نوع مرجع pinned را مشخص کنیم؛ در غیر این صورت، راست همچنان نمی‌داند که این‌ها را به‌عنوان شیءهای ویژگی دینامیک تفسیر کند، که همان چیزی است که برای قرار گرفتن در Vec نیاز داریم. بنابراین، هر آینده را وقتی تعریف می‌کنیم pin! می‌کنیم و futures را به‌عنوان یک Vec که شامل مراجع متغیر pinned به نوع ویژگی دینامیک Future است تعریف می‌کنیم، همانطور که در فهرست 17-19 نشان داده شده است.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{
    future::Future,
    pin::{pin, Pin},
    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", 35);
            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", 35);
            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::{future::Future, time::Duration};

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_millis(10)).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::{future::Future, 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 را در بخش ویژگی Iterator و متد next بررسی کردیم، اما بین iteratorها و گیرنده کانال async دو تفاوت وجود دارد. تفاوت اول در زمان است: iteratorها همزمان (synchronous) هستند، در حالی که گیرنده کانال async است. تفاوت دوم در API است. هنگام کار مستقیم با Iterator، ما متد همزمان next را فراخوانی می‌کنیم. به طور خاص، با stream trpl::Receiver، به جای آن، یک متد async به نام recv را فراخوانی کردیم. در غیر این صورت، این APIها احساس بسیار مشابهی دارند و این شباهت تصادفی نیست. یک stream مانند یک شکل ناهمزمان از iteration است. در حالی که trpl::Receiver به طور خاص منتظر دریافت پیام‌ها است، API عمومی stream بسیار گسترده‌تر است: این API آیتم بعدی را همان‌طور که Iterator انجام می‌دهد ارائه می‌دهد، اما به صورت ناهمزمان.

شباهت بین iteratorها و stream‌ها در Rust به این معناست که ما در واقع می‌توانیم از هر iterator یک stream ایجاد کنیم. مانند یک iterator، می‌توانیم با فراخوانی متد next یک stream کار کنیم و سپس خروجی را انتظار بکشیم، همان‌طور که در لیست ۱۷-۳۰ نشان داده شده است.

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-9de943556a6001b8.long-type-1281356139287206597.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}`, which is required by `Box<{async block@src/main.rs:10:23: 10:33}>: Future`
   |
   = 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-6f17d22bba15001f/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 داریم که آیتم‌ها دارای مراجع داخلی باشند. مقادیر اولیه (primitive) مانند اعداد و مقادیر 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) در Rust

برنامه‌نویسی شی‌گرا (OOP) روشی برای مدل‌سازی برنامه‌ها است. مفهوم اشیاء به‌عنوان یک مفهوم برنامه‌نویسی در دهه 1960 در زبان برنامه‌نویسی Simula معرفی شد. این اشیاء بر معماری برنامه‌نویسی آلن کی تأثیر گذاشتند که در آن اشیاء پیام‌هایی را به یکدیگر ارسال می‌کنند. برای توصیف این معماری، او اصطلاح برنامه‌نویسی شی‌گرا را در سال 1967 ابداع کرد. تعاریف متعددی با یکدیگر رقابت می‌کنند تا توضیح دهند که OOP چیست، و بر اساس برخی از این تعاریف 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 عمومی آن است؛ کدی که از شیء استفاده می‌کند نباید بتواند به جزئیات داخلی شیء دسترسی پیدا کند و داده‌ها یا رفتار را به صورت مستقیم تغییر دهد. این امکان را به برنامه‌نویس می‌دهد که جزئیات داخلی شیء را تغییر داده و بازسازی کند بدون اینکه نیازی به تغییر کدی که از آن شیء استفاده می‌کند، داشته باشد.

ما در فصل 7 بحث کردیم که چگونه می‌توان کپسوله‌سازی را کنترل کرد: می‌توانیم از کلمه کلیدی pub استفاده کنیم تا تصمیم بگیریم کدام ماژول‌ها، انواع، توابع و متدها در کد ما عمومی باشند، و به‌طور پیش‌فرض همه چیز دیگر خصوصی است. برای مثال، می‌توانیم یک struct به نام AveragedCollection تعریف کنیم که یک فیلد شامل یک بردار از مقادیر i32 دارد. این struct همچنین می‌تواند یک فیلد داشته باشد که میانگین مقادیر موجود در بردار را نگه می‌دارد، به این معنا که نیازی به محاسبه میانگین به صورت لحظه‌ای نیست هر زمان که کسی به آن نیاز داشت. به عبارت دیگر، AveragedCollection میانگین محاسبه‌شده را برای ما کش می‌کند. لیستینگ 18-1 تعریف struct 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 پیاده‌سازی کند، می‌تواند صفت 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 ارسال می‌کنیم که قصد نداشتیم ارسال کنیم و بنابراین باید نوع دیگری را ارسال کنیم یا باید Draw را روی String پیاده‌سازی کنیم تا Screen بتواند متد draw را روی آن فراخوانی کند.

اشیاء صفت اجرای Dispatch پویا را انجام می‌دهند

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

وقتی از اشیاء صفت استفاده می‌کنیم، Rust مجبور است از dispatch پویا استفاده کند. کامپایلر نمی‌داند که چه نوع‌هایی ممکن است با کدی که از اشیاء صفت استفاده می‌کند، استفاده شوند، بنابراین نمی‌داند کدام متد پیاده‌سازی‌شده روی کدام نوع را باید فراخوانی کند. در عوض، در زمان اجرا، Rust از اشاره‌گر (Pointer)های داخل شیء صفت استفاده می‌کند تا بداند کدام متد را باید فراخوانی کند. این جستجو هزینه زمان اجرایی به همراه دارد که با dispatch استاتیک اتفاق نمی‌افتد. dispatch پویا همچنین از این جلوگیری می‌کند که کامپایلر کد یک متد را inline کند، که به نوبه خود از برخی بهینه‌سازی‌ها جلوگیری می‌کند. Rust همچنین قوانینی دارد که مشخص می‌کنند کجا می‌توانید و کجا نمی‌توانید از dispatch پویا استفاده کنید، که به سازگاری dyn معروف است. با این حال، ما در کدی که در لیستینگ 18-5 نوشتیم و توانستیم در لیستینگ 18-9 پشتیبانی کنیم، انعطاف‌پذیری بیشتری به دست آوردیم، بنابراین این موضوع یک موازنه است که باید مورد توجه قرار گیرد.

پیاده‌سازی یک الگوی طراحی شی‌گرا

الگوی وضعیت یک الگوی طراحی شی‌گرا است. هسته این الگو این است که مجموعه‌ای از وضعیت‌ها را که یک مقدار می‌تواند به‌طور داخلی داشته باشد، تعریف کنیم. این وضعیت‌ها با مجموعه‌ای از اشیای وضعیت نمایش داده می‌شوند و رفتار مقدار بر اساس وضعیت آن تغییر می‌کند. قصد داریم مثالی از یک ساختار blog post (پست وبلاگ) را بررسی کنیم که یک فیلد برای نگه‌داشتن وضعیت دارد. این وضعیت یک شیء از مجموعه “پیش‌نویس” (draft)، “در حال بررسی” (review)، یا “منتشرشده” (published) خواهد بود.

اشیای وضعیت قابلیت‌هایی را به اشتراک می‌گذارند: در Rust، البته، ما از ساختارها (structs) و صفت‌ها (traits) به جای اشیا و ارث‌بری استفاده می‌کنیم. هر شیء وضعیت مسئول رفتار خود و مدیریت زمانی است که باید به وضعیت دیگری تغییر کند. مقداری که یک شیء وضعیت را نگه می‌دارد، هیچ اطلاعی از رفتارهای مختلف وضعیت‌ها یا زمان تغییر وضعیت ندارد.

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

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

قابلیت نهایی به این شکل خواهد بود:

  1. یک پست وبلاگ به‌صورت یک پیش‌نویس خالی شروع می‌شود.
  2. وقتی پیش‌نویس تمام شد، بررسی پست درخواست می‌شود.
  3. وقتی پست تأیید شد، منتشر می‌شود.
  4. تنها پست‌های وبلاگی که منتشر شده‌اند متن را برای چاپ بازمی‌گردانند، بنابراین پست‌های تأییدنشده نمی‌توانند به‌طور تصادفی منتشر شوند.

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

لیستینگ 18-11 این فرآیند کاری را به‌صورت کدی نشان می‌دهد: این یک نمونه از استفاده از API است که قصد داریم در یک crate کتابخانه‌ای به نام 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 است. این نوع از الگوی وضعیت استفاده خواهد کرد و مقداری نگه می‌دارد که یکی از سه شیء وضعیت نمایش‌دهنده وضعیت‌های مختلف یک پست باشد—پیش‌نویس، در انتظار بررسی، یا منتشرشده. تغییر از یک وضعیت به وضعیت دیگر به‌صورت داخلی در نوع Post مدیریت می‌شود. تغییر وضعیت‌ها در پاسخ به متدهایی که کاربران کتابخانه ما روی نمونه Post فراخوانی می‌کنند اتفاق می‌افتد، اما کاربران مجبور نیستند تغییر وضعیت‌ها را مستقیماً مدیریت کنند. همچنین، کاربران نمی‌توانند در مورد وضعیت‌ها اشتباه کنند، مانند انتشار یک پست قبل از بررسی آن.

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

بیایید پیاده‌سازی کتابخانه را شروع کنیم! می‌دانیم که به یک ساختار 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 هستیم که add_text روی آن فراخوانی شده است. سپس، متد push_str را روی String موجود در content فراخوانی می‌کنیم و آرگومان text را برای افزودن به محتوای ذخیره‌شده به آن می‌دهیم. این رفتار به وضعیتی که پست در آن قرار دارد وابسته نیست، بنابراین بخشی از الگوی وضعیت نیست. متد add_text هیچ تعاملی با فیلد state ندارد، اما بخشی از رفتاری است که می‌خواهیم پشتیبانی کنیم.

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

حتی پس از فراخوانی add_text و افزودن محتوایی به پست، همچنان می‌خواهیم متد content یک برش رشته خالی بازگرداند، زیرا پست هنوز در وضعیت پیش‌نویس است، همان‌طور که در خط 7 لیستینگ 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 نیاز به گرفتن مالکیت مقدار وضعیت دارد. اینجاست که Option در فیلد state از Post وارد عمل می‌شود: ما متد take را فراخوانی می‌کنیم تا مقدار Some را از فیلد state خارج کرده و یک مقدار None به جای آن قرار دهیم، زیرا Rust به ما اجازه نمی‌دهد فیلدهای ساختار را بدون مقدار رها کنیم. این کار به ما امکان می‌دهد مقدار state را از Post منتقل کنیم، نه اینکه آن را قرض بگیریم. سپس مقدار state پست را به نتیجه این عملیات تنظیم خواهیم کرد.

باید به‌طور موقت state را به None تنظیم کنیم، نه اینکه مستقیماً آن را با کدی مانند self.state = self.state.request_review(); تنظیم کنیم، تا مالکیت مقدار state را بدست آوریم. این کار اطمینان می‌دهد که Post نمی‌تواند از مقدار قدیمی state پس از تبدیل آن به یک وضعیت جدید استفاده کند.

متد request_review در Draft یک نمونه جدید از ساختار PendingReview را که نشان‌دهنده وضعیت زمانی است که یک پست منتظر بررسی است بازمی‌گرداند. ساختار PendingReview نیز متد request_review را پیاده‌سازی می‌کند، اما هیچ تبدیلی انجام نمی‌دهد. بلکه خودش را بازمی‌گرداند، زیرا وقتی برای یک پست در وضعیت PendingReview درخواست بررسی می‌کنیم، باید در همان وضعیت باقی بماند.

اکنون می‌توانیم مزایای الگوی وضعیت را مشاهده کنیم: متد request_review در Post بدون توجه به مقدار state آن یکسان است. هر وضعیت مسئول قوانین خاص خود است.

ما متد content در Post را به همان صورت باقی می‌گذاریم که یک برش رشته خالی بازمی‌گرداند. اکنون می‌توانیم یک Post در وضعیت PendingReview و همچنین در وضعیت Draft داشته باشیم، اما می‌خواهیم همان رفتار در وضعیت PendingReview نیز باشد. لیستینگ 18-11 اکنون تا خط 10 کار می‌کند!

افزودن approve برای تغییر رفتار content

متد approve شبیه متد request_review خواهد بود: این متد مقدار 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 که در یک Box قرار دارد، بازمی‌گرداند. ساختار Published صفت State را پیاده‌سازی می‌کند، و برای متدهای request_review و approve خودش را بازمی‌گرداند، زیرا در این موارد پست باید در وضعیت Published باقی بماند.

اکنون باید متد content در Post را به‌روزرسانی کنیم. می‌خواهیم مقدار بازگشتی از content به وضعیت فعلی Post بستگی داشته باشد، بنابراین می‌خواهیم Post این وظیفه را به متد content تعریف‌شده در وضعیت خود واگذار کند، همان‌طور که در لیستینگ 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 نشان داده شده است:

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: Adding the content method to the State trait

ما برای متد content یک پیاده‌سازی پیش‌فرض اضافه می‌کنیم که یک برش رشته خالی بازمی‌گرداند. این کار باعث می‌شود نیازی به پیاده‌سازی content روی ساختارهای Draft و PendingReview نداشته باشیم. ساختار Published متد content را بازنویسی کرده و مقدار موجود در post.content را بازمی‌گرداند.

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

و تمام—اکنون تمام لیستینگ 18-11 کار می‌کند! ما الگوی وضعیت را با قوانین مربوط به فرآیند کاری پست وبلاگ پیاده‌سازی کرده‌ایم. منطق مربوط به قوانین در اشیای وضعیت قرار دارد، نه اینکه در سراسر Post پراکنده باشد.

چرا از Enum استفاده نکردیم؟

ممکن است این سؤال برای شما پیش آمده باشد که چرا از یک enum با حالت‌های مختلف پست به‌عنوان متغیرها استفاده نکردیم. این قطعاً یک راه‌حل ممکن است؛ آن را امتحان کنید و نتایج نهایی را مقایسه کنید تا ببینید کدام را ترجیح می‌دهید! یکی از معایب استفاده از enum این است که هر جا مقدار enum بررسی می‌شود نیاز به یک عبارت match یا چیزی مشابه برای مدیریت تمام متغیرهای ممکن داریم. این می‌تواند نسبت به راه‌حل اشیای صفتی که استفاده کردیم تکراری‌تر باشد.

مزایا و معایب الگوی وضعیت

ما نشان داده‌ایم که Rust قادر است الگوی وضعیت شی‌گرا را برای کپسوله کردن رفتارهای مختلف یک پست در هر حالت پیاده‌سازی کند. متدهای Post هیچ اطلاعی از رفتارهای مختلف ندارند. با روشی که کد را سازمان‌دهی کرده‌ایم، تنها باید در یک مکان به‌دنبال راه‌های مختلف رفتار یک پست منتشرشده بگردیم: پیاده‌سازی صفت State روی ساختار Published.

اگر بخواهیم یک پیاده‌سازی جایگزین ایجاد کنیم که از الگوی وضعیت استفاده نمی‌کند، ممکن است به‌جای آن از عبارات match در متدهای Post یا حتی در کد main استفاده کنیم که وضعیت پست را بررسی کرده و رفتار را در همان مکان‌ها تغییر می‌دهد. این به این معناست که باید در مکان‌های مختلفی جست‌وجو کنیم تا تمام پیامدهای یک پست در حالت منتشرشده را بفهمیم! و با اضافه شدن حالت‌های بیشتر، این موضوع فقط بدتر خواهد شد: هر یک از آن عبارات match نیاز به یک شاخه دیگر خواهند داشت.

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

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

  • یک متد reject اضافه کنید که وضعیت پست را از PendingReview به Draft تغییر دهد.
  • دو فراخوانی به approve نیاز داشته باشید تا وضعیت به Published تغییر کند.
  • اجازه دهید کاربران فقط زمانی که یک پست در حالت Draft است متن محتوا اضافه کنند. نکته: بگذارید شیء وضعیت مسئول تغییراتی باشد که ممکن است در محتوا ایجاد شود، اما مسئول اصلاح مستقیم Post نباشد.

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

یکی دیگر از معایب این است که ما برخی از منطق‌ها را تکرار کرده‌ایم. برای حذف برخی از این تکرارها، ممکن است سعی کنیم برای متدهای request_review و approve در صفت State پیاده‌سازی پیش‌فرضی ایجاد کنیم که self را بازمی‌گرداند؛ با این حال، این با dyn سازگار نخواهد بود، زیرا صفت دقیقاً نمی‌داند self چه خواهد بود. ما می‌خواهیم بتوانیم از State به‌عنوان یک شیء صفت استفاده کنیم، بنابراین متدهای آن باید با dyn سازگار باشند.

پیاده‌سازی مشابه متدهای request_review و approve روی Post نیز نوعی تکرار است. هر دو متد اجرای متد مشابه روی مقدار موجود در فیلد state از Option را به آن واگذار کرده و مقدار جدید فیلد state را به نتیجه تنظیم می‌کنند. اگر متدهای زیادی روی Post داشته باشیم که این الگو را دنبال می‌کنند، ممکن است تعریف یک ماکرو را برای حذف این تکرار در نظر بگیریم (بخش “ماکروها” در فصل 20 را ببینید).

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

کدگذاری وضعیت‌ها و رفتار به‌عنوان انواع

به شما نشان خواهیم داد که چگونه الگوی وضعیت را دوباره طراحی کنید تا مجموعه‌ای متفاوت از مزایا و معایب به دست آورید. به‌جای اینکه وضعیت‌ها و انتقالات را کاملاً کپسوله کنیم تا کد خارجی از آن‌ها اطلاعی نداشته باشد، وضعیت‌ها را به انواع مختلف کدگذاری می‌کنیم. در نتیجه، سیستم بررسی نوع 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());
}

ما همچنان امکان ایجاد پست‌های جدید در وضعیت پیش‌نویس با استفاده از Post::new و افزودن متن به محتوای پست را فراهم می‌کنیم. اما به‌جای داشتن متد content روی یک پست پیش‌نویس که یک رشته خالی بازمی‌گرداند، آن را به گونه‌ای طراحی می‌کنیم که پست‌های پیش‌نویس اصلاً متد content نداشته باشند. به این ترتیب، اگر بخواهیم محتوای یک پست پیش‌نویس را دریافت کنیم، خطای کامپایلر دریافت خواهیم کرد که به ما می‌گوید این متد وجود ندارد. در نتیجه، نمایش محتوای پست‌های پیش‌نویس در محیط تولید به‌طور تصادفی غیرممکن می‌شود، زیرا آن کد حتی کامپایل نخواهد شد. لیستینگ 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 تعریف‌شده ندارد! بنابراین اکنون برنامه تضمین می‌کند که تمام پست‌ها به‌صورت پست‌های پیش‌نویس شروع می‌شوند و پست‌های پیش‌نویس محتوای خود را برای نمایش در دسترس ندارند. هر تلاشی برای دور زدن این محدودیت‌ها منجر به خطای کامپایلر خواهد شد.

پیاده‌سازی انتقال‌ها به‌عنوان تبدیل به انواع مختلف

چگونه می‌توانیم یک پست منتشرشده داشته باشیم؟ ما می‌خواهیم قانون را اجرا کنیم که یک پست پیش‌نویس باید بررسی و تأیید شود قبل از اینکه بتواند منتشر شود. یک پست در حالت “در انتظار بررسی” همچنان نباید هیچ محتوایی نمایش دهد. بیایید این محدودیت‌ها را با اضافه کردن یک ساختار دیگر به نام 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 که از یک الگو و یک عبارت برای اجرا در صورت مطابقت مقدار با الگوی آن بازو تشکیل شده‌اند، تعریف می‌شوند، مانند این:

match VALUE {
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
}

برای مثال، اینجا عبارت 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” بعداً در این فصل به‌طور مفصل بررسی خواهیم کرد.

Conditional if let Expressions

در فصل 6 بحث کردیم که چگونه از عبارات if let عمدتاً به‌عنوان یک روش کوتاه‌تر برای نوشتن معادل یک match که فقط یک حالت را مطابقت می‌دهد استفاده کنیم. به‌صورت اختیاری، if let می‌تواند یک else متناظر داشته باشد که شامل کدی برای اجرا در صورت عدم مطابقت الگو در if let باشد.

فهرست 19-1 نشان می‌دهد که همچنین ممکن است عبارات if let، else if، و else if let را با هم ترکیب و تطبیق دهید. این کار به ما انعطاف بیشتری نسبت به یک عبارت match می‌دهد، که در آن فقط می‌توانیم یک مقدار برای مقایسه با الگوها بیان کنیم. همچنین، راست نیاز ندارد که شرایط در یک سری از بازوهای if let، else if، else if let به یکدیگر مرتبط باشند.

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

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-1: ترکیب 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

مشابه با ساختار if let، حلقه شرطی while let به یک حلقه while اجازه می‌دهد تا زمانی که یک الگو همچنان مطابقت دارد، اجرا شود. اولین بار یک حلقه while let را در فصل 17 دیدیم، جایی که از آن برای ادامه حلقه زدن تا زمانی که یک stream مقادیر جدید تولید می‌کرد استفاده کردیم. به‌طور مشابه، در فهرست 19-2 یک حلقه while let نشان داده می‌شود که منتظر پیام‌هایی است که بین نخ‌ها ارسال می‌شود، اما در این مورد یک Result را بررسی می‌کند به‌جای یک Option.

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-2: استفاده از یک حلقه while let برای چاپ مقادیر تا زمانی که rx.recv() مقدار Ok را بازمی‌گرداند

این مثال مقادیر 1، 2، و 3 را چاپ می‌کند. وقتی که recv را در فصل 16 دیدیم، خطا را مستقیماً unwrap می‌کردیم یا با استفاده از یک حلقه for به‌عنوان یک iterator با آن تعامل داشتیم. با این حال، همان‌طور که فهرست 19-2 نشان می‌دهد، می‌توانیم از while let نیز استفاده کنیم، زیرا متد recv تا زمانی که فرستنده پیام‌ها تولید می‌کند مقدار Ok بازمی‌گرداند و سپس زمانی که طرف فرستنده قطع می‌شود یک مقدار Err تولید می‌کند.

for Loops

در یک حلقه for، مقداری که مستقیماً بعد از کلمه کلیدی for می‌آید یک الگو است. برای مثال، در عبارت for x in y مقدار x یک الگو است. فهرست 19-3 نشان می‌دهد که چگونه می‌توان از یک الگو در یک حلقه for برای تخریب (destructure) یا تجزیه یک tuple به‌عنوان بخشی از حلقه for استفاده کرد.

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}
Listing 19-3: Using a pattern in a for loop to destructure a tuple

کد در فهرست 19-3 خروجی زیر را چاپ خواهد کرد:

$ 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

ما یک iterator را با استفاده از متد enumerate تطبیق می‌دهیم تا یک مقدار و شاخص آن مقدار را تولید کند، که در یک tuple قرار می‌گیرد. اولین مقدار تولیدشده tuple (0, 'a') است. وقتی این مقدار با الگوی (index, value) مطابقت داده می‌شود، مقدار index برابر با 0 و مقدار value برابر با 'a' خواهد بود، و اولین خط خروجی چاپ می‌شود.

let Statements

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

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

هر بار که از یک عبارت let مانند این استفاده کرده‌اید، از الگوها استفاده کرده‌اید، حتی اگر متوجه آن نشده باشید! به‌طور رسمی، یک عبارت let به این شکل است:

let PATTERN = EXPRESSION;

در عبارات مانند let x = 5; با یک نام متغیر در محل PATTERN، نام متغیر فقط یک شکل ساده از یک الگو است. راست عبارت را با الگو مقایسه می‌کند و هر نامی که پیدا می‌کند را تخصیص می‌دهد. بنابراین در مثال let x = 5;، x الگویی است که به این معناست: «هر چیزی که در اینجا مطابقت دارد را به متغیر x اختصاص بده». چون نام x کل الگو است، این الگو به‌طور مؤثر به این معناست: «هر چیزی که هست را به متغیر x اختصاص بده».

برای مشاهده جنبه تطبیق الگو در let به‌صورت واضح‌تر، فهرست 19-4 را در نظر بگیرید، که از یک الگو با let برای تخریب یک tuple استفاده می‌کند.

fn main() {
    let (x, y, z) = (1, 2, 3);
}
Listing 19-4: Using a pattern to destructure a tuple and create three variables at once

اینجا، ما یک tuple را با یک الگو مطابقت می‌دهیم. راست مقدار (1, 2, 3) را با الگوی (x, y, z) مقایسه می‌کند و می‌بیند که مقدار با الگو مطابقت دارد، بنابراین راست 1 را به x، 2 را به y، و 3 را به z اختصاص می‌دهد. می‌توانید این الگوی tuple را به‌عنوان سه الگوی متغیر فردی که درون آن قرار دارند تصور کنید.

اگر تعداد عناصر در الگو با تعداد عناصر در tuple مطابقت نداشته باشد، کل نوع مطابقت نخواهد داشت و یک خطای کامپایلر دریافت خواهیم کرد. برای مثال، فهرست 19-5 یک تلاش برای تخریب یک tuple با سه عنصر به دو متغیر را نشان می‌دهد، که کار نخواهد کرد.

fn main() {
    let (x, y) = (1, 2, 3);
}
Listing 19-5: Incorrectly constructing a pattern whose variables don’t match the number of elements in the tuple

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

$ 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

برای رفع خطا، می‌توانیم یک یا چند مقدار در tuple را با استفاده از _ یا .. نادیده بگیریم، همان‌طور که در بخش “Ignoring Values in a Pattern” خواهید دید. اگر مشکل این است که تعداد متغیرها در الگو بیش از حد است، راه‌حل این است که نوع‌ها را با حذف متغیرها طوری تطبیق دهیم که تعداد متغیرها برابر با تعداد عناصر در tuple شود.

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/ch18-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;
    if let Some(x) = some_option_value {
        println!("{x}");
    }
}
Listing 19-9: استفاده از if let و یک بلوک با الگوهای قابل‌رد به‌جای let

ما به کد یک مسیر خروجی دادیم! این کد اکنون کاملاً معتبر است. با این حال، اگر به if let یک الگوی غیرقابل‌رد (الگویی که همیشه مطابقت دارد)، مانند x، بدهیم، همان‌طور که در فهرست 19-10 نشان داده شده است، کامپایلر یک هشدار خواهد داد.

fn main() {
    if let x = 5 {
        println!("{x}");
    };
}
Listing 19-10: تلاش برای استفاده از یک الگوی غیرقابل‌رد با if let

راست شکایت می‌کند که استفاده از if let با یک الگوی غیرقابل‌رد منطقی نیست:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `if let` pattern
 --> src/main.rs:2:8
  |
2 |     if let x = 5 {
  |        ^^^^^^^^^
  |
  = note: this pattern will always match, so the `if let` is useless
  = help: consider replacing the `if let` with a `let`
  = 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`
5

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

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

Pattern Syntax

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

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 را پوشش می‌دهد، باید از یک نگهبان شرطی (match guard) استفاده کنیم. ما درباره نگهبان‌های شرطی در بخش “Extra Conditionals with Match Guards” صحبت خواهیم کرد.

Multiple Patterns

می‌توانید با استفاده از نحو |، که عملگر یا (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 باشد، بازوی اول مطابقت خواهد داشت. این نحو برای مقادیر مطابقت چندگانه راحت‌تر از استفاده از عملگر | برای بیان همان ایده است؛ اگر بخواهیم از | استفاده کنیم، باید 1 | 2 | 3 | 4 | 5 را مشخص کنیم. مشخص کردن یک بازه بسیار کوتاه‌تر است، به‌ویژه اگر بخواهیم، برای مثال، هر عدد بین 1 و 1,000 را مطابقت دهیم!

کامپایلر بررسی می‌کند که بازه در زمان کامپایل خالی نیست، و چون تنها نوع‌هایی که راست می‌تواند تشخیص دهد که آیا یک بازه خالی است یا نه 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 the 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 که هیچ کاری انجام نمی‌دهد اما تمام مقادیر باقی‌مانده ممکن را در نظر می‌گیرد. چندین روش برای نادیده گرفتن مقادیر کامل یا بخش‌هایی از مقادیر در یک الگو وجود دارد: استفاده از الگوی _ (که دیده‌اید)، استفاده از الگوی _ درون یک الگوی دیگر، استفاده از نامی که با یک زیرخط شروع می‌شود، یا استفاده از .. برای نادیده گرفتن بخش‌های باقی‌مانده یک مقدار. بیایید بررسی کنیم چگونه و چرا از هر یک از این الگوها استفاده کنیم.

Ignoring 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 هستید و به یک امضای خاص نیاز دارید، اما بدنه تابع در پیاده‌سازی شما نیازی به یکی از پارامترها ندارد. در این صورت، از دریافت هشدار کامپایلر درباره پارامترهای استفاده‌نشده جلوگیری می‌کنید، همان‌طور که اگر به جای آن از یک نام استفاده می‌کردید، هشدار دریافت می‌کردید.

Ignoring 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 نادیده گرفته می‌شوند.

Ignoring 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 را به چیزی متصل نمی‌کنیم؛ بنابراین انتقال داده نمی‌شود.

Ignoring 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_variable @ 3..=7,
        } => println!("Found an id in range: {id_variable}"),
        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

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

در این فصل، ما به موضوعات زیر خواهیم پرداخت:

  • 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 یک اشاره‌گر (Pointer) خام
  • فراخوانی یک تابع یا متد ناامن
  • دسترسی یا تغییر یک متغیر static قابل تغییر
  • پیاده‌سازی یک trait ناامن
  • دسترسی به فیلدهای یک union

مهم است که بفهمید unsafe سیستم borrow checker یا سایر بررسی‌های ایمنی راست را خاموش نمی‌کند: اگر از یک reference در کد ناامن استفاده کنید، همچنان بررسی خواهد شد. کلیدواژه unsafe فقط به شما دسترسی به این پنج ویژگی می‌دهد که سپس توسط کامپایلر برای ایمنی حافظه بررسی نمی‌شوند. شما همچنان درجه‌ای از ایمنی را در داخل یک بلوک ناامن خواهید داشت.

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

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

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

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

Dereferencing a Raw Pointer

در فصل 4، در بخش “Dangling References”، اشاره کردیم که کامپایلر تضمین می‌کند که ارجاعات همیشه معتبر هستند. Unsafe Rust دو نوع جدید به نام اشاره‌گر (Pointer)های خام (raw pointers) دارد که مشابه ارجاعات هستند. مانند ارجاعات، اشاره‌گر (Pointer)های خام می‌توانند immutable یا mutable باشند و به‌ترتیب به‌شکل *const T و *mut T نوشته می‌شوند. ستاره (*) عملگر dereference نیست؛ بلکه بخشی از نام نوع است. در زمینه اشاره‌گر (Pointer)های خام، immutable به این معناست که اشاره‌گر (Pointer) نمی‌تواند پس از dereference مستقیماً مقداردهی شود.

در مقایسه با ارجاعات و اشاره‌گر های هوشمند (smart pointers)، اشاره‌گر (Pointer)های خام:

  • مجاز به نادیده گرفتن قوانین 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

توجه داشته باشید که ما در این کد از کلیدواژه unsafe استفاده نکرده‌ایم. می‌توانیم اشاره‌گر (Pointer)های خام را در کد امن ایجاد کنیم؛ فقط نمی‌توانیم خارج از یک بلوک unsafe اشاره‌گر (Pointer)های خام را dereference کنیم، همان‌طور که در ادامه خواهید دید.

ما اشاره‌گر (Pointer)های خام را با استفاده از عملگرهای raw borrow ایجاد کرده‌ایم: &raw const num یک اشاره‌گر (Pointer) خام immutable از نوع *const i32 ایجاد می‌کند، و &raw mut num یک اشاره‌گر (Pointer) خام mutable از نوع *mut i32 ایجاد می‌کند. چون آن‌ها را مستقیماً از یک متغیر محلی ایجاد کرده‌ایم، می‌دانیم که این اشاره‌گر (Pointer)های خام خاص معتبر هستند، اما نمی‌توانیم این فرض را برای هر اشاره‌گر (Pointer) خامی داشته باشیم.

برای نشان دادن این موضوع، در ادامه یک اشاره‌گر (Pointer) خام ایجاد می‌کنیم که نمی‌توانیم به‌طور قطع از اعتبار آن مطمئن باشیم، با استفاده از as برای تبدیل یک مقدار به‌جای استفاده از عملگرهای raw reference. فهرست 20-2 نشان می‌دهد که چگونه یک اشاره‌گر (Pointer) خام به یک مکان دلخواه در حافظه ایجاد کنیم. تلاش برای استفاده از حافظه دلخواه تعریف‌نشده است: ممکن است داده‌ای در آن آدرس باشد یا نباشد، کامپایلر ممکن است کد را بهینه‌سازی کند تا هیچ دسترسی حافظه‌ای وجود نداشته باشد، یا برنامه ممکن است با یک خطای segmentation fault مواجه شود. معمولاً دلیل خوبی برای نوشتن کدی مانند این وجود ندارد، به‌ویژه در مواردی که می‌توانید از عملگر raw borrow استفاده کنید، اما این کار امکان‌پذیر است.

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

ایجاد یک اشاره‌گر (Pointer) آسیبی نمی‌رساند؛ فقط وقتی سعی می‌کنیم به مقداری که به آن اشاره می‌کند دسترسی پیدا کنیم ممکن است با یک مقدار نامعتبر سر و کار داشته باشیم.

همچنین توجه داشته باشید که در فهرست 20-1 و 20-3، ما اشاره‌گر (Pointer)های خام *const i32 و *mut i32 ایجاد کردیم که هر دو به همان مکان حافظه که num در آن ذخیره شده بود اشاره می‌کردند. اگر به‌جای این کار، سعی می‌کردیم یک ارجاع immutable و یک ارجاع mutable به num ایجاد کنیم، کد کامپایل نمی‌شد، زیرا قوانین مالکیت راست اجازه نمی‌دهند که یک ارجاع mutable همزمان با هر ارجاع immutable دیگری وجود داشته باشد. با اشاره‌گر (Pointer)های خام، می‌توانیم یک اشاره‌گر (Pointer) mutable و یک اشاره‌گر (Pointer) immutable به همان مکان ایجاد کنیم و داده‌ها را از طریق اشاره‌گر (Pointer) mutable تغییر دهیم، که ممکن است یک data race ایجاد کند. مراقب باشید!

با وجود تمام این خطرات، چرا باید از اشاره‌گر (Pointer)های خام استفاده کنید؟ یکی از موارد استفاده اصلی هنگام تعامل با کد C است، همان‌طور که در بخش بعدی “Calling an Unsafe Function or Method.” خواهید دید. مورد دیگر زمانی است که انتزاعات امنی ایجاد می‌کنید که سیستم borrow checker آن را نمی‌فهمد. ابتدا توابع ناامن را معرفی می‌کنیم و سپس به یک مثال از یک انتزاع امن که از کد ناامن استفاده می‌کند، می‌پردازیم.

Calling an Unsafe Function or Method

دومین نوع عملیاتی که می‌توانید در یک بلوک ناامن انجام دهید، فراخوانی توابع ناامن است. توابع و متدهای ناامن دقیقاً شبیه توابع و متدهای عادی به نظر می‌رسند، اما قبل از بقیه تعریف یک unsafe اضافه دارند. کلیدواژه unsafe در این زمینه نشان می‌دهد که تابع دارای الزاماتی است که هنگام فراخوانی این تابع باید رعایت کنیم، زیرا راست نمی‌تواند تضمین کند که این الزامات را رعایت کرده‌ایم. با فراخوانی یک تابع ناامن در یک بلوک unsafe، ما می‌گوییم که مستندات این تابع را خوانده‌ایم و مسئولیت رعایت قراردادهای تابع را بر عهده می‌گیریم.

در اینجا یک تابع ناامن به نام dangerous آورده شده است که در بدنه خود کاری انجام نمی‌دهد:

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 function or 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

با استفاده از بلوک unsafe، ما به راست اعلام می‌کنیم که مستندات تابع را خوانده‌ایم، می‌دانیم چگونه به‌درستی از آن استفاده کنیم، و تأیید کرده‌ایم که قرارداد تابع را رعایت می‌کنیم.

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

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 فقط با استفاده از راست ایمن

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

سپس دو برش قابل تغییر را در یک tuple بازمی‌گردانیم: یکی از ابتدای برش اصلی تا ایندکس mid و دیگری از mid تا انتهای برش.

وقتی سعی می‌کنیم کد در فهرست 20-5 را کامپایل کنیم، با خطا مواجه خواهیم شد.

$ 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

به یاد بیاورید از بخش “The Slice Type” در فصل 4 که برش‌ها یک اشاره‌گر (Pointer) به برخی داده‌ها و طول آن برش هستند. ما از متد len برای دریافت طول یک برش و از متد as_mut_ptr برای دسترسی به اشاره‌گر (Pointer) خام یک برش استفاده می‌کنیم. در این مورد، چون ما یک برش قابل تغییر به مقادیر i32 داریم، as_mut_ptr یک اشاره‌گر (Pointer) خام با نوع *mut i32 بازمی‌گرداند که آن را در متغیر ptr ذخیره کرده‌ایم.

ما تأیید می‌کنیم که ایندکس mid در محدوده برش است. سپس به کد ناامن می‌رسیم: تابع slice::from_raw_parts_mut یک اشاره‌گر (Pointer) خام و یک طول را می‌گیرد و یک برش ایجاد می‌کند. ما از این تابع برای ایجاد یک برش که از ptr شروع می‌شود و mid آیتم طول دارد استفاده می‌کنیم. سپس متد add را روی ptr با آرگومان mid فراخوانی می‌کنیم تا یک اشاره‌گر (Pointer) خام که از mid شروع می‌شود دریافت کنیم، و با استفاده از آن اشاره‌گر (Pointer) و تعداد آیتم‌های باقی‌مانده بعد از mid به‌عنوان طول، یک برش ایجاد می‌کنیم.

تابع slice::from_raw_parts_mut ناامن است زیرا یک اشاره‌گر (Pointer) خام می‌گیرد و باید اعتماد کند که این اشاره‌گر (Pointer) معتبر است. متد add روی اشاره‌گر (Pointer)های خام نیز ناامن است، زیرا باید اعتماد کند که موقعیت آفست نیز یک اشاره‌گر (Pointer) معتبر است. بنابراین، ما مجبور شدیم یک بلوک unsafe در اطراف فراخوانی‌های خود به slice::from_raw_parts_mut و add قرار دهیم تا بتوانیم آن‌ها را فراخوانی کنیم. با نگاه به کد و با افزودن تأییدیه‌ای که mid باید کمتر از یا برابر با len باشد، می‌توانیم بگوییم که تمام اشاره‌گر (Pointer)های خام استفاده‌شده در بلوک unsafe اشاره‌گر (Pointer)های معتبری به داده‌های درون برش خواهند بود. این یک استفاده قابل‌قبول و مناسب از unsafe است.

توجه داشته باشید که نیازی به علامت‌گذاری تابع split_at_mut به‌عنوان unsafe نداریم و می‌توانیم این تابع را از کد امن Rust فراخوانی کنیم. ما یک انتزاع امن برای کد ناامن با پیاده‌سازی تابعی که از کد ناامن به روش ایمن استفاده می‌کند ایجاد کرده‌ایم، زیرا فقط اشاره‌گر (Pointer)های معتبری از داده‌هایی که این تابع به آن‌ها دسترسی دارد ایجاد می‌کند.

در مقابل، استفاده از slice::from_raw_parts_mut در فهرست 20-7 احتمالاً هنگام استفاده از برش باعث کرش کردن می‌شود. این کد یک مکان حافظه دلخواه می‌گیرد و یک برش با طول 10,000 آیتم ایجاد می‌کند.

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

گاهی اوقات، کد Rust شما ممکن است نیاز به تعامل با کدی که به زبان دیگری نوشته شده دارد. برای این منظور، راست کلیدواژه extern را ارائه می‌دهد که امکان ایجاد و استفاده از یک رابط تابع خارجی (FFI) را فراهم می‌کند. یک FFI راهی است برای یک زبان برنامه‌نویسی برای تعریف توابع و امکان فراخوانی آن توابع توسط یک زبان برنامه‌نویسی دیگر (خارجی).

فهرست 20-8 نشان می‌دهد که چگونه یک یکپارچه‌سازی با تابع abs از کتابخانه استاندارد C تنظیم کنیم. توابعی که درون بلوک‌های extern اعلام می‌شوند معمولاً از کد راست ناامن برای فراخوانی استفاده می‌شوند، بنابراین باید با unsafe نیز علامت‌گذاری شوند. دلیل این است که زبان‌های دیگر قوانین و تضمین‌های راست را اعمال نمی‌کنند، و راست نمی‌تواند آن‌ها را بررسی کند، بنابراین مسئولیت بر عهده برنامه‌نویس است که ایمنی را تضمین کند.

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 تعریف‌شده در زبان دیگر

درون بلوک unsafe extern "C"، ما نام‌ها و امضاهای توابع خارجی از یک زبان دیگر که می‌خواهیم فراخوانی کنیم را فهرست می‌کنیم. بخش "C" مشخص می‌کند که کدام رابط دودویی برنامه (ABI) توسط تابع خارجی استفاده می‌شود: ABI تعریف می‌کند که چگونه تابع در سطح اسمبلی فراخوانی شود. ABI "C" رایج‌ترین است و از ABI زبان برنامه‌نویسی C پیروی می‌کند.

این تابع خاص هیچ ملاحظات ایمنی حافظه‌ای ندارد. در واقع، ما می‌دانیم که هر فراخوانی به abs همیشه برای هر i32 ایمن خواهد بود، بنابراین می‌توانیم از کلیدواژه safe استفاده کنیم تا بگوییم که این تابع خاص حتی با وجود اینکه در یک بلوک unsafe extern است، ایمن است. هنگامی که این تغییر را اعمال کنیم، فراخوانی آن دیگر نیاز به یک بلوک unsafe ندارد، همان‌طور که در فهرست 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 و فراخوانی ایمن آن

علامت‌گذاری یک تابع به‌عنوان safe ذاتاً آن را ایمن نمی‌کند! در عوض، این مانند یک وعده‌ای است که شما به راست می‌دهید که ایمن است. همچنان مسئولیت شماست که اطمینان حاصل کنید این وعده رعایت شود!

Calling Rust Functions from Other Languages

ما همچنین می‌توانیم از extern برای ایجاد یک رابط استفاده کنیم که به زبان‌های دیگر اجازه دهد توابع راست را فراخوانی کنند. به جای ایجاد یک بلوک extern کامل، ما کلیدواژه extern را اضافه می‌کنیم و ABI مورد استفاده را درست قبل از کلیدواژه fn برای تابع مربوطه مشخص می‌کنیم. همچنین باید یک حاشیه‌نویسی #[unsafe(no_mangle)] اضافه کنیم تا به کامپایلر راست بگوییم نام این تابع را تغییر ندهد. Mangling زمانی است که یک کامپایلر نامی را که به یک تابع داده‌ایم به نامی متفاوت تغییر می‌دهد که حاوی اطلاعات بیشتری برای سایر بخش‌های فرآیند کامپایل باشد اما کمتر قابل خواندن برای انسان باشد. هر کامپایلر زبان برنامه‌نویسی نام‌ها را کمی متفاوت mangling می‌کند، بنابراین برای اینکه یک تابع راست توسط زبان‌های دیگر قابل نام‌گذاری باشد، باید mangling نام کامپایلر راست را غیرفعال کنیم. این ناامن است زیرا ممکن است در میان کتابخانه‌ها تضاد نام رخ دهد بدون mangling داخلی، بنابراین مسئولیت ماست که اطمینان حاصل کنیم نامی که صادر کرده‌ایم برای صدور بدون mangling ایمن است.

در مثال زیر، ما تابع call_from_c را برای کد C در دسترس قرار می‌دهیم، پس از اینکه به یک کتابخانه مشترک کامپایل و از C لینک شد:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

این استفاده از extern نیازی به unsafe ندارد.

Accessing or Modifying a Mutable Static Variable

در این کتاب، هنوز در مورد متغیرهای جهانی صحبت نکرده‌ایم، که راست از آن‌ها پشتیبانی می‌کند اما ممکن است با قوانین مالکیت راست مشکل‌ساز شوند. اگر دو thread به یک متغیر جهانی قابل تغییر دسترسی داشته باشند، ممکن است یک data race ایجاد شود.

در راست، متغیرهای جهانی static نامیده می‌شوند. فهرست 20-10 یک مثال از اعلام و استفاده از یک متغیر static با یک string slice به‌عنوان مقدار را نشان می‌دهد.

Filename: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}
Listing 20-10: تعریف و استفاده از یک متغیر static غیرقابل تغییر

متغیرهای static مشابه ثابت‌ها هستند، که در بخش “Constants” در فصل 3 در مورد آن‌ها صحبت کردیم. نام متغیرهای static طبق قرارداد به‌صورت SCREAMING_SNAKE_CASE نوشته می‌شود. متغیرهای static فقط می‌توانند ارجاع‌هایی با lifetime 'static ذخیره کنند، به این معنا که کامپایلر راست می‌تواند lifetime را مشخص کند و نیازی نیست که آن را صراحتاً حاشیه‌نویسی کنیم. دسترسی به یک متغیر static غیرقابل تغییر ایمن است.

یک تفاوت ظریف بین ثابت‌ها و متغیرهای static غیرقابل تغییر این است که مقادیر در یک متغیر static دارای یک آدرس ثابت در حافظه هستند. استفاده از مقدار همیشه به همان داده دسترسی خواهد داشت. از سوی دیگر، ثابت‌ها مجاز هستند داده‌های خود را هر زمان که استفاده می‌شوند تکرار کنند. تفاوت دیگر این است که متغیرهای static می‌توانند قابل تغییر باشند. دسترسی و تغییر متغیرهای static قابل تغییر ناامن است. فهرست 20-11 نشان می‌دهد که چگونه یک متغیر static قابل تغییر به نام 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: خواندن از یا نوشتن به یک متغیر static قابل تغییر ناامن است

همانند متغیرهای معمولی، ما با استفاده از کلمه کلیدی mut قابلیت تغییرپذیری را مشخص می‌کنیم. هر کدی که بخواهد از COUNTER بخواند یا در آن بنویسد، باید در یک بلوک unsafe باشد. کدی که در لیست ۲۰-۱۱ نشان داده شده است کامپایل می‌شود و مقدار COUNTER: 3 را همان‌طور که انتظار می‌رود چاپ می‌کند، زیرا این کد تک‌ریسمانی (single-threaded) است. دسترسی چندین ریسمان به COUNTER به احتمال زیاد منجر به رقابت داده‌ای (data race) می‌شود و این رفتار تعریف‌نشده (undefined behavior) خواهد بود. بنابراین، نیاز است کل تابع را به عنوان unsafe علامت‌گذاری کنیم و محدودیت ایمنی را مستند کنیم، تا هرکسی که تابع را فراخوانی می‌کند بداند چه کارهایی را می‌تواند با اطمینان انجام دهد و چه کارهایی را نمی‌تواند.

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

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

با داده‌های تغییرپذیری که به صورت جهانی قابل دسترسی هستند، اطمینان از اینکه رقابت داده‌ای (data race) رخ نمی‌دهد دشوار است، به همین دلیل Rust متغیرهای استاتیک تغییرپذیر را ناایمن در نظر می‌گیرد. در صورت امکان، ترجیح داده می‌شود از تکنیک‌های همزمانی (concurrency techniques) و اشاره‌گرهای هوشمند ایمن برای ریسمان‌ها (thread-safe smart pointers) که در فصل ۱۶ مورد بحث قرار گرفتند استفاده کنید تا کامپایلر بررسی کند که دسترسی به داده‌ها از ریسمان‌های مختلف به صورت ایمن انجام می‌شود.

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 ناامن

با استفاده از unsafe impl، ما قول می‌دهیم که قاعده‌هایی را که کامپایلر نمی‌تواند تأیید کند، رعایت کنیم.

به‌عنوان مثال، به marker traitهای Sync و Send که در بخش “Extensible Concurrency with the Sync and Send Traits” در فصل 16 بررسی کردیم، بازگردید: کامپایلر این traitها را به‌صورت خودکار پیاده‌سازی می‌کند اگر نوع‌های ما به‌طور کامل از نوع‌های Send و Sync تشکیل شده باشند. اگر نوعی پیاده‌سازی کنیم که حاوی نوعی است که Send یا Sync نیست، مانند اشاره‌گر (Pointer)های خام، و بخواهیم آن نوع را به‌عنوان Send یا Sync علامت‌گذاری کنیم، باید از unsafe استفاده کنیم. راست نمی‌تواند تأیید کند که نوع ما تضمین‌های لازم برای ارسال ایمن بین ریسمان‌ها یا دسترسی ایمن از ریسمان‌های متعدد را رعایت می‌کند؛ بنابراین، ما باید این بررسی‌ها را به‌صورت دستی انجام دهیم و این را با unsafe نشان دهیم.

Accessing Fields of a Union

آخرین عملی که تنها با unsafe کار می‌کند، دسترسی به فیلدهای یک union است. یک union شبیه به یک struct است، اما تنها یکی از فیلدهای اعلام‌شده در یک نمونه در هر زمان خاص استفاده می‌شود. unions عمدتاً برای تعامل با unions در کد C استفاده می‌شوند. دسترسی به فیلدهای union ناامن است زیرا راست نمی‌تواند نوع داده‌ای که در حال حاضر در نمونه union ذخیره شده را تضمین کند. می‌توانید اطلاعات بیشتری درباره unions در مرجع راست بیاموزید.

Using Miri to check unsafe code

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

استفاده از Miri نیاز به یک نسخه nightly از راست دارد (که در ضمیمه ی: How Rust is Made and “Nightly Rust” بیشتر درباره آن صحبت کرده‌ایم). می‌توانید یک نسخه nightly از راست و ابزار Miri را با تایپ کردن rustup +nightly component add miri نصب کنید. این کار نسخه راست پروژه شما را تغییر نمی‌دهد؛ فقط ابزار را به سیستم شما اضافه می‌کند تا هر زمان که بخواهید از آن استفاده کنید. می‌توانید Miri را روی یک پروژه با تایپ کردن cargo +nightly miri run یا cargo +nightly miri test اجرا کنید.

برای مثالی از اینکه این ابزار چقدر می‌تواند مفید باشد، به خروجی اجرای آن روی فهرست 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 `/Users/chris/.rustup/toolchains/nightly-aarch64-apple-darwin/bin/cargo-miri runner target/miri/aarch64-apple-darwin/debug/unsafe-example`
warning: creating a shared reference to mutable static is discouraged
  --> src/main.rs:14:33
   |
14 |         println!("COUNTER: {}", COUNTER);
   |                                 ^^^^^^^ shared reference to mutable static
   |
   = note: for more information, see <https://doc.rust-lang.org/nightly/edition-guide/rust-2024/static-mut-references.html>
   = note: shared references to mutable statics are dangerous; it's undefined behavior if the static is mutated or if a mutable reference is created for it while the shared reference lives
   = note: `#[warn(static_mut_refs)]` on by default

COUNTER: 3

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

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

When to Use Unsafe Code

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

برای یک بررسی عمیق‌تر درباره نحوه کار مؤثر با راست ناامن، راهنمای رسمی راست در این موضوع، یعنی Rustonomicon را بخوانید.

Advanced Traits

ما در بخش “Traits: Defining Shared Behavior” از فصل 10 به بررسی traits پرداختیم، اما جزئیات پیشرفته‌تر آن را مورد بحث قرار ندادیم. اکنون که اطلاعات بیشتری در مورد راست دارید، می‌توانیم به عمق موضوع بپردازیم.

Specifying Placeholder Types in Trait Definitions with 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 را می‌خواهیم استفاده کنیم.

با استفاده از نوع‌های مرتبط، نیازی به حاشیه‌نویسی نوع‌ها نداریم زیرا نمی‌توانیم یک trait را بر روی یک نوع چندین بار پیاده‌سازی کنیم. در فهرست 20-13 با تعریفی که از نوع‌های مرتبط استفاده می‌کند، ما فقط می‌توانیم نوع Item را یک بار انتخاب کنیم، زیرا تنها یک impl Iterator for Counter می‌تواند وجود داشته باشد. ما نیازی نداریم که مشخص کنیم می‌خواهیم یک iterator از مقادیر u32 داشته باشیم در هر جایی که next را بر روی Counter فراخوانی می‌کنیم.

نوع‌های مرتبط همچنین بخشی از قرارداد trait می‌شوند: پیاده‌سازان trait باید یک نوع ارائه دهند تا جایگزین نوع جایگزین مرتبط شود. نوع‌های مرتبط اغلب نامی دارند که توصیف می‌کند چگونه نوع استفاده خواهد شد و مستندسازی نوع مرتبط در مستندات API یک عمل خوب است.

Default Generic Type Parameters and Operator Overloading

وقتی که از پارامترهای generic type استفاده می‌کنیم، می‌توانیم یک نوع خاص پیش‌فرض برای پارامتر generic تعیین کنیم. این نیاز به مشخص کردن یک نوع خاص توسط پیاده‌سازان trait را در صورتی که نوع پیش‌فرض کار کند، از بین می‌برد. شما می‌توانید هنگام اعلام یک نوع generic، یک نوع پیش‌فرض با سینتکس <PlaceholderType=ConcreteType> مشخص کنید.

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

راست به شما اجازه نمی‌دهد عملگرهای خود را ایجاد کنید یا عملگرهای دلخواه را بارگذاری مجدد کنید. اما می‌توانید عملیات‌ها و traits مربوط به آن‌ها را که در std::ops فهرست شده‌اند با پیاده‌سازی traits مرتبط با عملگر، بارگذاری مجدد کنید. برای مثال، در فهرست 20-15 ما عملگر + را برای اضافه کردن دو نمونه Point به یکدیگر بارگذاری مجدد می‌کنیم. ما این کار را با پیاده‌سازی trait Add بر روی struct 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 pattern شناخته می‌شود که ما آن را در بخش “Using the Newtype Pattern to Implement External Traits on External Types” به‌طور مفصل توضیح می‌دهیم. ما می‌خواهیم مقادیر میلی‌متر را به مقادیر متر اضافه کنیم و پیاده‌سازی 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 بدون شکستن کد پیاده‌سازی موجود، یک مقدار پیش‌فرض برای آن تنظیم کنید.

Fully Qualified Syntax for Disambiguation: Calling 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 را باید استفاده کند.

با این حال، توابع مرتبطی که متد نیستند، پارامتر self ندارند. وقتی چندین نوع یا trait توابع غیر متد با یک نام مشترک تعریف می‌کنند، راست همیشه نمی‌داند که منظور شما کدام نوع است، مگر اینکه از fully qualified syntax استفاده کنید. به عنوان مثال، در فهرست 20-20 ما یک trait برای یک پناهگاه حیوانات ایجاد می‌کنیم که می‌خواهد تمام سگ‌های کوچک را به نام Spot نام‌گذاری کند. ما یک trait به نام Animal با یک تابع غیر متد مرتبط به نام baby_name تعریف می‌کنیم. trait Animal برای ساختار Dog پیاده‌سازی می‌شود، و همچنین یک تابع غیر متد مرتبط به نام baby_name مستقیماً بر روی Dog فراهم می‌کنیم.

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 را در هر جایی که توابع یا متدها را فراخوانی می‌کنید، استفاده کنید. با این حال، مجاز هستید هر بخشی از این سینتکس را که راست می‌تواند از اطلاعات دیگر برنامه تشخیص دهد، حذف کنید. شما فقط در مواردی که چندین پیاده‌سازی با نام یکسان وجود دارد و راست به کمک نیاز دارد تا مشخص کند کدام پیاده‌سازی را می‌خواهید فراخوانی کنید، نیاز به استفاده از این سینتکس دقیق‌تر دارید.

استفاده از Supertraits برای نیاز به قابلیت‌های یک trait درون trait دیگر

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

برای مثال، فرض کنید می‌خواهید یک trait به نام OutlinePrint بسازید که یک متد outline_print داشته باشد که یک مقدار داده شده را با فرمت مشخصی که در قاب ستاره‌ها قرار گرفته است، چاپ کند. به این صورت که اگر یک ساختار Point داشته باشیم که trait کتابخانه استاندارد Display را پیاده‌سازی کرده و نتیجه آن به شکل (x, y) باشد، وقتی متد outline_print را روی یک نمونه از Point که 1 برای x و 3 برای y دارد فراخوانی کنیم، باید خروجی زیر را چاپ کند:

**********
*        *
* (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 برای پیاده‌سازی Traits خارجی روی انواع خارجی

در فصل ۱۰ در بخش “پیاده‌سازی یک Trait روی یک نوع”، به قانون orphan اشاره کردیم که بیان می‌کند ما فقط مجاز هستیم یک trait را روی یک نوع پیاده‌سازی کنیم اگر یا trait یا نوع به crate ما تعلق داشته باشد. با این حال، می‌توان با استفاده از الگوی newtype، این محدودیت را دور زد. این الگو شامل ایجاد یک نوع جدید در یک tuple struct است. (ما tuple struct‌ها را در بخش “استفاده از Tuple Structs بدون فیلدهای نام‌گذاری‌شده برای ایجاد انواع مختلف” در فصل ۵ پوشش دادیم.) tuple struct یک فیلد خواهد داشت و یک wrapper نازک دور نوعی خواهد بود که می‌خواهیم trait را برای آن پیاده‌سازی کنیم. سپس، نوع wrapper به crate ما تعلق دارد و می‌توانیم trait را روی wrapper پیاده‌سازی کنیم. اصطلاح Newtype از زبان برنامه‌نویسی Haskell منشأ گرفته است. هیچ جریمه عملکردی در زمان اجرا برای استفاده از این الگو وجود ندارد و نوع wrapper در زمان کامپایل حذف می‌شود.

به‌عنوان مثال، فرض کنید می‌خواهیم 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> آیتمی در index صفر tuple است. سپس می‌توانیم از قابلیت‌های trait Display روی Wrapper استفاده کنیم.

عیب استفاده از این تکنیک این است که Wrapper یک نوع جدید است، بنابراین متدهای نوعی که درون خود نگه می‌دارد را ندارد. باید تمام متدهای Vec<T> را مستقیماً روی Wrapper پیاده‌سازی کنیم به طوری که متدها به self.0 ارجاع دهند، که به ما اجازه می‌دهد Wrapper را دقیقاً مانند Vec<T> رفتار دهیم. اگر بخواهیم نوع جدید تمام متدهایی که نوع داخلی دارد را داشته باشد، پیاده‌سازی trait Deref (که در فصل ۱۵ در بخش “رفتار با اشاره‌گر (Pointer)های هوشمند به‌عنوان ارجاعات معمولی با استفاده از trait Deref بحث شد) روی Wrapper به‌گونه‌ای که نوع داخلی را بازگرداند، راه‌حلی خواهد بود. اگر نخواهیم نوع Wrapper تمام متدهای نوع داخلی را داشته باشد—برای مثال، برای محدود کردن رفتار نوع Wrapper—باید متدهایی که واقعاً نیاز داریم را به صورت دستی پیاده‌سازی کنیم.

این الگوی newtype حتی زمانی که traits درگیر نیستند نیز مفید است. حالا بیایید تمرکز خود را تغییر دهیم و به برخی از روش‌های پیشرفته برای تعامل با سیستم نوع Rust بپردازیم.

انواع (Typeهای) پیشرفته

سیستم نوع‌بندی Rust شامل ویژگی‌هایی است که تاکنون فقط به آن‌ها اشاره کرده‌ایم و هنوز به‌طور کامل مورد بحث قرار نگرفته‌اند. ابتدا به بررسی الگوی newtype می‌پردازیم تا بفهمیم چرا این الگو به‌عنوان انواع مفید است. سپس به aliasهای نوع می‌پردازیم، که ویژگی مشابهی با newtype دارند اما با تفاوت‌هایی در معناشناسی. همچنین، نوع ! و انواع پویا (dynamically sized types) را نیز بررسی خواهیم کرد.

استفاده از الگوی Newtype برای ایمنی نوع و انتزاع

توجه: این بخش فرض می‌کند که قبلاً بخش “استفاده از الگوی Newtype برای پیاده‌سازی Traits خارجی روی انواع خارجی” را مطالعه کرده‌اید.

الگوی newtype علاوه بر مواردی که تاکنون بحث کردیم، برای وظایف دیگری مانند اعمال محدودیت‌های استاتیک برای جلوگیری از اشتباه و نمایش واحدهای یک مقدار نیز مفید است. شما یک مثال از استفاده از newtype برای نمایش واحدها را در مثال 20-16 دیدید: در آنجا، ساختارهای Millimeters و Meters مقادیر نوع u32 را در یک newtype بسته‌بندی می‌کردند. اگر تابعی با پارامتری از نوع Millimeters بنویسیم، برنامه‌ای که به‌طور اشتباه بخواهد این تابع را با مقدار نوع Meters یا یک u32 ساده فراخوانی کند، کامپایل نخواهد شد.

ما همچنین می‌توانیم از الگوی newtype برای انتزاع جزئیات پیاده‌سازی یک نوع استفاده کنیم: نوع جدید می‌تواند یک API عمومی ارائه دهد که با API نوع داخلی خصوصی متفاوت است.

الگوی newtype همچنین می‌تواند پیاده‌سازی داخلی را مخفی کند. به‌عنوان مثال، می‌توانیم نوعی به نام People ارائه دهیم که یک HashMap<i32, String> را برای ذخیره ID افراد با نام آن‌ها بسته‌بندی کند. کدی که از People استفاده می‌کند، فقط با API عمومی که ارائه می‌دهیم تعامل خواهد داشت، مانند متدی برای افزودن یک رشته نام به مجموعه People. این کد نیازی ندارد که بداند ما به‌صورت داخلی یک ID نوع 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);
}

اکنون، alias 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 جایگزین کنیم.

<فهرست شماره=“20-26” عنوان=“معرفی نوع مستعار 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 هرگز نمی‌تواند بازگردد.

اما استفاده از نوعی که هرگز نمی‌توان مقداری برای آن ایجاد کرد، چیست؟ کد مربوط به Listing 2-5 را به خاطر بیاورید که بخشی از بازی حدس عدد بود. ما بخشی از آن را اینجا در Listing 20-27 بازتولید کرده‌ایم.

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

    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

زبان Rust نیاز دارد تا جزئیاتی درباره انواع خود بداند، مانند اینکه چقدر فضا برای ذخیره‌سازی یک مقدار از یک نوع خاص تخصیص دهد. این امر یکی از گوشه‌های سیستم انواع این زبان را کمی گیج‌کننده می‌کند: مفهوم انواع با اندازه دایتانیک (پویا) (dynamically sized types). گاهی اوقات به این نوع‌ها DST یا انواع بدون اندازه (unsized types) نیز گفته می‌شود. این نوع‌ها به ما اجازه می‌دهند تا کدی بنویسیم که با مقادیری کار کند که اندازه آن‌ها تنها در زمان اجرا مشخص می‌شود.

بیایید به جزئیات یک نوع با اندازه دایتانیک به نام 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 می‌سازیم. به یاد بیاورید که در بخش “برش‌های رشته‌ای” از فصل ۴ گفته شد که ساختار داده برش تنها موقعیت شروع و طول برش را ذخیره می‌کند. بنابراین، اگرچه یک &T تنها یک مقدار است که آدرس حافظه‌ای که T در آن قرار دارد را ذخیره می‌کند، یک &str دو مقدار دارد: آدرس str و طول آن. بنابراین، ما می‌توانیم اندازه یک مقدار &str را در زمان کامپایل بدانیم: اندازه آن دو برابر طول یک usize است. به عبارت دیگر، ما همیشه اندازه یک &str را می‌دانیم، بدون توجه به اینکه رشته‌ای که به آن اشاره می‌کند چقدر طولانی است. به طور کلی، این روش استفاده از انواع با اندازه دایتانیک در Rust است: آن‌ها یک بخش اضافی از متادیتا دارند که اندازه اطلاعات دایتانیک را ذخیره می‌کند. قانون طلایی انواع با اندازه دایتانیک این است که باید همیشه مقادیر این نوع‌ها را پشت یک نوع اشاره‌گر (Pointer) قرار دهیم.

ما می‌توانیم str را با انواع مختلف اشاره‌گر (Pointer) ترکیب کنیم: به عنوان مثال، Box<str> یا Rc<str>. در واقع، قبلاً این مورد را دیده‌اید اما با یک نوع با اندازه دایتانیک متفاوت: ویژگی‌ها (Traits). هر ویژگی یک نوع با اندازه دایتانیک است که می‌توانیم با استفاده از نام ویژگی به آن ارجاع دهیم. در فصل ۱۸ در بخش “استفاده از اشیاء ویژگی که امکان مقادیر با تایپ‌های مختلف را فراهم می‌کنند” اشاره کردیم که برای استفاده از ویژگی‌ها به عنوان اشیاء ویژگی، باید آن‌ها را پشت یک اشاره‌گر (Pointer) قرار دهیم، مانند &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های تابع

قبلاً در مورد چگونگی ارسال Closureها به توابع صحبت کردیم؛ شما همچنین می‌توانید توابع معمولی را به توابع دیگر ارسال کنید! این تکنیک زمانی مفید است که بخواهید تابعی که قبلاً تعریف کرده‌اید را ارسال کنید به جای اینکه یک Closureها جدید تعریف کنید. توابع به نوع fn (با f کوچک) تبدیل می‌شوند، که نباید با ویژگی Closureها Fn اشتباه گرفته شود. نوع fn به عنوان یک اشاره‌گر (Pointer) تابع شناخته می‌شود. ارسال توابع با استفاده از Pointerهای تابع به شما این امکان را می‌دهد که از توابع به عنوان آرگومان برای توابع دیگر استفاده کنید.

سینتکس مشخص کردن اینکه یک پارامتر یک اشاره‌گر (Pointer) تابع است، مشابه Closureها است، همان‌طور که در لیست ۲۰-۲۸ نشان داده شده است. در این مثال، تابعی به نام add_one تعریف کرده‌ایم که یک واحد به پارامتر خود اضافه می‌کند. تابع do_twice دو پارامتر می‌گیرد: یک اشاره‌گر (Pointer) تابع به هر تابعی که یک پارامتر 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ها ندارد.

به عنوان مثالی از جایی که می‌توانید از یک Closureها تعریف‌شده درون‌خطی یا یک تابع نام‌گذاری‌شده استفاده کنید، بیایید به استفاده از متد map که توسط ویژگی Iterator در کتابخانه استاندارد ارائه شده است نگاهی بیندازیم. برای استفاده از تابع map برای تبدیل یک بردار اعداد به یک بردار رشته‌ها، می‌توانیم از یک Closureها به این صورت استفاده کنیم:

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

یا می‌توانیم به جای کلوزر، نام یک تابع را به عنوان آرگومان به map ارسال کنیم، به این صورت:

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

توجه داشته باشید که باید از سینتکس کاملاً مشخصی که قبلاً در بخش “ویژگی‌های پیشرفته” توضیح داده شد استفاده کنیم، زیرا چندین تابع با نام to_string در دسترس هستند. در اینجا، ما از تابع to_string که در ویژگی ToString تعریف شده است استفاده می‌کنیم، که کتابخانه استاندارد برای هر نوعی که ویژگی Display را پیاده‌سازی کند، آن را پیاده‌سازی کرده است.

به یاد بیاورید که در بخش “مقادیر Enum” از فصل ۶ گفته شد که نام هر واریانت enum که تعریف می‌کنیم، همچنین به یک تابع مقداردهی اولیه تبدیل می‌شود. می‌توانیم از این توابع مقداردهی اولیه به عنوان اشاره‌گر (Pointer)های تابع که ویژگی‌های کلوزر را پیاده‌سازی می‌کنند استفاده کنیم، به این معنی که می‌توانیم توابع مقداردهی اولیه را به عنوان آرگومان برای متدهایی که کلوزرها (closures) را می‌پذیرند مشخص کنیم، به این صورت:

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}

در اینجا با استفاده از تابع مقداردهی اولیه Status::Value، نمونه‌هایی از Status::Value ایجاد می‌کنیم که از هر مقدار u32 در محدوده‌ای که map روی آن فراخوانی می‌شود استفاده می‌کند. برخی افراد این سبک را ترجیح می‌دهند و برخی دیگر ترجیح می‌دهند از کلوزرها (closures) استفاده کنند. این‌ها به کدی یکسان کامپایل می‌شوند، بنابراین هر سبکی که برای شما واضح‌تر است را انتخاب کنید.

بازگرداندن کلوزرها (closures) (Returning Closures)

کلوزرها (closures) با ویژگی‌ها نمایش داده می‌شوند، به این معنی که نمی‌توانید مستقیماً کلوزرها (closures) را بازگردانید. در بیشتر مواردی که ممکن است بخواهید یک ویژگی را بازگردانید، می‌توانید به جای آن از نوع مشخصی که ویژگی را پیاده‌سازی می‌کند به عنوان مقدار بازگشتی تابع استفاده کنید. با این حال، نمی‌توانید این کار را با کلوزرها (closures) انجام دهید زیرا آن‌ها نوع مشخصی که قابل بازگشت باشد ندارند؛ به عنوان مثال، نمی‌توانید از اشاره‌گر (Pointer) تابع fn به عنوان نوع بازگشتی استفاده کنید.

در عوض، معمولاً از سینتکس impl Trait که در فصل ۱۰ یاد گرفتیم استفاده می‌کنید. می‌توانید هر نوع تابعی را با استفاده از Fn، FnOnce و FnMut بازگردانید. برای مثال، این کد به خوبی کار می‌کند:

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

با این حال، همان‌طور که در بخش “استنتاج نوع کلوزر و حاشیه‌نویسی” از فصل ۱۳ اشاره کردیم، هر کلوزر نوع مشخص خود را دارد. اگر نیاز داشته باشید با چندین تابع که امضای یکسانی دارند اما پیاده‌سازی‌های متفاوتی دارند کار کنید، باید از یک شیء ویژگی (trait object) برای آن‌ها استفاده کنید:

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

این کد به خوبی کامپایل می‌شود—اما اگر تلاش می‌کردیم از impl Fn(i32) -> i32 استفاده کنیم، کامپایل نمی‌شد. برای اطلاعات بیشتر در مورد اشیاء ویژگی، به بخش “استفاده از اشیاء ویژگی که امکان مقادیر با تایپ‌های مختلف را فراهم می‌کنند” در فصل ۱۸ مراجعه کنید.

در ادامه، بیایید نگاهی به ماکروها بیندازیم!

ماکروها (Macros)

ما در طول این کتاب از ماکروهایی مانند println! استفاده کرده‌ایم، اما هنوز به طور کامل بررسی نکرده‌ایم که یک ماکرو چیست و چگونه کار می‌کند. اصطلاح ماکرو به مجموعه‌ای از قابلیت‌ها در Rust اشاره دارد: ماکروهای اعلانی (declarative) با macro_rules! و سه نوع ماکرو رویه‌ای (procedural):

  • ماکروهای سفارشی #[derive] که کدی را که با ویژگی derive برای ساختارها (structs) و شمارش‌ها (enums) اضافه می‌شود مشخص می‌کنند.
  • ماکروهای شبیه ویژگی (Attribute-like) که ویژگی‌های سفارشی تعریف می‌کنند که می‌توانند روی هر آیتمی استفاده شوند.
  • ماکروهای شبیه تابع (Function-like) که مانند فراخوانی تابع به نظر می‌رسند اما روی توکن‌هایی که به عنوان آرگومان مشخص شده‌اند عمل می‌کنند.

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

تفاوت بین ماکروها و توابع

در اصل، ماکروها روشی برای نوشتن کدی هستند که کد دیگری را می‌نویسد، که به عنوان فرابرنامه‌نویسی (metaprogramming) شناخته می‌شود. در پیوست C، ما ویژگی derive را بررسی می‌کنیم که پیاده‌سازی ویژگی‌های مختلف را برای شما تولید می‌کند. همچنین ما از ماکروهای println! و vec! در طول کتاب استفاده کرده‌ایم. همه این ماکروها توسعه پیدا می‌کنند تا کدی بیشتر از کدی که به صورت دستی نوشته‌اید تولید کنند.

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

یک امضای تابع باید تعداد و نوع پارامترهایی که تابع دارد را مشخص کند. از سوی دیگر، ماکروها می‌توانند تعداد متغیری از پارامترها را بپذیرند: می‌توانیم println!("hello") را با یک آرگومان یا println!("hello {}", name) را با دو آرگومان فراخوانی کنیم. همچنین، ماکروها قبل از اینکه کامپایلر معنی کد را تفسیر کند گسترش می‌یابند، بنابراین یک ماکرو می‌تواند، به عنوان مثال، یک ویژگی را روی یک نوع مشخص پیاده‌سازی کند. اما یک تابع نمی‌تواند، زیرا در زمان اجرا فراخوانی می‌شود و یک ویژگی باید در زمان کامپایل پیاده‌سازی شود.

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

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

ماکروهای اعلانی با macro_rules! برای فرابرنامه‌نویسی عمومی

پرکاربردترین شکل ماکروها در Rust، ماکروهای اعلانی هستند. به این ماکروها گاهی اوقات “ماکروهای با مثال”، “ماکروهای 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! برای ساخت یک بردار شامل دو عدد صحیح یا یک بردار شامل پنج برش رشته استفاده کنیم. نمی‌توانیم از یک تابع برای انجام همین کار استفاده کنیم زیرا نمی‌دانیم تعداد یا نوع مقادیر از پیش چیست.

لیست ۲۰-۲۹ یک تعریف کمی ساده‌شده از ماکروی 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 و نه مقادیر تطابق داده می‌شوند. بیایید مرور کنیم که قسمت‌های الگوی لیست ۲۰-۲۹ چه معنایی دارند؛ برای مشاهده کامل سینتکس الگوهای ماکرو، به مستندات مرجع Rust مراجعه کنید.

ابتدا، مجموعه‌ای از پرانتزها را برای شامل کردن کل الگو استفاده می‌کنیم. از علامت دلار ($) برای اعلام یک متغیر در سیستم ماکرو استفاده می‌کنیم که کد Rust تطابق‌یافته با الگو را در خود جای می‌دهد. علامت دلار مشخص می‌کند که این یک متغیر ماکرو است، نه یک متغیر معمولی Rust. سپس مجموعه‌ای از پرانتزها می‌آیند که مقادیری را که با الگو درون پرانتزها تطابق دارند، برای استفاده در کد جایگزین ثبت می‌کنند. درون $()، $x:expr قرار دارد که با هر عبارت Rust تطابق دارد و به آن عبارت نام $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 macro) است که بیشتر شبیه به یک تابع عمل می‌کند (و نوعی رویه است). ماکروهای رویه‌ای کدی را به عنوان ورودی می‌پذیرند، روی آن کد عمل می‌کنند و به جای تطابق با الگوها و جایگزین کردن کد با کدی دیگر مانند ماکروهای اعلانی، کدی را به عنوان خروجی تولید می‌کنند. سه نوع ماکروی رویه‌ای شامل derive سفارشی، شبیه ویژگی (attribute-like) و شبیه تابع (function-like) هستند و همه به شیوه‌ای مشابه عمل می‌کنند.

هنگام ایجاد ماکروهای رویه‌ای، تعاریف باید در یک crate مجزا با نوع crate خاص خود قرار گیرند. این به دلایل فنی پیچیده‌ای است که امیدواریم در آینده برطرف شود. در لیست ۲۰-۳۰، نحوه تعریف یک ماکروی رویه‌ای را نشان می‌دهیم که در آن some_attribute به عنوان جایگزین برای استفاده از نوع خاصی از ماکرو است.

Filename: src/lib.rs
use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Listing 20-30: یک مثال از تعریف یک ماکروی رویه‌ای

تابعی که یک ماکروی رویه‌ای را تعریف می‌کند، یک TokenStream را به عنوان ورودی می‌گیرد و یک TokenStream را به عنوان خروجی تولید می‌کند. نوع TokenStream توسط crate به نام proc_macro تعریف شده است که با Rust همراه است و نمایانگر یک توالی از توکن‌ها است. این هسته ماکرو است: کد منبعی که ماکرو روی آن عمل می‌کند ورودی TokenStream را تشکیل می‌دهد و کدی که ماکرو تولید می‌کند خروجی TokenStream است. این تابع همچنین دارای یک ویژگی (attribute) متصل به خود است که مشخص می‌کند کدام نوع از ماکروی رویه‌ای را ایجاد می‌کنیم. ما می‌توانیم چندین نوع از ماکروهای رویه‌ای را در یک crate داشته باشیم.

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

نحوه نوشتن یک ماکروی derive سفارشی

بیایید یک crate به نام hello_macro ایجاد کنیم که یک ویژگی به نام HelloMacro را با یک تابع وابسته به نام hello_macro تعریف کند. به جای اینکه کاربران ما ویژگی HelloMacro را برای هر یک از انواع خود پیاده‌سازی کنند، ما یک ماکروی رویه‌ای فراهم می‌کنیم تا کاربران بتوانند نوع خود را با #[derive(HelloMacro)] حاشیه‌نویسی کنند و یک پیاده‌سازی پیش‌فرض برای تابع hello_macro دریافت کنند. پیاده‌سازی پیش‌فرض متن Hello, Macro! My name is TypeName! را چاپ می‌کند که در آن TypeName نام نوعی است که این ویژگی روی آن تعریف شده است. به عبارت دیگر، ما 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-31: کدی که کاربر crate ما می‌تواند هنگام استفاده از ماکروی رویه‌ای ما بنویسد

این کد متن Hello, Macro! My name is Pancakes! را چاپ می‌کند وقتی کار ما تمام شود. اولین قدم این است که یک crate جدید از نوع کتابخانه بسازیم، به این صورت:

$ cargo new hello_macro --lib

سپس، ویژگی HelloMacro و تابع وابسته به آن را تعریف می‌کنیم:

Filename: src/lib.rs
pub trait HelloMacro {
    fn hello_macro();
}

ما اکنون یک ویژگی و تابع وابسته به آن داریم. در این مرحله، کاربر crate ما می‌تواند ویژگی را پیاده‌سازی کند تا به عملکرد مورد نظر برسد، به این صورت:

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

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

علاوه بر این، ما هنوز نمی‌توانیم برای تابع hello_macro یک پیاده‌سازی پیش‌فرض ارائه دهیم که نام نوعی که ویژگی روی آن پیاده‌سازی شده است را چاپ کند: Rust قابلیت‌های بازتاب (reflection) ندارد، بنابراین نمی‌تواند نام نوع را در زمان اجرا جستجو کند. ما به یک ماکرو نیاز داریم تا کد را در زمان کامپایل تولید کند.

مرحله بعدی این است که ماکروی رویه‌ای را تعریف کنیم. در زمان نگارش این متن، ماکروهای رویه‌ای باید در یک crate جداگانه قرار گیرند. این محدودیت ممکن است در آینده برداشته شود. روش استاندارد برای ساختاردهی جعبه‌ها (crates) و جعبه‌ها (crates)ی ماکرو به این صورت است: برای یک crate به نام foo، یک ماکروی رویه‌ای سفارشی derive به نام foo_derive نام‌گذاری می‌شود. بیایید یک crate جدید به نام 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"

برای شروع تعریف ماکروی رویه‌ای، کد لیست ۲۰-۳۲ را در فایل src/lib.rs برای crate 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-32: کدی که اکثر جعبه‌ها (crates)ی ماکروی رویه‌ای برای پردازش کد 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-33: نمونه‌ای از DeriveInput که هنگام تجزیه کدی که ویژگی ماکرو در لیست ۲۰-۳۱ را دارد، دریافت می‌کنیم

فیلدهای این ساختار نشان می‌دهند که کد Rust که تجزیه کرده‌ایم یک ساختار واحد (unit struct) با شناسه (ident) به نام Pancakes است. این ساختار فیلدهای بیشتری برای توصیف انواع مختلف کد Rust دارد؛ برای اطلاعات بیشتر به مستندات syn برای DeriveInput مراجعه کنید.

به زودی تابع impl_hello_macro را تعریف خواهیم کرد، جایی که کد جدیدی که می‌خواهیم اضافه کنیم را تولید خواهیم کرد. اما قبل از این کار، توجه داشته باشید که خروجی ماکروی 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 تبدیل می‌کند، بیایید کدی که ویژگی HelloMacro را روی نوع حاشیه‌نویسی‌شده پیاده‌سازی می‌کند، تولید کنیم، همان‌طور که در لیست ۲۰-۳۴ نشان داده شده است.

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 gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    gen.into()
}
Listing 20-34: پیاده‌سازی ویژگی HelloMacro با استفاده از کد Rust تجزیه‌شده

ما با استفاده از ast.ident یک نمونه از ساختار Ident که شامل نام (شناسه) نوع حاشیه‌نویسی‌شده است، دریافت می‌کنیم. ساختار موجود در لیست ۲۰-۳۳ نشان می‌دهد که وقتی تابع impl_hello_macro را روی کد لیست ۲۰-۳۱ اجرا می‌کنیم، فیلد ident با مقدار "Pancakes" پر خواهد شد. بنابراین، متغیر name در لیست ۲۰-۳۴ یک نمونه از ساختار Ident را شامل می‌شود که وقتی چاپ می‌شود، رشته "Pancakes"، یعنی نام ساختار در لیست ۲۰-۳۱، خواهد بود.

ماکروی 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، را گرفته و در زمان کامپایل آن را به یک رشته ثابت، مانند "1 + 2"، تبدیل می‌کند. این با ماکروهایی مانند format! یا println! که عبارت را ارزیابی کرده و سپس نتیجه را به یک String تبدیل می‌کنند، متفاوت است. احتمال دارد ورودی #name یک عبارتی برای چاپ باشد، بنابراین از stringify! استفاده می‌کنیم. استفاده از stringify! همچنین با تبدیل #name به یک رشته ثابت در زمان کامپایل، یک تخصیص را صرفه‌جویی می‌کند.

در این مرحله، دستور cargo build باید با موفقیت در هر دو crate hello_macro و hello_macro_derive اجرا شود. بیایید این جعبه‌ها (crates) را به کد موجود در لیست ۲۰-۳۱ متصل کنیم تا ماکروی رویه‌ای را در عمل ببینیم! یک پروژه باینری جدید در دایرکتوری projects خود با استفاده از دستور cargo new pancakes ایجاد کنید. باید hello_macro و hello_macro_derive را به عنوان وابستگی در فایل Cargo.toml crate pancakes اضافه کنیم. اگر نسخه‌های خود از hello_macro و hello_macro_derive را در crates.io منتشر می‌کنید، آن‌ها به عنوان وابستگی‌های معمولی خواهند بود؛ در غیر این صورت، می‌توانید آن‌ها را به صورت وابستگی‌های path به شکل زیر مشخص کنید:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

کد موجود در لیست ۲۰-۳۱ را در فایل src/main.rs قرار دهید و دستور cargo run را اجرا کنید: باید عبارت Hello, Macro! My name is Pancakes! را چاپ کند. پیاده‌سازی ویژگی HelloMacro که از ماکروی رویه‌ای آمده بود، بدون نیاز به پیاده‌سازی آن توسط crate pancakes اضافه شد؛ ویژگی #[derive(HelloMacro)] پیاده‌سازی ویژگی را اضافه کرد.

در ادامه، بیایید بررسی کنیم که انواع دیگر ماکروهای رویه‌ای چه تفاوتی با ماکروهای سفارشی derive دارند.

ماکروهای شبیه ویژگی (Attribute-like macros)

ماکروهای شبیه ویژگی مشابه ماکروهای سفارشی derive هستند، اما به جای تولید کد برای ویژگی derive، به شما امکان می‌دهند ویژگی‌های جدید ایجاد کنید. آن‌ها همچنین انعطاف‌پذیرتر هستند: derive فقط برای ساختارها (structs) و شمارش‌ها (enums) کار می‌کند؛ اما ویژگی‌ها می‌توانند به آیتم‌های دیگر نیز اعمال شوند، مانند توابع. در اینجا یک مثال از استفاده از یک ماکروی شبیه ویژگی آورده شده است: فرض کنید یک ویژگی به نام 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 ایجاد می‌کنید و تابعی را پیاده‌سازی می‌کنید که کدی را که می‌خواهید تولید می‌کند!

ماکروهای شبیه تابع

ماکروهای شبیه تابع، ماکروهایی را تعریف می‌کنند که شبیه به فراخوانی توابع به نظر می‌رسند. مشابه با ماکروهای macro_rules!، این ماکروها انعطاف‌پذیرتر از توابع هستند؛ برای مثال، می‌توانند تعداد نامشخصی از آرگومان‌ها را بپذیرند. با این حال، ماکروهای macro_rules! فقط می‌توانند با استفاده از سینتکس شبیه به match که در بخش “ماکروهای اعلانی با macro_rules! برای فرابرنامه‌نویسی عمومی” بحث شد تعریف شوند. ماکروهای شبیه تابع یک پارامتر TokenStream می‌گیرند و تعریف آن‌ها این TokenStream را با استفاده از کد Rust مانند دو نوع دیگر ماکروهای رویه‌ای دستکاری می‌کند. مثالی از یک ماکروی شبیه تابع، ماکروی 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) توان عملیاتی سرور را بهبود می‌بخشیم.

قبل از شروع، باید به دو نکته اشاره کنیم: اول، روشی که استفاده خواهیم کرد بهترین روش برای ساخت یک وب سرور با 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> بازمی‌گرداند که نشان می‌دهد امکان دارد فرآیند binding شکست بخورد. به عنوان مثال، اتصال به پورت 80 نیاز به دسترسی مدیر (administrator) دارد (کاربران عادی فقط می‌توانند به پورت‌های بالاتر از 1023 گوش دهند)، بنابراین اگر تلاش کنیم بدون دسترسی مدیر به پورت 80 متصل شویم، فرآیند binding کار نخواهد کرد. همچنین اگر دو نمونه از برنامه خود اجرا کنیم و در نتیجه دو برنامه به همان پورت گوش دهند، فرآیند binding شکست خواهد خورد. از آنجا که ما یک سرور ساده فقط برای اهداف آموزشی می‌نویسیم، نگرانی‌ای در مورد مدیریت این نوع خطاها نخواهیم داشت؛ در عوض از 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 بسته می‌شود. مرورگرها گاهی با اتصالات بسته شده با تلاش مجدد مقابله می‌کنند، زیرا ممکن است مشکل موقتی باشد. نکته مهم این است که ما با موفقیت به یک اتصال TCP دست پیدا کرده‌ایم!

به یاد داشته باشید که برنامه را با فشار دادن کلیدهای ctrl-c متوقف کنید وقتی که اجرای نسخه خاصی از کد تمام شد. سپس برنامه را با اجرای دستور cargo run پس از ایجاد هر مجموعه از تغییرات کد، مجدداً راه‌اندازی کنید تا مطمئن شوید که جدیدترین کد اجرا می‌شود.

خواندن درخواست

بیایید عملکرد خواندن درخواست از مرورگر را پیاده‌سازی کنیم! برای جدا کردن نگرانی‌ها از اتصال اولیه و سپس انجام برخی اقدامات با اتصال، یک تابع جدید برای پردازش اتصالات ایجاد می‌کنیم. در این تابع جدید handle_connection، داده‌ها را از جریان TCP می‌خوانیم و آن‌ها را چاپ می‌کنیم تا بتوانیم داده‌هایی که از مرورگر ارسال می‌شوند را ببینیم. کد را تغییر دهید تا شبیه لیست ۲۱-۲ شود.

Filename: src/main.rs
use std::{
    io::{prelude::*, BufReader},
    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 با مدیریت فراخوانی متدهای ویژگی 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) است که client درخواست می‌کند: یک URI تقریباً اما نه کاملاً همان مکان‌نمای منبع یکسان (Uniform Resource Locator یا URL) است. تفاوت بین URIs و URLs برای اهداف ما در این فصل مهم نیست، اما استاندارد HTTP از اصطلاح URI استفاده می‌کند، بنابراین می‌توانیم ذهنی URL را به جای URI در نظر بگیریم.

قسمت آخر نسخه HTTP است که client استفاده می‌کند، و سپس خط درخواست با یک دنباله CRLF پایان می‌یابد. (CRLF به معنای بازگشت حامل و تغذیه خط است که اصطلاحاتی از روزهای ماشین تایپ هستند!) دنباله CRLF همچنین می‌تواند به صورت \r\n نوشته شود، جایی که \r یک بازگشت حامل و \n یک تغذیه خط است. دنباله 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::{prelude::*, BufReader},
    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::{prelude::*, BufReader},
    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::{prelude::*, BufReader},
    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::{prelude::*, BufReader},
    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::{prelude::*, BufReader},
    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

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

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

ما بررسی می‌کنیم که چگونه یک درخواست با پردازش کند می‌تواند بر سایر درخواست‌های ارسال‌شده به پیاده‌سازی فعلی سرور تأثیر بگذارد. لیست ۲۱-۱۰ پیاده‌سازی مدیریت یک درخواست به /sleep را نشان می‌دهد که یک پاسخ کند شبیه‌سازی‌شده است و باعث می‌شود سرور قبل از پاسخ دادن به مدت ۵ ثانیه بخوابد.

Filename: src/main.rs
use std::{
    fs,
    io::{prelude::*, BufReader},
    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 تغییر داده‌ایم زیرا اکنون سه حالت داریم. باید به‌طور صریح روی یک برش از request_line الگو تطابق ایجاد کنیم تا مقادیر رشته‌ای را تطابق دهیم؛ match به طور خودکار مرجع‌دهی و عدم مرجع‌دهی را مانند متد برابری انجام نمی‌دهد.

بازوی اول همان بلوک if از لیست ۲۱-۹ است. بازوی دوم یک درخواست به /sleep را تطابق می‌دهد. وقتی آن درخواست دریافت شود، سرور به مدت ۵ ثانیه می‌خوابد قبل از اینکه صفحه HTML موفقیت‌آمیز را نمایش دهد. بازوی سوم همان بلوک else از لیست ۲۱-۹ است.

می‌توانید ببینید که سرور ما چقدر ابتدایی است: کتابخانه‌های واقعی مدیریت تشخیص درخواست‌های متعدد را به روشی بسیار کمتر پرحرف انجام می‌دهند!

سرور را با استفاده از 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 ایجاد کند، کسی که ۱۰ میلیون درخواست به سرور ما ارسال کند می‌تواند با استفاده از تمام منابع سرور، پردازش درخواست‌ها را متوقف کند.

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

این تکنیک تنها یکی از راه‌های بهبود توان عملیاتی یک وب سرور است. گزینه‌های دیگری که ممکن است بررسی کنید شامل مدل fork/join، مدل I/O async تک‌Threaded، یا مدل I/O async چندThreaded هستند. اگر به این موضوع علاقه دارید، می‌توانید بیشتر در مورد راه‌حل‌های دیگر بخوانید و آن‌ها را پیاده‌سازی کنید؛ با یک زبان سطح پایین مانند Rust، همه این گزینه‌ها ممکن هستند.

پیش از آنکه پیاده‌سازی یک Thread Pool را شروع کنیم، بیایید در مورد نحوه استفاده از Pool صحبت کنیم. وقتی قصد طراحی کدی را دارید، ابتدا نوشتن رابط کاربری (client interface) می‌تواند به طراحی شما کمک کند. API کد را به گونه‌ای بنویسید که ساختاری برای نحوه فراخوانی آن داشته باشد؛ سپس قابلیت‌ها را در آن ساختار پیاده‌سازی کنید به جای اینکه ابتدا قابلیت‌ها را پیاده‌سازی کنید و سپس API عمومی را طراحی کنید.

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

ایجاد یک Thread جدید برای هر درخواست

ابتدا، بیایید بررسی کنیم که اگر کد ما برای هر اتصال یک Thread جدید ایجاد کند، چگونه به نظر می‌رسد. همان‌طور که قبلاً ذکر شد، این طرح نهایی ما نیست به دلیل مشکلاتی که ممکن است با ایجاد تعداد نامحدودی از Threadها پیش بیاید، اما این یک نقطه شروع برای ایجاد یک سرور Multithreaded کارا است. سپس Thread Pool را به عنوان یک بهبود اضافه خواهیم کرد، و مقایسه این دو راه‌حل آسان‌تر خواهد بود. لیست ۲۱-۱۱ تغییراتی را که باید در main انجام دهیم تا برای هر جریان در حلقه for یک Thread جدید ایجاد کنیم، نشان می‌دهد.

Filename: src/main.rs
use std::{
    fs,
    io::{prelude::*, BufReader},
    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 ما استفاده می‌کند نداشته باشد. لیست ۲۱-۱۲ رابط فرضی برای یک ساختار ThreadPool را نشان می‌دهد که می‌خواهیم به جای thread::spawn استفاده کنیم.

Filename: src/main.rs
use std::{
    fs,
    io::{prelude::*, BufReader},
    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ها استفاده می‌کنیم، که در اینجا چهار است. سپس، در حلقه for، متد pool.execute رابطی مشابه با thread::spawn دارد، به طوری که یک Closure را می‌گیرد که Pool باید برای هر جریان اجرا کند. ما نیاز داریم pool.execute را پیاده‌سازی کنیم تا Closure را بگیرد و به یکی از Threadهای موجود در Pool برای اجرا بدهد. این کد هنوز کامپایل نمی‌شود، اما آن را امتحان می‌کنیم تا کامپایلر راهنمایی کند که چگونه آن را اصلاح کنیم.

ساخت ThreadPool با استفاده از توسعه مبتنی بر کامپایلر

تغییرات لیست ۲۱-۱۲ را در فایل 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 را ویرایش کنید تا ThreadPool را از crate کتابخانه‌ای وارد دامنه کنید. برای این کار کد زیر را به بالای فایل src/main.rs اضافه کنید:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{prelude::*, BufReader},
    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
    }
}

ما نوع usize را برای پارامتر size انتخاب کردیم، زیرا می‌دانیم که تعداد منفی 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 و ویژگی‌های Fn از فصل ۱۳ توضیح داده شد که می‌توانیم Closureها را با سه ویژگی مختلف به عنوان پارامتر بپذیریم: 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 انتخاب کردیم، زیرا یک Pool با تعداد منفی Thread منطقی نیست. با این حال، یک Pool با صفر Thread نیز منطقی نیست، اما صفر یک مقدار معتبر برای usize است. کدی اضافه خواهیم کرد تا بررسی کند که مقدار size بیشتر از صفر باشد قبل از اینکه یک نمونه از ThreadPool بازگردانیم و در صورت دریافت مقدار صفر، برنامه با استفاده از ماکروی assert! متوقف شود، همان‌طور که در لیست ۲۱-۱۳ نشان داده شده است.

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 می‌نامیم که یک اصطلاح رایج در پیاده‌سازی‌های Pool است. Worker کدی را که باید اجرا شود دریافت می‌کند و آن را در Thread مربوط به Worker اجرا می‌کند. می‌توانید به افرادی که در آشپزخانه یک رستوران کار می‌کنند فکر کنید: Workerها منتظر می‌مانند تا سفارش‌هایی از مشتریان دریافت کنند، و سپس مسئول گرفتن این سفارش‌ها و انجام آن‌ها هستند.

به جای ذخیره یک بردار از نمونه‌های JoinHandle<()> در Thread Pool، ما نمونه‌هایی از ساختار Worker را ذخیره خواهیم کرد. هر Worker یک نمونه JoinHandle<()> را نگه می‌دارد. سپس یک متد روی Worker پیاده‌سازی خواهیم کرد که یک Closure از کد برای اجرا بگیرد و آن را به Thread در حال اجرای Worker برای اجرا ارسال کند. همچنین به هر Worker یک id اختصاص می‌دهیم تا هنگام ثبت لاگ یا اشکال‌زدایی بتوانیم بین Workerهای مختلف در Pool تمایز قائل شویم.

این فرآیند جدیدی است که هنگام ایجاد یک ThreadPool اتفاق می‌افتد. کدی که Closure را به Thread ارسال می‌کند، پس از تنظیم Worker به این شکل پیاده‌سازی خواهد شد:

  1. تعریف یک ساختار Worker که یک id و یک JoinHandle<()> نگه می‌دارد.
  2. تغییر ThreadPool به طوری که یک بردار از نمونه‌های Worker را ذخیره کند.
  3. تعریف یک تابع Worker::new که یک عدد id می‌گیرد و یک نمونه Worker بازمی‌گرداند که شامل id و یک Thread ایجادشده با یک Closure خالی است.
  4. در ThreadPool::new، از شمارنده حلقه for برای تولید یک id استفاده کرده، یک Worker جدید با آن id ایجاد کرده و Worker را در بردار ذخیره می‌کنیم.

اگر آماده یک چالش هستید، سعی کنید این تغییرات را خودتان پیاده‌سازی کنید قبل از اینکه به کد موجود در لیست ۲۱-۱۵ نگاه کنید.

آماده‌اید؟ در اینجا لیست ۲۱-۱۵ با یک روش برای انجام اصلاحات قبلی آورده شده است.

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 خواهد بود. این کد با موفقیت کامپایل می‌شود.

بیایید تلاش کنیم یک receiver از Channel را به هر Worker در هنگام ایجاد Channel توسط Thread Pool ارسال کنیم. می‌دانیم که می‌خواهیم receiver را در Threadی که Workerها ایجاد می‌کنند استفاده کنیم، بنابراین به پارامتر receiver در Closure ارجاع می‌دهیم. کد موجود در لیست ۲۱-۱۷ هنوز کاملاً کامپایل نخواهد شد.

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 منتقل کند. این کار امکان‌پذیر نیست، همان‌طور که در فصل ۱۶ بحث شد: پیاده‌سازی کانال (channel) که Rust ارائه می‌دهد، از نوع چند تولیدکننده (multiple producer) و یک مصرف‌کننده (single consumer) است. این به این معنی است که نمی‌توانیم به سادگی بخش مصرف‌کننده کانال را برای رفع این کد کپی کنیم. همچنین نمی‌خواهیم یک پیام را چندین بار به چند مصرف‌کننده ارسال کنیم؛ بلکه می‌خواهیم یک لیست از پیام‌ها داشته باشیم که چندین Worker آن را پردازش کنند به‌گونه‌ای که هر پیام فقط یک بار پردازش شود.

علاوه بر این، برداشتن یک کار از صف کانال شامل تغییر receiver می‌شود، بنابراین Threadها به یک روش امن برای اشتراک و تغییر receiver نیاز دارند؛ در غیر این صورت، ممکن است با شرایط رقابتی (race conditions) مواجه شویم (همان‌طور که در فصل ۱۶ توضیح داده شد).

با یادآوری اشاره‌گر (Pointer)های هوشمند ایمن برای Threadها که در فصل ۱۶ معرفی شدند: برای اشتراک مالکیت میان چندین Thread و اجازه تغییر مقدار، نیاز به استفاده از Arc<Mutex<T>> داریم. نوع Arc به چندین Worker اجازه می‌دهد مالکیت receiver را به اشتراک بگذارند و Mutex تضمین می‌کند که فقط یک Worker در هر لحظه یک کار را از receiver دریافت کند. لیست ۲۱-۱۸ تغییراتی را که باید اعمال کنیم نشان می‌دهد.

Filename: src/lib.rs
use std::{
    sync::{mpsc, Arc, Mutex},
    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 را کپی می‌کنیم تا شمارنده مرجع افزایش یابد و Workerها بتوانند مالکیت receiver را به اشتراک بگذارند.

با این تغییرات، کد کامپایل می‌شود! به نتیجه نزدیک‌تر می‌شویم!

پیاده‌سازی متد execute

در نهایت، بیایید متد execute را روی ThreadPool پیاده‌سازی کنیم. همچنین Job را از یک ساختار به یک نام مستعار نوع (type alias) برای یک شیء ویژگی تغییر خواهیم داد که نوع Closureی که execute دریافت می‌کند را نگه می‌دارد. همان‌طور که در بخش “ایجاد مترادف‌های نوع با نام مستعار” از فصل ۲۰ بحث شد، نام‌های مستعار نوع به ما امکان می‌دهند تایپ‌های طولانی را برای استفاده آسان‌تر کوتاه کنیم. به لیست ۲۱-۱۹ نگاه کنید.

Filename: src/lib.rs
use std::{
    sync::{mpsc, Arc, Mutex},
    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 به طور مداوم حلقه بزند، از بخش دریافت‌کننده کانال درخواست یک کار کند و کار را هنگام دریافت اجرا کند. بیایید تغییرات نشان داده‌شده در لیست ۲۱-۲۰ را به Worker::new اعمال کنیم.

Filename: src/lib.rs
use std::{
    sync::{mpsc, Arc, Mutex},
    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 را مانند لیست ۲۱-۲۱ ننوشتیم.

Filename: src/lib.rs
use std::{
    sync::{mpsc, Arc, Mutex},
    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> توجه نکنیم.

کد موجود در لیست ۲۱-۲۰ که از let job = receiver.lock().unwrap().recv().unwrap(); استفاده می‌کند کار می‌کند زیرا با let، هر مقدار موقتی استفاده‌شده در عبارت سمت راست علامت برابر بلافاصله پس از پایان دستور let حذف می‌شود. با این حال، while let (و همچنین if let و match) مقادیر موقتی را تا پایان بلوک مرتبط حذف نمی‌کند. در لیست ۲۱-۲۱، قفل در طول فراخوانی به job() نگه داشته می‌شود، به این معنی که سایر Workerها نمی‌توانند کار دریافت کنند.

خاموشی و پاک‌سازی منظم

کدی که در لیستینگ 21-20 وجود دارد، همان‌طور که انتظار داشتیم، با استفاده از یک مجموعه نخ (thread pool) به درخواست‌ها به صورت غیرهمزمان پاسخ می‌دهد. ما هشدارهایی در مورد فیلدهای workers، id و thread دریافت می‌کنیم که به طور مستقیم از آن‌ها استفاده نمی‌کنیم و به ما یادآوری می‌کنند که هیچ چیزی را پاک‌سازی نمی‌کنیم. وقتی از روش کم‌ظرافت ctrl-c برای متوقف کردن نخ اصلی استفاده می‌کنیم، تمام نخ‌های دیگر نیز بلافاصله متوقف می‌شوند، حتی اگر در میانه ارائه یک درخواست باشند.

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

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

پیاده‌سازی Drop Trait روی ThreadPool

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

Filename: src/lib.rs
use std::{
    sync::{mpsc, Arc, Mutex},
    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: ملحق کردن هر نخ وقتی مجموعه نخ از محدوده خارج می‌شود

ابتدا، ما از میان هر یک از workers موجود در مجموعه نخ حلقه می‌زنیم. ما برای این کار از &mut استفاده می‌کنیم زیرا self یک ارجاع قابل تغییر است و ما همچنین نیاز داریم که بتوانیم worker را تغییر دهیم. برای هر worker، پیامی چاپ می‌کنیم که نشان می‌دهد این worker خاص در حال خاموش‌شدن است، و سپس join را روی نخ آن worker فراخوانی می‌کنیم. اگر فراخوانی 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`
    --> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/std/src/thread/mod.rs:1763:17
     |
1763 |     pub fn join(self) -> Result<T> {
     |                 ^^^^

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::{mpsc, Arc, Mutex},
    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 }
    }
}
}

این تغییر خطای کامپایلر را برطرف می‌کند و نیازی به تغییرات دیگر در کد ما ندارد.

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

با تمام تغییراتی که اعمال کرده‌ایم، کد ما بدون هیچ هشداری کامپایل می‌شود. با این حال، خبر بد این است که این کد هنوز به درستی کار نمی‌کند. کلید مشکل در منطق موجود در closureهایی است که توسط نخ‌های نمونه‌های Worker اجرا می‌شوند: در حال حاضر، ما join را فراخوانی می‌کنیم، اما این کار نخ‌ها را خاموش نمی‌کند زیرا آن‌ها برای همیشه در جستجوی وظایف loop می‌زنند. اگر با پیاده‌سازی فعلی drop، ThreadPool خود را حذف کنیم، نخ اصلی برای همیشه منتظر می‌ماند تا اولین نخ تکمیل شود.

برای حل این مشکل، باید تغییری در پیاده‌سازی drop در ThreadPool و سپس تغییری در حلقه Worker ایجاد کنیم.

ابتدا، پیاده‌سازی drop در ThreadPool را تغییر می‌دهیم تا sender را قبل از منتظر ماندن برای تکمیل نخ‌ها به صورت صریح حذف کنیم. لیستینگ 21-23 تغییرات در ThreadPool برای حذف صریح sender را نشان می‌دهد. برخلاف workers، اینجا ما باید از یک Option استفاده کنیم تا بتوانیم sender را با Option::take از ThreadPool منتقل کنیم.

Filename: src/lib.rs
use std::{
    sync::{mpsc, Arc, Mutex},
    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: حذف صریح sender قبل از ملحق کردن نخ‌های worker

حذف sender کانال را می‌بندد، که نشان می‌دهد دیگر هیچ پیامی ارسال نخواهد شد. وقتی این اتفاق می‌افتد، تمام فراخوانی‌های recv که workers در حلقه بی‌نهایت انجام می‌دهند یک خطا برمی‌گرداند. در لیستینگ 21-24، حلقه Worker را تغییر می‌دهیم تا در چنین حالتی به صورت منظم از حلقه خارج شود، که به این معناست که نخ‌ها وقتی پیاده‌سازی drop در ThreadPool روی آن‌ها join را فراخوانی می‌کند تکمیل خواهند شد.

Filename: src/lib.rs
use std::{
    sync::{mpsc, Arc, Mutex},
    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::{prelude::*, BufReader},
    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

ممکن است ترتیب متفاوتی از کارگران و پیام‌های چاپ‌شده را مشاهده کنید. از پیام‌ها می‌توان فهمید که این کد چگونه کار می‌کند: کارگران 0 و 3 اولین دو درخواست را دریافت کردند. سرور پس از اتصال دوم دیگر اتصال‌ها را نمی‌پذیرد و پیاده‌سازی Drop روی ThreadPool شروع به اجرا می‌کند قبل از اینکه کارگر 3 حتی کار خود را شروع کند. حذف sender تمام کارگران را قطع کرده و به آن‌ها می‌گوید که خاموش شوند. هر کارگر هنگام قطع شدن یک پیام چاپ می‌کند و سپس مجموعه نخ (thread pool) join را فراخوانی می‌کند تا منتظر تکمیل هر نخ کارگر بماند.

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

تبریک می‌گویم! پروژه خود را کامل کردید؛ ما یک سرور وب ساده داریم که از یک مجموعه نخ برای پاسخ‌دهی غیرهمزمان استفاده می‌کند. ما توانستیم سرور را به صورت منظم خاموش کنیم و تمام نخ‌ها در مجموعه را پاک‌سازی کنیم.

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

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{prelude::*, BufReader},
    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::{mpsc, Arc, Mutex},
    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 سپاسگزاریم. اکنون آماده‌اید که پروژه‌های Rust خود را پیاده‌سازی کنید و به پروژه‌های دیگران کمک کنید. به یاد داشته باشید که جامعه‌ای خوش‌آمدگوی از Rustaceans وجود دارد که مشتاقانه منتظر کمک به شما در هر چالشی هستند که در مسیر 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، برای فراخوانی آن تابع از کد نسخه ۲۰۱۸ خود استفاده کنید. برای اطلاعات بیشتر در مورد نسخه‌ها به ضمیمه ه مراجعه کنید.

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

این ضمیمه شامل واژه‌نامه‌ای از سینتکس زبان Rust است، از جمله عملگرها و سایر نمادهایی که به تنهایی یا در زمینه مسیرها، جنریک‌ها، محدودیت‌های ویژگی، ماکروها، ویژگی‌ها، نظرات، تاپل‌ها و براکت‌ها ظاهر می‌شوند.

عملگرها

جدول B-1 عملگرهای موجود در Rust، یک مثال از چگونگی ظاهر شدن عملگر در زمینه، توضیح کوتاه و اینکه آیا آن عملگر قابل اضافه‌بارگذاری است یا نه را نشان می‌دهد. اگر یک عملگر قابل اضافه‌بارگذاری باشد، ویژگی مرتبط برای اضافه‌بارگذاری آن عملگر ذکر شده است.

جدول B-1: عملگرها

عملگرمثالتوضیحOverloadable؟
!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نوع اشاره‌گر (Pointer) ارجاعی
&expr & exprAND بیتیBitAnd
&=var &= exprAND بیتی و انتسابBitAndAssign
&&expr && exprAND منطقی کوتاه
*expr * exprضرب حسابیMul
*=var *= exprضرب حسابی و انتسابMulAssign
**exprرفع ارجاعDeref
**const type, *mut typeاشاره‌گر (Pointer) خام
+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.., ..expr, expr..exprمحدوده راست‌انحصاریPartialOrd
..=..=expr, expr..=exprمحدوده راست‌شاملPartialOrd
....exprسینتکس به‌روزرسانی literal ساختار
..variant(x, ..), struct_type { x, .. }الگوی “و بقیه”
...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 @ patبایند الگو
^expr ^ exprXOR بیتیBitXor
^=var ^= exprXOR بیتی و انتسابBitXorAssign
|pat | patجایگزین‌های الگو
|expr | exprOR بیتیBitOr
|=var |= exprOR بیتی و انتسابBitOrAssign
||expr || exprOR منطقی کوتاه
?expr?انتشار خطا

نمادهای غیرعملگری

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

جدول B-2 نمادهایی را نشان می‌دهد که به تنهایی ظاهر می‌شوند و در مکان‌های مختلف معتبر هستند.

جدول B-2: سینتکس مستقل

نمادتوضیح
'identطول عمر نام‌گذاری‌شده یا برچسب حلقه
...u8, ...i32, ...f64, ...usize, etc.لیترال عددی با نوع مشخص
"..."لیترال رشته
r"...", r#"..."#, r##"..."##, etc.لیترال رشته خام، کاراکترهای escape پردازش نمی‌شوند
b"..."لیترال رشته بایتی؛ آرایه‌ای از بایت‌ها به جای رشته می‌سازد
br"...", br#"..."#, br##"..."##, etc.لیترال رشته خام بایتی، ترکیبی از رشته خام و رشته بایتی
'...'لیترال کاراکتر
b'...'لیترال بایت ASCII
|…| exprclosure
!همیشه خالی، نوع bottom برای توابع واگرا
_بایند الگوی “نادیده‌گرفته‌شده”؛ همچنین برای خواناتر کردن لیترال‌های عددی استفاده می‌شود

جدول 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]ویژگی خارجی
#![meta]ویژگی داخلی
$identجایگزینی ماکرو
$ident:kindگرفتن ماکرو
$(…)…تکرار ماکرو
ident!(...), ident!{...}, ident![...]فراخوانی ماکرو

جدول B-7: نظرات

نمادتوضیح
//نظر تک‌خطی
//!نظر مستند داخلی تک‌خطی
///نظر مستند خارجی تک‌خطی
/*...*/نظر بلوکی
/*!...*/نظر مستند داخلی بلوکی
/**...*/نظر مستند خارجی بلوکی

جدول B-8: تاپل‌ها

نمادتوضیح
()تاپل خالی (معروف به واحد)، هم به عنوان لیترال و هم نوع
(expr)عبارت پرانتزدار
(expr,)عبارت تاپل تک‌عنصری
(type,)نوع تاپل تک‌عنصری
(expr, ...)عبارت تاپل
(type, ...)نوع تاپل
expr(expr, ...)عبارت فراخوانی تابع؛ همچنین برای مقداردهی اولیه به struct‌های تاپلی و واریانت‌های enum تاپلی استفاده می‌شود
expr.0, expr.1, etc.اندیس‌گذاری تاپل

جدول B-9: کروشه‌ها

زمینهتوضیح
{...}عبارت بلوک
Type {...}لیترال struct

جدول 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 به عنوان “اندیس”

ضمیمه ج: ویژگی‌های قابل اشتقاق

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

در این ضمیمه، مرجعی از تمام ویژگی‌های موجود در کتابخانه استاندارد که می‌توانید با derive استفاده کنید ارائه می‌شود. هر بخش شامل موارد زیر است:

اگر رفتار متفاوتی از آنچه ویژگی derive ارائه می‌دهد می‌خواهید، به مستندات کتابخانه استاندارد برای هر ویژگی مراجعه کنید تا جزئیات مربوط به نحوه پیاده‌سازی دستی آن را بیابید.

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

مثالی از یک ویژگی که نمی‌تواند مشتق شود، Display است که فرمت‌دهی برای کاربران نهایی را مدیریت می‌کند. شما باید همیشه راه مناسب برای نمایش یک نوع به کاربر نهایی را در نظر بگیرید. چه بخش‌هایی از نوع باید به کاربر نهایی نشان داده شود؟ چه بخش‌هایی برای او مرتبط است؟ چه فرمتی از داده برای او بیشترین اهمیت را دارد؟ کامپایلر Rust این بینش را ندارد، بنابراین نمی‌تواند رفتار پیش‌فرض مناسب را برای شما فراهم کند.

لیست ویژگی‌های قابل اشتقاق ارائه‌شده در این ضمیمه جامع نیست: کتابخانه‌ها می‌توانند derive را برای ویژگی‌های خود پیاده‌سازی کنند و لیست ویژگی‌هایی که می‌توانید با derive استفاده کنید را به‌طور واقعی باز بگذارند. پیاده‌سازی derive شامل استفاده از یک ماکروی فرآیندی است که در بخش “ماکروها” از فصل 20 پوشش داده شده است.

Debug برای خروجی برنامه‌نویسی

ویژگی Debug فرمت‌دهی دیباگ را در رشته‌های فرمت فعال می‌کند که با افزودن :? درون نگه‌دارنده‌های {} مشخص می‌کنید.

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

ویژگی 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 به شما امکان می‌دهد نمونه‌های یک نوع را برای اهداف مرتب‌سازی مقایسه کنید. نوعی که ویژگی PartialOrd را پیاده‌سازی می‌کند می‌تواند با عملگرهای <، >، <= و >= استفاده شود. شما فقط می‌توانید ویژگی PartialOrd را به نوع‌هایی اعمال کنید که همچنین PartialEq را پیاده‌سازی کرده باشند.

مشتق‌سازی 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> است، یک ساختار داده که داده‌ها را بر اساس ترتیب مرتب‌سازی مقادیر ذخیره می‌کند.

Clone و Copy برای تکثیر مقادیر

ویژگی Clone به شما امکان می‌دهد به طور صریح یک کپی عمیق از یک مقدار ایجاد کنید، و فرایند تکثیر ممکن است شامل اجرای کد دلخواه و کپی داده‌های heap باشد. برای اطلاعات بیشتر درباره Clone، به بخش “راه‌های تعامل متغیرها و داده‌ها: Clone” در فصل 4 مراجعه کنید.

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

مثالی از زمانی که Clone مورد نیاز است، هنگام فراخوانی متد to_vec روی یک slice است. slice مالک نمونه‌های نوعی که شامل است را ندارد، اما وکتوری که از to_vec برگردانده می‌شود باید مالک نمونه‌های خود باشد، بنابراین to_vec روی هر آیتم clone را فراخوانی می‌کند. بنابراین، نوع ذخیره‌شده در slice باید Clone را پیاده‌سازی کند.

ویژگی 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 به شما امکان می‌دهد یک مقدار پیش‌فرض برای یک نوع ایجاد کنید. مشتق‌سازی Default تابع default را پیاده‌سازی می‌کند. پیاده‌سازی مشتق‌شده تابع default تابع default را روی هر بخش از نوع فراخوانی می‌کند، به این معنی که تمام فیلدها یا مقادیر در نوع نیز باید 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

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

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

$ rustup component add rustfmt

این دستور ابزارهای rustfmt و cargo-fmt را به شما می‌دهد، مشابه به اینکه Rust ابزارهای rustc و cargo را ارائه می‌دهد. برای فرمت کردن هر پروژه‌ای که از Cargo استفاده می‌کند، دستور زیر را وارد کنید:

$ cargo fmt

اجرای این دستور تمام کدهای Rust در crate فعلی را مجدداً فرمت می‌کند. این کار باید فقط سبک کدنویسی را تغییر دهد، نه معنای کد را. برای اطلاعات بیشتر در مورد rustfmt، به مستندات آن مراجعه کنید.

اصلاح کد شما با rustfix

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

Filename: src/main.rs

fn do_something() {}

fn main() {
    for i in 0..100 {
        do_something();
    }
}

در اینجا، ما تابع do_something را 100 بار فراخوانی می‌کنیم، اما هرگز متغیر i را در بدنه حلقه for استفاده نمی‌کنیم. Rust در این مورد به ما هشدار می‌دهد:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: unused variable: `i`
 --> src/main.rs:4:9
  |
4 |     for i in 0..100 {
  |         ^ help: consider using `_i` instead
  |
  = note: #[warn(unused_variables)] on by default

    Finished dev [unoptimized + debuginfo] target(s) in 0.50s

هشدار پیشنهاد می‌دهد که به جای آن از نام _i استفاده کنیم: خط زیرنویس نشان می‌دهد که قصد داریم این متغیر استفاده نشده باقی بماند. ما می‌توانیم به طور خودکار این پیشنهاد را با استفاده از ابزار 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 do_something() {}

fn main() {
    for _i in 0..100 {
        do_something();
    }
}

اکنون متغیر حلقه for به نام _i تغییر یافته است و هشدار دیگر نمایش داده نمی‌شود.

همچنین می‌توانید از دستور cargo fix برای انتقال کد خود بین نسخه‌های مختلف Rust استفاده کنید. نسخه‌ها در ضمیمه ه پوشش داده شده‌اند.

لینت‌های بیشتر با Clippy

ابزار Clippy مجموعه‌ای از تحلیلگرهای کد (لینت‌ها) است که کد شما را تحلیل می‌کنند تا بتوانید اشتباهات رایج را پیدا کرده و کد Rust خود را بهبود دهید.

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

$ rustup component add clippy

برای اجرای تحلیلگرهای 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 را توصیه می‌کند. این ابزار مجموعه‌ای از ابزارهای متمرکز بر کامپایلر است که با پروتکل زبان سرور کار می‌کند، که یک مشخصه برای ارتباط IDEها و زبان‌های برنامه‌نویسی با یکدیگر است. مشتری‌های مختلف می‌توانند از rust-analyzer استفاده کنند، مانند پلاگین Rust analyzer برای Visual Studio Code.

برای دستورالعمل‌های نصب، به صفحه اصلی پروژه rust-analyzer مراجعه کنید، سپس پشتیبانی از سرور زبان را در 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 می‌شود.