Руководство по Rust для новичков

Перевод | Автор оригинала: Dylan Hicks

Используйте свои навыки Rust, создав простую игру в крестики-нолики.

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

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

Предварительные требования

Начните с прочтения первой части этой серии, "Руководство по Rust для новичков". Я покажу вам, как установить и запустить Rust, опишу его основные функции и познакомлю вас с концепциями, необходимыми для начала работы. В этой статье я не буду описывать все аспекты языка, поэтому вам нужно разобраться в основах языка.

Запустить проект

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

cd ~/Documents
cargo new tic_tac_toe –bin

В древовидной программе ваш новый каталог tic_tac_toe выглядит так:

cd tic_tac_toe
tree .
.
??? Cargo.toml
??? src
    ??? main.rs

Файл main.rs должен состоять из следующих строк:

fn main() {
    println!("Hello, world!");
}

Запустить программу так же просто, как создать ее, как запустить «Hello, World!» показывает.

Запуск «Hello, World!»

  cargo build
    Compiling...
     Finished...
  cargo run
     Finished...
      Running...
Hello, world!

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

touch ./src/game.rs

После настройки проекта и каталогов вы можете погрузиться в описание игры.

Планируйте игру с помощью типов и структур

Классическая игра в крестики-нолики состоит из двух основных компонентов: доски и поворотов для каждого игрока. Доска представляет собой пустой массив 3 × 3, и повороты указывают, какой игрок должен сделать ход. Чтобы перевести эту функциональность, вы должны отредактировать файл game.rs, созданный в последнем разделе (см. Листинг 2).

Game.rs модифицированы для доски и ходов игрока

type Board = Vec<Vec<String>>;

enum Turn {
    Player,
    Bot,
}

pub struct Game {
    board: Board,
    current_turn: Turn,
}

Возможно, вы заметили здесь странный синтаксис, но не беспокойтесь: я опишу это по ходу дела.

Доска

Чтобы преобразовать игровое поле, вы используете ключевое слово type для псевдонима Board, которое будет синонимом типа Vec<Vec>. Теперь Board - это простой тип двумерного вектора строк. Я бы использовал здесь char, потому что единственными значениями в массиве будут x, o или число, указывающее на открытую позицию.

Повороты

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

Игра

Наконец, вы должны создать объект Game, содержащий доску и текущий ход. Но ждать! Где методы для структуры Game? Не бойтесь: это дальше.

Реализуйте игру

Какие методы составляют игру в крестики-нолики? Ну есть очереди. Каждый ход отображается доска, игрок делает ход, доска отображается снова и проверяется условие выигрыша. Если игра была выиграна, игра объявляет, какой игрок выиграл, и предлагает ему сыграть снова. Если никто не выиграл игру, игра переключает текущего игрока и начинает следующий ход. Очевидно, что внутри каждого хода есть более тонкие проблемы, в зависимости от игрока, но вы можете просто нырнуть отсюда.

Сначала вы создаете конструкцию, вложенную в блок impl, как показано в листинге 3.

Конструкция игры

impl Game {
    pub fn new() -> Game {
        let first_row = vec![
            String::from("1"), String::from("2"),
            String::from("3")];

        let second_row = vec![
            String::from("4"), String::from("5"),
            String::from("6")];

        let third_row = vec![
            String::from("7"), String::from("8"),
            String::from("9")];

        Game {
            board: vec![first_row, second_row, third_row],
            current_turn: Turn::Player,
        }
    }
}

Статический метод new создает и возвращает структуру Game. Это стандартное имя для конструктора объекта в Rust.

Вы должны связать переменную члена правления с двумерным вектором объектов String. Вместо того, чтобы оставлять каждое место пустым, обратите внимание, что я заполнил их числом, обозначающим доступные позиции для каждого хода. Затем привяжите переменную-член current_turn к значению Turn::Player. Эта линия означает, что в каждой игре игрок ходит первым.

Как вы играете в игру?

Первый метод служит картой для программы. Вы добавляете этот метод в блок impl Game (вместе с остальными методами в этом разделе). В листинге 4 показан метод.

Карта игровой программы

pub fn play_game( self) {
    let mut finished = false;

    while !finished {
        self.play_turn();

        if self.game_is_won() {
            self.print_board();

            match self.current_turn {
                Turn::Player => println!("You won!"),
                Turn::Bot => println!("You lost!"),
            };

            finished = Self::player_is_finished();

            self.reset();
        }

        self.current_turn = self.get_next_turn();
    }
}

Легко увидеть ход игры. Используя бесконечный цикл, вы переходите от одного поворота к другому, чередуя current_turn. По этой причине вы используете изменяемое заимствование для себя, потому что внутреннее состояние игры меняется с каждым ходом.

