ساخت یه بازی حدس عدد ساده در Rust

این مطلب رو از روی The Rust Book نوشتم. اینجا یه بازی ساده حدس عدد با Rust میسازیم و با let، match، Result، کریت ها و Cargo آشنا میشیم.

ساخت یه بازی حدس عدد ساده در Rust

توی این مقاله یه بازی کلاسیک guessing game میسازیم تا یکم ترسمون از یه سری مفاهیم پایه ای Rust بریزه.

یه سری مفاهیم مثل let، match، متد ها، associated functions، external crates و یه سری چیزای دیگه.

منطق برنامه هم که خیلی ساده اس و اینجوریه که یه عدد رندوم بین ۱ تا ۱۰۰ تولید میکنه بعد از یوزر میخاد که یه عددی رو همینطوری حدس بزنه، بعد وقتی که شما عدد رو وارد کردی سه تا حالت هست:

  • حدست کمتر از عدد اصلی بوده.
  • حدست بیشتر از عدد اصلی بوده.
  • عدد اصلی رو درست حدس زدی.

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

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

ساختن پروژه جدید

فرض رو بر این میذارم که Rust و Cargo رو نصب کردی، اگه نه اول از اینجا نصبشون کن.

برای شروع، برو داخل دایرکتری‌ای که پروژه هات رو اونجا نگه میداری و با Cargo یه پروژه جدید بساز:

$ cargo new guessing_game
$ cd guessing_game

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

حالا یه نگاه به فایل Cargo.toml که ساخته شده بنداز:

نام فایل: Cargo.toml

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

[dependencies]

این cargo new خودش یه برنامه‌ Hello, world! هم برات میسازه. فایل src/main.rs رو ببین:

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

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

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

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

اینم بگم که دستور run توی development خیلی به کار میاد چون شما داری تند تند کد رو تغییر میدی و هربار میخوای میتونی نتیجه تغییرات رو سریعا ببینی.

حالا برگرد به main.rs و بریم یکم کد بزنیم ببینیم چند چندیم.

گرفتن حدس از کاربر

بخش اول برنامه باید یدونه input از کاربر بگیره و بعد این input رو پردازش کنه و فرمتشو چک کنه و این داستانا.

بیا اصلا از همون قدم اول شروع کنیم، اون اول گفتم که ما میخوایم یوزر بیاد یه عدد رو حدس بزنه، درسته؟ خب شما کد لیستینگ ۱ رو داخل src/main.rs بنویس.

نام فایل: 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}");
}

لیستینگ ۱: کدی که یه حدس از کاربر میگیره و چاپش میکنه

داخل همین چند خط کدی که میبینی کلی اتفاقات مختلف داره میفته، اول برای اینکه بتونیم ورودی رو بگیریم و خروجی چاپ کنیم، باید لایبرری io (مخفف input/output) رو بیاریم توی اسکوپ.

این io هم بخشی از استاندارد لایبرری Rust هست که بهش میگیم std:

use std::io;

این استاندارد لایبرری یا std در Rust در واقع یه سری کد و ابزار آماده‌ست که خود Rust در اختیار شما میذاره و لازم نیست جداگانه نصبشون کنی. چیزهایی مثل String، Vec، Option، Result، کار با فایل‌ ها، thread ها و خیلی قابلیت‌ های پایه داخلش هستن.

بعد حالا خود Rust میاد یه سری چیزارو به صورت پیش فرض از استاندارد لایبرری خودش میاره داخل اسکوپ برنامه و به این مجموعه پیش فرض میگیم prelude و میتونی لیست کاملش رو توی مستندات استاندارد لایبرری ببینی.

حالا شما میخوای یه چیزی استفاده کنی که داخل prelude نیست، خودت باید دستی با use بیاریش داخل اسکوپ، نمونه اش همین std::io که برای گرفتن input از کاربر بهش نیاز داریم.

فانکشن main هم نقطه شروع برنامه اس:

