Язык программирования Rust

от авторов Steve Klabnik, Carol Nichols и участников сообщества Rust

Эта версия текста предполагает, что вы используете Rust версии 1.50 (или более позднюю) с настройкой edition="2018" внутри файлов конфигурации Cargo.toml всех проектов данной книги (для использования идиом Rust версии 2018 Edition). Смотрите раздел "Установка" главы 1, чтобы установить или обновить Rust, и посмотрите на новое приложение Приложение E для получения информации об изданиях.

Язык Rust редакции 2018 года включает в себя ряд улучшений, которые делают Rust более эргономичным и лёгким в освоении. Эта версия книги содержит ряд изменений, отражающих эти улучшения:

  • Глава 7, "Управление растущими проектами с помощью пакетов, крейтов и модулей", по большей части переписана. Система модулей и пути работы с ними стали более согласованными в 2018 редакции.
  • Глава 10 обзавелась новыми разделами - "Типажи как параметры" и "Возврат типов, реализующих типаж", которые разъясняют новый синтаксис impl Trait.
  • В главе 11 добавлен раздел "Использование Result<T, E> в тестах", который показывает как писать тесты, использующие оператор ?.
  • Раздел "Дополнительно о временах жизни" в главе 19 был удалён, так как улучшения в компиляторе сделали конструкции из этого раздела ещё более редкими.
  • В приложение D, "Макросы", была добавлена информация о процедурных макросах. Само приложение стало разделом "Макросы" главы 19.
  • Приложение А, "Ключевые слова", теперь также описывает возможности сырых идентификаторов, которые позволяют взаимодействовать коду 2015 редакции с кодом 2018 редакции.
  • Приложение D теперь называется "Средства разработки" и описывает инструменты, которые помогут вам писать код на Rust.
  • Мы исправили ряд небольших ошибок и неточностей формулировок. Спасибо читателям, которые сообщают нам об этом!

Обратите внимание, что любой код из более ранних версий книги, продолжит компилироваться без указания edition="2018" в Cargo.toml проекта, даже если вы обновите используемую версию компилятора Rust. Это гарантия обратной совместимости Rust!

HTML-версия книги доступна онлайн по адресам https://doc.rust-lang.org/stable/book/(англ.) и https://doc.rust-lang.ru/book(рус.) и оффлайн, при установке Rust с помощью rustup: просто запустите rustup docs --book чтобы открыть её.

Английский вариант книги доступен в печатном виде и в ebook формате от No Starch Press.

Начало работы

Начнём наше путешествие в Rust! Нужно много всего изучить, но каждое путешествие должно где-то начаться. В этой главе мы обсудим:

  • установку Rust на Linux, macOS и Windows,
  • написание программы, печатающей Hello, world!,
  • использование cargo, менеджера пакетов и системы сборки в Rust в одном лице.

Установка

Первым шагом является установка Rust. Мы загрузим Rust, используя инструмент командной строки rustup, предназначенный для управлениями версиями Rust и другими связанными с ним инструментами. Вам понадобится интернет соединение для его загрузки.

Замечание: Если вы предпочтёте не использовать rustup по какой-то причине, пожалуйста, ознакомьтесь с другими вариантами на с странице the Rust installation page.

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

Нотация командной строки

В данной главе и потом во всей книге мы покажем некоторые команды в терминале командной строки. Строки, которые нужно ввести в терминале, начинаются с $. Вам не нужно вводить сам символ $; он только отображает, что это начало каждой команды. Строки, которые НЕ начинаются с $, обычно показывают вывод предыдущей команды. В дополнение, специфичные для PowerShell примеры используют символ > вместо символа $.

Установка rustup на Linux или macOS

Если вы используете Linux или macOS, пожалуйста, выполните следующую команду:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

Команда скачивает скрипт и начинает установку инструмента rustup, одновременно с установкой последней стабильной версии Rust. Вас могут запросить ввести локальный пароль. При успешной установке вы увидите следующий вывод:

Rust is installed now. Great!

Дополнительно, вам нужен какой-то вариант программы "линковщика". Возможно какой-то у вас уже установлен, но если при попытке компиляции Rust программы вы получаете ошибки, что "линковщик" не смог выполнится, то это означает, что он не установлен в системе и вам необходимо установить его вручную. Компиляторы языка C обычно имеют в своём составе корректный линковщик. Поищите как установить на вашу платформу компилятор языка C. Также, некоторые общие Rust пакеты зависят от C кода и им тоже будет необходим компилятор C. Так что, имеет смысл его поставить сейчас.

Установка rustup на Windows

Для Windows, посетите ссылку https://www.rust-lang.org/tools/install и следуйте инструкциям для установки Rust. На некотором шаге установки вы можете получить сообщение, объясняющее необходимость инструментов сборки C++ для Visual Studio 2013 или более поздней версии. Самый простой способ получить их это инсталлировать Build Tools for Visual Studio 2019. Когда вас спросят, какие рабочие нагрузки установить, убедитесь, что выбраны "C++ build tools" и что включены Windows 10 SDK и компоненты пакета английского языка.

Остальные часть этой книги используют команды, работающие как в cmd.exe, так и в PowerShell. Есть некоторые отличия, которые мы объясним.

Обновление и удаление

После установки Rust с помощью rustup, обновление на последние версии выполняется с помощью следующего простого скрипта командой:

$ rustup update

Чтобы удалить Rust и rustup, выполните
следующую команду:

$ rustup self uninstall

Устранение возможных ошибок

Чтобы проверить, правильно ли у вас установлен Rust, откройте оболочку и введите эту строку:

$ rustc --version

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

rustc x.y.z (abcabcabc yyyy-mm-dd)

Если вы видите данную информацию, то вы установили всё успешно. Если нет и вы используете Windows, проверьте что путь к Rust находится в системной переменной %PATH%. Если он корректный, но Rust все ещё не работает, то есть множество мест, где можно получить помощь. Самое простое это канал #beginners официального Rust Discord сервера. Там можно списаться в другими пользователями Rust (Rustaceans - как мы себя называем) которые могут помочь. Другие хорошие ресурсы включают пользовательский форум и ресурс Stack Overflow.

Локальная документация

Установка Rust также включает локальную копию документации, поэтому вы можете читать её в автономном режиме. Запустите rustup doc, чтобы открыть локальную документацию в вашем браузере.

Каждый раз, когда тип или функция предоставляется из стандартной библиотеки и вы не знаете, что они делают или как их использовать, используйте документацию по интерфейсу прикладного программирования (API), чтобы узнать это!

Привет, мир!

Итак, когда Rust уже установлен можно приступать к написанию вашей первой программы. Общая традиция при изучении нового языка программирования - писать маленькую программу которая печатает в строке вывода "Hello, world!". Давайте сделаем тоже самое.

Обратите внимание: данная книга подразумевает, что читатель должен быть знаком с командной строкой. Однако, Rust не выдвигает каких-либо специальных требований к тому, как вы пишете и по какому принципу храните код своих программ. По этому, если вы предпочитаете использовать интегрированную среду разработки (IDE) взамен командной строки терминала, чувствуйте себя спокойно и пользуйтесь тем, чем вам удобно. Разные IDE имеют разный уровень поддержки Rust, по этому не забывайте проверять документацию к выбранной IDE. Однако, не так давно команда Rust сфокусировалась на задаче большей поддержки языка в разных IDE и однозначно сделала определённый прогресс в данном направлении!

Создание папки проекта

Прежде всего начнём с создания директории, в которой будем сохранять наш код на языке Rust. На самом деле не важно, где сохранять наш код. Однако, для упражнений и проектов, обсуждаемых в данной книге, мы советуем создать директорию projects в вашем домашнем каталоге, там же и хранить в будущем код программ из книги.

Откройте терминал и введите следующие команды для того, чтобы создать директорию projects для хранения кода разных проектов, и, внутри неё, директорию hello_world для проекта “Hello, world!”.

Для Linux, macOS и PowerShell на Windows, введите:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

Для Windows в CMD, введите:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

Написание и запуск первой Rust программы

Теперь создадим новый файл, в котором сохраним исходный код программы, назовём его main.rs. Заметьте, что файлы с кодом на языке программирования Rust всегда заканчиваются расширением .rs. В случае, если вы захотите использовать более одного слова в названии файла, используйте знак подчёркивания в качестве разделителя. Например, возможно использовать именование hello_world.rs, но не рекомендуется использовать вариант helloworld.rs.

Теперь откроем файл main.rs для редактирования и введём следующие строки кода:

Название файла: main.rs

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

Листинг 1-1: Программа которая печатает Hello, world!

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

$ rustc main.rs
$ ./main
Hello, world!

В Windows, введите команду .\main.exe вместо ./main:

> rustc main.rs
> .\main.exe
Hello, world!

Независимо от операционной системы, строка Hello, world! должна напечататься в окне вашего терминала. Если вы не увидели вывода, вернитесь в часть "Решение проблем" "Troubleshooting" раздела "Установка" для получения помощи.

Если напечаталось Hello, world!, то примите наши поздравления! Вы написали программу на Rust, что делает вас Rust программистом — добро пожаловать!

Анатомия программы на Rust

Давайте рассмотрим в деталях, что происходит в программе “Hello, world!”. Вот первый кусок пазла:

fn main() {

}

Эти строки определяют функцию в Rust. Функция main имеет специальный смысл: она всегда является первым кодом, который запускается в исполняемой программе на Rust. Первая строка объявляет функцию с именем main у которой нет параметров и она ничего не возвращает. Если бы тут были параметры, они были бы записаны внутри круглых скобок, ().

Также заметим, что тело функции обёрнуто в фигурные скобки {} (curly brackets). В Rust тело любой функции оборачивается в фигурные скобки. Хорошим стилем является размещение открывающей скобки в строке объявления функции, оставляя пробел между ними.

На момент написания этих строк инструмент, выполняющий автоматическое форматирование, называется rustfmt и находится ещё в разработке. Если вы хотите использовать стандартный стиль во всех Rust проектах, то rustfmt отформатирует код в нужном стиле. Команда Rust планирует в конечном счёте включить данный инструмент в стандартный дистрибутив Rust, подобно rustc компилятору. Так что в зависимости от того, когда вы читаете данную книгу, rustfmt уже может быть инсталлирован на ваш компьютер! Загляните в документацию для деталей.

Содержимое функции main:


#![allow(unused)]
fn main() {
    println!("Hello, world!");
}

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

Первая, не столь заметная, - в стиле Rust для отступа используются четыре пробела, а не знак табуляции.

Вторая - вызов println! является макросом. Если бы вызывалась функция, то она была бы введена как println (без восклицательного знака !). Более детального обсуждения макросов Rust мы коснёмся в 19 главе. Сейчас вам достаточно знать, что использование символа ! означает вызов макроса, а не обычной функции.

Третья - вы видите строку "Hello, world!". Мы передаём эту строку как аргумент в макрос println! и, благодаря этому, строка выводится макросом на экран.

Четвёртая - в конце строки стоит точка с запятой (;), которая означает, что выражение закончилось и следующее выражение можно начинать опять. Большая часть строк в Rust коде заканчивается точкой с запятой.

Компиляция и выполнение кода являются отдельными шагами

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

Перед запуском программы, её необходимо скомпилировать компилятором Rust с помощью ввода команды rustc и передачи имени вашего файла с исходным кодом, например так:

$ rustc main.rs

Если вы знакомы с C или C++, то заметили, что это весьма похоже на вызов компиляции при помощи gcc или clang. После успешной компиляции, Rust выдаст двоичный исполняемый файл.

Для того, чтобы посмотреть на исполняемый файл на Linux, macOS и в PowerShell на Windows достаточно выполнить команду ls в командной строке. На Linux или macOS будет отображено два файла. В PowerShell на Windows, как и в Windows CMD - три файла.

$ ls
main  main.rs

В CMD на Windows следует ввести следующие команды:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

В обоих случаях видно: файл исходного кода с расширением .rs, исполняемый двоичный файл (main.exe в Windows, но main на всех других платформах), а в случаем использования Windows, ещё и файл включающий отладочную информацию с расширением .pdb.

Отсюда мы можем запустить нашу программу (исполняемый двоичный файл) main или main.exe соответствующей командой для Windows или иных ОС:

$ ./main # для Linux
> .\main.exe # для Windows

Если main.rs был с текстом “Hello, world!”, то строка Hello, world будет напечатана в терминале.

Если вы знакомы с динамическими языками вроде Ruby, Python или JavaScript, то, возможно, вам не доводилось выполнять компиляцию и запуск программы отдельными шагами. Rust является "заранее скомпилированным" (ahead-of-time compiled) языком. Это означает, что можно скомпилировать программу и передать исполняемый файл ещё кому-то, и получатели смогут его запустить, не имея локально установленного Rust. А если вы отдаёте файл .rb, .py или .js, то получателям необходимо иметь установленную реализацию для Ruby, Python или JavaScript соответственно. Но в этих языках нужна только одна команда для компиляции и запуска программы. Что ж, всё является компромиссом в дизайне языков.

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

Привет, Cargo!

Cargo является системой сборки и менеджером пакетов в Rust. Большая часть разработчиков используют данный инструмент для управления проектами, потому что Cargo выполняет для вас множество таких задач, как сборка кода, загрузка библиотек зависимостей, сборка этих библиотек и прочие. (Мы называем библиотеками то, что необходимо вашему коду как зависимость, dependencies.)

Простейшие Rust программы, вроде той, что мы уже написали, не имеют зависимостей. Если бы мы собрали "Hello, world!" проект с помощью Cargo, то сборка использовала бы часть возможностей Cargo: те её функции, которые выполняют только сборку кода. По мере того, как вы будете писать более сложные программы на Rust, вы будете добавлять в них разные зависимости. Если вы начнёте проект с использованием Cargo, то добавлять зависимости будет намного проще, чем без него.

Так как большая часть проектов использует Cargo, то остальная часть книги подразумевает, что вы также используете Cargo. Cargo устанавливается вместе с Rust при использовании официальных установщиков обсуждаемых в разделе "Установка Rust". Если вы установили Rust другим способом, то проверьте, работает ли он, введя команду проверки версии Cargo в терминале:

$ cargo --version

Если команда выдала номер версии, то значит Cargo установлен. Если вы видите ошибку, вроде command not found ("команда не найдена"), загляните в документацию для использованного вами способа установки, чтобы выполнить установку Cargo отдельно.

Создание проекта с помощью Cargo

Давайте создадим новый проект с помощью Cargo и посмотрим, как он отличается от нашего начального проекта "Hello, world!". Перейдите обратно в папку projects (или любую другую, где вы решили сохранять код). Затем, в любой операционный системе, запустите команду:

$ cargo new hello_cargo
$ cd hello_cargo

Первая команда создаёт новую папку с названием hello_cargo. Мы дали название проекту hello_cargo, поэтому Cargo создаёт свои файлы внутри папки с этим именем.

Перейдём в каталог hello_cargo и посмотрим файлы. Увидим, что Cargo сгенерировал два файла и одну директорию: файл Cargo.toml и каталог src с файлом main.rs внутри.

Кроме того, cargo инициализировал новый репозиторий Git вместе с файлом .gitignore. Файлы Git не будут сгенерированы, если вы запустите cargo new в существующем репозитории Git; вы можете изменить это поведение, используя cargo new --vcs=git.

Примечание: Git - это распространённая система контроля версий. Вы можете заставить cargo new использовать другую систему контроля версий или вообще отказаться от неё, используя флаг --vcs. Запустите cargo new --help, чтобы увидеть доступные параметры.

Откройте файл Cargo.toml в любом текстовом редакторе. Он должен выглядеть как код в листинге 1-2.

Cargo.toml:

[package]
name = "hello_cargo"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
edition = "2018"

[dependencies]

Листинг 1-2: Содержимое файла Cargo.toml сгенерированное командой cargo new

Это файл в формате TOML (Tom’s Obvious, Minimal Language), который является форматом конфигураций Cargo.

Первая строка, [package], является заголовочной секцией, которая указывает что следующие инструкции настраивают пакет. По мере добавления больше информации в данный файл, будет добавляться больше секций и инструкций (строк).

Следующие четыре строки секции указывают конфигурационную информацию, которая нужна Cargo для компиляции программы: имя (name), версия (version), кто написал и используемую редакцию (edition) Rust. Cargo получает ваше имя и эл.адрес (email) из окружения вашей ОС, так что если информация не корректна, исправьте эти данные и сохраните файл. Про строчку с ключом edition возможно узнать отдельно в Приложении E.

Последняя строка, [dependencies] является началом секции для списка любых зависимостей вашего проекта. В Rust, это внешние пакеты кода, на которые ссылаются ключевым словом crate. Нам не нужны никакие зависимости в данном проекте, но мы будем использовать их в первом проекте главы 2, так что нам пригодится данная секция зависимостей потом.

Откройте файл src/main.rs и загляните в него:

Название файла: src/main.rs

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

Cargo сгенерировал программу "Hello, world!", такую же как мы писали в листинге 1-1! Различиями между нашим предыдущим проектом и проектом, который генерирует Cargo является то, что Cargo помещает исходный код в каталог src и снабжает нас конфигурационным файлом Cargo.toml в корневой директории нашего проекта.

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

Если вы начали проект без использования Cargo, как мы делали для "Hello, world!" проекта, то можно конвертировать его в проект с использованием Cargo. Переместите код в подкаталог src и создайте соответствующий файл Cargo.toml в папке.

Сборка и запуск Cargo проекта

Посмотрим, в чем разница при сборке и запуске программы "Hello, world!" с помощью Cargo. В каталоге hello_cargo соберите проекта следующей командой:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

Данная команда создаёт выполняемый файл в папке target/debug/hello_cargo (или target\debug\hello_cargo.exe на Windows), а не в текущей директории проекта. Можно запустить исполняемый файл командой:

$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!

Если все хорошо, то Hello, world! печатается в терминале. Запуск команды cargo build в первый раз также приводит к созданию нового файла Cargo.lock в папке верхнего уровня. Данный файл хранит точные версии зависимостей вашего проекта. Так как у нас нет зависимостей, то файл пустой. Вы никогда не должны менять этот файл вручную: Cargo сам управляет его содержимым для вас.

Мы только что собрали проект командой cargo build и запустили его из ./target/debug/hello_cargo. Но мы также можем использовать команду cargo run для компиляции кода и затем его запуска одной командой:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

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

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Также Cargo предоставляет команду cargo check. Данная команда быстро проверяет ваш код, чтобы убедиться, что он компилируется, но не создаёт исполняемого файла:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

В каком случае не нужно создавать исполняемый файл? Часто команда cargo check является более быстрой по сравнению с cargo build, потому что она пропускает шаг создания исполняемого файла. В случае, если вы постоянно проверяете работу во время написания кода с помощью cargo check, то это ускоряет процесс! Таким образом многие разработчики запускают cargo check периодически, по мере того, как пишут программу, чтобы убедится, что она компилируется. А запускают команду cargo build, когда готовы создать исполняемый файл.

Повторим полученные знания про Cargo:

  • можно собирать проект, используя команду cargo build,
  • можно одновременно собирать и запускать проект одной командой cargo run,
  • можно собрать проект для проверки ошибок с помощью cargo check, не тратя время на кодогенерацию исполняемого файла,
  • cargo сохраняет результаты сборки не в директорию с исходным кодом, а в отдельный каталог target/debug.

Дополнительным преимуществом использования Cargo является то, что его команды одинаковы для разных операционных систем. С этой точки зрения, мы больше не будем предоставлять отдельные инструкции для Linux, macOS или Windows.

Сборка финальной версии (Release)

Когда проект, наконец, готов к релизу, можно использовать команду cargo build --release для его компиляции с оптимизацией. Данная команда создаёт исполняемый файл в папке target/release в отличии от папки target/debug. Оптимизации делают так, что Rust код работает быстрее, но их включение увеличивает время компиляции. По этой причине есть два отдельных профиля: один для разработки, когда нужно осуществлять пересборку быстро и часто, и другой, для сборки финальной программы, которую будете отдавать пользователям, которая готова к работе и будет выполняться максимально быстро. Если вы замеряете время выполнения вашего кода, убедитесь, что собрали проект с оптимизацией cargo build --release и тестируете исполняемый файл из папки target/release.

Cargo как конвенция

Для простых проектов Cargo не даёт большой пользы по сравнению с использованием rustc, но он докажет свою пользу как только ваши программы станут более запутанными. С помощью Cargo гораздо проще координировать сборку на сложных проектах, скомбинированных из множества внешних библиотек (crates).

Не смотря на то, что проект hello_cargo простой, теперь он использует большую часть реального инструментария, который вы будете повседневно использовать в вашей карьере, связанной с Rust. Когда потребуется работать над проектами размещёнными в сети, вы сможете просто использовать следующую последовательность команд для получения кода с помощью Git, перехода в каталог проекта, сборку проекта:

$ git clone example.org/someproject
$ cd someproject
$ cargo build

Для более детальной информации про Cargo, загляните в его документацию.

Итоги

Теперь вы готовы начать своё Rust путешествие! В данной главе вы изучили как:

  • установить последнюю стабильную версию Rust, используя rustup,
  • обновить Rust до последней версии,
  • открыть локально установленную документацию,
  • написать и запустить программу типа "Hello, world!", используя напрямую компилятор rustc,
  • создать и запустить новый проект, используя соглашения и команды Cargo.

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

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

Давайте погрузимся в 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 объясняет, как работают перечисления.

Общие концепции программирования

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

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

Ключевые слова

Язык Rust имеет набор ключевых слов (keywords), которые зарезервированы только для использования в языке, подобно тому, как это есть в других языках. Помните, что эти слова нельзя использовать в качестве названий переменных или функций. Большая часть этих слов имеет специальные значения и вы будете использовать их для выполнения различных задач в ваших программах. Некоторые из ключевых слов не имеют в данный момент связанной с ними функциональности, но были зарезервированы для функциональности, которая возможно будет добавлена в будущем. Список этих слов можно найти в приложении А.

Переменные и понятие изменяемости

Как было указано в Главе 2, по умолчанию все Rust переменные являются неизменяемыми (immutable). Это один из многих подходов Rust, который стимулирует вас писать код таким образом, чтобы использовать преимущества безопасности и лёгкого параллелизма, которые предлагает Rust. Тем не менее, у вас остаётся возможность сделать переменные изменяемыми (mutable). Давайте рассмотрим как и почему Rust поощряет неизменяемые переменные и почему вам иногда стоит отказаться от этого.

Когда переменная неизменяемая, то её значение нельзя менять, как только значение привязано к её имени. Приведём пример использования этого типа переменной. Для этого создадим новый проект variables в каталоге projects при помощи команды: cargo new variables.

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

Файл: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

Сохраните код программы и выполните команду cargo run. В командной строке вы увидите сообщение об ошибке:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         -
  |         |
  |         first assignment to `x`
  |         help: make this binding mutable: `mut x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable

error: aborting due to previous error

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

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

Данный пример показывает как компилятор помогает найти ошибки в программах. Несмотря на то, что ошибки компилятора могут вызывать разочарование, они означают, что ваша программа ещё не выполняет то, что вы от неё хотите. Ошибки не означают, что вы пока не являетесь хорошим программистом! Опытные разработчики Rust также получают ошибки компиляции.

Описание ошибки даёт понять, что причиной является то, что вы cannot assign twice to immutable variable x (не можете присвоить неизменяемой переменной новое значение), потому что вы попытались назначить второе значение неизменяемой переменной x.

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

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

Но изменяемость может быть очень полезной. Переменные являются неизменяемыми только по умолчанию. Аналогично как вы делали в Главе 2, можно сделать переменные изменяемыми добавлением ключевого слова mut перед названием переменной. В дополнение к возможности изменить значение, указание mut передаёт намерение будущим читателям кода, что другие части кода будут изменять значение этой переменной.

Например, изменим src/main.rs на следующий код:

Файл: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

Запустив программу, мы получим результат:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

Мы разрешили изменять значение, которое привязано к x со значения равного 5 на значение 6, когда стали использовать mut. В некоторых случаях, вам захочется сделать переменную изменяемой, потому что так становится проще писать код, в отличии от того, когда она осталась бы неизменяемой.

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

Различия между переменными и константами

Невозможность изменить значение переменной может напомнить другую концепцию программирования, которая есть в других языках: константы (constants). Подобно неизменяемым переменным, константы являются значениями привязанными к именам, которые не разрешено менять, но есть несколько отличий между константами и неизменяемыми переменными.

Первое, это то что с константами не разрешено использовать mut. Константы не являются не изменяемыми по умолчанию — они не изменяемые всегда.

Константы объявляются с помощью ключевого слова const вместо ключевого слова let и тип хранимых в них значений должен быть указан явно. Мы собираемся объяснить типы и аннотации типов в следующем разделе “Типы данных”, так что сейчас не волнуйтесь о деталях. Просто знайте, что для констант тип нужно всегда указывать явно.

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

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

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


#![allow(unused)]
fn main() {
const MAX_POINTS: u32 = 100_000;
}

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

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

Затенение (переменных)

Как вы видели в обучающем материале по угадыванию числа из раздела “Сравнение предположения с секретным числом” Главы 2, можно объявить переменную с тем же именем которое было использовано раньше и такая новая переменная затеняет предыдущую. Rust разработчики говорят, что первая переменная затенена (shadowed) второй переменной, что означает, что когда будут использовать такую переменную будет отображаться её второе значение, вместо первого. Можно затенять переменную, используя то же самое имя и повторяя использование ключевого слова let как в примере:

Файл: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    let x = x * 2;

    println!("The value of x is: {}", x);
}

Программа сначала связывает значение 5 с переменной x. Затем x затеняется повторением кода let x = с помощью использования начального значения и прибавления к нему 1, так что значение x становится равным 6. Третье выражение let также затеняет переменную x, умножением предыдущего значения на 2. Это даёт переменной x финальное значение равное 12. При запуске программы мы получим вывод:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x is: 12

Затенение отличается от объявления переменной с помощью mut, так как мы получим ошибку компиляции, если случайно попробуем переназначить значение без использования ключевого слова let. Используя let, можно выполнить несколько превращений над значением, при этом оставляя переменную неизменяемой, после того как все эти превращения завершены.

Другой разницей между mut и затенением является то, что мы создаём совершенно новую переменную, когда снова используем слово let (ещё одну). Мы можем даже изменить тип значения, но снова использовать предыдущее имя. К примеру, наша программа спрашивает пользователя сколько пробелов он хочет разместить между некоторым текстом, запрашивая символы пробела, но мы на самом деле хотим сохранить данный ввод как число:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

Данная конструкция разрешена, потому что первая переменная spaces является строковым типом, а вторая переменная spaces числового типа. Она является совершенно новой переменной с одинаковым именем, таким же какое было у первой переменной. Таким образом, затенение избавляет нас от необходимости придумывать разные имена, вроде spaces_str и spaces_num. Вместо этого можно использовать снова более простое имя spaces. Тем не менее, если попробовать использовать для этого mut как показано ниже, то мы получим ошибку компиляции:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

Ошибка говорит, что не разрешается менять тип переменной:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

error: aborting due to previous error

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

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

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

Типы данных

Любое значение в Rust является определённым типом данных (data type), которое говорит о том, какие данные указаны, так что Rust знает как с ними работать. Рассмотрим два подмножества тип данных: скалярные (простые) и составные (сложные).

Не забывайте, что Rust является статически типизированным (statically typed) языком. Это означает, что он должен знать типы всех переменных во время компиляции. Обычно компилятор может вывести (infer) какой тип мы хотим использовать, основываясь на значении и на том, как мы с ним работаем. В случаях, когда может быть выведено несколько типов, необходимо вручную добавлять аннотацию типа. Например, когда мы конвертировали String в число с помощью вызова parse в разделе "Сравнение предположения с загаданным номером" Главы 2, мы должны добавить такую аннотацию:


#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

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

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^ consider giving `guess` a type

error: aborting due to previous error

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

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

В будущем вы увидите различные аннотации для разных типов данных.

Скалярные типы данных

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

Целые числа

Целое число, integer, является числом без дробной составляющей. Мы использовали целочисленный тип в Главе 2, это был u32. Данное объявление типа указывает, что значение связанное с ним должно быть беззнаковым целым (unsigned integer). Типы знаковых целых (signed integer) начинаются с буквы i, вместо буквы u и занимают до 32 бит памяти. Таблица 3-1 показывает встроенные целые типы Rust. Каждый вариант в колонках Знаковый и Беззнаковый, к примеру i16, может использоваться для объявления значения целочисленного типа.

Таблица 3-1: целые типы Rust

РазмерЗнаковыйБеззнаковый
8-biti8u8
16 битi16u16
32 битаi32u32
64 битаi64u64
128 битi128u128
archisizeusize

Каждый вариант типа может быть со знаком или без знака, и имеет точный размер в битах. Определение характеристики типа как знаковый и без знаковый означает, что число данного типа может быть либо отрицательным, либо только положительным. Другими словами характеристика показывает, нужно ли числу иметь знак (знаковое) или оно будет только положительным и может быть представлено без знака (без знаковое). Это похоже на написание чисел на бумаге: когда знак важен, то число отображено со знаком плюс или минус. Однако, когда можно с уверенностью предположить, что число положительное, то оно отображается без знака. Знаковые числа сохраняются при помощи дополнительного кода знака перед числом.

Каждый знаковый вариант может хранить числа от -(2n - 1) до 2n -1 - 1 включительно, где n является количеством используемых бит. Так i8 может хранить числа от -(27) до 27 - 1, что равно от -128 до 127. Беззнаковые варианты могут хранить числа от 0 до 2n - 1, так u8 может хранить числа от 0 до 28 - 1, т.е. от 0 до 255.

Также есть типы isize и usize, размер которых зависит от компьютера, на котором работает ваша программа: они имеют размер 64 бит, если операционная система использует 64-битную архитектуру, и 32 бита, если 32-битную.

Вы можете записывать целочисленные литералы в любой форме из таблицы 3-2. Заметим, что все числовые литералы, кроме байтового, позволяют использовать суффиксы, такие как 57u8 и _ в качестве визуального разделителя, например 1_000.

Таблица 3-2: целочисленные литералы в Rust

Числовые литералыПример
Десятичный98_222
Шестнадцатеричный0xff
Восьмеричный0o77
Бинарный0b1111_0000
Байтовый (только u8)b'A'

Как узнать, какой литерал необходимо использовать? Если вы не уверены, то вариант предоставляемый в Rust по умолчанию является хорошим выбором. Для целых чисел типом по умолчанию является i32: в общем случае, данный тип самый быстрый даже на 64-битных системах. Основной ситуацией, когда вам необходимо использование isize или usize, является индексирование некоторых коллекций.

Целочисленное переполнение

Предположим, у нас есть переменная типа u8, которая может сохранять значения между 0 и 255. Если вы попытаетесь задать переменной значение вне данного диапазона, например в 256, то произойдёт целочисленное переполнение. В Rust есть несколько интересных правил, связанных с этим поведением. При компиляции кода в режиме отладки, компилятор Rust включает проверки, которые приводят к панике во время выполнения, если случится целочисленное переполнение. В Rust термин "паниковать" означает, что программа сразу завершается с ошибкой. Мы обсудим "панику" более детально разделе "Необрабатываемые ошибки с помощью макроса panic!" главы 9.

При компиляции кода в финальную версию при помощи флага --release, Rust не включает проверки на целочисленное переполнение, приводящие к панике. Вместо этого, в случае переполнения Rust выполняет оборачивание дополнительного кода. Если кратко, то значения больше, чем максимальное значение, которое может хранить тип, превращается в минимальное значение данного типа. Для типа u8, число 256 превращается в 0, 257 станет 1 и так далее. Программа не будет "паниковать", но переменная получит значение, которое вы возможно не ожидали. Полагаться на такое поведение считается ошибкой.

Чтобы явно обработать возможность переполнения, вы можете использовать следующие семейства методов, которые стандартная библиотека предоставляет для примитивных числовых типов:

  • Обернуть все режимы с помощью wrapping_* методов, например wrapping_add
  • Вернуть значение None в случае переполнения при помощи методов checked_*
  • Вернуть значение и логическое значение, указывающее, было ли переполнение с помощью методов overflowing_*
  • Подавить минимальные или максимальные значения с помощью методов saturating_*

Числа с плавающей запятой

В Rust есть два примитивных типа для чисел с плавающей точкой (floating-point numbers), которые являются числами с десятичными точками. Числа с плавающей точкой в Rust представлены типами f32 и f64, имеющими размер 32 и 64 бита соответственно. Типом по умолчанию является f64, потому что все современные CPU работают с ним приблизительно с такой же скоростью, как и с f32, но с большей точностью.

Пример для чисел с плавающей точкой в действии:

Файл: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

Числа с плавающей точкой представлены согласно стандарту IEEE-754. Тип f32 является числом с плавающей точкой одинарной точности, а f64 имеет двойную точность.

Числовые операции

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

Файл: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;

    // remainder
    let remainder = 43 % 5;
}

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

Логический тип данных

Как и в большинстве языков программирования, логический тип в Rust может иметь два значения: true и false, и занимает в памяти один байт. Логический тип в Rust аннотируется при помощи bool. Например:

Файл: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

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

Символьный тип данных

До этого момента мы работали только с числами, но Rust поддерживает также и буквы. Тип char является самым примитивным буквенным типом и следующий код показывает как им пользоваться. (Заметим, что литералы char определяются с помощью одинарных кавычек, в отличии от строк где используются двойные кавычки.)

Файл: src/main.rs

fn main() {
    let c = 'z';
    let z = 'ℤ';
    let heart_eyed_cat = '😻';
}

Тип char имеет размер в четыре байта и представляет собой скалярное юникод значение (Unicode Scalar Value), а значит, он может представить больше символов, чем есть в ASCII. Акцентированные буквы, китайские, японские и корейские символы, эмодзи и пробелы нулевой ширины - всё является корректными значениями char в Rust. Скалярное юникод значение имеет диапазон от U+0000 до U+D7FF и от U+E000 до U+10FFFF включительно. Тем не менее, "символ" на самом деле не является концептом в Юникод, так что человеческая интуиция о том, что такое "символ" может не совпадать с тем, чем является тип char в Rust. Более детально мы обсудим эту тему в разделе "Сохранение UTF-8 текста в строки" Главы 8.

Сложные типы данных

Сложные типы могут группировать несколько значений в один тип. В Rust есть два примитивных сложных (комбинированных) типа: кортежи и массивы.

Кортежи

Кортеж является общим способом совместной группировки нескольких значений различного типа в единый комбинированный тип. Кортежи имеют фиксированную длину: после объявления они не могут расти или уменьшаться в размере.

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

Файл: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

Переменной с именем tup привязывается весь кортеж, потому что кортеж является единым комбинированным элементом. Чтобы получить отдельные значения из кортежа, можно использовать сопоставление с образцом для деструктурирования значений кортежа, как в примере:

Файл: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {}", y);
}

Программа создаёт кортеж, привязывает его к переменной tup. Затем в let используется шаблон для превращения tup в три отдельных переменные: x, y и z. Такого рода операция называется деструктуризацией (destructuring), потому что она разрушает один кортеж на три части. В конце программа печатает значение y, которое равно 6.4.

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

Файл: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

Программа создаёт кортеж с именем x, а затем создаёт новые переменные для каждого элемента, используя соответствующие индексы. Как и в большинстве языков, первый индекс в кортеже - это ноль.

Массивы

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

В Rust, значения, хранящиеся в массиве, записываются как список разделённых запятой значений внутри квадратных скобок:

Файл: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

Массивы являются полезными, когда вы хотите разместить данные в стеке, вместо выделения памяти в куче (мы обсудим стек и кучу в Главе 4) или когда мы хотим быть уверенными, что у нас есть место под фиксированное количество элементов. Массив не такой гибкий, как вектор. Вектор является похожим типом для коллекций, предоставленным стандартной библиотекой, которому позволено увеличиваться или уменьшаться в размере. Если вы не уверены, использовать массив или вектор, то возможно следует воспользоваться вектором. В Главе 8 обсуждаются вектора более детально.

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


#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

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


#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

Здесь, i32 является типом каждого элемента массива. После точки с запятой указано число 5 показывающее, что массив содержит 5 элементов.

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


#![allow(unused)]
fn main() {
let a = [3; 5];
}

Массив в переменной a будет включать 5 элементов, значение которых будет равно 3. Данная запись аналогична коду let a = [3, 3, 3, 3, 3];, но является более краткой.

Доступ к элементам массива

Массив является единым блоком памяти выделенным на стеке. Можно получать доступ к элементам используя индекс:

Файл: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

В данном примере, переменная first получит значение равное 1, потому что это значение доступно по индексу [0] из массива. Переменная с именем second получит значение равное 2 из массива по индексу [1].

Некорректный доступ к элементу массива

Что произойдёт, если вы попытаетесь получить доступ к элементу массива, который находится за пределами массива? Допустим, вы изменили пример на следующий, в котором используется код, аналогичный игре в угадывание в главе 2, для получения индекса массива от пользователя:

Файл: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

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

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!(
        "The value of the element at index {} is: {}",
        index, element
    );
}

Этот код успешно компилируется. Если запустить этот код с помощью cargo run и ввести 0, 1, 2, 3 или 4, то программа распечатает соответствующее значение по указанному индексу из массива. Если вы введёте число за границей массива, например 10, то вы увидите следующий результат:

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Программа привела к ошибке времени выполнения в случае использования недопустимого значения для операции индексации массива. Программа завершилась с сообщением об ошибке и не выполнила последний оператор println!. Когда вы пытаетесь получить доступ к элементу с помощью индексации, Rust проверяет, что указанный вами индекс меньше длины массива. Если индекс больше или равен длине, Rust программа паникует. Эта проверка должна происходить во время выполнения, особенно в данном случае, потому что компилятор не может знать, какое значение позже введёт пользователь при выполнении кода.

Это первый пример на деле демонстрирующий принципы безопасности в Rust. Во многих низкоуровневых языках, такие типы проверок не выполняются, и когда вы предоставляете некорректный индекс, можно получить доступ к не корректной памяти. Rust защищает вас от такого рода ошибок тем, что немедленно завершает программу, вместо того, чтобы позволить получить такой доступ и продолжить выполнение. Обсуждение обработки ошибок в Rust ведётся в Главе 9.

Функции

Функция - это ключевые части Rust кода. Вы, конечно, знакомы с самой важной функцией. Это функция main, которая является точкой входа программ. Также вы уже познакомились с ключевым словом fn, позволяющим объявить новую функцию.

В Rust используется т.н."змеиный" (snake case) стиль написания функций и переменных: это когда все слова пишутся в нижнем регистре и слова в многословных обозначениях разделяются нижним подчёркиванием. Пример объявления функции:

Файл: src/main.rs

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

    another_function();
}

fn another_function() {
    println!("Another function.");
}

Определение функции начинается с fn и состоит из группы скобок после имени функции. Фигурные скобки указывают, где начинается и заканчивается тело функции.

Мы можем вызвать любую функцию которую мы объявили, введя её имя и скобки за именем. Так как another_function определена в программе, её можно вызвать из функции main. Заметьте, что мы определили another_function после функции main в исходном коде; мы также могли бы определить её до main. Rust не волнует, в каком месте файла с кодом вы определяете свои функции, главное только то, что они где-то определены.

Создадим новый проект с названием functions для дальнейшего изучения функций. Поместите пример another_function в файл src/main.rs и запустите его. Вы должны увидеть следующий вывод:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

Строчки кода выполняются в том порядке, в котором они появляются в функции main. Сначала печатается сообщение "Hello, world!", а затем вызывается another_function и она также печатает сообщение.

Параметры функции

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

Переписанная версия функции another_function показывает, как параметры выглядят в Rust:

Файл: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {}", x);
}

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

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

Объявление функции another_function имеет один параметр с именем x. Тип параметра x определён как i32. Когда значение 5 передаётся в функцию another_function, то макрос println! помещает число 5 в том месте форматированной строки, где находится пара фигурных скобок.

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

Когда у функции множество параметров, то они разделяются с помощью запятой, как тут:

Файл: src/main.rs

fn main() {
    another_function(5, 6);
}

fn another_function(x: i32, y: i32) {
    println!("The value of x is: {}", x);
    println!("The value of y is: {}", y);
}

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

Попробуем запустить этот код. Замените программу из проекта functions в файле src/main.rs следующим примером и запустите его командой cargo run:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The value of x is: 5
The value of y is: 6

Так как мы вызвали функцию со значением 5 в параметре x а число 6 было передано в y, две строки в функции напечатали эти два значения.

Тело функции состоит из операторов и выражений

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

На самом деле мы уже использовали операторы и выражения. Операторы (Statements) - это инструкции, которые выполняют действия, но не возвращают значение. Выражения (Expressions) вычисляются в результирующее значение, которое возвращается. Рассмотрим примеры.

Создадим переменную и присвоим ей значение с помощью ключевого слова let. В листинге 3-1 let y = 6; является оператором.

Файл: src/main.rs

fn main() {
    let y = 6;
}

Листинг 3-1: Объявление функции main включающей один оператор

Определение функции также является оператором. Весь предыдущий пример тоже является оператором.

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

Файл: src/main.rs

fn main() {
    let x = (let y = 6);
}

Если вы запустите эту программу, то ошибка будет выглядеть так:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0658]: `let` expressions in this position are experimental
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^^^^^^^
  |
  = note: see issue #53667 <https://github.com/rust-lang/rust/issues/53667> for more information
  = help: you can write `matches!(<expr>, <pattern>)` instead of `let <pattern> = <expr>`

error: expected expression, found statement (`let`)
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^^^^^^^
  |
  = note: variable declaration using `let` is a statement

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^^^^^^^^^^^ help: remove these parentheses
  |
  = note: `#[warn(unused_parens)]` on by default

