Программирование PC Rust: программирование игры - угадывание числа Fri, December 13 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.


Rust: программирование игры - угадывание числа Печать
Добавил(а) microsin   

[Глава 2, базовые понятия]

В этой главе будут продемонстрированы несколько общих концепций Rust на примере их использования в реальной программе. Будут рассмотрены такие понятия, как let, match, методы, связанные функции, внешние crate и многое другое. В последующих главах мы более подробно обсудим все эти идеи, здесь просто будет реальная практика программирования.

Примечание: это перевод главы 2 книги программирования Rust [2].

Наша программа будет генерировать случайное число от 1 до 100. После этого она выведет пользователю предложение угадать число. После того, как пользователь ввел угадываемое число, программа скажет, больше это число или меньше того, что сгенерировано. Если пользователь угадал, то программа выведет поздравление и завершит работу.

Создание нового проекта. Чтобы создать новый проект, перейдите в ваш каталог для проектов (директория projects, что мы рассматривали в главе 1 [2], когда обсуждали установку Rust) и создайте новый проект с помощью Cargo:

$ cargo new guessing_game
$ cd guessing_game

Первая команда cargo new создаст новый проект, и в директории projects появится каталог с таким же именем, guessing_game. Это корневой каталог проекта. Вторая команда делает этот каталог текущим.

Давайте посмотрим на содержимое сгенерированного файла Cargo.toml в корневом каталоге проекта:

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"

[dependencies]

Как мы уже видели в главе 1 (см. [2]) команда cargo new автоматически генерирует для вас шаблон новой программы в виде кода, который выводит сообщение "Hello, world!". Вот содержимое этой программы, находящейся в файле исходного кода src/main.rs:

