Программируем игру Угадайка

Давайте погрузимся в 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"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[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 [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Hello, world!

Команда run пригодится, когда необходимо ускоренно выполнить итерацию проекта, мы так собираемся сделать в этом проекте, быстро тестируя каждую итерацию, прежде чем перейти к следующей.

Снова откройте файл src/main.rs. Весь код вы будете писать в этом файле.

Обработка отгадки

Первая часть программы игры угадывания запрашивает ввод данных пользователем, обрабатывает их и проверяет, что вводимые данные имеют ожидаемую форму. Для начала мы позволим игроку ввести отгадку. Введите код из Листинга 2-1 в src/main.rs.

Файл: 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}");
}

Листинг 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 есть набор элементов, определённых в стандартной библиотеке, которые он добавляет в область видимости каждой программы. Этот набор называется прелюдией, и вы можете изучить его содержание в документации стандартной библиотеки .

Если тип, который требуется использовать, отсутствует в прелюдии, его нужно явно ввести в область видимости с помощью оператора 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 указывает на то, что операция прошла успешно, а внутри Ok находится успешно сгенерированное значение. Вариант Err означает, что операция не удалась, а 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: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished dev [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 = {} and y = {}", x, y);
}

Этот код напечатает x = 5 and y = 10.

Тестирование первой части

Давайте протестируем первую часть игры. Запустите её используя cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [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 crate с подобной функциональностью.

Использование пакета для получения дополнительной функциональности

Запомните, что крейт — это набор файлов с исходным кодом Rust. Проект, который мы создавали, представляет собой двоичный крейт, являющийся исполняемым файлом. rand крейт — это библиотечный крейт, содержащий код, предназначенный для использования в других программах, и не может быть выполнен сам по себе.

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

Имя файла: Cargo.toml

rand = "0.8.3"

В файле Cargo.toml всё, что следует за заголовком, является частью этой секции, которая продолжается до тех пор, пока не начнётся следующая. В [dependencies] вы сообщаете Cargo, от каких внешних крейтов зависит ваш проект и какие версии этих крейтов вам нужны. В этом случае мы указываем крейт rand со спецификатором семантической версии 0.8.3. Cargo понимает семантическое версионирование (иногда называемое SemVer), которое является стандартом для описания версий. Число 0.8.3 на самом деле является сокращением от ^0.8.3, что означает любую версию не ниже 0.8.3, но ниже 0.9.0.

Cargo рассчитывает, что эти версии имеют общедоступное API, совместимое с версией 0.8.3, и вы получите последние версии исправлений, которые по-прежнему будут компилироваться с кодом из этой главы. Не гарантируется, что версия 0.9.0 или выше будет иметь тот же API, что и в следующих примерах.

Теперь, ничего не меняя в коде, давайте создадим проект, как показано в Листинге 2-2.

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.3
  Downloaded libc v0.2.86
  Downloaded getrandom v0.2.2
  Downloaded cfg-if v1.0.0
  Downloaded ppv-lite86 v0.2.10
  Downloaded rand_chacha v0.3.0
  Downloaded rand_core v0.6.2
   Compiling rand_core v0.6.2
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.3
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s

Листинг 2-2: Результат выполнения cargo build после добавления крейта rand в качестве зависимости

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

Когда мы включаем внешнюю зависимость, Cargo берет последние версии всего, что нужно этой зависимости, из реестра (registry), который является копией данных с 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 [unoptimized + debuginfo] target(s) in 2.53 secs

Эти строки показывают, что Cargo обновляет сборку только на основании вашего крошечного изменения в файле src/main.rs. Поскольку зависимости не изменились, Cargo знает, что может повторно использовать ранее загруженные и скомпилированные зависимости.

Обеспечение воспроизводимых сборок с помощью файла Cargo.lock

В Cargo есть механизм, обеспечивающий возможность пересобрать все тот же артефакт каждый раз, когда вы или кто-либо другой собирает ваш код. Пока вы не укажете обратное, Cargo будет использовать только те версии зависимостей, которые были заданы ранее. Например, допустим, что на следующей неделе выходит версия 0.8.4 пакета rand , и эта версия содержит важное исправление ошибки, но также содержит регрессию, которая может сломать ваш код. Чтобы справиться с этим, Rust создаёт файл Cargo.lock при первом запуске cargo build, поэтому теперь он есть в каталоге guessing_game.

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

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

Если вы захотите обновить пакет, Cargo предоставляет команду update, которая игнорирует файл Cargo.lock и определяет последние версии, соответствующие вашим спецификациям из файла Cargo.toml. После этого Cargo запишет эти версии в файл Cargo.lock. Иначе, по умолчанию, Cargo будет искать только версии больше 0.8.3 , но при этом меньше 0.9.0. Если пакет rand имеет две новые версии 0.8.4 и 0.9.0, то при запуске cargo update вы увидите следующее:

$ cargo update
    Updating crates.io index
    Updating rand v0.8.3 -> v0.8.4

Cargo игнорирует релиз 0.9.0. В этот момент также появится изменение в файле Cargo.lock, указывающее на то, что версия rand, которая теперь используется, равна 0.8.4. Чтобы использовать rand версии 0.9.0 или любой другой версии из серии 0.9.x, необходимо обновить файл Cargo.toml следующим образом:

[dependencies]
rand = "0.9.0"

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

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

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

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

Имя файла: 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}");
}