Это перечисление уже приносит свои плоды, потому что, если игра выиграна, информация о том, кто выиграл игру, встроена. Затем вы сообщаете игроку, что он или она выиграли или проиграли. Кроме того, вы сбрасываете доску в исходное состояние, что полезно, если пользователь хочет снова сыграть.

Обратите внимание, что это будет единственный метод pub, кроме new. Это означает, что play_game и new - единственные методы, к которым другая библиотека имеет доступ при использовании объектов Game. Все остальные методы, статические или другие, являются частными.

Изменение ситуации

Первый вспомогательный метод, используемый в методе play_game, - это play_turn. В листинге 5 показана эта изящная маленькая функция.

Функция play_turn

fn play_turn( self) {
    self.print_board();

    let (token, valid_move) = match self.current_turn {
        Turn::Player => (
            String::from("X"), self.get_player_move()),
        Turn::Bot => (
            String::from("O"), self.get_bot_move()),
    };

    let (row, col) = Self::to_board_location(valid_move);

    self.board[row][col] = token;
}

Это сложно. Сначала вы распечатываете доску, чтобы пользователь знал, какие позиции доступны (полезно, даже когда очередь бота). Затем, в зависимости от варианта current_turn, вы назначаете переменные token и valid_move, используя деконструкцию кортежа и сопоставление.

token - это строка X или O для игрока или бота соответственно. valid_move - это целое число от 1 до 9, то есть место на доске не занято. Затем эта переменная преобразуется в соответствующую строку и столбец платы с помощью статического метода to_board_location. (Self с заглавной буквой S возвращает тип self - в данном случае Game.)

Посмотрим на доску

Теперь, когда вы настроили play_turn, вам нужен метод для печати. В листинге 6 показан этот метод.

Печать игрового поля

fn print_board() {
    let separator = "+---+---+---+";

    println!("\n{}", separator);

    for row in  {
        println!("| {} |\n{}", row.join(" | "), separator);
    }

    print!("\n");
}

В этом методе вы используете цикл for для печати ASCII-представления строк на плате. Строка временной переменной является ссылкой на каждый вектор на плате. Используя метод соединения, вы можете превратить строку в String и распечатать это новое значение с добавленным разделителем String.

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

Игрок, твоя очередь

Пока что эта программа представляет собой серию жестко запрограммированных возвратов без участия игрока. Листинг 7 меняет это.

Настройка очередности

fn get_player_move() -> u32 {
    loop {
        let mut player_input = String::new();

        println!(
            "\nPlease enter your move (an integer between \
            1 and 9): ");

        match io::stdin().read_line( player_input) {
            Err(_) => println!(
                "Error reading input, try again!"),
            Ok(_) => match self.validate() {
                Err(err) => println!("{}", err),
                Ok(num) => return num,
            },
        }
    }
}

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

Первое выражение соответствия после приглашения пользователя пытается прочитать ввод пользователя в строку - player_input - и проверяет, возникает ли при этом ошибка. Модуль io предоставляет эту функциональность; вы должны импортировать этот модуль в начало файла game.rs. Его метод stdin(). Read_line (stdin() возвращает объект дескриптора в текущий стандартный ввод). Вот мой импорт модуля io:

use std::io;

Также важно отметить, что метод read_line, изменяя заданную строку, также возвращает перечисление с именем Result. Я не говорил о Result в своей вводной статье, поэтому коснусь его далее.

Перечисление результатов

Результат - это так называемый алгебраический тип. Это перечисление с двумя вариантами: Ok и Err. Каждый вариант может содержать данные, например String или i32.

В случае read_line возвращаемый результат является специальной версией модуля io, что означает, что Err - это особый вариант io::Error. Напротив, Ok - это то же самое, что и исходный вариант Result, и в этом случае содержит целое число, которое представляет количество прочитанных байтов. Result - это полезное перечисление, которое помогает убедиться, что вы обрабатываете все возможные ошибки во время компиляции, а не во время выполнения.

Еще одно родственное перечисление, широко распространенное в Rust, - Option. Вместо Ok и Err его вариантами являются None (который не содержит данных) и Some(который содержит). Опция полезна тем же способом, что и nullptr в C++ или None в Python.

В чем разница между Option и Result и когда их использовать? Вот мои ответы. Во-первых, если вы ожидаете, что функция ничего не вернет, используйте Option. Используйте Result для функций, которые, как вы ожидаете, всегда будут успешными, но которые могут дать сбой, а это означает, что ошибка должна быть обнаружена. Понятно? Здорово. Вернемся к методу get_player_move.

Вернуться к игре

Я остановился на чтении ввода от плеера. Если происходит ошибка чтения ввода пользователя, программа уведомляет пользователя и просит его или ее ввести другой ввод. Если ошибки не возникает, программа достигает второго выражения соответствия. Обратите внимание на использование подчеркиваний (_): они говорят Rust, что вы не привязываете данные внутри вариантов результата Ok или Err, что вы делаете во втором выражении соответствия.

