Пишем игру «Угадай число»
Давайте сразу окунёмся в 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 = "2024"
[dependencies]
Как вы уже видели в главе 1, 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 очень выручает, когда над проектом нужно работать короткими
итерациями, как мы будем делать с этой игрой: внесли изменение, быстро
проверили — и дальше.
Снова откройте файл src/main.rs. Весь код игры мы будем писать в нём.
Обрабатываем догадку игрока
Первая часть программы будет запрашивать у пользователя ввод, обрабатывать его и проверять, что он в ожидаемом формате. Для начала дадим игроку ввести свою догадку. Введите в src/main.rs код из листинга 2-1.
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. Она входит в стандартную библиотеку, которая называется 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), и его полный состав можно посмотреть в документации стандартной библиотеки.
Если нужного типа в прелюдии нет, его придётся явно подключить инструкцией
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, что
мы сразу привязываем к переменной какое-то значение. Справа от знака равенства
— то самое значение: результат вызова String::new, функции, которая
возвращает новый экземпляр String. String — это
строковый тип из стандартной библиотеки: текст в кодировке UTF-8, который
может расти.
:: в ::new означает, что new — это ассоциированная функция типа String.
Ассоциированная функция — это функция, реализованная для типа, в данном
случае для String. Функция new создаёт новую пустую строку. Функцию new
вы встретите у многих типов: это общепринятое имя для функции, которая создаёт
новое значение того или иного рода.
Итак, строка let mut guess = String::new(); создаёт изменяемую переменную и
привязывает к ней новый пустой экземпляр String. Уф!
Получаем ввод пользователя
Напомним: в первой строке программы мы подключили средства ввода-вывода из
стандартной библиотеки — use std::io;. Теперь вызовем функцию stdin из
модуля io, чтобы обработать ввод пользователя:
use std::io;
fn main() {
println!("Guess the number!");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}
Если бы мы не подключили модуль io через use std::io; в начале программы,
функцию всё равно можно было бы вызвать, просто написав полный путь:
std::io::stdin. Функция stdin возвращает экземпляр
std::io::Stdin — это тип, представляющий
дескриптор (handle) стандартного ввода вашего терминала.
Следующая строка, .read_line(&mut guess), вызывает у этого дескриптора метод
read_line, чтобы получить ввод пользователя.
Аргументом мы передаём &mut guess — так read_line узнаёт, в какую строку
записать введённое. Работа read_line в том, чтобы взять всё, что пользователь
напечатал в стандартный ввод, и дописать это в конец строки (не затирая её
прежнее содержимое). Поэтому мы и передаём строку аргументом. Строка должна
быть изменяемой, иначе метод не сможет поменять её содержимое.
& означает, что аргумент — это ссылка (reference). Ссылки позволяют
разным частям кода обращаться к одним и тем же данным, не копируя их в памяти
много раз. Ссылки — сложная тема, и одно из главных преимуществ 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");
Но одну длинную строку тяжело читать, поэтому лучше её разбить. Когда
вызываете методы через .имя_метода(), разумно переносить длинную цепочку
на новые строки и добавлять отступы. Теперь разберёмся, что же делает эта
строка.
Как мы уже говорили, read_line записывает введённое пользователем в
переданную строку, но кроме этого он ещё и возвращает значение типа Result.
Result — это перечисление
(enumeration, или коротко enum), то есть тип, значение которого может
находиться в одном из нескольких возможных состояний. Каждое такое состояние
называется вариантом.
Подробнее перечисления разбираются в главе 6. Типы
Result нужны для того, чтобы передавать информацию об ошибках.
У Result два варианта: Ok и Err. Ok означает, что операция прошла
успешно, и внутри него лежит полученное значение. Err означает, что операция
не удалась, и внутри него лежат сведения о том, как и почему это произошло.
У значений типа Result, как и у значений любого другого типа, есть методы.
Например, у Result есть метод expect. Если значение
Result — это Err, то 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)]` (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. Как восстанавливаться после
ошибок, вы узнаете в главе 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}");
}
Она выводит строку, в которой теперь лежит ввод пользователя. Пара фигурных
скобок {} — это заполнитель (placeholder): представьте, что {} — это
маленькие крабьи клешни, которые держат значение на месте. Чтобы вывести
значение переменной, можно написать её имя прямо внутри фигурных скобок. А
чтобы вывести результат вычисления выражения, поставьте в строке формата
пустые фигурные скобки, а после строки через запятую перечислите выражения —
они по порядку подставятся в пустые заполнители. Вот как вывести переменную и
результат выражения одним вызовом println!:
#![allow(unused)]
fn main() {
let x = 5;
let y = 10;
println!("x = {x} and y + 2 = {}", y + 2);
}
Этот код выведет x = 5 and y + 2 = 12.
Проверяем первую часть
Проверим, как работает первая часть игры. Запустите её командой cargo run:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6
Первая часть игры готова: мы получаем ввод с клавиатуры и выводим его.
Загадываем число
Теперь нужно загадать число, которое пользователь будет угадывать. Оно должно
каждый раз быть новым, иначе играть второй раз будет неинтересно. Возьмём
случайное число от 1 до 100, чтобы игра не была слишком сложной. В стандартной
библиотеке Rust пока нет генератора случайных чисел, но команда Rust выпускает
для этого крейт rand.
Расширяем возможности с помощью крейта
Напомним, что крейт — это набор файлов с исходным кодом на Rust. Проект,
который мы пишем, — это бинарный крейт, то есть исполняемая программа. А
rand — библиотечный крейт: его код предназначен для использования в других
программах, а сам по себе он не запускается.
Именно в работе с внешними крейтами Cargo по-настоящему блистает. Прежде чем
писать код с rand, нужно добавить этот крейт в зависимости в файле
Cargo.toml. Откройте файл и допишите в самый конец, под заголовком секции
[dependencies], который Cargo уже создал, следующую строку. Укажите rand
именно так, как у нас, с этим номером версии, — иначе примеры кода из этой
главы могут не заработать:
Файл: Cargo.toml
[dependencies]
rand = "0.10.1"
В Cargo.toml всё, что идёт после заголовка, относится к его секции — до тех
пор, пока не начнётся следующая. В [dependencies] вы перечисляете, от каких
внешних крейтов зависит проект и какие их версии нужны. Здесь мы указали крейт
rand со спецификатором семантической версии 0.10.1. Cargo понимает
семантическое версионирование (его ещё называют
SemVer) — стандарт записи номеров версий. Спецификатор 0.10.1 — это на
самом деле сокращение для ^0.10.1, то есть «любая версия не ниже 0.10.1, но
ниже 0.11.0».
Cargo считает, что у всех этих версий публичный API совместим с версией 0.10.1. Такая запись гарантирует, что вы получите самый свежий патч-релиз, который всё ещё будет компилироваться с кодом из этой главы. А вот у версий 0.11.0 и выше API может уже отличаться от того, что используется в примерах ниже.
Теперь, ничего не меняя в коде, соберём проект, как показано в листинге 2-2.
$ 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 в зависимостиУ вас могут быть другие номера версий (но все они будут совместимы с кодом — спасибо SemVer!) и другие строки (в зависимости от операционной системы), да и порядок строк может отличаться.
Когда мы добавляем внешнюю зависимость, Cargo скачивает последние версии всего, что ей нужно, из реестра (registry) — копии данных с Crates.io. Crates.io — это место, где участники экосистемы Rust публикуют свои открытые проекты, чтобы ими могли пользоваться другие.
Обновив реестр, 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 повторно использует то, что уже скачал и скомпилировал.
Воспроизводимые сборки
В Cargo есть механизм, который гарантирует, что при каждой сборке кода — вами
или кем-то ещё — получится один и тот же результат: пока вы явно не укажете
иное, Cargo будет использовать только те версии зависимостей, что вы выбрали.
Допустим, на следующей неделе выйдет версия 0.10.2 крейта rand. В ней
исправлена важная ошибка, но заодно появилась регрессия, которая ломает ваш
код. На этот случай при первом запуске cargo build Rust создаёт файл
Cargo.lock — он уже лежит у нас в папке guessing_game.
При первой сборке проекта Cargo подбирает версии всех зависимостей,
подходящие под условия, и записывает их в Cargo.lock. При следующих сборках
Cargo видит, что Cargo.lock уже есть, и берёт версии оттуда, а не подбирает
их заново. Так сборка автоматически становится воспроизводимой. Иначе говоря,
благодаря Cargo.lock ваш проект будет использовать rand 0.10.1, пока вы
явно не обновите его. Поскольку Cargo.lock так важен для воспроизводимости,
его обычно хранят в системе контроля версий вместе с остальным кодом.
Обновляем крейт до новой версии
Когда вы всё-таки хотите обновить крейт, воспользуйтесь командой Cargo
update. Она проигнорирует Cargo.lock, подберёт самые свежие версии,
подходящие под условия из Cargo.toml, и запишет их в Cargo.lock. При этом
по умолчанию Cargo будет искать только версии выше 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 проигнорирует. Заодно вы заметите, что в Cargo.lock
теперь записано: используется rand версии 0.10.2. Чтобы перейти на rand
0.999.0 или любую версию из серии 0.999.x, нужно изменить Cargo.toml вот
так (но на самом деле не меняйте — примеры ниже рассчитаны на rand 0.10):
[dependencies]
rand = "0.999.0"
При следующем запуске cargo build Cargo обновит реестр доступных крейтов и
заново подберёт rand в соответствии с новой версией, которую вы указали.
О Cargo и его экосистеме можно рассказать ещё очень многое — мы займёмся этим в главе 14, а пока этого достаточно. Cargo очень упрощает повторное использование библиотек, поэтому растацеаны могут писать небольшие проекты, собирая их из множества готовых пакетов.
Генерируем случайное число
Начнём пользоваться rand, чтобы загадать число. Обновите src/main.rs, как
показано в листинге 2-3.
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 и получаем генератор случайных чисел, которым будем
пользоваться. У каждого потока выполнения такой генератор свой, а начальное
значение (seed) он берёт у операционной системы. Затем у
генератора вызывается метод random_range. Этот метод определён в трейте
RngExt, который входит в модуль rand::prelude, — мы подключили его
инструкцией use rand::prelude::*;. Метод random_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` 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. Если появляются
предупреждения, их можно смело игнорировать. Если же появляются ошибки,
проверьте, что в Cargo.toml указано rand = "0.10.1": у будущих версий
rand API может отличаться, а вот с любой версией из серии 0.10 код из этой
главы должен работать.
Сравниваем догадку с загаданным числом
Теперь, когда у нас есть ввод пользователя и случайное число, их можно сравнить. Этот шаг показан в листинге 2-4. Обратите внимание: пока этот код не скомпилируется — почему, мы сейчас объясним.
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);
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 — это ещё одно
перечисление, у него три варианта: Less (меньше), Greater (больше) и
Equal (равно). Это три возможных исхода сравнения двух значений.
Затем мы добавляем внизу пять новых строк, где используется тип Ordering.
Метод cmp сравнивает два значения; его можно вызвать у всего, что вообще
можно сравнивать. Он принимает ссылку на то, с чем сравнивать: здесь мы
сравниваем guess с secret_number. Результат — один из вариантов
перечисления Ordering, которое мы подключили через use. С помощью выражения
match мы решаем, что делать дальше, в зависимости от
того, какой вариант Ordering вернул вызов cmp для guess и
secret_number.
Выражение match состоит из веток (arms). Каждая ветка — это пара:
образец (pattern) и код. Если значение, переданное в 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::Greater, и он совпадает с Ordering::Greater! Выполняется код
этой ветки, и на экран выводится Too big!. Выражение match завершается
после первого же совпадения, так что последнюю ветку в этом случае оно даже не
проверяет.
Однако код из листинга 2-4 пока не компилируется. Попробуем:
$ cargo build
Compiling libc v0.2.186
Compiling rand_core v0.10.1
Compiling cfg-if v1.0.0
Compiling getrandom v0.4.3
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/88d9e12ae178fab0fb5cc050a94da85685d449ea/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
Суть ошибки — несовпадение типов (mismatched types). В Rust сильная
статическая система типов, но при этом в нём есть выведение типов. Когда мы
написали let mut guess = String::new(), Rust сам понял, что guess должна
быть типа String, и не заставил нас указывать тип. А вот secret_number —
числового типа. Значение от 1 до 100 могут хранить несколько числовых типов
Rust: i32 (32-битное число), u32 (32-битное беззнаковое), i64
(64-битное) и другие. Если не сказано иное, Rust по умолчанию выбирает i32 —
именно такой тип и будет у secret_number, если где-то ещё в коде не окажется
подсказки, из которой Rust выведет другой числовой тип. А ошибка возникает
потому, что Rust не умеет сравнивать строку с числом.
В итоге нам нужно превратить String, которую программа прочитала из ввода, в
число, чтобы сравнивать его с загаданным уже как число с числом. Для этого
добавим в тело функции main такую строку:
Файл: 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);
println!("The secret number is: {secret_number}");
println!("Please input your guess.");
// --пропуск--
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = guess.trim().parse().expect("Please type a number!");
println!("You guessed: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
Вот она:
let guess: u32 = guess.trim().parse().expect("Please type a number!");
Мы создаём переменную с именем guess. Но постойте, разве в программе уже нет
переменной guess? Есть, но Rust любезно позволяет затенить (shadow)
прежнее значение guess новым. Затенение позволяет снова использовать имя
guess, а не заводить две разные переменные вроде guess_str и guess.
Подробнее мы поговорим об этом в главе 3, а пока
просто запомните: этим часто пользуются, когда нужно преобразовать значение из
одного типа в другой.
Новую переменную мы привязываем к выражению guess.trim().parse(). guess в
этом выражении — это исходная переменная, в которой лежит ввод в виде строки.
Метод trim у экземпляра String убирает пробельные символы в начале и в
конце строки. Это обязательно нужно сделать, прежде чем превращать строку в
u32: тип u32 может хранить только число. Чтобы read_line принял догадку,
пользователь должен нажать enter, а это добавляет в строку символ
перевода строки. Например, если пользователь ввёл 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 и сравниваем guess с secret_number, Rust
выведет, что secret_number тоже должна быть типа u32. Так что теперь
сравниваются два значения одного типа!
Метод parse работает только с символами, которые по смыслу можно превратить
в число, поэтому легко может закончиться ошибкой. Если, например, в строке
A👍%, превратить это в число никак не получится. Раз parse может не
сработать, он возвращает Result — точно так же, как метод read_line (мы
обсуждали это выше, в разделе «Обрабатываем возможный сбой с помощью
Result»). И
обработаем мы этот Result так же — снова методом expect. Если parse не
сможет получить из строки число и вернёт вариант Err, вызов expect уронит
игру и выведет наше сообщение. Если же parse успешно преобразует строку в
число, он вернёт вариант Ok, а 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!
Отлично! Хотя перед числом стояли пробелы, программа всё равно поняла, что пользователь ввёл 76. Запустите программу несколько раз и проверьте, как она ведёт себя при разном вводе: угадайте число, введите слишком большое и слишком маленькое.
Почти вся игра уже работает, но пользователь может сделать только одну попытку. Исправим это, добавив цикл!
Даём несколько попыток с помощью цикла
Ключевое слово loop создаёт бесконечный цикл. Добавим его, чтобы у игрока
было больше попыток угадать число:
Файл: 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);
// --пропуск--
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}");
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' (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
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);
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}");
// --пропуск--
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.
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);
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 = 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;
}
}
}
}
Мы заменили вызов expect на выражение match: теперь программа не падает
при ошибке, а обрабатывает её. Напомним, что parse возвращает Result, а
Result — это перечисление с вариантами Ok и Err. Мы используем здесь
match так же, как раньше для результата Ordering, который возвращал метод
cmp.
Если parse удалось превратить строку в число, он вернёт значение Ok с этим
числом внутри. Ok совпадёт с образцом первой ветки, и выражение match
просто вернёт число num, которое parse положил внутрь Ok. Это число
окажется ровно там, где нам нужно, — в новой переменной guess, которую мы
создаём.
Если же parse не удалось превратить строку в число, он вернёт значение
Err с подробностями об ошибке. Err не совпадает с образцом Ok(num) из
первой ветки, зато совпадает с образцом Err(_) из второй. Подчёркивание _ —
это «подходит что угодно»: здесь мы говорим, что нас устраивает любое значение
Err, что бы у него ни было внутри. Поэтому программа выполнит код второй
ветки — continue, то есть перейдёт к следующей итерации цикла loop и
попросит новую догадку. В итоге программа просто игнорирует любые ошибки,
которые может вернуть parse!
Теперь всё в программе должно работать как задумано. Проверим:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!
Потрясающе! Осталась последняя мелочь — и игра готова. Помните, что программа
всё ещё выводит загаданное число? Для проверки это было удобно, но игру
портит. Удалим println!, который выводит загаданное число. Финальный код —
в листинге 2-6.
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, функциями, использованием внешних крейтов и другими. В
следующих нескольких главах мы разберём их подробнее. Глава 3 рассказывает о
том, что есть почти в любом языке программирования, — переменных, типах данных
и функциях — и о том, как с ними работать в Rust. Глава 4 посвящена владению —
тому, что отличает Rust от других языков. В главе 5 речь пойдёт о структурах и
синтаксисе методов, а в главе 6 — о том, как устроены перечисления.