fn main() {

سینتکس fn یعنی داریم یه فانکشن جدید تعریف میکنیم. پرانتز های خالی () هم یعنی این فانکشن فعلا پارامتری نمیگیره و آکولاد { هم که شروع بدنه فانکشنه.

بعد این println! یه macro هست که یه رشته رو روی صفحه چاپ میکنه:

    println!("Guess the number!");

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

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

ولی لابد الان برات سوال شده که اصلا این macro چی هست؟

این macro در Rust یه ابزاریه که میاد قبل از کامپایل، از روی input شما کد Rust تولید می‌کنه.

حالا برای اینکه برات جا بیفته، الان یه لحظه مثال بالارو ول کن و فرض کن کلا یک فانکشن جدیدی داری که این شکلیه:

fn greet(name: &str) {
    println!("Hello, {name}");
}

وقتی می‌نویسی greet("Yazdan")، فانکشن اجرا می‌شه و این یعنی فانکشن از قبل وجود داره و فقط ورودی رو می‌گیره و کد داخلش رو اجرا می‌کنه.

این فانکشن کد جدیدی تولید نمی‌کنه و فقط کدی که از قبل نوشته شده رو اجرا می‌کنه.

حالا بیا به println! نگاه کن:

println!("Hello, {}", name);

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

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

این کانسپت فقط مخصوص Rust نیست و من اینو داخل C/C++ و Elixir و زبان های دیگه هم دیدم ولی روش کار اونا با macro های Rust یکسان نیست.

ذخیره کردن مقادیر داخل متغیر ها

خب طبیعتا قدم بعدی اینه که یه variable بسازیم تا ورودی کاربر رو توش نگه داریم:

    let mut guess = String::new();

اینجاس که ماجرا جالب تر میشه! توی همین یه خط کوچولو کلی چیز داره اتفاق میفته.

یه دقیقه اینو ولش کن بذار اول بهت بگم که این let چیه.

شما اگه با زبان های دیگه کد زده باشی قطعا قبلا چشمت به let خورده و داخل Rust هم ما با let میتونیم variable بسازیم. یه مثال ساده:

let apples = 5;

این لاین یه variable جدید به اسم apples میسازه و مقدار 5 رو بهش bind میکنه.

توی Rust متغیر ها به صورت پیشفرض immutable ( غیرقابل تغییر ) هستن. یعنی همینکه یه مقدار بهشون دادی، دیگه اون مقدار عوض نمیشه.

اگه میخوای بیشتر راجع به این مفهوم بدونی، بخش Variables and Mutability از کتاب رسمی Rust رو بخون.

ولی خیلی اوقات هست که شما میخوای متغیرت mutable ( قابل تغییر ) باشه، در این سناریو شما میای یدونه mut قبل اسم متغیرت میذاری:

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

نکته: سینتکس // یه کامنت شروع میکنه که تا آخر همون خط ادامه داره. Rust هرچی داخل کامنت باشه رو نادیده میگیره.

الان شما میدونی که let mut guess یه متغیر قابل تغییر به اسم guess میسازه.

علامت مساوی (=) هم به Rust میگه که میخوایم همین الان یه چیزی رو به این متغیر بایند کنیم. سمت راست مساوی هم همون مقداریه که guess بهش بایند میشه.

یعنی outputـی که صدا زدن String::new بدست میاد و String::new هم که یه instance جدید از String برمیگردونه.

خود این String هم یه تایپ رشته‌ ایه که standard library بهمون میده.

اون سینتکس :: توی String::new یعنی new یه associated function روی تایپ String هست.

در زبان راست، associated function در واقع یه فانکشنیه که به یک struct یا enum تعلق داره ولی برای اجرا شدن به یک instance از اون نیاز نداره مثلا User::new() یک associated function هست که برای ساختن یک User استفاده می‌شه.

به جای . ، با :: بهش دسترسی پیدا میکنیم.

به عبارتی این associated function در مقابل method روی یک instance موجود کار می‌کنه، بیشتر از این توضیح نمیدم چون احتمالا همینجوریشم سردرگم شدی، هدفمم همین بود که فقط چشمت بهش بخوره تا بعدا که بهش رسیدی برات آشنا باشه. اگه الان کنجکاوی، بخش Method Syntax از کتاب رسمی Rust رو بخون.

بعد دیگه این فانکشن new هم یه رشته‌ خالی جدید میسازه.

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

پس در کل الان فهمیدیم که لاین let mut guess = String::new(); یه متغیر قابل تغییر ساخته که فعلا به یه String خالی و جدید بایند شده.

گرفتن input از کاربر

یادته که ما اول برنامه با use std::io; امکانات ورودی/خروجی رو آوردیم توی اسکوپ؟ حالا میخوام فانکشن stdin رو از ماژول io صدا بزنم و باهاش ورودی کاربر رو بگیرم:

    io::stdin()
        .read_line(&mut guess)

حالا اصلا اگه اول برنامه use std::io; رو هم نمینوشتیم، بازم میتونستیم از این فانکشن استفاده کنیم منتها باید کاملش رو مینوشتیم یعنی std::io::stdin.

این فانکشن stdin یه instance از std::io::Stdin رو بهت میده که در واقع یه handle به ورودی استاندارد ترمینالته.

بعدش لاین .read_line(&mut guess) میاد متد read_line رو روی همون handle صدا میزنه تا ورودی کاربر رو ازش بگیره.

کار read_line دقیقا اینه که هرچی کاربر تایپ کرده رو برمیداره و به انتهای اون رشته اضافه میکنه (بدون اینکه محتوای قبلیش رو پاک کنه) و برای همینم خود رشته رو بهش پاس میدیم و چون قراره محتوای رشته عوض بشه، اون رشته باید حتما mutable باشه.

در ادامه ما &mut guess رو هم به عنوان آرگومان بهش پاس میدیم تا بهش بگیم ورودی کاربر رو کجا ذخیره کنه.

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

مثلا &guess داره به Rust میگه که بیا به متغیر guess نگاه کن ولی دست بهش نزن و &mut guess یعنی بیا به guess دسترسی داشته باش و اگه خواستی هم تغییرش بده.

پس فعلا فقط اینو یادت باشه: & یعنی reference به یک مقدار، و &mut یعنی reference قابل تغییر به اون مقدار.

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

پسر رو همین یه لاین کد، اینهمه حرف زدیم تا حالا!! هووووف!!

تازه الان رسیدیم به لاین سوم:

        .expect("Failed to read line");

حالا ما اصلا میتونستیم همه اینارو اینجوری هم بنویسیما:

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

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

اصلا در هر زبانی شما وقتی داری متد هارو با سینتکس .method_name() صدا میزنی، شکستن لاین ها و اضافه کردن اسپیسینگ کمک زیادی به خوانایی کد میکنه، البته خیلیا قطعا با من مخالف هستن.

حالا ولش کن بیا ببینیم این خط چیکار میکنه.

همونطور که گفتم، read_line هرچی کاربر وارد کرده رو میریزه توی رشته‌ ای که بهش پاس دادیم و در ضمن یه مقدار Result هم برمیگردونه.

این Result یه enumeration هست که بهش خلاصه میگیم enum، یعنی یه تایپ که می‌تونه یکی از چند حالت مشخص رو داشته باشه. به هر کدوم از این حالت ها هم میگیم یه variant.

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

ورینت های Result عبارتن از Ok و Err.

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

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

حالا اگه این نمونه از Result یه مقدار Err باشه، expect باعث میشه برنامه کرش کنه و همون پیامی که بهش پاس دادی رو نشون بده.

حالا اگه این read_line یه Err برگردونه، به احتمال زیاد به خاطر یه خطا از سمت سیستم عاملته.

ولی اگه Result مقدار Ok داشته باشه، expect میاد و مقداری که داخل Ok هست رو برمیداره و بهت میده تا ازش استفاده کنی که توی این مثال ما، اون مقدار میشه تعداد بایت‌ های input کاربر.

شما اگه این 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)]` (part of `#[warn(unused)]`) 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 کافیه. ریکاور کردن از خطاها خودش یه بحث جداست (اگه کنجکاوی، این بخش از کتاب Rust رو بخون) و اینجا فقط برای یادگیریه، حواست باشه بعدا نری شت کد بزنی بندازی گردن من.

