Программирование игры в угадайку
Окунёмся в мир Rust, вместе создав прикладной проект! Эта глава познакомит вас с несколькими наиболее важными концепциями Rust, показав, как их использовать в реальной программе. Вы узнаете о let
, match
, методах, ассоциированных функциях, внешних крейтах и многом другом! В дальнейших главах мы изучим всё перечисленное подробнее. В этой главе вы прикоснётесь только к самым основам.
Мы реализуем классическую задачку для новичка: игру в угадывание числа. Она будет работать следующим образом: программа загадает случайное целое число от 1 до 100; затем она запросит у пользователя ввод догадки в консоль. Программа сообщит, больше или меньше ли догадка, чем загаданное число. Если догадка окажется верной, игра выведет поздравительное сообщение и завершится.
Создание нового проекта
Чтобы создать новый проект, перейдите в директорию projects, созданную вами в Главе 1, и воспользуйтесь Cargo, вот так:
$ cargo new guessing_game
$ cd guessing_game
Первая команда (cargo new
) принимает имя проекта (guessing_game
) в качестве первого аргумента. Вторая команда осуществляет переход в директорию проекта.
Посмотрим на сгенерированный файл Cargo.toml:
Файл: Cargo.toml
[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"
[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.20s
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;
Эта строка создаёт новую переменную под названием apples
и связывает с ней значение 5. По умолчанию, переменные в Rust неизменяемы, то есть однажды связав их со значением, мы больше не сможем его изменить. Мы обсудим это подробнее в разделе "Переменные и изменяемость" Главы 3. Чтобы создать изменяемую переменную, мы добавим mut
перед именем переменной:
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);
}
Если бы мы не испортировали библиотеку 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!("Угадайте число!");
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
означает, что операцию не удалось исполнить, и содержит информацию об ошибке.
Для значений типа Result
реализовано несколько методов. Например — метод expect
method. Если этот метод вызывается на Err
, он вызовет сбой программы и выведет на экран сообщение, переданное ему как аргумент. (Если метод read_line
возвращает Err
, то скорее всего, произошла какая-то системная ошибка.) Если метод expect
будет вызван на Ok
, он вернёт значение, хранимое в 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 предупреждает вас, что: 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);
}
Эта строчка печатает строку, содержащую пользовательский ввод. Пара фигурных скобок {}
— это метка подстановки. Представьте, что {}
— это клешни крабика, держащего между ними значение. Если вам нужно напечатать значение, содержащееся в переменной, вы можете заключить его сразу в фигурные скобки. Если же нужно напечатать значения каких-то выражений и вы не хотите записывать их в переменные, то вы можете через запятую перечислить выражения после текстовой строки — они будут подставлены в метки подстановки в том же порядке, в каком вы их перечислили. Вот так будет выглядеть вывод значения переменной и выражения одним вызовом println!
:
#![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
Первая часть программы завершена и работает, как задумано: мы принимаем ввод с клавиатуры и затем печатаем его.
Генерация секретного числа
Далее! Нам нужно сгенерировать секретное число, которое пользователь и будет пытаться угадать. Секретное число должно быть разным от игры к игре, чтобы в неё действительно можно было играть. Мы будем использовать случайное число от 1 до 100, так что игра не будет сильно сложной. Функционал работы со случайными числами всё ещё не входит в стандартную библиотеку Rust, однако Команда Rust предоставляет крейт rand
, реализующий всё нужное.
Использование крейтов для расширения возможностей
Помните, что крейт — это набор исходных файлов кода на языке Rust. Проект, который мы сейчас делаем, — это бинарный (binary) крейт, то есть собираемый в исполняемую программу. Крейт rand
— это библиотечный (library) крейт, то есть содержащий код, который встраивается в другие программы и сам по себе не запускается.
Управление внешними крейтами — это конёк Cargo. Чтобы получить возможность использовать крейт rand
, нам нужно отредактировать файл Cargo.toml, чтобы включить этот крейт как зависимость. Откройте этот файл и добавьте строчки ниже в его конец (то есть под [dependencies]
— заголовком раздела зависимостей). Убедитесь, что вы подключили версию rand
ровно такую же, что и мы, иначе наши примеры могут не заработать у вас.
Файл: Cargo.toml
[dependencies]
rand = "0.8.5"
К разделам в файле Cargo.toml относится всё, что находится между заголовком раздела и заголовком следующего раздела. В [dependencies]
вы указываете, какие внешние крейты каких версий требует ваш проект. В данном случае, мы конкретизируем версию крейта rand
с помощью спецификатора 0.8.5
. Cargo опирается на Семантическое версионирование (Semantic Versioning, SemVer) — систему записи версий программ. Спецификатор версии 0.8.5
на самом деле является сокращением для ^0.8.5
, означающего любую версию программы не более раннюю, чем 0.8.5, но не более новую, чем 0.9.0.
Cargo рассчитывает, что версии, входящие в данный промежуток, имеют API, совместимый с API крейта версии 0.8.5. Это позволяет подключать более новые версии крейта, и при этом даёт гарантию, что приведённые в этой главе примеры будут компилироваться. Любая версия от 0.9.0 и выше не обязательно будет иметь такой же API, как используемый в примерах далее.
Теперь, ничего не меняя в программе, давайте соберём проект, как показано в Листинге 2-2.
$ cargo build
Updating crates.io index
Locking 16 packages to latest compatible versions
Adding wasi v0.11.0+wasi-snapshot-preview1 (latest: v0.13.3+wasi-0.2.2)
Adding zerocopy v0.7.35 (latest: v0.8.9)
Adding zerocopy-derive v0.7.35 (latest: v0.8.9)
Downloaded syn v2.0.87
Downloaded 1 crate (278.1 KB) in 0.16s
Compiling proc-macro2 v1.0.89
Compiling unicode-ident v1.0.13
Compiling libc v0.2.161
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.37
Compiling syn v2.0.87
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 3.69s
Если вы проделаете всё на своей машине, вы можете увидеть другие (но всё ещё обратно совместимые; спасибо 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.lock
В Cargo есть механизм, гарантирующий каждому (в том числе, и вам) пересобрать ваш проект с в точности одинаковыми зависимостями: Cargo будет использовать только те версии зависимостей, которые вы определили, пока не укажете обратное. Например, допустим, что на следующей неделе выходит версия 0.8.6 крейта rand
, и эта версия содержит важные исправления ошибок, но также включает прекращение поддержки некоторой части API, которую использовали вы. Чтобы обработать такой случай, 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
Updating rand v0.8.5 -> v0.8.6
Cargo игнорирует релиз 0.9.0. Если вы посмотрите в файл Cargo.lock, вы также увидите, что используемая теперь версия крейта rand
— 0.8.6. Чтобыц использовать версию 0.9.0 (или любую другую версию 0.9.x), вам нужно обновить файл Cargo.toml, чтобы он выглядел вот так:
[dependencies]
rand = "0.9.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}");
}
Во-первых, мы добавили строчку use rand::Rng;
. Трейт Rng
определяет методы, реализуемые генератором случайных чисел, так что чтобы использовать эти методы, этот трейт должен быть в области видимости. Трейты будут рассмотрены в Главе 10.
Во-вторых, мы добавили две строчки в середине. В первой строчке мы вызываем функцию rand::thread_rng
, возвращающую нам генератор случайных чисел (локальный для текущего потока исполнения и запущенный операционной системой). Затем мы вызываем метод gen_range
генератора случайных чисел. Этот метод определён трейтом Rng
, который мы добавили в область видимости инструкцией use rand::Rng;
. Метод gen_range
принимает в качестве аргумента выражение диапазона значений и возвращает случайное число из этого диапазона. Использованное выражение диапазона значений имеет вид start..=end
; оно включает в себя как нижнюю, так и верхнюю границы. Выражение 1..=100
тем самым означает, что нам требуется случайное число от 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`
Угадайте число!
Загаданное число: 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 rand::Rng;
use std::cmp::Ordering;
use std::io;
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!("Вы победили!"),
}
}
Во-первых, вы добавили ещё одну инструкцию с use
, подключив в область видимости тип из стандартной библиотеки под названием std::cmp::Ordering
. Тип Ordering
— это перечисление, вариантами которого являются Less
, Greater
и Equal
. Они отвечают всем возможным результатам сравнения двух значений.
Затем, мы добавили пять новых строчек в конце, использующих тип Ordering
. Метод cmp
сравнивает два значения; он может быть вызван на всём, что может быть сравнимо. Он берёт ссылку на то, с чем вы хотите сравнить значение. В нашем случае он сравнивает guess
с secret_number
. Метод возвращает вариант перечисления Ordering
(которое мы ранее подключили в область видимости инструкцией с use
). Мы используем выражение match
для того, чтобы выбрать что делать в зависимости от варианта Ordering
, возвращённого вызовом метода cmp
на guess
и secret_number
.
Выражение match
состоит из ветвей. Каждая ветвь начинается с шаблона, с которым сопоставляется переданное в конструкцию match
, и заканчивается кодом, который исполнится, если значение успешно сопоставится с шаблоном. Значение сравнивается с шаблонами в порядке их перечисления. Шаблоны и конструкция match
— это очень мощные средства языка Rust: они дают вам возможность 1) учитывать различные развития событий и 2) делать это гарантированно. Эти особенности будут рассмотрены в Главе 6 и Главе 9, соответственно.
Рассмотрим наш вышеприведённый пример с выражением 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:22:21
|
22 | 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
--> file:///home/.rustup/toolchains/1.82/lib/rustlib/src/rust/library/core/src/cmp.rs:838:8
|
838 | fn cmp(&self, other: &Self) -> Ordering;
| ^^^
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 сравнить значения строкового и числового типов.
В конечном счёте, мы хотим преобразовать строку String
, получаемую программой из ввода, в числовой тип, который мы и сможем сравнить с секретным числом. Мы сделаем это, добавив одну строчку в тело функции main
...
Файл: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
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.
В добавок, аннотирование guess
типом 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`
Угадайте число!
Загаданное число: 58
Введите свою догадку.
76
Вы предположили: 76
Слишком большое!
Отлично! Пусть программа и добавляет пару пробелов перед догадкой, программа всё-таки понимает, что пользователь предположил число 76. Запустите программу несколько раз, чтобы проверить разное поведение с разными введёнными числами: правильную догадку, слишком большую догадку и слишком маленькую.
Большая часть игры готова, но пользователь пока что может сделать дать одну догадку. Изменим это, добавив цикл!
Возможность дать догадку не один раз с помощью циклов
Ключевое слово loop
создаёт бесконечный цикл. Мы добавим цикл, чтобы дать пользователю больше попыток угадать число:
Файл: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
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!("Вы победили!"),
}
}
}
Как вы можете видеть, мы переместили весь код обработки догадки в цикл. Убедитесь, что отбили строчки от левого края ещё четырьмя пробелами каждую, и снова запустите программу. Программа теперь будет спрашивать догадку бесконечно, и это создало нам новую проблему: игра никогда не закончится!
Пользователь, конечно, всегда может прервать исполнение программы сочетанием клавиш 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`
Угадайте число!
Загаданное число: 59
Введите свою догадку.
45
Вы предположили: 45
Слишком маленькое!
Введите свою догадку.
60
Вы предположили: 60
Слишком большое!
Введите свою догадку.
59
Вы предположили: 59
Вы победили!
Введите свою догадку.
quit
thread 'main' panicked at 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Ввод слова quit
остановит программу, но как вы можете заметить, это произойдёт при любом нечисловом вводе. Такое поведение программы, мягко говоря, неоптимально. Мы хотим, чтобы игра завершалась, когда догадка игрока оказывается правильной.
Завершение игры после правильной догадки
Запрограммируем выход из игры, когда игрок побеждает. Для этого, используем инструкцию break
:
Файл: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Угадайте число!");
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
.
Обработка неправильного ввода
Чтобы сделать поведение программы ещё лучше, заменим преднамеренный вылет программы при нечисловом вводе на игнорирование такого ввода, дадим игроку возможность продолжить. Мы сделаем это, изменив строчку, где guess
конвертируется из String
в u32
. Изменение показано в Листинге 2-5.
use rand::Rng;
use std::cmp::Ordering;
use std::io;
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
.
Если 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`
Угадайте число!
Загаданное число: 61
Введите свою догадку.
10
Вы предположили: 10
Слишком маленькое!
Введите свою догадку.
99
Вы предположили: 99
Слишком большое!
Введите свою догадку.
foo
Введите свою догадку.
61
Вы предположили: 61
Вы победили!
Замечательно! Нам нужно внести только ещё одну небольшую последнюю правку. Вспомним, что программа всё ещё печатает загаданное число. Это было полезно для тестирования, но для игры это бессмысленно. Удалим строчку с println!
, которая печатает загаданное число. В Листинге 2-6 приведён окончательный код программы.
use rand::Rng;
use std::cmp::Ordering;
use std::io;
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 объясняет работу с перечислениями.