error: aborting due to 2 previous errors; 1 warning emitted

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

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

Оператор let y = 6не возвращает значения, так что нет ничего что можно было бы назначить переменной x. Такое поведение отлично от некоторых других языков, типа C и Ruby, где выражение присваивания возвращает присваиваемое значение. В таких языках можно писать код x = y = 6 и обе переменные x и y будут иметь одинаковое значение 6; но это не так в Rust.

Выражения обычно во что-то вычисляются и составляют большую часть кода, который вы будете писать в Rust. Рассмотрим простую математическую операцию 5 + 6, которая является выражением вычисляющим значение 11. Выражения могут быть частью операторов: в листинге 3-1 число 6 в операторе let y = 6; является выражением, которое вычисляется в значение 6. Вызов функции является выражением. Вызов макроса является выражением. Блок используемый для создания областей видимости {} также является выражением, например:

Файл: src/main.rs

fn main() {
    let x = 5;

    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {}", y);
}

Это выражение:

{
    let x = 3;
    x + 1
}

является блоком, который в данном случае вычисляется в число 4. Его значение привязывается, назначается переменной y как часть оператора let. Заметьте, что строка x + 1 не имеет точки с запятой в конце, что отличается от большинства строк, которые вы видели до сих пор. Выражения не включают точки с запятой в их завершении. Если её добавить в конце выражения, вы превратите его в оператор, который не будет возвращать значение. Имейте это в виду, по мере дальнейшего изучения возвращаемых значений функций и выражений.

Функции возвращающие значения

Функции могут возвращать значения в вызывающий их код. Мы не именуем возвращаемые значения, но мы объявляем их тип после символа (->). В Rust, возвращаемое значение функции является синонимом значения последнего выражения в блоке тела функции. Можно выполнить ранний возврат из функции используя ключевое слово return и указав значение, но большинство функций явно возвращает последнее выражение в теле. Вот пример функции возвращающей значение:

Файл: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {}", x);
}

В коде функции five нет вызовов функций, макросов или даже операторов let - есть только одно число 5. Это является абсолютно корректной функцией в Rust. Заметьте, что возвращаемый тип у данной функции определён как -> i32. Попробуйте запустить; вывод будет таким:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

Число 5 в функции five является возвращаемым значением функции (можно сказать что функция five вычисляется в 5), вот почему возвращаемым типом является i32. Рассмотрим пример более детально. Есть два важных момента: первый строка let x = five(); показывает, что мы используем значение возвращаемое функцией для инициализации переменной. Так как функция five возвращает 5, то эта строка эквивалентна следующей:


#![allow(unused)]
fn main() {
let x = 5;
}

Второй момент, функция five не имеет входных параметров и определяет тип возвращаемого значения. Само тело функции - единственная 5 без точки с запятой. Т.к. мы хотим, чтобы функция возвращала значение, последняя строка функции должна быть выражением (не иметь после себя знак точки с запятой). В данной функции мы хотим вернуть 5 - по этому 5 должно быть выражением (не должно иметь после себя знак точки с запятой).

Рассмотрим другой пример:

Файл: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {}", x);
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

Запуск кода выведет The value of x is: 6. Но если поместить точку с запятой в конец строки, включающей x + 1, то это изменит её с выражения на оператор и мы получим ошибку.

Файл: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {}", x);
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

Компиляция данного кода вызывает следующую ошибку:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: consider removing this semicolon

error: aborting due to previous error

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

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

Главное сообщение в ошибке, “mismatched types” раскрывает основную проблему этого кода. Определение функции plus_one обещает, что она вернёт значение типа i32, однако теперь уже она заканчивается оператором, а оператор не вычисляет значение, что отображено символами пустого кортежа (empty tuple) - круглыми скобками () в описании ошибки. Поэтому, здесь ничего не возвращается, что противоречит определению функции и приводит к ошибке. В данном выводе Rust предлагает свою помощь в сообщении, чтобы исправить проблему: он советует убрать точку с запятой (сделать оператор вновь выражением), что должно исправить ошибку.

Комментарии

Все хорошие программисты, создавая программный код, стремятся сделать его простыми понятным. Бывают всё же случаи, когда дополнительное описание просто необходимо. В этих случаях программисты пишут заметки (или как их ещё называют, комментарии). Комментарии игнорируются компилятором, но для тех кто код читает - это очень важная часть документации.

Пример простого комментария:


#![allow(unused)]
fn main() {
// Hello, world.
}

В Rust комментарии должны начинаться двумя символами // и простираются до конца строки. Чтобы комментарии поместились на более чем одной строке, необходимо разместить их на каждой строке, как в примере:


#![allow(unused)]
fn main() {
// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.
}

Комментарии могут быть размещены в конце строки имеющей код:

Файл: src/main.rs

fn main() {
    let lucky_number = 7; // I’m feeling lucky today
}

Но чаще вы увидите их использованные в следующем формате, здесь комментарий размещён на отдельной строке над кодом, который комментируется:

Файл: src/main.rs

fn main() {
    // I’m feeling lucky today
    let lucky_number = 7;
}

Также в Rust есть другой тип комментариев - документирующие комментарии, они используются в документации, и их обсуждаются разделе "Публикация пакета на Crates.io" Главы 14.

Управление выполнением кода

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

Выражения if

Выражение if позволяет разделить ваш код на ветви и выполнять ту или иную ветвь кода в зависимости от условий. Вы предоставляете условие и затем пишите утверждение вида "если это условие выполняется/верное, выполнить данный блок кода; если не выполняется, не выполнять этот блок кода".

Чтобы изучить выражение if, создадим новый проект с названием branches в каталоге наших проектов. В файл src/main.rs поместите данный код:

Файл: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Все выражения if начинаются с ключевого слова if, за которым следует логическое условие. В данном случае, условие проверяет имеет ли переменная number значение меньше, чем 5. Блок кода, который мы хотим выполнить, если условие истинно, размещён сразу после условия в фигурных скобках. Блоки кода ассоциированные с условиями в выражении if иногда называют ветками/arms, подобно веткам в выражении match из секции “Сравнение предположения и загаданный номер” главы 2.

Опционально можно включить выражение else, которое мы используем в данном примере, чтобы предоставить программе альтернативный блок выполнения кода, выполняющийся при ложном условии. Если не написать выражение else и условие будет ложным, то программа просто пропустит блок if и перейдёт к выполнению кода размещённого дальше.

Результат работы программы:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

Попробуем поменять число number в значение, которое сделает условие ложным (false) и посмотрим, что будет:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Результат работы программы:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

Также стоит отметить, что условие в этом коде должно быть булевым типом bool . Если условие не будет bool, то вы получите ошибку. Например, попробуйте запустить следующий код:

Файл: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

На этот раз условие if вычисляется в значение 3 и Rust генерирует ошибку:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

error: aborting due to previous error

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

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

Ошибка говорит, что Rust ожидал тип bool, но получил значение целочисленного типа. В отличии от других языков вроде Ruby и JavaScript, Rust не будет пытаться автоматически конвертировать не булевые типы в булевые. Необходимо быть явными и всегда предоставлять значение типа bool в выражение if в качестве условия. Если нужно выполнить блок if, когда номер не равен значению 0, то можно изменить выражение if следующим образом:

Файл: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

Будет выведена следующая строка number was something other than zero.

Использование выражений else if

Можно получить множество условий, комбинируя выражения if и else, в конструкцию else if. Например:

Файл: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

У этой программы есть четыре возможных пути выполнения. После её запуска вы должны увидеть следующий результат:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

Во время выполнения, программа проверяет каждое выражение if по порядку и выполняет первый блок для которого условие вычисляется в истинное. Заметьте, что не смотря на то что 6 делится на 2, мы не увидим вывод number is divisible by 2 , также не увидим вывод текстаnumber is not divisible by 4, 3, or 2 и блока else. Причина в том, что Rust выполняет блок только для первого встретившегося истинного условия и как только он найден, то он не проверяет остальные варианты.

Использование слишком большого количества выражений else if загромождает код. Поэтому, если есть более чем одна ветвь, то скорее всего можно переделать код. Глава 6 объясняет мощную конструкцию Rust для подобных случаев, которая называется match.

Использование if в let-операторах

Так как if является выражением (а значит возвращает значение), то его можно использовать в правой части кода оператора let, как в листинге 3-2.

Файл: src/main.rs

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {}", number);
}

Листинг 3-2: Присвоение результата if-выражения переменной при её инициализации

Переменная number будет привязана к значению, которое является результатом выражения if. Запустим код и посмотрим, что происходит:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

Запомните, что блоки кода вычисляются последним выражением внутри их, и числа сами по себе также являются выражениями. В данном случае, значение всего выражения if зависит от того, какой блок выполняется. Это значит, что значения которые могут быть результатом каждой ветви выражения if должны иметь одинаковый тип. В листинге 3-2 результирующим типом обоих ветвей выражения if и else было целое число типа i32. Если типы в ветках не будут совпадать как в примере, то будет ошибка:

Файл: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("The value of number is: {}", number);
}

При попытке компиляции этого кода, мы получим ошибку. Ветви if и else имеют не совместимые типы значений и компилятор Rust точно указывает, где находится проблема в программе:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

error: aborting due to previous error

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

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

Выражение в блоке if вычисляется как целое число, а выражение в блоке else вычисляется как строка. Это не будет работать, потому что переменные должны иметь одинаковый тип. Rust должен знать во время компиляции, какой тип имеет переменная number, поэтому он может проверить во время компиляции, что её тип корректен везде, где мы используем number. Rust не сможет сделать этого, если тип number может быть определён только во время выполнения. Тогда компилятор был бы более сложным и давал бы меньше гарантий о коде, потому что должен был бы отслеживать несколько гипотетических типов для любой переменной.

Повторение выполнения кода с помощью циклов

Довольно часто бывает полезно выполнить блок кода более одного раза. Для такой задачи Rust предоставляет несколько разновидностей циклов (loops). Цикл выполняет код внутри тела цикла от начала тела и до конца, а затем немедленно возвращается обратно в начало своего тела. Для экспериментов создадим новый проект с именем loops.

Rust имеет три вида циклов: loop, while и for. Рассмотрим каждый в отдельности.

Повторение выполнения кода с помощью loop

Ключевое слово loop указывает Rust выполнять блок кода снова и снова до бесконечности, или пока вы явно не скажете ему остановиться.

В качестве примера, измените код файла src/main.rs в каталоге проекта loops на код ниже:

Файл: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

После запуска программы, мы увидим что сообщение again! будет печататься снова и снова без остановки, пока вы не остановите программу. Большинство терминалов поддерживает клавиатурное сокращение ctrl-c для прерывания работы программы, которая ушла в бесконечный цикл. Попробуйте сами:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.29s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

Обозначение ^C представляет собой место, где вы нажали сочетание клавиш ctrl-c. Вы могли увидеть или не увидеть напечатанное слово again! после вывода ^C, это зависит от того, находился ли код в цикле или нет, на момент поступления сигнала прерывания работы программы.

К счастью, Rust предоставляет другой более надёжный способ выхода из цикла. Можно поместить ключевое слово break внутрь цикла, чтобы сообщить программе когда необходимо остановить выполнение цикла. Вспомните, как мы использовали это ключевое слово в игре по угадыванию числа (в разделе “Выход после правильной догадки” Главы 2) для выхода из программы, когда пользователь выиграл игру, угадав правильное число.

Возврат чисел из цикла

Одно из применений цикла loop - выполнить повторение какой-либо операции (в том числе той, которая может вызвать ошибку, например, операция определения закончил поток свою работу или нет). В то же время, вам может понадобиться передать результат этой операции остальной части вашего кода. Чтобы это сделать можно добавить значение, которое вы хотите вернуть после выражения break, которое используется для остановки цикла; данное значение будет возвращено из цикла и мы сможем его использовать. Как это сделать показано ниже:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {}", result);
}

Перед циклом объявляется переменная с именем counter и её значение инициализируется в 0. Затем объявляется переменная с именем result для хранения значения, возвращаемого из цикла. На каждом проходе цикла добавляется 1 к переменной counter и затем проверяется, равен ли этот счётчик значению 10. Если равен, то используется ключевое слово break со значением counter * 2. После цикла мы используем точку с запятой чтобы завершить выражение которое назначает значение переменной result. В итоге печатается значение из переменной result, которое в данном случае равно 20.

Циклы по условию while

Часто бывает полезно вычислять (проверять) условие внутри цикла. Пока условие выполняется, цикл продолжается. Когда условие перестаёт быть истинным, программа вызывает break и останавливает цикл. Данный тип цикла может быть реализован используя комбинацию из loop, if, else и break. Вы можете попробовать прибегнуть к такому методу в своей программе, если хотите.

Тем не менее, данный шаблон является настолько общим, что в Rust есть встроенная в язык конструкция, она называется циклом while. Листинг 3-3 использует while: программа выполняет цикл три раза, каждый раз уменьшая счётчик и печатая текст в терминал, а затем по завершению цикла она печатает другое сообщение и завершается.

Файл: src/main.rs

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{}!", number);

        number -= 1;
    }

    println!("LIFTOFF!!!");
}

Листинг 3-3: Использует цикл while для выполнения кода, пока условие истинно

Данная конструкция устраняет множество вложенностей, которые понадобились бы при использовании loop, if, else и break и является более понятной. Пока условие является истинным, код выполняется; иначе происходит выход из цикла.

Цикл по элементам коллекции с помощью for

Можно использовать конструкцию while для прохода по элементам коллекции, например такой, как массив. Посмотрим на листинг 3-4.

Файл: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}

Листинг 3-4: Проход по элементам коллекции используя цикл while

Данный код проходит по всем элементам массива. Он начинается с индекса 0 и затем идёт далее, пока не достигнет последнего индекса массива (когда условие index < 5 больше не является истинным). Запуск данного кода печатает каждый элемента массива:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

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

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

В качестве более краткой альтернативы, можно использовать цикл for и выполнять некоторый код для каждого элемента в коллекции. Цикл for использующий такой подход показан в листинге 3-5.

Файл: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a.iter() {
        println!("the value is: {}", element);
    }
}

Листинг 3-5: Проход по всем элементам коллекции используя цикл for

При запуске данного кода, мы увидим вывод подобный листингу 3-4. Более важно то, что мы увеличили безопасность кода и уменьшили шанс появления ошибки, которая могла бы получится из-за выхода за границы массива или прохода коллекции не до конца и пропуска некоторых элементов.

Например, если обновить объявление массива a так чтобы он хранил четыре элемента в коде листинга 3-4, но забыть обновить условие while index < 4, то код завершится паникой. Используя цикл for вам не придётся помнить, что необходимо изменение любого другого кода, при изменении количества элементов в массиве.

Безопасность и краткость цикла for делает его наиболее используемой конструкцией циклов в Rust. Даже в ситуации в которой вы хотите запустить некоторый код определённое количество раз, как в примере обратного счёта где используется цикл while в листинге 3-3, большая часть разработчиков Rust использовала бы цикл for. Способом сделать это, было бы использование типа Range, который является типом предоставляемым стандартной библиотекой генерирующим все числа последовательности начиная от первого числа и заканчивая последним числом, которое само не включается в диапазон.

Вот так может выглядеть реализация обратного отсчёта с применением цикла for и метода rev который изменит последовательность диапазона на обратную:

Файл: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{}!", number);
    }
    println!("LIFTOFF!!!");
}

Данный код выглядит лучше, не так ли?

Итоги

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

  • конвертер температур из единиц Фаренгейта в единицы Цельсия,
  • Генератор чисел Фибоначчи.
  • генератор строк сказки "12 дней Рождества" использующий преимущество повторяющихся строк в сказке.

Если вы готовы двигаться далее, то в следующей главе мы расскажем о концепции языка Rust, которая отсутствует в других языках - это владение.

Понимание владения

Владение является наиболее уникальной особенностью языка Rust. Благодаря ей в Rust осуществляется безопасная работа с памятью без необходимости использования автоматической системы сборки мусора (garbage collector). Тем не менее, важно понимать как владение работает в Rust. В этой главе мы расскажем о владении, а также затронем несколько связанных с ней функциональностей, таких как: заимствование и срезы, а также разберёмся каким образом Rust распределяет данные в памяти.

Что же такое владение?

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

Все программы во время выполнения используют память компьютера и используют разные подходы для управления своей памятью. В одних языках программирования для этой цели используют систему сборки мусора (garbage collection, GC) постоянно следящую за памятью программы, которая больше не используется программой. В других языках программист должен сам явно запрашивать и освобождать память. Rust же использует третий подход: память управляется с помощью системы владения с набором правил, которые компилятор проверяет только во время компиляции программы. Ни одно из правил владения не замедляет выполнение программы.

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

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

Стек и куча (heap)

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

Стек и куча являются частями памяти компьютера, которая доступна вашему коду во время выполнения, но они структурированы по разному. Стек сохраняет значения в порядке получения данных и удаляет их в обратном порядке. Такого рода концепция известна как последний зашёл, первый вышел (last in, first out). Думайте о стеке как о стопке тарелок: при добавлении тарелок вы размещаете их сверху стопки, а когда тарелка нужна берете её сверху. Добавление и удаление тарелок из середины или снизу запрещено и не работает! Добавление данных называется помещением в стек (pushing onto), а удаление называется извлечением из стека (popping off).

Все данные сохраняемые в стеке должны быть известны и иметь фиксированный размер. Данные с неизвестным размером во время компиляции или размером, который может изменится в ходе выполнения программы должны сохраняться в куче. Куча является менее организованной: при размещении данных в куче запрашивается определённое количество памяти. Операционная система находит пустой участок кучи, являющийся достаточно большим, помечает его как используемый и возвращает указатель, который является адресом данного участка. Данный процесс называется выделением в куче и иногда сокращённо называется просто выделение (allocating). Размещение значений в стеке не считается выделением. По причине того, что указатель имеет известный, фиксированный размер, его можно сохранить в стеке, но когда вам нужны сами данные необходимо проследовать по указателю.

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

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

Доступ данных в куче является более медленным, чем в стеке, потому что необходимо сначала проследовать по указателю для получения данных. Современные процессоры работают быстрее, если они меньше "прыгают" по памяти. Продолжая аналогию, представьте официанта в ресторане, который принимает заказы с нескольких столов по мере их поступления. Для официанта наиболее эффективным было бы получить сразу все заказы со стола и уже затем перейти к следующему столу, не возвращаясь к гостю с первого стола который вдруг вспомнил, что ему ещё надо заказать десерт. Принимать один заказ со стола A, затем со стола B, а затем снова со стола A и снова со стола B будет гораздо более медленным процессом. Кроме того, процессор может лучше выполнить работу, если оперирует данными которые расположены в памяти близко к другим данным (подобно тому как в стеке), а не далеко (как это может быть в куче). Выделение большого количества памяти в куче также занимает время.

При вызове функции значения передаваемые в неё (потенциально включая и указатели на данные в куче) и локальные переменные функции размещаются в стеке. Когда функция завершается, эти значения извлекаются из стека.

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

Правила владения

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

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

Область видимости переменной

Мы уже видели как область видимости работает на примере Rust программы в Главе 2. После прохождения базового синтаксиса, мы не будем включать в примеры код функции fn main() {, так что если вы будете следовать примерам, вам нужно будет поместить следующие примеры внутрь функции main самостоятельно. В результате наш пример будут немного короче и мы сможем фокусироваться на деталях, а не на шаблонном коде.

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


#![allow(unused)]
fn main() {
let s = "hello";
}

Переменная s ссылается на строковый литерал и значение данной переменной жёстко задано в коде программы. Переменная считается действительной с момента её объявления до конца текущей области видимости. В листинге 4-1 есть комментарии с аннотациями где переменная s является действительной.

fn main() {
    {                      // s is not valid here, it’s not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

Листинг 4-1: переменная и область видимости в которой она действительна

Другими словами, здесь есть два важных момента:

  • когда переменная s появляется в области видимости, она считается действительной,
  • она остаётся действительной до момента выхода за границы этой области.

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

Тип данных String

Для иллюстрации правил владения нужен тип данных более сложный чем, те которые были в разделе "Типы данных" Главы 3. Типы описанные ранее, являются типами сохраняемыми в стеке и извлекаемые из него, когда их область видимости заканчивается. Но мы хотим рассмотреть данные сохранённые в куче и разобраться в том как Rust определяет, в какой момент нужно очищать эти данные.

Воспользуемся типом String в качестве примера и сконцентрируемся на частях String относящихся ко владению. Данные аспекты применимы и для более сложных типов данных, не важно предоставлены ли они из стандартной библиотеки или созданы вами. Мы ещё обсудим более детально тип String в Главе 8.

Мы видели строковые литералы в которых значение строки жёстко закодировано в программе. Строковые литералы удобны, но не подходят для любой ситуации в которой мы бы хотели использовать текст. Одна из причин - неизменяемость данных литерала. Другая причина в том, что не любое строковое значение может быть известным при написании кода: например, что если хочется получить ввод пользователя и сохранить его? В данной ситуации Rust имеет второй строковый тип String. Память для значений этого типа выделяется в куче, так что можно сохранять количество текста неизвестное во время компиляции. Можно создать String из строкового литерала используя функцию from, вот так:


#![allow(unused)]
fn main() {
let s = String::from("hello");
}

Два двоеточия (::) является оператором, который позволяет воспользоваться в текущем пространстве имён функцией from для типа String вместо использования некоторого имени функции string_from. Мы обсудим синтаксис детальнее в разделе "Синтаксис методов" Главы 5 и когда поговорим про пространства имён с модулями "Путь для обращения к элементу в дереве модулей" Главы 7.

Такие строки могут быть изменены:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{}", s); // This will print `hello, world!`
}

В чем здесь разница? Почему String можно менять, а литерал нельзя? Разница в том, как эти два типа работают с памятью.

Память и способы её выделения

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

Чтобы поддерживать изменяемый, увеличивающийся кусок текста типа String, ему необходимо выделять память в куче для всего содержимого (объем которого неизвестен во время компиляции). Это означает, что:

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

Первая часть сделана нами, когда вызывается String::from: эта реализация запрашивает необходимую память. Это является довольно универсальным подходом в языках программирования.

Тем не менее, вторая часть отличается. В языках со сборщиком мусора, сборщик отслеживает и очищает память, которая больше не используется и нам не нужно заботиться об этом процессе. Без сборщика мусора, мы отвечаем за определение момента, когда память больше не используется и вызываем код явно возвращающий память, также как когда бы запрашивали её. Корректное выполнение этих действий было исторически сложной проблемой программирования. Если забываем освободить, то теряем память. Если освободим слишком рано, то получим недействительную переменную. Если освободим дважды, то это тоже будет ошибкой. Нужно связать ровно одно выделение (allocate) с ровно одним освобождением (free).

Rust выбирает другой путь: память автоматически возвращается как только переменная владеющая памятью выходит из области видимости. Вот версия примера с областью видимости из листинга 4-1 использующего тип String вместо строкового литерала:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

Здесь есть естественная точка в которой можно вернуть память занимаемую String обратно в операционную систему: когда переменная s уходит из области видимости. Когда переменная выходит из области видимости, Rust вызывает специальную функцию для нас. Данная функция называется drop и это место где автор String может поместить код для возвращения памяти. Rust вызывает drop автоматически на закрывающей фигурной скобке.

Заметьте: Данный шаблон освобождения ресурсов в конце цикла жизни переменной в C++ иногда называется Resource Acquisition Is Initialization (RAII). Функция drop в Rust будет вам знакома, если вы уже использовали шаблон RAII.

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

Способы взаимодействия переменных и данных: перемещение

Множество переменных могут взаимодействовать разными способами с одинаковыми данными в Rust. Давайте рассмотрим пример использующий целое число в листинге 4-2.

fn main() {
    let x = 5;
    let y = x;
}

Листинг 4-2: Назначение значения целого числа из переменной x в y

Возможно мы догадаемся, что делает данный код: "привязать значение 5 к переменной x; затем сделать копию значения x и привязать его к переменной y". Теперь у нас две переменные, x и y, обе равны 5. Действительно тут происходит именно это потому что целые числа являются простыми значениями с известным, фиксированным размером и эти два значения 5 размещаются в стеке.

Теперь рассмотрим версию с типом String:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

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

Посмотрим на рисунок 4-1 и разберём, что происходит со String под капотом. Тип String состоит из трёх частей показанных слева: указатель на память занятую содержимым строки, длина и ёмкость. Данная группа данных сохраняется в стеке. Справа память в куче, которая хранит содержимое строки.

Строка в памяти

Рисунок 4-1: Представление в памяти строки String содержащей значение "hello" привязанное к s1

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

Когда мы назначили s1 переменной s2, то данные типа String были скопированы, что означает мы скопировали указатель, длину и ёмкость, которые находятся в стеке. Мы не копируем данные в куче на которые ссылается указатель. Другими словами данные представленные в памяти выглядят как на картинке 4-2.

s1 и s2 указывают на одинаковое значение

Картинка 4-2: Представление в памяти переменной s2, которая является копией указателя, длины и ёмкости переменной s1

Представление НЕ выглядит как на картинке 4-3, при котором память могла бы выглядеть так, как если бы Rust ещё скопировал и сами данные в куче. Если Rust сделал бы это, то операция s2 = s1 могла бы быть очень дорогостоящей в смысле производительности: представьте если бы копируемые данные в куче были очень большими.

Строка в памяти

Картинка 4-3: Другая возможность того, как можно было бы сделать при s2 = s1, если бы Rust также копировал бы данные в куче

Ранее мы сказали, что когда переменная выходит из области видимости, Rust автоматически вызывает функцию drop и очищает память кучи для данной переменной. Но картинка 4-2 показывает, что теперь оба указателя указывают на одно и тоже место. Это проблема: когда переменная s2 и переменная s1 выходят из области видимости они обе будут пытаться освободить одну и туже память в куче. Это известно как "ошибка двойного освобождения", double free, и является одной из ошибок безопасности памяти, упоминаемых ранее. Освобождение памяти дважды может привести к повреждению памяти, что потенциально может привести к уязвимостям безопасности.

Чтобы убедиться в безопасности использования памяти, расскажем детали того, что происходит в данной ситуации в Rust. Вместо попытки копировать выделенную память, Rust считает что переменная s1 больше недействительна и таким образом в Rust ничего не нужно освобождать позже, когда s1 покинет область видимости. Проверьте что происходит при попытке использования переменной s1 после того как s2 создана, это не сработает:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}, world!", s1);
}

Вы получите ошибку ниже, потому что Rust не даст использовать недействительную ссылку s1:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 | 
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move

error: aborting due to previous error

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

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

Если вы слышали термины "поверхностное копирование" shallow copy и "глубокое копирование" deep copy в других языках, то концепция копирования указателя, длины и ёмкости без копирования самих данных в куче, возможно выглядит как создание "поверхностной копии". Но так как Rust делает первую переменную недействительной вместо создания поверхностной копии, то такое действие известно как "перемещение" move. В данном примере, мы бы сказали, что s1 была перемещена в переменную s2. То что происходит на самом деле показано на картинке 4-4.

s1 и s2 указывают на одинаковое значение

Картинка 4-4: представление памяти после того как s1 была сделана не действительной

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

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

Способы взаимодействия переменных и данных: клонирование

Если мы хотим сделать глубокое копирование данных в куче для типа String, а не только данных в стеке, то мы можем использовать общий метод называемый clone. Мы обсудим его синтаксис в Главе 5, но так как методы являются общими особенностями во многих языках программирования, то вы возможно уже видели их ранее.

Вот пример метода clone в действии:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {}, s2 = {}", s1, s2);
}

Код работает отлично и явно выполняет поведение, показанное на картинке 4-3, где данные в куче действительно скопированы.

Когда вы видите вызов clone, то вы знаете о выполнении некоторого кода, который может быть дорогим. В то же время использование clone является визуальным индикатором того, что тут происходит что-то нестандартное (глубокое копирование вместо обыденного перемещения).

Стековые данные: Копирование

Это ещё одна особенность о которой мы ещё не говорили. Этот код, часть которого была показа ранее в листинге 4-2, использует целые числа. Этот код работает и не имеет ошибок:

fn main() {
    let x = 5;
    let y = x;

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

Но данный код, кажется противоречит тому, что мы только что изучили: тут не нужно вызывать clone, но x является все ещё действительной переменной и не перемещена в y.

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

В Rust есть специальная аннотация называемая типаж Copy, который можно применить на типы вроде целых чисел, размещённых в стеке (мы поговорим про типажи в Главе 10). Если тип имеет типаж Copy, то старая переменная этого типа все ещё может быть использована после её перемещения в новую. Rust не позволит аннотировать тип с типажом Copy, если тип или любая его часть имеет реализацию типажа Drop. Если типу нужно делать что-то особенное, когда значение уходит из области видимости и мы добавляем аннотацию Copy к данному типу, мы получим ошибку компиляции. Для изучения как добавлять аннотацию Copy к вашему типу, смотрите раздел "Выводимые типажи" в приложении C.

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

  • все целочисленные типы, такие как u32,
  • логический тип данных bool, возможные значения которого true и false,
  • все числа с плавающей запятой такие как f64,
  • символьный тип char,
  • кортежи, но только если они содержат типы, которые также реализуют Copy. Например, (i32, i32) будет с Copy, но кортеж (i32, String) уже нет.

Владение и функции

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

Файл: src/main.rs

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // x would move into the function,
                                    // but i32 is Copy, so it's okay to still
                                    // use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

Листинг 4-3: Функции с комментариями про владение и область видимости

Если попытаться использовать s после вызова takes_ownership, Rust выдаст ошибку времени компиляции. Такие статические проверки защищают от ошибок. Попробуйте добавить код в main, который использует переменную s и x, чтобы увидеть где их можно использовать и где правила владения предотвращают их использование.

Возвращение значений и область видимости

Возвращение значений также может перемещать владение. Листинг 4-4 является примером с похожими комментариями, что даны в листинге 4-3.

Файл: src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1

    let s2 = String::from("hello");     // s2 comes into scope

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 goes out of scope but was
  // moved, so nothing happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_string = String::from("hello"); // some_string comes into scope

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function
}

// takes_and_gives_back will take a String and return one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope

    a_string  // a_string is returned and moves out to the calling function
}

Листинг 4-4: Перемещение владения и возврат значений

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

Приём во владение и затем возвращение владения из каждой функции немного утомительно. А что если мы позволим функции использовать значение, но не забирать его во владение? Весьма раздражает, если все, что мы передаём, также должно быть возвращено обратно. И это ещё надо будет делать в добавок к обслуживанию любых данных, которые мы также можем захотеть вернуть из тела функции.

Есть возможность возвращать несколько значений используя кортеж, как в листинге 4-5.

Файл: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

Листинг 4-5: возврат владения параметров

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

Ссылочные переменные и заимствование

Основная проблематика в подходе с использованием кортежа в листинге 4-5, заключается в том, что нам необходимо возвращать переменную s в вызывающую функцию, чтобы иметь возможность вновь использовать s после вызова calculate_length, потому что в ином случае s была перемещена в метод calculate_length и затем при выходе из метода была бы удалена.

А вот пример того, как можно было бы определить и использовать функцию calculate_length в новой интерпретации, когда функция имеет в качестве параметра ссылку на объект вместо того, чтобы забирать его во владение:

Файл: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Сначала, обратите внимание, что код работы с кортежем в объявлении переменной и возвращаемом значении исчез. Второе, заметьте, что мы передаём &s1 в функцию calculate_length и в её объявлении мы используем &String вместо String.

Данные амперсанды являются ссылками и позволяют ссылаться на некоторые значения не забирая их во владение. Картинка 4-5 показывает диаграмму.

&String s pointing at String s1

Картинка 4-5: Диаграмма для &String s указывающей на String s1

Заметьте: Операцией обратной созданию ссылки используя & является операция разыменования, которая выполняется с помощью оператора разыменования *. Вы увидите использование этого оператора в главе 8 и мы обсудим детали ещё в главе 15.

Давайте подробнее рассмотрим механизм вызова функции:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Синтаксическая конструкция &s1 позволяет создать ссылку, которая ссылается на значение переменной s1, но не владеет ей. Т.к. нет передачи владения, то значение на которое она указывает не будет удалено, когда ссылка выйдет из области видимости функции.

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

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because it does not have ownership of what
  // it refers to, nothing happens.

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

Наличие ссылок в качестве параметров функций называется заимствованием (borrowing). Как и в реальной жизни, если человек владеет чем-то, вы можете позаимствовать это у него. Когда вы закончили, вы вернули это обратно.

А что произойдёт, если попытаться изменить то, что было позаимствовано? Попробуйте код листинга 4-6 Предупреждаем, этот код не сработает!

Файл: src/main.rs

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

Listing 4-6: Попытка модификации заимствованной переменной

Вот ошибка:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
7 | fn change(some_string: &String) {
  |                        ------- help: consider changing this to be a mutable reference: `&mut String`
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable

error: aborting due to previous error

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

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

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

Изменяемые ссылочные переменные

Можно исправить ошибку в коде листинга 4-6, для этого необходимо сделать небольшие изменения в коде:

Файл: src/main.rs

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

Первое, мы должны поменять s добавив mut. Затем нужно создать изменяемую ссылку с помощью &mut s и принять изменяемую ссылку с помощью some_string: &mut String.

Изменяемая ссылочная переменная имеет одно большое ограничение: можно иметь только одну изменяемую ссылочную переменную на часть данных в определённой области видимости. Такой код не будет скомпилирован:

Файл: src/main.rs

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);
}

Описание ошибки:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 | 
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

error: aborting due to previous error

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

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

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

Польза от этого ограничения в том, что Rust может предотвратить возникновение эффекта гонок данных во время компиляции. Эффект гонок данных (data race) является похожим на состояние гонки и возникает, когда происходят три следующих поведения:

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

Ситуация гонок данных приводит к неопределённому поведению (undefined behavior - UB), которая является трудно диагностируемой и трудно исправляемой проблемой при попытке отследить её во время выполнения; Rust предотвращает возникновение этой проблемы, потому что он даже не скомпилирует код с гонками данных!

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

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

Подобное правило существует и для комбинации изменяемых и неизменяемых ссылочных переменных. Пример кода с ошибкой:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

    println!("{}, {}, and {}", r1, r2, r3);
}

Ошибка:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 | 
8 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

error: aborting due to previous error

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

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

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

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

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{} and {}", r1, r2);
    // r1 and r2 are no longer used after this point

    let r3 = &mut s; // no problem
    println!("{}", r3);
}

Области видимости неизменяемых ссылочных переменных r1 и r2 заканчиваются после println!, там где их последний раз использовали, что происходит перед созданием изменяемой ссылочной переменной r3. Данные области не пересекаются, так что этот код разрешён.

Не смотря на то, что ошибки заимствования могут иногда вызывать разочарование, помните, что компилятор Rust указывает про потенциальную проблему на ранних этапах (во время компиляции, а не во время выполнения) и показывает точно, где находится проблема. Так что вам не нужно отслеживать, почему ваши данные не соответствуют вашим ожиданиям.

Недействительные ссылки

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

Попытаемся смоделировать подобную висячую ссылку, появление которой компилятор предотвратит:

Файл: src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

Здесь ошибка:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
  |
5 | fn dangle() -> &'static String {
  |                ^^^^^^^^

error: aborting due to previous error

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

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

Эта ошибка сообщает об ещё не освещённой нами возможности языка Rust: времени жизни переменной (lifetime). Мы расскажем подробнее об этой возможности в Главе 10. Но если вы проигнорируете раздел ошибки который говорит о времени жизни, то все ещё будете способны найти ключ к тому, почему этот код является проблемным:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from.

Давайте пристальней рассмотрим, что же происходит на каждой стадии работы кода функции dangle:

Файл: src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped. Its memory goes away.
  // Danger!

