Игра "Угадай число"

Давайте погрузимся в 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"
authors = ["Your Name <you@example.com>"]
edition = "2018"

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

[dependencies]

Если информация об авторе, полученная Cargo из вашей среды окружения является неверной, то исправьте её в файле и снова сохраните.

Как вы уже видели в Главе 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 (input/output) в область видимости. Библиотека io подключается из стандартной библиотеки (которая известна как std):

use std::io;

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

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

    let mut guess = String::new();

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

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

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

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

use std::io;

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

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

    let mut guess = String::new();

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

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

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

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

use std::io;

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

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

    let mut guess = String::new();

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

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

Этот код печатает название игры и приглашает пользователя ввести число.

Хранение данных с помощью переменных

Далее, мы создаём место хранения введённых игроком данных:

use std::io;

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

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

    let mut guess = String::new();

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

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

Теперь программа становится интересней. В этой маленькой строчке происходит много всего. Прежде всего здесь есть выражение let, которое используется для создания переменной. Вот другой пример:

let foo = bar;

В этой строке создаётся переменная с именем foo, которая связывается со значением из переменной bar. Особенностью языка Rust является то, что переменные по умолчанию неизменяемые. Мы рассмотрим эту концепцию более детально в разделе "Переменные и изменяемость" Главы 3. Этот пример показывает, как использовать ключевое слово mut перед именем переменной для того, чтобы сделать переменную изменяемой:

let foo = 5; // неизменяемая
let mut bar = 5; // изменяемая

Примечание: синтаксис из символов // обозначает начало комментария, который продолжается дальше до конца строки. Rust игнорирует все, что размещено в комментариях, о чем более подробно рассказывается в Главе 3.

Вернёмся к программе игры по угадыванию числа. Теперь вы знаете, что let mut guess объявит изменяемую переменную с именем guess. На другой стороне знака равенства ( = ) находится значение, с которым переменная guess связана, а само значение является результатом вызова функции String::new. Данная функция возвращает новый экземпляр типа String. String - это строковый тип, предоставляемый стандартной библиотекой, который представляет собой расширяемый текст в кодировке UTF-8.

Синтаксис :: в строке ::new показывает что new является ассоциированной функцией для типа String. Ассоциированные функции реализуются в каком-либо типе, в данном случае в 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);
}

Если бы мы не добавили строку use std::io в начало программы, мы смогли бы вызвать эту функцию в коде как std::io::stdin. Функция stdin возвращает экземпляр std::io::Stdin, который является типом, предоставляющим обработку стандартного ввода из вашего терминала.

Следующая часть кода, .read_line(&mut guess), вызывает метод read_line обработчика стандартного ввода для получения данных от пользователя. Мы передаём в функцию read_line один аргумент: &mut guess.

Работа функции read_line состоит в том, чтобы взять любые символы введённые пользователем из стандартного потока ввода и разместить эти данные в строку, которая была передана как её аргумент. Строковый аргумент должен быть изменяемым, чтобы метод мог изменить содержимое строки добавлением данных вводимых пользователем.

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

Обработка потенциальных ошибок с помощью типа Result

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

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

Когда вы вызываете метод с синтаксисом .foo(), часто следует добавить перевод строки и пробелы, чтобы разбить длинные выражения на логические части. Мы можем переписать этот код так:

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

Тем не менее, одна строка сложнее читается, поэтому лучшим решением будет разделить её: две строки для двух вызовов функций. Давайте теперь объясним, что эта строка делает.

Как упоминалось ранее, функция read_line помещает символы пользовательского ввода в переменную переданную в неё, но она также имеет возвращаемый тип - в этом случае это io::Result. В стандартной библиотеке Rust имеется несколько типов с именем Result: обобщённый тип Result, а также конкретные версии для под модулей, такие как io::Result.

Типы Result являются перечислениями, часто называемыми enums. Перечисление имеет фиксированное множество возможных значений, которые называются вариантами перечисления. Глава 6 расскажет про перечисления более детально.

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

