Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Програмування гри у вгадування (Programming a Guessing Game)

Давайте зануримося в Rust, разом виконавши практичний проєкт! Цей розділ знайомить вас із кількома поширеними концепціями Rust, показуючи, як використовувати їх у реальній програмі. Ви дізнаєтеся про let, match, методи (methods), асоційовані функції (associated functions), зовнішні крейти (external crates) та багато іншого! У наступних розділах ми розглянемо ці ідеї детальніше. У цьому розділі ви просто потренуєте основи.

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

Налаштування нового проєкту (Setting Up a New Project)

Щоб налаштувати новий проєкт, перейдіть до каталогу projects, який ви створили в Розділі 1, і створіть новий проєкт за допомогою Cargo, ось так:

$ cargo new guessing_game
$ cd guessing_game

Перша команда, cargo new, бере назву проєкту (guessing_game) як перший аргумент. Друга команда змінює каталог на каталог нового проєкту.

Подивіться на згенерований файл Cargo.toml:

Filename: Cargo.toml

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

[dependencies]

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

Filename: src/main.rs

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

Тепер давайте скомпілюємо цю програму “Hello, world!” і запустимо її на тому ж кроці, використовуючи команду cargo run:

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

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

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

Обробка припущення (Processing a Guess)

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

Цей код друкує підказку, яка повідомляє, що це за гра, і запитує введення від користувача.

Зберігання значень у змінних (Storing Values with Variables)

Далі ми створимо змінну для зберігання введення користувача, ось так:

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 змінні за замовчуванням є незмінними (immutable), тобто після того, як ми надаємо змінній значення, це значення не змінюватиметься. Ми детально обговоримо цю концепцію в розділі “Змінні та змінність (Variables and Mutability)” Розділу 3. Щоб зробити змінну змінною, ми додаємо mut перед іменем змінної:

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

Примітка: Синтаксис // починає коментар, який триває до кінця рядка. 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. Ух!

Отримання введення від користувача (Receiving User Input)

Згадайте, що ми включили функціональність введення/виведення зі стандартної бібліотеки за допомогою 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) викликає метод read_line на дескрипторі стандартного введення, щоб отримати введення від користувача. Ми також передаємо &mut guess як аргумент до read_line, щоб сказати йому, у який рядок зберігати введення користувача. Повне завдання read_line — взяти все, що користувач вводить у стандартне введення, і додати це до рядка (не перезаписуючи його вміст), тож ми відповідно передаємо цей рядок як аргумент. Аргумент рядка має бути змінним, щоб метод міг змінити вміст рядка.

Символ & вказує, що цей аргумент є посиланням, яке дає вам спосіб дозволити кільком частинам вашого коду отримувати доступ до одного фрагмента даних без потрібності копіювати ці дані в пам’ять багато разів. Посилання — це складна можливість, і однією з головних переваг Rust є те, наскільки безпечно та легко використовувати посилання. Вам не потрібно знати багато з цих деталей, щоб завершити цю програму. Наразі все, що вам потрібно знати, це те, що, як і змінні, посилання є незмінними за замовчуванням. Отже, вам потрібно писати &mut guess замість &guess, щоб зробити його змінним. (Розділ 4 пояснить посилання детальніше.)

Обробка можливої помилки за допомогою Result (Handling Potential Failure with the Result Type)

Ми все ще працюємо над цим рядком коду. Тепер ми обговорюємо третій рядок тексту, але зверніть увагу, що він усе ще є частиною одного логічного рядка коду. Наступна частина — це цей метод:

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 — це перелік (enum (скорочено від enumeration)), який часто називають переліком, і це тип, який може перебувати в одному з кількох можливих станів. Кожен можливий стан ми називаємо варіантом.

Розділ 6 детальніше розгляне enum. Призначення цих типів Result — кодувати інформацію про обробку помилок.

Варіанти Result — це Ok і Err. Варіант Ok вказує, що операція була успішною, і містить успішно згенероване значення. Варіант Err означає, що операція не вдалася, і містить інформацію про те, як або чому операція не вдалася.

Значення типу Result, як і значення будь-якого типу, мають визначені для них методи. Екземпляр Result має expect method який ви можете викликати. Якщо цей екземпляр 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.

Тестування першої частини (Testing the First Part)

Давайте протестуємо першу частину гри у вгадування. Запустіть її за допомогою 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

На цьому етапі перша частина гри завершена: ми отримуємо введення з клавіатури, а потім друкуємо його.

Генерування секретного числа (Generating a Secret Number)