По причине того, что переменная s создана внутри функции dangle, то при завершении dangle содержимое памяти для s будет удалено из памяти. Но мы пытаемся вернуть ссылку на эту память. Это означает, что данная ссылка могла бы указывать на недействительную String. Это плохо! Rust не позволит нам этого сделать.

Решением является вернуть непосредственно String:

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

Это решение работает без проблем. Владение перемещено наружу и ничего не удаляется из памяти.

Правила работы с ссылками

Давайте повторим все, что мы обсудили про ссылки:

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

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

Срезы

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

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

Давайте подумаем над сигнатурой этой функции:

fn first_word(s: &String) -> ?

Функция first_word имеет входной параметр типа &String. Нам не нужно владение переменной, так что это нормально. Но что мы должны вернуть? На самом деле у нас нет способа выразить часть строки. Тем не менее, для решения задачи мы можем найти индекс конца слова в строке используя пробел. Попробуем сделать как на листинге 4-7:

Файл: src/main.rs

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Листинг 4-7: Пример функции first_word, которая возвращает значение индекса пробела внутри строкового параметра String

Для того, чтобы найти пробел в строке, мы превратим String в массив байт, используя метод as_bytes и пройдём по String элемент за элементом, проверяя является ли значение пробелом.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Далее, мы создаём итератор по массиву байт используя метод iter:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Мы изучим итераторы более детально в Главе 13. Сейчас, достаточно понять, что метод iter при каждом вызове возвращает следующий элемент коллекции, а метод enumerate оборачивает результаты работы метода iter и возвращает каждый элемент упакованным в кортеж. Первый элемент этого кортежа возвращён из enumerate и является индексом, а второй элемент - ссылка на элемент коллекции которую предоставил метод iter. Такой способ перебора элементов массива является более удобным - не надо считать индекс самостоятельно.

Так как метод enumerate возвращает кортеж, мы можем использовать шаблон деструктуризации кортежа, как и везде в Rust. Так в цикле for, мы указываем шаблон (i, &item) который распакует значения кортежа в i для хранения индекса из кортежа и в &item который сразу же возьмёт по ссылке байт символа из кортежа. Мы используем & в шаблоне по причине того, что метод .iter().enumerate() возвращает нам ссылку на элемент.

Внутри цикла for, ищем байт представляющий пробел используя синтаксис байт литерала. Если пробел найден, возвращается его позиция. Иначе, возвращается длина строки s.len():

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Теперь у нас есть способ узнать индекс байта указывающего на конец первого слова в строке, но есть проблема. Мы возвращаем сам usize, но это число имеет значение только в контексте &String. Другими словами, поскольку это значение отдельное от String, то нет гарантии, что оно все ещё будет действительным в будущем. Рассмотрим программу из листинга 4-8, которая использует функцию first_word листинга 4-7.

Файл: src/main.rs

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // word still has the value 5 here, but there's no more string that
    // we could meaningfully use the value 5 with. word is now totally invalid!
}

Listing 4-8: Сохранение результата вызова функции first_word, а затем изменение содержимого String

Данная программа компилируется без ошибок и будет успешно работать, даже после того как мы воспользуемся переменной word после вызова s.clear(). Так как значение word совсем не связано с состоянием переменной s, то word сохраняет своё значение 5 без изменений. Мы могли бы использовать 5 вместе с переменной s и попытаться извлечь первое слово из строки, но это приведёт к ошибке, потому что содержимое s изменилось после того как мы сохранили 5 в переменной word (стало пустой строкой в вызове s.clear()).

Необходимость беспокоиться о том, что индекс в переменной word не синхронизируется с данными в переменной s является утомительной и подверженной ошибкам! Управление этими индексами становится ещё более хрупким, если мы напишем функцию second_word. Её сигнатура могла бы выглядеть так:

fn second_word(s: &String) -> (usize, usize) {

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

К счастью в Rust есть решение данной проблемы: строковые срезы.

Строковые срезы

Строковый срез - это ссылка на часть строки String и он выглядит следующим образом:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

Эта инициализация похожа на создание ссылки на переменную String, но с дополнительным условием - указанием отрезка [0..5]. Вместо ссылки на всю String, срез ссылается на её часть.

Мы можем создавать срезы, используя диапазон в квадратных скобках указывая [starting_index..ending_index], где starting_index означает первую позицию в срезе, а ending_index на единицу больше, чем последняя позиция. Во внутреннем представлении, срез хранит начальную позицию и длину среза, которая соответствует числу ending_index минус starting_index. Таким образом, в примере let world = &s[6..11];, переменная world будет срезом, который содержит ссылку на 7-ой байт в s со значением длины равным 5.

Рисунок 4-12 отображает это на диаграмме.

world containing a pointer to the 6th byte of String s and a length 5

Рисунок 4-6: Строковый срез ссылается на часть String

Возможно использовать синтаксис диапазона .. и другим способом. Если хочется начать с начального индекса (с нуля), то можно убрать число перед двоеточием. Другими словами, это эквивалентно:


#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

Таким же образом, если срез включает последний байт строки String, можно убрать завершающее число. Это эквивалентно:


#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

Также можно не указывать оба значения, чтобы получить срез всей строки. Это эквивалентно:


#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

Внимание: Индексы среза строк должны соответствовать границам UTF-8 символов. Если вы попытаетесь получить срез нарушая границы символа в котором больше одного байта, то вы получите ошибку времени исполнения. В рамках этой главы мы будем предполагать только ASCII кодировку. Более детальное обсуждение UTF-8 находится в секции "Сохранение текста с кодировкой UTF-8 в строках" Главы 8.

Давайте используем полученную информацию и перепишем метод first_word так, чтобы он возвращал среза. Для обозначения типа "срез строки" существует запись &str:

Файл: src/main.rs

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

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

Теперь, вызвав метод first_word, мы получим одно единственное значение, которое привязано к нижележащим данным. Значение, которое составлено из ссылки на начальную точку среза и количества элементов в срезе.

Аналогичным образом можно переписать и второй метод second_word:

fn second_word(s: &String) -> &str {

Теперь есть простое API, работу которого гораздо сложнее испортить, потому что компилятор обеспечивает нам то, что ссылки на String останутся действительными. Помните ошибку в программе листинга 4-8, когда мы получили индекс конца первого слова, но затем очистили строку, так что она стала недействительной? Тот код был логически некорректным, хотя не показывал никаких ошибок. Проблемы возникли бы позже, если бы мы попытались использовать индекс первого слова для пустой строки. Срезы делают невозможной данную ошибку и позволяют понять о наличии проблемы гораздо раньше. Так, использование версии метода first_word со срезом вернёт ошибку компиляции:

Файл: src/main.rs

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {}", word);
}

Ошибка компиляции:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 | 
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 | 
20 |     println!("the first word is: {}", word);
   |                                       ---- immutable borrow later used here

error: aborting due to previous error

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

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

Напомним вам правила заимствования: если у нас есть неизменяемая ссылка на что-либо, то нельзя взять изменяемую ссылку для этого чего-то. Так как методу clear требуется обрезать String, ему нужно получить изменяемую ссылку. Rust не позволяет это сделать и компиляции не проходит. Rust не только упростил использование нашего API, но и исключил целый класс ошибок во время компиляции!

Строковые литералы это срезы

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


#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

Тип s здесь является &str срезом, указывающим на конкретное место в бинарном файле программы. Это также объясняет, почему строковый литерал является неизменяемым, потому что тип &str это неизменяемая ссылка.

Строковые срезы как параметры

Знание о том, что можно брать срезы строковых литералов и String строк приводит к ещё одному улучшению метода first_word, улучшению его сигнатуры:

fn first_word(s: &String) -> &str {

Более опытные разработчики Rust написали бы сигнатуру из листинга 4-9, потому что она позволяет использовать одну функцию для значений обоих типов &String и &str.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Листинг 4-9: Улучшение функции first_word используя тип строкового среза для параметра s

Если есть строковый срез, то можно его передавать напрямую. Если есть String, можно передавать срез полностью всей строки String. Определение функции принимающей строковый срез вместо ссылки на String делает API более общим и полезным без потери функциональности:

Файл: src/main.rs

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Другие срезы

Как вы могли бы представить, строковые срезы относятся к строкам. Но также есть более общий тип среза. Рассмотрим массив:


#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

Подобно тому как мы хотели бы ссылаться на часть строки, мы можем захотеть ссылаться на часть массива. Мы можем делать это вот так:


#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

Данный срез имеет тип &[i32]. Он работает таким же образом, как и строковый срез, сохраняя ссылку на первый элемент и длину. Вы будете использовать данную разновидность среза для всех видов коллекций. Мы обсудим коллекции детально, когда будем говорить про векторы в Главе 8.

Итоги

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

Владение влияет на множество других частей и концепций языка Rust. Мы будем говорить об этих концепциях на протяжении оставшихся частей книги. Давайте перейдём к Главе 5 и рассмотрим группировку частей данных в структуры struct.

Использование структур для объединения логически связанных данных

Тип struct, или structure (структура) это пользовательский тип данных, который позволяет назвать и упаковать вместе несколько связанных значений, которые составляют логическую группу. Если вы знакомы с объектно-ориентированными языками, struct похожа на атрибуты данных объекта. В этой главе мы сравним и сопоставим кортежи со структурами, продемонстрируем, как использовать структуры, и обсудим, как создавать методы и ассоциированные функции для определения поведения данных, связанных со структурой. Структуры и перечисления (обсуждаемые в Главе 6) - это строительные блоки для создания новых типов данных, диктуемых бизнес-областью вашей программы, которые позволяют в полной мере воспользоваться преимуществом проверки типов во время компиляции в Rust.

Определение и инициализация структур

Внешне структуры похожи на кортежи, рассмотренные в Главе 3. Также как кортежи, структуры могут содержать разные типы данных. Но в отличии от кортежей, вы именуете части данных так, чтобы было ясно, что эти имена означают. Поэтому структуры более удобны для создания новых типов данных, так как нет необходимости запоминать порядковый номер какого-либо значения внутри экземпляра структуры.

Для определения структуры указывается ключевое слово struct и её название. Название должно описывать значение частей данных, сгруппированных вместе. Далее, в фигурных скобках для каждой новой части данных поочерёдно определяются имя части данных и её тип. Каждая пара имя: тип называется полем. Листинг 5-1 описывает структуру для хранения информации о учётной записи пользователя:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {}

Листинг 5-1: определение структуры User

После определения структуры можно создавать её экземпляр, назначая определённое значение каждому полю с соответствующим типом данных. Чтобы создать экземпляр, мы указываем имя структуры, затем добавляем фигурные скобки и включаем в них пары ключ: значение (key: value), где ключами являются имена полей, а значениями являются данные, которые мы хотим сохранить в полях. Нет необходимости чётко следовать порядку объявления полей в описании структуры (но всё-таки желательно, для удобства чтения). Другими словами, объявление структуры - это как шаблон нашего типа, в то время как экземпляр структуры использует этот шаблон, заполняя его определёнными данными, для создания значений нашего типа. Например, можно объявить пользователя как в листинге 5-2:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };
}

Листинг 5-2: создание экземпляра структуры User

Чтобы получить определённое значение из структуры, мы можем использовать точечную нотацию (как в кортеже). Например, если нам нужен только электронный адрес пользователя, можно использовать user1.email везде, где нужно это значение. Если объявить экземпляр структуры изменяемым, то мы сможем при помощи точечной нотации и присвоения так же и изменить значение поля. Листинг 5-3 показывает как изменить значение в поле email изменяемого экземпляра User:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let mut user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}

Листинг 5-3: изменение значения поля email экземпляра структуры User

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

На листинге 5-4 функция build_user возвращает экземпляр User с указанным адресом и именем. Поле active получает значение true, а поле sign_in_count получает значение 1.

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

Листинг 5-4: функция build_user принимает электронный адрес и имя и возвращает экземпляр User

Имеет смысл называть параметры функции теми же именами, что и поля структуры, но необходимость повторять email и username для названий полей и переменных несколько утомительна. Если структура имеет много полей, повторение каждого имени станет ещё более раздражающим. К счастью, есть удобное сокращение!

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

Так как имена входных параметров функции и полей структуры являются полностью идентичными в листинге 5-4, возможно использовать синтаксис сокращённой инициализации поля, чтобы переписать build_user так, чтобы он работал точно также, но не содержал повторений для email и username, как в листинге 5-5.

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

Листинг 5-5: функция build_user использует сокращённую инициализацию полей, потому что её входные параметры email и username имеют имена аналогичные именам полей структуры

Здесь происходит создание нового экземпляра структуры User, которая имеет поле с именем email. Мы хотим установить поле структуры email значением входного параметра email функции build_user. Так как поле email и входной параметр функции email имеют одинаковое название, можно писать просто email вместо кода email: email.

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

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

Сначала листинг 5-6 показывает как создать новый экземпляр User для переменной user2 без синтаксиса обновления. Устанавливаются новые значения для email и username, для остальных же полей используются те же самые значения из переменной user1, как сделано в листинге 5-2.

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        username: String::from("anotherusername567"),
        active: user1.active,
        sign_in_count: user1.sign_in_count,
    };
}

Листинг 5-6: создание экземпляра User с присвоением некоторым полям значений из user1

Используя синтаксис обновления структуры, можно получить тот же эффект, используя меньше кода как показано в листинге 5-7. Синтаксис .. указывает, что оставшиеся поля устанавливаются неявно и должны иметь значения из указанного экземпляра.

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        username: String::from("anotherusername567"),
        ..user1
    };
}

Листинг 5-7: используем синтаксис обновления структур для установки значений email и username экземпляра User, но для остальных значений берём данные из полей экземпляра переменной user1

Код в листинге Listing 5-7 также создаёт экземпляр переменной user2, который имеет отличные от user1 значения полей email и username, но те же самые значения полей active и sign_in_count.

Кортежные структуры: структуры без именованных полей для создания разных типов

Можно определять структуры очень напоминающие кортежи (такие структуры называют кортежными структурами, tuple structs). Кортежные структуры имеют дополнительный смысл из-за имени в объявлении, но не имеют имён, ассоциированных с их полями, у них есть только типы полей. Кортежные структуры удобны, когда нужно дать всему кортежу имя и сделать его отличным от других кортежей, при этом именование полей как у обычной структуры будет подробным или избыточным.

Определение кортежной структуры начинается ключевым словом struct и названием структуры, за которыми следуют типы в кортеже. Например, вот определение и использование двух кортежных структур с именами Color и Point:

fn main() {
    struct Color(i32, i32, i32);
    struct Point(i32, i32, i32);

    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

Обратите внимание, что переменные black и origin разного типа, потому что они являются экземплярами разных кортежных структур. Каждая определяемая структура является собственным типом, не смотря на то, что поля внутри структуры имеют одинаковые типы. Например, функция, принимающая параметром тип Color, не может принять аргумент типа Point, не смотря на то, что оба типа состоят из трёх значений i32. Тем не менее, экземпляры кортежных структур ведут себя как кортежи: их можно разделять на отдельные части, использовать . за которой идёт индекс для доступа к отдельному значению и т.д.

Единично-подобные структуры: структуры без полей

Можно также определять структуры вообще без полей! Они называются unit-like, единично-подобные структуры, потому что ведут себя подобно единичному типу (). Единично-подобные структуры могут быть полезны в ситуации, в которой нужно реализовать типаж (trait) для некоторого типа, но нет никаких данных для сохранения в самом типе. Мы обсудим типажи в главе 10.

Владение данными структуры

При определении структуры User в листинге 5-1 мы использовали тип String владеющий данными вместо &str. Это было осознанное решение, т.к. мы хотели, чтобы экземпляры структур владели всеми своими данными и чтобы данные были действительными во время всего существования структуры.

Структуры могут хранить ссылки на данные, которыми владеет кто-то другой, но для этого требуется использование времени жизни (lifetimes) — функции Rust, которую мы обсудим в главе 10. Время жизни гарантирует, что данные, на которые ссылается структура, действительны, пока существует сама структура. Если вы попытаетесь сохранить ссылку в структуре без указания времени жизни, как тут, то это не сработает:

Файл : src/main.rs

 struct User {
     username: &str,
     email: &str,
     sign_in_count: u64,
     active: bool,
 }

 fn main() {
     let user1 = User {
         email: "someone@example.com",
         username: "someusername123",
         active: true,
         sign_in_count: 1,
     };
 }

Компилятор будет жаловаться на необходимость определения времени жизни ссылок:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:2:15
  |
2 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 | struct User<'a> {
2 |     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:3:12
  |
3 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 | struct User<'a> {
2 |     username: &str,
3 |     email: &'a str,
  |

error: aborting due to 2 previous errors

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

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

В Главе 10 мы обсудим, как исправить такие ошибки сохранения ссылок в структурах. Но сейчас мы просто забудем об этих ошибках и будем использовать типы с владением данными, по типу String вместо ссылочных типов, таких как &str.

Пример использования структур

Для понимания, где мы могли бы использовать структуры, мы напишем программу, которая будет рассчитывать площадь прямоугольника. Мы начнём с создания переменных, а потом постепенно напишем код, который будет использовать структуры.

Давайте создадим новый проект программы при помощи Cargo и назовём его rectangles. Наша программа будет получать на вход длину и ширину прямоугольника в пикселях и затем рассчитывать площадь прямоугольника. Листинг 5-8 показывает один из коротких вариантов кода который позволит нам сделать именно то, что надо, код в файле проекта src/main.rs.

Файл: src/main.rs

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

Листинг 5-8: Расчёт площади прямоугольника с помощью отдельных переменных ширины и высоты

Теперь, проверим её работу cargo run:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

Несмотря на то, что код листинга 5-8 работает и рассчитывает площадь прямоугольника вызывая функцию area для каждого изменения, мы можем улучшить программу. Переменные длины и ширины связаны логически, так как они совместно описывают параметры прямоугольника.

Проблема данного метода очевидна из сигнатуры area:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

Функция area должна рассчитывать площадь одного прямоугольника, но у функции описано два параметра. Эти параметры связаны логически, но это никак не отражено в коде программы. Код был бы более очевидным и управляемым, если бы переменные ширины и длины были сгруппированы вместе. Мы уже знаем один из методов группировки переменных из раздела "Тип кортеж" Главы 3, в котором используются кортежи.

Рефакторинг при помощи кортежей

Листинг 5-9 это другая версия программы, использующая кортежи.

Файл: src/main.rs

fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

Листинг 5-9: Указание ширины и высоты прямоугольника с помощью кортежа

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

Если мы перепутаем местами ширину с высотой при расчёте площади, то это не имеет значения. Но если нужно нарисовать прямоугольник на экране, то это уже будет иметь значение! Придётся помнить, что ширина width находится в кортеже с индексом 0, а высота height с индексом 1. Если кто-то другой поработал бы с кодом, ему бы пришлось разобраться в этом и также помнить про порядок. Легко забыть и перепутать эти значения и это вызовет ошибки, потому что данный код не передаёт наши намерения.

Рефакторинг при помощи структур: добавим больше смысла

Мы используем структуры чтобы добавить смысл данным при помощи назначения им осмысленных имён . Мы можем переделать используемый кортеж в структуру: тип данных (data type) с единым именем для сущности и частными названиями её частей, как показано в листинге 5-10.

Файл: src/main.rs

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

Листинг 5-10: Определение структуры Rectangle

Здесь мы определили структуру и дали ей имя Rectangle. Внутри фигурных скобок определили поля как width и height, оба - типа u32. Затем в main создали конкретный экземпляр Rectangle с шириной в 30 и высотой в 50 единиц.

Наша функция area теперь определена с одним параметром названным rectangle, чей тип является неизменяемым заимствованием структуры Rectangle. Как упоминалось в Главе 4, необходимо заимствовать структуру, а не передавать её во владение. Таким образом функция main сохраняет rect1 в собственности и может её использовать дальше, по этой причине мы и используем & в сигнатуре и в месте вызова функции.

Функция area имеет доступ к полям width и height экземпляра Rectangle. Сигнатура нашей функции для area теперь точно говорит, что мы имели в виду: посчитать площадь Rectangle используя поля width и height. Такой подход сообщает, что ширина и высота связаны по смыслу друг с другом. А названия значений структуры теперь носят понятные описательные имена, вместо ранее используемых значений индексов кортежа 0 и 1. Это плюс к ясности.

Добавление полезной функциональности при помощи Выводимых Типажей

Было бы неплохо иметь возможность печатать экземпляр Rectangle во время отладки программы и видеть значения всех полей. Листинг 5-11 использует макрос println!, который мы уже использовали в предыдущих главах. Тем не менее, это не работает.

Файл: src/main.rs

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {}", rect1);
}

Листинг 5-11: Попытка распечатать экземпляр Rectangle

При компиляции этого кода мы получаем ошибку с сообщением:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

Макрос println! умеет делать разные виды форматирования по умолчанию, фигурные скобки в println! говорят использовать форматирование известное как типаж Display: вывод в таком варианте форматирования предназначен для прямого и пользовательского потребления. Примитивные типы изученные ранее, по умолчанию реализуют типаж Display, потому что есть только один способ отобразить число 1 или любой другой примитивный тип пользователю. Но для структур у которых println! должен форматировать способ вывода данных, это является менее очевидным, потому что есть гораздо больше возможностей для отображения: Вы хотите запятые или нет? Вы хотите печатать фигурные скобки? Нужно ли показать все поля? Из-за этой неоднозначности Rust не пытается угадать, что нам нужно - структуры не имеют готовой реализации типажа Display.

Продолжив чтение текста ошибки, мы найдём полезное замечание:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

Давайте попробуем! Вызов макроса println! теперь будет выглядеть так println!("rect1 is {:?}", rect1);. Ввод спецификатора :? внутри фигурных скобок говорит макросу println!, что мы хотим использовать другой формат вывода известный как Debug. Типаж Debug позволяет печатать структуру способом, удобным для разработчиков, чтобы видеть значение во время отладки кода.

Скомпилируем код с этими изменениями. Упс! Мы всё ещё получаем ошибку:

error[E0277]: `Rectangle` doesn't implement `Debug`

Снова компилятор даёт нам полезное замечание:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` or manually implement `Debug`

Rust  реализует функциональность для печати отладочной информации, но не включает (не выводит) её по умолчанию , мы должны явно включить эту функциональность для нашей структуры. Чтобы это сделать, добавляем аннотацию #[derive(Debug)] сразу перед определением структуры как показано в листинге 5-12.

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {:?}", rect1);
}

Листинг 5-12: Добавление аннотации для вывода типажа Debug и печати экземпляра Rectangle с отладочным форматированием

Теперь при запуске программы мы не получим ошибок и увидим следующий вывод:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

Отлично! Это не делает вывод приятнее, но показывает значения всех полей экземпляра, которые определённо помогут при отладке. Когда у нас структуры больше, то полезно иметь более простой для чтения вывод; в таком случае можно использовать код {:#?} вместо {:?} в строке макроса println!. При использовании стиля {:#?} в примере вывод будет выглядеть так:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Rust предоставляет много типажей для использования в аннотации derive, они умеют давать полезное поведение пользовательским типам. Эти типажи и их поведение перечислены в приложении C. Мы обсудим как реализовать поддержку типажей для нашего кода с индивидуальным поведением, а также как создавать свои собственные типажи в Главе 10.

Функция area является довольно специфичной: она считает только площадь прямоугольников. Было бы полезно привязать данное поведение как можно ближе к структуре Rectangle, потому что наш специфичный код не будет работать с любым другим типом. Давайте рассмотрим, как можно улучшить наш кода превращая функцию area в метод area, определённый для типа Rectangle.

Синтаксис метода

Методы похожи на функции: они объявлены с помощью ключевого слова fn и его имени. Они могут иметь параметры и возвращаемое значение, могут содержать некоторый код, который выполняется при вызове из другого места. Тем не менее, методы отличаются от функций тем, что они определены внутри контекста структуры (также перечисления или объекта-типажа, которые мы рассмотрим в главе 6 и 17, соответственно), а их первым параметром всегда является self, который представляет экземпляр структуры для которого этот метод будет вызван.

Определение методов

Давайте изменим функцию area так, чтобы она имела экземпляр Rectangle в качестве входного параметра и сделаем её методом area, определённым для структуры Rectangle, как показано в листинге 5-13:

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

Листинг 5-13: Определение метода area у структуры Rectangle

Для определения функции в контексте типа Rectangle, мы начинаем блок impl (implementation - реализация). Затем переносим функцию area внутрь фигурных скобок impl и меняем первый (в данном случае единственный) параметр в сигнатуре на self, и далее везде в теле метода. В main, там где мы вызывали функцию area и передавали ей переменную rect1 в качестве аргумента, теперь можно использовать синтаксис метода для вызова метода area на экземпляре типа Rectangle. Синтаксис метода идёт после экземпляра: мы добавляем точечную нотацию за которой следует название метода, круглые скобки и любые аргументы.

В сигнатуре area, используется &self вместо rectangle: &Rectangle потому что Rust знает, что тип self является типом Rectangle, так как данный метод находится внутри impl Rectangle контекста. Заметьте, всё ещё нужно использовать & перед self, как мы делали с &Rectangle. Методы могут принимать во владение self, заимствовать неизменяемый self, как мы сделали здесь, или заимствовать изменяемый self, а также любые другие параметры.

Мы выбрали &self здесь по той же причине, по которой использовали &Rectangle в версии кода с функцией: мы не хотим брать структуру во владение, мы просто хотим прочитать данные в структуре, а не писать в неё. Если бы мы хотели изменить экземпляр, на котором мы вызывали метод силами самого метода, то мы бы использовали &mut self в качестве первого параметра. Наличие метода, который берёт экземпляр во владение, используя только self в качестве первого параметра, является редким; эта техника обычно используется, когда метод превращает self во что-то ещё, и вы хотите запретить вызывающей стороне использовать исходный экземпляр после превращения.

Основным преимуществом использования методов вместо функций, в дополнение к использованию синтаксиса метода, где не нужно повторять тип self в каждой сигнатуре метода, является организация кода. Мы собрали все, что мы можем сделать с экземпляром типа в одном блоке impl, не заставляя будущих пользователей нашего кода искать дополнительный реализованный функционал для Rectangle в разных местах библиотеки.

Где используется оператор ->?

В языках C и C++, используются два различных оператора для вызова методов: используется ., если вызывается метод непосредственно у экземпляра структуры и используется ->, если вызывается метод у ссылки на объект. Другими словами, если object является ссылкой, то вызовы метода object->something() и (*object).something() являются аналогичными.

Rust не имеет эквивалента оператора ->, наоборот, в Rust есть функциональность называемая автоматическое обращение по ссылке и разыменование (automatic referencing and dereferencing). Вызов методов является одним из немногих мест в Rust, в котором есть такое поведение.

Вот как это работает: когда вы вызываете метод object.something(), Rust автоматически добавляет &, &mut или *, таким образом, чтобы object соответствовал сигнатуре метода. Другими словами, следующий код является одинаковым:


#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

Первый пример выглядит намного понятнее. Автоматический вывод ссылки работает потому, что методы имеют понятного получателя - тип self. Учитывая получателя и имя метода, Rust может точно определить, что в данном случае делает код: читает ли метод (&self), делает ли изменение (&mut self) или поглощает (self). Тот факт, что Rust делает заимствование неявным для принимающего метода, в значительной степени способствует тому, чтобы сделать владение эргономичным на практике.

Методы с несколькими параметрами

Давайте попрактикуемся в использовании методов, реализовав второй метод у структуры Rectangle. На этот раз мы хотим, чтобы экземпляр Rectangle использовал другой экземпляр типа Rectangle и возвращал true, если второй Rectangle может полностью разместиться внутри площади экземпляра self; в противном случае он должен возвращать false. То есть мы хотим иметь возможность написать программу, показанную в листинге 5-14, в которой определили метод can_hold.

Файл: src/main.rs

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Листинг 5-14: Использование ещё не написанного метода can_hold

И ожидаемый результат будет выглядеть следующим образом, т.к. оба размера в экземпляре rect2 меньше, чем размеры в экземпляре rect1, а rect3 шире, чем rect1:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

Мы знаем, что хотим определить метод, поэтому он будет находится в impl Rectangle блоке. Имя метода будет can_hold, и оно будет принимать неизменяемое заимствование на другой Rectangle в качестве параметра. Мы можем сказать, какой это будет тип параметра, посмотрев на код вызывающего метода: метод rect1.can_hold(&rect2) передаёт в него &rect2 , который является неизменяемым заимствованием экземпляра rect2 типа Rectangle. В этом есть смысл, потому что нам нужно только читать rect2 (а не писать, что означало бы, что нужно изменяемое заимствование), и мы хотим, чтобы main сохранил право собственности на экземпляр rect2, чтобы мы могли использовать его снова после вызова метода can_hold. Возвращаемое значение can_hold имеет булевый тип, а реализация проверяет, являются ли ширина и высота self больше, чем ширина и высота другого Rectangle соответственно. Давайте добавим новый метод can_hold в impl блок из листинга 5-13, как показано в листинге 5-15.

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Листинг 5-15: реализация метода can_hold у структуры Rectangle, который принимает другой экземпляр Rectangle в качестве параметра

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

Ассоциированные функции

Ещё одной полезной особенностью блоков impl является то, что мы можем определить функции внутри блоков impl, которые не принимают self в качестве параметра. Они называются ассоциированными функциями, потому что они всё ещё связаны со структурой в отличии от простых функций. Так же они всё ещё функции, а не методы, потому что у них нет экземпляра структуры над которой они могут работать. Вы уже использовали ассоциированную функцию String::from.

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

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

Чтобы вызвать эту ассоциированную функцию, используется синтаксис :: с именем структуры; пример let sq = Rectangle::square(3);. Эта функция относится к структуре: синтаксис :: используется как для ассоциированных функций, так и для пространства имён, созданных модулями. Мы обсудим модули в Главе 7.

Несколько блоков impl

Для каждой структуры разрешено иметь множество impl блоков. Например, листинг 5-15 является эквивалентным коду из листинга 5-16, который описывает метод в своём отдельном блоке impl.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Листинг 5-16: Переписанный листинг 5-15 с использованием нескольких блоков impl

В данном случае нет причин разделять эти методы на несколько блоков impl, но это тоже является правильным синтаксисом. Мы увидим случай, в котором полезно иметь несколько impl блоков в Главе 10, где мы будем обсуждать обобщённые типы и типажи.

Итоги

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

Но структуры - это не единственный способ создания пользовательских типов: давайте обратимся к перечислениям в Rust, чтобы добавить ещё один инструмент в наш арсенал.

Перечисления и Сопоставление с образцом

В этой главе мы рассмотрим перечисления, также называемые enums. Перечисления позволяют определять тип, перечисляя его возможные варианты. Сначала, мы определим и воспользуемся перечислением, чтобы показать, как перечисление может закодировать значение вместе с данными. Далее мы рассмотрим особенно полезный enum, называемый Option, который выражает факт того, что значение может быть либо чем-то, либо ничем. Потом мы посмотрим на сопоставление с образцом в match выражении, позволяющем легко выполнять разный код для различных значений перечисления. Наконец, мы рассмотрим конструкцию if let - ещё одну удобную и лаконичную идиому, которая позволяет вам управлять перечислениями в коде.

Перечисления являются особенностью многих языков, но в каждом языке их возможности различаются. Перечисления в Rust наиболее похожи на алгебраические типы данных, Algebraic Data Types, представленные в таких функциональных языках как F#, OCaml и Haskell.

Определение перечисления

Давайте посмотрим на ситуацию, которую мы могли бы выразить в коде, и рассмотрим почему перечисления полезны и более уместны чем структуры в данном случае. Представим, что нам нужно работать с IP-адресами. В настоящее время используются два основных стандарта IP-адресов: версия четыре и версия шесть. Это единственные варианты IP адресов, с которым столкнётся наша программа: мы можем перечислить (enumerate) все возможные варианты, отсюда и появляется понятие перечисление (enumeration, enum).

Любой IP-адрес может быть либо адресом версии четыре, либо версии шесть - но не может быть одновременно и шестой и четвёртой версии. Это свойство IP-адресов делает перечисление подходящей структурой данных для их хранения, т.к. значения enum, как и версия IP-адреса, могут быть только одним из возможных в данном перечислении вариантом. Адреса как версии четыре, так и версии шесть по-прежнему являются IP-адресами, поэтому они должны рассматриваться как один и тот же тип, когда код обрабатывает ситуации применимые к любому виду IP-адреса.

Можно выразить эту концепцию в коде, определив перечисление IpAddrKind и составив список возможных видов IP-адресов, V4 и V6. Вот варианты перечислений:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

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

Значения перечислений

Экземпляры каждого варианта перечисления IpAddrKind можно создать следующим образом:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Обратите внимание, что варианты перечисления находятся в пространстве имён его идентификатора, мы используем двойное двоеточие чтобы отделить вариант от пространства имён. Причина по которой это полезно в том, что сейчас оба значения IpAddrKind::V4 и IpAddrKind::V6 имеют одинаковый тип: IpAddrKind. Благодаря этому в дальнейшем мы имеем возможность определять функции, которые принимают любой вариант IpAddrKind:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Можно вызвать эту функцию с любым из вариантов:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Использование перечислений имеет даже больше преимуществ. Размышляя о нашем типе IP-адреса в данный момент, мы понимаем, что у нас нет способа сохранить фактические данные IP-адреса; мы только знаем, каким вариантом он является. Учитывая то, что вы недавно узнали о структурах в Главе 5, можно решить эту проблему как показано в листинге 6-1.

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}

Листинг 6-1: Сохранение данных и вариантов IpAddrKind IP адреса используя структуру struct

Здесь мы определили структуру IpAddr, которая имеет два поля: поле kind имеет тип IpAddrKind (перечисление, которое мы определили ранее) и поле address типа String. У нас есть два экземпляра этой структуры. Первый, home, имеет значение kind равное IpAddrKind::V4 и связан с адресом 127.0.0.1. Второй экземпляр, loopback, имеет другой вариант IpAddrKind качестве значения kind - вариант V6 и имеет связанный с ним адрес ::1. Мы использовали структуру для объединения значений kind и address, теперь вариант связан со значением.

Мы можем представить ту же концепцию в более сжатой форме, используя только перечисление, вместо перечисления запакованного внутри структуры, и помещать данные непосредственно в каждый вариант перечисления. Это новое определение перечисления IpAddr говорит, что оба варианта V4 и V6 будут иметь связанные с ними значения типа String :

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

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

Ещё одно преимущество использования перечисления вместо структуры заключается в том, что каждый вариант перечисления может иметь разное количество ассоциированных данных представленных в разных типах. Версия 4 для типа IP адресов всегда будет содержать четыре цифровых компонента, которые будут иметь значения между 0 и 255. При необходимости сохранить адреса типа V4 как четыре значения типа u8, а также описать адреса типа V6 как единственное значение типа String, мы не смогли бы с помощью структуры. Перечисления решают эту задачу легко:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

Мы показали несколько способов определения структур данных для хранения IP-адресов стандарта версии четыре и версии шесть. Однако, как выясняется, желание хранить IP-адреса и кодировать какого они типа, настолько распространено среди разработчиков, что в стандартной библиотеке уже есть готовое для нашей задачи определение, которое мы можем использовать! Давайте посмотрим, как стандартная библиотека определяет тип IpAddr: она так же как и у нас имеет аналогичное перечисление с аналогичными вариантами (подобными тем, которые мы определили и использовали ранее), но она представляет (а затем и встраивает в варианты) данные IP-адресов в форме двух разных структур, которые определяются по-разному для каждого варианта.


#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --вырезано--
}

struct Ipv6Addr {
    // --вырезано--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

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

Обратите внимание, что хотя определение перечисления IpAddr есть в стандартной библиотеке, мы смогли объявлять и использовать свою собственную реализацию с аналогичным названием без каких-либо конфликтов, потому что мы не добавили определение стандартной библиотеки в область видимости кода. Подробнее об этом поговорим в Главе 7.

Рассмотрим другой пример перечисления в листинге 6-2: в этом примере каждый элемент перечисления имеет свой особый тип данных внутри:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Пример 6-2: Перечисление Message, в котором каждый элемент содержит различные значения и их типы данных (наиболее удобные и нужные для использования).

Это перечисление имеет 4 элемента:

  • Quit - пустой элемент без ассоциированных данных,
  • Move - элемент имеющий внутри анонимную структуру,
  • Write - элемент с единственной строкой типа String,
  • ChangeColor - кортеж из трёх значений типа i32.

Определение перечисления с вариантами, такими как в листинге 6-2, похоже на определение значений различных типов внутри структур, за исключением того, что перечисление не использует ключевое слово struct и все варианты сгруппированы внутри типа Message. Следующие структуры могут содержать те же данные, что и предыдущие варианты перечислений:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

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

Есть ещё одно сходство между перечислениями и структурами: так же, как мы можем определять методы для структур с помощью impl блока, мы можем определять и методы для перечисления. Вот пример метода с именем call, который мы могли бы определить в нашем перечислении Message:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

Тело метода будет использовать self, чтобы получить значение из объекта на котором мы вызвали этот метод. В этом примере мы создали переменную m которой назначено значение из выражения Message::Write( String::from("hello")) и это то чем будет self в теле метода call при вызове m.call().

Теперь посмотрим на другое наиболее часто используемое перечисление из стандартной библиотеки, которое является очень распространённым и полезным: Option.

Перечисление Option и его преимущества перед Null-значениями

В предыдущем разделе мы рассмотрели, как перечисление IpAddr позволило нам использовать систему типов Rust для кодирования в программе большего количества информации, чем просто данные. В этом разделе рассматривается пример использования Option, ещё одного перечисления, определённого стандартной библиотекой. Тип Option используется во многих местах, потому что он кодирует очень распространённый сценарий, в котором значение может быть чем-то или может быть ничем. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработаны ли все случаи для данного типа, которые должны обрабатываться; такого рода проверка компилятора может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования.

Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но те функции, которые вы исключаете, также важны. Например в Rust нет такого функционала как null значения, однако он есть во многих других языках. Null значение - это значение, которое означает, что значения нет. В языках с null значением переменные всегда могут находиться в одном из двух состояний: нет значения (null) или есть значение (not-null).

В своей презентации 2009 года «Null ссылки: ошибка в миллиард долларов» Тони Хоар (Tony Hoare), изобретатель null, сказал следующее:

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

Проблема с null значениям заключается в том, что если вы попытаетесь использовать его значение в качестве не-null значения, вы получите какую-то ошибку. Из-за того, что null или не-null свойство всеобъемлющее и может быть использовано повсеместно, очень легко использовать null и в дальнейшем получить такого рода ошибку.

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

Проблема не в самой концепции, а в конкретной реализации. Таким образом, в Rust нет null-значений, но есть перечисление, которое может закодировать концепцию наличия или отсутствия значения. Это перечисление Option<T> и оно объявляется в стандартной библиотеке следующим образом:


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Перечисление Option<T> настолько полезно, что даже подключено в авто-импорте; его не нужно явно подключать в область видимости. Дополнительно подключены также и его варианты: можно использовать Some и None напрямую, без префикса Option::. Перечисление Option<T> все ещё является обычным перечислением, а Some(T) и None являются вариантами типа Option<T>.

Синтаксис <T> - это особенность Rust, о которой мы ещё не говорили. Это параметр обобщённого типа, и мы рассмотрим его более подробно в Главе 10. На данный момент всё, что вам нужно знать, это то, что <T> означает, что вариант Some из перечисления Option может содержать один фрагмент данных любого типа. Вот несколько примеров использования значений Option для хранения числовых и строковых типов:

fn main() {
    let some_number = Some(5);
    let some_string = Some("a string");

    let absent_number: Option<i32> = None;
}

Если используется None, а не Some , то нужно сообщить Rust, какой тип Option<T> у нас есть, потому что компилятор не может определить тип варианта который будет содержать Some, глядя только на значение None.

Когда есть значение Some, мы знаем, что значение присутствует и содержится внутри Some. Когда есть значение None, это означает то же самое, что и null в некотором смысле: у нас нет действительного значения. Так почему наличие Option<T> лучше, чем null?

Вкратце, поскольку Option<T> и T (где T может быть любым типом) относятся к разным типам, компилятор не позволит нам использовать значение Option<T> даже если бы оно было определённо допустимым вариантом Some. Например, этот код не будет компилироваться, потому что он пытается добавить i8 к значению типа Option<i8> :

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

Запуск данного кода даст ошибку ниже:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`

