Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Программирование игры «Угадай число»

Давайте погрузимся в Rust через практический проект! Эта глава познакомит вас с несколькими распространёнными концепциями Rust, показывая, как использовать их в реальной программе. Вы познакомитесь с let, match, методами, связанными функциями (associated functions), внешними крейтами и многим другим! В следующих главах мы изучим эти идеи более подробно. В этой главе вы просто познакомитесь с основами на практике.

Мы реализуем классическую задачу для начинающих программистов: игру «Угадай число». Вот как она работает: программа генерирует случайное целое число от 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.

Filename: 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}");
}
Listing 2-1: Код, который получает предположение от пользователя и выводит его

Этот код содержит много информации, поэтому давайте разберём его построчно. Чтобы получать ввод пользователя и затем выводить результат, нам необходимо добавить в область видимости библиотеку ввода-вывода io. Библиотека io поставляется со стандартной библиотекой, известной как std:

use std::io;

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

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

    let mut guess = String::new();

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

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

По умолчанию Rust имеет набор элементов стандартной библиотеки, которые автоматически добавляются в область видимости каждой программы. Этот набор называется prelude, и вы можете посмотреть его содержимое в документации стандартной библиотеки.

Если нужный вам тип отсутствует в prelude, его необходимо явно добавить в область видимости с помощью инструкции use. Использование библиотеки std::io предоставляет множество полезных возможностей, включая получение ввода пользователя.

Как вы уже видели в Главе 1, функция main является точкой входа в программу:

use std::io;

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

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

    let mut guess = String::new();

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

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