Далі нам потрібно згенерувати секретне число, яке користувач намагатиметься відгадати. Секретне число має бути різним щоразу, щоб гра була цікавою більше ніж один раз. Ми використаємо випадкове число від 1 до 100, щоб гра не була надто складною. Rust ще не включає функціональність випадкових чисел у свою стандартну бібліотеку. Однак команда Rust надає rand crate із такою функціональністю.

Розширення функціональності за допомогою крейта (Using a Crate to Get More Functionality)

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

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

Filename: Cargo.toml

[dependencies]
rand = "0.8.5"

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

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

Тепер, не змінюючи жодного рядка коду, давайте зберемо проєкт, як показано в Лістингу (Listing) 2-2.

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s

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

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

Після оновлення registry Cargo перевіряє розділ [dependencies] і завантажує будь-які перелічені крейти, які ще не завантажено. У цьому випадку, хоча ми перелічили лише rand як залежність, Cargo також підтягнув інші крейти, від яких rand залежить для роботи. Після завантаження крейтів 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 знає, що може повторно використати те, що вже завантажив і скомпілював для них.

Забезпечення відтворюваних збірок (Ensuring Reproducible Builds with the Cargo.lock File)

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

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

Оновлення крейта, щоб отримати нову версію (Updating a Crate to Get a New Version)

Коли ви дійсно хочете оновити крейт, Cargo надає команду update, яка ігноруватиме файл Cargo.lock і визначатиме всі останні версії, що відповідають вашим специфікаціям у Cargo.toml. Потім Cargo запише ці версії у файл Cargo.lock. В іншому випадку, за замовчуванням, Cargo шукатиме лише версії більші за 0.8.5 і менші за 0.9.0. Якщо крейт rand випустив дві нові версії 0.8.6 і 0.999.0, ви побачили б таке, якби запустили cargo update:

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

Cargo ігнорує реліз 0.999.0. На цьому етапі ви також помітили б зміну у вашому файлі Cargo.lock, що вказує, що версія крейта rand, яку ви тепер використовуєте, — 0.8.6. Щоб використовувати версію rand 0.999.0 або будь-яку версію в серії 0.999.x, вам потрібно було б оновити файл Cargo.toml так, щоб він виглядав ось так (не робіть цього насправді, тому що наведені нижче приклади припускають, що ви використовуєте rand 0.8):

[dependencies]
rand = "0.999.0"

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

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

Генерування випадкового числа (Generating a Random Number)

Давайте почнемо використовувати rand, щоб згенерувати число для вгадування. Наступний крок — оновити src/main.rs, як показано в Лістингу (Listing) 2-3.

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

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