error: aborting due to previous error

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

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

Сильно! Фактически, это сообщение об ошибке означает, что Rust не понимает, как сложить i8 и Option<i8>, потому что это разные типы. Когда у нас есть значение типа на подобие i8, компилятор гарантирует, что у нас всегда есть допустимое значение типа. Мы можем уверенно продолжать работу, не проверяя его на null перед использованием. Однако, когда у нас есть значение типа Option<T> (где T - это любое значение любого типа T, упакованное в Option, например значение типа i8 или String), мы должны беспокоиться о том, что значение типа T возможно не имеет значения (является вариантом None), и компилятор позаботится о том, чтобы мы обработали такой случай, прежде чем мы бы попытались использовать None значение.

Другими словами, вы должны преобразовать Option<T> в T прежде чем вы сможете выполнять операции с этим T. Как правило, это помогает выявить одну из наиболее распространённых проблем с null: когда мы предполагаем, что что-то не равно null, хотя на самом деле оно null.

С Rust не нужно беспокоиться о неправильном предположении касательно не-null значения, это помогает чувствовать себя более уверенно. Для того, чтобы иметь значение, которое может быть null, вы должны явно сказать об этом, указав тип T этого значения как Option<T> (обернуть его в Option). Затем, когда вы используете это значение, вы обязаны явно обрабатывать случай, когда значение равно null. Везде, где значение имеет тип, не являющий Option<T>, вы можете смело рассчитывать на то, что значение не равно null. Такой подход - продуманное проектное решение в Rust, ограничивающее распространение null и увеличивающее безопасность Rust кода.

Итак, как мы можем получить желанное значение типа T, упакованное в варианте Some типа Option, когда у нас на руках есть только значение типа Option<T>? Option<T> имеет большое количество методов, которые полезны в различных ситуациях; можно проверить их в документации. Знакомство с методами в Option<T> будет чрезвычайно полезным в вашем путешествии по языку Rust.

В общем случае, чтобы использовать значение Option<T>, нужен код, который будет обрабатывать все варианты перечисления Option<T>. Вам понадобится некоторый код, который будет работать только тогда, когда у вас есть значение Some(T), и этому коду разрешено использовать внутреннее T. Также вам понадобится другой код, который будет работать, если у вас есть значение None, и у этого кода не будет доступного значения T. Выражение match — это конструкция управления потоком выполнения программы, которая делает именно это при работе с перечислениями: она запускает разный код в зависимости от того, какой вариант перечисления имеется, и этот код может использовать данные, находящиеся внутри совпавшего варианта.

Оператор управления потоком выполнения match

В Rust есть невероятно мощная конструкция управления потоком выполнения программы под названием match, которая позволяет вам сравнивать значение с серией шаблонов и затем выполнять код, связанный с совпавшим шаблоном. Шаблонами могут выступать литералы, имена переменных, подстановочные значения и многое другое; в Главе 18 описаны все разновидности шаблонов и что они делают. Сила match проистекает из выразительности шаблонов и того факта, что компилятор подтверждает, что все возможные случаи обрабатываются.

Думайте о выражении match как о машине для сортировки монет: монеты скользят вниз по дорожке с отверстиями разного размера и каждая монета проваливается в первое отверстие, в которое она проходит. Таким же образом значения проходят через каждый шаблон в конструкции match и при первом же совпадении с шаблоном значение "проваливается" в соответствующий блок кода для дальнейшего использования.

Поскольку мы только что упомянули монеты, давайте использовать их в качестве примера, используя match! Можно написать функцию, которая возьмёт неизвестную монету Соединённых Штатов и, подобно счётной машине, определит какая это монета и вернёт её значение в центах, как показано в листинге 6-3.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

Листинг 6-3: Перечисление и выражение match, которое использует варианты перечисления в качестве шаблонов

Давайте разберём match в функции value_in_cents. Сначала пишется ключевое слово match, затем следует выражение, которое в данном случае является значением coin. Это выглядит очень похоже на выражение if, но есть большая разница: с if выражение должно возвращать булево значение, а здесь это может быть любой тип. Тип coin в этом примере - перечисление типа Coin, объявленное в строке 1.

Далее идут ветки match. Ветки состоят из двух частей: шаблон и некоторый код. Здесь первая ветка имеет шаблон, который является значением Coin::Penny, затем идёт оператор =>, который разделяет шаблон и код для выполнения. Код в этом случае - это просто значение 1. Каждая ветка отделяется от последующей при помощи запятой.

Когда выполняется выражение match, оно сравнивает полученное значение с образцом каждой ветки по порядку. Если шаблон совпадает со значением, то выполняется код, связанный с этим шаблоном. Если этот шаблон не соответствует значению, то выполнение продолжается со следующей ветки, так же, как в автомате по сортировке монет. У нас может быть столько веток, сколько нужно: в листинге 6-3 наш match состоит из четырёх веток.

Код, связанный с каждой веткой, является выражением, а полученное значение выражения в соответствующей ветке — это значение, которое возвращается для всего выражения match.

Фигурные скобки обычно не используются, если код ветки короткий, как в листинге 6-3, где каждая ветка только возвращает значение. Если необходимо выполнить несколько строк кода в ветке, можно использовать фигурные скобки. Например, следующий код будет выводить «Lucky penny!» каждый раз, когда метод вызывается со значением Coin::Penny, но возвращаться при этом будет результат последнего выражения в блоке, то есть значение 1:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

Шаблоны, которые привязывают значения

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

В качестве примера, давайте изменим один из вариантов перечисления так, чтобы он хранил в себе данные. С 1999 по 2008 год Соединённые Штаты чеканили 25 центов с различным дизайном на одной стороне для каждого из 50 штатов. Ни одна другая монета не получила дизайна штата, только четверть доллара имела эту дополнительную особенность. Мы можем добавить эту информацию в наш enum путём изменения варианта Quarter и включить в него значение UsState, как сделано в листинге 6-4.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}

Листинг 6-4: Перечисление Coin, где вариант Quarter содержит также значение UsState

Давайте представим, что наш друг пытается собрать четвертаки всех 50 штатов. Пока мы сортируем мелочь по типу монет, мы также будем печатать имя штата, связанное с каждым четвертаком. Таким образом, если у нашего друга ещё нет такой монеты, то её можно добавить в его коллекцию.

В выражении match для этого кода мы добавляем переменную с именем state в шаблон, который соответствует значениям варианта Coin::Quarter. Когда Coin::Quarter совпадёт с шаблоном, переменная state будет привязана к значению штата этого четвертака. Затем мы сможем использовать state в коде этой ветки, вот так:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {:?}!", state);
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

Если мы сделаем вызов функции value_in_cents(Coin::Quarter(UsState::Alaska)), то coin будет иметь значение Coin::Quarter(UsState::Alaska). Когда мы будем сравнивать это значение с каждой из веток, ни одна из них не будет совпадать, пока мы не достигнем варианта Coin::Quarter(state). В этот момент state привяжется к значению UsState::Alaska. Затем мы сможем использовать эту привязку в выражении println!, получив таким образом внутреннее значение варианта Quarter перечисления Coin.

Сопоставление шаблона для Option<T>

В предыдущем разделе мы хотели получить внутреннее значение T для случая Some при использовании Option<T>; мы можем обработать тип Option<T> используя match, как уже делали с перечислением Coin! Вместо сравнения монет мы будем сравнивать варианты Option<T>, независимо от этого изменения механизм работы выражения match останется прежним.

Допустим, мы хотим написать функцию, которая принимает Option<i32> и если есть значение внутри, то добавляет 1 к существующему значению. Если значения нет, то функция должна возвращать значение None и не пытаться выполнить какие-либо операции.

Такую функцию довольно легко написать благодаря выражению match, код будет выглядеть как в листинге 6-5.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Листинг 6-5: Функция, которая использует выражение match с типом Option<i32>

Давайте рассмотрим процесс выполнения функции plus_one более подробно. Когда мы вызываем plus_one(five), то переменная x в теле plus_one будет иметь значение Some(5). Затем мы сравниваем это значение с каждой веткой выражения match.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Значение Some(5) не соответствует шаблону None, поэтому мы продолжаем со следующей ветки.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Совпадает ли Some(5) с шаблоном Some(i)? Да, это так! У нас такой же вариант. Тогда переменная i привязывается к значению, содержащемуся внутри Some, поэтому i получает значение 5. Затем выполняется код ассоциированный для данной ветки, поэтому мы добавляем 1 к значению i и создаём новое значение Some со значением 6 внутри.

Теперь давайте рассмотрим второй вызов plus_one в листинге 6-5, где x является None. Мы входим в выражение match и сравниваем значение с первой веткой.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Оно совпадает! Для данной ветки шаблон (None) не подразумевает наличие какого-то значения к которому можно было бы что-то добавить, поэтому программа останавливается и возвращает значение которое находится справа от => - т.е. None. Так как шаблон первой ветки совпал, то никакие другие шаблоны веток не сравниваются.

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

Match объемлет все варианты значения

Есть ещё один аспект выражения match, который необходимо обсудить. Рассмотрим версию нашей функции plus_one, которая имеет ошибку и не будет компилироваться:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Мы не обработали вариант None, поэтому этот код вызовет дефект в программе. К счастью, Rust знает и умеет ловить такой случай. Если мы попытаемся скомпилировать такой код, мы получим ошибку компиляции:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
   --> src/main.rs:3:15
    |
3   |         match x {
    |               ^ pattern `None` not covered
    |
    = help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
    = note: the matched value is of type `Option<i32>`

error: aborting due to previous error

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

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

Rust знает, что мы не обработали все возможные варианты входного значения, и даже знает какие ветки с какими шаблонами мы забыли добавить! Сравнение по шаблону в Rust является полными и исчерпывающими (exhaustive): мы должны обработать все возможные варианты до конца, чтобы код был корректным в понимании компилятора. Особенно в случае Option<T>, когда Rust не позволит нам забыть обработать случай None и защитит нас от ошибочного предположения, о том, что у нас всегда есть значение, хотя на самом деле мы могли бы получить null. Таким образом не дают допустить ошибку на миллиард долларов, рассмотренную ранее.

Заполнитель _

В Rust также есть шаблон, который можно использовать, когда не хочется перечислять все возможные значения. Например, u8 может иметь допустимые значения от 0 до 255. Если мы только заботимся о значениях 1, 3, 5 и 7 и не хотим перечислять 0, 2, 4, 6, 8, 9 вплоть до 255, то к счастью нам это не нужно. Вместо этого можно использовать специальный шаблон _:

fn main() {
    let some_u8_value = 0u8;
    match some_u8_value {
        1 => println!("one"),
        3 => println!("three"),
        5 => println!("five"),
        7 => println!("seven"),
        _ => (),
    }
}

Шаблон с заполнителем _ будет соответствовать любому значению. Поместив его после наших других веток, ветка с заполнителем _ будет соответствовать всем возможным случаям, которые не были указаны ранее. Так как () это просто значение единичного типа, то в случае _ ничего не произойдёт. В результате можно сказать, что мы хотим ничего не делать для всех возможных значений, которые мы не обработали в списке перед _ заполнителем.

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

Подробнее о шаблонах и сопоставлении с образцом можно найти в Главе 18.

Компактное управление потоком выполнения с if let

Синтаксис if let позволяет скомбинировать if и let в менее многословную конструкцию, и затем обработать значения соответствующе только одному шаблону, одновременно игнорируя все остальные. Рассмотрим программу, в которой мы делаем поиск по шаблону значения Option<u8>, чтобы выполнить код только когда значение равно 3:

fn main() {
    let some_u8_value = Some(0u8);
    match some_u8_value {
        Some(3) => println!("three"),
        _ => (),
    }
}

Листинг 6-6. Выражение match которое выполнит код только при значении равном Some(3)

Мы хотим выполнить что-нибудь при совпадении значения с Some(3) и не хотим ничего делать с любым другим Some<u8> или значением None . Для удовлетворения match, после первой и единственной ветки, нам пришлось добавить дополнительный шаблонный код: ветку _ => ().

Вместо этого мы могли бы решить нашу задачу более коротким способом, используя if let. Следующий код ведёт себя так же, как выражение match в листинге 6-6:

fn main() {
    let some_u8_value = Some(0u8);
    if let Some(3) = some_u8_value {
        println!("three");
    }
}

Синтаксис if let принимает шаблон и выражение, разделённые знаком равенства. if let сработает так же, как match, когда в него на вход передадут выражение и подходящим шаблоном для этого выражения окажется первая ветка.

Используя if let мы меньше печатаем, меньше делаем отступов и меньше получаем шаблонного кода. Тем не менее, мы теряем полную проверку всех вариантов, предоставляемую выражением match. Выбор между match и if let зависит от того, что вы делаете в вашем конкретном случае и является ли получение краткости при потере полноты проверки подходящим компромиссом.

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

Можно добавлять else к if let. Блок кода, который находится внутри else аналогичен по смыслу блоку кода ветки связанной с шаблоном _ выражения match (которое эквивалентно сборной конструкции if let и else). Вспомним объявление перечисления Coin в листинге 6-4, где вариант Quarter также содержит внутри значение штата типа UsState. Если бы мы хотели посчитать все монеты не являющиеся четвертями, а для четвертей печатать название штата, то мы могли бы сделать это с помощью выражения match таким образом:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {:?}!", state),
        _ => count += 1,
    }
}

Или мы могли бы использовать выражение if let и else так:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {:?}!", state);
    } else {
        count += 1;
    }
}

Если у вас есть ситуация в которой ваша программа имеет логику которая слишком многословна для того чтобы её выражать используя match, помните, о том, что также в вашем наборе инструментов Rust есть if let.

Итоги

Мы рассмотрели как использовать перечисления для создания пользовательских типов, которые могут быть одним из наборов перечисляемых значений. Мы показали, как тип Option<T> из стандартной библиотеки помогает использовать систему типов для предотвращения ошибок. А когда значения перечисления имеют данные внутри них, можно использовать match или if let, чтобы извлечь и пользоваться значением, в зависимости от того, сколько случаев нужно обработать.

Теперь ваши программы Rust могут выражать концепции вашей предметной области используя структуры и перечисления. Создание и использование пользовательских типов в API обеспечивает типобезопасность, type safety, вашего API: компилятор позаботится о том, чтобы функции получали значения только того типа, который они ожидают.

Чтобы предоставить хорошо организованный API пользователям, необходимо использовать и показывать только то, что нужно пользователям, давайте теперь обратимся к модулям в Rust.

Управление растущими проектами с помощью пакетов, крейтов и модулей

По мере роста кодовой базы ваших программ, организация проекта будет иметь большое значение, ведь отслеживание всей программы в голове будет становиться всё более сложным. Группируя связанные функции и разделяя код по основным функциональностям, (фичам, feature), вы делаете более прозрачным понимание о том, где искать код реализующий определённую функцию и где стоит вносить изменения для того чтобы изменить её поведение.

Программы, которые мы писали до сих пор, были в одном файле одного модуля. По мере роста проекта, мы можем организовывать код иначе, разделив его на несколько модулей и несколько файлов. Пакет может содержать несколько бинарных крейтов и опционально один крейт библиотеки. По мере роста пакета мы также можем извлекать части нашей программы в отдельные крейты, которые затем станут внешними зависимостями для основного кода нашей программы. Эта глава охватывает все эти техники. В свою очередь для очень крупных проектов, состоящих из набора взаимосвязанных пакетов развивающихся вместе, Cargo предоставляет рабочие пространства, workspaces, их мы рассмотрим за пределами данной главы, в разделе "Рабочие пространства Cargo" Главы 14.

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

Связанное понятие - это область видимости: вложенный контекст в котором написан код имеющий набор имён, которые определены «в текущей области видимости». При чтении, письме и компиляции кода, программистам и компиляторам необходимо знать, относится ли конкретное имя в определённом месте к переменной, к функции, к структуре, к перечислению, к модулю, к константе или другому элементу и что означает этот элемент. Можно создавать области видимости и изменять какие имена входят или выходят за их рамки. Нельзя иметь два элемента с тем же именем в одной области; есть доступные инструменты для разрешения конфликтов имён.

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

  • Пакеты, Packages: Функционал Cargo позволяющий собирать, тестировать и делиться крейтами
  • Крейты, Crates: Дерево модулей, которое создаёт библиотечный или исполняемый файл
  • Модули, Modules и use: Позволяют вместе контролировать организацию, область видимости и конфиденциальность путей
  • Пути, Paths: способ именования элемента, такого как структура, функция или модуль

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

Пакеты и крейты

Первые части модульной системы, которые мы рассмотрим - это пакеты и крейты. Крейт - это исполняемый файл или библиотека. Выделяют два типа крейтов: библиотечный и исполняемый. Библиотечные крейты можно подключать в другие крейты, но нельзя исполнять. Исполняемые же крейты - полная противоположность библиотечным - могут исполняться, но их нельзя подключить в другие крейты. Корень крейта - это исходный файл, на котором запускается и, исходя из которого, составляет корневой модуль вашего крейта Rust компилятор (мы расскажем о корневых модулях в разделе "Определение модулей для управления областью видимости и конфиденциальностью"). Пакет состоит из одного или нескольких крейтов, которые предоставляют набор функций. Пакет содержит файл Cargo.toml описывающий, как собрать крейты пакета.

Несколько правил определяют что может содержать пакет. Сейчас нам достаточно знать следующие:
1 - пакет должен содержать ноль или один библиотечный крейт (library crate), не больше,
2 - пакет может содержать любое число исполняемых крейтов (binary crates),
3 - пакет должен содержать по крайней мере один крейт (библиотечный или исполняемый).

Давайте пройдёмся по тому, что происходит, когда мы создаём пакет. Сначала введём команду cargo new:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

После ввода команды Cargo создал файл Cargo.toml, предоставив пакет. Если мы просмотрим содержимое Cargo.toml, то не увидим упоминания о src/main.rs потому что Cargo следует соглашению, что src/main.rs является корнем исполняемого крейта с тем же именем, что и пакет. Аналогично, Cargo знает, что если каталог пакета содержит src/lib.rs, то пакет содержит библиотечный крейт с тем же именем, что и пакет, а src/lib.rs является корнем библиотечного крейта. Cargo передаёт корневой файл крейта в rustc и тот уже создаст библиотеку или бинарный исполняемый файл в зависимости от типа крейта.

Здесь мы имеем пакет, который содержит только src/main.rs, то есть содержит только бинарный крейт с именем my-project. Если пакет содержит src/main.rs и src/lib.rs, то он имеет два крейта: библиотечный и исполняемый, оба с одинаковыми именами в качестве пакета. Пакет может иметь несколько исполняемых крейтов, размещая их файлы в каталоге src/bin: каждый файл будет отдельным исполняемым крейтом.

Крейт группирует в области видимости связанные вместе функциональности, поэтому функциональности легко распространить между несколькими проектами. Например, крейт rand, который мы использовали в Главе 2 обеспечивает функциональность генерации случайных чисел. Можно использовать эту функциональность в наших собственных проектах, привнося крейт rand в область видимости проекта. Вся функциональность предоставляемая крейтом rand станет доступна через имя крейта rand.

Сохранение функциональности крейта в его собственной области видимости проясняет, является ли конкретная функциональность определённой в нашем крейте или в крейте rand, таким образом предотвращая потенциальные конфликты. Например, крейт rand предоставляет типаж с именем Rng. Мы также можем определить struct с именем Rng в нашем собственном крейте. Так как функциональность крейта находится в пространстве имён собственной области видимости, то когда мы добавляем rand как зависимость, компилятор не смущён ссылкой на имя Rng. В нашем крейте ссылка относится к объявленной у нас struct Rng. А доступ к типажу Rng из крейта rand мы бы получили как rand::Rng.

Давайте будем двигаться дальше и поговорим о модульной системе!

Определение модулей для контроля видимости и конфиденциальности

В этом разделе мы поговорим о модулях и других частях системы модулей, а именно: путях(paths), которые позволяют именовать элементы; ключевом слове use, которое приносит путь в область видимости; ключевом слове pub, которое делает элементы общедоступными. Мы также обсудим ключевое слово as, внешние пакеты и оператор glob. А пока давайте сосредоточимся на модулях!

Модули позволяют организовывать код внутри крейта по группам для удобства чтения и простого повторного использования. Модули также контролируют конфиденциальность (privacy) элементов: определяют может элемент использоваться внешним кодом, быть публичным (public) или является деталями внутренней реализации и недоступен для внешнего использования, т.е. является приватным (private).

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

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

Чтобы структурировать крейт аналогично тому, как работает настоящий ресторан, можно организовать размещение функций во вложенных модулях. Создадим новую библиотеку (библиотечный крейт) с именем restaurant выполнив команду cargo new --lib restaurant; затем вставим код из листинга 7-1 в файл src/lib.rs для определения некоторых модулей и сигнатур функций.

Файл: src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

Листинг 7-1. Модуль front_of_house содержащий другие модули, которые затем содержат функции

Мы определяем модуль, начиная с ключевого слова mod, затем определяем название модуля (в данном случае front_of_house) и размещаем фигурные скобки вокруг тела модуля. Внутри модулей, можно иметь другие модули, как в случае с модулями hosting и serving. Модули также могут содержать определения для других элементов, таких как структуры, перечисления, константы, типажи или - как в листинге 7-1 - функции.

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

Как мы упоминали ранее, файлы src/main.rs и src/lib.rs называются корнями крейтов. Причина такого именования в том, что любой из этих двух файлов определяет содержимое и размещает в корне структуры модуля, известной как дерево модулей, корневой модуль известный как crate.

В листинге 7-2 показано дерево модулей для структуры модулей приведённой в коде листинга 7-1.

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

Листинг 7-2: Дерево модулей для для структуры модулей приведённой в коде в листинге 7-1

Это дерево показывает, как некоторые из модулей вкладываются друг в друга (например, hosting находится внутри front_of_house). Дерево также показывает, что некоторые модули являются братьями (siblings) друг для друга, то есть они определены в одном модуле (hosting и serving - братья которые определены внутри front_of_house). Продолжая метафору с семьёй: если модуль A содержится внутри модуля B, мы говорим, что модуль A является потомком (child) модуля B, а модуль B является родителем (parent) модуля A. Обратите внимание, что корнем (отцом, главным предком) всего дерева модулей является неявный модуль с именем crate.

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

Ссылаемся на элементы дерева модулей при помощи путей

Чтобы показать Rust, где найти элемент в дереве модулей, мы используем путь так же, как мы используем путь при навигации по файловой системе. Например, если мы хотим вызвать функцию, то нам нужно знать её путь.

Пути бывают двух видов:

  • абсолютный путь берет своё начало с корня крейта: названия крейта или ключевого слова crate,
  • относительный путь начинается с текущего модуля и использует ключевые слова self, super или идентификатор в текущем модуле.

Как абсолютные, так и относительные, пути сопровождаются одним или несколькими идентификаторами разделёнными двойными двоеточиями (::).

Давайте вернёмся к примеру в листинге 7-1. Как бы мы вызывали функцию add_to_waitlist? Наш вызов был бы похож на путь к функции add_to_waitlist? В листинге 7-3 мы немного упростили код листинга 7-1, удалив ненужные модули и функции. Мы покажем два способа вызова функции add_to_waitlist из новой функции eat_at_restaurant определённой в корне крейта. Функция eat_at_restaurant является частью нашей библиотеки публичного API, поэтому мы помечаем её ключевым словом pub. В разделе "Раскрытие путей с помощью ключевого слова pub", мы рассмотрим более подробно pub. Обратите внимание, что этот пример ещё не компилируется; мы скоро объясним почему.

Файл: src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Листинг 7-3. Вызов функции add_to_waitlist с использованием абсолютного и относительного пути

Первый раз, когда мы вызываем функцию add_to_waitlist в функции eat_at_restaurant мы используем абсолютный путь. Функция add_to_waitlist определена в том же крейте что и eat_at_restaurant и это означает, что мы можем использовать ключевое слово crate в начале абсолютного пути.

После ключевого слова crate мы включаем каждый из последующих дочерних модулей, пока не составим путь до add_to_waitlist. Вы можете представить файловую систему с такой же структурой, где мы указали бы путь /front_of_house/hosting/add_to_waitlist для запуска программы add_to_waitlist; мы используем слово crate, чтобы начать путь из корня крейта, подобно тому как используется / для указания корневой директории файловой системы.

Второй раз, когда мы вызываем add_to_waitlist внутри eat_at_restaurant, мы используем относительный путь. Путь начинается с имени модуля front_of_house, определённого на том же уровне дерева модулей, что и модуль eat_at_restaurant. Для относительного пути эквивалентный путь в вымышленной файловой системе выглядел бы так: front_of_house/hosting/add_to_waitlist. Начало пути совпадает с именем модуля, что указывает на то, что перед нами относительный путь.

Выбор, использовать относительный или абсолютный путь, является решением, которое вы примете на основании вашего проекта. Решение должно зависеть от того, с какой вероятностью вы переместите объявление элемента отдельно от или вместе с кодом использующим этот элемент. Например, в случае перемещения модуля front_of_house и его функции eat_at_restaurant в другой модуль с именем customer_experience, будет необходимо обновить абсолютный путь до add_to_waitlist, но относительный путь все равно будет действителен. Однако, если мы переместим отдельно функцию eat_at_restaurant в модуль с именем dining, то абсолютный путь вызова add_to_waitlist останется прежним, а относительный путь нужно будет обновить. Мы предпочитаем указывать абсолютные пути, потому что это позволяет проще перемещать определения кода и вызовы элементов независимо друг от друга.

Давайте попробуем скомпилировать листинг 7-3 и выяснить, почему он ещё не компилируется. Ошибка, которую мы получаем, показана в листинге 7-4.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^ private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^ private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
2  |     mod hosting {
   |     ^^^^^^^^^^^

error: aborting due to 2 previous errors

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

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

Листинг 7-4. Ошибки компиляции при сборке кода из листинга 7-3

Сообщения об ошибках говорят о том, что модуль hosting является приватным. Таким образом, у нас есть правильные пути к модулю hosting и функции add_to_waitlist, но Rust не позволяет использовать их, потому что он не имеет доступа к разделам которые являются приватными, как в нашем случае.

Модули не только полезны для организации кода. Они также определяют границы конфиденциальности (privacy boundary) в Rust: граница, которая инкапсулирует детали реализации, которые внешний код не может знать, вызывать или полагаться на них. Итак, если вы хотите сделать элемент приватным, например функцию или структуру, то разместите его в модуль.

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

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

Раскрываем приватные пути с помощью ключевого слова pub

Давайте вернёмся к ошибке в листинге 7-4, которая говорит что модуль hosting является приватным. Мы хотим, чтобы функция eat_at_restaurant представленная в родительском модуле eat_at_restaurant имела доступ к функции add_to_waitlist в дочернем модуле, поэтому мы помечаем модуль hosting с ключевым словом pub, как показано в листинге 7-5.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Листинг 7-5. Объявление модуля hosting как pub для его использования из eat_at_restaurant

К сожалению, код в листинге 7-5 всё ещё приводит к ошибке, как показано в листинге 7-6.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
 --> src/lib.rs:9:37
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                                     ^^^^^^^^^^^^^^^ private function
  |
note: the function `add_to_waitlist` is defined here
 --> src/lib.rs:3:9
  |
3 |         fn add_to_waitlist() {}
  |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:12:30
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

error: aborting due to 2 previous errors

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

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

Листинг 7-6: Ошибки компиляции при сборке кода в листинге 7-5

Что произошло? Добавление ключевого слова pub перед mod hosting сделало модуль публичным. После этого изменения, если мы можем получить доступ к модулю front_of_house, то мы можем доступ к модулю hosting. Но содержимое модуля hosting всё ещё является приватным: превращение модуля в публичный не делает его содержимое публичным. Ключевое слово pub позволяет внешнему коду в модулях предках обращаться только к модулю.

Ошибки в листинге 7-6 говорят, что функция add_to_waitlist является закрытой. Правила конфиденциальности применяются к структурам, перечислениям, функциям и методам, также как и к модулям.

Давайте также сделаем функцию add_to_waitlist общедоступной, добавив ключевое слово pub перед её определением, как показано в листинге 7-7.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Листинг 7-7. Добавление ключевого слова pub у модуля mod hosting и функции fn add_to_waitlist позволяет вызывать ранее скрытую функцию из функции eat_at_restaurant

Теперь код компилируется! Давайте посмотрим на абсолютный и относительный путь и перепроверим, почему добавление ключевого слова pub позволяет использовать эти пути в add_to_waitlist с учётом правила конфиденциальности.

В случае абсолютного пути мы начинаем с crate, корня дерева модуля нашего крейта. Затем в корне крейта определён модуль front_of_house. Модуль front_of_house приватный, потому что функция eat_at_restaurant определена в том же модуле, что и front_of_house (то есть eat_at_restaurant и front_of_house являются родственными), мы можем сослаться на front_of_house из eat_at_restaurant. Затем идёт модуль hosting, он также помечен с помощью pub. Мы можем получить доступ к родительскому модулю hosting, по этому hosting также доступен. Наконец функция add_to_waitlist тоже помечена как pub, и в то же время можно получить доступ к её родительскому модулю, значит вызов функции работает!

В случае относительного пути логика совпадает со случаем абсолютного пути, за исключением первого шага: вместо того, чтобы начинать с корня крейта, путь начинается с front_of_house. Модуль front_of_house определён в том же модуле, что и eat_at_restaurant, поэтому относительный путь, начинающийся с модуля в котором определён eat_at_restaurant тоже работает. Тогда, по причине того, что hosting и add_to_waitlist помечены как pub, остальная часть пути работает и вызов функции действителен!

Начинаем относительный путь с помощью super

Также можно построить относительные пути, которые начинаются в родительском модуле, используя ключевое слово super в начале пути. Это похоже на синтаксис начала пути файловой системы ... Зачем нам так делать?

Рассмотрим код в листинге 7-8, который моделирует ситуацию в которой повар исправляет неправильный заказ и лично выдаёт его клиенту. Функция fix_incorrect_order вызывает функцию serve_order, указывая путь к serve_order начинающийся со super:

Файл: src/lib.rs

fn serve_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::serve_order();
    }

    fn cook_order() {}
}

Листинг 7-8: Вызов функции с использованием относительного пути, начинающегося со super

Функция fix_incorrect_order находится в модуле back_of_house, поэтому мы можем использовать super для перехода к родительскому модулю back_of_house, который в этом случае является корнем crate. Из корня мы пытаемся найти serve_order и находим его. Успех! Мы считаем, что модуль back_of_house и функция serve_order остаются в одинаковых отношениях друг с другом и должны быть перемещены вместе, если мы решим реорганизовать дерево модулей крейта. Поэтому мы использовали super. В итоге, в будущем нам не понадобится обновлять путь до модуля, при перемещении кода в другой модуль.

Делаем публичными структуры и перечисления

Мы также можем использовать pub, чтобы сделать структуры и перечисления публичными, но есть несколько дополнительных деталей. Если используется pub перед определением структуры, то структура становится публичной, но поля структуры все ещё остаются приватными. Делать ли каждое поле публичным или нет решается в каждом конкретном случае. В листинге 7-9 мы определили публичную структуру back_of_house::Breakfast с открытым полем toast, но оставили приватным поле seasonal_fruit. Это моделирует случай в ресторане, когда клиент может выбрать тип хлеба к блюду, но повар решает, какие фрукты сопровождают блюдо на основании того, какой сейчас сезон и что есть на складе. Доступные фрукты быстро меняются, поэтому покупатели не могут выбирать фрукты или даже посмотреть, какие фрукты они получат.

Файл: src/lib.rs


#![allow(unused)]
fn main() {
mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal
    // meal.seasonal_fruit = String::from("blueberries");
}
}

Листинг 7-9: Структура с публичными и приватными полями

Поскольку поле toast в структуре back_of_house::Breakfast является открытым, то в функции eat_at_restaurant можно писать и читать поле toast, используя точечную нотацию. Обратите внимание, что мы не можем использовать поле seasonal_fruit в eat_at_restaurant, потому что seasonal_fruit является приватным. Попробуйте убрать комментирование с последней строки для значения поля seasonal_fruit, чтобы увидеть какую ошибку вы получите!

Также обратите внимание, что поскольку back_of_house::Breakfast имеет приватное поле, то структура должна предоставить публичную ассоциированную функцию, которая создаёт экземпляр Breakfast (мы назвали её summer). Если Breakfast не имел бы такой функции, мы бы не могли создать экземпляр Breakfast внутри eat_at_restaurant, потому что мы не смогли бы установить значение приватного поля seasonal_fruit в функции eat_at_restaurant.

В отличии от структуры, если мы сделаем публичным перечисление, то все его варианты будут публичными. Нужно только указать pub перед ключевым словом enum, как в листинге 7-10.

Файл: src/lib.rs


#![allow(unused)]
fn main() {
mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}
}

Листинг 7-10. Определяя перечисление публичным мы делаем все его варианты публичными

Поскольку мы сделали публичным список Appetizer, то можно использовать варианты Soup и Salad в функции eat_at_restaurant. Перечисления не очень полезны, если их варианты являются приватными: было бы досадно каждый раз аннотировать все перечисленные варианты как pub. По этой причине по умолчанию варианты перечислений являются публичными. Структуры часто полезны, если их поля не являются открытыми, поэтому поля структуры следуют общему правилу, согласно которому всё по умолчанию является приватными, если не указано pub.

Есть ещё одна ситуация с pub которую мы не освещали, и это последняя особенность модульной системы: ключевое слово use. Мы сначала опишем use само по себе, а затем покажем как сочетать pub и use вместе.

Подключение путей в область видимости с помощью ключевого слова use

Может показаться, что пути, которые мы писали для вызова функций неудобные, длинные и повторяющиеся. Например, в листинге 7-7, где мы выбирали абсолютный или относительный путь к функции add_to_waitlist, каждый раз для вызова add_to_waitlist мы должны были указать модули front_of_house и hosting. К счастью, есть способ упрощения этого процесса. Можно подключить путь в область видимости один раз, а затем вызывать элементы из этого пути будто это локальные элементы используя ключевое слово use.

В листинге 7-11 мы подключили модуль crate::front_of_house::hosting в область действия функции eat_at_restaurant, поэтому нам достаточно только указать hosting::add_to_waitlist для вызова функции add_to_waitlist внутри eat_at_restaurant.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

Листинг 7-11. Подключение модуля в область видимости с помощью use

Добавление use и пути в область видимости аналогично созданию символической ссылки в файловой системе. Добавляя use crate::front_of_house::hosting в корень крейта, hosting теперь является допустимым именем в этой области, как если бы hosting модуль был определён в корне крейта. Пути подключённые в область видимости с помощью use также проверяют конфиденциальность как и любые другие пути.

Также можно подключить элемент в область видимости с помощью use и относительного пути. Листинг 7-12 показывает как указать относительный путь, чтобы получить то же поведение, что и в листинге 7-11.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use self::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

Листинг 7-12. Подключение модуля в область видимости с помощью use и относительного пути

Создание идиоматических путей с use

В листинге 7-11 вы могли бы задаться вопросом, почему мы указали use crate::front_of_house::hosting, а затем вызвали hosting::add_to_waitlist внутри eat_at_restaurant вместо указания в use полного пути прямо до функции add_to_waitlist для получения того же результата, что в листинге 7-13.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
    add_to_waitlist();
    add_to_waitlist();
}

Листинг 7-13. Подключение функции add_to_waitlist в область видимости с помощью use не идиоматическим способом

Хотя листинги 7-11 и 7-13 выполняют одну и ту же задачу, листинг 7-11 является идиоматическим способом подключения функции в область видимости с помощью use. Подключение родительского модуля функции в область видимости при помощи use, и последующее указание родительского модуля в строке вызова его функций, даёт ясное понимание того, что эта функция определена не локально, и в то же время всё ещё минимизирует повторение полного пути. В коде листинга 7-13 не ясно, где именно определена add_to_waitlist.

С другой стороны, при подключении структур, перечислений и других элементов используя use, идиоматически правильным будет указывать полный путь. Листинг 7-14 показывает идиоматический способ подключения структуры стандартной библиотеки HashMap в область видимости исполняемого крейта.

Файл: src/main.rs

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

Листинг 7-14. Подключение HashMap в область видимости идиоматическим способом

За этой идиомой нет веской причины: это просто соглашение, которое появилось само собой. Люди привыкли читать и писать код Rust таким образом.

Исключением из этой идиомы является случай, когда мы подключаем два элемента с одинаковыми именами в область видимости используя оператор use - Rust просто не позволяет этого сделать. Листинг 7-15 показывает, как подключить в область действия два типа с одинаковыми именами Result, но из разных родительских модулей и как на них ссылаться.

Файл: src/lib.rs


#![allow(unused)]
fn main() {
use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}
}

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

Как видите, использование имени родительских модулей позволяет различать два типа Result. Если бы вместо этого мы указали use std::fmt::Result и use std::io::Result, мы бы имели два типа Result в одной области видимости, и Rust не смог бы понять какой из двух Result мы имели в виду когда нашёл бы их употребление в коде.

Предоставление новых имён с помощью ключевого слова as