چاپ کردن مقادیر با پلیس‌هولدرهای println!

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

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

این خط مقدار guess رو داخل متن قرار میده و چاپ می‌کنه. این {} یه placeholder هست یعنی همونجایی که میخوای مقدار یا نتیجه‌ یک expression قرار بگیره. مثلا println!("You guessed: {guess}") مقدار guess رو می‌ذاره اونجا، و با println!("Result: {}", 2 + 3) نتیجه‌ی 2 + 3 رو داخل {} قرار می‌ده.

چاپ کردن یه متغیر و نتیجه‌ یه اکسپرشن توی یه println! اینجوری میشه:

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

اگه ترمینالت همینو نشون میده، بهت ماشالا میگم! بخش اول بازی تمومه، یعنی داریم از کیبورد ورودی میگیریم و بعد چاپش میکنیم و تامام.

ساختن عدد مخفی

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

این عدد باید هر بار متفاوت باشه، وگرنه بازی بار دوم دیگه بی معنی میشه کلا. ما یه عدد رندوم بین ۱ تا ۱۰۰ انتخاب میکنیم که بازی خیلی هم سخت نشه و سریع بتونیم تست بگیریم.

حالا Rust فعلا امکانات تولید عدد رندوم رو توی استاندارد لایبرری نداره، ولی خود تیم Rust یه کریت به اسم rand داره که کارو برامون در میاره.

