Програмування гри - відгадайки

Розпочнемо вивчення Rust зі спільної розробки проєкту! Цей розділ ознайомить вас із кількома поширеними концепціями Rust, демонструючи як вони використовуються у реальній програмі. Ви дізнаєтеся про let, match, методи, асоційовані функції, використання зовнішніх крейтів і навіть більше! Наступні розділи розкриють ці концепції детальніше. У цьому розділі ви займатиметеся основами.

Ми розв'язуватимемо класичну задачу для програмістів-початківців: гру "відгадай число". Умови такі: програма генерує випадкове ціле число між 1 та 100. Потім пропонує гравцю ввести спробу відгадати. Після введення спроби вона скаже, чи число більше або менше за загадане. Якщо відгадано правильно, гра виведе привітання і припинить роботу.

Початок нового проєкту

Щоб розпочати новий проєкт, перейдіть до теки projects, яку ви створили у Розділі 1, і створіть новий проєкт за допомогою 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 = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

Як ви вже бачили у Розділі 1, cargo new створює програму "Hello, world!". Подивімося, що міститься у файлі src/main.rs:

Файл: 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 [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Hello, world!

Команда run стає в пригоді, коли треба швидко розвивати проєкт, і ця гра є якраз таким проєктом: ми хочемо швидко тестувати кожну ітерацію перед тим, як переходити до наступної.

Відкрийте файл src/main.rs. Увесь код ми писатимемо у цьому файлі.

Обробляємо здогадку

Перша частина програми буде просити у користувача ввести здогадку, обробляти те, що він увів, і перевіряти, чи ввів він дані у потрібній формі. Для початку, дозволимо користувачеві ввести здогадку. Введіть код з Блоку коду 2-1 до 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}");
}

Блок коду 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 має набір елементів, визначених у стандартній бібліотеці, що їх він вводить до області видимості будь-якої програми. Цей набір зветься прелюдією, і ви можете побачити все, що в ній міститься, в документації стандартної бібліотеки.

Якщо типу, який ви хочете використати, нема у прелюдії, вам доведеться явно вносити цей тип у область видимості за допомогою інструкції 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; // немутабельна
let mut bananas = 5; // мутабельна

Примітка: синтаксична конструкція // починає коментар, що продовжується до кінця рядка. Rust ігнорує весь вміст коментаря. Про коментарі детальніше йдеться в Розділі 3.

Повернімося до нашої ігрової програми - відгадайки. Тепер ви знаєте, що let mut guess створить мутабельну змінну на ім'я guess. Знак рівності (=) каже Rust, що тепер ми хочемо зв'язати щось зі змінною. З правого боку знаку рівності знаходиться значення, з яким зв'язується guess, а саме результат виклику String::new, функції, що повертає новий екземпляр стрічки String. String String - це тип стрічки, що надається стандартною бібліотекою; це кодовані в UTF-8 шматки тексту, які можна нарощувати.