Есть ещё одно решение проблемы объединения двух типов с одинаковыми именами в одной области видимости используя use: после пути можно указать as и новое локальное имя (псевдоним) для типа. Листинг 7-16 показывает другой способ написать код в листинге 7-15 путём переименования одного из двух типов Result используя as.

Файл: src/lib.rs


#![allow(unused)]
fn main() {
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}
}

Листинг 7-16. Переименование типа с помощью ключевого слова as при его подключении в область видимости

Во втором операторе use мы выбрали новое имя IoResult для типа std::io::Result, которое теперь не будет конфликтовать с типом Result из std::fmt, который также подключён в область видимости. Листинги 7-15 и 7-16 считаются идиоматичными, поэтому выбор за вами!

Реэкспорт имён с pub use

Когда мы подключаем имя в область видимости используя ключевое слово use, то имя доступное в новой области видимости является приватным. Чтобы позволить коду, который вызывает наш код, ссылаться на это имя, как если бы оно было определено в области видимости данного кода, можно объединить pub и use. Этот метод называется реэкспортом (re-exporting), потому что мы подключаем элемент в область видимости, но также делаем этот элемент доступным для подключения в других областях видимости.

Листинг 7-17 показывает код как в листинге 7-11 (где используется use в корневом модуле), но с изменениями: теперь применяется pub use.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

Листинг 7-17. Делаем при помощи pub use имя доступным для любого кода из новой области видимости

Благодаря использованию pub use, внешний код теперь может вызывать функцию add_to_waitlist используя hosting::add_to_waitlist. Если бы мы не указали pub use, то только функция eat_at_restaurant могла бы вызывать hosting::add_to_waitlist в своей области видимости, но внешний код не смог бы так сделать.

Реэкспорт полезен, когда внутренняя структура вашего кода отличается от того, как другие программисты вызывающие ваш код, будут думать о предметной области. Например, в метафоре про ресторан, люди работающие в ресторане, думают о «фронтальной части дома» и «задней части дома». Но вероятно что клиенты посещающие ресторан, не буду думать о частях ресторана в таких терминах. С помощью pub use, можно структурировать код по одному принципу, но наружу публиковать другие варианты структуризации кода (подходящие под разные предметные области). Благодаря этому мы можем сделать нашу библиотеку удобно организованной как для программистов, работающих над библиотекой так и для программистов вызывающих нашу библиотеку.

Использование внешних пакетов

В Главе 2 мы запрограммировали игру угадывания числа, где использовался внешний пакет для получения случайного числа, называемый rand. Чтобы использовать в нашем проекте пакет rand, мы добавили строку в Cargo.toml:

Файл: Cargo.toml

[dependencies]
rand = "0.8.3"

Добавление rand в качестве зависимости в Cargo.toml указывает Cargo загрузить пакет rand и любые требующиеся для работы этого пакета зависимости из crates.io и сделать rand доступным для нашего проекта.

Затем, чтобы подключить определения rand в область видимости нашего пакета, мы добавили строку use начинающуюся с названия пакета rand и списка элементов, которые мы хотим подключить в область видимости. Напомним, что в разделе "Генерация случайного числа" Главы 2, мы подключили типаж Rng в область видимости и вызвали функцию rand::thread_rng:

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

Члены сообщества Rust сделали много пакетов доступными на ресурсе crates.io, и добавление любого из них в свой пакет включает в себя одни и те же шаги: добавить пакет в файл Cargo.toml вашего пакета, использовать use для подключения элементов этого пакета в область видимости.

Обратите внимание, стандартная библиотека (std) также является крейтом, который является внешним по отношению к нашему пакету. Поскольку стандартная библиотека поставляется с языком Rust, то не нужно вносить изменения в Cargo.toml для подключения std. Но чтобы добавить её элементы в область видимости нашего пакета, нам нужно сослаться на неё используя use. Например, чтобы добавить HashMap в область видимости нам потребуется использовать следующую строку:


#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

Это абсолютный путь, начинающийся с std, имени крейта стандартной библиотеки.

Использование вложенных путей для уменьшения длинных списков use

Если мы используем несколько элементов определённых в одном пакете или в том же модуле, то перечисление каждого элемента в отдельной строке может занимать много вертикального пространства в файле. Например, эти два объявления use используются в программе угадывания числа (листинг 2-4) для подключения элементов из std в область видимости:

Файл: src/main.rs

use rand::Rng;
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

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

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Вместо этого, для того чтобы подключить в область видимости те же элементы одной строкой, можно использовать вложенные пути. Мы делаем это, как показано в листинге 7-18, указывая общую часть пути, за которой следуют два двоеточия, а затем фигурные скобки вокруг списка тех частей продолжения пути, которые отличаются.

Файл: src/main.rs

use rand::Rng;
// --snip--
use std::{cmp::Ordering, io};
// --snip--

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

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

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

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

Можно использовать вложенный путь на любом уровне, что полезно при объединении двух операторов use, которые имеют общую часть пути. Например, в листинге 7-19 показаны два оператора use: один подключает std::io, другой подключает std::io::Write в область видимости.

Файл: src/lib.rs


#![allow(unused)]
fn main() {
use std::io;
use std::io::Write;
}

Листинг 7-19. Два оператора use где один содержит часть пути другого

Общей частью этих двух путей является std::io, и это полный первый путь. Чтобы объединить эти два пути в одно выражение use, мы можем использовать ключевое слово self во вложенном пути, как показано в листинге 7-20.

Файл: src/lib.rs


#![allow(unused)]
fn main() {
use std::io::{self, Write};
}

Листинг 7-20. Объединение путей из листинга 7-19 в один оператор use

Эта строка подключает std::io и std::io::Write в область видимости.

Оператор * (Glob)

Если хотим подключить в область видимости все общие элементы, определённые в пути, можно указать путь за которым следует оператор * (звёздочка, glob):


#![allow(unused)]
fn main() {
use std::collections::*;
}

Этот оператор use подключает все открытые элементы из модуля std::collections в текущую область видимости. Будьте осторожны при использовании оператора *! Он может усложнить понимание, какие имена находятся в области видимости и где были определены имена, используемые в вашей программе.

Оператор * часто используется при тестировании для подключения всего что есть в модуле tests; мы поговорим об этом в разделе "Как писать тесты" Главы 11. Оператор * также иногда используется как часть шаблона автоматического импорта (prelude): смотрите документацию по стандартной библиотеке для получения дополнительной информации об этом шаблоне.

Разделение модулей на разные файлы

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

Например, давайте начнём с кода листинга 7-17 и первым шагом переместим модуль front_of_house в свой собственный файл src/front_of_house.rs, изменив корневой файл крейта так, чтобы он содержал код показанный в листинге 7-21. В этом случае, корневым файлом крейта является src/lib.rs, но эта процедура также работает с исполняемыми крейтами у которых корневой файл крейта src/main.rs.

Файл: src/lib.rs

mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

Листинг 7-21. Добавление в корневой файл крейта тела модуля front_of_house (которое далее будет вынесено в src/front_of_house.rs)

И на втором шаге в содержимом src/front_of_house.rs определим тело модуля front_of_house (которое мы изъяли из src/lib.rs), как показано в листинге 7-22.

Файл: src/front_of_house.rs

pub mod hosting {
    pub fn add_to_waitlist() {}
}

Листинг 7-22. Определения тела модуля front_of_house в файле src/front_of_house.rs

Использование точки с запятой после mod front_of_house, вместо объявления начала блока, говорит Rust загрузить содержимое модуля из другого файла имеющего такое же название как и имя модуля. Продолжим наш пример и выделим модуль hosting в отдельный файл, а затем поменяем содержимое файла src/front_of_house.rs так, чтобы он содержал только объявление модуля hosting:

Файл: src/front_of_house.rs

pub mod hosting;

Затем мы создаём каталог src/front_of_house и файл src/front_of_house/hosting.rs в данной директории. Чтобы вынести модуль мы, так же как и ранее, должны выделить содержимое модуля hosting из прежнего места и перенести его в свой файл модуля hosting.rs:

Файл: src/front_of_house/hosting.rs


#![allow(unused)]
fn main() {
pub fn add_to_waitlist() {}
}

Дерево модулей остаётся прежним, а вызовы функций в eat_at_restaurant будут работать без каких-либо изменений, даже если определения будут в разных файлах. Этот метод позволяет перемещать модули в новые файлы по мере их разрастания.

Обратите внимание на то, что выражение pub use crate::front_of_house::hosting в файле src/lib.rs не претерпело каких-либо изменений после переноса модулей в отдельные файлы. В то же время благодаря этому use не добавило какого-либо влияния на то какие файлы будут скомпилированы как часть крейта. Ключевое слово mod объявляет модули, а Rust просматривает файл с тем же именем, что и модуль: так он определяет код, который входит в этот модуль.

Итог

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

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

Коллекции стандартной библиотеки

Стандартная библиотека содержит несколько полезных структур данных, которые называются коллекциями. Большая часть других типов данных представляют собой хранение конкретного значения, но особенностью коллекций является хранение множества однотипных значений. В отличии от массива или кортежа данные коллекций хранятся в куче, а это значит, что размер коллекции может быть неизвестен в момент компиляции программы. Он может изменяться (увеличиваться, уменьшаться) во время работы программы. Каждый вид коллекций имеет свои возможности и отличается по производительности, так что выбор конкретной коллекции зависит от ситуации и является умением разработчика, вырабатываемым со временем. В этой главе будет рассмотрено несколько коллекций:

  • Вектор (vector) - позволяет нам сохранять различное количество последовательно хранящихся значений,
  • Строка (string) - это последовательность символов. Мы же упоминали тип String ранее, но в данной главе мы поговорим о нем подробнее.
  • Хеш таблица (hash map) - коллекция которая позволяет хранить перечень ассоциаций значения с ключом (перечень пар ключ:значение). Является конкретной реализацией более общей структуры данных называемой map.

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

Мы обсудим как создавать и обновлять вектора, строки и хеш таблицы, а также объясним что делает каждую из них особенной.

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

Первым типом коллекции, который мы разберём, будет Vec, также известный как вектор (vector). Векторы позволяют сохранять более одного значения в одной структуре данных, сохраняющей элементы в памяти один за другим. Векторы могут сохранять данные только одного типа. Их удобно использовать, когда нужно сохранить список элементов, например, список текстовых строк в файле, или список цен товаров из корзины покупок.

Создание нового вектора

Для создания нового вектора используется функция Vec::new, как показано в листинге 8-1.

fn main() {
    let v: Vec<i32> = Vec::new();
}

Листинг 8-1. Создание нового пустого вектора для хранения значений типа i32

Обратите внимание, что здесь мы добавили аннотацию типа. Поскольку мы не вставляем никаких значений в этот вектор, Rust не знает, какие элементы мы собираемся хранить. Это важный момент. Векторы реализованы с использованием обобщённых типов; мы рассмотрим, как использовать обобщённые типы с вашими собственными типами в Главе 10. А пока знайте, что тип Vec<T> предоставляемый стандартной библиотекой, может содержать любой тип, и когда конкретный вектор содержит определённый тип, тип указан в угловых скобках. В листинге 8-1 мы сообщили Rust, что Vec<T> в v будет содержать элементы типа i32.

В более реальном коде, Rust часто может вывести тип сохраняемых вами значений, как только вы вставите значения в вектор. Так что вам довольно редко нужна данная аннотация типа. Более общим является создание Vec<T> имеющего начальные значения: для удобства Rust предоставляет макрос vec! для этой цели. Макрос создаст новый вектор, содержащий указанные значения. Листинг 8-2 создаёт новый Vec<i32>, содержащий значения 1, 2 и 3. Числовым типом является i32, потому что это числовой тип по умолчанию для целочисленных значений, о чём упоминалось в разделе "Типы данных" Главы 3.

fn main() {
    let v = vec![1, 2, 3];
}

Листинг 8-2. Создание нового вектора, содержащего значения

Поскольку мы указали начальные значения i32, Rust может сделать вывод, что тип переменной v это Vec и аннотация типа здесь не нужна. Далее мы посмотрим как изменять вектор.

Изменение вектора

Чтобы создать вектор и затем добавить к нему элементы, можно использовать метод push показанный в листинге 8-3.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

Листинг 8-3. Использование метода push для добавления значений в вектор

Как и с любой переменной, если мы хотим изменить её значение, нам нужно сделать её изменяемой с помощью ключевого слова mut, что обсуждалось в Главе 3. Все числа которые мы помещаем в вектор имеют тип i32 по этому Rust с лёгкостью выводит тип вектора, по этой причине нам не нужна здесь аннотация типа вектора Vec<i32>.

Удаление элементов из вектора

Подобно структурам struct, вектор высвобождает свою память когда выходит из области видимости функции в которой он определён, данное поведение прокомментировано в листинге 8-4.

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}

Листинг 8-4. Показывает, где вектор и его элементы уже будут удалены.

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

Чтение данных вектора

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

В листинге 8-5 показаны оба метода доступа к значению в векторе: либо с помощью синтаксиса индексации, либо с помощью метода get.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {}", third);

    match v.get(2) {
        Some(third) => println!("The third element is {}", third),
        None => println!("There is no third element."),
    }
}

Листинг 8-5. Использование синтаксиса индексации или метода get для доступа к элементу в векторе

Обратите внимание здесь на пару деталей. Во-первых, используется значение индекса 2 для получения третьего элемента: векторы индексируются начиная с нуля. Во-вторых, есть два способа получения третьего элемента: либо используя & с [] возвращающих ссылку на элемент, либо с помощью метода get содержащего индекс, переданный в качестве аргумента, который возвращает Option<&T>.

В Rust есть два способа ссылаться на элемент, поэтому вы можете выбрать, как будет вести себя программа, когда вы попытаетесь использовать значение индекса, для которого в векторе нет элемента. В качестве примера давайте посмотрим, что будет делать программа, если в ней определён вектор, содержащий пять элементов, но она пытается получить доступ к элементу с индексом 100, как показано в листинге 8-6.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}

Листинг 8-6. Попытка доступа к элементу с индексом 100 в векторе, содержащем всего пять элементов

Когда мы запускаем этот код, первая строка с &v[100] вызовет панику программы, потому что происходит попытка получить ссылку на несуществующий элемент. Такой подход лучше всего использовать, когда вы хотите, чтобы ваша программа аварийно завершила работу при попытке доступа к элементу за пределами вектора.

Когда методу get передаётся индекс, который находится за пределами вектора, он без паники возвращает None. Вы могли бы использовать такой подход, если доступ к элементу за пределами диапазона вектора происходит время от времени при нормальных обстоятельствах. Тогда ваш код будет иметь логику для обработки наличия Some(&element) или None, как обсуждалось в Главе 6. Например, индекс может исходить от человека, вводящего число. Если пользователь случайно введёт слишком большое число, то программа получит значение None и у вас будет возможность сообщить пользователю, сколько элементов находится в текущем векторе, и дать ему ещё один шанс ввести допустимое значение. Такое поведение было бы более дружелюбным для пользователя, чем внезапный сбой программы из-за опечатки!

Когда у программы есть действительная ссылка, borrow checker (средство проверки заимствований), обеспечивает соблюдение правил владения и заимствования (описаны в Главе 4), чтобы гарантировать, что эта ссылка и любые другие ссылки на содержимое вектора остаются действительными. Вспомните правило, которое гласит, что у вас не может быть изменяемых и неизменяемых ссылок в одной и той же области. Это правило применяется в листинге 8-7, где мы храним неизменяемую ссылку на первый элемент вектора и затем пытаемся добавить элемент в конец вектора, что не сработает:

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("The first element is: {}", first);
}

Листинг 8-7. Попытка добавить некоторый элемент в вектор, в то время когда есть ссылка на элемент вектора

Компиляция этого кода приведёт к ошибке:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 | 
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 | 
8 |     println!("The first element is: {}", first);
  |                                          ----- immutable borrow later used here

error: aborting due to previous error

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

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

Код в Листинге 8-7 может выглядеть вполне рабочим: почему ссылка на первый элемент должна беспокоится про изменения в конце вектора? Ошибка, о которой сообщает компилятор, связана с тем, как работают векторы. Добавление нового элемента в конец вектора, может потребовать выделение нового участка памяти и копирования старых элементов в него. Повторное выделение памяти произойдёт если там, где вектор находится в настоящее время, недостаточно места для размещения новых элементов рядом со старыми - придётся разместить вектор в новом месте. В этом случае ссылка на первый элемент будет указывать на освобождённую память. Правила заимствования не допускают, чтобы программа оказалась в такой ситуации.

Примечание: Дополнительные сведения о реализации типа Vec<T> смотрите в разделе "The Rustonomicon".

Перебор значений в векторе

Если мы хотим получить доступ к каждому элементу вектора по очереди, мы можем перебирать все элементы, а не использовать индексы для доступа по одному за раз. В листинге 8-8 показано, как использовать цикл for для получения неизменяемых ссылок на каждый элемент в векторе со значениями типа i32 и их печати.

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{}", i);
    }
}

Листинг 8-8. Печать каждого элемента вектора, при помощи итерирования по элементам вектора с помощью цикла for

Мы также можем итерировать изменяемые ссылки на каждый элемент изменяемого вектора, чтобы вносить изменения во все элементы. Цикл for в листинге 8-9 добавит 50 к каждому элементу.

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}

Листинг 8-9. Итерирование и изменение элементов вектора по изменяемым ссылкам

Чтобы изменить значение на которое ссылается изменяемая ссылка, мы должны использовать оператор разыменования ссылки (*) для получения значения по ссылке в переменной i прежде чем использовать оператор +=. Мы поговорим подробнее об операторе разыменования в разделе "Следуя указателю на значение с помощью оператора разыменования" Главы 15.

Использование перечислений для хранения множества разных типов

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

Например, мы хотим получить значения из строки в электронной таблице где некоторые столбцы строки содержат целые числа, некоторые числа с плавающей точкой, а другие - вообще строковые значения. Можно определить перечисление, варианты которого будут содержать разные типы значений и тогда все варианты перечисления будут считаться одними и тем же типом: типом самого перечисления. Затем можно создать вектор, который содержит в себе значения этого перечисления и таким образом в конечном счёте добиться того, что в векторе будут сохраняться значения разного типа. Мы продемонстрировали это в листинге 8-10.

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];
}

Листинг 8-10. Определение enum для хранения значений разных типов в векторе

Rust должен знать, какие типы будут в векторе во время компиляции, чтобы знать сколько именно памяти в куче потребуется для хранения каждого элемента. Вторичным преимуществом является то, что мы можем чётко указать какие типы разрешены в таком векторе. Если бы Rust позволял вектору содержать любой тип, был бы шанс что один или несколько типов будут вызывать ошибки при выполнении операций над элементами вектора. Использование перечисления вместе с выражением match означает, что Rust сможет убедиться во время компиляции что обработаны все возможные случаи, как обсуждалось в Главе 6.

При написании программы, если вы не знаете исчерпывающий набор типов, которые программа может получить во время своего выполнения, которые потребуется сохранить в векторе, то техника использования перечисления не будет работать. Вместо этого вы можете использовать объект типажа, trait object, который мы рассмотрим в Главе 17.

Теперь, когда мы обсудили некоторые из наиболее распространённых способов использования векторов, просмотрите документацию по API для знакомства со всем множеством полезных методов, определённых для Vec<T> в стандартной библиотеке. Например, в дополнение к методу push, существует метод pop который одновременно удалит и вернёт последний элемент вектора. Давайте перейдём к следующему типу коллекций: строкам String!

Сохранение текста с UTF-8 кодировкой в строках

Мы говорили о строках в главе 4, но сейчас мы рассмотрим их более подробно. Новички в Rust обычно застревают на строках из-за комбинации трёх причин: склонность Rust компилятора к выявлению возможных ошибок, более сложная структура данных чем считают многие программисты и UTF-8. Эти факторы объединяются таким образом, что тема может показаться сложной, если вы пришли из других языков программирования.

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

Что же такое строка?

Сначала мы определим, что мы подразумеваем под термином строка (string). В Rust есть только один строковый тип в ядре языка - срез строки str, обычно используемый в заимствованном виде как &str. В Главе 4 мы говорили о срезах строк, string slices, которые являются ссылками на некоторые строковые данные в кодировке UTF-8. Например, строковые литералы хранятся в двоичном файле программы и поэтому являются срезами строк.

Тип String предоставляемый стандартной библиотекой Rust, не встроен в ядро языка и является расширяемым, изменяемым, владеющим, строковым типом в UTF-8 кодировке. Когда Rust разработчики говорят о "строках" то, они обычно имеют ввиду типы String и строковые срезы &str, а не просто один из них. Хотя этот раздел в основном посвящён String, оба типа интенсивно используются в стандартной библиотеке Rust, оба, и String, и строковые срезы, кодируются в UTF-8.

Стандартная библиотека Rust также включает ряд других строковых типов, таких как OsString, OsStr, CString и CStr. Библиотечные крейты могут предоставить даже большее количество возможностей для хранения строковых данных. Видите, как все имена этих типов заканчиваются на String или Str? Они относятся к собственным и заимствованным вариантам, так же как типы String и str которые вы видели ранее. Эти типы строк могут хранить текст в различных кодировках или, например, быть по-другому представлены в памяти. Мы не будем обсуждать эти другие типы строк в данной главе; посмотрите документацию API для получения дополнительной информации о том как их использовать и когда каждый тип уместен.

Создание новых строк

Многие из тех же операций, которые доступны Vec<T>, доступны также в String, начиная с new функции для создания строки, показанной в листинге 8-11.

fn main() {
    let mut s = String::new();
}

Листинг 8-11. Создание новой пустой String строки

Эта строка создаёт новую пустую строковую переменную с именем s, в которую мы можем затем загрузить данные. Часто у нас есть некоторые начальные данные, которые мы хотим назначить строке. Для этого мы используем метод to_string доступный для любого типа, который реализует типаж Display, как у строковых литералов. Листинг 8-12 показывает два примера.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // the method also works on a literal directly:
    let s = "initial contents".to_string();
}

Листинг 8-12. Использование метода to_string для создания экземпляра типа String из строкового литерала

Эти выражения создают строку с initial contents.

Мы также можем использовать функцию String::from для создания String из строкового литерала. Код листинга 8-13 является эквивалентным коду из листинга 8-12, который использует функцию to_string:

fn main() {
    let s = String::from("initial contents");
}

Листинг 8-13. Использование функции String::from для создания экземпляра типа String из строкового литерала

Поскольку строки используются для очень многих вещей, можно использовать множество API для строк, предоставляющих множество возможностей. Некоторые из них могут показаться избыточными, но все они занимаются своим делом! В данном случае String::from и to_string делают одно и тоже, поэтому выбор зависит от стиля который вам больше импонирует.

Запомните, что строки хранятся в кодировке UTF-8, поэтому можно использовать любые правильно кодированные данные в них, как показано в листинге 8-14:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שָׁלוֹם");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Листинг 8-14. Хранение приветствий в строках на разных языках

Все это допустимые String значения.

Обновление строковых данных

Строка String может увеличиваться в размере, а её содержимое может меняться, по аналогии как содержимое Vec<T> при вставке в него большего количества данных. Кроме того, можно использовать оператор + или макрос format! для объединения значений String.

Присоединение к строке с помощью push_str и push

Мы можем нарастить String используя метод push_str который добавит в исходное значение новый строковый срез, как показано в листинге 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

Листинг 8-15: Добавление среза строки к String используя метод push_str

После этих двух строк кода s будет содержать foobar. Метод push_str принимает строковый срез, потому что мы не всегда хотим владеть входным параметром. Например, код в листинге 8-16 показывает вариант, когда будет не желательно поведение, при котором мы не сможем использовать s2 после его добавления к содержимому значения переменной s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {}", s2);
}

Листинг 8-16. Использование фрагмента строки после его добавления в состав другого String

Если метод push_str стал бы владельцем переменнойs2, мы не смогли бы напечатать его значение в последней строке. Однако этот код работает так, как мы ожидали!

Метод push принимает один символ в качестве параметра и добавляет его к String. В листинге 8-17 показан код, добавляющий букву "l" к String, используя метод push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

Листинг 8-17. Добавление одного символа в String значение используя push

После этого переменная s будет содержать lol.

Объединение строк с помощью оператора + или макроса format!

Часто хочется объединять две существующие строки. Один из возможных способов - это использование оператора + из листинга 8-18:

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}

Листинг 8-18. Использование оператора + для объединения двух значений String в новое String значение

Строка s3 будет содержать Hello, world! как результат выполнения этого кода. Причина того, что s1 после добавления больше недействительна и причина, по которой мы использовали ссылку на s2 имеют отношение к сигнатуре вызываемого метода при использовании оператора +. Оператор + использует метод add, чья сигнатура выглядит примерно так:

fn add(self, s: &str) -> String {

Это не точная сигнатура из стандартной библиотеки: в стандартной библиотеке add определён с помощью обобщённых типов. Здесь мы видим сигнатуру add с конкретными типами, заменяющими обобщённый, что происходит когда вызывается данный метод со значениями String. Мы обсудим обобщённые типы в Главе 10. Эта сигнатура даёт нам ключ для понимания особенностей оператора +.

Во-первых, перед s2 мы видим &, что означает что мы складываем ссылку (reference) на вторую строку с самой первой строкой. Из-за параметра s в функции add, которая может только добавлять тип &str к типу String, мы не можем складывать два значения String вместе. Но подождите - тип &s2 является типом &String, а не типом &str, как указано в сигнатуре второго параметра функции add. Так почему код в листинге 8-18 компилируется?

Причина, по которой мы можем использовать &s2 в вызове add заключается в том, что компилятор может принудительно привести (coerce) аргумент типа &String к типу &str. Когда мы вызываем метод add в Rust используется принудительное приведение (deref coercion), которое превращает &s2 в &s2[..]. Мы подробно обсудим принудительное приведение в Главе 15. Так как add не забирает во владение параметр s, s2 по прежнему будет действительной строкой String после применения операции.

Во-вторых, как можно видеть в сигнатуре, add забирает во владение self, потому что self не имеет &. Это означает, что s1 в листинге 8-18 будет перемещён в вызов add и больше не будет действителен после этого вызова. Не смотря на то, что код let s3 = s1 + &s2; выглядит как будто он скопирует обе строки и создаёт новую, это выражение фактически забирает во владение переменную s1, присоединяет к ней копию содержимого s2, а затем возвращает владение результатом. Другими словами, это выглядит как будто код создаёт множество копий, но это не так; данная реализация более эффективна чем копирование.

Если нужно объединить несколько строк, поведение оператора + становится громоздким:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

На этом этапе переменная s будет содержать tic-tac-toe. С множеством символов + и " становится трудно понять, что происходит. Для более сложного комбинирования строк можно использовать макрос format!:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{}-{}-{}", s1, s2, s3);
}

Этот код также устанавливает переменную s в значение tic-tac-toe. Макрос format! работает тем же способом что макрос println!, но вместо вывода на экран возвращает тип String с содержимым. Версия кода с использованием format! значительно легче читается и не забирает во владение ни один из его параметров.

Индексирование в строках

Доступ к отдельным символам в строке, при помощи ссылки на них по индексу, является допустимой и распространённой операцией во многих других языках программирования. Тем не менее, если вы попытаетесь получить доступ к частям String, используя синтаксис индексации в Rust, то вы получите ошибку. Рассмотрим неверный код в листинге 8-19.

fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
}

Листинг 8-19. Попытка использовать синтаксис индекса со строкой

Этот код приведёт к следующей ошибке:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

error: aborting due to previous error

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

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

Ошибка и примечание говорит, что в Rust строки не поддерживают индексацию. Но почему так? Чтобы ответить на этот вопрос, нужно обсудить то, как Rust хранит строки в памяти.

Внутреннее представление

Тип String является оболочкой над типом Vec<u8>. Давайте посмотрим на несколько закодированных корректным образом в UTF-8 строк из примера листинга 8-14. Начнём с этой:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שָׁלוֹם");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

В этом случае len будет 4, что означает вектор, хранит строку "Hola" длиной 4 байта. Каждая из этих букв занимает 1 байт при кодировании в UTF-8. Но как насчёт следующей строки? (Обратите внимание, что эта строка начинается с заглавной кириллической "З", а не арабской цифры 3.)

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שָׁלוֹם");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Отвечая на вопрос, какова длина строки, вы можете ответить 12. Однако ответ Rust - 24, что равно числу байт, необходимых для кодирования «Здравствуйте» в UTF-8, так происходит, потому что каждое скалярное значение Unicode символа в этой строке занимает 2 байта памяти. Следовательно, индекс по байтам строки не всегда бы соответствовал действительному скалярному Unicode значению. Для демонстрации рассмотрим этот недопустимый код Rust:

let hello = "Здравствуйте";
let answer = &hello[0];

Каким должно быть значение переменной answer? Должно ли оно быть значением первой буквы З? При кодировке в UTF-8, первый байт значения З равен 208, а второй - 151, поэтому значение в answer на самом деле должно быть 208, но само по себе 208 не является действительным символом. Возвращение 208, скорее всего не то, что хотел бы получить пользователь: ведь он ожидает первую букву этой строки; тем не менее, это единственный байт данных, который в Rust доступен по индексу 0. Пользователи обычно не хотят получить значение байта, даже если строка содержит только латинские буквы: если &"hello"[0] было бы допустимым кодом, который вернул значение байта, то он вернул бы 104, а не h. Чтобы предотвратить возврат непредвиденного значения, вызывающего ошибки которые не могут быть сразу обнаружены, Rust просто не компилирует такой код и предотвращает недопонимание на ранних этапах процесса разработки.

Байты, скалярные значения и кластеры графем! Боже мой!

Ещё один момент, касающийся UTF-8, заключается в том, что на самом деле существует три способа рассмотрения строк с точки зрения Rust: как байты, как скалярные значения и как кластеры графем (самая близкая вещь к тому, что мы назвали бы буквами).

Если посмотреть на слово языка хинди «नमस्ते», написанное в транскрипции Devanagari, то оно хранится как вектор значений u8 который выглядит следующим образом:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

Эти 18 байт являются именно тем, как компьютеры в конечном итоге сохранят в памяти эту строку. Если мы посмотрим на 18 байт как на скалярные Unicode значения, которые являются Rust типом char, то байты будут выглядеть так:

['न', 'म', 'स', '्', 'त', 'े']

Здесь есть шесть значений типа char, но четвёртый и шестой являются не буквами: они диакритики, специальные обозначения которые не имеют смысла сами по себе. Наконец, если мы посмотрим на байты как на кластеры графем, то получим то, что человек назвал бы словом на хинди состоящем из четырёх букв:

["न", "म", "स्", "ते"]

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

Последняя причина, по которой Rust не позволяет нам индексировать String для получения символов является то, что программисты ожидают, что операции индексирования всегда имеют постоянное время (O(1)) выполнения. Но невозможно гарантировать такую производительность для String, потому что Rust понадобилось бы пройтись по содержимому от начала до индекса, чтобы определить, сколько было действительных символов.

Срезы строк

Индексирование строк часто является плохой идеей, потому что не ясно каким должен быть возвращаемый тип такой операции: байтовым значением, символом, кластером графем или срезом строки. Поэтому Rust просит вас быть более конкретным, если действительно требуется использовать индексы для создания срезов строк. Чтобы быть более конкретным в случае строкового среза, нужно явно указывать, что вы хотите строковый срез (а не индексирование с помощью числового индекса []): вы можете использовать оператор диапазона [] при создании среза строки в котором содержится указание на то, срез каких байтов надо делать:


#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

Здесь переменная s будет типа &str который содержит первые 4 байта строки. Ранее мы упоминали, что каждый из этих символов был по 2 байта, что означает, что s будет содержать Зд.

Что бы произошло, если бы мы использовали &hello[0..1]? Ответ: Rust бы запаниковал во время выполнения точно так же, как если бы обращались к недействительному индексу в векторе:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

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

Методы для перебора строк

Сейчас поговорим о предпочтительных способах доступа к элементам строки.

Если необходимо производить операции над отдельными элементами юникод-строки (не буквами, а char символами), то наилучший способ - использовать метод chars. Вызов chars у "नमस्ते" разделяет и возвращает 6 значений типа char. Далее, вы можете перебирать результат для доступа к каждому элементу:


#![allow(unused)]
fn main() {
for c in "नमस्ते".chars() {
    println!("{}", c);
}
}

Код напечатает следующее:

न
म
स
्
त
े

Метод bytes возвращает каждый байт, который может быть подходящим в другой предметной области:


#![allow(unused)]
fn main() {
for b in "नमस्ते".bytes() {
    println!("{}", b);
}
}

Этот код напечатает 18 байтов, составляющих эту строку String:

224
164
// --часть байтов вырезана--
165
135

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

Извлечение кластеров графем из строк сложно, поэтому данный функционал не предоставляется в стандартной библиотеке. На crates.io есть доступные библиотеки, если Вам нужен данный функционал.

Строки не так просты

Подводя итог, становится ясно, что строки сложны. Различные языки программирования реализуют различные варианты того, как представить эту сложность для программиста. В Rust решили сделать правильную обработку данных String поведением по умолчанию для всех программ Rust, что означает, что программисты должны заранее продумать обработку UTF-8 данных. Этот компромисс раскрывает большую сложность строк, чем в других языках программирования, но это предотвращает от необходимости обрабатывать ошибки, связанные с не-ASCII символами которые могут появиться в ходе разработки позже.

Давайте переключимся на что-то немного менее сложное: HashMap!

Хранение ключей со связанными значениями в HashMap

Последняя коллекция, которую мы рассмотрим в нашей книге будет hash map (хэш-карта). HashMap<K, V> сохраняет ключи типа K и значения типа V. Данная структура организует и хранит данные с помощью функции хэширования. Во множестве языков программирования реализована данная структура, но часто с разными наименованиями: такими как hash, map, object, hash table, dictionary или ассоциативный массив.

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

В этом разделе мы рассмотрим базовый API хеш-карт. Остальной набор полезных функций скрывается в объявлении типа HashMap<K, V>. Как и прежде, советуем обратиться к документации по стандартной библиотеке для получения дополнительной информации.

Создание новой хеш-карты

Создать пустую хеш-карту можно с помощью new, а добавить в неё элементы - с помощью insert. В листинге 8-20 мы отслеживаем счёт двух команд, синей (Blue) и жёлтой (Yellow). Синяя команда стартует с 10 очками, а жёлтая команда с 50.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}

Листинг 8-20. Создание новой хеш-карты и вставка в неё некоторых ключей и начальных значений

Обратите внимание, что нужно сначала указать строку use HashMap для её подключения из коллекций стандартной библиотеки. Из трёх коллекций данная является наименее используемой, поэтому она не подключается в область видимости функцией автоматического импорта (prelude). Хеш-карты также имеют меньшую поддержку со стороны стандартной библиотеки; например, нет встроенного макроса для их конструирования.

Подобно векторам, хеш-карты хранят свои данные в куче. Здесь тип HashMap имеет в качестве типа ключей String, а в качестве типа значений тип i32. Как и векторы, HashMap однородны: все ключи должны иметь одинаковый тип и все значения должны иметь тоже одинаковый тип.

Ещё один способ построения хеш-карты - использование метода collect на векторе кортежей, где каждый кортеж состоит из двух значений (первое может быть представлено как ключ, а второе как значение хеш-карты). Метод collect собирает данные в несколько типов коллекций, включая HashMap . Например, если бы у нас были названия команд и начальные результаты в двух отдельных векторах, то мы могли бы использовать метод zip для создания вектора кортежей, где имя "Blue" спарено с числом 10, и так далее. Тогда мы могли бы использовать метод collect, чтобы превратить этот вектор кортежей в HashMap, как показано в листинге 8-21.

fn main() {
    use std::collections::HashMap;

    let teams = vec![String::from("Blue"), String::from("Yellow")];
    let initial_scores = vec![10, 50];

    let mut scores: HashMap<_, _> =
        teams.into_iter().zip(initial_scores.into_iter()).collect();
}

Листинг 8-21. Создание HashMap из списка команд и списка результатов

Здесь нужна аннотация типа HashMap<_, _> , поскольку с помощью метода collect данные можно собрать во множество различных структур данных и Rust не знает, в какую именно вы хотите собрать, пока вы не укажете это явно. Для параметров типа ключа и значения, мы используем подчёркивания и Rust может вывести типы, которые хеш содержит на основе типов данных из двух векторов. В листинге 8-21, тип ключа будет String, а тип значения будет i32, так же как в листинге 8-20.

Хеш-карты и владение

Для типов, которые реализуют типаж Copy, например i32, значения копируются в HashMap. Для значений со владением, таких как String, значения будут перемещены в хеш-карту и она станет владельцем этих значений, как показано в листинге 8-22.

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // field_name and field_value are invalid at this point, try using them and
    // see what compiler error you get!
}

Листинг 8-22. Показывает, что ключи и значения находятся во владении HashMap, как только они были вставлены

Мы не можем использовать переменные field_name и field_value после того, как их значения были перемещены в HashMap вызовом метода insert.

Если мы вставим в HashMap ссылки на значения, то они не будут перемещены в HashMap. Значения, на которые указывают ссылки, должны быть действительными хотя бы до тех пор, пока хеш-карта действительна. Мы поговорим об этих вопросах подробнее в разделе "Проверка ссылок с помощью времени жизни" главы 10.

Доступ к данным в HashMap

Мы можем получить значение из HashMap по ключу, с помощью метода get, как показано в листинге 8-23:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name);
}

Листинг 8-23. Доступ к очкам команды "Blue" сохранённой в HashMap

Здесь score будет иметь количество очков, связанное с командой "Blue", результат будет Some(&10). Результат обёрнут в вариант перечисления Some потому что get возвращает Option<&V>; если для этого ключа нет значения в HashMap, get вернёт None. Из-за такого подхода программе следует обрабатывать Option, например одним из способов, которые мы рассмотрели в Главе 6.

Мы можем перебирать каждую пару ключ/значение в HashMap таким же образом, как мы делали с векторами, используя цикл for:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{}: {}", key, value);
    }
}

Этот код будет печатать каждую пару в произвольном порядке:

Yellow: 50
Blue: 10

Обновление данных

Хотя количество ключей и значений может увеличиваться в HashMap, каждый ключ может иметь только одно значение, связанное с ним в один момент времени. Когда вы хотите изменить данные в хеш-карте, необходимо решить, как обрабатывать случай, когда ключ уже имеет назначенное значение. Можно заменить старое значение новым, полностью игнорируя старое. Можно сохранить старое значение и игнорировать новое и добавлять новое значение, если только ключ ещё не имел значения. Или можно было бы объединить старое значение и новое значение. Давайте посмотрим, как сделать каждый из вариантов!

Перезапись старых значений

