Программирование игры «Угадай число»
Давайте погрузимся в 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!"); }
Теперь скомпилируем эту программу «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!("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 (прелюдия), и вы можете увидеть всё его содержимое в документации по стандартной библиотеке.
Если тип, который вы хотите использовать, отсутствует в прелюдии, вы должны явно подключить этот тип в область видимости с помощью оператора use. Использование библиотеки std::io предоставляет вам ряд полезных возможностей, включая возможность принимать ввод пользователя.
Как вы видели в главе 1, функция main является точкой входа в программу:
use std::io;
fn main() {
println!("Guess the number!");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}
Синтаксис fn объявляет новую функцию; круглые скобки () указывают, что параметров нет; а фигурная скобка { начинает тело функции.
Как вы также узнали в главе 1, println! — это макрос, который выводит строку на экран:
use std::io;
fn main() {
println!("Guess the number!");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}
Этот код выводит приглашение, объясняющее, что это за игра, и запрашивает ввод у пользователя.
Хранение значений с помощью переменных
Далее мы создадим переменную для хранения ввода пользователя:
use std::io;
fn main() {
println!("Guess the number!");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}
Теперь программа становится интересной! В этой короткой строке происходит много всего. Мы используем оператор let для создания переменной. Вот ещё один пример:
let apples = 5;
Эта строка создаёт новую переменную с именем apples и связывает её со значением 5. В Rust переменные по умолчанию неизменяемы, то есть после того, как мы присвоили переменной значение, оно не изменится. Мы подробно обсудим эту концепцию в разделе «Переменные и изменяемость» в главе 3. Чтобы сделать переменную изменяемой, добавляем mut перед именем переменной:
let apples = 5; // неизменяемая
let mut bananas = 5; // изменяемая
Примечание: Синтаксис
//начинает комментарий, который продолжается до конца строки. Rust игнорирует всё в комментариях. Мы подробнее обсудим комментарии в главе 3.
Возвращаясь к программе игры «Угадай число», теперь вы знаете, что let mut guess создаст изменяемую переменную с именем guess. Знак равенства (=) говорит Rust, что мы хотим сейчас связать что-то с переменной. Справа от знака равенства находится значение, с которым связывается guess, а именно результат вызова String::new — функции, которая возвращает новый экземпляр String. String — это тип строки, предоставляемый стандартной библиотекой, который представляет собой изменяемый текст в кодировке 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 — типа, который представляет дескриптор стандартного ввода для вашего терминала.
Далее строка .read_line(&mut guess) вызывает метод read_line для дескриптора стандартного ввода, чтобы получить ввод от пользователя. Мы также передаём &mut guess в качестве аргумента в read_line, чтобы сообщить ей, в какую строку сохранить ввод пользователя. Полная задача read_line — взять всё, что пользователь ввёл в стандартный ввод, и добавить это в строку (не перезаписывая её содержимое), поэтому мы передаём эту строку в качестве аргумента. Аргумент-строка должен быть изменяемым, чтобы метод мог изменить содержимое строки.
Символ & указывает, что этот аргумент — это ссылка, которая даёт вам способ позволить нескольким частям вашего кода обращаться к одному фрагменту данных без необходимости копировать эти данные в память несколько раз. Ссылки — это сложная возможность, и одно из главных преимуществ Rust — насколько безопасно и легко использовать ссылки. Вам не нужно знать много этих деталей, чтобы завершить эту программу. Пока что всё, что вам нужно знать, это то, что, как и переменные, ссылки по умолчанию неизменяемы. Следовательно, вам нужно написать &mut guess, а не &guess, чтобы сделать её изменяемой. (Глава 4 подробнее объяснит ссылки.)
Обработка возможных сбоев с помощью типа Result
Мы всё ещё работаем над этой строкой кода. Сейчас мы обсуждаем третью строку текста, но обратите внимание, что это всё ещё часть одной логической строки кода. Следующая часть — это метод:
use std::io;
fn main() {
println!("Guess the number!");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}
Мы могли бы написать этот код так:
io::stdin().read_line(&mut guess).expect("Failed to read line");
Однако одна длинная строка трудна для чтения, поэтому лучше разделить её. Часто стоит добавить перенос строки и другие пробелы, чтобы разбить длинные строки при вызове метода с синтаксисом .method_name(). Теперь давайте обсудим, что делает эта строка.
Как упоминалось ранее, read_line помещает всё, что ввёл пользователь, в строку, которую мы передаём ей, но она также возвращает значение Result. Result — это перечисление, часто называемое enum, которое является типом, который может находиться в одном из нескольких возможных состояний. Мы называем каждое возможное состояние вариантом.
Глава 6 подробно рассмотрит перечисления. Цель этих типов Result — закодировать информацию об обработке ошибок.
Вариантами Result являются Ok и Err. Вариант Ok указывает, что операция прошла успешно, и содержит успешно сгенерированное значение. Вариант Err означает, что операция завершилась сбоем, и содержит информацию о том, как или почему операция завершилась сбоем.
У значений типа Result, как и у значений любого типа, есть определённые для них методы. У экземпляра Result есть метод expect, который вы можете вызвать. Если этот экземпляр Result является значением Err, expect приведёт к аварийному завершению программы и отобразит сообщение, которое вы передали в качестве аргумента в expect. Если метод read_line возвращает Err, это, скорее всего, результат ошибки, исходящей от базовой операционной системы. Если этот экземпляр Result является значением Ok, expect возьмёт возвращаемое значение, которое содержит Ok, и вернёт только его вам, чтобы вы могли его использовать. В этом случае это значение — количество байтов во вводе пользователя.
Если вы не вызываете expect, программа скомпилируется, но вы получите предупреждение:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
--> src/main.rs:10:5
|
10 | io::stdin().read_line(&mut guess);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: 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.
Тестирование первой части
Давайте протестируем первую часть игры «Угадай число». Запустите её с помощью 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 — это то, где Cargo действительно блистает. Прежде чем мы сможем написать код, использующий rand, нам нужно изменить файл Cargo.toml, чтобы включить крейт rand в качестве зависимости. Откройте этот файл сейчас и добавьте следующую строку в конец, под заголовком раздела [dependencies], который Cargo создал для вас. Обязательно укажите rand точно так, как мы указали здесь, с этим номером версии, иначе примеры кода в этом руководстве могут не работать:
Имя файла: Cargo.toml
[dependencies]
rand = "0.8.5"
В файле Cargo.toml всё, что следует за заголовком, является частью этого раздела, который продолжается до начала другого раздела. В [dependencies] вы сообщаете Cargo, какие внешние крейты требуются вашему проекту и какие версии этих крейтов вам нужны. В этом случае мы указываем крейт rand с указателем семантической версии 0.8.5. Cargo понимает Семантическое версионирование (иногда называемое SemVer), которое является стандартом для написания номеров версий. Указатель 0.8.5 на самом деле является сокращением для ^0.8.5, что означает любую версию, которая не ниже 0.8.5, но ниже 0.9.0.
Cargo считает, что эти версии имеют совместимые с версией 0.8.5 публичные API, и это указание гарантирует, что вы получите последний патч-релиз, который всё ещё будет компилироваться с кодом в этой главе. Любая версия 0.9.0 или выше не гарантирует того же API, что и следующие примеры.
Теперь, не меняя никакого кода, давайте соберём проект, как показано в листинге 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 после добавления крейта rand в качестве зависимостиВы можете увидеть разные номера версий (но они все будут совместимы с кодом, благодаря SemVer!) и разные строки (в зависимости от операционной системы), и строки могут быть в другом порядке.
Когда мы включаем внешнюю зависимость, Cargo загружает последние версии всего, что требуется этой зависимости, из реестра, который является копией данных с Crates.io. Crates.io — это место, где люди в экосистеме Rust публикуют свои открытые проекты на 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.lock
У 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 важен для воспроизводимых сборок, его часто добавляют в систему контроля версий вместе с остальным кодом вашего проекта.
Обновление крейта для получения новой версии
Когда вы хотите обновить крейт, Cargo предоставляет команду update, которая игнорирует файл Cargo.lock и определяет все последние версии, соответствующие вашим спецификациям в Cargo.toml. Cargo затем запишет эти версии в файл Cargo.lock. В этом случае Cargo будет искать только версии больше 0.8.5 и меньше 0.9.0. Если крейт rand выпустил две новые версии 0.8.6 и 0.9.0, вы увидите следующее, если запустите cargo update:
$ cargo update
Updating crates.io index
Locking 1 package to latest Rust 1.85.0 compatible version
Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)
Cargo игнорирует выпуск 0.9.0. На этом этапе вы также заметите изменение в файле Cargo.lock, в котором указано, что версия крейта rand, которую вы теперь используете, — 0.8.6. Чтобы использовать rand версии 0.9.0 или любую версию в серии 0.9.x, вам придётся обновить файл Cargo.toml так:
[dependencies]
rand = "0.9.0"
В следующий раз, когда вы запустите cargo build, Cargo обновит реестр доступных крейтов и переоценит ваши требования к rand в соответствии с новой указанной версией.
О многом ещё можно сказать о Cargo и его экосистеме, что мы обсудим в главе 14, но пока этого достаточно. Cargo очень упрощает повторное использование библиотек, поэтому Rustaceans могут писать меньшие проекты, собранные из ряда пакетов.
Генерация случайного числа
Давайте начнём использовать rand для генерации числа, которое нужно угадать. Следующий шаг — обновить src/main.rs, как показано в листинге 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. Отлично!
Сравнение догадки с секретным числом
Теперь, когда у нас есть ввод пользователя и случайное число, мы можем их сравнить. Этот шаг показан в листинге 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 — это ещё одно перечисление и имеет варианты Less, Greater и Equal. Это три исхода, которые возможны при сравнении двух значений.
Затем мы добавляем пять новых строк внизу, которые используют тип Ordering. Метод cmp сравнивает два значения и может быть вызван для всего, что можно сравнивать. Он принимает ссылку на то, с чем вы хотите сравнить: здесь он сравнивает guess с secret_number. Затем он возвращает вариант перечисления 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 заканчивается после первого успешного совпадения, поэтому в этом сценарии оно не будет смотреть на последнюю ветвь.
Однако код в листинге 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/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/cmp.rs:964: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, если вы не добавите информацию о типе в другом месте, которая заставит Rust вывести другой числовой тип. Причина ошибки в том, что Rust не может сравнить строку и числовой тип.
В конечном итоге мы хотим преобразовать String, которую программа считывает как ввод, в числовой тип, чтобы мы могли сравнить её численно с секретным числом. Мы делаем это, добавляя эту строку в тело функции main:
Имя файла: 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. Мы подробнее рассмотрим это в главе 3, но пока знайте, что эта возможность часто используется, когда вы хотите преобразовать значение из одного типа в другой тип.
Мы связываем эту новую переменную с выражением guess.trim().parse(). guess в выражении относится к исходной переменной guess, которая содержала ввод в виде строки. Метод trim у экземпляра String удалит все пробелы в начале и конце, что мы должны сделать, прежде чем сможем преобразовать строку в u32, который может содержать только числовые данные. Пользователь должен нажать Enter, чтобы удовлетворить read_line и ввести свою догадку, что добавляет символ новой строки в строку. Например, если пользователь вводит 5 и нажимает Enter, guess выглядит так: 5\n. \n означает «новая строка». (В Windows нажатие Enter приводит к возврату каретки и новой строке, \r\n.) Метод trim удаляет \n или \r\n, оставляя только 5.
Метод parse для строк преобразует строку в другой тип. Здесь мы используем его для преобразования из строки в число. Нам нужно сообщить Rust точный числовой тип, который мы хотим, используя let guess: u32. Двоеточие (:) после guess говорит Rust, что мы будем аннотировать тип переменной. У Rust есть несколько встроенных числовых типов; u32, который мы видим здесь, — это беззнаковое 32-битное целое число. Это хороший выбор по умолчанию для небольшого положительного числа. Вы узнаете о других числовых типах в главе 3.
Кроме того, аннотация u32 в этом примере программы и сравнение с secret_number означают, что Rust выведет, что secret_number также должен быть u32. Так что теперь сравнение будет между двумя значениями одного типа!
Метод parse будет работать только с символами, которые логически могут быть преобразованы в числа, и поэтому может легко вызывать ошибки. Если, например, строка содержала A👍%, не было бы способа преобразовать это в число. Поскольку это может завершиться неудачей, метод parse возвращает тип Result, как и метод read_line (обсуждавшийся ранее в «Обработка возможных сбоев с помощью Result»). Мы будем обращаться с этим Result так же, снова используя метод expect. Если parse возвращает вариант 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. Запустите программу несколько раз, чтобы проверить разное поведение с разным вводом: угадайте число правильно, угадайте число, которое слишком велико, и угадайте число, которое слишком мало.
У нас теперь есть большая часть игры, но пользователь может сделать только одну догадку. Давайте это изменим, добавив цикл!
Разрешение нескольких догадок с помощью циклов
Ключевое слово loop создаёт бесконечный цикл. Мы добавим цикл, чтобы дать пользователям больше шансов угадать число:
Имя файла: 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 завершит игру, но, как вы заметите, так же сделает и ввод любого другого не-числового значения. Это не оптимально, мягко говоря; мы хотим, чтобы игра также останавливалась, когда угадано правильное число.
Выход после правильной догадки
Давайте запрограммируем игру на выход, когда пользователь выигрывает, добавив оператор break:
Имя файла: 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.
Обработка неверного ввода
Чтобы дополнительно улучшить поведение игры, вместо аварийного завершения программы, когда пользователь вводит не-число, давайте заставим игру игнорировать не-число, чтобы пользователь мог продолжать угадывать. Мы можем сделать это, изменив строку, где guess преобразуется из String в u32, как показано в листинге 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 — это перечисление с вариантами Ok и Err. Мы используем выражение match здесь, как и с результатом Ordering от метода cmp.
Если parse может успешно преобразовать строку в число, он вернёт значение Ok, содержащее полученное число. Это значение Ok будет соответствовать шаблону первой ветви, и выражение match просто вернёт значение num, которое parse произвел и поместил внутрь значения Ok. Это число окажется именно там, где мы хотим, в новой переменной guess, которую мы создаём.
Если parse не может преобразовать строку в число, он вернёт значение Err, содержащее дополнительную информацию об ошибке. Значение Err не соответствует шаблону Ok(num) в первой ветви match, но оно соответствует шаблону Err(_) во второй ветви. Подчёркивание _ — это значение-заполнитель; в этом примере мы говорим, что хотим соответствовать всем значениям Err, независимо от того, какую информацию они содержат внутри. Поэтому программа выполнит код второй ветви, continue, который говорит программе перейти к следующей итерации loop и запросить другую догадку. Так что, по сути, программа игнорирует все ошибки, которые parse может встретить!
Теперь всё в программе должно работать так, как ожидается. Давайте попробуем:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` 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::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;
}
}
}
}
На этом этапе вы успешно создали игру «Угадай число». Поздравляем!
Резюме
Этот проект был практическим способом познакомить вас со многими новыми концепциями Rust: let, match, функции, использование внешних крейтов и многом другом. В следующих нескольких главах вы узнаете об этих концепциях более подробно. Глава 3 охватывает концепции, которые есть в большинстве языков программирования, такие как переменные, типы данных и функции, и показывает, как использовать их в Rust. Глава 4 исследует владение, особенность, которая отличает Rust от других языков. Глава 5 обсуждает структуры и синтаксис методов, а глава 6 объясняет, как работают перечисления.