Далі ми додаємо два рядки посередині. У першому рядку ми викликаємо функцію rand::thread_rng, яка дає нам конкретний генератор випадкових чисел, який ми використовуватимемо: той, що локальний для поточного потоку виконання і ініціалізується операційною системою. Потім ми викликаємо метод gen_range на генераторі випадкових чисел. Цей метод визначений трейтом Rng, який ми внесли в область видимості за допомогою оператора use rand::Rng;. Метод gen_range бере вираз діапазону як аргумент і генерує випадкове число в цьому діапазоні. Тип виразу діапазону, який ми використовуємо тут, має форму start..=end і є включним як для нижньої, так і для верхньої межі, тож нам потрібно вказати 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` 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. Чудова робота!

Порівняння припущення із секретним числом (Comparing the Guess to the Secret Number)

Тепер, коли у нас є введення користувача і випадкове число, ми можемо їх порівняти. Цей крок показано в Лістингу (Listing) 2-4. Зверніть увагу, що цей код ще не скомпілюється, як ми зараз пояснимо.

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

use rand::Rng;

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

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

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

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

    let mut guess = String::new();

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

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

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

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

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

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

Однак код у Лістингу (Listing) 2-4 ще не скомпілюється. Давайте спробуємо:

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

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

Суть помилки полягає в тому, що є mismatched types. Rust має сильну статичну систему типів. Однак він також має виведення типів. Коли ми написали let mut guess = String::new(), Rust зміг вивести, що guess має бути String, і не змусив нас писати тип. secret_number, з іншого боку, є числовим типом. Кілька числових типів Rust можуть мати значення між 1 і 100: i32, 32-бітне число; u32, беззнакове 32-бітне число; i64, 64-бітне число; а також інші. Якщо не вказано інше, Rust за замовчуванням використовує i32, що і є типом secret_number, якщо ви не додасте типову інформацію десь іще, що змусить Rust вивести інший числовий тип. Причина помилки в тому, що Rust не може порівняти рядок і числовий тип.

Зрештою, ми хочемо перетворити String, який програма читає як введення, на числовий тип, щоб ми могли порівняти його чисельно із секретним числом. Ми робимо це, додаючи цей рядок до тіла функції main:

Filename: src/main.rs

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

use rand::Rng;

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

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

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

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

    // --snip--

    let mut guess = String::new();

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

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

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

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

Цей рядок:

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

Ми створюємо змінну з іменем guess. Але зачекайте, хіба програма вже не має змінної з іменем guess? Має, але, на щастя, Rust дозволяє нам затінити попереднє значення guess новим. Затінення дозволяє нам повторно використовувати ім’я змінної guess замість того, щоб змушувати нас створювати дві унікальні змінні, такі як guess_str і guess, наприклад. Ми детальніше розглянемо затінення (shadowing) в Розділі 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 on strings перетворює рядок на інший тип. Тут ми використовуємо його, щоб перетворити рядок на число. Нам потрібно сказати Rust точний тип числа, який ми хочемо, використовуючи let guess: u32. Двокрапка (:) після guess каже Rust, що ми будемо анотувати тип змінної. Rust має кілька вбудованих числових типів; u32, який тут показано, — це беззнакове 32-бітне ціле число. Це хороший вибір за замовчуванням для невеликого додатного числа. Ви дізнаєтеся про інші числові типи в Розділі 3.

Крім того, анотація u32 у цьому прикладному програмному коді та порівняння з secret_number означає, що Rust також виведе, що secret_number має бути u32. Отже, тепер порівняння буде між двома значеннями одного типу!

Метод parse працюватиме лише з символами, які можна логічно перетворити на числа, тож він легко може спричиняти помилки. Якщо, наприклад, рядок містив A👍%, не було б способу перетворити це на число. Оскільки він може зазнати невдачі, метод parse повертає тип Result, так само як і метод read_line (обговорювався раніше в “Обробка можливої помилки за допомогою Result (Handling Potential Failure with the Result Type)”). Ми поводитимемося з цим 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. Запустіть програму кілька разів, щоб перевірити різну поведінку з різними типами введення: вгадайте число правильно, вгадайте число, яке надто велике, і вгадайте число, яке надто мале.

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

Дозволяємо кілька припущень за допомогою циклу (Allowing Multiple Guesses with Looping)

Ключове слово loop створює нескінченний цикл. Ми додамо цикл, щоб дати користувачам більше шансів відгадати число:

Filename: src/main.rs

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

use rand::Rng;

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

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

    // --snip--

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

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

        // --snip--


        let mut guess = String::new();

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

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

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

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

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

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

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

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

Введення quit завершить гру, але, як ви помітите, так само завершить її і будь-яке інше невідповідне числу введення. Це, м’яко кажучи, неоптимально; ми хочемо, щоб гра також зупинялася, коли вгадано правильне число.

Вихід після правильного припущення (Quitting After a Correct Guess)

Давайте запрограмуємо гру завершуватися, коли користувач перемагає, додавши оператор break:

Filename: src/main.rs

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

use rand::Rng;

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

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

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

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

        let mut guess = String::new();

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

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

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

        // --snip--

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

Додавання рядка break після You win! змушує програму вийти з циклу, коли користувач правильно вгадає секретне число. Вихід із циклу також означає вихід із програми, тому що цикл — це остання частина main.

Обробка некоректного введення (Handling Invalid Input)

Щоб далі вдосконалити поведінку гри, замість аварійного завершення програми, коли користувач вводить нечислове значення, давайте зробимо так, щоб гра ігнорувала нечислове значення, щоб користувач міг продовжувати відгадувати. Ми можемо зробити це, змінивши рядок, де guess перетворюється зі String на u32, як показано в Лістингу (Listing) 2-5.

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

use rand::Rng;

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

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

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

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

        let mut guess = String::new();

        // --snip--

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

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

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

        // --snip--

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

Ми переходимо від виклику 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(_) у другій гілці. Підкреслення, _, — це значення catch-all; у цьому прикладі ми кажемо, що хочемо зіставити всі значення 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!, який виводить секретне число. Лістинг (Listing) 2-6 показує фінальний код.

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

use rand::Rng;

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

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

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

        let mut guess = String::new();

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

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

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

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

На цьому етапі ви успішно створили гру у вгадування. Вітаємо!

Підсумок (Summary)

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