Если мы вставим ключ и значение в HashMap, а затем вставим тот же ключ с новым значением, то старое значение связанное с этим ключом, будет заменено на новое. Даже несмотря на то, что код в листинге 8-24 вызывает insert дважды, хеш-карта будет содержать только одну пару ключ/значение, потому что мы вставляем значения для одного и того же ключа - ключа команды "Blue".

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{:?}", scores);
}

Листинг 8-24. Замена значения, хранимого в конкретном ключе

Код напечатает {"Blue": 25}. Начальное значение 10 было перезаписано.

Вставка значения только в том случае, когда ключ не имеет значения

Обычно проверяют, имеется ли значение для конкретного ключа и если нет, то значение для него вставляется. Хеш-карты имеют для этого специальный API называемый entry, который принимает ключ для проверки в качестве входного параметра. Возвращаемое значение метода entry - это перечисление Entry, с двумя вариантами: первый представляет значение, которое может существовать, а второй говорит о том, что значение отсутствует. Допустим, мы хотим проверить, имеется ли ключ и связанное с ним значение для команды "Yellow". Если хеш-карта не имеет значения для такого ключа, то мы хотим вставить значение 50. То же самое мы хотим проделать и для команды "Blue". Используем API entry в коде листинга 8-25.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{:?}", scores);
}

Листинг 8-25. Использование метода entry для вставки значения только в том случае, когда ключ не имеет значения

Метод or_insert определён в Entry так, чтобы возвращать изменяемую ссылку на соответствующее значение ключа внутри варианта перечисления Entry, когда этот ключ существует, а если его нет, то вставлять параметр в качестве нового значения этого ключа и возвращать изменяемую ссылку на новое значение. Эта техника намного чище, чем самостоятельное написание логики и, кроме того, она более безопасна и согласуется с правилами заимствования.

При выполнении кода листинга 8-25 будет напечатано {"Yellow": 50, "Blue": 10} . Первый вызов метода entry вставит ключ для команды "Yellow" со значением 50, потому что для жёлтой команды ещё не имеется значения в HashMap. Второй вызов entry не изменит хеш-карту, потому что для ключа команды "Blue" уже имеется значение 10.

Создание нового значения на основе старого значения

Другим распространённым вариантом использования хеш-карт является поиск значения по ключу, а затем обновление этого значения на основе старого значения. Например, в листинге 8-26 показан код, который подсчитывает, сколько раз определённое слово появляется в каком-либо тексте. Мы используем HashMap со словами в качестве ключей и увеличиваем соответствующее слову значение, чтобы отслеживать, сколько раз в тексте мы увидели слово. Если мы впервые увидели слово, то сначала вставляем значение 0.

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{:?}", map);
}

Листинг 8-26. Подсчёт вхождений слов с использованием хеш-карты, которая хранит слова и количество их упоминаний в тексте

Будет напечатано {"world": 2, "hello": 1, "wonderful": 1}. Метод or_insert возвращает изменяемую ссылку (&mut V) на значение ключа. Мы сохраняем изменяемую ссылку в переменной count. Для того, чтобы присвоить переменной значение, необходимо произвести разыменование с помощью звёздочки (*). Изменяемая ссылка удаляется сразу же после выхода из области видимости цикла for. Все эти изменения безопасны и согласуются с правилами заимствования.

Функция хэширования

По умолчанию HashMap использует "криптографически сильную" функцию хэширования SipHash, которая может противостоять атакам класса отказ в обслуживании, Denial of Service (DoS). Это не самый быстрый из возможных алгоритмов хеширования, в данном случае производительность идёт на компромисс с обеспечением лучшей безопасности. Если после профилирования вашего кода окажется, что хэш функция используемая по умолчанию очень медленная, вы можете заменить её используя другой hasher. Hasher - это тип, реализующий трейт BuildHasher. Подробнее о типажах мы поговорим в Главе 10. Вам совсем не обязательно реализовывать свою собственную функцию хэширования, crates.io имеет достаточное количество библиотек, предоставляющих разные реализации hasher с множеством общих алгоритмов хэширования.

Итоги

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

  • Есть список целых чисел. Создайте функцию, используйте вектор и верните из списка: среднее значение; медиану (значение элемента из середины списка после его сортировки); моду списка (mode of list, то значение которое встречается в списке наибольшее количество раз; HashMap будет полезна в данном случае)
  • Преобразуйте строку в кодировку "поросячьей латыни" (Pig Latin), где первая согласная каждого слова перемещается в конец и к ней добавляется окончание "ay". Например "first" в поросячьей латыни станет "irst-fay". Если слово начинается на гласную, то в конец слова добавляется суффикс "hay" ("apple" становится "apple-hay"). Помните о деталях работы с кодировкой UTF-8!
  • Используя хеш-карту и векторы, создайте текстовый интерфейс позволяющий пользователю добавлять имена сотрудников к названию отдела компании. Например, "Add Sally to Engineering" или "Add Amir to Sales". Затем позвольте пользователю получить список всех людей из отдела или всех людей в компании отсортированным в алфавитном порядке по отделам.

Документация API стандартной библиотеки описывает методы у векторов, строк и HashMap. Рекомендуем воспользоваться ей при решении упражнений.

Потихоньку мы переходим к более сложным программам, в которых операции могут потерпеть неудачу. Наступило идеальное время для обсуждения обработки ошибок.

Обработка ошибок

Приверженность Rust к надёжности распространяется и на обработку ошибок. Возникновение ошибок в ходе выполнения программ - это суровая реальность в жизни программного обеспечения, поэтому Rust имеет ряд функций для обработки ситуаций в которых что-то идёт не так. Во многих случаях Rust требует, чтобы вы признали возможность ошибки и предприняли некоторые действия, прежде чем ваш код будет скомпилирован. Это требование делает вашу программу более надёжной, гарантируя, что вы обнаружите ошибки и обработаете их надлежащим образом, прежде чем развернёте свой код в производственной среде!

В Rust ошибки делятся на две большие группы обрабатываемые (recoverable, которые возможно обработать и затем восстановить работу программы, для них разумно сообщить о проблеме пользователю и повторить операцию) и необрабатываемые (unrecoverable, после которых невозможно восстановить работу программы). Обрабатываемые ошибки случаются, когда, например, файл не найден. К необрабатываемым ошибкам, т.н. дефектам кода, относятся обращения к неверному индексу массива.

Большинство языков не различают эти два вида ошибок и обрабатывают оба вида одинаково, используя такие механизмы, как исключения. В Rust нет исключений. Вместо этого он имеет тип Result<T, E> для обрабатываемых (исправимых) ошибок и макрос panic!, который останавливает выполнение, когда программа встречает необрабатываемую (неисправимую) ошибку. Сначала эта глава расскажет про вызов panic!, а потом расскажет о возврате значений Result<T, E>. Кроме того, мы рассмотрим, что нужно учитывать при принятии решения о том, следует ли попытаться исправить ошибку или остановить выполнение.

Неустранимые ошибки с макросом panic!

Иногда в коде происходят плохие вещи, и вы ничего не можете с этим поделать. В этих случаях у Rust есть макрос panic!. Когда выполняется макрос panic!, ваша программа напечатает сообщение об ошибке, раскрутит и очистит стек вызовов, а затем завершится. Это чаще всего происходит, когда был обнаружен какой-то дефект и программисту не ясно, как его обработать.

Раскручивать стек или прерывать выполнение программы в ответ на панику?

По умолчанию, когда происходит паника, программа начинает процесс раскрутки стека, означающий в Rust проход обратно по стеку вызовов и очистку данных для каждой обнаруженной функции. Но данный проход по стеку в обратном порядке и очистка генерируют много работы. Альтернативой является немедленное прерывание выполнения, которое завершает программу без очистки. Память, которую использовала программа, должна быть очищена операционной системой. Если в вашем проекте нужно сделать маленьким исполняемый файл, насколько это возможно, вы можете переключиться с варианта раскрутки стека на вариант прерывания, добавьте panic = 'abort' в раздел [profile] вашего Cargo.toml файла. Например, если вы хотите прерывать выполнение программы по панике в релизной версии программы добавьте следующее:

[profile.release]
panic = 'abort'

Давайте попробуем вызвать panic! в простой программе:

Файл: src/main.rs

fn main() {
    panic!("crash and burn");
}

При запуске программы, вы увидите что-то вроде этого:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Выполнение макроса panic! вызывает сообщение об ошибке, содержащееся в двух последних строках. Первая строка показывает сообщение паники и место в исходном коде, где возникла паника: src/main.rs: 2:5 указывает, что это вторая строка, пятый символ внутри нашего файла src/main.rs

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

Использование обратной трассировки panic!

Давайте посмотрим на другой пример, где, вызов panic! происходит в сторонней библиотеке из-за ошибки в нашем коде (а не как в примере ранее, из-за вызова макроса нашим кодом напрямую). В листинге 9-1 приведён код, который пытается получить доступ к элементу по индексу в векторе.

Файл: src/main.rs

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

Листинг 9-1. Попытка доступа к элементу за пределами вектора, которая вызовет panic!

Здесь мы пытаемся получить доступ к 100-му элементу вектора (который находится по индексу 99, потому что индексирование начинается с нуля), но вектор имеет только 3 элемента. В этой ситуации, Rust будет вызывать панику. Использование [] должно возвращать элемент, но вы передаёте неверный индекс: не существует элемента, который Rust мог бы вернуть.

В языке C, например, попытка прочесть за пределами конца структуры данных (в нашем случае векторе) приведёт к неопределённому поведению, undefined behavior, UB. Вы всё равно получите значение, которое находится в том месте памяти компьютера, которое соответствовало бы этому элементу в векторе, несмотря на то, что память по тому адресу совсем не принадлежит вектору (всё просто: C рассчитал бы место хранения элемента с индексом 99 и считал бы то, что там хранится, упс). Это называется чтением за пределом буфера, buffer overread, и может привести к уязвимостям безопасности. Если злоумышленник может манипулировать индексом таким образом, то у него появляется возможность читать данные, которые он не должен иметь возможности читать.

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

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Данная ошибка указывает на файл, который не является нашим, файл libcore/slice/mod.rs. Это файл с исходными кодами реализации slice, как вы возможно заметили, он расположен в исходных кодах Rust. Когда мы используем [] для вектора v запускается код находящийся в libcore/slice/mod.rs и это является тем местом, где на самом деле происходит вызов panic!.

Следующая строка говорит, что мы можем установить переменную среды RUST_BACKTRACE, чтобы получить обратную трассировку того, что именно стало причиной ошибки. Обратная трассировка создаёт список всех функций, которые были вызваны до какой-то определённой точки выполнения программы. Обратная трассировка в Rust работает так же, как и в других языках. Поэтому предлагаем вам читать данные обратной трассировки как и везде - сверху вниз, пока не увидите информацию о файлах написанных вами. Это место, где возникла проблема. Другие строки трассировки, которые находятся над строками с упоминанием наших файлов, - это код, который вызывается нашим кодом; строки ниже являются кодом, который вызывает наш код. Эти строки могут включать основной код Rust, код стандартной библиотеки или используемые крейты. Давайте попробуем получить обратную трассировку с помощью установки переменной среды RUST_BACKTRACE в любое значение, кроме 0. Листинг 9-2 показывает вывод, подобный тому, что вы увидите.

$  RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/std/src/panicking.rs:483
   1: core::panicking::panic_fmt
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:85
   2: core::panicking::panic_bounds_check
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:62
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:255
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:15
   5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/alloc/src/vec.rs:1982
   6: panic::main
             at ./src/main.rs:4
   7: core::ops::function::FnOnce::call_once
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Листинг 9-2. Обратная трассировка, сгенерированная вызовом panic!, когда установлена переменная окружения RUST_BACKTRACE

Тут много вывода! Вывод, который вы увидите, может отличаться от представленного, в зависимости от вашей операционной системы и версии Rust. Для того, чтобы получить обратную трассировку с этой информацией, должны быть включены символы отладки, debug symbols. Символы отладки включены по умолчанию при использовании cargo build или cargo run без флага --release, как у нас в примере.

В выводе обратной трассировки, в листинге 9-2, строка 12 указывает на строку в нашем проекте, который вызывал проблему: строка 4 из файла src/main.rs. Если мы не хотим возникновения паники в программе, место на которое указывает первая строка трассировки, упоминающая название нашего файла, - это то место, где мы должны начать расследование. В листинге 9-1, где мы для демонстрации обратной трассировки сознательно написали код, который паникует, способ исправления паники состоит в том, чтобы не запрашивать элемент с индексом 99 из вектора, который содержит только 3 элемента. Когда ваш код запаникует в будущем, вам нужно будет выяснить, какое выполняющееся кодом действие, с какими значениями вызывает панику и что этот код должен делать вместо этого.

Мы вернёмся к обсуждению макроса panic!, и того когда нам следует и не следует использовать panic! для обработки ошибок в разделе "panic! или НЕ panic!" этой главы. Далее мы рассмотрим, как восстановить выполнение программы после исправляемых ошибок, использующих тип Result.

Исправимые ошибки с Result

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

Вспомните раздел "Обработка потенциального сбоя с помощью типа Result" главы 2: мы использовали там перечисление Result, имеющее два варианта, Ok и Err для обработки сбоев. Само перечисление определено следующим образом:


#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Типы T и E являются параметрами обобщённого типа: мы обсудим обобщённые типы более подробно в Главе 10. Все что вам нужно знать прямо сейчас - это то, что T представляет тип значения, которое будет возвращено в случае успеха внутри варианта Ok, а E представляет тип ошибки, которая будет возвращена при сбое внутри варианта Err. Так как тип Result имеет эти типовые параметры (generic type parameters), мы можем использовать тип Result и его методы, которые определены в стандартной библиотеке, в ситуациях, когда тип успешного значения и значения ошибки, которые мы хотим вернуть, отличаются.

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

Файл: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");
}

Листинг 9-3: Открытие файла

Откуда мы знаем, что File::open возвращает Result? Мы могли бы посмотреть документацию по API стандартной библиотеки или мы могли бы спросить компилятор! Если мы припишем переменной f тип, отличный от возвращаемого типа функции, а затем попытаемся скомпилировать код, компилятор скажет нам, что типы не совпадают. Сообщение об ошибке подскажет нам, каким должен быть тип f. Давайте попробуем! Мы знаем, что возвращаемый тип File::open не является типом u32, поэтому давайте изменим выражение let f на следующее:

use std::fs::File;

fn main() {
    let f: u32 = File::open("hello.txt");
}

Попытка компиляции выводит сообщение:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0308]: mismatched types
 --> src/main.rs:4:18
  |
4 |     let f: u32 = File::open("hello.txt");
  |            ---   ^^^^^^^^^^^^^^^^^^^^^^^ expected `u32`, found enum `Result`
  |            |
  |            expected due to this
  |
  = note: expected type `u32`
             found enum `Result<File, std::io::Error>`

error: aborting due to previous error

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

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

Ошибка говорит нам о том, что возвращаемым типом функции File::open является Result<T, E>. Типовой параметр T здесь равен типу успешного выполнения, std::fs::File, то есть дескриптору файла. Тип E, используемый в значении ошибки, равен std::io::Error.

Этот возвращаемый тип означает, что вызов File::open может завершиться успешно и вернуть дескриптор файла, с помощью которого можно читать из файла или писать в него. Вызов функции также может завершиться ошибкой: например, файла может не существовать или у нас может не быть прав на доступ к нему. Функция File::open должна иметь способ сообщить нам, был ли её вызов успешен или потерпел неудачу и одновременно возвратить либо дескриптор файла либо информацию об ошибке. Эта информация - именно то, что возвращает перечисление Result.

Когда вызов File::open успешен, значение в переменной f будет экземпляром Ok, внутри которого содержится дескриптор файла. Если вызов не успешный, значением переменной f будет экземпляр Err, который содержит больше информации о том, какая ошибка произошла.

Необходимо дописать в код листинга 9-3 выполнение разных действий в зависимости от значения, которое вернёт вызов File::open. Листинг 9-4 показывает один из способов обработки Result - пользуясь базовым инструментом языка, таким как выражение match, рассмотренным в Главе 6.

Файл: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}

Листинг 9-4: Использование выражения match для обработки возвращаемых вариантов типа Result

Обратите внимание, что также как перечисление Option, перечисление Result и его варианты, входят в область видимости благодаря авто-импорту (prelude), поэтому не нужно указывать Result:: перед использованием вариантов Ok и Err в ветках выражения match.

Здесь мы говорим Rust, что когда результат - это Ok, то надо вернуть внутреннее значение file из варианта Ok, и затем мы присваиваем это значение дескриптора файла переменной f. После match мы можем использовать дескриптор файла для чтения или записи.