Это выражение соответствия проверяет, допустима ли переменная player_input. Если это не так, код возвращает ошибку (о которой игра предупреждает игрока) и запрашивает у игрока допустимый ввод. Если player_input действителен, то возвращается этот ввод, преобразованный в целое число с помощью метода проверки.

Подтвердите свой код

Когда ядро игры написано, неплохо написать функцию проверки. В листинге 8 показан код.

Функция проверки

fn validate(, input: ) -> Resultu32, String {
    match input.trim().parse::u32() {
        Err(_) => Err(
            String::from(
                "Please input a valid unsigned integer!")),
        Ok(number) => {
            if self.is_valid_move(number) {
                Ok(number)
            } else {
                Err(
                    String::from(
                        "Please input a number, between \
                        1 and 9, not already chosen!"))
            }
        }
    }
}

Просматривая этот вывод построчно, вот суть метода.

Сначала программа возвращает перечисление Result. Я не рассматривал шаблоны типов, но в основном вы утверждаете, что вариант Ok для Result должен содержать целое число u32, а вариант Err должен содержать String. Почему результат возвращается сюда? Что ж, ожидается, что метод пройдет и выдаст ошибку только в том случае, если заданный ввод:

Затем программа пытается преобразовать ввод в u32, используя метод синтаксического анализа input. Turbofish,::type - это особый аспект некоторых функций, который сообщает им, какой тип возвращать. В этом случае он одновременно сообщает синтаксическому анализатору, что нужно попытаться преобразовать ввод в u32, и устанавливает вариант Ok для результата для хранения u32. Если ввод не может быть преобразован, код возвращает ошибку, указывающую, что ввод не был целым числом без знака. Однако, если он успешно преобразован, код передает ввод через другую вспомогательную функцию: is_valid_move.

Почему есть еще одна вспомогательная функция для проверки? Из предыдущего списка возможных ошибок номер 1 специфичен для пользователя. Бот всегда будет давать целое число. Вот почему вы используете проверку только для проверки ответа игрока. is_valid_move проверяет две другие возможные ошибки.

В листинге 9 показан последний фрагмент кода проверки.

Еще немного проверки

fn is_valid_move(, unchecked_move: u32) -> bool {
    match unchecked_move {
        1...9 => {
            let (row, col) = Self::to_board_location(
                unchecked_move);

            match self.board[row][col].as_str() {
                "X" | "O" => false,
                 _ => true,
            }
        }
        _ => false,
    }
}

Достаточно просто. Если значение unchecked_move не находится в диапазоне от 1 до 9 (включительно), то это недопустимый ход. В противном случае код вынужден проверять, был ли уже сделан ход. Как и раньше в play_turn, вы преобразуете unchecked_move в соответствующую строку и столбец на доске. Затем вы можете проверить, есть ли это место на доске. Если местоположение - X или O, то ход недействителен.

К боту

Прежде чем переходить к написанию метода для получения движения бота, создайте статический метод to_board_location, который показан в листинге 10.

Метод to_board_location

fn to_board_location(game_move: u32) -> (usize, usize) {
    let row = (game_move - 1) / 3;
    let col = (game_move - 1) % 3;

    (row as usize, col as usize)
}

Этот метод немного обманывает, потому что вы знаете, что когда to_board_location вызывается в validate и play_turn, аргумент game_move является целым числом от 1 до 9 (включительно). Вы устанавливаете этот метод как статический, потому что математика не связана с игровым объектом. Доска для крестиков-ноликов всегда 3x3.

чат-бот

Ваш код может получить ход от игрока, но рассмотрите возможность использования бота. Во-первых, ход бота должен быть случайным числом, что означает, что вам нужно импортировать сторонний крэйт rand. Во-вторых, вы продолжаете генерировать этот случайный ход до тех пор, пока он не достигнет допустимого местоположения, используя метод is_valid_move. Затем игра должна уведомить игрока о том, какой ход сделал бот, и вернуть ход.

Вы импортируете и устанавливаете этот крэйт rand в файле Cargo.toml с rand в качестве зависимости. В листинге 11 показан файл.

Cargo.toml

[package]
name = "tic_tac_toe"
version = "0.1.0"
authors = ["Dylan Hicks dirtgrub.dylanhicks@gmail.com"]

[dependencies]
rand = "0.4"

Поместите эту команду в начало файла game.rs над импортом io:

use rand;

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

The bot_move method

fn get_bot_move() -> u32 {
    let mut bot_move: u32 = rand::random::u32() % 9 + 1;

    while !self.is_valid_move(bot_move) {
        bot_move = rand::random::u32() % 9 + 1;
    }

    println!("Bot played moved at: {}", bot_move);

    bot_move
}