Предназначение типа Result состоит в кодировании информации для обработки ошибки. Значения типа Result, как и значения любых типов, имеют определённые в них методы. Экземпляр io::Result имеет метод expect, который можно вызвать. Если экземпляр io::Result является значением Err, то метод expect вызовет сбой программы и покажет сообщение, которое Вы передали как аргумент в expect. Если метод read_line вернёт Err, это вероятно будет ошибка, происходящая от операционной системы. Если экземпляр io::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: 1 warning emitted

    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 и 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.

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

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

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

Файл: Cargo.toml

[dependencies]
rand = "0.8.3"

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

Давайте теперь соберём наш проект без каких-либо правок кода, как показано в листинге 2-2.

$ cargo build
    Updating crates.io index
  Downloaded rand v0.5.5
  Downloaded libc v0.2.62
  Downloaded rand_core v0.2.2
  Downloaded rand_core v0.3.1
  Downloaded rand_core v0.4.2
   Compiling rand_core v0.4.2
   Compiling libc v0.2.62
   Compiling rand_core v0.3.1
   Compiling rand_core v0.2.2
   Compiling rand v0.5.5
   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 подгружает последние версии всех нужных зависимостей из реестра, который в свою очередь является копией данных с Crates.io. Crates.io - это ресурс, где разработчики выкладывают свои проекты в виде крейтов Rust для общего пользования.

После обновления вышеупомянутого реестра, Cargo проверяет секцию [dependencies] и загружает указанные в этой секции, но ещё не скачанные крейты. В нашем случае, несмотря на то, что мы указали лишь rand как зависимость, Cargo также загрузил libc и rand_core, поскольку работа 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 будет использовать только указанные вами версии зависимостей, пока вы не сообщите ему о необходимости использовать иные. Например, что произойдёт, если на следующей неделе выйдет крейт rand с версией 0.5.6, содержащей исправление важной ошибки, и другое исправление, которое сломает ваш код?

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

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

Когда вы хотите обновить крейт, Cargo предоставляет другую команду, update, которая проигнорирует файл Cargo.lock и выяснит все последние версии, которые соответствуют вашим спецификациям из Cargo.toml файла. Если это работает, Cargo запишет эти версии в файл Cargo.lock.

Но по умолчанию Cargo будет искать только версии больше 0.5.5 и меньше, чем 0.6.0. Если rand крейт выпустил две новые версии, 0.5.6 и 0.6.0, вы увидите следующее при запуске команды cargo update:

$ cargo update
    Updating crates.io index
    Updating rand v0.5.5 -> v0.5.6

В этот момент вы также заметите изменение в файле Cargo.lock, обращающее ваше внимание на то, что версия крейта rand, которую вы теперь используете - 0.5.6.

Если вы вдруг захотите использовать rand версии 0.6.0 или любой другой версии в серии 0.6.x, то в файле Cargo.toml надо будет внести правки на подобие этих:

[dependencies]
rand = "0.6.0"

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

На данный момент это всё, что вам нужно знать про Cargo. Можно много рассказать про Cargo и его экосистему, но этим мы займёмся в Главе 14. Как вы видите, Cargo позволяет с лёгкостью повторно использовать библиотеки, благодаря этому Rust разработчики имеют возможность писать компактные проекты, которые базируются на других пакетах.

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

Теперь, после добавления крейта rand в Cargo.toml, давайте начнём использовать 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..101);

    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: use rand::Rng. Типаж (trait) Rng определяет методы, которые генераторы случайных чисел реализуют для своих задач. Этот типаж должен быть включён в области видимости, чтобы использовать его методы. Глава 10 расскажет о типажах более подробно.

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

    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. Как и Result, тип Ordering тоже является перечислением, но вариантами для Ordering являются Less, Greater и Equal. Это варианты: три результата, которые возможны при сравнении двух значений.

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

Выражение match состоит из веток. Ветка состоит из шаблона и кода, который должен быть выполнен, если значение заданное в начале выражения 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, поэтому код в данной ветке будет проигнорирован, а выражение match перейдёт к следующей ветке. Шаблон следующей ветки, 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.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}`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game`

To learn more, run the command again with --verbose.