اضافه کردن قابلیت با یه کریت

یادت باشه که یه crate در واقع یه مجموعه از فایل های سورس کد Rust هست.

پروژه‌ ای که ما الان داریم میسازیم یه binary crateـه یعنی یه چیزیه که قابل اجراس.

ولی rand یه library crateـه ، یعنی کدی که قراره داخل برنامه های دیگه استفاده بشه و خودش به تنهایی اجرا نمیشه.

ساده تر بخوام بگم، library crate خودش به تنهایی قابل اجرا نیست و باید داخل یه binary crate اجرا بشه.

یکی از دلایل اصلی استفاده از کارگو، مدیریت crate های external هست و اینجا دقیقا همون جاییه که کارگو میاد کارو راحت میکنه.

حالا قبل از اینکه بتونیم از rand استفاده کنیم، باید فایل Cargo.toml رو ویرایش کنیم و rand رو به عنوان یه dependency بهش اضافه کنیم.

اون فایله رو باز کن و این خط رو زیر هدر [dependencies] که Cargo برات ساخته اضافه کن.

حواست باشه دقیقا همین ورژنی که اینجا نوشتم رو اونجا بنویسی وگرنه ممکنه مثال های این مقاله برات کار نکنه:

نام فایل: Cargo.toml

[dependencies]
rand = "0.10.1"

توی فایل Cargo.toml هرچی بعد از یه هدر بیاد، بخشی از همون section حساب میشه تا وقتی که section بعدی شروع بشه.

توی [dependencies] به Cargo میگی پروژت به چه crate های اکسترنالی و چه ورژن هایی از اونا نیاز داره.

اینجا ما rand رو با ورژن 0.10.1 مشخص کردیم.

بعد این Cargo از یه استانداردی به اسم Semantic Versioning (SemVer) برای مدیریت ورژن‌ها استفاده می‌کنه.

مثلا وقتی می‌نویسیم 0.10.1، یعنی Cargo می‌تونه نسخه‌های سازگار با 0.10.1 رو بگیره، یعنی یه چیزی بین 0.10.1 و 0.11.0، اینطوری مطمئن می‌شیم که داریم یه ورژنی رو دریافت میکنیم که با کد خودمون سازگار هست.

حالا بدون اینکه هیچ کدی رو تغییر بدیم بیا طبق لیستینگ ۲ پروژه رو بیلد کنیم:

$ cargo build
    Updating crates.io index
     Locking 8 packages to latest Rust 1.96.0 compatible versions
  Downloaded rand_core v0.10.1
  Downloaded chacha20 v0.10.1
  Downloaded rand v0.10.1
  Downloaded 3 crates (162.9KiB) in 0.59s
   Compiling libc v0.2.186
   Compiling rand_core v0.10.1
   Compiling getrandom v0.4.3
   Compiling cfg-if v1.0.4
   Compiling chacha20 v0.10.1
   Compiling rand v0.10.1
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.03s

