Программирование игры в угадайку
Окунёмся в мир Rust, вместе создав прикладной проект! Эта глава познакомит вас с несколькими наиболее важными концепциями Rust, показав, как их использовать в реальной программе. Вы узнаете о let, match, методах, ассоциированных функциях, внешних крейтах и многом другом! В дальнейших главах мы изучим всё перечисленное подробнее. В этой главе вы прикоснётесь только к самым основам.
We’ll implement a classic beginner programming problem: a guessing game. Here’s how it works: The program will generate a random integer between 1 and 100. It will then prompt the player to enter a guess. After a guess is entered, the program will indicate whether the guess is too low or too high. If the guess is correct, the game will print a congratulatory message and exit.
Создание нового проекта
Чтобы создать новый проект, перейдите в директорию 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. Весь код нашего проекта вы будете писать именно в нём.
Обработка догадки
Первая часть игры в угадайку будет просить от пользователя ввод, обрабатывать его, и проверять, имеет ли ввод ожидаемый вид. Для начала, позволим игроку ввести догадку. Поместите код из Листинга 2-1 в src/main.rs.
use std::io;
fn main() {
println!("Угадайте число!");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
В этом коде много нового, так что давайте изучим его строчка за строчкой. Чтобы получить пользовательский ввод и затем (по возможности) напечатать его, нам нужно подключить библиотеку ввода-вывода io в область видимости. Библиотека io входит в стандартную библиотеку, также известную как std:
use std::io;
fn main() {
println!("Угадайте число!");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
Rust по умолчанию подключает некоторые важные части стандартной библиотеки в каждую программу. Это подмножество называется prelude, и с его составом вы можете ознакомиться в документации стандартной библиотеки.
Если тип, который вы хотите использовать, не входит в prelude, вам нужно явно подключить этот тип к области видимости программы, используя инструкцию use. Библиотека std::io даст вам набор полезных возможностей, включая получение пользовательского ввода.
Как вы увидели в Главе 1, функция main — это точка входа в программу:
use std::io;
fn main() {
println!("Угадайте число!");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
Новая функция объявляется ключевым словом fn. Пустые круглые скобки () обозначают, что эта функция не имеет параметов. Открывающая игурная скобка { определяет начало тела функции.
Как вы также узнали в Главе 1, println! — это макрос, печатающий строку на экран:
use std::io;
fn main() {
println!("Угадайте число!");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
Этот код печатает фразы: сообщающую о сути игры и запрашивающую ввод от пользователя
Хранение значений с помощью переменных
Далее! Создадим переменную для хранения пользовательского ввода, вот так:
use std::io;
fn main() {
println!("Угадайте число!");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
Программа становится интереснее! В этой строчке, на самом деле, происходит очень многое. Мы используем инструкцию let для создания переменной. Вот ещё пример:
let apples = 5;
This line creates a new variable named apples and binds it to the value 5. In Rust, variables are immutable by default, meaning once we give the variable a value, the value won’t change. We’ll be discussing this concept in detail in the “Variables and Mutability” section in Chapter 3. To make a variable mutable, we add mut before the variable name:
let apples = 5; // неизменяема
let mut bananas = 5; // изменяема
Примечание: символы // обозначают начало комментария, продолжающегося до конца строки. Rust игнорирует весь текст в комментариях. Мы детальнее обсудим комментирование в Главе 3.
Вернёмся к программе игры в угадайку. Теперь вы знаете, что let mut guess создаёт изменяемую переменную guess. Знак равенства (=) говорит о том, что мы хотим связать что бы то ни было с данной переменной. Значение, с которым связывается переменная guess, располагается справа от знака равенства; оно является результатом вызова функции String::new — функции, возвращающей новое значение типа String. [String](https://doc.rust-lang.org/std/string/struct. String.html) — это тип строки, предоставляемый стандартной библиотекой. Он представляет собой строку текста переменной длины в кодировке 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!("Угадайте число!");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
If we hadn’t imported the io module with use std::io; at the beginning of the program, we could still use the function by writing this function call as std::io::stdin. The stdin function returns an instance of std::io::Stdin, which is a type that represents a handle to the standard input for your terminal.
Next, the line .read_line(&mut guess) calls the read_line method on the standard input handle to get input from the user. We’re also passing &mut guess as the argument to read_line to tell it what string to store the user input in. The full job of read_line is to take whatever the user types into standard input and append that into a string (without overwriting its contents), so we therefore pass that string as an argument. The string argument needs to be mutable so that the method can change the string’s content.
Знак & означает, что мы передаём методу не само значение, а ссылку на его область памяти. Это позволяет давать нескольким частям программы доступ к одной и той же информации, без необоходимости многократно копировать её. Ссылки — вещь многогранная, и одним из основных преимуществ Rust является безопасность и простота их использования. Касательно всего, что мы обсудили: на данный момент вам достаточно знать, что по умолчанию переменные и ссылки неизменяемы. Поэтому нам пришлось написать &mut guess вместо &guess, чтобы сделать получить возможность изменять нужную нам область памяти. (В Главе 4 ссылки будут рассмотрены подробнее.)
Обработка возможных ошибок с помощью Result
Мы ещё не закончили с той строчкой, той последовательностью методов. Теперь мы обсудим третью строчку, однако стоит отметить, что, логически, это всё одна строка кода. Вот строчка, о которой мы говорим:
use std::io;
fn main() {
println!("Угадайте число!");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
Мы могли бы переписать всю строку кода вот так:
io::stdin().read_line(&mut guess).expect("Не удалось прочесть ввод.");
Однако, одну длинную строчку читать было бы тяжело, так что мы её разделили, перенеся вызовы методов (.method_name()) на новые строчки и отбив их пробелами от края. Теперь обсудим, что именно делает эта строка.
Как ранее упоминалось, read_line помещает всё, что пользователь ввёл в стандартный поток вывода, в строку, получаемую как аргумент. Но он также возвращает значение типа Result. Result — это перечисление, то есть тип, который может быть представлен одним из возможных состояний. Мы называем каждое такое возможное состояние вариантом.
В Главе 6 мы в деталях обсудим перечисления. Пока вам достаточно знать, что тип Result — это тип, варианты которого хранят информацию для обработки ошибок.
Вариантами типа Result являются Ok и Err. Вариант Ok означает успешное исполнение операции и содержит в себе результат её исполнения. Вариант Err означает, что операцию не удалось исполнить, и содержит информацию об ошибке.
Values of the Result type, like values of any type, have methods defined on them. An instance of Result has an expect method that you can call. If this instance of Result is an Err value, expect will cause the program to crash and display the message that you passed as an argument to expect. If the read_line method returns an Err, it would likely be the result of an error coming from the underlying operating system. If this instance of Result is an Ok value, expect will take the return value that Ok is holding and return just that value to you so that you can use it. In this case, that value is the number of bytes in the user’s input.
Если вы не вызовите expect, то программа скомпилируется, но вы получите предупреждение:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
--> src/main.rs:10:5
|
10 | io::stdin().read_line(&mut guess);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` 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 предупреждает вас, что: 1) вы не используете значение типа Result, возвращаемое методом read_line, и 2) вы не обрабатываете возможную ошибку, с которой может завершиться вызов этого метода.
Правильный способ избавиться от таких предупреждений — это писать код с обработками ошибок. Однако, в нашем случае, мы хотим просто аварийно завершить нашу программу, если что-то пойдёт не так, поэтому использование expect допустимо. Больше об обработке ошибок вы узнаете в [Главе 9] (ch09-02-recoverable-errors-with-result.html).
Печать значений переменных с помощью меток подстановки println!
Не считая закрывающей фигурной скобки, нам пока что осталось обсудить лишь одну строчку:
use std::io;
fn main() {
println!("Угадайте число!");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
This line prints the string that now contains the user’s input. The {} set of curly brackets is a placeholder: Think of {} as little crab pincers that hold a value in place. When printing the value of a variable, the variable name can go inside the curly brackets. When printing the result of evaluating an expression, place empty curly brackets in the format string, then follow the format string with a comma-separated list of expressions to print in each empty curly bracket placeholder in the same order. Printing a variable and the result of an expression in one call to println! would look like this:
#![allow(unused)]
fn main() {
let x = 5;
let y = 10;
println!("x = {x} и y + 2 = {}", y + 2);
}
Этот код напечатает x = 5 и 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`
Угадайте число!
Введите свою догадку.
6
Вы предположили: 6
At this point, the first part of the game is done: We’re getting input from the keyboard and then printing it.
Генерация секретного числа
Next, we need to generate a secret number that the user will try to guess. The secret number should be different every time so that the game is fun to play more than once. We’ll use a random number between 1 and 100 so that the game isn’t too difficult. Rust doesn’t yet include random number functionality in its standard library. However, the Rust team does provide a rand crate with said functionality.
Increasing Functionality with a Crate
Remember that a crate is a collection of Rust source code files. The project we’ve been building is a binary crate, which is an executable. The rand crate is a library crate, which contains code that is intended to be used in other programs and can’t be executed on its own.
Управление внешними крейтами — это конёк Cargo. Чтобы получить возможность использовать крейт rand, нам нужно отредактировать файл Cargo.toml, чтобы включить этот крейт как зависимость. Откройте этот файл и добавьте строчки ниже в его конец (то есть под [dependencies] — заголовком раздела зависимостей). Убедитесь, что вы подключили версию rand ровно такую же, что и мы, иначе наши примеры могут не заработать у вас.
Файл: Cargo.toml
[dependencies]
rand = "0.8.5"
In the Cargo.toml file, everything that follows a header is part of that section that continues until another section starts. In [dependencies], you tell Cargo which external crates your project depends on and which versions of those crates you require. In this case, we specify the rand crate with the semantic version specifier 0.8.5. Cargo understands Semantic Versioning (sometimes called SemVer), which is a standard for writing version numbers. The specifier 0.8.5 is actually shorthand for ^0.8.5, which means any version that is at least 0.8.5 but below 0.9.0.
Cargo considers these versions to have public APIs compatible with version 0.8.5, and this specification ensures that you’ll get the latest patch release that will still compile with the code in this chapter. Any version 0.9.0 or greater is not guaranteed to have the same API as what the following examples use.
Теперь, ничего не меняя в программе, давайте соберём проект, как показано в Листинге 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
cargo build after adding the rand crate as a dependencyЕсли вы проделаете всё на своей машине, вы можете увидеть другие (но всё ещё обратно совместимые; спасибо 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 не стал их ещё раз загружать и компилировать.
Ensuring Reproducible Builds
Cargo has a mechanism that ensures that you can rebuild the same artifact every time you or anyone else builds your code: Cargo will use only the versions of the dependencies you specified until you indicate otherwise. For example, say that next week version 0.8.6 of the rand crate comes out, and that version contains an important bug fix, but it also contains a regression that will break your code. To handle this, Rust creates the Cargo.lock file the first time you run cargo build, so we now have this in the guessing_game directory.
Когда вы впервые собираете проект, Cargo выясняет всё о версиях зависимостей, которые удовлетворяют требованиям, и записывает информацию о них в файл Cargo.lock. Когда вы ещё раз соберёте проект, Cargo увидит, что файл Cargo.lock уже существует, и воспользуется указанными в нём версиями. Это автоматически делает ваш код воспроизводимым. Иными словами, ваш проект продолжит использовать версию 0.8.5 до тех пор, пока вы не обновитесь явно — и всё благодаря файлу Cargo.lock. Поскольку файл Cargo.lock важен для обеспечения воспроизводимости сборок, он часто включается в систему управления версиями вместе с остальным кодом вашего проекта.
Обновление крейта до новейшей версии
When you do want to update a crate, Cargo provides the command update, which will ignore the Cargo.lock file and figure out all the latest versions that fit your specifications in Cargo.toml. Cargo will then write those versions to the Cargo.lock file. Otherwise, by default, Cargo will only look for versions greater than 0.8.5 and less than 0.9.0. If the rand crate has released the two new versions 0.8.6 and 0.999.0, you would see the following if you ran 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 ignores the 0.999.0 release. At this point, you would also notice a change in your Cargo.lock file noting that the version of the rand crate you are now using is 0.8.6. To use rand version 0.999.0 or any version in the 0.999.x series, you’d have to update the Cargo.toml file to look like this instead (don’t actually make this change because the following examples assume you’re using rand 0.8):
[dependencies]
rand = "0.999.0"
В следующий раз, когда вы запустите cargo build, Cargo обновит реестр доступных крейтов и обновит вашу зависимость rand согласно определённой вами новой версии.
Мы оставим подробности о [Cargo](https://rust-lang-translations.org/ cargo/) и [его экосистеме](https://rust-lang-translations.org/cargo/ reference/publishing.html) до Главы 14. Cargo делает переиспользование вашего кода другими людьми (и наоборот) значительно более простым, так что у программистов на Rust есть отличная возможность писать небольшие проекты, собранные на основе нескольких пакетов.
Генерация случайного числа
Применим rand для загадывания числа. Обновите файл src/main.rs, поместив в него код Листинга 2-3.
use std::io;
use rand::Rng;
fn main() {
println!("Угадайте число!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("Загаданное число: {secret_number}");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
}
First, we add the line use rand::Rng;. The Rng trait defines methods that random number generators implement, and this trait must be in scope for us to use those methods. Chapter 10 will cover traits in detail.
Next, we’re adding two lines in the middle. In the first line, we call the rand::thread_rng function that gives us the particular random number generator we’re going to use: one that is local to the current thread of execution and is seeded by the operating system. Then, we call the gen_range method on the random number generator. This method is defined by the Rng trait that we brought into scope with the use rand::Rng; statement. The gen_range method takes a range expression as an argument and generates a random number in the range. The kind of range expression we’re using here takes the form start..=end and is inclusive on the lower and upper bounds, so we need to specify 1..=100 to request a number between 1 and 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`
Угадайте число!
Загаданное число: 7
Введите свою догадку.
4
Вы предположили: 4
$ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/guessing_game`
Угадайте число!
Загаданное число: 83
Введите свою догадку.
5
Вы предположили: 5
Вы должны увидеть разные случайные числа, и они должны быть в пределах от 1 до 100. Отличная работа!
Сравнение догадки с загаданным числом
Теперь мы имеем пользовательский ввод и случайное число, а значит, мы можем их сравнить. Этот шаг показан в Листинге 2-4. Обратите внимание, что этот код не скомпилируется.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
// --код сокращён--
println!("Угадайте число!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("Загаданное число: {secret_number}");
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
println!("Вы предположили: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Слишком маленькое!"),
Ordering::Greater => println!("Слишком большое!"),
Ordering::Equal => println!("Вы победили!"),
}
}
First, we add another use statement, bringing a type called std::cmp::Ordering into scope from the standard library. The Ordering type is another enum and has the variants Less, Greater, and Equal. These are the three outcomes that are possible when you compare two values.
Then, we add five new lines at the bottom that use the Ordering type. The cmp method compares two values and can be called on anything that can be compared. It takes a reference to whatever you want to compare with: Here, it’s comparing guess to secret_number. Then, it returns a variant of the Ordering enum we brought into scope with the use statement. We use a match expression to decide what to do next based on which variant of Ordering was returned from the call to cmp with the values in guess and secret_number.
A match expression is made up of arms. An arm consists of a pattern to match against, and the code that should be run if the value given to match fits that arm’s pattern. Rust takes the value given to match and looks through each arm’s pattern in turn. Patterns and the match construct are powerful Rust features: They let you express a variety of situations your code might encounter, and they make sure you handle them all. These features will be covered in detail in Chapter 6 and Chapter 19, respectively.
Рассмотрим наш вышеприведённый пример с выражением 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.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
В сердце ошибки находится в несоответствии типов. Rust — язык с сильной статической типизацией. Однако, он также способен самостоятельно вывести тип. Когда мы пишем let mut guess = String::new(), Rust может вывести, что guess должна быть типа String, а потому с нас не требуется уточнять тип. secret_number же — это целочисленный тип. Типов Rust, которые могут представлять числа от 1 до 100, множество: i32, знаковое 32-битное число; u32, беззнаковое 32-битное число; i64, знаковое 64-битное число; и так далее. В случае равновозможности использования нескольких числовых типов, Rust выводит для числа тип i32. Это он и делает с secret_number — выводит i32, пока не появится дополнительная информация, которая заставила бы Rust вывести другой тип. Причиной же ошибки является невозможность в Rust сравнить значения строкового и числового типов.
Ultimately, we want to convert the String the program reads as input into a number type so that we can compare it numerically to the secret number. We do so by adding this line to the main function body:
Файл: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Угадайте число!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("Загаданное число: {secret_number}");
println!("Введите свою догадку.");
// --код сокращён--
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
let guess: u32 = guess.trim().parse().expect("Пожалуйста, введите число!");
println!("Вы предположили: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Слишком маленькое!"),
Ordering::Greater => println!("Слишком большое!"),
Ordering::Equal => println!("Вы победили!"),
}
}
… вот эту строчку:
let guess: u32 = guess.trim().parse().expect("Пожалуйста, введите число!");
Здесь мы создаём переменную guess. Но только… в программе же уже есть переменная guess, верно? Да, это так, в самом деле; но Rust позволяет переобъявлять переменные, присваивая им новые значения (и даже других типов). Это называется затенением; оно позволяет переиспользовать имя переменной вместо того, чтобы создавать несколько переменных одинакового смысла, но разных типов (например, guess_str и guess). Мы обсудим это детальнее в Главе 3, а пока просто знайте, что затенение часто полезно, когда вам нужно преобразовать значение из одного типа в другой.
Мы связываем эту новую переменную со значением выражения guess.trim().parse(). В нём, guess — это название уже ранее существующей переменной: нашей изначальной guess, содержащей ввод в виде строки. Метод trim, реализованный для экземпляров String, убирает начальные и конечные пробелы — нам нужно это сделать перед конвертацией строки в число типа u32 (целое, беззнаковое, 32-битное). Пользователь должен нажать 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 есть встроенные числовые типы; использованный нами тип u32 означает целое беззнаковое 32-битное число — хороший выбор для относительно небольших положительных чисел. Вы узнаете больше о других числовых типах в Главе 3.
Additionally, the u32 annotation in this example program and the comparison with secret_number means Rust will infer that secret_number should be a u32 as well. So, now the comparison will be between two values of the same type!
The parse method will only work on characters that can logically be converted into numbers and so can easily cause errors. If, for example, the string contained A👍%, there would be no way to convert that to a number. Because it might fail, the parse method returns a Result type, much as the read_line method does (discussed earlier in “Handling Potential Failure with Result”). We’ll treat this Result the same way by using the expect method again. If parse returns an Err Result variant because it couldn’t create a number from the string, the expect call will crash the game and print the message we give it. If parse can successfully convert the string to a number, it will return the Ok variant of Result, and expect will return the number that we want from the Ok value.
Теперь запустим нашу программу:
$ 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`
Угадайте число!
Загаданное число: 58
Введите свою догадку.
76
Вы предположили: 76
Слишком большое!
Nice! Even though spaces were added before the guess, the program still figured out that the user guessed 76. Run the program a few times to verify the different behavior with different kinds of input: Guess the number correctly, guess a number that is too high, and guess a number that is too low.
Большая часть игры готова, но пользователь пока что может сделать дать одну догадку. Изменим это, добавив цикл!
Возможность дать догадку не один раз с помощью циклов
Ключевое слово loop создаёт бесконечный цикл. Мы добавим цикл, чтобы дать пользователю больше попыток угадать число:
Файл: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Угадайте число!");
let secret_number = rand::thread_rng().gen_range(1..=100);
// --код сокращён--
println!("Загаданное число: {secret_number}");
loop {
println!("Введите свою догадку.");
// --код сокращён--
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
let guess: u32 = guess.trim().parse().expect("Пожалуйста, введите число!");
println!("Вы предположили: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Слишком маленькое!"),
Ordering::Greater => println!("Слишком большое!"),
Ordering::Equal => println!("Вы победили!"),
}
}
}
Как вы можете видеть, мы переместили весь код обработки догадки в цикл. Убедитесь, что отбили строчки от левого края ещё четырьмя пробелами каждую, и снова запустите программу. Программа теперь будет спрашивать догадку бесконечно, и это создало нам новую проблему: игра никогда не закончится!
The user could always interrupt the program by using the keyboard shortcut ctrl-C. But there’s another way to escape this insatiable monster, as mentioned in the parse discussion in “Comparing the Guess to the Secret Number”: If the user enters a non-number answer, the program will crash. We can take advantage of that to allow the user to quit, as shown here:
$ 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 остановит программу, но как вы можете заметить, это произойдёт при любом нечисловом вводе. Такое поведение программы, мягко говоря, неоптимально. Мы хотим, чтобы игра завершалась, когда догадка игрока оказывается правильной.
Завершение игры после правильной догадки
Запрограммируем выход из игры, когда игрок побеждает. Для этого, используем инструкцию break:
Файл: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Угадайте число!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("Загаданное число: {secret_number}");
loop {
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
let guess: u32 = guess.trim().parse().expect("Пожалуйста, введите число!");
println!("Вы предположили: {guess}");
// --код сокращён--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Слишком маленькое!"),
Ordering::Greater => println!("Слишком большое!"),
Ordering::Equal => {
println!("Вы победили!");
break;
}
}
}
}
Строка break после You win! заставляет программу покинуть цикл, когда игрок делает правильную догадку. Выход из цикла также означает конец программы, поскольку цикл оказывается последней частью main.
Обработка неправильного ввода
To further refine the game’s behavior, rather than crashing the program when the user inputs a non-number, let’s make the game ignore a non-number so that the user can continue guessing. We can do that by altering the line where guess is converted from a String to a u32, as shown in Listing 2-5.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Угадайте число!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("Загаданное число: {secret_number}");
loop {
println!("Введите свою догадку.");
let mut guess = String::new();
// --код сокращён--
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("Вы предположили: {guess}");
// --код сокращён--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Слишком маленькое!"),
Ordering::Greater => println!("Слишком большое!"),
Ordering::Equal => {
println!("Вы победили!");
break;
}
}
}
}
We switch from an expect call to a match expression to move from crashing on an error to handling the error. Remember that parse returns a Result type and Result is an enum that has the variants Ok and Err. We’re using a match expression here, as we did with the Ordering result of the cmp method.
Если parse может преобразовать строку в число, он вернёт значение Ok, содержащее результат преобразования — число. Этот Ok сопоставится с шаблоном первой ветви, и выражение match просто вернёт значение num, которое до этого создало parse и поместило в значение Ok. Это число будет сохранено в созданной нами переменной guess.
If parse is not able to turn the string into a number, it will return an Err value that contains more information about the error. The Err value does not match the Ok(num) pattern in the first match arm, but it does match the Err(_) pattern in the second arm. The underscore, _, is a catch-all value; in this example, we’re saying we want to match all Err values, no matter what information they have inside them. So, the program will execute the second arm’s code, continue, which tells the program to go to the next iteration of the loop and ask for another guess. So, effectively, the program ignores all errors that parse might encounter!
Теперь вся программа должна работать нужным образом. Попробуем:
$ 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`
Угадайте число!
Загаданное число: 61
Введите свою догадку.
10
Вы предположили: 10
Слишком маленькое!
Введите свою догадку.
99
Вы предположили: 99
Слишком большое!
Введите свою догадку.
foo
Введите свою догадку.
61
Вы предположили: 61
Вы победили!
Замечательно! Нам нужно внести только ещё одну небольшую последнюю правку. Вспомним, что программа всё ещё печатает загаданное число. Это было полезно для тестирования, но для игры это бессмысленно. Удалим строчку с println!, которая печатает загаданное число. В Листинге 2-6 приведён окончательный код программы.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Угадайте число!");
let secret_number = rand::thread_rng().gen_range(1..=100);
loop {
println!("Введите свою догадку.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Не удалось прочесть ввод.");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("Вы предположили: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Слишком маленькое!"),
Ordering::Greater => println!("Слишком большое!"),
Ordering::Equal => {
println!("Вы победили!");
break;
}
}
}
}
Игра готова. Хорошая работа, поздравляем!
Подведём итоги
Этот прикладной проект познакомил вас со многими концепциями Rust: let, match, функциями, использованием внешних крейтов и многим другим. В нескольких следующих главах вы изучите эта концепции подробнее. Глава 3 расскажет об общих понятиях программирования, присущих и Rust, таких как переменные, типы данных и функциях, а также покажет, как их использовать. Глава 4 посвящена системе владения — особенности Rust, сильно выделяющей его среди других языков программирования. В Главе 5 обсуждаются структуры и синтаксис метода, а Глава 6 объясняет работу с перечислениями.