Это было безболезненно, правда?

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

Мы - чемпионы

Теперь вы собираетесь немного поиграться с булевой алгеброй (немного булевой алгебры).

Немного булевой алгебры

fn game_is_won() -> bool {
    let mut all_same_row = false;
    let mut all_same_col = false;

    for index in 0..3 {
        all_same_row |=
            self.board[index][0] == self.board[index][1]
             self.board[index][1] == self.board[index][2];
        all_same_col |=
            self.board[0][index] == self.board[1][index]
             self.board[1][index] == self.board[2][index];
    }

    let all_same_diag_1 =
        self.board[0][0] == self.board[1][1]
         self.board[1][1] == self.board[2][2];
    let all_same_diag_2 =
        self.board[0][2] == self.board[1][1]
         self.board[1][1] == self.board[2][0];

        (all_same_row || all_same_col || all_same_diag_1 ||
         all_same_diag_2)
}

Во время цикла for вы одновременно проверяете строки и столбцы, чтобы увидеть, выполнено ли условие выигрыша для Tic-Tac-Toe (то есть три X или O подряд). Вы делаете это с помощью | =, что похоже на + =, но вместо оператора сложения он использует оператор или. Затем вы проверяете, совпадают ли все две диагонали с одним и тем же символом. Наконец, вы возвращаете, было ли выполнено какое-либо из условий выигрыша, используя некоторую булеву алгебру. Еще три метода, и готово.

Хотели бы вы снова сыграть?

Если вы вернетесь и посмотрите на метод play_game на карте игровой программы, вы увидите, что код продолжает цикл до тех пор, пока не будет выполнено завершение. Это происходит только в том случае, если метод player_is_finished истинен. Этот метод должен быть основан на ответе игрока: да или нет (метод player_is_finished).

Метод player_is_finished

fn player_is_finished() -> bool {
    let mut player_input = String::new();

    println!("Are you finished playing (y/n)?:");

    match io::stdin().read_line( player_input) {
        Ok(_) => {
            let temp = player_input.to_lowercase();

            temp.trim() == "y" || temp.trim() == "yes"
        }
            Err(_) => false,
    }
}

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

Аппаратный сброс исправляет все

Один из последних методов, используемых в play_game, - это сброс, показанный в листинге 15.

Метод сброса

fn reset( self) {
    self.current_turn = Turn::Player;
    self.board = vec![
        vec![
            String::from("1"), String::from("2"),
            String::from("3")],
        vec![
            String::from("4"), String::from("5"),
            String::from("6")],
        vec![
            String::from("7"), String::from("8"),
            String::from("9")],
    ];
}

Все, что делает этот метод, - это устанавливает для переменных-членов игры их значения по умолчанию.

Последний метод, который вам понадобится для завершения игры, - это get_next_turn, показанный в листинге 16.

Метод get_next_turn

fn get_next_turn() -> Turn {
    match self.current_turn {
        Turn::Player => Turn::Bot,
        Turn::Bot => Turn::Player,
    }
}

Этот метод просто проверяет, какой из автоматов включен, и возвращает обратное.

Запускаем и скомпилируем игру

Когда модуль game.rs завершен, main.rs теперь в той точке, в которой вы можете скомпилировать игру и начать играть (скомпилировать игру).

Скомпилируйте игру

mod game;

use game::Game;

fn main() {
    println!("Welcome to Tic-Tac-Toe!");

    let mut game = Game::new();

    game.play_game();
}

Вот и все. Вы только что объявили, что игровой модуль существует в этом проекте с помощью мода, и ввели объект Game в область видимости с использованием. Затем вы создали игровой объект с помощью Game::new() и сказали объекту начать игру. Теперь запустите его с помощью Cargo (Запустите игру).

Запускаем игру

  cargo run
   Compiling tic_tac_toe v0.1.0...
    Finished dev [unoptimized + debuginfo]...
     Running...
Welcome to Tic-Tac-Toe!

+---+---+---+
| 1 | 2 | 3 |
+---+---+---+
| 4 | 5 | 6 |
+---+---+---+
| 7 | 8 | 9 |
+---+---+---+

Please enter your move (an integer between 1 and 9):
...

Последние мысли

Как вы узнали из этого руководства, Rust - это универсальный язык, который имеет простоту использования Java, C# или Python, но скорость и мощность C или C++. Этот код не только компилируется и быстро, но и все проблемы с памятью и ошибками обрабатываются во время компиляции, а не во время выполнения, сокращая количество человеческих ошибок, возможных в коде.

Следующие шаги

Чтобы увидеть код, который я создал для этой статьи, посетите мой репозиторий GitHub.