لیستینگ ۲: خروجی cargo build بعد از اضافه کردن کریت rand به عنوان دپندنسی

شما وقتی یه دپندنسی اکسترنال اضافه میکنی، Cargo میره آخرین ورژن هر چیزی که اون دپندنسی لازم داره رو از registry میگیره.

این registry هم یه کپی از داده های Crates.io هست یعنی همون جایی که دولوپرای اکوسیستم Rust پروژه های اوپن سورسشون رو میذارن تا بقیه ازش استفاده کنن.

بعد از آپدیت کردن registry، کارگو میاد سکشن [dependencies] رو چک میکنه و هر کریتی که هنوز دانلود نشده باشه رو دانلود میکنه.

حالا توی این مورد ما فقط rand رو نوشته بودیم ولی کارگو یه سری کریت دیگه که خود rand بهشون نیاز داره رو هم آورد.

بعد از دانلود، Rust همشون رو کامپایل میکنه و بعد پروژه رو با این دپندنسی ها کامپایل میکنه.

حالا اگه بدون اینکه چیزیو عوض کنی بلافاصله دوباره cargo build رو بزنی، هیچ outputـی به جز خط Finished نمیبینی چون کارگو میدونه که آلردی دپندنسی هارو دانلود و کامپایل کرده، و تو هم توی Cargo.toml چیزیشون رو عوض نکردی.

ضمن اینکه میدونه کد خودتم تغییری نکرده پس اونم دوباره کامپایل نمیکنه.

حالا اگه فایل 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

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

بیلد های تکرارپذیر (reproducible builds)

این کارگو یه مکانیزمی داره که مطمئن میشه هر بار تو یا هر کس دیگه ای کدتو بیلد کنه، دقیقا همون outputـه یکسان ساخته بشه.

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

مثلا فرض کن هفته بعد ورژن 0.10.2 از کریت rand بیاد بیرون و توش یه باگ مهمی رو فیکس کرده باشن، ولی همزمان یه regression هم داشته باشه که کد تو رو به فنا میده.

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

برای همینه که Rust اولین باری که cargo build میزنی، فایل Cargo.lock رو میسازه، پس الان این فایل رو داخل دایرکتری guessing_game داریم.

وقتی برای اولین بار پروژه رو بیلد میکنی، کارگو تمام ورژن هایی که با شرایط تو جور در میان رو پیدا میکنه و مینویسه توی Cargo.lock.

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

اینجوری بیلدت به صورت خودکار تکرارپذیر میشه.

بخوام ساده بگم، پروژه‌ شما به لطف Cargo.lock روی 0.10.1 قفل میشه تاااااا وقتی که خودت صراحتا آپگریدش کنی و تغییرش بدی، و چون این فایل برای تکرارپذیری خیلی مهمه معمولا کنار بقیه کد پروژه توی گیت هم کامیت میشه.

آپدیت کردن یه کریت به ورژن جدید

حالا وقتی که واقعا خودت بخوای یه کریت رو آپدیت کنی، کارگو دستور update رو داره.

این دستور فایل Cargo.lock رو نادیده میگیره و آخرین ورژن هایی که با شرط های Cargo.toml جور در میان رو پیدا میکنه بعد اونارو مینویسه توی Cargo.lock.

البته کارگو به صورت پیشفرض فقط دنبال ورژن های بالاتر از 0.10.1 و پایین تر از 0.11.0 میگرده.

شما الان فرض کن کریت rand دو تا ورژن جدید 0.10.2 و 0.999.0 منتشر کرده باشه، اگه cargo update بزنی همچین چیزی میبینی:

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.96.0 compatible version
    Updating rand v0.10.1 -> v0.10.2 (available: v0.999.0)

میبینی که کارگو ورژن 0.999.0 رو نادیده گرفت. الان اگه فایل Cargo.lock رو نگاه کنی، میبینی که ورژن rand شده 0.10.2.