Листинг 2-3: Добавление кода для генерации случайного числа

Сначала мы добавляем строку 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 [unoptimized + debuginfo] target(s) in 2.53s
     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 [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. Обратите внимание, что этот код ещё не удастся скомпилировать.

Имя файла: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

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!"),
    }
}

Листинг 2-4: Обработка возможных возвращаемых значений сравнения двух чисел

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

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

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

Давайте рассмотрим пример с выражением match, которое мы используем здесь. Предположим, что пользователь выбрал 50, а случайно сгенерированное секретное число на этот раз равно 38. Когда код сравнивает 50 с 38, метод cmp вернёт Ordering::Greater, поскольку 50 больше 38. Выражение match получает Ordering::Greater значение и начинает проверку шаблона каждой ветки. Оно просматривает шаблон первой ветви, Ordering::Less, и видит, что значение Ordering::Greater не соответствует Ordering::Less, поэтому игнорирует код в этой ветви и переходит к следующей. Следующий образец ветки — Ordering::Greater, который соответствует Ordering 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.3
   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 struct `String`, found integer
   |
   = note: expected reference `&String`
              found reference `&{integer}`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` due to 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 rand::Rng;
use std::cmp::Ordering;
use std::io;

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 Type"). Мы будем точно так же обрабатывать данный Result, вновь используя метод expect. Если parse вернёт вариант Result Err, так как не смог создать число из строки, вызов expect аварийно завершит игру и распечатает переданное ему сообщение. Если parse сможет успешно преобразовать строку в число, он вернёт вариант Result Ok, а expect вернёт число, полученное из значения Ok.

Давайте запустим программу теперь!

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

Хорошо! Несмотря на то, что были добавлены пробелы перед догадкой 76, программа все равно вывела пользовательскую догадку 76. Запустите программу несколько раз, чтобы проверить разное поведение при различных типах ввода: задайте число правильно, задайте слишком большое число и задайте слишком маленькое число.

Сейчас у нас работает большая часть игры, но пользователь может сделать только одно предположение. Давайте изменим это, добавив цикл!

Возможность нескольких догадок с помощью циклов

Ключевое слово loop создаёт бесконечный цикл. Мы добавляем цикл, чтобы дать пользователям больше шансов угадать число:

Имя файла: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

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 [unoptimized + debuginfo] target(s) in 1.50s
     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 '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!("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.

Файл: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

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;
            }
        }
    }
}

Листинг 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(_) второй ветки. Подчёркивание _ является всеохватывающим выражением. В этой ветке мы говорим, что хотим обработать совпадение всех значений Err, независимо от того, какая информация находится внутри Err. Поэтому программа выполнит код второй ветки, continue, который сообщает программе перейти к следующей итерации loop и запросить ещё одну догадку. В этом случае программа эффективно игнорирует все ошибки, с которыми может столкнуться parse!

Все в программе теперь должно работать как положено. Давайте попробуем:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 4.45s
     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 показан окончательный вариант кода.

Файл: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

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;
            }
        }
    }
}

Листинг 2-6: Полный код игры в угадывание

Итоги

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

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