Другая ветвь match обрабатывает случай, где мы получаем значение Err после вызова File::open. В этом примере мы решили вызвать макрос panic!. Если в нашей текущей директории нет файла с именем hello.txt и мы выполним этот код, то мы увидим следующее сообщение от макроса panic!:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`
thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Как обычно, данное сообщение точно говорит, что пошло не так.

Обработка различных ошибок с помощью match

Код в листинге 9-4 будет вызывать panic! независимо от того, почему вызов File::open не удался. Мы бы хотели предпринять различные действия для разных причин сбоя. Если открытие File::open не удалось из-за отсутствия файла, мы хотим создать файл и вернуть его дескриптор. Если вызов File::open не удался по любой другой причине (например, потому что у нас не было прав на открытие файла), то мы хотим вызвать panic! как у нас сделано в листинге 9-4. Посмотрите листинг 9-5, в котором мы добавили дополнительное внутреннее выражение match.

Файл: src/main.rs

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error)
            }
        },
    };
}

Листинг 9-5: Обработка различных ошибок разными способами

Типом значения возвращаемого функцией File::open внутри Err варианта является io::Error, структура из стандартной библиотеки. Данная структура имеет метод kind, который можно вызвать для получения значения io::ErrorKind. Перечисление io::ErrorKind из стандартной библиотеки имеет варианты, представляющие различные типы ошибок, которые могут появиться при выполнении операций в io (крейте который занимается проблемами ввода/вывода данных). Вариант, который мы хотим использовать, это ErrorKind::NotFound. Он даёт информацию, о том, что файл который мы пытаемся открыть ещё не существует. Итак, во второй строке мы вызываем сопоставление шаблона с переменной f и попадаем в ветку с обработкой ошибки, но также у нас есть внутренняя проверка для сопоставления error.kind() ошибки.

Условие, которое мы хотим проверить во внутреннем match - это то, что значение, которое вернул вызов error.kind(), является вариантом NotFound перечисления ErrorKind. Если это так, мы пытаемся создать файл с помощью функции File::create. Однако, поскольку вызов File::create тоже может завершиться ошибкой, нам нужна обработка ещё одной ошибки теперь уже во внутреннем выражении match - третий вложенный match. Заметьте: если файл не может быть создан, выводится другое сообщение об ошибке, более специализированное. Вторая же ветка внешнего match (который обрабатывает вызов error.kind()), остаётся той же самой. В итоге программа паникует при любой ошибке, кроме ошибки отсутствия файла.

Достаточно про match! Код с match является очень удобным, но также достаточно примитивным. В Главе 13 вы узнаете про замыкания (closures); тип Result<T, E> имеет много методов, реализованных с помощью выражения match и принимающих замыкание в качестве входного значения. Использование данных методов сделает ваш код более лаконичным. Более опытные разработчики могли бы написать код как в листинге 9-5, вместо нашего:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {:?}", error);
            })
        } else {
            panic!("Problem opening the file: {:?}", error);
        }
    });
}

Несмотря на то, что данный код имеет такое же поведение как в листинге 9-5, он не содержит ни одного выражения match и проще для чтения. Рекомендуем вам вернутся к примеру этого раздела после того как вы прочитаете Главу 13 и изучите метод unwrap_or_else по документации стандартной библиотеки. Многие из методов о которых вы узнаете в документации и Главе 13 могут очистить код от больших, вложенных выражений match при обработке ошибок.

Сокращённые способы обработки ошибок unwrap и expect

Использование match работает неплохо, однако может выглядеть несколько многословно и не всегда хорошо передаёт намерения. У типа Result<T, E> есть много методов для различных задач. Один из них, unwrap, является сокращённым методом, который реализован прямо как выражение match из листинга 9-4. Если значение Result это Ok, unwrap вернёт значение внутри Ok. Если же Result это Err, unwrap вызовет макрос panic!. Вот пример unwrap в действии:

Файл: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").unwrap();
}

Если мы запустим этот код при отсутствии файла hello.txt , то увидим сообщение об ошибке из вызова panic! метода unwrap :

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4

Другой метод, похожий на unwrap, это expect, позволяющий выбрать сообщение об ошибке для макроса panic!. Использование expect вместо unwrap с предоставлением хорошего сообщения об ошибке выражает ваше намерение и делает более простым отслеживание источника паники. Синтаксис метода expect выглядит так:

Файл: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

Мы используем expect таким же образом, как и unwrap: чтобы вернуть дескриптор файла или вызвать макрос panic!. Сообщением об ошибке, которое expect передаст в panic!, будет параметр функции expect, а не значение по умолчанию, используемое unwrap. Вот как оно выглядит:

thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

Так как сообщение об ошибке начинается с нашего пользовательского текста: Failed to open hello.txt, то потом будет проще найти из какого места в коде данное сообщение приходит. Если использовать unwrap во множестве мест, то придётся потратить время для выяснения какой именно вызов unwrap вызывает "панику", так как все вызовы unwrap генерируют одинаковое сообщение.

Проброс ошибок

Когда вы пишете функцию, реализация которой вызывает что-то, что может завершиться ошибкой, вместо обработки ошибки в этой функции, вы можете вернуть ошибку в вызывающий код, чтобы он мог решить, что с ней делать. Такой приём известен как распространение ошибки, propagating the error. Благодаря нему мы даём больше контроля вызывающему коду, где может быть больше информации или логики, которая диктует, как ошибка должна обрабатываться, чем было бы в месте появления этой ошибки.

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

Файл: src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let f = File::open("hello.txt");

    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();

    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(e) => Err(e),
    }
}
}

Листинг 9-6: Функция, которая возвращает ошибки в вызывающий код, используя оператор match

Данную функцию можно записать гораздо короче. Чтобы больше проникнуться обработкой ошибок, мы сначала сделаем многое самостоятельно, а в конце покажем более короткий способ. Давайте сначала рассмотрим тип возвращаемого значения: Result<String, io::Error>. Здесь есть возвращаемое значение функции типа Result<T, E> где шаблонный параметр T был заполнен конкретным типом String и шаблонный параметр E был заполнен конкретным типом io::Error. Если эта функция выполнится успешно, будет возвращено Ok, содержащее значение типа String - имя пользователя прочитанное функцией из файла. Если же при чтении файла будут какие-либо проблемы, то вызываемый код получит значение Err с экземпляром io::Error, в котором содержится больше информации об ошибке. Мы выбрали io::Error в качестве возвращаемого значения функции, потому что обе операции, которые мы вызываем внутри этой функции, возвращают этот тип ошибки: функция File::open и метод read_to_string.

Тело функции начинается с вызова File::open. Затем мы обрабатываем значение Result возвращённое с помощью match аналогично коду match листинга 9-4, но вместо вызова panic! для случая Err делаем ранний возврат из данной функции и передаём ошибку из File::open обратно в вызывающий код, как ошибку уже текущей функции. Если File::open выполнится успешно, мы сохраняем дескриптор файла в переменной f и выполнение продолжается далее.

Затем мы создаём новую String в переменной s и вызываем метод read_to_string у дескриптора файла в переменной f, чтобы считать содержимое файла в переменную s. Метод read_to_string также возвращает Result, потому что он может потерпеть неудачу, даже если File::open пройдёт успешно. Таким образом, нам нужно ещё одно выражение match, чтобы справиться с этим Result: если read_to_string выполнится успешно, то наша функция завершится успешно и мы вернём имя пользователя из файла, которое сейчас находится в s, завёрнутым в Ok. Если вызов read_to_string не успешен, мы возвращаем значение ошибки так же, как мы вернули значение ошибки в match, обработавшем возвращаемое значение File::open. Тем не менее, нам не нужно явно писать return, потому что это последнее выражение в функции.

Код, вызывающий данный код, будет обрабатывать либо значение Ok, содержащее имя пользователя, либо значение Err, содержащее io::Error. Мы не знаем, что будет делать вызывающий код с этими значениями. Если вызывающий код получает значение Err, он может вызвать panic! и завершить программу, использовать имя пользователя по умолчанию, или например, попытается получить имя пользователя из какого-то другого места. У нас недостаточно информации о том, чего пытается достичь вызывающий код, поэтому мы пробрасываем всю информацию об успехе или ошибке наверх для её правильной обработки.

Такая схема распространения ошибок настолько распространена в Rust, что Rust предоставляет оператор вопросительный знак ? для простоты.

Сокращение для проброса ошибок: оператор ?

Код программы 9-6 показывает реализацию функции read_username_from_file, функционал которой аналогичен коду программы 9-5, но реализация использует оператор ? :

Файл: src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt")?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}
}

Листинг 9-7: Функция, которая возвращает ошибки в вызывающий код, используя оператор ?

Оператор ?, помещаемый после значения типа Result, работает почти таким же образом, как выражение match, которое мы определили для обработки значений типа Result в листинге 9-6. Если значение Result равно Ok, значение внутри Ok будет возвращено из этого выражения и программа продолжит выполнение. Если значение является Err, то Err будет возвращено из всей функции, как если бы мы использовали ключевое слово return, таким образом ошибка передаётся в вызывающий код.

Имеется разница между тем, что делает выражение match листинга 9-6 и оператор ?. Ошибочные значения при выполнении методов с оператором ? возвращаются через функцию from, определённую в типаже From стандартной библиотеки. Данный типаж используется для конвертирования ошибок одного типа в ошибки другого типа. Когда оператор ? вызывает функцию from, то полученный тип ошибки конвертируется в тип ошибки, который определён для возврата в текущей функции. Это удобно, когда функция возвращает один тип ошибки для представления всех возможных вариантов, из-за которых она может не завершиться успешно, даже если части кода функции могут не выполниться по разным причинам. Если каждый тип ошибки реализует функцию from определяя, как конвертировать себя в возвращаемый тип ошибки, то оператор ? позаботится об этой конвертации автоматически.

В коде примера 9-7 оператор ? в конце вызова функции File::open возвращает значения содержимого Ok в переменную f. Если же в при работе этой функции произошла ошибка, оператор ? произведёт ранний возврат из функции со значением Err. То же касается ? на конце вызова read_to_string.

Использование оператора ? позволят уменьшить количество строк кода и сделать реализацию проще. Написанный в предыдущем примере код можно
сделать ещё короче с помощью сокращения промежуточных переменных и конвейерного вызова нескольких методов подряд, как показано в листинге 9-8:

Файл: src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();

    File::open("hello.txt")?.read_to_string(&mut s)?;

    Ok(s)
}
}

Листинг 9-8. Цепочка вызовов методов после оператора ?

Мы перенесли в начало функции создание новой переменной s типа String; эта часть не изменилась. Вместо создания переменной f мы добавили вызов read_to_string непосредственно к результату File::open("hello.txt")?, У нас ещё есть ? в конце вызова read_to_string, и мы по-прежнему возвращаем значение Ok, содержащее имя пользователя в s когда оба метода: File::open и read_to_string успешны, а не возвращают ошибки. Функциональность снова такая же, как в листинге 9-6 и листинге 9-7; это просто другой, более эргономичный способ решения той же задачи.

Продолжая рассматривать разные способы записи данной функции, листинг 9-9 показывает способ сделать её ещё короче.

Файл: src/main.rs


#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

Листинг 9-9: Использование fs::read_to_string вместо открытия и чтения файла

Чтение файла в строку довольно распространённая операция, так что Rust предоставляет удобную функцию fs::read_to_string, которая открывает файл, создаёт новую String, читает содержимое файла, размещает его в String и возвращает её. Конечно, использование функции fs::read_to_string не даёт возможности объяснить обработку всех ошибок, поэтому мы сначала изучили длинный способ.

Оператор ? можно использовать для функций возвращающих Result

Оператор ? может использоваться в функциях, которые имеют возвращаемый тип Result, потому что он работает так же, как выражение match, определённое в листинге 9-6. Той частью match, которая требует возвращаемый тип Result, является код return Err(e), таким образом возвращаемый тип функции может быть Result, чтобы быть совместимым с этим return.

Посмотрим что происходит, если использовать оператор ? в теле функции main, которая, как вы помните, имеет возвращаемый тип ():

use std::fs::File;

fn main() {
    let f = File::open("hello.txt")?;
}

При компиляции этого кода, мы получим следующее сообщение об ошибке:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `Try`)
 --> src/main.rs:4:13
  |
3 | / fn main() {
4 | |     let f = File::open("hello.txt")?;
  | |             ^^^^^^^^^^^^^^^^^^^^^^^^ cannot use the `?` operator in a function that returns `()`
5 | | }
  | |_- this function should return `Result` or `Option` to accept `?`
  |
  = help: the trait `Try` is not implemented for `()`
  = note: required by `from_error`

error: aborting due to previous error

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

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

Эта ошибка указывает на то, что разрешено использовать оператор ? только в функциях, которые возвращают Result или Option или другой тип, который реализует типаж std::ops::Try. Если вы пишете функцию, которая не возвращает один из этих типов, и хотите использовать ? при вызове других функций, возвращающих Result<T, E>, у вас есть два варианта решения этой проблемы. Один из методов - изменить тип возвращаемого значения вашей функции на Result<T, E>, при условии что у вас нет ограничений, препятствующих этому. Другая техника заключается в использовании match или одного из методов Result<T, E> для обработки Result<T, E> любым подходящим способом.

Функция main является специальной и имеются ограничение на то, какой должен быть её возвращаемый тип. Один из допустимых типов для main это (), другой - Result<T, E>, как в примере:

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("hello.txt")?;

    Ok(())
}

Тип Box<dyn Error> называется типаж объектом, о котором мы поговорим в разделе "Использование типаж объектов, которые допускают значения различных типов" Главы 17. А пока вы можете читать обозначение Box<dyn Error> как "любая ошибка". Использование ? в main функции с этим возвращаемым типом также разрешено.

Теперь, когда мы обсудили детали вызова panic! или возврата Result, давайте вернёмся к тому, как решить, какой из случаев подходит для какой ситуации.

panic! или не panic!

Итак, как принимается решение о том, когда следует вызывать panic!, а когда вернуть Result? При панике код не имеет возможности восстановить своё выполнение. Можно было бы вызывать panic! для любой ошибочной ситуации, независимо от того, имеется ли способ восстановления или нет, но, с другой стороны, вы принимаете решение от имени вызывающего вас кода, что ситуация необратима. Когда вы возвращаете значение Result, вы делегируете принятие решения вызывающему коду. Вызывающий код может попытаться выполнить восстановление способом, который подходит в данной ситуации, или же он может решить, что из ошибки в Err нельзя восстановиться и вызовет panic!, превратив вашу исправимую ошибку в неисправимую. Поэтому возвращение Result является хорошим выбором по умолчанию для функции, которая может дать сбой.

В редких случаях более уместно писать код, который паникует вместо возвращения Result. Давайте рассмотрим, почему уместно паниковать в примерах, прототипах кода и тестах. Затем мы обсудим ситуации, в которых компилятор не может доказать, что ошибка невозможна, но вы, как человек, можете это сделать. Глава будет заканчиваться некоторыми общими руководящими принципами о том, как решить, стоит ли паниковать в коде библиотеки.

Примеры, прототипирование и тесты

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

Точно так же методы unwrap и expect являются очень удобными при создании прототипа, прежде чем вы будете готовы решить, как обрабатывать ошибки. Они оставляют чёткие маркеры в коде до момента, когда вы будете готовы сделать программу более надёжной.

Если в тесте происходит сбой при вызове метода, то вы бы хотели, чтобы весь тест не прошёл, даже если этот метод не является тестируемой функциональностью. Поскольку вызов panic! это способ, которым тест помечается как провалившийся, использование unwrap или expect - именно то, что нужно.

Случаи, в которых у вас больше информации, чем у компилятора

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

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1".parse().unwrap();
}

Мы создаём экземпляр IpAddr, анализируя жёстко закодированную строку. Можно увидеть, что 127.0.0.1 является действительным IP-адресом, поэтому здесь допустимо использование unwrap. Однако наличие жёстко закодированной допустимой строки не меняет тип возвращаемого значения метода parse: мы все ещё получаем значение Result и компилятор все также заставляет нас обращаться с Result так, будто возможен вариант Err. Это потому, что компилятор недостаточно умён, чтобы увидеть, что эта строка всегда действительный IP-адрес. Если строка IP-адреса пришла от пользователя, то она не является жёстко запрограммированной в программе и, следовательно, может привести к ошибке, мы определённо хотели бы обработать Result более надёжным способом.

Руководство по обработке ошибок

Желательно, чтобы код паниковал, если он может оказаться в некорректном состоянии. В этом контексте некорректное состояние это когда некоторое допущение, гарантия, контракт или инвариант были нарушены. Например, когда недопустимые, противоречивые или пропущенные значения передаются в ваш код - плюс один или несколько пунктов из следующего перечисленного в списке:

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

Если кто-то вызывает ваш код и передаёт значения, которые не имеют смысла, лучшим выбором может быть вызов panic! для оповещения пользователя библиотеки, что в его коде есть ошибка и он может её исправить. Также panic! подходит, если вы вызываете внешний, неподконтрольный вам код, и он возвращает недопустимое состояние, которое вы не можете исправить.

Однако, когда ожидается сбой, лучше вернуть Result, чем выполнить вызов panic!. В качестве примера можно привести синтаксический анализатор, которому передали неправильно сформированные данные, или HTTP-запрос, возвращающий статус указывающий на то, что вы достигли ограничения на частоту запросов. В этих случаях возврат Result означает, что ошибка является ожидаемой и вызывающий код должен решить, как её обрабатывать.

Когда код выполняет операции над данными, он должен проверить, что они корректны, и паниковать, если это не так. Так рекомендуется делать в основном из соображений безопасности: попытка оперировать некорректными данными может подвергнуть ваш код уязвимости. Это основная причина, по которой стандартная библиотека будет вызывать panic!, если попытаться получить доступ к памяти вне границ массива: доступ к памяти, не относящейся к текущей структуре данных, является известной проблемой безопасности. Функции часто имеют контракты: их поведение гарантируется, только если входные данные отвечают определённым требованиям. Паника при нарушении контракта имеет смысл, потому что это всегда указывает на дефект со стороны вызывающего кода, и это не ошибка, которую вы хотели бы, чтобы вызывающий код явно обрабатывал. На самом деле, нет разумного способа для восстановления вызывающего кода; программисты, вызывающие ваш код, должны исправить свой. Контракты для функции, особенно когда нарушение вызывает панику, следует описать в документации по API функции.

Тем не менее, наличие множества проверок ошибок во всех ваших функциях было бы многословным и раздражительным. К счастью, можно использовать систему типов Rust (следовательно и проверку типов компилятором), чтобы она сделала множество проверок за вас. Если ваша функция имеет определённый тип в качестве параметра, вы можете продолжить работу с логикой кода зная, что компилятор уже обеспечил правильное значение. Например, если используется обычный тип, а не тип Option, то ваша программа ожидает наличие чего-то вместо ничего. Ваш код не должен будет обрабатывать оба варианта Some и None: он будет иметь только один вариант для определённого значения. Код, пытающийся ничего не передавать в функцию, не будет даже компилироваться, поэтому ваша функция не должна проверять такой случай во время выполнения. Другой пример - это использование целого типа без знака, такого как u32, который гарантирует, что параметр никогда не будет отрицательным.

Создание пользовательских типов для проверки

Давайте разовьём идею использования системы типов Rust чтобы убедиться, что у нас есть корректное значение, и рассмотрим создание пользовательского типа для валидации. Вспомним игру угадывания числа из Главы 2, в которой наш код просил пользователя угадать число между 1 и 100. Мы никогда не проверяли, что предположение пользователя лежит между этими числами, перед сравнением предположения с загаданным нами числом; мы только проверяли, что оно положительно. В этом случае последствия были не очень страшными: наши сообщения «Слишком много» или «Слишком мало», выводимые в консоль, все равно были правильными. Но было бы лучше подталкивать пользователя к правильным догадкам и иметь различное поведение для случаев, когда пользователь предлагает число за пределами диапазона, и когда пользователь вводит, например, буквы вместо цифр.

Один из способов добиться этого - пытаться разобрать введённое значение как i32, а не как u32, чтобы разрешить потенциально отрицательные числа, а затем добавить проверку для нахождение числа в диапазоне, например, так:

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 {
        // --snip--

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

        let mut guess = String::new();

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

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

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Выражение if проверяет, находится ли наше значение вне диапазона, сообщает пользователю о проблеме и вызывает continue, чтобы начать следующую итерацию цикла и попросить ввести другое число. После выражения if мы можем продолжить сравнение значения guess с загаданным числом, зная, что guess лежит в диапазоне от 1 до 100.

Однако это не идеальное решение: если бы было чрезвычайно важно, чтобы программа работала только со значениями от 1 до 100, существовало бы много функций, требующих этого, то такая проверка в каждой функции была бы утомительной (и могла бы отрицательно повлиять на производительность).

Вместо этого можно создать новый тип и поместить проверки в функцию создания экземпляра этого типа, не повторяя их везде. Таким образом, функции могут использовать новый тип в своих сигнатурах и быть уверены в значениях, которые им передают. Листинг 9-10 показывает один из способов, как определить тип Guess, чтобы экземпляр Guess создавался только при условии, что функция new получает значение от 1 до 100.


#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

Листинг 9-10. Тип Guess, который будет создавать экземпляры только для значений от 1 до 100

Сначала мы определяем структуру с именем Guess, которая имеет поле с именем value типа i32, в котором будет храниться число.

Затем мы реализуем ассоциированную функцию new, создающую экземпляры значений типа Guess. Функция new имеет один параметр value типа i32, и возвращает Guess. Код в теле функции new проверяет, что значение value находится между 1 и 100. Если value не проходит эту проверку, мы вызываем panic!, которая оповестит программиста, написавшего вызывающий код, что в его коде есть ошибка, которую необходимо исправить, поскольку попытка создания Guess со значением value вне заданного диапазона нарушает контракт, на который полагается Guess::new. Условия, в которых Guess::new паникует, должны быть описаны в документации к API; мы рассмотрим соглашения о документации, указывающие на возможность появления panic! в документации API, которую вы создадите в Главе 14. Если value проходит проверку, мы создаём новый экземпляр Guess, у которого значение поля value равно значению параметра value, и возвращаем Guess.

Затем мы реализуем метод с названием value, который заимствует self, не имеет других параметров, и возвращает значение типа i32. Этот метод иногда называют извлекатель (getter), потому что его цель состоит в том, чтобы извлечь данные из полей структуры и вернуть их. Этот публичный метод является необходимым, поскольку поле value структуры Guess является приватным. Важно, чтобы поле value было приватным, чтобы код, использующий структуру Guess, не мог устанавливать value напрямую: код снаружи модуля должен использовать функцию Guess::new для создания экземпляра Guess, таким образом гарантируя, что у Guess нет возможности получить value, не проверенное условиями в функции Guess::new.

Функция, которая принимает или возвращает только числа от 1 до 100, может объявить в своей сигнатуре, что она принимает или возвращает Guess, вместо i32, таким образом не будет необходимости делать дополнительные проверки в теле такой функции.

Итоги

Функции обработки ошибок в Rust призваны помочь написанию более надёжного кода. Макрос panic! сигнализирует, что ваша программа находится в состоянии, которое она не может обработать, и позволяет сказать процессу чтобы он прекратил своё выполнение, вместо попытки продолжить выполнение с некорректными или неверными значениями. Перечисление Result использует систему типов Rust, чтобы сообщить, что операции могут завершиться неудачей, и ваш код мог восстановиться. Можно использовать Result, чтобы сообщить вызывающему коду, что он должен обрабатывать потенциальный успех или потенциальную неудачу. Использование panic! и Result правильным образом сделает ваш код более надёжным перед лицом неизбежных проблем.

Теперь, когда вы увидели полезные способы использования обобщённых типов Option и Result в стандартной библиотеке, мы поговорим о том, как работают обобщённые типы и как вы можете использовать их в своём коде.

Обобщённые типы, типажи и время жизни

Каждый язык программирования имеет в своём арсенале эффективные средства борьбы с дублированием кода. В Rust одним из таких инструментов являются обобщённые типы данных - generics. Это абстрактные подставные типы на место которых возможно поставить какой-либо конкретный тип или другое свойство. Когда мы пишем код, мы можем выразить поведение обобщённых типов или их связь с другими обобщёнными типами, не зная какой тип будет использован на их месте при компиляции и запуске кода.

Это подобно тому, как функция принимает на вход параметры с разными заранее неизвестными значениями и запускает на них одинаковый код. Функции могут принимать параметры некоторого "обобщённого" типа вместо конкретного типа, вроде i32 или String. Мы уже использовали такие типы данных в Главе 6 (Option<T>), в Главе 8 (Vec<T> и HashMap<K, V>) и в Главе 9 (Result<T, E>). В этой главе мы рассмотрим, как определить наши собственные типы данных, функции и методы, используя возможности обобщённых типов.

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

Затем вы изучите как использовать типажи (traits) для определения поведения в обобщённом виде. Можно комбинировать типажи с обобщёнными типами для ограничения обобщённого типа только теми типами, которые имеют определённое поведение, в отличии от любых типов.

В конце мы обсудим времена жизни (lifetimes), вариации обобщённых типов, которые дают компилятору информацию о том, как сроки жизни ссылок относятся друг к другу. Времена жизни позволяют одалживать (borrow) значения во многих ситуациях, предоставляя возможность компилятору удостовериться, что ссылки являются корректными.

Удаление дублирования кода с помощью выделения общей функциональности

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

Рассмотрим небольшую программу, которая ищет наибольшее число в списке, как показано в листинге 10-1.

Файл: src/main.rs

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = number_list[0];

    for number in number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);
    assert_eq!(largest, 100);
}

Листинг 10-1: Код поиска наибольшего числа в списке

Программа сохраняет вектор целых чисел в переменной number_list и помещает первое значение из списка в переменную largest. Далее, итератор проходит по всем элементам списка. Если текущий элемент больше числа сохранённого в переменной largest, то его значение заменяет предыдущее значение в этой переменной. Если текущий элемент меньше или равен "наибольшему" найденному ранее, то значение переменной не изменяется. После полного перебора всех элементов, переменная largest должна содержать наибольшее значение, которое в нашем случае будет равно 100.

Чтобы найти наибольшее число в двух различных списках, мы можем дублировать код листинга 10-1 и использовать такую же логику в двух различных местах программы, как показано в листинге 10-2:

Файл: src/main.rs

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = number_list[0];

    for number in number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = number_list[0];

    for number in number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);
}

Листинг 10-2: Программа поиска наибольшего числа в двух списках

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

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

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

Файл: src/main.rs

fn largest(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);
    assert_eq!(result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {}", result);
    assert_eq!(result, 6000);
}

Листинг 10-3: Абстрактный код для поиска наибольшего числа в двух списках.

Функция largest имеет параметр с именем list, который представляет срез любых значений типа i32, которые мы можем передать в неё. В результате вызова функции, код выполнится с конкретными, переданными в неё значениями. Не беспокойтесь о синтаксисе цикла for на данный момент. Мы не ссылаемся здесь на ссылку на i32; мы сопоставляем шаблон и деструктурируем каждый &i32 который получает цикл for по этой причине item будет типа i32 внутри тела цикла. Мы подробно рассмотрим сопоставление с образцом в Главе 18.

Итак, вот шаги выполненные для изменения кода из листинга 10-2 в листинг 10-3:

  1. Определить дублирующийся код.
  2. Извлечь дублирующийся код и поместить в тело функции, определяя входные и выходные значения сигнатуры функции.
  3. Обновить и заменить два участка дублирующегося кода вызовом одной функции.

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

Например, у нас есть две функции: одна ищет наибольший элемент внутри среза значений типа i32, а другая внутри среза значений типа char. Как уменьшить такое дублирование? Давайте выяснять!

Обобщённые типы данных

Мы можем использовать обобщённые типы данных для функций или структур, которые затем можно использовать с различными конкретными типами данных. Давайте сначала посмотрим, как объявлять функции, структуры, перечисления и методы, используя обобщённые типы данных. Затем мы обсудим, как обобщённые типы данных влияют на производительность кода.

В объявлении функций

Когда мы объявляем функцию с обобщёнными типами, мы размещаем обобщённые типы в сигнатуре функции, где мы обычно указываем типы данных аргументов и возвращаемое значение. Используя обобщённые типы, мы делаем код более гибким, и предоставляем большую функциональность при вызове нашей функции, предотвращая дублирование кода.

Рассмотрим пример с функцией largest. Листинг 10-4 показывает две функции, каждая из которых находит самое большое значение в срезе своего типа.

Файл: src/main.rs

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {}", result);
    assert_eq!(result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {}", result);
    assert_eq!(result, 'y');
}

Листинг 10-4: Две функции, отличающихся только именем и типом обрабатываемых данных

Функция largest_i32 уже встречалась нам: мы извлекли её в листинге 10-3, когда боролись с дублированием кода, она находит наибольшее значение типа i32 в срезе. Функция largest_char находит самое большое значение типа char в срезе. Тело у этих функций одинаковое, поэтому давайте избавимся от дублируемого кода, добавив обобщённые типы данных.

Для параметризации типов данных в новой объявляемой функции, нам нужно дать имя обобщённому типу, также как мы это делаем для аргументов функций. Можно использовать любой идентификатор для имени параметра типа. Но мы будем использовать T, потому что, по соглашению, имена параметров в Rust должны быть короткими (обычно длиной в один символ) и именование типов в Rust делается в нотации CamelCase. Сокращение слова "type" до одной буквы T является стандартным выбором большинства программистов использующих язык Rust.

Когда мы используем параметр в теле функции, мы должны объявить имя параметра в сигнатуре, так компилятор будет знать, что означает имя. Аналогично, когда мы используем имя параметра в сигнатуре функции, мы должны объявить имя параметра раньше, чем мы его используем. Чтобы определить обобщённую функцию largest, поместим объявление имён параметров в треугольные скобки, <>, между именем функции и списком параметров, как здесь:

fn largest<T>(list: &[T]) -> T {

Объявление читается так: функция largest является обобщённой по типу T. Эта функция имеет один параметр с именем list, который является срезом значений с типом данных T. Функция largest возвращает данные такого же типа T.

Листинг 10-5 показывает определение функции largest с использованием обобщённых типов данных в её сигнатуре. Листинг также показывает, как мы можем вызвать функцию со срезом данных типа i32 или char. Данный код пока не будет компилироваться, но мы исправим это к концу раздела.

Файл: src/main.rs

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

Листинг 10-5: определение функции largest с использованием обобщённых типов, но код пока не компилируется

Если мы скомпилируем программу сейчас, мы получим следующую ошибку:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- T
  |            |
  |            T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T {
  |             ^^^^^^^^^^^^^^^^^^^^^^

error: aborting due to previous error

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

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

В подсказке упоминается std::cmp::PartialOrd, который является типажом. Мы поговорим про типажи в следующей секции. Сейчас, ошибка в функции largest указывает, что функция не будет работать для всех возможных типов T. Так как мы хотим сравнивать значения типа T в теле функции, то можно использовать только те типы, данные которых можно упорядочить: можем упорядочить, значит можем и сравнить. Для возможности сравнения, стандартная библиотека имеет типаж std::cmp::PartialOrd, который вы можете реализовать для типов (смотрите Дополнение С для большей информации про данный типаж). Вы узнаете, как потребовать чтобы обобщённый тип реализовывал определённый типаж в секции "Типажи как параметры", но сначала давайте рассмотрим другие варианты использования обобщённых типов.

В определении структур

Также можно определять структуры с использованием обобщённых типов в одном или нескольких полях структуры с помощью синтаксиса <>. Листинг 10-6 показывает как определить структуру Point<T>, чтобы хранить поля координат x и y любого типа данных.

Файл: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

Листинг 10-6: структура Point содержащая поля x и y типа T

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

Так как мы используем только один обобщённый тип данных для определения структуры Point<T>, это определение означает, что структура Point<T> является обобщённой с типом T, и оба поля x и y имеют одинаковый тип, каким бы он типом не являлся. Если мы создадим экземпляр структуры Point<T> со значениями разных типов, как показано в Листинге 10-7, наш код не компилируется.

Файл: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Листинг 10-7: поля x и y должны быть одного типа, так как они имеют один и тот же обобщённый тип T

В этом примере, когда мы присваиваем целочисленное значение 5 переменной x , мы сообщаем компилятору, что обобщённый тип T будет целым числом для этого экземпляра Point<T>. Затем, когда мы указываем значение 4.0 (имеющее тип отличный от целого числа) для y, который мы определили имеющим тот же тип, что и x, мы получим ошибку несоответствия типов:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

error: aborting due to previous error

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

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

Чтобы определить структуру Point где оба x и y являются обобщёнными, но могут иметь различные типы, можно использовать несколько параметров обобщённого типа. Например, в листинге 10-8 мы можем изменить определение Point, чтобы оно было общим для типов T и U где x имеет тип T а y имеет тип U.

Файл: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

Листинг 10-8: структура Point<T, U> обобщена для двух типов, так что x и y могут быть значениями разных типов

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

В определениях перечислений

Как и в случае со структурами, можно определить перечисления для хранения обобщённых типов в их вариантах. Давайте ещё раз посмотрим на перечисление Option<T> предоставленное стандартной библиотекой, которое мы использовали в Главе 6:


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Это определение теперь должно иметь больше смысла. Как видите, перечисление Option<T>, которое является обобщённым по типу T и имеет два варианта: Some, который содержит одно значение типа T и вариант None, который не содержит никакого значения. Используя перечисление Option<T>, можно выразить абстрактную концепцию необязательного значения и так как Option<T> является обобщённым, можно использовать эту абстракцию независимо от того, каким будет тип для необязательного значения.

Перечисления также могут использовать в определении несколько обобщённых типов. Определение перечисления Result, которое мы использовали в Главе 9, является таким примером:


#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Перечисление Result имеет два обобщённых типа T и E и два варианта: Ok, которое содержит тип T, и Err, которое содержит тип E. Такое определение позволяет использовать перечисление Result везде, где операции могут быть выполнены успешно (возвращая значение типа данных T) или неуспешно (возвращая значение типа данных E). Это то что мы делали в коде листинга 9-2, где при открытии файла заполнялись данные типа T, в примере тип std::fs::File или E тип std::io::Error при ошибке, при каких-либо проблемах открытия файла.

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

В определении методов

Также, как и в Главе 5, можно реализовать методы структур и перечислений с помощью обобщённых типов и их объявлений. Код листинга 10-9 демонстрирует пример добавления метода с названием x в структуру Point<T>, которую мы ранее описали в листинге 10-6.

Файл: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Листинг 10-9. Реализация метода с именем x у структуры Point, которая будет возвращать ссылку на поле x типа T

Здесь мы определили метод с именем x у Point<T> который возвращает ссылку на данные в поле x.

Обратите внимание, что нужно объявить T сразу после impl, чтобы можно было использовать его для указания, что мы реализуем методы для типа Point<T>. Объявляя T как обобщённый тип после impl, Rust может определить, что тип в угловых скобках у Point - это обобщённый, а не конкретный тип.

Мы могли бы, например, реализовать методы только для экземпляров типа Point<f32> вместо остальных экземпляров Point<T> где используется какой-то другой обобщённый тип. В листинге 10-10 мы реализуем код для конкретного типа f32: здесь мы не объявляем иных блоков impl для других вариантов обобщённого типа после.

Файл: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Листинг 10-10: блок impl который применяется только к структуре с конкретным типом для параметра обобщённого типа T

Этот код означает, что тип Point<f32> будет иметь метод с именем distance_from_origin, а другие экземпляры Point<T> где T имеет тип отличный от f32 не будут иметь этого метода. Метод измеряет, насколько далеко наша точка находится от точки с координатами (0,0, 0,0) и использует математические операции, доступные только для типов с плавающей запятой.

Обобщённые типы которые мы используем в определении структур ведут себя не всегда такими же образом, как обобщённые типы используемые в сигнатурах методов структур. Код листинга 10-11 описывает метод mixup у структуры Point<T, U>. Метод получает в качестве параметра другую структуру Point, которая могла бы иметь типы отличные от self Point у которой мы вызываем метод mixup. Метод создаёт новый экземпляр структуры Point, который получает значение x из self``Point (типа T) и y из Point (типа W):

Файл: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

Листинг 10-11: метод, использующий разные обобщённые типы из определения структуры для которой он определён

В функции main, мы определили тип Point, который имеет i32 для x (со значением 5 ) и тип f64 для y (со значением 10.4 ). Переменная p2 является структурой Point которая имеет строковый срез для x (со значением "Hello") и char для y (со значением c ). Вызов mixup на p1 с аргументом p2 создаст для нас экземпляр структуры p3. Новый экземпляр p3 будет иметь для x тип i32 (потому что x взят из p1), а для y тип char (потому что y взят из p2). Вызов макроса println! выведет p3.x = 5, p3.y = c.

Цель этого примера продемонстрировать ситуацию, в которой некоторые обобщённые параметры объявлены с помощью impl, а некоторые объявлены в определении метода. Здесь обобщённые параметры T и U объявляются после impl, потому что они идут вместе с определением структуры. Обобщённые параметры типа V и W объявляются после fn mixup, потому что они относятся только к методу.

Производительность кода использующего обобщённые типы

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

Rust достигает этого благодаря выполнению мономорфизации кода использующего обобщения. Мономорфизация - это процесс превращения обобщённого кода в конкретный код во время компиляции, при котором из кода с обобщёнными типами генерируется код содержащий конкретные типы которые могут встретиться в вашем приложении.

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

Давайте посмотрим, как это работает, на примере, который использует перечисление Option<T> из стандартной библиотеки:


#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

Когда Rust компилирует этот код, он выполняет мономорфизацию. Во время этого процесса компилятор считывает значения, которые были использованы у экземпляра Option<T> и определяет два вида Option<T>: один для i32, а другой для f64. Таким образом, он расширяет общее определение Option<T> в Option_i32 и Option_f64, тем самым заменяя обобщённое определение на конкретное.

Мономорфизированная версия кода выглядит следующим образом. Обобщённый Option<T> заменяется конкретными определениями, созданными компилятором:

Файл: src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

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

Типажи: определение общего поведения

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

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

Определение типажа

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

Например, скажем есть несколько структур, которые имеют различный тип и различное количество текста: структура NewsArticle, которая содержит новости, напечатанные в различных местах в мире; структура Tweet, которая содержит 280 символьную строку твита и мета-данные, обозначающие является ли твит новым или ответом на другой твит.

Мы хотим создать библиотеку медиа-агрегатора, которая может отображать сводку данных сохранённых в экземплярах структур NewsArticle или Tweet. Чтобы этого достичь, нам необходимо иметь возможность для каждой структуры сделать короткую сводку на основе имеющихся данных: надо, чтобы обе структуры реализовали общее поведение. Мы можем делать такую сводку вызовом метода summarize у экземпляра объекта. Пример листинга 10-12 иллюстрирует определение типажа Summary, который выражает данное поведение:

Файл: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String;
}

Листинг 10-12: Определение типажа Summary, который содержит поведение предоставленное методом summarize

Здесь мы объявляем типаж с использованием ключевого слова trait, а затем его название, которым является Summary в данном случае. Внутри фигурных скобок объявляются сигнатуры методов, которые описывают поведения типов, реализующих данный типаж, в данном случае поведение определяется только одной сигнатурой метода: fn summarize(&self) -> String.

После сигнатуры метода, вместо предоставления реализации в фигурных в скобках, мы используем точку с запятой. Каждый тип, реализующий данный типаж, должен предоставить своё собственное поведение для данного метода. Компилятор обеспечит, что любой тип содержащий типаж Summary, будет также иметь и метод summarize объявленный с точно такой же сигнатурой.

Типаж может иметь несколько методов в описании его тела: сигнатуры методов перечисляются по одной на каждой строке и должны закачиваться символом ;.

Реализация типажа у типа

Теперь, после того как мы определили желаемое поведение используя типаж Summary, можно реализовать его у типов в нашем медиа-агрегаторе. Листинг 10-13 показывает реализацию типажа Summary у структуры NewsArticle, которая использует для создания сводки в методе summarize заголовок, автора и место публикации статьи. Для структуры Tweet мы определяем реализацию summarize используя пользователя и полный текст твита, полагая содержание твита уже ограниченным 280 символами.

Файл: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Код программы 10-13: Реализация типажа Summary для структур NewsArticle и Tweet

Реализация типажа у типа аналогична реализации обычных методов. Разница в том, что после impl мы ставим имя типажа, который мы хотим реализовать, затем используем ключевое слово for, а затем указываем имя типа, для которого мы хотим сделать реализацию типажа. Внутри блока impl мы помещаем сигнатуру метода объявленную в типаже. Вместо добавления точки с запятой в конце, после каждой сигнатуры используются фигурные скобки и тело метода заполняется конкретным поведением, которое мы хотим получить у методов типажа для конкретного типа.

После того, как мы реализовали типаж, можно вызвать его методы у экземпляров NewsArticle и Tweet тем же способом, что и вызов обычных методов, например так:

use chapter10::{self, Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Данный код напечатает: 1 new tweet: horse_ebooks: of course, as you probably already know, people.

Обратите внимание, что поскольку мы определили типаж Summary и типы NewsArticle и Tweet в одном и том же файле lib.rs примера 10-13, все они находятся в одной области видимости. Допустим, что lib.rs предназначен для крейта, который мы назвали aggregator и кто-то ещё хочет использовать функциональность нашего крейта для реализации типажа Summary у структуры, определённой в области видимости внутри их библиотеки. Им нужно будет сначала подключить типаж в их область видимости. Они сделали бы это, указав use aggregator::Summary;, что позволит реализовать Summary для их типа. Типажу Summary также необходимо быть публичным для реализации в других крейтах, потому мы поставили ключевое слово pub перед trait в листинге 10-12.

Одно ограничение, на которое следует обратить внимание при реализации типажей это то, что мы можем реализовать типаж для типа, только если либо типаж, либо тип являются локальным для нашего крейта. Например, можно реализовать типажи из стандартной библиотеки, такие как Display для пользовательского типа Tweet являющимся частью функциональности крейта aggregator, потому что тип Tweet является локальным в крейте aggregator. Мы также можем реализовать типаж Summary для Vec<T> в нашем крейте aggregator, потому что типаж Summary является локальным для крейта aggregator.

Но мы не можем реализовать внешние типажи для внешних типов. Например, мы не можем реализовать функцию Display для Vec<T> в нашем крейте aggregator, потому что и типаж Display и тип Vec<T> определены в стандартной библиотеке, а не локально в нашем крейте aggregator. Это ограничение является частью свойства программы называемое согласованность, а точнее сиротское правило (orphan rule), называемое так, потому что родительский тип не представлен. Это правило гарантирует, что код других людей не может сломать ваш код и наоборот. Без этого правила два крейта могли бы реализовать один типаж для одинакового типа и Rust не будет знать, какой реализацией пользоваться.

Реализация поведения по умолчанию

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

В примере 10-14 показано, как указать строку по умолчанию для метода summarize из типажа Summary вместо определения только сигнатуры метода, как мы сделали в примере 10-12.

Файл: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Листинг 10-14. Определение типажа Summary с реализацией метода summarize по умолчанию

Для использования реализации по умолчанию при создании сводки у экземпляров NewsArticle вместо определения пользовательской реализации, мы указываем пустой блок impl с impl Summary for NewsArticle {}.

Хотя мы больше не определяем метод summarize непосредственно в NewsArticle, мы предоставили реализацию по умолчанию и указали, что NewsArticle реализует типаж Summary. В результате мы всё ещё можем вызвать метод summarize у экземпляра NewsArticle, например так:

use chapter10::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

Этот код печатает New article available! (Read more...) .

Создание реализации по умолчанию для метода summarize не требует от нас изменений чего-либо в реализации Summary для типа Tweet в листинге 10-13. Причина заключается в том, что синтаксис для переопределения реализации по умолчанию является таким же, как синтаксис для реализации метода типажа, который не имеет реализации по умолчанию.

Реализации по умолчанию могут вызывать другие методы в том же типаже, даже если эти другие методы не имеют реализации по умолчанию. Таким образом, типаж может предоставить много полезной функциональности и только требует от разработчиков указывать небольшую его часть. Например, мы могли бы определить типаж Summary имеющий метод summarize_author, реализация которого требуется, а затем определить метод summarize который имеет реализацию по умолчанию, которая внутри вызывает метод summarize_author :

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Чтобы использовать такую версию типажа Summary, нужно только определить метод summarize_author, при реализации типажа для типа:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

После того, как мы определим summarize_author, можно вызвать summarize для экземпляров структуры Tweet и реализация по умолчанию метода summarize будет вызывать определение summarize_author которое мы уже предоставили. Так как мы реализовали метод summarize_author типажа Summary, то типаж даёт нам поведение метода summarize без необходимости писать код.

use chapter10::{self, Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Этот код печатает 1 new tweet: (Read more from @horse_ebooks...) .

Обратите внимание, что невозможно вызвать реализацию по умолчанию из переопределённой реализации того же метода.

Типажи как параметры

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

Например, в листинге 10-13 мы реализовали типаж Summary для типов структур NewsArticle и Tweet. Можно определить функцию notify которая вызывает метод summarize с параметром item, который имеет тип реализующий типаж Summary . Для этого можно использовать синтаксис &impl Trait, например так:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Вместо конкретного типа у параметра item указывается ключевое слово impl и имя типажа. Этот параметр принимает любой тип, который реализует указанный типаж. В теле notify мы можем вызывать любые методы у экземпляра item, которые должны быть определены при реализации типажа Summary, например можно вызвать метод summarize. Мы можем вызвать notify и передать в него любой экземпляр NewsArticle или Tweet. Код, который вызывает данную функцию с любым другим типом, таким как String или i32, не будет компилироваться, потому что эти типы не реализуют типаж Summary.

Синтаксис ограничения типажа

Синтаксис impl Trait работает для простых случаев, но на самом деле является синтаксическим сахаром для более длинной формы, которая называется ограничением типажа; это выглядит так:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Эта более длинная форма эквивалентна примеру в предыдущем разделе, но она более многословна. Мы помещаем объявление параметра обобщённого типа с ограничением типажа после двоеточия внутри угловых скобок.

Синтаксис impl Trait удобен и делает более выразительным код в простых случаях. Синтаксис ограничений типажа может выразить большую сложность в других случаях. Например, у нас может быть два параметра, которые реализуют типаж Summary. Использование синтаксиса impl Trait выглядит следующим образом:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Если бы мы хотели, чтобы эта функция позволяла иметь item1 и item2 разных типов, то использование impl Trait было бы уместно (до тех пор, пока оба типа реализуют Summary). Если мы хотим форсировать, чтобы оба параметра имели одинаковый тип, то это можно выразить только с использованием ограничения типажа, например так:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

Обобщённый тип T указан для типов параметров item1 и item2 и ограничивает функцию так, что конкретные значения типов переданные аргументами в item1 и item2 должны быть одинаковыми.

Задание нескольких границ типажей с помощью синтаксиса +

Также можно указать более одного ограничения типажа. Скажем, мы хотели бы использовать в методе notify для параметра item с форматированием отображения, также как метод summarize: для этого мы указываем в определении notify, что item должен реализовывать как типаж Display так и Summary. Мы можем сделать это используя синтаксис +:

pub fn notify(item: &(impl Summary + Display)) {

Синтаксис + также допустим с ограничениями типажа для обобщённых типов:

pub fn notify<T: Summary + Display>(item: &T) {

При наличии двух ограничений типажа, тело метода notify может вызывать метод summarize и использовать {} для форматирования item при его печати.

Более ясные границы типажа с помощью where

Использование слишком большого количества ограничений типажа имеет свои недостатки. Каждый обобщённый тип имеет свои границы типажа, поэтому функции с несколькими параметрами обобщённого типа могут содержать много информации об ограничениях между названием функции и списком её параметров затрудняющих чтение сигнатуры. По этой причине в Rust есть альтернативный синтаксис для определения ограничений типажа внутри предложения where после сигнатуры функции. Поэтому вместо того, чтобы писать так:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

можно использовать предложение where , например так:

fn some_function<T, U>(t: &T, u: &U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{

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

Возврат значений типа реализующего определённый типаж

Также можно использовать синтаксис impl Trait в возвращаемой позиции, чтобы вернуть значение некоторого типа реализующего типаж, как показано здесь:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

Используя impl Summary для возвращаемого типа, мы указываем, что функция returns_summarizable возвращает некоторый тип, который реализует типаж Summary без обозначения конкретного типа. В этом случае returns_summarizable возвращает Tweet, но код, вызывающий эту функцию, этого не знает.

Возможность возвращать тип, который определяется только реализуемым им признаком, особенно полезна в контексте замыканий и итераторов, которые мы рассмотрим в Главе 13. Замыкания и итераторы создают типы, которые знает только компилятор или типы, которые очень долго указывать. Синтаксис impl Trait позволяет кратко указать, что функция возвращает некоторый тип, который реализует типаж Iterator без необходимости писать очень длинный тип.

Однако, impl Trait возможно использовать, если возвращаете только один тип. Например, данный код, который возвращает значения или типа NewsArticle или типа Tweet, но в качестве возвращаемого типа объявляет impl Summary, не будет работать:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

Возврат либо NewsArticle либо Tweet не допускается из-за ограничений того, как реализован синтаксис impl Trait в компиляторе. Мы рассмотрим, как написать функцию с таким поведением в разделе "Использование объектов типажей, которые разрешены для значений или разных типов" Главы 17.

Исправление кода функции largest с помощью ограничений типажа

Теперь, когда вы знаете, как указать поведение, которое вы хотите использовать для ограничения параметра обобщённого типа, давайте вернёмся к листингу 10-5 и исправим определение функции largest. В прошлый раз мы пытались запустить этот код, но получили ошибку:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- T
  |            |
  |            T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T {
  |             ^^^^^^^^^^^^^^^^^^^^^^

error: aborting due to previous error

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

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

В теле функции largest мы хотели сравнить два значения типа T используя оператор больше чем ( > ). Так как этот оператор определён у типажа std::cmp::PartialOrd из стандартной библиотеки как метод по умолчанию, то нам нужно указать PartialOrd в качестве ограничения для типа T: благодаря этому функция largest сможет работать со срезами любого типа, значения которого мы можем сравнить. Нам не нужно подключать PartialOrd в область видимости, потому что он есть в авто-импорте. Изменим сигнатуру largest, чтобы она выглядела так:

fn largest<T: PartialOrd>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

На этот раз при компиляции кода мы получаем другой набор ошибок:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0508]: cannot move out of type `[T]`, a non-copy slice
 --> src/main.rs:2:23
  |
2 |     let mut largest = list[0];
  |                       ^^^^^^^
  |                       |
  |                       cannot move out of here
  |                       move occurs because `list[_]` has type `T`, which does not implement the `Copy` trait
  |                       help: consider borrowing here: `&list[0]`

error[E0507]: cannot move out of a shared reference
 --> src/main.rs:4:18
  |
4 |     for &item in list {
  |         -----    ^^^^
  |         ||
  |         |data moved here
  |         |move occurs because `item` has type `T`, which does not implement the `Copy` trait
  |         help: consider removing the `&`: `item`

error: aborting due to 2 previous errors

Some errors have detailed explanations: E0507, E0508.
For more information about an error, try `rustc --explain E0507`.
error: could not compile `chapter10`

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

Ключевая строка в этой ошибке cannot move out of type [T], a non-copy slice. В нашей необобщённой версии функции largest мы пытались найти самый большой элемент только для типа i32 или char. Как обсуждалось в разделе "Данные только для стека: Копирование" Главы 4, типы подобные i32 и char, имеющие известный размер, могут храниться в стеке, поэтому они реализуют типаж Copy. Но когда мы сделали функцию largest обобщённой, для параметра list стало возможным иметь типы, которые не реализуют типаж Copy. Следовательно, мы не сможем переместить значение из переменной list[0] в переменную largest, в результате чего появляется эта ошибка.

Чтобы вызывать этот код только с теми типами, которые реализуют типаж Copy, можно добавить типаж Copy в список ограничений типа T! Листинг 10-15 показывает полный код обобщённой функции largest, которая будет компилироваться, пока типы значений среза передаваемых в функцию, реализуют одновременно типажи PartialOrd и Copy, как это делают i32 и char.

Файл: src/main.rs

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

Листинг 10-15: Объявление функции largest работающей с любыми обобщёнными типами, которые реализуют типажи PartialOrd и Copy

Если мы не хотим ограничить функцию largest типами, которые реализуют типаж Copy, мы можем указать, что T имеет ограничение типажа Clone вместо Copy. Затем мы могли бы клонировать каждое значение в срезе, если бы хотели чтобы функция largest забирала владение. Использование функции clone означает, что потенциально делается больше операций выделения памяти в куче для типов, которые владеют данными в куче, например для String. В то же время стоит помнить о том, что выделение памяти в куче может быть медленным, если мы работаем с большими объёмами данных.

Ещё один способ, который мы могли бы реализовать в largest - это создать функцию возвращающую ссылку на значение T из среза. Если мы изменим возвращаемый тип на &T вместо T, то тем самым изменим тело функции, чтобы она возвращала ссылку, тогда нам были бы не нужны ограничения входных значений типажами Clone или Copy и мы могли бы избежать выделения памяти в куче. Попробуйте реализовать эти альтернативные решения самостоятельно!

Использование ограничений типажа для условной реализации методов

Используя ограничение типажа с блоком impl, который использует параметры обобщённого типа, можно реализовать методы условно, для тех типов, которые реализуют указанный типаж. Например, тип Pair<T> в листинге 10-16 всегда реализует функцию new. Но Pair<T> реализует метод cmp_display только если его внутренний тип T реализует типаж PartialOrd (позволяющий сравнивать) и типаж Display (позволяющий выводить на печать).

Файл: src/lib.rs

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

Листинг 10-17: Условная реализация методов у обобщённых типов в зависимости от ограничений типажа

Мы также можем условно реализовать типаж для любого типа, который реализует другой типаж. Реализации типажа для любого типа, который удовлетворяет ограничениям типажа называются общими реализациями и широко используются в стандартной библиотеке Rust. Например, стандартная библиотека реализует типаж ToString для любого типа, который реализует типаж Display. Блок impl в стандартной библиотеке выглядит примерно так:

impl<T: Display> ToString for T {
    // --snip--
}

Поскольку стандартная библиотека имеет эту общую реализацию, то можно вызвать метод to_string определённый типажом ToString для любого типа, который реализует типаж Display. Например, мы можем превратить целые числа в их соответствующие String значения, потому что целые числа реализуют типаж Display:


#![allow(unused)]
fn main() {
let s = 3.to_string();
}

Общие реализации приведены в документации к типажу в разделе "Implementors".

Типажи и ограничения типажей позволяют писать код, который использует параметры обобщённого типа для уменьшения дублирования кода, а также указывая компилятору, что мы хотим обобщённый тип, чтобы иметь определённое поведение. Затем компилятор может использовать информацию про ограничения типажа, чтобы проверить, что все конкретные типы, используемые с нашим кодом, обеспечивают правильное поведение. В динамически типизированных языках мы получили бы ошибку во время выполнения, если бы вызвали метод для типа, который не реализует тип определяемый методом. Но Rust перемещает эти ошибки на время компиляции, поэтому мы вынуждены исправить проблемы, прежде чем наш код начнёт работать. Кроме того, мы не должны писать код, который проверяет своё поведение во время выполнения, потому что это уже проверено во время компиляции. Это повышает производительность без необходимости отказываться от гибкости обобщённых типов.

Другой тип обобщения, который мы уже использовали, называется временами жизни (lifetimes). Вместо гарантирования того, что тип ведёт себя так, как нужно, время жизни гарантирует что ссылки действительны до тех пор, пока они нужны. Давайте посмотрим, как времена жизни это делают.

Валидация ссылок при помощи времён жизни

Когда мы говорили о ссылках в разделе "Ссылки и заимствование" Главы 4, мы опустили весьма важную деталь: каждая ссылка в Rust имеет время жизни (lifetime), определяющее область действия, в которой ссылка является действительной. В большинстве случаев, времена жизни выводятся неявно также как у типов. Мы должны явно аннотировать типы, когда возможно выведение нескольких типов. Аналогичным образом, мы должны аннотировать времена жизни, когда времена жизни ссылок могут быть соотнесены несколькими различными способами. Rust требует, чтобы мы аннотировали отношения, используя обобщённые параметры времени жизни для гарантирования того, что реальные ссылки используемые во время выполнения, будут однозначно действительными.

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

Времена жизни предотвращают появление недействительных ссылок

Основная цель времён жизни состоит в том, чтобы предотвратить недействительные ссылки (dangling references), которые приводят к тому, что программа ссылается на данные отличные от данных на которые она должна ссылаться. Рассмотрим программу из листинга 10-17, которая имеет внешнюю и внутреннюю области видимости.

fn main() {
    {
        let r;

        {
            let x = 5;
            r = &x;
        }

        println!("r: {}", r);
    }
}

Листинг 10-17: Попытка использования ссылки, значение которой вышло из области видимости

Примечание: примеры в листингах 10-17, 10-18 и 10-24 объявляют переменные без предоставления им начального значения, поэтому переменные существуют во внешней области видимости. На первый взгляд может показаться, что это противоречит отсутствию нулевых (null) значений. Однако, если мы попытаемся использовать переменную, прежде чем дать ей значение, мы получим ошибку во время компиляции, которая показывает, что Rust действительно не позволяет использование нулевых (null) значений.

Внешняя область видимости объявляет переменную с именем r без начального значения, а внутренняя область объявляет переменную с именем x с начальным значением 5. Во внутренней области мы пытаемся установить значение r как ссылку на x. Затем внутренняя область видимости заканчивается и мы пытаемся напечатать значение из r. Этот код не будет скомпилирован, потому что значение на которое ссылается r исчезает из области видимости, прежде чем мы попробуем использовать его. Вот сообщение об ошибке:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
  --> src/main.rs:7:17
   |
7  |             r = &x;
   |                 ^^ borrowed value does not live long enough
8  |         }
   |         - `x` dropped here while still borrowed
9  | 
10 |         println!("r: {}", r);
   |                           - borrow later used here

error: aborting due to previous error

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

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

Переменная x «не живёт достаточно долго». Причина в том, что x выйдет из области видимости, когда эта внутренняя область закончится в строке 7. Но r все ещё является действительной во внешней области видимости; поскольку её охват больше, мы говорим, что она «живёт дольше». Если бы Rust позволил такому коду работать, то переменная r бы смогла ссылаться на память, которая была освобождена (в тот момент, когда x вышла из внутренней области видимости) и всё что мы попытались бы сделать с r не работало бы правильно. Так как же Rust определяет, что этот код неверен? Он использует анализатор заимствований.

Анализатор заимствований

Компилятор Rust имеет в своём составе анализатор заимствований, который сравнивает области видимости для определения являются ли все заимствования действительными. В листинге 10-18 показан тот же код, что и в листинге 10-17, но с аннотациями, показывающими времена жизни переменных.

fn main() {
    {
        let r;                // ---------+-- 'a
                              //          |
        {                     //          |
            let x = 5;        // -+-- 'b  |
            r = &x;           //  |       |
        }                     // -+       |
                              //          |
        println!("r: {}", r); //          |
    }                         // ---------+
}

Пример 10-18: Описание времён жизни переменных r и x, с помощью идентификаторов времени жизни 'a и 'b

Здесь мы описали время жизни для r с помощью 'a и время жизни x с помощью 'b . Как видите, внутренний блок времени жизни 'b гораздо меньше времени жизни внешнего блока 'a. Во время компиляции Rust сравнивает размер двух времён жизни и видит, что r имеет время жизни 'a, но ссылается на память со временем жизни 'b. Программа отклоняется, потому что 'b короче, чем 'a: объект ссылки не живёт так же долго как сама ссылка на него.

Листинг 10-19 исправляет код так, что в нём нет проблем с недействительными ссылками: он компилируется без ошибок.

fn main() {
    {
        let x = 5;            // ----------+-- 'b
                              //           |
        let r = &x;           // --+-- 'a  |
                              //   |       |
        println!("r: {}", r); //   |       |
                              // --+       |
    }                         // ----------+
}

Листинг 10-19: Все ссылки действительны, поскольку данные имеют большее время жизни, чем ссылка на эти данные

Здесь переменная x имеет время жизни 'b, которое больше, чем время жизни 'a. Это означает, что переменная r может ссылаться на переменную x потому что Rust знает, что ссылка в переменной r будет всегда действительной до тех пор, пока переменная x является действительной.

После того, как мы на примерах рассмотрели времена жизни ссылок и обсудили как Rust их анализирует, давайте поговорим об обобщённых временах жизни входных параметров и возвращаемых значений функций.

Обобщённые времена жизни в функциях

Давайте напишем функцию, которая возвращает наиболее длинный срез строки из двух. Эта функция принимает два среза строки и вернёт один срез строки. После того как мы реализовали функцию longest, код в листинге 10-20 должен вывести The longest string is abcd.

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

Листинг 10-20: Функция main вызывает функцию longest для поиска наибольшей строки

Обратите внимание, что мы хотим чтобы функция принимала строковые срезы, которые являются ссылками, потому что мы не хотим, чтобы функция longest забирала во владение параметры. Обратитесь к разделу "Строковые фрагменты как параметры" Главы 4 для более подробного обсуждения того, почему параметры используемые в листинге 10-20 выбраны именно таким образом.

Если мы попробуем реализовать функцию longest так, как это показано в листинге 10-22, то программа не будет скомпилирована:

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Листинг 10-21: Реализация функции longest, которая возвращает наибольший срез строки, но пока не компилируется

Вместо этого мы получим следующую ошибку, сообщающую об ошибке в определении времени жизни возвращаемого параметра:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ^^^^    ^^^^^^^     ^^^^^^^     ^^^

error: aborting due to previous error

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

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

Текст показывает, что возвращаемому типу нужен обобщённый параметр времени жизни, потому что Rust не может определить, относится ли возвращаемая ссылка к x или к y. На самом деле, мы тоже не знаем, потому что блок if в теле функции возвращает ссылку на x, а блок else возвращает ссылку на y!

Когда мы определяем функцию longest таким образом, то мы не знаем конкретных значений передаваемых в неё. Поэтому мы не знаем какая из ветвей оператора if или else будет выполнена. Мы также не знаем конкретных времён жизни ссылок, передаваемых в функцию, из-за чего не можем посмотреть на их области видимости, как мы делали в примерах 10-19 и 10-20, чтобы убедиться в том, что возвращаемая ссылка всегда действительна. Анализатор заимствований тоже не может этого определить, потому что не знает как времена жизни переменных x и y соотносятся с временем жизни возвращаемого значения. Мы добавим обобщённый параметр времени жизни, который определит отношения между ссылками, чтобы анализатор зависимостей мог провести анализ ссылок с помощью проверки заимствования.

Синтаксис аннотации времени жизни

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

Аннотации времени жизни имеют немного необычный синтаксис: имена параметров времени жизни должны начинаться с апострофа ', они обычно очень короткие и пишутся в нижнем регистре. Обычно, по умолчанию, большинство людей использует имя 'a. Аннотации параметров времени жизни следуют после символа & и отделяются пробелом от названия ссылочного типа.

Приведём несколько примеров: у нас есть ссылка на i32 без указания времени жизни, ссылка на i32, с временем жизни имеющим имя 'a и изменяемая ссылка на i32, которая тоже имеет время жизни 'a.

&i32        // ссылка
&'a i32     // ссылка с явным временем жизни
&'a mut i32 // изменяемая ссылка с явным временем жизни

Одна аннотация времени жизни сама по себе не имеет большого смысла, потому что эти аннотации призваны сообщить компилятору Rust как соотносятся между собой несколько обобщённых параметров времени жизни. Предположим, что у нас есть функция с параметром first, имеющим ссылочный тип данных &i32 и временем жизни 'a, и вторым параметром second, который также имеет ссылочный тип &i32 со временем жизни 'a. Аннотации времени жизни этих параметров имеют одинаковое имя, что говорит о том, что обе ссылки first и second должны жить одинаково долго.

Аннотации времени жизни в сигнатурах функций

Давайте посмотрим на аннотации времён жизни в контексте функции longest. Обобщённые параметры времени жизни объявляются в угловых скобках между именем функции и списком её параметров. Ограничение, которое мы хотим выразить в этой сигнатуре, говорит о том, что все ссылки во входных параметрах и возвращаемом значении функции должны иметь одинаковое время жизни. Мы назовём время жизни как 'a и добавим его к каждой ссылке, как показано в листинге 10-23.

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Листинг 10-23: В сигнатуре функции longest указано, что все ссылки должны иметь одинаковое время обозначенное как 'a

Этот код должен компилироваться и давать желаемый результат, когда мы вызовем его в main функции листинга 10-20.

Сигнатура функции теперь говорит Rust, что для некоторого времени жизни 'a функция принимает два параметра, оба из которых представляют собой срезы строк, которые существуют как минимум в течение времени жизни указанном как 'a. Сигнатура функции также сообщает Rust, что срез строки, возвращаемый из функции будет жить как минимум столько же, сколько и время жизни 'a. На практике это означает, что время жизни ссылки, возвращаемой из функции longest такое же, как и меньшее время жизни из двух ссылок переданных в неё. Эти ограничения - это то, что мы хотим, чтобы Rust соблюдал. Помните, когда мы указываем параметры времени жизни в сигнатуре функции, мы не меняем время жизни любых значений, переданных или возвращённых функцией. Скорее, мы указываем, что анализатор заимствования должен отклонять любые значения, которые не придерживаются этих ограничений. Обратите внимание, что самой функции longest не нужно точно знать, как долго будут жить x и y, а только то что некоторую область времени жизни можно заменить на 'a, что будет удовлетворять сигнатуре.

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

Когда мы передаём конкретные ссылки в longest, время жизни, которое заменено на 'a, будет привязано ко времени жизни которое является пересечением времени жизни области видимости x с временем жизни области видимости y. Другими словами, обобщённое время жизни 'a получит конкретное время жизни: время равное меньшему из времён жизни x и y. Так как мы аннотировали возвращаемую ссылку тем же параметром времени жизни 'a, то возвращённая ссылка также будет действительна в течение меньшего из времён жизни x и y.

Давайте посмотрим, как аннотации времени жизни ограничивают функцию longest передавая внутрь ссылки, которые имеют разные конкретные времена жизни. Листинг 10-23 является простым примером.

Файл: src/main.rs

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Листинг 10-23: Использование функции longest со ссылками на значения типа String, которые имеют разное время жизни

В этом примере переменная string1 действительна до конца внешней области, string2 действует до конца внутренней области видимости и result ссылается на что-то, что является действительным до конца внутренней области видимости. Запустите этот код, и вы увидите что анализатор заимствований разрешает такой код; он скомпилирует и напечатает The longest string is long string is long.

Далее, давайте попробуем пример, который показывает, что время жизни ссылки result должно быть меньшим временем жизни одного из двух аргументов. Мы переместим объявление переменной result наружу из внутренней области видимости, но оставим присвоение значения переменной result в области видимости string2. Затем мы переместим println!, который использует result за пределы внутренней области видимости, после того как внутренняя область видимости закончилась. Код в листинге 10-24 не будет компилироваться.

Файл: src/main.rs

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Листинг 10-24: Попытка использования переменной result после выхода string2 за пределы области видимости

Когда мы попытаемся скомпилировать этот код, мы получим ошибку:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
 --> src/main.rs:6:44
  |
6 |         result = longest(string1.as_str(), string2.as_str());
  |                                            ^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `string2` dropped here while still borrowed
8 |     println!("The longest string is {}", result);
  |                                          ------ borrow later used here

error: aborting due to previous error

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

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

Эта ошибка говорит о том, что если мы хотим использовать result в println!, переменная string2 должна бы быть действительной до конца внешней области видимости. Rust знает об этом, потому что мы аннотировали параметры функции и её возвращаемое значение одинаковым временем жизни 'a.

Как люди, мы можем увидеть, что string1 живёт дольше, чем string2 и следовательно, result будет содержать ссылку на string1. Поскольку string1 ещё не вышла из области видимости, ссылка на string1 будет все ещё действительной в выражении println!. Однако компилятор не видит, что ссылка действительная в этом случае. Мы сказали Rust, что время жизни ссылки, возвращаемой из функции longest, равняется меньшему из времён жизни переданных в неё ссылок. Таким образом, проверка заимствования запрещает код в листинге 10-24, как возможно имеющий недействительную ссылку.

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

Мышление в терминах времён жизни

Правильный способ определения времён жизни зависит от того, что функция делает. Например, если мы изменим реализацию функции longest таким образом, чтобы она всегда возвращала свой первый аргумент вместо самого длинного среза строки, то и не придётся указывать время жизни для параметра y. Этот код компилируется:

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

В этом примере мы указали параметр времени жизни 'a для параметра x и возвращаемого значения, но не для параметра y, поскольку параметр y никак не соотносится с параметром x и возвращаемым значением.

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

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

Здесь, несмотря на то, что мы указали параметр времени жизни 'a для возвращаемого типа, реализация не будет скомпилирована, потому что возвращаемое значение времени жизни совсем не связано со временем жизни параметров. Получаемое сообщение об ошибке:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `result` is borrowed here

error: aborting due to previous error

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

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

Проблема заключается в том, что result выходит за область видимости и очищается в конце функции longest. Мы также пытаемся вернуть ссылку на result из функции. Мы не можем указать параметры времени жизни, которые могли бы изменить недействительную ссылку, а Rust не позволит нам создать недействительную ссылку. В этом случае лучшим решением будет вернуть данные во владение вызывающей функции, а не ссылку: так вызывающая функция понесёт ответственность за очистку полученного в её распоряжение значения.

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

Определение времён жизни при объявлении структур

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

Файл: src/main.rs

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Листинг 10-25. Структура, которая содержит ссылку, поэтому её объявление требует аннотации времени жизни

У структуры имеется одно поле part, хранящее ссылку на срез строки. Как в случае с обобщёнными типами данных, объявляется имя обобщённого параметра времени жизни внутри угловых скобок после имени структуры, чтобы иметь возможность использовать его внутри тела определения структуры. Данная аннотация означает, что экземпляр ImportantExcerpt не может пережить ссылку, которую он содержит в своём поле part.

Функция main здесь создаёт экземпляр структуры ImportantExcerpt, который содержит ссылку на первое предложение типа String принадлежащее переменной novel. Данные в novel существуют до создания экземпляра ImportantExcerpt. Кроме того, novel не выходит из области видимости до тех пор, пока ImportantExcerpt выходит за область видимости, поэтому ссылка в внутри экземпляра ImportantExcerpt является действительной.

Правила неявного выведения времени жизни

Вы изучили, что у каждой ссылки есть время жизни и что нужно указывать параметры времени жизни для функций или структур, которые используют ссылки. Однако в Главе 4 у нас была функция в листинге 4-9, которая снова показана в листинге 10-26, где код собран без аннотаций времени жизни.

Файл: src/lib.rs

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Листинг 10-26: Функция, которую мы определили в листинге 4-9 компилируется без описания времени жизни параметров, несмотря на то, что входной и возвращаемый тип параметров являются ссылками

Причина, по которой этот код компилируется — историческая. В первых (pre-1.0) версиях Rust этот код не скомпилировался бы, поскольку каждой ссылке нужно было явно назначать время жизни. В те времена, сигнатура функции была бы написана примерно так:

fn first_word<'a>(s: &'a str) -> &'a str {

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

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

Шаблоны анализа ссылок, запрограммированные в анализаторе Rust, называются правилами неявного выведения времени жизни. Это не правила, которым должны следовать программисты; а набор частных случаев, которые рассмотрит компилятор и если ваш код попадает в эти случаи, вам не нужно будет указывать время жизни явно.

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

Времена жизни параметров функции или метода называются временем жизни ввода, а времена жизни возвращаемых значений называются временем жизни вывода.

Компилятор использует три правила, чтобы выяснить времена жизни имеющиеся у ссылок, когда нет явных аннотаций. Первое правило относится ко времени жизни ввода, второе и третье правила применяются ко временам жизни вывода. Если компилятор доходит до конца проверки трёх правил и всё ещё есть ссылки для которых он не может выяснить время жизни, то компилятор остановится с ошибкой. Эти правила применяются к объявлениям fn, а также impl блоков.

Первое правило говорит, что каждый параметр являющийся ссылкой, получает свой собственный параметр времени жизни. Другими словами, функция с одним параметром получит один параметр времени жизни: fn foo<'a>(x: &'a i32); функция с двумя аргументами получит два различных параметра времени жизни: fn foo<'a, 'b>(x: &'a i32, y: &'b i32), и так далее.

Второе правило говорит, что если существует точно один входной параметр времени жизни, то его время жизни назначается всем выходным параметрам: fn foo<'a>(x: &'a i32) -> &'a i32.

Третье правило о том, что если есть множество входных параметров времени жизни, но один из них является ссылкой &self или &mut self при условии что эта функция является методом структуры или перечисления, то время жизни self назначается временем жизни всем выходным параметрам метода. Это третье правило делает методы намного приятнее для чтения и записи, потому что требуется меньше символов.

Давайте представим, что мы компилятор и применим эти правила, чтобы вывести времена жизни ссылок в сигнатуре функции first_word листинга 10-26. Сигнатура этой функции начинается без объявления времён жизни ссылок:

fn first_word(s: &str) -> &str {

Теперь мы (в качестве компилятора) применим первое правило, утверждающее, что каждый параметр функции получает своё собственное время жизни. Как обычно, мы собираемся назвать его 'a и сигнатура выглядит так:

fn first_word<'a>(s: &'a str) -> &str {

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

fn first_word<'a>(s: &'a str) -> &'a str {

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

Давайте рассмотрим ещё один пример: заголовок функции longest, в котором не было параметров времени жизни в начале работы с листингом 10-21:

fn longest(x: &str, y: &str) -> &str {

Применим первое правило: каждому параметру назначается собственное время жизни. На этот раз у функции есть два параметра, поэтому есть два времени жизни:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

Видно, что второе правило не применимо, потому что в сигнатуре указано больше одного входного параметра. Третье правило также не применимо, так как longest — функция, а не метод, следовательно, в ней нет параметра self. Итак, мы прошли все три правила, но так и не смогли вычислить время жизни выходного параметра. Вот почему мы получили ошибку при попытке скомпилировать код листинга 10-21: компилятор работал по правилам неявного выведения времён жизни, но не мог выяснить все времена жизни ссылок в сигнатуре.

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

Аннотация времён жизни в определении методов

Когда мы реализуем методы для структур с временами жизни, синтаксис аннотаций снова схож с аннотациями обобщённых типов данных, как было показано в листинге 10-11. Место объявления времён жизни зависит от того, с чем оно связано — с полем структуры или с аргументами методов и возвращаемыми значениями.

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

В сигнатурах методов внутри блока impl ссылки могут быть привязаны ко времени жизни ссылок в полях структуры или могут быть независимыми. Вдобавок, правила неявного выведения времён жизни часто делают так, что аннотации переменных времён жизни являются необязательными в сигнатурах методов. Рассмотрим несколько примеров использования структуры с названием ImportantExcerpt, которую мы определили в листинге 10-25.

Сначала, воспользуемся методом с именем level где входной параметр является ссылкой на self, а возвращаемое значение i32, не является ссылкой:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Объявление параметра времени жизни находится после impl и его использование после типа структуры является обязательным, но нам не нужно аннотировать время жизни ссылки у self, благодаря первому правилу неявного выведения времён жизни.

Пример применения третьего правила неявного выведения времён жизни:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

В этом методе имеется два входных параметра, поэтому Rust применят первое правило и назначает обоим параметрам &self и announcement собственные времена жизни. Далее, поскольку один из параметров является &self, то возвращаемое значение получает время жизни переменой &self и все времена жизни выведены.

Статическое время жизни

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


#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

Содержание этой строки сохраняется внутри бинарного файла вашей программы и всегда доступно для использования. Следовательно, время жизни всех строковых литералов равно 'static.

Сообщения компилятора об ошибках в качестве решения проблемы могут предлагать вам использовать 'static. Но прежде чем указывать время жизни для ссылки как 'static, подумайте, должна ли данная ссылка всегда быть доступна во время всей работы программы. Большинство таких проблем появляются при попытках создания недействительных ссылок или несовпадения времён жизни. В таких случаях, она может быть решена без указания статического времени жизни 'static.

Обобщённые типы параметров, ограничения типажей и время жизни вместе

Давайте кратко рассмотрим синтаксис задания параметров обобщённых типов, ограничений типажа и времён жизни в одной функции:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {}", result);
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Это функция longest из листинга 10-22, возвращающая наибольший срез из двух строк. Но она имеет дополнительный аргумент ann обобщённого типа T, который может быть заполнен любым типом реализующим типаж Display, как указано в выражении where. Этот дополнительный параметр будет напечатан до того, как функция сравнит длины срезов строк, поэтому необходимо ограничение типажа Display. Поскольку время жизни является обобщённым типом, то объявления параметра времени жизни 'a и параметра обобщённого типа T помещаются в один и тот же список внутри угловых скобок после имени функции.

Итоги

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

Верите или нет, но в рамках этой темы всё есть ещё чему поучиться: в Главе 17 обсуждаются типажи-объекты, что является ещё одним способом использовать типажи. Существуют также более сложные сценарии с аннотациями времени жизни, которые вам понадобятся только в очень сложных случаях; для этого вам следует прочитать Rust Reference. Далее вы узнаете, как писать тесты на Rust, чтобы убедиться, что ваш код работает так, как должен.

Написание автоматизированных тестов

В своём эссе 1972 года “The Humble Programmer,” Edsger W. Dijkstra сказал, что «Тестирование программы может быть очень эффективным способом показать наличие ошибок, но это безнадёжно неадекватно для показа их отсутствия». Это не значит, что мы не должны пытаться тестировать столько, сколько мы можем!

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

В качестве примера, скажем, мы пишем функцию с именем add_two которая добавляет 2 к любому числу передаваемому в неё. Сигнатура этой функции принимает параметром целое число и возвращает в результате целое число. Когда мы реализуем и компилируем такую функцию, Rust выполняет все проверки типов и заимствований про которые вы уже узнали, чтобы убедиться, например, что мы не передаём значение String или неверную ссылку в эту функцию. Но Rust не может проверить, что данная функция будет делать именно то, что мы намереваемся получить, что она возвращает входной параметр плюс 2, а не скажем, параметр плюс 10 или параметр минус 50! Это то, где тесты приходят на помощь.

Мы можем написать тесты, которые утверждают, например, что когда мы передаём 3 в функцию add_two, возвращаемое значение будет 5. Мы можем запускать эти тесты всякий раз, когда мы вносим изменения в наш код, чтобы убедиться, что любое существующее правильное поведение не изменилось.

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

Как писать тесты

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

  1. Установка любых необходимых данных или состояния.
  2. Запуск кода, который вы хотите проверить.
  3. Утверждение, что результаты являются теми, которые вы ожидаете.

Давайте рассмотрим функции предоставляемые в Rust специально для написания тестов, которые выполнят все эти действия, включая атрибут test, несколько макросов и атрибут should_panic .

Структура тестирующей функции

В простейшем случае в Rust тест - это функция, аннотированная атрибутом test. Атрибуты представляют собой метаданные о фрагментах кода Rust; один из примеров атрибут derive, который мы использовали со структурами в главе 5. Чтобы изменить функцию в тестирующую функцию добавьте #[test] в строку перед fn . Когда вы запускаете тесты командой cargo test, Rust создаёт бинарный модуль выполняющий функции аннотированные атрибутом test и сообщающий о том, прошла успешно или не прошла каждая тестирующая функция.

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

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

Давайте создадим новый проект библиотеки под названием adder :

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

Содержимое файла src/lib.rs вашей библиотеки adder должно выглядеть как в листинге 11-1.

Файл: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

Листинг 11-1: Тестовый модуль и функция автоматически генерируемая с помощью команды cargo new

Сейчас проигнорируем первые две строчки кода и сосредоточимся на функции, чтобы увидеть как она работает. Обратите внимание на синтаксис аннотации #[test] перед ключевым словом fn. Этот атрибут сообщает компилятору, что это является заголовком тестирующей функции, так что функционал запускающий тесты на выполнение теперь знает, что это тестирующая функция. Также в составе модуля тестов tests могут быть вспомогательные функции, помогающие настроить и выполнить общие подготовительные операции, поэтому специальная аннотация важна для указания объявления функций тестами с использованием атрибута #[test].

Тело функции использует макрос assert_eq!, чтобы утверждать, что 2 + 2 равно 4. Это утверждение служит примером формата для типичного теста. Давайте запустим, чтобы увидеть, что этот тест проходит.

Команда cargo test выполнит все тесты в выбранном проекте и сообщит о результатах как в листинге 11-2:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Листинг 11-2: Вывод информации о работе автоматически сгенерированных тестов

Cargo скомпилировал и выполнил тест. После строк Compiling, Finished и Running мы видим строку running 1 test. Следующая строка показывает имя созданной тест функции с названием it_works и результат её выполнения - ok. Далее вы видите обобщённую информацию о работе всех тестов. Текст test result: ok. означает, что все тесты пройдены успешно и часть вывода 1 passed; 0 failed сообщает общее количество тестов, которые прошли или были ошибочными.

Поскольку у нас нет тестов, которые мы пометили как игнорируемые, в сводке отображается 0 ignored. Мы также не отфильтровывали тесты для выполнения, поэтому конец сводки пишет 0 filtered out. Мы поговорим про игнорирование и фильтрацию тестов в следующем разделе "Контролирование хода выполнения тестов".

Статистика 0 measured предназначена для тестов производительности. На момент написания этой статьи такие тесты доступны только в ночной сборке Rust. Посмотрите документацию о тестах производительности, чтобы узнать больше.

Следующая часть вывода тестов начинается с Doc-tests adder - это информация о тестах в документации. У нас пока нет тестов документации, но Rust может компилировать любые примеры кода, которые находятся в API документации. Такая возможность помогает поддерживать документацию и код в синхронизированном состоянии. Мы поговорим о написании тестов документации в секции "Комментарии документации как тесты" Главы 14. Пока просто проигнорируем часть Doc-tests вывода.

Давайте поменяем название нашего теста и посмотрим что же измениться в строке вывода. Назовём нашу функцию it_works другим именем - exploration:

Файл: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }
}

Снова выполним команду cargo test. Вывод показывает наименование нашей тест функции - exploration вместо it_works:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Добавим ещё один тест, но в этот раз специально сделаем так, чтобы этот новый тест не отработал. Тест терпит неудачу, когда что-то паникует в тестируемой функции. Каждый тест запускается в новом потоке и когда главный поток видит, что тестовый поток упал, то помечает тест как завершившийся аварийно. Мы говорили о простейшем способе вызвать панику в главе 9, используя для этого известный макрос panic!. Введём код тест функции another, как в файле src/lib.rs из листинга 11-3.

Файл: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

Листинг 11-3: Добавление второго теста. Второй тест вызывает макрос panic!

Запустим команду cargo test. Вывод результатов показан в листинге 11-4, который сообщает, что тест exploration пройден, а another нет:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----
thread 'main' panicked at 'Make this test fail', src/lib.rs:10:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Листинг 11-4: Результаты выполнения тестов, когда один пройден, а второй нет

Вместо ok, строка test tests::another сообщает FAILED. У нас есть два новых раздела между результатами и итогами. Первый раздел показывает детальную причину ошибки каждого теста. В данном случае тест another не сработал, потому что panicked at 'Make this test fail', произошло в строке 10 файла src/lib.rs. В следующем разделе перечисляют имена всех не пройденных тестов, что удобно, когда тестов очень много и есть много деталей про аварийное завершение. Мы можем использовать имя не пройденного теста для его дальнейшей отладки; мы больше поговорим о способах запуска тестов в разделе "Контролирование хода выполнения тестов".

Итоговая строка отображается в конце: общий результат нашего тестирования FAILED. У нас один тест пройден и один тест завершён аварийно.

Теперь, когда вы увидели, как выглядят результаты теста при разных сценариях, давайте рассмотрим другие макросы полезные в тестах, кроме panic!.

Проверка результатов с помощью макроса assert!

Макрос assert! доступен из стандартной библиотеки и является удобным, когда вы хотите проверить что некоторое условие в тесте вычисляется в значение true. Внутри макроса assert! переданный аргумент вычисляется в логическое значение. Если оно true, то assert! в тесте ничего не делает и он считается пройденным. Если же значение вычисляется в false, то макрос assert! вызывает макрос panic!, что делает тест аварийным. Использование макроса assert! помогает проверить, что код функционирует как ожидалось.

В главе 5, листинга 5-15, мы использовали структуру Rectangle и метод can_hold, который повторён в листинге 11-5. Давайте поместим этот код в файл src/lib.rs и напишем несколько тестов для него используя assert! макрос.

Файл: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

Листинг 11-5. Использование структуры Rectangle и её метода can_hold из главы 5

Метод can_hold возвращает логическое значение, что означает, что она является идеальным вариантом использования в макросе assert!. В листинге 11-6 мы пишем тест, который выполняет метод can_hold путём создания экземпляра Rectangle шириной 8 и высотой 7 и убеждаемся, что он может содержать другой экземпляр Rectangle имеющий ширину 5 и высоту 1.

Файл: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}

Листинг 11-6: Теста для метода can_hold, который проверяет что больший прямоугольник действительно может содержать меньший

Также, в модуле tests обратите внимание на новую добавленную строку use super::*;. Модуль tests является обычным и подчиняется тем же правилам видимости, которые мы обсуждали в главе 7 "Пути для ссылки на элементы внутри дерева модуля". Так как этот модуль tests является внутренним, нужно подключить тестируемый код из внешнего модуля в область видимости внутреннего модуля с тестами. Для этого используется глобальное подключение, так что все что определено во внешнем модуле становится доступным внутри tests модуля.

Мы назвали наш тест larger_can_hold_smaller и создали два нужных экземпляра Rectangle. Затем вызвали макрос assert! и передали результат вызова larger.can_hold(&smaller) в него. Это выражение должно возвращать true, поэтому наш тест должен пройти. Давайте выясним!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

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

Файл: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Поскольку правильный результат функции can_hold в этом случае false, то мы должны инвертировать этот результат, прежде чем передадим его в assert! макро. Как результат, наш тест пройдёт, если can_hold вернёт false:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

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

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Запуск тестов теперь производит следующее:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----
thread 'main' panicked at 'assertion failed: larger.can_hold(&smaller)', src/lib.rs:28:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Наши тесты нашли ошибку! Так как в тесте larger.width равно 8 и smaller.width равно 5 сравнение ширины в методе can_hold возвращает результат false, поскольку число 8 не меньше чем 5.

Проверка на равенство с помощью макросов assert_eq! и assert_ne!

Общим способом проверки функциональности является использование сравнения результата тестируемого кода и ожидаемого значения, чтобы убедиться в их равенстве. Для этого можно использовать макрос assert!, передавая ему выражение с использованием оператора ==. Важно также знать, что кроме этого стандартная библиотека предлагает пару макросов assert_eq! и assert_ne!, чтобы сделать тестирование более удобным. Эти макросы сравнивают два аргумента на равенство или неравенство соответственно. Макросы также печатают два значения входных параметров, если тест завершился ошибкой, что позволяет легче увидеть почему тест ошибочен. Противоположно этому, макрос assert! может только отобразить, что он вычислил значение false для выражения ==, но не значения, которые привели к результату false.

В листинге 11-7, мы напишем функцию add_two, которая прибавляет к входному параметру 2 и возвращает значение. Затем, протестируем эту функцию с помощью макроса assert_eq!:

Файл: src/lib.rs

pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

Листинг 11-7: Тестирование функции add_two, используя макрос assert_eq!

Проверим, что тесты проходят!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Первый аргумент, который мы передаём в макрос assert_eq! число 4 чей результат вызова равен add_two(2) . Строка для этого теста - test tests::it_adds_two ... ok , а текст ok означает, что наш тест пройден!

Давайте введём ошибку в код, чтобы увидеть, как она выглядит, когда тест, который использует assert_eq! завершается ошибкой. Измените реализацию функции add_two, чтобы добавлять 3 :

pub fn add_two(a: i32) -> i32 {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

Попробуем выполнить данный тест ещё раз:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Наш тест нашёл ошибку! Тест it_adds_two не выполнился, отображается сообщение assertion failed: `(left == right)` и показывает, что left было 4, а right было 5. Это сообщение полезно и помогает начать отладку: это означает left аргумент assert_eq! имел значение 4, но right аргумент для вызова add_two(2) был со значением 5.

Обратите внимание, что в некоторых языках (таких как Java) в библиотеках кода для тестирования принято именовать входные параметры проверочных функций как "ожидаемое" (expected) и "фактическое" (actual). В Rust приняты следующие обозначения left и right соответственно, а порядок в котором определяются ожидаемое значение и производимое тестируемым кодом значение не имеют значения. Мы могли бы написать выражение в тесте как assert_eq!(add_two(2), 4), что приведёт к отображаемому сообщению об ошибке assertion failed: `(left == right)`, слева left было бы 5, а справа right было бы 4.

Макрос assert_ne! сработает успешно, если входные параметры не равны друг другу и завершится с ошибкой, если значения равны. Этот макрос наиболее полезен в тех случаях, когда мы не знаем заранее, каким значение будет, но знаем точно, каким оно не может быть. К примеру, если тестируется функция, которая гарантировано изменяет входные данные определённым образом, но способ изменения входного параметра зависит от дня недели, в который запускаются тесты, что лучший способ проверить правильность работы такой функции - это сравнить и убедиться, что выходное значение функции не должно быть равным входному значению.

С своей работе макросы assert_eq! и assert_ne! неявным образом используют операторы == и != соответственно. Когда проверка не срабатывает, макросы печатают значения аргументов с помощью отладочного форматирования и это означает, что значения сравниваемых аргументов должны реализовать типажи PartialEq и Debug. Все примитивные и большая часть типов стандартной библиотеки Rust реализуют эти типажи. Для структур и перечислений, которые вы реализуете сами будет необходимо реализовать типаж PartialEq для сравнения значений на равенство или неравенство. Для печати отладочной информации в виде сообщений в строку вывода консоли необходимо реализовать типаж Debug. Так как оба типажа являются выводимыми типажами, как упоминалось в листинге 5-12 главы 5, то эти типажи можно реализовать добавив аннотацию #[derive(PartialEq, Debug)] к определению структуры или перечисления. Смотрите больше деталей в Appendix C "Выводимые типажи" про эти и другие выводимые типажи.

Создание сообщений об ошибках

Также можно добавить пользовательское сообщение для печати в сообщении об ошибке теста как дополнительный аргумент макросов assert!, assert_eq!, and assert_ne!. Любые аргументы, указанные после одного обязательного аргумента в assert! или после двух обязательных аргументов в assert_eq! и assert_ne! передаются в макрос format! (он обсуждается в разделе "Конкатенация с помощью оператора + или макроса format!" главы 8), так что вы можете передать форматированную строку, которая содержит символы {} для заполнителей и значения, заменяющие эти заполнители. Пользовательские сообщения полезны для пояснения, что означает утверждение, когда тест не пройден. У вас будет лучшее представление о том, какая проблема в коде.

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

Файл: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {}!", name)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Требования к этой программе ещё не были согласованы и мы вполне уверены, что текст Hello в начале приветствия ещё изменится. Мы решили, что не хотим обновлять тест при изменении требований, поэтому вместо проверки на точное равенство со значением возвращённым из greeting, мы просто будем проверять, что вывод содержит текст из входного параметра.

Давайте внесём ошибку в этот код, изменив greeting так, чтобы оно не включало name и увидим, как выглядит сбой этого теста:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Запуск этого теста выводит следующее:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'main' panicked at 'assertion failed: result.contains(\"Carol\")', src/lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Сообщение содержит лишь информацию о том что сравнение не было успешным и в какой строке это произошло. В данном случае, более полезный текст сообщения был бы, если бы также выводилось значение из функции greeting. Изменим тестирующую функцию так, чтобы выводились пользовательское сообщение форматированное строкой с заменителем и фактическими данными из кода greeting :

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{}`",
            result
        );
    }
}