fn main() {
    println!("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!

Откройте файл src/main.rs в текстовом редакторе, мы будем вносить в него свои изменения.

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

use std::io;

fn main() { println!("Угадайте число!"); println!("Введите ваш вариант числа.");

let mut guess = String::new();

io::stdin() .read_line(&mut guess) .expect("Не получилось прочитать строку");

println!("Вы предположили число: {}", guess); }

Рассмотрим этот код подробнее, строка за строкой. Чтобы получить ввод пользователя и напечатать результат на выходе, необходимо добавить в область видимости библиотеку ввода/вывода. Библиотека io поставляется вместе со стандартными библиотеками Rust, известными как std:

use std::io;

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

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

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

fn main() {

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

Как мы также рассматривали в главе 1, макрос println! служит для печати строки текста на экране:

    println!("Угадайте число!");
    println!("Введите ваш вариант числа.");

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

Сохранение значений в переменных. Далее мы создаем переменную guess для того, чтобы сохранить в ней введенное пользователем значение:

    let mut guess = String::new();

Программа становится интереснее. Здесь оператор let создает переменную. Вот другой пример использования let:

let apples = 5;

Эта строка создает новую переменную с именем apples, и присваивает ей значение 5. На языке Rust по умолчанию переменные не мутируемые (immutable), т. е. они ведут себя как константы, у которых нельзя поменять значение (на языке C это соответствует определению переменной с модификатором const). Более подробно эта концепция обсуждается в секции "Переменные и их мутируемость" главы 3. Чтобы сделать переменную мутируемой (т. е. изменяемой, чтобы ей можно было поменять значение), мы добавляем mut перед именем переменной:

let apples = 5;      // immutable
let mut bananas = 5; // mutable

Примечание: синтаксис // обозначает начало комментария, который продолжается до конца строки. Rust игнорирует все, что относится к комментариям. Также комментарии могут заключаться в конструкции /*  */ (так же, как на языке C).

Итак, оператором let mut guess мы ввели изменяемую переменную с именем guess. Знак равенства (=) говорит для Rust, что мы хотим сейчас присвоить переменной какое-то значение. Правая часть оператора присваивания содержит то, что присваивается, а именно результат вызова функции String::new. Этот вызов вернет экземпляр объекта строки, объекта типа String. String это стандартный тип из библиотеки Rust, представляющий текст в кодировке UTF-8 (т. е. туда можно записывать и русский текст).

Синтаксис :: означает, что функция new относится к типу String (такой же синтаксис у методов и свойств объектов классов C++). Связанная функция реализована как часть типа, в нашем случае это String. Этот вызов функции new создаст пустую строку. Вы найдете такую же функцию new у многих типов, потому что это общее имя для функции, которая создает новое значение какого-то вида.

Если рассмотреть строку let mut guess = String::new(); целиком, то она создает mutable-переменную, которая связана с экземпляром класса строки String, и эта строка пока что пустая.

Ввод пользователя. Вспомним, что мы добавили функционал ввода/вывода из стандартной библиотеки строкой use std::io;. Теперь мы воспользуемся функцией stdin модуля io, которая позволит обработать ввод пользователя:

    io::stdin()
        .read_line(&mut guess)

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

Далее, строка .read_line(&mut guess) вызывает метод read_line на стандартном дескрипторе ввода (standard input handle), чтобы получить введенные данные пользователя. Также мы передаем &mut в качестве аргумента для read_line, чтобы указать строку, в которой должен сохраняться пользовательский ввод. Полная задача read_line состоит в том, чтобы взять все, что пользователь вводит через стандартный ввод, и добавить это в строку (не перезаписывая её содержимое), переданную в качестве аргумента. Строковый аргумент должен быть mutable, т. е. изменяемым, чтобы метод мог поменять содержимое строки.

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

Обработка потенциального сбоя чтения ввода. Обратите внимание, что две строки, где .read_line и .expect это все еще одна и та же логическая строка кода, потому что вторая часть этой строки, где оператор .expect, не отделена от предыдущей строки точкой с запятой. Таким образом, эти строки с одинаковым эффектом могли бы быть записаны вот так:

io::stdin().read_line(&mut guess).expect("Не получилось прочитать строку");

Однако слишком длинная строка неудобна для чтения, так что было принято решение разделить оператор на 2 строки. Часто целесообразно вводить новую строку и другие пробелы, чтобы помочь разбить длинные строки при вызове метода с синтаксисом .method_name(). Давайте разберемся, что делает строка с методом .expect.

Как упоминалось выше, read_line поместит все, что ввел пользователь, в строку guess, однако read_line также возвращает значение результата вызова типа Result. Это значение из перечисления (enum), обозначающие результат работы метода. Мы называем каждое возможное состояние из этого перечисления вариантом (variant).

В главе 6 перечисления будут обсуждаться более подробно. Назначение типа Result - кодировать информацию, относящуюся к обработке ошибочных ситуаций.

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

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

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

$ 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 [unoptimized + debuginfo] target(s) in 0.59s

Rust предупреждает, что вы не использовали значение Result, возвращенное из read_line, показывая тем самым, что программа не обрабатывает возможную ошибку.

Правильным решением будет избавиться от этого предупреждения путем написания реального кода обработки ошибки (error-handling), но в нашем случае мы будем просто крашить нашу программу при возникновении проблемы, так что можно использовать expect. Больше про восстановление из состоянии ошибки можно узнать в главе 9.

Печать значений с помощью println!. Помимо закрывающей фигурной скобки, в коде есть пока что только одна строка для обсуждения:

    println!("Вы предположили число: {}", guess);

Этот код печатает строку, которую ввел пользователь. Набор фигурных скобок {} это заполнитель (placeholder): думайте о {} как ячейке, которая удерживает в этом месте значение. При печати значения переменной имя этой переменной может входить в фигурные скобки. При печати результата вычисления выражения поместите пустые фигурные скобки в строку формата, затем за строкой формата указывайте разделяемый запятой список печатаемых выражений, по одному на каждый набор фигурных скобок (по аналогии с printf языка C). Например, печать переменной и результата выражения в одном вызове println! может выглядеть следующим образом:

let x = 5;
let y = 10;

println!("x = {x} и y + 2 = {}", y + 2);

Этот код напечатает строку "x = 5 и y + 2 = 12".

Тестирование первой части кода. Попробуйте запустить написанный код с помощью cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Угадайте число!
Введите ваш вариант числа.
6
Вы предположили число: 6

На этом этапе первая часть игры завершена: мы получаем ввод с клавиатуры, а затем печатаем его.

[Генерация секретного числа]

Далее нам нужно сгенерировать секретное число, которое пользователь должен угадать. Чтобы играть было интересно, это число должно всякий раз отличаться при каждом запуске игры. Мы будем использовать случайное число между 1 и 100, чтобы игра не была слишком сложной. Rust пока что не включает функционал генерации случайного числа в своей стандартной библиотеке. Тем не менее команда разработчиков Rust предоставляет rand crate с такой функциональностью.

Использование Crate для расширения функционала. Вспомним, что crate это коллекция файлов исходного кода Rust. Проект, который мы создаем, это двоичный crate, который является исполняемым (executable). Набор rand crate это библиотечный crate, который содержит код, который предназначен для использования другими программами, и он не может запускаться самостоятельно.

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

[dependencies]
rand = "0.8.5"

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

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

Теперь без изменения текущего кода соберем проект:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
  Downloaded libc v0.2.127
  Downloaded getrandom v0.2.7
  Downloaded cfg-if v1.0.0
  Downloaded ppv-lite86 v0.2.16
  Downloaded rand_chacha v0.3.1
  Downloaded rand_core v0.6.3
   Compiling libc v0.2.127
   Compiling getrandom v0.2.7
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.16
   Compiling rand_core v0.6.3
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s

Здесь мы видим различные номера версий (но благодаря SemVer все они будут совместимы с нашим кодом). Содержимое строк версий их порядок следования зависят от операционной системы.

Когда мы подключаем внешнюю зависимость, Cargo извлекает из реестра последние версии, которые требуются этой зависимости. Реестр является копией данных из файла Crates.io, в нем люди из экосистемы Rust публикуют свои открытые (open source) проекты Rust, чтобы их могли использовать другие разработчики.

После обновления реестра (строка "Updating crates.io index") Cargo проверяет секцию [dependencies], и загружает перечисленные пакеты crate, которые пока что не загружены. В нашем случае, хотя мы перечислили только rand в качестве зависимости, Cargo также хватает другие crate, от которых зависит работа rand. После загрузки пакетов crate система Rust компилирует их и затем компилирует проект с доступными зависимостями.

Если вы сейчас запустите снова cargo build, не делая больше никаких изменений кода, то не получите какой то другой вывод, кроме строки "Finished ...". Cargo знает, что зависимости уже загружены и скомпилированы, и содержимое вашего файла Cargo.toml не поменялось. Cargo также знает, что вы ничего не поменяли в коде, поэтому нет необходимости перекомпилировать что-либо. Делать ничего не надо, и Cargo завершает работу.

Если же вы откроете файл src/main.rs, сделаете в нем какое-нибудь тривиальное изменение, затем сохраните его и снова выполните сборку, то увидите только 2 строки вывода:

$ 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 crate версии 0.8.6, и эта версия содержит важное исправление бага, но также содержит и регрессию, которая повредит ваш код. Чтобы обработать такую ситуацию, Rust создает файл Cargo.lock, когда первый раз запускается cargo build, так что он теперь есть в папке проекта guessing_game.

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

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

$ cargo update
    Updating crates.io index
    Updating rand v0.8.5 -> v0.8.6

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

[dependencies]
rand = "0.9.0"

Cargo обновит реестр доступных пакетов crate, и заново вычислит требования зависимостей для новой указанной вами версии rand.

Можно еще многое рассказать про Cargo и его экосистему, о чем мы поговорим в главе 14, но сейчас пока что это все, что нужно знать. Cargo очень упрощает повторное использование библиотек.

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

Листинг 2-3, файл src/main.rs. Добавление кода для генерации случайного числа.

use std::io;
use rand::Rng;

fn main() { println!("Угадайте число!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("Секретное число: {secret_number}"); println!("Введите ваш вариант числа.");

let mut guess = String::new();

io::stdin() .read_line(&mut guess) .expect("Не получилось прочитать строку");

println!("Вы предположили число: {guess}"); }

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

Далее мы добавили еще 2 строки посередине. А первой из них мы вызываем функцию rand::thread_rng, что дает нам генератор случайных чисел, которые мы собираемся использовать: тот, который является локальным для текущего потока выполнения, и заполняется операционной системой. Затем мы вызываем метод gen_range на генераторе случайного числа. Этот метод определен трейтом Rng, который мы привели в область видимости оператором use rand::Rng;. Метод gen_range принимает в качестве аргумента выражение диапазона и генерирует случайное число в этом диапазоне. Здесь используется выражение диапазона вида start..=end, где в диапазон включены начальная (start) и конечная границы (end), так что нам надо указать 1..=100, чтобы запросить получение случайного числа от 1 до 100.

Примечание: вы сразу можете не знать, какие трейты использовать, какие методы и функции можно вызвать из crate, так что каждый crate имеет документацию с инструкциями по его использованию. Еще одна приятная особенность Cargo состоит в том, что запуск команды cargo doc --open выполнит сборку документации, предоставляемую локально по всем вашим зависимостям, и откроет её в браузере. Если вас интересует другой функционал rand crate, то запустите команду 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`
Угадайте число!
Секретное число: 7
Введите ваш вариант числа.
4
Вы предположили число: 4
$ cargo run Finished dev [unoptimized + debuginfo] target(s) in 0.02s Running `target/debug/guessing_game` Угадайте число! Секретное число: 83 Введите ваш вариант числа. 5 Вы предположили число: 5

Каждый раз вы должны получать другое случайное число, и все они будут в диапазоне от 1 до 100.

Сравнение угадываемого числа с секретным. У нас есть число, введенное пользователем, и случайное сгенерированное число, мы можем их сравнить. Этот шаг показан в листинге 2-4. Обратите внимание, что пока этот код компилировать не надо.

Листинг 2-4, файл src/main.rs: обработка возможных результатов сравнения двух чисел.

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

fn main() { // -- вырезано -- println!("Вы предположили число: {guess}");

match guess.cmp(&secret_number) { Ordering::Less => println!("Слишком мало!"), Ordering::Greater => println!("Слишком много!"), Ordering::Equal => println!("Вы выиграли!"), } }

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

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

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

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

Когда код сравнивает 50 с 38, метод cmp возвратит Ordering::Greater, потому что 50 больше 38. Выражение match берет значение Ordering::Greater и начинает проверять каждый arm-шаблон. Первый шаблон Ordering::Less, и match видит, что он не соответствует значению Ordering::Greater, игнорирует эту arm и переходит к следующей arm. Следующий arm-шаблон Ordering::Greater, который соответствует Ordering::Greater. Код, связанный с этой arm, выполнится, и напечатает на экране "Слишком много!". Выражение match завершается после первого успешного соответствия, так что оно в нашем сценарии не будет рассматривать последнюю arm.

Однако код листинга 2-4 мы не захотели компилировать. Давайте попробуем это сделать:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:22:21
   |
22 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/cmp.rs:814:8

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

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

В конечном счете мы хотим преобразовать строку, которую программа прочитала в качестве входных данных, в числовой тип, чтобы мы смогли выполнить сравнение с секретным числом. Мы также добавим эту строк в тело функции main:

    // -- вырезано --

let mut guess = String::new();

io::stdin() .read_line(&mut guess) .expect("Не получилось прочитать строку");

let guess: u32 = guess.trim().parse().expect("Пожалуйста введите число!");

println!("Вы предположили число: {guess}");

match guess.cmp(&secret_number) { Ordering::Less => println!("Слишком мало!"), Ordering::Greater => println!("Слишком много!"), Ordering::Equal => println!("Вы выиграли!"), }

Мы добавили строку:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

Здесь создается переменная с именем guess. Но как же так, разве не была ранее создана переменная с таким же именем guess? Да, это так, но Rust услужливо позволяет вам скрыть предыдущее значение guess, заменив его новым. Это называется затенением (shadowing), оно позволяет нам повторно использовать имя переменной guess вместо того, чтобы принудительно создавать 2 уникальные переменные, такие как например guess_str и guess. Более подробно затенение будет рассмотрено в главе 3, но пока следует знать, что функция затенения часто используется, когда необходимо преобразовать значение одного типа в значение другого типа.

Мы привязали эту новую переменную к выражению guess.trim().parse(). Здесь guess в выражении ссылается на оригинальную строковую переменную, в которую попал ввод пользователя. Метод trim экземпляра типа String удалит любые пробелы, которые могли оказаться в начале и в конце, чтобы можно было успешно преобразовать строку в число u32. Пользователь должен в конце ввода нажать Enter, чтобы произошел возврат из read_line, и в результате в guess также попадет символ новой строки. Например, если пользователь нажал 5 и затем нажал Enter, то в переменной guess окажется строка "5\n". Здесь \n представляет символ новой строки, "newline" (на Windows нажатие Enter приведет к добавлению двух символов к символу '5', возврат каретки и новой строки, в результате получится "5\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 таким же образом, снова используя метод expect. Если parse вернет Err вариант Result из-за того, что не получится создать число из строки, то вызов expect приведет к сбою игры и печати сообщения, которое мы ему дадим. Если parse может успешно преобразовать строку в число, то он вернет Ok вариант Result, и тогда expect возвратит нужное нам число.

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

$ 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`
Угадайте число!
Секретное число: 58
Введите ваш вариант числа.
  76
Вы предположили число: 76
Слишком много!

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

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

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

    // -- вырезано --

println!("Секретное число: {secret_number}");

loop { println!("Введите ваш вариант числа.");

// -- вырезано --

match guess.cmp(&secret_number) { Ordering::Less => println!("Слишком мало!"), Ordering::Greater => println!("Слишком много!"), Ordering::Equal => println!("Вы выиграли!"), } } }

Как вы можете видеть, мы перенесли весь алгоритм в тело цикла. Теперь программа будет повторять запрос числа для угадывания, но это создаст другую проблему. Все выглядит так, что пользователь не может закончить игру, даже когда выиграл!

Пользователь всегда может прервать работу программы, нажав комбинацию клавиш Ctrl+C. Но есть также еще один способ выхода, что мы обсуждали выше в секции "Сравнение угадываемого числа с секретным": если пользователь введет ответ, который не соответствует числу, то программа аварийно завершится. Мы можем воспользоваться этим, чтобы дать пользователю возможность выхода, как показано здесь:

$ 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`
Угадайте число!
Секретное число: 59
Введите ваш вариант числа.
45
Вы предположили число: 45
Слишком мало!
Введите ваш вариант числа.
60
Вы предположили число: 60
Слишком много!
Введите ваш вариант числа.
59
Вы предположили число: 59
Вы выиграли!
Введите ваш вариант числа.
quit
thread 'main' panicked at 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Ввод "quit" вместо числа позволяет выйти из программы, как и любой другой не числовой ввод. Это не очень красивое решение, так что лучше всего добавить в игру остановку, когда число было угадано пользователем.

Выход после правильного угадывания. Можно реализовать выход из цикла и как следствие завершение программы путем добавления оператора break:

        // -- вырезано --

match guess.cmp(&secret_number) { Ordering::Less => println!("Слишком мало!"), Ordering::Greater => println!("Слишком много!"), Ordering::Equal => { println!("Вы выиграли!"); break; } } } }

Добавление строки break после "Вы выиграли!" дает эффект выхода из цикла, когда пользователь правильно угадал число. Выход из цикла также означает и выход из программы, потому что цикл находится в последней части функции main.

Обработка некорректного ввода. Чтобы дополнительно улучшить поведение программы, вместо того, чтобы программа падала когда пользователь введет не число, давайте сделаем так, чтобы игра игнорировала не числовые введенные пользователем значения и продолжала выдавать запрос на ввод угадывания числа. Мы можем сделать это путем изменения строки, где guess преобразуется из String в u32, как показано в листинге 2-5.

Листинг 2-5, файл src/main.rs: игнорирование не числового ввода вместо падения программы.

        // -- вырезано --

io::stdin() .read_line(&mut guess) .expect("Не получилось прочитать строку");

let guess: u32 = match guess.trim().parse() { Ok(num) => num, Err(_) => continue, };

println!("Вы предположили число: {guess}");

// -- вырезано --

Мы переключились с вызова expect на выражение match, вместо падения программы при ошибке реализовать обработку ситуации ошибки. Вспомните, что parse возвращает тип Result, и Result это варианты из перечисления, которые могут быть Ok и Err. Здесь мы используем выражение match, и делаем то же самое, что и с результатом Ordering метода cmp.

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

Если parse не может превратить строку в число, то оно вернет значение Err, которое содержит дополнительную информацию об ошибке. Значение Err не соответствует шаблону Ok(num) в первом arm-шаблоне, но соответствует шаблону Err(_) второй arm. Символ подчеркивания _ это catchall значение (означает "все равно что"); в нашем примере это означает, что нужно сопоставлять все значения Err независимо от того, какая информация у него внутри. Так что программа при ошибке выполнит код второй arm, т. е. continue, которое заставит программу прокрутить следующую итерацию цикла и снова запросить у пользователя ввод числа. Таким образом программа игнорирует все ошибки, которые могли произойти в 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`
Угадайте число!
Секретное число: 61
Введите ваш вариант числа.
10
Вы предположили число: 10
Слишком мало!
Введите ваш вариант числа.
99
Вы предположили число: 99
Слишком много!
Введите ваш вариант числа.
foo
Введите ваш вариант числа.
61
Вы предположили число: 61
Вы выиграли!

С последней правкой программа завершается, когда пользователь угадал число. Помните, что мы вставили отладочный оператор println!, который сразу печатает секретное число. Но для конечной версии программы это не нужно, так что давайте удалим эту строку. Листинг 2-6 показывает завершенный код программы.

Листинг 2-6, файл src/main.rs: конечный вариант программы игры в угадывание числа.

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

fn main() { println!("Угадайте число!");

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

loop { println!("Введите ваш вариант числа.");

let mut guess = String::new();

io::stdin() .read_line(&mut guess) .expect("Не получилось прочитать строку");

let guess: u32 = match guess.trim().parse() { Ok(num) => num, Err(_) => continue, };

println!("Вы предположили число: {guess}");

match guess.cmp(&secret_number) { Ordering::Less => println!("Слишком мало!"), Ordering::Greater => println!("Слишком много!"), Ordering::Equal => { println!("Вы выиграли!"); break; } } } }

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

[Ссылки]

1. Programming a Guessing Game site:rust-lang.org.
2. Язык программирования Rust.
3. Rust Module std::prelude site:rust-lang.org.

 

Добавить комментарий


Защитный код
Обновить

Top of Page