اگه بخوای از rand ورژن 0.999.0 یا هر ورژنی از سری 0.999 استفاده کنی، باید Cargo.toml رو اینجوری تغییر بدی (حالا الان واقعا این کارو نکن، چون مثال های بعدی فرض رو بر این گرفتن که تو داری از rand نسخه 0.10 استفاده میکنی):

[dependencies]
rand = "0.999.0"

دفعه بعد که cargo build بزنی، کارگو registry رو آپدیت میکنه و شرط های rand تو رو بر اساس ورژن جدیدی که مشخص کردی دوباره بررسی میکنه.

راجع به کارگو و اکوسیستمش خیلی حرف ها هست که از حوصله این مقاله خارجه.

کلا الان یاد گرفتی که کارگو میاد استفاده دوباره از لایبرری هارو خیلی راحت میکنه و برای همینم دولوپر ها میتونن پروژه های کوچیک تری بنویسن که از کنار هم گذاشتن چندتا پکیج ساخته شدن.

تولید کردن یه عدد رندوم

بعد اینهمه حرف، بیا بریم از rand استفاده کنیم تا یه عدد برای حدس زدن بسازیم.

قدم بعدی آپدیت کردن src/main.rs هست، مثل لیستینگ ۳:

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

use std::io;

use rand::prelude::*;

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

    let secret_number = rand::rng().random_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}");
}

لیستینگ ۳: اضافه کردن کد برای تولید یه عدد رندوم

اول از همه، خط use rand::prelude::*; رو اضافه کردیم. ماژول prelude پرکاربردترین بخش های کریت rand رو داخل خودش داره و use هم اونارو میاره توی اسکوپ برنامه‌ ما.

بعدش اومدیم دو تا خط وسط کد اضافه کردیم.

توی خط اول، rand::rng() یه random number generator بهمون میده.

این همون چیزیه که میتونه عدد تصادفی تولید کنه و ما با random_range بهش میگیم که یه عدد تصادفی داخل یه بازه مشخص تولید کنه. اینجا 1..=100 یعنی عددی از ۱ تا ۱۰۰، شامل خود ۱ و ۱۰۰.

لازم نیست از همون اول بدونی هر crate چه function ها و method هایی داره و معمولا مستندات crate بهت میگه چطور باید ازش استفاده کنی اصلا شما با cargo doc --open می‌تونی مستندات dependency های پروژه رو بسازی و توی مرورگر ببینی.

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

حالا چند بار برنامه رو اجرا کن:

$ 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

باید هر بار عدد های رندوم متفاوتی بگیری و همشون هم باید بین ۱ تا ۱۰۰ باشن.

اگه وارنینگ گرفتی فعلا بیخیالش شو مشکلی نیست. ولی اگه ارور گرفتی، چک کن که حتما rand = "0.10.1" توی Cargo.toml باشه، چون ورژن های بعدی rand ممکنه API متفاوتی داشته باشن.

هر ورژنی از سری 0.10 با کد های این مقاله کار میکنه.

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

حالا که هم ورودی کاربر رو داریم و هم عدد رندوم، میتونیم بشینیم مقایسشون کنیم.

این قدم توی لیستینگ ۴ اومده. فقط حواست باشه که این کد فعلا کامپایل نمیشه و دلیلش رو هم الان توضیح میدم.

این کد کامپایل نمیشه!

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

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

use rand::prelude::*;