После того, как выполним тест ещё раз мы получим подробное сообщение об ошибке:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'main' panicked at 'Greeting did not contain name, value was `Hello!`', src/lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Мы можем увидеть значение, которое мы на самом деле получили в тестовом выводе, что поможет нам отлаживать произошедшее, а не то, что мы ожидали.

Проверка с помощью макроса should_panic

В дополнение к проверке того, что наш код возвращает правильные, ожидаемые значения, важным также является проверить, что наш код обрабатывает ошибки, которые мы ожидаем. Например, рассмотрим тип Guess который мы создали в главе 9, листинга 9-10. Другой код, который использует Guess зависит от гарантии того, что Guess экземпляры будут содержать значения только от 1 до 100. Мы можем написать тест, который гарантирует, что попытка создать экземпляр Guess со значением вне этого диапазона вызывает панику.

Реализуем это с помощью другого атрибута тест функции #[should_panic]. Этот атрибут сообщает системе тестирования, что тест проходит, когда метод генерирует ошибку. Если ошибка не генерируется - тест считается не пройденным.

Листинг 11-8 показывает тест, который проверяет, что условия ошибки Guess::new произойдут, когда мы их ожидаем их.

Файл: src/lib.rs

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Листинг 11-9: Тестирования случая, когда условие вызовет выполнение макроса panic! содержащего определённое сообщение об ошибке

Атрибут #[should_panic] следует после #[test] и до объявления текстовой функции. Посмотрим на вывод результата, когда тест проходит:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Выглядит хорошо! Теперь давайте внесём ошибку в наш код, убрав условие о том, что функция new будет паниковать если значение больше 100:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Когда мы запустим тест в листинге 11-8, он потерпит неудачу:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Мы получаем не очень полезное сообщение в этом случае, но когда мы смотрим на тестирующую функцию, мы видим, что она #[should_panic]. Аварийное выполнение, которое мы получили означает, что код в тестирующей функции не вызвал паники.

Тесты, которые используют should_panic могут быть неточными, потому что они только указывают, что код вызвал панику. Тест с атрибутом should_panic пройдёт, даже если тест паникует по причине, отличной от той, которую мы ожидали. Чтобы сделать тесты с should_panic более точными, мы можем добавить необязательный параметр expected для атрибута should_panic. Такая детализация теста позволит удостовериться, что сообщение об ошибке содержит предоставленный текст. Например, рассмотрим модифицированный код для Guess в листинге 11-9, где new функция паникует с различными сообщениями в зависимости от того, является ли значение слишком маленьким или слишком большим.

Файл: src/lib.rs

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {}.",
                value
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {}.",
                value
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "Guess value must be less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Листинг 11-9: Тестирования случая, когда условие вызовет выполнение макроса panic! содержащего определённое сообщение об ошибке

Этот тест пройдёт, потому что значение, которое мы поместили для should_panic в параметр атрибута expected является подстрокой сообщения, с которым функция Guess::new вызывает панику. Мы могли бы указать полное, ожидаемое сообщение для паники, в этом случае это будет Guess value must be less than or equal to 100, got 200. То что вы выберите для указания как ожидаемого параметра у should_panic зависит от того, какая часть сообщения о панике уникальна или динамична, насколько вы хотите, чтобы ваш тест был точным. В этом случае достаточно подстроки из сообщения паники, чтобы гарантировать выполнение кода в тестовой функции else if value > 100 .

Чтобы увидеть, что происходит, когда тест should_panic неуспешно завершается с сообщением expected, давайте снова внесём ошибку в наш код, поменяв местами if value < 1 и else if value > 100 блоки:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {}.",
                value
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {}.",
                value
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "Guess value must be less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

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

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 ... FAILED

failures:

---- tests::greater_than_100 stdout ----
thread 'main' panicked at 'Guess value must be greater than or equal to 1, got 200.', src/lib.rs:13:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"Guess value must be less than or equal to 100"`

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Сообщение об ошибке указывает, что этот тест действительно вызвал панику, как мы и ожидали, но сообщение о панике не включено ожидаемую строку 'Guess value must be less than or equal to 100'. Сообщение о панике, которое мы получили в этом случае, было Guess value must be greater than or equal to 1, got 200. Теперь мы можем начать выяснение, где ошибка!

Использование Result<T, E> в тестах

Пока что мы написали тесты, которые паникуют, когда терпят неудачу. Мы также можем написать тесты которые используют Result<T, E>! Вот тест из листинга 11-1, переписанный с использованием Result<T, E> и возвращающий Err вместо паники:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

Функция it_works теперь имеет возвращаемый тип Result<(), String>. В теле функции, вместо вызова assert_eq! макроса, мы возвращаем Ok(()) когда тест успешно выполнен и Err со String внутри, когда тест не проходит.

Написание тестов так, чтобы они возвращали Result<T, E> позволяет использовать оператор "вопросительный знак" в теле тестов, который может быть удобным способом писать тесты, которые должны выполниться не успешно, если какая-либо операция внутри них возвращает вариант ошибки Err.

Нельзя использовать аннотацию #[should_panic] в тестах, которые используют Result<T, E>. Вместо этого вы должны вернуть непосредственно значение Err, когда тест должен быть неуспешен.

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

Контролирование хода выполнения тестов

Подобно тому, как cargo run компилирует ваш код и затем запускает полученный двоичный файл, cargo test компилирует ваш код в тестовом режиме и запускает полученный тестовый двоичный файл. Вы можете указать параметры командной строки, чтобы изменить поведение cargo test по умолчанию. Например, по умолчанию двоичный файл, созданный с помощью cargo test запускает все тесты параллельно и фиксирует выходные данные, созданные во время тестовых запусков, предотвращая отображение выходных данных и упрощая чтение выходных данных, связанных с результатами тестирования.

Опции команды cargo test могут быть добавлены после, опции для тестов должны устанавливаться дополнительно (следовать далее). Для разделения этих двух типов аргументов используется разделитель --. Чтобы узнать подробнее о доступных опциях команды cargo test - используйте опцию --help. Для того, чтобы узнать о доступных опциях, непосредственно для тестов, используйте команду cargo test -- --help. Обратите внимание, что данную команду необходимо запускать внутри cargo-проекта (пакета).

Выполнение тестов параллельно или последовательно

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

Например, когда тесты создают в одном и том же месте на диске файл с одним и тем же названием, читают из него данные, записывают их - вероятность ошибки в работе таких тестов (из-за конкурирования доступа к ресурсу, некорректных данных в файле) весьма высока. Решением будет использование уникальных имён создаваемых и используемых файлов каждым тестом в отдельности, либо выполнение таких тестов последовательно.

Если вы не хотите запускать тесты параллельно или хотите более детальный контроль над количеством используемых потоков, можно установить флаг --test-threads и то количество потоков, которое вы хотите использовать для теста. Взгляните на следующий пример:

$ cargo test -- --test-threads=1

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

Демонстрация результатов работы функции

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

Например, в коде (11-10) функция выводит значение параметра с поясняющим текстовым сообщением, а также возвращает целочисленное константное значение 10. Далее следует тест, который имеет правильный входной параметр и тест, который имеет ошибочный входной параметр:

Файл: src/lib.rs

fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {}", a);
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(10, value);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(5, value);
    }
}

Listing 11-10: Тест функции, которая использует макрос println!

Результат вывода на консоль команды cargo test:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `10`', src/lib.rs:19:9
note: run with `RUST_BACKTRACE=1` environment variable to display a ba