Синаксична конструкція :: в рядку ::new`позначає, що new - це асоційована функція типу String. Асоційована функція є реалізованою для типу, в цьому випадку String. Ця функція new створює нову, порожню String. Функція new зустрінеться вам у багатьох типах, оскільки це звичайна назва функції, що створює нове значення певного виду.

В цілому: рядок let mut guess = String::new(); створив мутабельну змінну, що зараз зв'язана з новим, порожнім екземпляром String. Хух!

Отримання введення від користувача

Згадаймо, що ми додали функціональність введення/виведення зі стандартної бібліотеки за допомогою use std::io; у першому рядку програми. Тепер викличмо функцію stdin з модуля io, що дозволить обробляти те, що вводить користувач:

use std::io;

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

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

    let mut guess = String::new();

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

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

Якби ми не імпортували бібліотеку io за допомогою use std::io на початку програми, ми могли б використати цю функцію, написавши цей виклик як std::io::stdin. Функциія stdin повертає екземпляр std::io::Stdin; цей тип являє собою дескриптор (handle) стандартного потоку введення термінала.

Далі рядок .read_line(&mut guess) викликає метод read_line дескриптора стандартного введення, щоб отримати, що ввів користувач. Ми також передаємо

&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 Тип Result - це

перелік (enumeration), який часто звуть просто енум, і цей тип може перебувати в одному з кількох можливих станів. Кожен такий стан зветься варіантом.

Розділ 6 розповість про енуми детальніше. Призначення типів Result - представлення інформації для обробки помилок.

Result має варіанти Ok та Err. Варіант Ok показує, що операція була вдалою, і всередині варіанту 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: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished dev [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 [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 надає крейт rand з таким функціоналом.

Генерація випадкового числа

Пам'ятайте, що крейт є набором файлів вихідного коду Rust. Проєкт, що ми збираємо - це двійковий крейт, який є виконуваним. Крейт rand - це бібліотечний крейт, і він містить код, призначений для використання в інших програмах, та не може бути запущеним самостійно.

Використання зовнішніх крейтів - найсильніший бік Cargo. Перед тим, як писати код, що використовує rand, ми маємо змінити файл Cargo.toml, додавши туди крейт rand як залежність. Відкрийте цей файл і додайте такий рядок унизу, під заголовком секції [dependencies], яку для вас створив Cargo. Переконайтеся, що зазначили rand точно так, як тут, із цим номером версії, інакше приклади коду з цього розділу можуть не запрацювати:

Файл: Cargo.toml

[dependencies]
rand = "0.8.3"

У файлі Cargo.toml все, що йде після заголовку секції, належить до цієї секції - до початку нової секції. У секції [dependencies] ви повідомляєте Cargo, від яких зовнішніх крейтів залежить ваш проєкт і які версії цих крейтів вам потрібні. У цьому випадку, ми зазначаємо крейт rand із семантичним версіюванням 0.8.5. Cargo розуміє Семантичне версіювання (яке іноді звуть SemVer), що є стандартом для запису номерів версій. Запис 0.8.5 насправді є скороченням для ^0.8.5, що означає будь-яку версію, не меншу за 0.8.5, але меншу за 0.9.0.

Cargo вважає ці версії з сумісними з публічним API з версією 0.8.5, і ця специфікація гарантує, що ви отримаєте останній патч реліз, який все ще буде скомпільований з кодом у цьому розділі. У версій 0.9.0 і більших не гарантується збереження API, яке використовується подальшими прикладами.

Тепер, не змінюючи коду, побудуємо проєкт, як показано в Блоці коду 2-2.

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
  Downloaded libc v0.2.127
  Downloaded getrandom v0.2.7
  Downloaded cfg-if v1.0.0
  Downloaded ppv-lite86 v0.2.16
  Downloaded rand_chacha v0.3.1
  Downloaded rand_core v0.6.3
   Compiling libc v0.2.127
   Compiling getrandom v0.2.7
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.16
   Compiling rand_core v0.6.3
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s

Блок коду 2-2: Вивід команди cargo build після додавання крейту rand як залежності

Ви можете бачити інші номери версій (але всі вони будуть сумісні з кодом, завдяки SemVer!) і різні рядки (залежно від операційної системи), і рядки можуть бути в іншому порядку.

Тепер, коли ми маємо зовнішню залежність, Cargo витягає останні версії всього, що нам треба, з реєстру, тобто копії даних з Crates.io. На crates.io в екосистемі Rust люди викладають свої проєкти Rust з відкритим кодом, щоб ними могли скористатися інші.

Після оновлення реєстру Cargo перевіряє секцію [dependencies] і завантажує крейти, вказані там, але яких у вас бракує. В цьому випадку, хоча ми вказали тільки залежність від rand, Cargo також завантажив інші крейти, від яких залежить робота rand. Після завантаження крейтів Rust їх компілює, а потім компілює проєкт із доступними залежностями.

Якщо ви знову запустите cargo build, не зробивши жодних змін, ви не отримаєте жодної відповіді окрім рядка Finished. Cargo знає, що він вже завантажив і скомпілював залежності, а ви не змінили нічого, що б їх стосувалося, у файлі Cargo.toml. Cargo також знає, що ви не змінили нічого у коді, тому він не буде його перекомпільовувати. Оскільки роботи у Cargo немає, він просто завершується.

Якщо ви відкриєте файл src/main.rs, зробите тривіальну зміну, збережете і знову зберете, то побачите тільки два рядки виводу:

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

Ці рядки показують, що Cargo оновив збірку тільки вашою дрібною правкою до файлу src/main.rs. Залежності не змінилися, і Cargo знає, що може заново використати те, що він вже завантажив і скомпілював.

Файл Cargo.lock гарантує відтворюваність збірки

Cargo має механізм, що гарантує однаковість збірки того самого артефакту кожного разу, коли ви чи хтось інший збирає ваш код: Cargo використає тільки ті версії залежностей, які ви зазначили, доки ви не вкажете інші. Наприклад, якщо наступного тижня вийде rand версії 0.8.6, що міститиме важливе виправлення помилки, але також міститиме регресію, що зламає ваш код. Щоб упоратися з цим, при першому запуску cargo build Rust створює файл Cargo.lock, що відтепер розміщується у теці guessing_game.

Коли ви збираєте проєкт вперше, Cargo визначає всі версії залежностей, що відповідають критерію, і записує їх до файлу Cargo.lock. Коли ви пізніше збиратимете проєкт, Cargo побачить, що файл Cargo.lock існує, і використає версії, зазначені там, а не буде наново робити всю роботу з визначення версій. Це дозволяє автоматично робити відтворювану збірку. Іншими словами, ваш проєкт залишиться на версії 0.8.5, доки ви самі не захочете оновити її, завдяки файлу Cargo.lock. Оскільки файл Cargo.lock важливий для відтворюваної збірки, він часто додається до контролю початкового коду разом із рештою коду в проєкті.

Оновлення крейта для отримання нової версії

Коли ж ви хочете оновити крейт, Cargo надає іншу команду, update, яка ігнорує файл Cargo.lock і визначає всі останні версії, що відповідають специфікаціям у Cargo.toml. Cargo запише ці версії до файлу Cargo.lock. Але за замовчанням Cargo шукатиме тільки версії, більші за 0.8.5 і менші 0.9.0. Якщо крейт 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 - версія крейта rand, яку ви використовуєте, тепер 0.8.6. Якщо вам потрібен rand версії 0.9.0 чи будь-якої версії у гілці 0.9.x, вам доведеться оновити файл Cargo.toml, щоб він мав такий вигляд:

[dependencies]
rand = "0.9.0"

Наступного разу, коли ви запустите cargo build, Cargo оновить реєстр доступних крейтів і заново перечитає вимоги до rand відповідно до вказаної вами нової версії.

Можна ще багато розповісти про Cargo і його екосистему, яка обговорюється у Розділі 14, але поки що цього знати достатньо. Cargo робить використання бібліотек дуже простим, що дозволяє растацеанцям писати менші проєкти, зібрані з кількох пакетів.

Генерація випадкового числа

Використаймо rand для генерації числа, що треба відгадати. Наступний крок - оновити src/main.rs, як показано в Блоці коду 2-3.

Файл: 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: Adding code to generate a random number

Спершу ми додаємо рядок use rand::Rng. Трейт Rng визначає методи, які реалізує генератор випадкових чисел, і цей трейт має бути в області видимості, щоб ми могли скористатися цими методами. Розділ 10 розповість про трейти детальніше.

Далі ми додаємо всередині ще два рядки. У першому рядку ми викликаємо функцію rand::thread_rng, що дає нам генератор випадкових чисел, яким ми користуватимемся: він прив'язаний до потоку виконання, а його початкове значення задане операційною системою. Потім ми викликаємо метод генератора випадкових чисел gen_range. Цей метод визначається трейтом Rng, який ми внесли до області видимості інструкцією use range::Rng. Метод gen_range приймає параметрами два числа і генерує випадкове число в діапазоні між ними. Вираз для діапазону, що ми його тут застосували, має форму початок..=кінець і включає нижню і верхню межі, тому треба вказувати 1..=100, щоб отримати число між 1 та 100.

Примітка: Ви, звісно, не можете одразу знати, які трейти використати і які методи та функції викликати з крейта, тому кожен крейт має документацію з інструкцією до використання. Ще одна корисна можливість Cargo полягає в тому, що команда cargo doc --open збере на вашому комп'ютері документацію, надану всіма залежностями, і відкриє її у вашому переглядачі. Якщо вам цікавий інший функціонал, скажімо, крейту rand, запустіть cargo doc --open і клацніть rand на боковій панелі ліворуч.

Другий рядок, який ми додали до коду, виводить таємне число. Це корисно, поки ми розробляємо програму, щоб можна було перевірити її роботу, але ми видалимо його у фінальній версії. Буде не дуже цікаво, якщо програма виводитиме відповідь одразу по запуску!

Спробуємо запустити програму кілька разів:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s
     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 [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. Зверніть увагу, що цей код ще не компілюється, як ми зараз пояснимо.

Файл: 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!"),
    }
}

Блок коду 2-4: Різні дії в залежності від порівняння двох чисел

Спершу ми додали ще одну інструкцію use, яка вводить тип std::cmp::Ordering зі стандартної бібліотеки в область видимості. Тип Ordering ("впорядкування") - це ще один енум, що має варіанти: Less ("менше"), Greater ("більше"), and Equal ("дорівнює"). Це три можливі результати порівняння двох значень.

Потім ми додали в кінець коду п'ять нових рядків, в яких використали тип Ordering. Метод cmp порівнює два значення і може бути викликаний для всього, що можна порівнювати. Він приймає параметром посилання на те, що ви хочете порівнювати; тут він порівнює guess із secret_number. Потім він повертає варіант енуму Ordering, який ми внесли у область видимості за допомогою інструкції use. Ми скористалися виразом match , щоб визначити, що робити далі залежно від варіанту Ordering, що його повернув виклик cmp зі значеннями guess та secret_number.

Вираз match складається з рукавів. Рукав складається зі шаблона (<0>pattern</0>) для порівняння та коду, який буде виконано, якщо значення, передане виразу match, відповідає шаблону цього рукава. Rust бере значення, передане match, і по черзі перевіряє шаблони рукавів. Шаблони та конструкція match - потужні засоби Rust, які дозволяють вам виражати різноманітні ситуації, які можуть трапитися вам при програмуванні, і допомагають переконатися, що ви обробили їх усіх. Детально ці можливості будуть розглянуті в Розділах 6 і 18, відповідно.

Розберімо крок за кроком цей приклад з виразом 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.3
   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 struct `String`, found integer
   |
   = note: expected reference `&String`
              found reference `&{integer}`

error[E0283]: type annotations needed for `{integer}`
   --> src/main.rs:8:44
    |
8   |     let secret_number = rand::thread_rng().gen_range(1..=100);
    |         -------------                      ^^^^^^^^^ cannot infer type for type `{integer}`
    |         |
    |         consider giving `secret_number` a type
    |
    = note: multiple `impl`s satisfying `{integer}: SampleUniform` found in the `rand` crate:
            - impl SampleUniform for i128;
            - impl SampleUniform for i16;
            - impl SampleUniform for i32;
            - impl SampleUniform for i64;
            and 8 more
note: required by a bound in `gen_range`
   --> /Users/carolnichols/.cargo/registry/src/github.com-1ecc6299db9ec823/rand-0.8.3/src/rng.rs:129:12
    |
129 |         T: SampleUniform,
    |            ^^^^^^^^^^^^^ required by this bound in `gen_range`
help: consider specifying the type arguments in the function call
    |
8   |     let secret_number = rand::thread_rng().gen_range::<T, R>(1..=100);
    |                                                     ++++++++

Some errors have detailed explanations: E0283, E0308.
For more information about an error, try `rustc --explain E0283`.
error: could not compile `guessing_game` due to 2 previous errors

Суть цієї помилки в тому, що тут є невідповідні типи. 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:

Файл: 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Розділ 3 детальніше розповідає про затінення, а поки що знайте, що ця особливість часто використовується, коли нам треба перетворити значення з одного типу в інший.

Ми зв'язали нову змінну з виразом guess.trim().parse(). guess у цьому виразі стосується першої змінної guess, у якій міститься стрічка, введена користувачем. Метод trim, застосований до екземпляра String, видалить всі пробільні символи на початку і в кінці, що треба зробити, аби порівняти стрічку з u32, який містить виключно числові дані. Користувач має натиснути на enter, щоб спрацював метод read_line і данні були введені, але це додає символ нового рядка до стрічки. Наприклад, якщо користувач набере 5 і натисне enter, guess буде виглядати як 5\n. \n позначає символ нового рядка. (У Windows натискання enter створює символи повернення каретки та нового рядка, \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 поверне варіант Result Err, бо він не зміг створити число зі стрічки, виклик expect аварійно припинить гру і виведе повідомлення, яке ми йому надали. Якщо parse вдало створив число зі стрічки, він поверне варіант Result Ok, а expect поверне потрібне нам число зі значення Ok.

А тепер запустімо програму:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     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 створює нескінчений цикл. Ми додамо цикл, щоб дати користувачам більше можливостей відгадати число:

Файл: 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!"),
        }
    }
}

Як ви можете бачити, ми перенесли в цикл усе від запрошення ввести здогадку і до кінця. Обов'язково додайте в ці рядки відступи у чотири пробілами та знову запустіть програму. Програма запрошує ввести нову здогадку до нескінченості, що, власне, є новою проблемою. Не схоже, що користувач може вийти!

Користувач завжди може перервати програму, натиснувши клавіатурне скорочення ctrl-c. Але є інший спосіб втекти від цього ненажерного чудовиська - згаданий при обговоренні parse у підрозділі "Порівняння здогадки з таємним числом”: якщо користувач введе щось, крім числа, програма аварійно завершиться. Ми можемо скористатися з цього, щоб користувач зумів вийти з програми, як показано тут:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     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:

Файл: 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.

Файл: 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;
            }
        }
    }
}

Блок коду 2-5: ігнорування введеного не-числа і запит ввести іншу відгадку замість аварійного завершення програми

Ми замінили виклик expect на вираз match, щоб перейти від аварійного завершення програми до обробки помилки. Згадаймо, що метод parse повертає тип Result, а Result - це енум, що має варіанти 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 [unoptimized + debuginfo] target(s) in 4.45s
     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 показує остаточний код.

Файл: 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;
            }
        }
    }
}

Блок коду 2-6: Повний код гри "відгадай число!"

Отже, ви зуміли вдало зібрати гру "відгадай число". Вітаємо!

Підсумок

Цей проєкт був вступом до багатьох концепцій мови Rust через практику: let, match, функції, використання зовнішніх крейтів та інших. У кількох наступних розділах ми детальніше розберемо ці концепції. Розділ 3 розповідає про концепції, які є у більшості мов програмування, такі як змінні, типи даних, функції і показує, як ними користуватися в Rust. Розділ 4 досліджує володіння, концепцію мови Rust, що є найбільш відмінною від інших мов. Розділ 5 обговорює синтаксис структур і методів, а Розділ 6 детально розкриває, як працюють енуми.