Основная часть ошибки утверждает, что произошло несовпадение типов. Rust имеет строгую, статическую систему типов. Тем не менее, в нем также есть выведение типов. Когда мы написали let mut guess = String::new(), Rust смог вывести, что guess должно быть типа String и нам не пришлось писать тип. Но переменная secret_number является числовым типом. Несколько числовых типов могут иметь значение от 1 до 100: i32 - 32-битное знаковое число; u32 - беззнаковое 32-битное число; i64 - знаковое 64-битное, а также другие. По умолчанию для чисел Rust определяет тип i32, он и будет типом у secret_number до тех пор, пока вы где-нибудь не добавите информацию о типе, что заставить Rust вывести другой числовой тип для этой переменной. Итак, secret_number имеет числовой тип i32, а guess строковой тип String: причина ошибки в том, что Rust не может сравнить строковый и числовой тип.

В конечном счёте, мы захотим преобразовать значение типа String, считываемые программой из стандартного ввода, в тип действительного числа, чтобы было можно сравнивать его в числовом виде с загаданным числом secret_number. Мы можем это сделать, добавив следующие две строки в тело функции 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..101);

    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 с выражением guess.trim().parse(). Переменная guess в выражении ссылается на исходную переменную guess которая была типа String при вводе данных. Метод trim на экземпляре (или значении типа)String удалит все пробелы в начале и конце. Хоть u32 и может содержать только числовые символы, но пользователь должен нажать Enter, чтобы удовлетворить механизм работы метода read_line. Когда пользователь нажимает Enter, к числовому символу в конец буде добавлен символ новой строки. Например, если пользователь вводит 5 и нажимает Enter, значение guess выглядит так: 5\n. Символ \n представляет символ "новая строка" как результат нажатия Enter. Метод trim исключает \n, и в результате мы получаем 5.

Метод parse у строк разбирает строку в число некоторого типа. Поскольку этот метод может сконструировать различные типы чисел, то нужно указать точный тип числа, который мы хотим получить с его помощью, косвенно мы делаем это так: let guess: u32. Двоеточие (:) после guess, говорит Rust что мы аннотировали переменную типом. Rust имеет несколько встроенных числовых типов; здесь вы видите u32 являющийся 32-битным без знаковым целым числом. Это хороший выбор по умолчанию для небольшого положительного числа. Вы узнаете о других типах чисел в Главе 3. Кроме того, аннотация переменной типом u32 в этом примере программы и последующее сравнение переменной guess типа u32 с secret_number вкупе означает, что Rust выведет что secret_number должен иметь так же тип u32, чтобы удовлетворить требованию операции сравнения. Теперь сравнение будет между двумя значениями одинакового типа!

Вызов метода parse может легко вызвать ошибку. Если, например, строка содержит значение A👍%, то нет никакого способа преобразовать это значение в число, что приведёт к сбою. Поскольку вызов метода parse может дать сбой, сам метод возвращает значение типа Result, так же как и метод read_line (поведение которого обсуждалось ранее в "Обработка потенциального сбоя с помощью типа Result"). Мы будем обрабатывать этот Result таким же образом как и раньше, используя метод expect. Если parse возвращает вариант перечисления Result равный Err (потому что он не может создать число из строки), то вызов expect на Err приведёт к сбою игры и распечатает сообщение, которое которое он получил от parse. Если parse сможет успешно преобразовать строку в число, он вернёт вариант Ok перечисления Result, а 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..101);

    // --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..101);

    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..101);

    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, по тому же принципу как мы это делали с результатом метода cmp (а он у нас был типа Ordering).

Если parse может успешно превратить строку в число, он вернёт вариант Ok, который содержит внутри себя полученное число. Значение Ok с числом внутри будет соответствовать шаблону первой ветки выражения match. Когда выражение match начнёт выполнять код этой ветки, то оно просто вернёт значение какого-то числа num из внутренностей Ok (которое ранее метод parse разобрал из текстовой строки с пользовательским вводом и услужливо поместил во внутрь Ok). Это число затем будет возвращено выражением match в новую созданную переменную guess.

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

    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 объясняет, как работают перечисления.