Синтаксис fn объявляет новую функцию; круглые скобки () означают отсутствие параметров; а фигурная скобка { начинает тело функции.

Как вы также узнали в Главе 1, println! — это макрос, выводящий строку на экран:

use std::io;

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

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

    let mut guess = String::new();

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

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

Этот код выводит приглашение, сообщающее о сути игры и предлагающее пользователю ввести значение.

Сохранение значений с помощью переменных

Теперь создадим переменную для хранения пользовательского ввода:

use std::io;

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

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

    let mut guess = String::new();

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

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

Теперь программа становится интереснее! В этой небольшой строке происходит многое. Мы используем инструкцию let для создания переменной. Вот ещё один пример:

let apples = 5;

Эта строка создаёт новую переменную с именем apples и связывает её со значением 5. В Rust переменные по умолчанию неизменяемы (immutable), то есть после присвоения значения оно больше не может изменяться. Мы подробно рассмотрим эту концепцию в разделе «Переменные и изменяемость» Главы 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 является связанной функцией (associated function) типа 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 состоит в том, чтобы взять всё, что пользователь вводит через стандартный ввод, и добавить это в строку (не перезаписывая её содержимое), поэтому мы передаём эту строку в качестве аргумента. Строковый аргумент должен быть изменяемым, чтобы метод мог изменять его содержимое.

Символ & показывает, что аргумент является ссылкой (reference), которая даёт возможность нескольким частям кода получать доступ к одному и тому же участку данных без необходимости многократного копирования этих данных в память. Ссылки являются довольно сложной возможностью, и одно из главных преимуществ Rust заключается в том, насколько безопасно и удобно они используются. Для завершения этой программы вам пока не нужно знать все подробности. Сейчас достаточно помнить, что, как и переменные, ссылки по умолчанию являются неизменяемыми. Поэтому необходимо писать &mut guess, а не &guess, чтобы сделать ссылку изменяемой. (Глава 4 объясняет ссылки более подробно.)

Обработка возможных ошибок с помощью Result

Мы всё ещё работаем с этой строкой кода. Сейчас мы обсуждаем третью строку текста, но обратите внимание, что она всё ещё является частью одной логической строки кода. Следующая часть выглядит так:

use std::io;

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

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

    let mut guess = String::new();

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

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

Мы могли бы записать этот код следующим образом:

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

Однако одну длинную строку трудно читать, поэтому её лучше разделить. Часто полезно добавлять переносы строк и дополнительные пробелы, чтобы разбивать длинные конструкции при использовании синтаксиса .method_name(). Теперь рассмотрим, что именно делает эта строка.

Как упоминалось ранее, read_line помещает всё, что вводит пользователь, в переданную строку, но также возвращает значение Result. Result представляет собой перечисление (enumeration), часто называемое enum, то есть тип, который может находиться в одном из нескольких возможных состояний. Каждое возможное состояние называется вариантом (variant).

В Главе 6 перечисления будут рассмотрены более подробно. Назначение типов Result состоит в кодировании информации об обработке ошибок.

Вариантами Result являются Ok и Err. Вариант Ok указывает, что операция завершилась успешно, и содержит успешно полученное значение. Вариант Err означает, что операция завершилась неудачей, и содержит информацию о том, как или почему произошла ошибка.

Значения типа Result, как и значения любого другого типа, имеют определённые для них методы. Экземпляр Result имеет метод expect, который можно вызвать. Если данный экземпляр Result содержит значение Err, expect приведёт к аварийному завершению программы и отобразит сообщение, переданное ему в качестве аргумента. Если метод read_line возвращает Err, скорее всего, ошибка возникла на уровне операционной системы.

Если же данный экземпляр Result содержит значение Ok, expect извлечёт значение, находящееся внутри Ok, и вернёт только его, чтобы вы могли использовать его дальше. В данном случае этим значением является количество байтов, введённых пользователем.

Если не вызвать expect, программа скомпилируется, но вы получите предупреждение:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` 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.

<!-- Old headings. Do not remove or links may break. -->
<a id="using-a-crate-to-get-more-functionality"></a>

### Расширение функциональности с помощью крейта

Напомним, что крейт представляет собой набор файлов исходного кода Rust.
Проект, который мы создаём, является бинарным крейтом (binary crate), то
есть исполняемой программой. Крейт `rand` является библиотечным крейтом
(library crate), который содержит код, предназначенный для использования
в других программах и не может выполняться самостоятельно.

Именно при работе с внешними крейтами Cargo проявляет свои сильные стороны.
Прежде чем писать код, использующий `rand`, необходимо изменить файл
_Cargo.toml_, добавив крейт `rand` как зависимость. Откройте этот файл
и добавьте следующую строку в конец, ниже заголовка секции
`[dependencies]`, который Cargo создал автоматически. Убедитесь, что
указываете `rand` точно так же, как показано здесь, включая номер версии,
иначе примеры кода из этого руководства могут не работать:

<!-- When updating the version of `rand` used, also update the version of
`rand` used in these files so they all match:
* ch07-04-bringing-paths-into-scope-with-the-use-keyword.md
* ch14-03-cargo-workspaces.md
-->

<span class="filename">Имя файла: Cargo.toml</span>

```toml
[dependencies]
rand = "0.8.5"
```

В файле _Cargo.toml_ всё, что находится после заголовка, относится к данной
секции до тех пор, пока не начинается следующая секция. В `[dependencies]`
вы сообщаете Cargo, от каких внешних крейтов зависит ваш проект и какие
версии этих крейтов вам необходимы.

В данном случае мы указываем крейт `rand` со спецификатором семантической
версии `0.8.5`. Cargo понимает
[семантическое версионирование][semver]<!-- ignore -->
(часто называемое _SemVer_), которое является стандартом записи номеров
версий. Спецификатор `0.8.5` на самом деле является сокращением для
`^0.8.5`, что означает любую версию не ниже `0.8.5`, но меньше `0.9.0`.

Cargo считает, что эти версии имеют публичный API, совместимый с версией 0.8.5, и данная спецификация гарантирует получение последнего исправления (patch release), которое по-прежнему будет компилироваться с кодом этой главы. Для любой версии 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
Listing 2-2: Вывод cargo build после добавления крейта rand в качестве зависимости

Вы можете увидеть другие номера версий (но они всё равно будут совместимы с кодом благодаря SemVer), другие строки (в зависимости от операционной системы), а также иной порядок строк.

Когда мы добавляем внешнюю зависимость, Cargo получает из реестра (registry) последние версии всего, что необходимо этой зависимости. Реестр представляет собой копию данных с 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, содержащая важное исправление ошибки, но также включающая регрессию, которая ломает ваш код. Для решения этой проблемы Rust создаёт файл Cargo.lock при первом запуске cargo build, поэтому теперь в каталоге guessing_game появляется этот файл.

Когда вы собираете проект впервые, Cargo определяет все версии зависимостей, которые удовлетворяют указанным условиям, а затем записывает их в файл Cargo.lock. При последующих сборках Cargo обнаружит существование Cargo.lock и будет использовать указанные там версии вместо повторного поиска подходящих вариантов. Это позволяет автоматически получать воспроизводимые сборки. Иными словами, благодаря файлу Cargo.lock ваш проект останется на версии 0.8.5, пока вы явно не выполните обновление.

Обновление крейта для получения новой версии

Когда вы действительно захотите обновить крейт, Cargo предоставляет команду update, которая игнорирует файл Cargo.lock и определяет самые новые версии, соответствующие ограничениям в Cargo.toml. Затем Cargo запишет эти версии в файл Cargo.lock. В остальных случаях Cargo по умолчанию будет искать только версии выше 0.8.5, но ниже 0.9.0. Если крейт rand выпустил две новые версии 0.8.6 и 0.999.0, вы увидите следующее после выполнения cargo update:

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

Cargo проигнорирует выпуск 0.999.0. В этот момент вы также заметите изменение в файле Cargo.lock, указывающее, что используемая версия крейта rand теперь 0.8.6. Чтобы использовать rand версии 0.999.0 или любую версию серии 0.999._x_, потребуется изменить файл Cargo.toml следующим образом (на самом деле не выполняйте это изменение, поскольку дальнейшие примеры предполагают использование rand версии 0.8):

[dependencies]
rand = "0.999.0"

При следующем запуске cargo build Cargo обновит реестр доступных крейтов и заново проверит требования к rand согласно новой версии, которую вы указали.

О Cargo и его экосистеме можно рассказать гораздо больше, и мы поговорим об этом в Главе 14, но пока этого достаточно. Cargo значительно упрощает повторное использование библиотек, поэтому Rustacean могут создавать небольшие проекты, собранные из множества пакетов.

Генерация случайного числа

Теперь начнём использовать rand для генерации числа, которое нужно угадать. Следующим шагом будет обновление файла src/main.rs, как показано в листинге 2-3.

Filename: src/main.rs
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}");
}
Listing 2-3: Добавление кода для генерации случайного числа

Сначала мы добавляем строку use rand::Rng;. Трейт Rng определяет методы, которые реализуют генераторы случайных чисел, и этот трейт должен находиться в области видимости, чтобы мы могли использовать его методы. Глава 10 рассматривает трейты более подробно.

Далее мы добавляем две строки в середину программы. В первой строке вызывается функция rand::thread_rng, которая предоставляет конкретный генератор случайных чисел, который мы будем использовать: генератор, локальный для текущего потока выполнения и инициализируемый операционной системой.

Затем мы вызываем метод gen_range у генератора случайных чисел. Этот метод определён трейтом Rng, который мы добавили в область видимости с помощью инструкции use rand::Rng;. Метод gen_range принимает выражение диапазона (range expression) в качестве аргумента и генерирует случайное число внутри этого диапазона. Используемое здесь выражение диапазона имеет вид 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. Обратите внимание, что этот код пока ещё не будет компилироваться, и сейчас мы объясним почему.

Filename: src/main.rs
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!"),
    }
}
Listing 2-4: Обработка возможных результатов сравнения двух чисел

Сначала мы добавляем ещё одну инструкцию use, помещая в область видимости тип std::cmp::Ordering из стандартной библиотеки. Тип Ordering также является перечислением (enum) и содержит варианты Less, Greater и Equal. Это три возможных результата, которые могут возникнуть при сравнении двух значений.

Затем мы добавляем пять новых строк в нижнюю часть программы, использующих тип Ordering. Метод cmp сравнивает два значения и может быть вызван для любого типа, который поддерживает сравнение. Он принимает ссылку на значение, с которым необходимо выполнить сравнение. В данном случае происходит сравнение guess и secret_number. Затем метод возвращает вариант перечисления Ordering, которое мы импортировали с помощью инструкции use.

Мы используем выражение match, чтобы определить, что делать дальше, основываясь на том, какой вариант Ordering был возвращён вызовом cmp для значений guess и secret_number.

Выражение match состоит из ветвей (arms). Каждая ветвь включает шаблон (pattern), с которым производится сопоставление, и код, который будет выполнен, если значение, переданное в match, соответствует этому шаблону. Rust берёт значение, переданное в match, и по очереди проверяет каждый шаблон ветвей. Шаблоны и конструкция match являются мощными возможностями Rust: они позволяют выразить различные ситуации, которые могут возникнуть в программе, и гарантируют, что все они будут обработаны. Эти возможности подробно рассматриваются соответственно в Главе 6 и Главе 19.

Разберём пример использования выражения match. Предположим, пользователь ввёл 50, а случайно сгенерированное секретное число равно 38.

Когда код сравнивает 50 и 38, метод cmp возвращает Ordering::Greater, потому что 50 больше 38. Выражение match получает значение Ordering::Greater и начинает проверять шаблоны ветвей. Сначала проверяется шаблон первой ветви — Ordering::Less. Так как Ordering::Greater не совпадает с Ordering::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/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/cmp.rs:979:8

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

Основная часть ошибки сообщает о наличии несоответствия типов (mismatched types). Rust использует строгую статическую систему типов. Однако он также поддерживает вывод типов (type inference). Когда мы написали 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 позволяет перекрывать (shadow) предыдущее значение guess новым. Затенение (shadowing) позволяет повторно использовать имя переменной guess, вместо того чтобы создавать две разные переменные, например guess_str и guess. Мы рассмотрим это подробнее в Главе 3, а пока достаточно знать, что эта возможность часто используется, когда необходимо преобразовать значение из одного типа в другой.

Мы связываем новую переменную с выражением guess.trim().parse(). Переменная guess внутри выражения ссылается на исходную переменную guess, которая содержала введённую строку. Метод trim для экземпляра String удаляет пробельные символы в начале и конце строки, что необходимо перед преобразованием строки в u32, который может содержать только числовые данные.

Пользователь должен нажать Enter, чтобы завершить ввод через read_line, и это добавляет символ новой строки в строку. Например, если пользователь вводит 5 и нажимает Enter, содержимое guess выглядит следующим образом: 5\n. Символ \n обозначает перевод строки (“newline”). В 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, как и read_line, возвращает тип Result (о котором говорилось ранее в разделе «Обработка возможных ошибок с помощью 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.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

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

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

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

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

        let mut guess = String::new();

        // --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;
            }
        }
    }
}
Listing 2-5: Игнорирование нечислового ввода и запрос нового предположения вместо аварийного завершения программы

Мы заменяем вызов 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(_) во второй ветви. Символ подчёркивания _ является универсальным шаблоном (catch-all value); в данном примере мы говорим, что хотим обработать любые значения 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 показывает итоговый код.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

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

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

    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;
            }
        }
    }
}
Listing 2-6: Полный код игры «Угадай число»

На этом этапе вы успешно создали игру «Угадай число». Поздравляем!

Итоги

Этот проект стал практическим способом познакомить вас со многими новыми концепциями Rust: let, match, функциями, использованием внешних крейтов и другими. В следующих главах вы изучите эти концепции подробнее. Глава 3 рассматривает концепции, которые есть в большинстве языков программирования, такие как переменные, типы данных и функции, и показывает, как использовать их в Rust. Глава 4 исследует владение (ownership) — возможность, которая отличает Rust от других языков. Глава 5 рассматривает структуры и синтаксис методов, а Глава 6 объясняет, как работают перечисления.