fn main() {
    // --snip--

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

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

لیستینگ ۴: هندل کردن حالت های ممکن موقع مقایسه دو تا عدد

اول میایم Ordering رو از استاندارد لایبرری وارد میکنیم. این Ordering یه enum هست که نتیجه‌ی مقایسه‌ دو مقدار رو مشخص میکنه: Less یعنی اولی کوچیک‌ تره، Greater یعنی بزرگ‌ تره و Equal هم که یعنی برابرن.

بعد با متد cmp دو مقدار رو با هم مقایسه می‌کنیم. اینجا guess رو با secret_number مقایسه می‌کنیم و cmp یکی از همین سه حالت (Less، Greater یا Equal) رو برمیگردونه.

بعدش از یه اکسپرشن match استفاده میکنیم تا بر اساس اینکه cmp کدوم ورینت رو برگردونده، تصمیم بگیریم قدم بعدی چیه.

شما این match رو می‌تونی خیلی ساده مثل چندتا if/else پشت سر هم در نظر بگیری.

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

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

ولی خب همونطور که گفتم، کد لیستینگ ۴ فعلا کامپایل نمیشه. بیا امتحانش کنیم:

$ cargo build
   Compiling libc v0.2.186
   Compiling rand_core v0.10.1
   Compiling getrandom v0.4.3
   Compiling cfg-if v1.0.0
   Compiling chacha20 v0.10.1
   Compiling rand v0.10.1
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/core/src/cmp.rs:1000:7

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

دلیل این ارور خیلی ساده‌ست: داریم یه String رو با یه عدد مقایسه می‌کنیم، در حالی که این دو تا type متفاوت دارن.

وقتی مینویسیم:

let mut guess = String::new();

اینجا Rust از روی کدی که نوشتیم می‌فهمه guess از نوع String هست. از طرف دیگه secret_number یه عدد هست. Rust به صورت پیش‌فرض برای این عدد i32 رو انتخاب می‌کنه. بنابراین وقتی مینویسیم:

guess.cmp(&secret_number)

راست داره میگه که من نمی‌تونم String و i32 رو با هم مقایسه کنم.

اینجا باید guess رو از String به یک نوع عددی تبدیل کنیم تا بتونیم اون رو با secret_number مقایسه کنیم.

به این حالت میگن mismatched types

در نهایت ما باید اون String که به عنوان input خوندیم رو به یه تایپ عددی تبدیل کنیم تا بتونیم عددی با عدد مخفی مقایسش کنیم.

برای این کار این خط رو به بدنه‌ی main اضافه میکنیم:

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

    // --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 اجازه می‌ده متغیر قبلی رو shadow کنیم!

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

اینجا هم این حرکت رو زدیم چون می‌خوایم guess رو از String به یک عدد تبدیل کنیم.

اول trim() میاد فاصله‌ های اضافی اول و آخر رشته رو حذف می‌کنه، بعد parse() تلاش می‌کنه اون رشته رو به یک نوع عددی مثل u32 تبدیل کنه. پس در نهایت guess جدید دیگه String نیست و میتونه با secret_number مقایسه بشه.

وقتی کاربر 5 رو تایپ می‌کنه و Enter می‌زنه، read_line کاراکتر newline رو هم داخل guess قرار میده، یعنی مقدارش چیزی شبیه 5\n می‌شه. trim() این کاراکتر اضافی رو حذف میکنه و فقط 5 باقی میمونه.

بعد parse() رشته رو به یک نوع دیگه تبدیل می‌کنه. اینجا ما میخوایم 5 رو به عدد تبدیل کنیم و با let guess: u32 به Rust میگیم که نوع guess باید u32 باشه. u32 هم یعنی یک عدد صحیح بدون علامت (صفر یا مثبت) که در ۳۲ بیت ذخیره می‌شه.

بقیه تایپ های عددی رو هم میتونی اینجا ببینی.

چون اینجا نوع guess رو u32 مشخص کردیم، Rust میفهمه secret_number هم باید u32 باشه تا بتونیم این دوتا رو با هم مقایسه کنیم.

از طرف دیگه، parse ممکنه نتونه یه رشته رو به عدد تبدیل کنه و مثلا A👍% هیچ‌جوره عدد نیست. برای همین parse میاد یه Result برمیگردونه. اگر موفق بشه، Ok برمیگردونه و expect عدد رو از داخلش درمیاره و اگر موفق نشه، Err برمیگردونه و expect برنامه رو متوقف میکنه و پیام خطایی که بهش دادیم رو نمایش میده.

حالا بیا برنامه رو اجرا کنیم ببینیم چطوریاس:

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

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

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

الان تقریبا بیشتر قسمتای بازی کار میکنه ولی کاربر فقط یه بار میتونه حدس بزنه.

اینجاس که باید لوپ بزنیم!

حدس زدن با لوپ

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

loop {
    println!("Hello!");
}

این کد Hello! رو تا ابد چاپ می‌کنه.

حالا توی بازی، ما اونجایی که از کاربر میخوایم حدس بزنه رو داخل یه loop می‌ذاریم:

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

    // --snip--

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

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

        // --snip--

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

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

فعلا کاربر می‌تونه با Ctrl+C برنامه رو ببنده.

البته اگر کاربر یه چیزی غیر از یه عدد به عنوان input وارد کنه، برنامه دیگه نمیتونه اون رو 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' (6694925) panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

تایپ کردن quit باعث میشه از بازی خارج بشی، ولی خب همونطور که دیدی هر ورودی غیر عددی دیگه ای هم همین کارو میکنه.

حالا این خیلی راه حل خوبی نیست. ضمن اینکه ما میخوایم بازی وقتی عدد درست حدس زده شد هم تموم بشه.

حدس درست و خروج از بازی

بیا کاری کنیم که وقتی کاربر برنده شد بازی تموم بشه، برای این کار یه دستور break اضافه میکنیم:

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

        // --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 تبدیل میکنه رو عوض میکنیم، مثل لیستینگ ۵:

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

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

لیستینگ ۵: نادیده گرفتن حدس غیر عددی و درخواست یه حدس جدید به جای کرش کردن برنامه

ما expect رو با یه اکسپرشن match عوض کردیم تا از کرش موقع ارور به هندل کردن ارور برسیم.

یادت باشه که parse یه Result برمیگردونه و Result هم یه enum هست که ورینت های Ok و Err داره.

اینجا هم دقیقا مثل کاری که با نتیجه Ordering از متد cmp کردیم، از match استفاده میکنیم.

اگه parse بتونه رشته رو با موفقیت به عدد تبدیل کنه، یه مقدار Ok برمیگردونه که عدد نهایی داخلشه.

اینجا match می‌بینه مقدار Ok با الگوی بازوی اول جور درمیاد، پس همون عدد رو از داخل Ok درمیاره و داخل متغیر guess قرار می‌ده.

ولی اگه parse نتونه رشته رو به عدد تبدیل کنه، یه مقدار Err برمیگردونه که اطلاعات بیشتری راجع به ارور داخلشه.

این مقدار Err با الگوی Ok(num) توی بازوی اول جور در نمیاد، ولی با الگوی Err(_) توی بازوی دوم جور در میاد.

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

پس برنامه کد بازوی دوم یعنی continue رو اجرا میکنه، که به برنامه میگه برو سراغ لوپ بعدی و دوباره یه حدس جدید از کاربر بگیر. در عمل، برنامه هر خطایی که 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!

عالی! فقط یه تغییر ریز آخر مونده تا بازی guessing game تموم بشه.

یادت باشه که برنامه هنوز داره عدد مخفی رو چاپ میکنه و این موقع تست کردن به دردمون خورد ولی الان کل بازی رو خراب میکنه. پس بیا اون println! که عدد مخفی رو چاپ میکنه پاک کنیم. لیستینگ ۶ کد نهایی رو نشون میده:

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

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

use rand::prelude::*;

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

    let secret_number = rand::rng().random_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;
            }
        }
    }
}

لیستینگ ۶: کد کامل بازی حدس عدد

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

جمع بندی

این پروژه یه راه عملی بود که بتونم کلی مفهوم جدید Rust رو بهت معرفی کنم: let، match، فانکشن ها، استفاده از کریت های اکسترنال و یه سری چیز دیگه.

اگه میخوای همه اینارو با جزئیات بیشتر یاد بگیری، اینا قدم های بعدی هستن:

  • مفاهیم پایه: چیزایی که تقریبا توی همه زبان های برنامه‌نویسی هست مثل متغیر ها، دیتا تایپ ها و فانکشن ها، و اینکه توی Rust چطوری ازشون استفاده میکنیم.
  • مالکیت یا همون ownership: تقریبا فیچریه که Rust رو از بقیه زبان ها متمایز میکنه.
  • استراکت ها (structs) و سینتکس متد ها.
  • enum ها و اینکه چطوری کار میکنن.

تا بعد، بوس بوس!