143 16
Russian (Old) Pages 528 [526] Year 2023
Санкт-Петербург «БХВ-Петербург» 2023
УДК 004.4 ББК 32.973.26-02 М15 М15
Макнамара Т. Rust в действии: Пер. с англ. — СПб.: БХВ-Петербург, 2023. — 528 с.: ил. ISBN 978-5-9775-1166-7 Книга о прикладных аспектах языка программирования Rust, описывающая внутреннее устройство языка и сферы его использования. Rust рассматривается как современное дополнение для С при программировании ядра ОС и при системном программировании, а также как низкоуровневый скоростной язык, обеспечивающий максимальную производительность. Объяснены тонкости работы с процессором, многопоточное программирование, работа с памятью, а также взаимодействие с Linux. Изложенный материал позволяет как писать современные приложения на Rust с нуля, так и внедрять Rust в сложившуюся базу кода.
Книга ориентирована на специалистов по C, Linux, системному программированию и на всех, кто желает освоить Rust и сразу приступить к работе с ним. УДК 004.4 ББК 32.973.26-02
Группа подготовки издания: Руководитель проекта Зав. редакцией Перевод с английского Редактор Компьютерная верстка Оформление обложки
Олег Сивченко Людмила Гауль Николая Вильчинского Дарья Кустовская Натальи Смирновой Зои Канторович
Original English language edition published by Manning Publications. Copyright (c) 2021 by Manning Publications. Russian-language edition copyright (c) 2022 by BHV. All rights reserved. Оригинальное издание на английском языке опубликовано Manning Publications. © 2021 Manning Publications. Издание на русском языке © 2022 ООО «БХВ». Все права защищены.
"БХВ-Петербург", 191036, Санкт-Петербург, Гончарная ул., 20.
ISBN 978-1-61729-455-6 (англ.) ISBN 978-5-9775-1166-7 (рус.)
© Manning Publications, 2021 © Перевод на русский язык, оформление. ООО "БХВ-Петербург", ООО "БХВ", 2022
Оглавление
Предисловие ................................................................................................................... 15 Благодарности ............................................................................................................... 17 О книге ............................................................................................................................ 19 Кому следует прочитать эту книгу........................................................................... 19 Организация книги: дорожная карта........................................................................ 19 О программном коде.................................................................................................. 21 Дискуссионный форум liveBook .............................................................................. 21 Другие онлайн-ресурсы............................................................................................. 22 Об авторе .................................................................................................................... 22 Об иллюстрации на обложке книги ......................................................................... 22 Глава 1. Введение в Rust.............................................................................................. 23 1.1. Где используется Rust?............................................................................................ 24 1.2. С какой целью была написана книга «Rust в действии» ...................................... 25 1.3. Вкус языка ................................................................................................................ 26 1.3.1. Хитрый путь к «Hello, world!»...................................................................... 27 1.3.2. Ваша первая программа на Rust ................................................................... 29 1.4. Загрузка исходного кода книги............................................................................... 30 1.5. На что похож Rust? .................................................................................................. 31 1.6. В чем заключается особенность Rust? ................................................................... 34 1.6.1. Цель создания Rust: безопасность................................................................ 36 1.6.2. Цель создания Rust: производительность.................................................... 41 1.6.3. Цель создания Rust: управляемость ............................................................. 43 1.7. Особые возможности Rust....................................................................................... 45 1.7.1. Достижение высокой производительности ................................................. 45 1.7.2. Многопоточное выполнение программ ....................................................... 46 1.7.3. Достижение эффективной работы с памятью ............................................. 46
6
Оглавление
1.8. Недостатки Rust........................................................................................................ 46 1.8.1. Циклические структуры данных .................................................................. 46 1.8.2. Время, затрачиваемое на компиляцию ........................................................ 46 1.8.3. Строгость ........................................................................................................ 47 1.8.4. Объем языка ................................................................................................... 47 1.8.5. Излишний ажиотаж ....................................................................................... 47 1.9. Примеры использования TLS-безопасности ......................................................... 47 1.9.1. Heartbleed ........................................................................................................ 48 1.9.2. Goto fail; .......................................................................................................... 48 1.10. Для чего Rust подходит лучше всего?.................................................................. 50 1.10.1. В утилитах командной строки .................................................................... 50 1.10.2. В обработке данных..................................................................................... 51 1.10.3. В расширяемых приложениях .................................................................... 51 1.10.4. В средах с ограниченными ресурсами ....................................................... 51 1.10.5. В серверных приложениях .......................................................................... 52 1.10.6. В приложениях для ПК................................................................................ 52 1.10.7. В автономном режиме ................................................................................. 52 1.10.8. В мобильных приложениях......................................................................... 53 1.10.9. В веб-режиме................................................................................................ 53 1.10.10. В системном программировании.............................................................. 53 1.11. Скрытая фишка Rust: его сообщество.................................................................. 54 1.12. Разговорник по Rust ............................................................................................... 54 Резюме.............................................................................................................................. 54
ЧАСТЬ I. ОСОБЕННОСТИ ЯЗЫКА RUST......................................................................... 57 Глава 2. Основы языка ................................................................................................ 59 2.1. Создание работоспособной программы................................................................. 60 2.1.1. Компиляция одиночных файлов с помощью утилиты rustc ...................... 60 2.1.2. Компиляция Rust-проектов с использованием cargo.................................. 61 2.2. Взгляд на синтаксис Rust......................................................................................... 62 2.2.1. Определение переменных и вызов функций ............................................... 63 2.3. Числа ......................................................................................................................... 64 2.3.1. Целые и десятичные (с плавающей точкой) числа ..................................... 65 2.3.2. Записи целых чисел с основанием 2, 8 и 16 ................................................ 66 2.3.3. Сравнение чисел ............................................................................................ 68 2.3.4. Рациональные, комплексные числа и другие числовые типы ................... 73
Оглавление
7
2.4. Управление ходом выполнения программы.......................................................... 76 2.4.1. For: основной механизм итераций................................................................ 76 2.4.2. Continue: пропуск оставшейся части текущей итерации ........................... 78 2.4.3. While: выполнение цикла, пока не изменится состояние условия ............ 78 2.4.4. Loop: основа для циклических конструкций Rust ...................................... 79 2.4.5. Break: прерывание цикла............................................................................... 80 2.4.6. If, if else и else: условное ветвление ............................................................. 81 2.4.7. Match: соответствие образцу с учетом типов.............................................. 82 2.5. Определение функций ............................................................................................. 84 2.6. Использование указателей ...................................................................................... 85 2.7. Проект: визуализация множества Мандельброта ................................................. 86 2.8. Расширенные определения функций...................................................................... 90 2.8.1. Явные аннотации времени жизни ................................................................ 90 2.8.2. Обобщенные функции................................................................................... 92 2.9. Создание grep-lite ..................................................................................................... 95 2.10. Создание списков с использованием массивов, слайсов и векторов ................ 99 2.10.1. Массивы........................................................................................................ 99 2.10.2. Слайсы ........................................................................................................ 101 2.10.3. Векторы....................................................................................................... 102 2.11. Включение стороннего кода ............................................................................... 104 2.11.2. Создание документации по сторонним контейнерам в локальной среде .................................................................................................. 107 2.11.3. Управление имеющимся в Rust набором инструментальных средств с помощью rustup ..................................................................................... 107 2.12. Поддержка аргументов командной строки........................................................ 108 2.13. Чтение данных из файлов.................................................................................... 110 2.14. Чтение из стандартного устройства ввода stdin ................................................ 113 Резюме............................................................................................................................ 114 Глава 3. Составные типы данных ........................................................................... 117 3.1. Использование простых функций для экспериментов с API ............................. 117 3.2. Моделирование файлов с помощью struct ........................................................... 120 3.3. Добавление методов к структуре struct путем использования блока impl....................................................................................................................... 125 3.3.1. Упрощение создания объектов за счет реализации метода new () .......................................................................................................... 126 3.4. Возвращение сообщений об ошибках .................................................................. 129 3.4.1. Изменение значения известной глобальной переменной ........................ 129 3.4.2. Использование возвращаемого типа Result............................................... 135
8
Оглавление
3.5. Определение и использование перечисления enum............................................ 138 3.5.1. Использование enum для управления внутренним состоянием .............. 141 3.6. Определение общего поведения с помощью типажей ....................................... 143 3.6.1. Создание типажа Read................................................................................. 143 3.6.2. Реализация std::fmt::Display для ваших собственных типов.................... 145 3.7. Выставление своих типов на всеобщее обозрение ............................................. 148 3.7.1. Protecting private data Защита личных данных .......................................... 148 3.8. Создание встроенной документации ваших проектов ....................................... 149 3.8.1. Использование rustdoc для визуализации документов, касающихся одного исходного файла.................................................................. 151 3.8.2. Использование cargo для визуализации документов для контейнера и его зависимостей ..................................................................... 151 Резюме............................................................................................................................ 153 Глава 4. Время жизни, владение и заимствование ............................................... 155 4.1. Реализация имитации наземной станции CubeSat .............................................. 156 4.1.1. Выявление первой проблемы, связанной со временем жизни................. 158 4.1.2. Особое поведение элементарных типов .................................................... 161 4.2. Справочник по рисункам, используемым в этой главе ...................................... 163 4.3. Кто такой владелец? Есть ли у него какие-либо обязанности? ......................... 164 4.4. Как происходит переход владения ....................................................................... 165 4.5. Решение проблем, связанных с владением.......................................................... 167 4.5.1. Если полное владение не требуется, используйте ссылки....................... 170 4.5.2. Сократите количество долгоживущих значений ...................................... 173 4.5.3. Продублируйте значение ............................................................................ 180 4.5.4. Заключите данные в специальные типы .................................................... 184 Резюме............................................................................................................................ 186
ЧАСТЬ II. ДЕМИСТИФИКАЦИЯ СИСТЕМНОГО ПРОГРАММИРОВАНИЯ ................... 189 Глава 5. Углубленное изучение данных ................................................................. 191 5.1. Комбинации битов и типы .................................................................................... 191 5.2. Жизнь целых чисел ................................................................................................ 194 5.2.1. Усвоение порядка следования байтов........................................................ 197 5.3. Представление десятичных чисел ........................................................................ 198 5.4. Числа с плавающей точкой ................................................................................... 199 5.4.1. Взгляд на f32 изнутри.................................................................................. 200 5.4.2. Выделение знакового бита.......................................................................... 201
Оглавление
9
5.4.3. Выделение экспоненты ............................................................................... 202 5.4.4. Выделение мантиссы ................................................................................... 203 5.4.5. Разбиение числа с плавающей точкой на составные части ..................... 205 5.5. Форматы чисел с фиксированной точкой ............................................................ 207 5.6. Генерация случайных вероятностей из случайных байтов................................ 213 5.7. Реализация центрального процессора (CPU), чтобы удостовериться, что функции также являются данными....................................................................... 215 5.7.1. CPU RIA/1: сумматор .................................................................................. 215 5.7.2. Полный листинг кода для CPU RIA/1: сумматор ..................................... 220 5.7.3. CPU RIA/2: мультипликатор....................................................................... 222 5.7.4. CPU RIA/3: блок вызова.............................................................................. 226 5.7.5. CPU 4: добавление всего остального ......................................................... 233 Резюме............................................................................................................................ 233 Глава 6. Память........................................................................................................... 235 6.1. Указатели ................................................................................................................ 235 6.2. Исследование типов ссылок и указателей, имеющихся в Rust.......................... 238 6.2.1. Обычные указатели, используемые в Rust ................................................ 243 6.2.2. Экосистема указателей Rust........................................................................ 246 6.2.3. Строительные блоки интеллектуальных указателей ................................ 248 6.3. Предоставление программам памяти для размещения их данных.................... 249 6.3.1. Стек ............................................................................................................... 250 6.3.2. Куча ............................................................................................................... 252 6.3.3. Что такое динамическое распределение памяти? ..................................... 256 6.3.4. Анализ влияния, оказываемого динамическим выделением памяти...... 264 6.4. Виртуальная память ............................................................................................... 266 6.4.1. История вопроса........................................................................................... 266 6.4.2. Шаг 1. Сканирование процессом собственной памяти ............................ 267 6.4.3. Преобразование виртуальных адресов в физические............................... 271 6.4.4. Шаг 2. Работа с операционной системой для сканирования адресного пространства......................................................................................... 274 6.4.5. Шаг 3. Чтение и запись в память процесса................................................ 277 Резюме............................................................................................................................ 277 Глава 7. Файлы и хранилища................................................................................... 279 7.1. Что такое формат файла? ...................................................................................... 279 7.2. Создание собственных форматов файлов для хранения данных....................... 281 7.2.1. Запись данных на диск с помощью serde и формата bincode................... 281
10
Оглавление
7.3. Реализация клона hexdump.................................................................................... 284 7.4. Файловые операции, проводимые в Rust ............................................................. 288 7.4.1. Открытие файла в Rust и управление его режимом доступности ........... 288 7.4.2. Безопасное взаимодействие с файловой системой с помощью std::fs::Path .............................................................................................................. 289 7.5. Реализация хранилища «ключ-значение» с архитектурой, структурированной по записям и доступной только для добавления ..................... 291 7.5.1. Модель «ключ-значение»............................................................................ 291 7.5.2. Представление actionkv v1: хранилище ключей и значений в памяти с интерфейсом командной строки........................................................ 292 7.6. Actionkv v1: интерфейсный код............................................................................ 293 7.6.1. Настройка продукта условной компиляции .............................................. 296 7.7. Понимание сути actionkv: контейнер libactionkv ................................................ 298 7.7.1. Инициализация структуры ActionKV ........................................................ 298 7.7.2. Работа с отдельно взятой записью ............................................................. 302 7.7.3. Запись многобайтных двоичных данных на диск в гарантированном порядке следования байтов ................................................. 304 7.7.4. Проверка ошибок ввода-вывода с помощью контрольных сумм ........... 306 7.7.5. Вставка в существующую базу данных новой пары «ключ-значение».................................................................................................... 309 7.7.6. Полный код листинга для actionkv............................................................. 310 7.7.7. Работа с ключами и значениями с использованием HashMap и BTreeMap ............................................................................................ 315 7.7.8. Создание HashMap и ее заполнение значениями...................................... 318 7.7.9. Извлечение значений из HashMap и BTreeMap ........................................ 319 7.7.10. Что выбрать: HashMap или BTreeMap? ................................................... 320 7.7.11. Добавление к actionkv v2.0 индекса базы данных .................................. 322 Резюме............................................................................................................................ 326 Глава 8. Работа в сети ................................................................................................ 327 8.1. Все о сетевой работе в семи абзацах .................................................................... 328 8.2. Создание HTTP GET-запроса с использованием reqwest................................... 330 8.3. Типажные объекты................................................................................................. 332 8.3.1. На что способны типажные объекты? ....................................................... 332 8.3.2. Что такое типажные объекты?.................................................................... 333 8.3.3. Создание небольшой ролевой игры: rpg-проект ....................................... 333 8.4. TCP .......................................................................................................................... 337 8.4.1. Что такое номер порта? ............................................................................... 339 8.4.2. Преобразование имени хоста в IP-адрес.................................................... 339
Оглавление
11
8.5. Способы обработки ошибок, наиболее удобные для помещения в библиотеки.................................................................................................................. 347 8.5.1. Проблема: невозможность возвращения нескольких типов ошибок .................................................................................................................... 347 8.5.2. Заключение в оболочку нижестоящих ошибок путем определения нашего собственного типа ошибки................................................ 351 8.5.3. Фокусы с unwrap() и expect() ...................................................................... 358 8.6. MAC-адреса ............................................................................................................ 358 8.6.1. Создание MAC-адресов............................................................................... 360 8.7. Реализация конечных автоматов с помощью перечислений ............................. 362 8.8. Чистый TCP ............................................................................................................ 363 8.9. Создание виртуального сетевого устройства ...................................................... 363 8.10. «Чистый» HTTP.................................................................................................... 365 Резюме............................................................................................................................ 376 Глава 9. Время и хронометраж................................................................................. 377 9.1. Предыстория вопроса ............................................................................................ 378 9.2. Источники времени................................................................................................ 380 9.3. Определения ........................................................................................................... 380 9.4. Кодирование времени ............................................................................................ 382 9.4.1. Представление часовых поясов .................................................................. 383 9.5. clock v0.1.0: учим приложение сообщать о времени .......................................... 383 9.6. clock v0.1.1: форматирование меток времени в соответствии с ISO 8601 и стандартами электронной почты........................................................... 384 9.6.1. Реструктуризация кода clock v0.1.0 с целью более широкой архитектурной поддержки .................................................................................... 385 9.6.2. Форматирование времени ........................................................................... 386 9.6.3. Предоставление полноценного интерфейса командной строки.............. 387 9.6.4. clock v0.1.1: полный проект ........................................................................ 388 9.7. clock v0.1.2: установка времени............................................................................ 392 9.7.1. Общее поведение ......................................................................................... 392 9.7.2. Установка времени для операционных систем, использующих libc................................................................................................. 392 9.7.3. Установка времени в MS Windows ............................................................ 395 9.7.4. clock v0.1.2: листинг полного кода............................................................. 397 9.8. Более совершенные способы обработки ошибок................................................ 401 9.9. clock v0.1.3: вычисление разницы показания часов с показанием протокола сетевого времени — Network Time Protocol (NTP) ................................. 402 9.9.1. Отправка NTP-запросов и интерпретация ответов ................................... 402 9.9.2. Корректировка местного времени по ответу сервера............................... 405
12
Оглавление
9.9.3. Преобразования между представлениями о времени, использующими различные степени точности и эпохи ..................................... 407 9.9.4. clock v0.1.3: листинг полной версии кода ................................................. 409 Резюме............................................................................................................................ 417 Глава 10. Процессы, потоки и контейнеры............................................................ 419 10.1. Безымянные функции .......................................................................................... 420 10.2. Порождение потоков ........................................................................................... 421 10.2.1. Введение в замыкания ............................................................................... 421 10.2.2. Порождение потока ................................................................................... 422 10.2.3. Эффект от порождения нескольких потоков........................................... 423 10.2.4. Эффект от порождения множества потоков............................................ 424 10.2.5. Воспроизведение результатов .................................................................. 426 10.2.6. Совместно используемые переменные .................................................... 430 10.3. Отличие замыканий от функций......................................................................... 433 10.4. Аватары, процедурно генерируемые из многопоточного парсера и генератора кода .......................................................................................................... 434 10.4.1. Как запустить проект render-hex, и как выглядит его предполагаемый вывод ................................................................................... 434 10.4.2. Обзор однопоточной версии render-hex................................................... 435 10.4.3. Порождение потока для каждой логической задачи .............................. 446 10.4.4. Использование пула потоков и очереди задач ........................................ 449 10.5. Конкурентные вычисления и виртуализация задач .......................................... 457 10.5.1. Потоки......................................................................................................... 460 10.5.2. Что такое контекстное переключение?.................................................... 460 10.5.3. Процессы .................................................................................................... 460 10.5.4. WebAssembly .............................................................................................. 461 10.5.5. Контейнеры ................................................................................................ 461 10.5.6. А зачем вообще использовать операционную систему? ........................ 461 Резюме............................................................................................................................ 462 Глава 11. Ядро операционной системы .................................................................. 463 11.1. Оперяющаяся операционная система (FledgeOS) ............................................. 463 11.1.1. Настройка среды разработки под создание ядра операционной системы ................................................................................................................... 463 11.1.2. Проверка среды разработки ...................................................................... 465 11.2. FledgeOS-0: получение хоть чего-то работоспособного .................................. 466 11.2.1. Первая загрузка .......................................................................................... 466 11.2.2. Инструкции по компиляции...................................................................... 468
Оглавление
13
11.2.3. Листинги исходного кода.......................................................................... 469 11.2.4. Способы справиться с паникой ................................................................ 474 11.2.5. Вывод информации на экран с использованием VGA-совместимого текстового режима .............................................................. 475 11.2.6 _start (): функция main () для FledgeOS..................................................... 477 11.3. fledgeos-1: избавление от цикла занятости ........................................................ 477 11.3.1. Экономия ресурсов за счет прямого взаимодействия с центральным процессором................................................................................. 477 11.3.2. Исходный код fledgeos-1 ........................................................................... 478 11.4. fledgeos-2: самостоятельная обработка исключений ........................................ 479 11.4.1. Почти что правильная обработка исключений ....................................... 479 11.4.2. Исходный код fledgeos-2 ........................................................................... 480 11.5. fledgeos-3: текстовый вывод................................................................................ 481 11.5.1. Вывод на экран цветного текста............................................................... 482 11.5.2. Управление представлением перечислений в памяти ............................ 482 11.5.3. Зачем использовать перечисления?.......................................................... 483 11.5.4. Создание шрифта для вывода информации в буфер кадра VGA .......... 483 11.5.5. Вывод на экран........................................................................................... 484 11.5.6. Исходный код fledgeos-3 ........................................................................... 485 11.6. fledgeos-4: специализированная обработка паники .......................................... 487 11.6.1. Реализация обработчика паники, сообщающего пользователю об ошибке. .............................................................................................................. 487 11.6.2. Повторная реализация panic() с использованием core::fmt::Write......... 487 11.6.3. Реализация core::fmt::Write ....................................................................... 488 11.6.4. Исходный код fledge-4............................................................................... 489 Резюме............................................................................................................................ 491 Глава 12. Сигналы, прерывания и исключения................................................... 493 12.1. Глоссарий.............................................................................................................. 493 12.1.1. Сравнение сигналов и прерываний .......................................................... 495 12.2. Влияние прерываний на приложения................................................................. 496 12.3. Программные прерывания .................................................................................. 498 12.4. Аппаратные прерывания ..................................................................................... 499 12.5. Обработка сигналов ............................................................................................. 499 12.5.1. Поведение по умолчанию ......................................................................... 499 12.5.2. Приостановка и возобновление работы программы............................... 500 12.5.3. Перечень всех сигналов, поддерживаемых операционной системой.................................................................................................................. 503
14
Оглавление
12.6. Обработка сигналов с помощью настраиваемых действий ............................. 504 12.6.1. Применение в Rust глобальных переменных .......................................... 505 12.6.2. Использование глобальной переменной для указания на инициирование завершения выполнения программы ........................................ 507 12.7. Отправка сигналов, определяемых в приложении............................................ 510 12.7.1. Общие сведения об указателях на функции и их синтаксисе................ 511 12.8. Игнорирование сигналов..................................................................................... 512 12.9. Завершение работы из глубокой вложенности в стеке вызовов...................... 514 12.9.1. Представление проекта sjlj........................................................................ 516 12.9.2. Настройка встроенных функций для их использования в программе ............................................................................................................ 517 12.9.3. Приведение указателя к другому типу .................................................... 519 12.9.4. Компиляция кода проекта sjlj ................................................................... 520 12.9.5. Исходный код проекта sjlj......................................................................... 521 12.10. Заметка о применении этих методов на платформах, не использующих сигналы. .......................................................................................... 524 12.11. Пересмотр исключений ..................................................................................... 524 Резюме............................................................................................................................ 525
Предисловие Кто знает, стоит ли вообще читать техническую литературу. Она может быть дорогой, скучной и сомнительной по содержанию. Хуже того, велика вероятность, что с ней вы так ничему и не научитесь. К счастью, эта книга написана автором, который все это прекрасно понимает. Главная цель книги — обучить вас программированию на языке Rust. В книге представлены довольно крупные и способствующие обучению рабочие проекты. По ходу изучения материала будут созданы база данных, эмулятор процессора, ядро операционной системы и разработано несколько других интересных проектов. Предстоит даже заняться процедуральным искусством. Каждый проект разработан с целью изучения языка программирования Rust в удобном для вас темпе. Для тех, кто еще не освоился в Rust-программировании, есть множество возможностей по расширению проектов в любом выбранном направлении. Но изучение языка программирования — это не только освоение его синтаксиса и семантики. Это также вступление в сообщество. К сожалению, уже устоявшиеся сообщества могут создавать для новичков незримые препятствия из-за совместно приобретенных знаний, применения специальных терминов и уже наработанной практики. Для многих новичков Rust-программирования один из таких барьеров — концепция системного программирования. Редко у кого из программистов, переходящих на Rust, имеется опыт в данной области. В качестве компенсации перед книгой поставлена еще одна задача: научить вас системному программированию. А кроме других тем, в двенадцати главах книги вы почерпнете сведения о памяти, цифровом хронометраже и о работе драйверов устройств. Надеюсь, что после этого вы, став участником Rust-сообщества, сможете почувствовать себя в нем вполне уютно. И вы нам в нем нужны! Наше общество зависит от применения программных средств, а критические дыры в безопасности считаются вполне нормальным и, возможно, даже неизбежным явлением. Rust показывает нам, что это уже не соответствует истине. А кроме того, наши компьютеры забиты под завязку раздутыми энергоемкими приложениями. Rust представляет собой жизнеспособную альтернативу, предназначенную для разработки программного обеспечения, менее требовательного к имеющимся ограниченным ресурсам. Эта книга посвящена расширению возможностей. Ее конечная цель — убедить вас в этом. Rust не предназначен для какой-то избранной группы экспертов. Он является инструментом, доступным каждому. Читатели, проделавшие столь длинный путь в своем обучающем путешествии, удостоятся моей похвалы, и я с удовольствием сделаю для вас еще несколько шагов.
Благодарности Спасибо Кэти за то, что удержала меня от свертывания проекта, и за то, что раз за разом поднимала мне настроение, когда я падал духом. Спасибо также Флоренс и Октавии за их обнимашки и улыбки, даже когда папа не мог с ними играть, потому что он писал эту книгу. Я в долгу перед столькими людьми, что мне кажется несправедливым перечислить лишь немногих избранных. При работе над книгой я пользовался поддержкой многих участников Rust-сообщества. В ходе разработки материалов книги через liveBook поступили тысячи читательских исправлений, вопросов и предложений. И вклад каждого читателя помог мне усовершенствовать текст. Спасибо. Особое чувство благодарности я испытываю к небольшому числу читателей, со многими из которых мы стали друзьями. Это Ай Майга (Aï Maiga), Ана Хобден (Ana Hobden), Эндрю Мередит (Andrew Meredith), Андрей Лесников (Andréy Lesnikóv), Энди Гроув (Andy Grove), Артуро Ж. Перес (Arturo J. Pérez), Брюс Митченер (Bruce Mitchener), Сесиль Тонглет (Cecile Tonglet), Дэниел Карозоне (Daniel Carosone), Эрик Ридж (Eric Ridge), Эстебан Кубер (Esteban Kuber), Флориан Гилчер (Florian Gilcher), Ян Бэттерсби (Ian Battersby), Джейн Ласби (Jane Lusby), Хавьер Виола (Javier Viola), Джонатан Тернер (Jonathan Turner), Лачезар Лечев (Lachezar Lechev), Лучано Маммино (Luciano Mammino), Люк Джонс (Luke Jones), Натали Блумфилд (Natalie Bloomfield), Олександр Каленюк (Oleksandr Kaleniuk), Оливия Ифрим (Olivia Ifrim), Пол Фариа (Paul Faria), Пол Дж. Саймондс (Paul J. Symonds), Филипп Гневош (Philipp Gniewosz), Род Элиас (Rod Elias), Стивен Оутс (Stephen Oates), Стив Клабник (Steve Klabnik), Таннер Аллард (Tanner Allard), Томас Локни (Thomas Lockney) и Уильям Браун (William Brown); для меня общение с вами на протяжении последних четырех лет было особой привилегией. Я выражаю сердечную благодарность рецензентам книги, среди которых Афшин Мехрабани (Afshin Mehrabani), Аластер Смит (Alastair Smith), Брайс Дарлинг (Bryce Darling), Кристоффер Финк (Christoffer Fink), Кристофер Хаупт (Christopher Haupt), Дамиан Эстебан (Damian Esteban), Федерико Эрнандес (Federico Hernandez), Герт Ван Лаэтхем (Geert Van Laethem), Джефф Лим (Jeff Lim), Йохан Лизеборн (Johan Liseborn), Джош Коэн (Josh Cohen), Конарк Моди (Konark Modi), Марк Купер (Marc Cooper), Морган Нельсон (Morgan Nelson), Рамнивас Ладдад (Ramnivas Laddad), Риккардо Москетти (Riccardo Moschetti), Санкет Найк (Sanket Naik), Сумант Тамбе (Sumant Tambe), Тим ван Дерзен (Tim van Deurzen), Том Барбер (Tom Barber), Уэйд Джонсон (Wade Johnson), Уильям Браун (William Brown), Уильям Уиллер (William Wheeler) и Ив Дорфсман (Yves Dorfsman). Все ваши комментарии были прочитаны. А многие улучшения на последних этапах работы над книгой стали возможны благодаря вашим содержательным отзывам.
18
Благодарности
Особой похвалы за их терпение, профессионализм и позитивный настрой заслуживают два представителя команды Manning: Элеша Хайд (Elesha Hyde) и Фрэнсис Буран (Frances Buran), которые умело провели книгу через длинную череду черновых вариантов. Также спасибо всем остальным редакторам разработки, в числе которых Берт Бейтс (Bert Bates), Джерри Куч (Jerry Kuch), Михаэла Батинич (Mihaela Batinić), Ребекка Райнхарт (Rebecca Rinehart), Рене ван ден Берг (René van den Berg) и Тим ван Дейрзен (Tim van Deurzen). Моя благодарность распространяется также на производственных редакторов, в числе которых Бенджамин Берг (Benjamin Berg), Дейрдре Хиам (Deirdre Hiam), Дженнифер Хоул (Jennifer Houle) и Пол Уэллс (Paul Wells). В процессе выполнения принятой в Manning программы раннего доступа (MEAPпрограммы) книга претерпела 16 выпусков, что было бы невозможно без поддержки большой группы специалистов. Спасибо вам, Александар Драгосавлевич (Aleksandar Dragosavljević), Ана Ромак (Ana Romac), Элеонора Гарднер (Eleonor Gardner), Иван Мартинович (Ivan Martinović), Лори Вейдерт (Lori Weidert), Марко Райкович (Marko Rajkovic), Матко Хрватин (Matko Hrvatin), Мехмед Пашич (Mehmed Pasic), Мелисса Айс (Melissa Ice), Михаэла Батинич (Mihaela Batinic), Оуэн Робертс (Owen Roberts), Радмила Эрцеговац (Radmila Ercegovac) и Рейхана Марканович (Rejhana Markanovic). Спасибо также маркетинговой команде в составе Бранко Латинчича (Branko Latincic), Кэндис Гиллхулли (Candace Gillhoolley), Коди Танкерсли (Cody Tankersley), Лукаса Вебера (Lucas Weber) и Степана Юрековича (Stjepan Jureković). Вы были для меня великим источником поддержки. Отзывчивость и польза чувствовались и от расширенного состава команды Manning. Спасибо вам, Айра Дукжич (Aira Dučić), Эндрю Уолдрон (Andrew Waldron), Барбара Мирецки (Barbara Mirecki), Бранко Латинчич (Branko Latincic), Брекин Эли (Breckyn Ely), Кристофер Кауфманн (Christopher Kaufmann), Деннис Далинник (Dennis Dalinnik), Эрин Туи (Erin Twohey), Ян Хаф (Ian Hough), Иосип Марас (Josip Maras), Джулия Куинн (Julia Quinn), Лана Класик (Lana Klasic), Линда Котлярская (Linda Kotlyarsky), Лори Кервальд (Lori Kehrwald) и Мелоди Долаб (Melody Dolab) за помощь в работе над книгой. Спасибо и Майку Стивенсу (Mike Stephens) за то, что он положил начало этому изменяющему всю мою жизнь процессу. Он меня предупреждал, что будет сложно, и был абсолютно прав.
О книге В первую очередь книга предназначена для людей, которые, возможно, изучали бесплатные материалы по Rust в Интернете, а затем спросили себя: «А что же дальше?». В книге содержатся десятки интересных примеров, поддающихся расширению при наличии соответствующего времени и творческого потенциала. Эти примеры позволяют двенадцати главам книги охватить продуктивное подмножество Rust и многие из наиболее важных сторонних библиотек экосистемы. Основной упор в примерах кода делается на доступность для начинающих. В них нет особых излишеств, применяемых в элегантных, идиоматических приемах языка Rust. Читатели, неплохо разбирающиеся в программировании на Rust, могут не согласиться с некоторыми принятыми в примерах стилевыми решениями. Но я надеюсь, что они потерпят их ради тех, кто еще только учится. Эта книга не задумывалась в качестве исчерпывающего справочника. Некоторые части языка и стандартной библиотеки были опущены. Как правило, здесь имеются в виду узкоспециализированные составляющие, изучению которых потребовалось бы уделить отдельное внимание. Вместо этого книга сосредоточена на предоставлении читателям достаточного объема базовых знаний и придания уверенности в том, что, в случае необходимости, им будет по силам изучить опущенные здесь специализированные области. Книга также уникальна в семействе книг, посвященных системному программированию, поскольку почти каждый приведенный в ней пример работает в Microsoft Windows.
Кому следует прочитать эту книгу Эта книга должна понравиться тем, кто интересуется языком Rust, кто учится на практических примерах, или тем, кто напуган фактом принадлежности Rust к языкам системного программирования. Наибольшую пользу от книги получат читатели, уже имеющие опыт программирования, поскольку в ней будет изложен ряд концепций, используемых при программировании компьютерного оборудования.
Организация книги: дорожная карта Книга состоит из двух частей. В первой части представлен синтаксис языка Rust и ряд его характерных особенностей. Во второй части те знания, что были получены при изучении первой, применяются при разработке ряда проектов. В каждой главе вводится одна-две новых концепций языка Rust.
20
О книге
Согласно вышесказанному, в первой части книги дается краткое введение в Rust: Глава 1, «Введение в Rust», объясняет причины появления Rust и рассказывает
о том, как приступить к программированию на этом языке. Глава 2, «Особенности языка Rust», предоставляет фундаментальные сведения о синтаксисе языка. В качестве примеров используются визуализация множества Мандельброта и создание клона команды grep. Глава 3, «Составные типы данных», объясняет, как в Rust составляются типы
данных и какими средствами обработки ошибок располагает этот язык. Глава 4, «Время жизни, владение и заимствование», рассматривает механизмы, гарантирующие неизменную корректность доступа к данным. Во второй части книги Rust применяется для введения в область системного программирования: Глава 5, «Углубленное изучение данных», повествует о том, как информация представлена в цифровых компьютерах, при этом особое внимание уделяется приближению чисел. Примеры включают создание собственного числового формата и эмулятора центрального процессора. Глава 6, «Память», объясняет такие понятия, как ссылки, указатели, виртуальная память, стек и куча. Примеры включают создание сканера памяти и реализацию проекта процедурального искусства. Глава 7, «Файлы и хранилища», объясняет процессы сохранения структур данных на устройствах хранения информации. Примеры включают создание клона утилиты hex dump и разработку работоспособной базы данных. Глава 8, «Работа в сети», объясняет способы, применяемые компьютерами для обмена данными посредством многократной повторной реализации HTTP с избавлением при каждом шаге от очередного уровня абстракции. Глава 9, «Время и хронометраж», исследует процессы отслеживания времени в цифровом компьютере. Примеры включают создание работоспособного NTPклиента. Глава 10, «Процессы, потоки и контейнеры», объясняет, что такое процессы, потоки и связанные с ними абстракции. Примеры включают создание приложения черепашьей графики и средства синтаксического анализа, работающего в режиме параллельных вычислений. Глава 11, «Ядро операционной системы», дает описание роли операционной системы и способов начальной загрузки компьютеров. Примеры включают компиляцию своего собственного загрузчика и ядра операционной системы. Глава 12, «Сигналы, прерывания и исключения», объясняет порядок связи внешнего мира с центральным процессором и операционными системами. Книга предназначена для последовательного чтения. Предполагается, что во всех последующих главах используются знания, полученные в предыдущих. Но проекты из каждой главы не связаны друг с другом. Поэтому по всем проектам, касающимся интересующих вас тем, можно проходить в произвольном порядке.
О книге
21
О программном коде Примеры кода, приведенные в этой книге, написаны на языке Rust в редакции 2018 года и протестированы под управлением Windows и Ubuntu Linux. Никакого специального программного обеспечения, кроме рабочей установки Rust, не требуется. Инструкции по установке изложены в главе 2. Эта книга содержит множество примеров исходного кода как в пронумерованных листингах, так и в виде обычного текста. В обоих случаях, чтобы исходный код отличался от обычного текста, он отформатирован таким вот шрифтом фиксированной ширины. Иногда код также выделяется жирным шрифтом, чтобы отметить его фрагменты, изменившиеся по сравнению с предыдущими действиями, рассмотренными в главе, например когда к существующей строке кода добавляется новая функция. Во многих случаях первоначальный исходный код был переформатирован: чтобы он уместился на книжной странице, в нем были переработаны отступы и добавлены разрывы строк. Но иногда и этого было недостаточно, поэтому в листингах попадаются маркеры продолжения строки (➥). Кроме того, при описании кода в тексте комментарии, имевшиеся в листингах исходного кода, зачастую удаляются. Для выделения важных понятий многие листинги сопровождаются примечаниями к коду.
Дискуссионный форум liveBook Приобретение этой книги открывает свободный доступ к закрытому веб-форуму, запущенному издательством Manning Publications, где можно оставлять отзывы о книге, задавать вопросы технического плана и получать помощь от автора и от других пользователей: Чтобы попасть на форум, перейдите по адресу
https://livebook.manning.com/book/rust-in-action/welcome/v-16/. Дополнительные сведения о форумах, запущенных издательством Manning, и
о правилах поведения на них можно получить по адресу https://livebook.manning.com/#!/discussion. Придерживаясь обязательств, принятых перед читателями, издательство Manning обеспечивает место, где может состояться содержательный диалог между отдельными читателями, а также между читателями и автором. При этом автор не обязан общаться с каким-то определенным количеством участников форума, поскольку его собственное участие остается добровольным (и неоплачиваемым). Чтобы не потерять авторский интерес к форуму, предлагаем попробовать задать автору несколько сложных вопросов! Пока книга находится в печати, форум и архивы предыдущих обсуждений будут доступны с веб-сайта издателя.
22
О книге
Другие онлайн-ресурсы Тима Макнамару можно найти в социальной сети по метке @timClicks. Его основные каналы — Twitter (https://twitter.com/timclicks), YouTube (https://youtube.com/c/timclicks) и Twitch (https://twitch.tv/timclicks). Также можно свободно подключиться к его Discordсерверу по адресу https://discord.gg/vZBX2bDa7W.
Об авторе Тим Макнамара (Tim McNamara) учился программировать, чтобы помогать осуществлению проектов гуманитарной помощи по всему миру прямо из своего дома в Новой Зеландии. За последние 15 лет Тим стал экспертом в области интеллектуального анализа текста, обработки естественного языка и обработки данных. Он организатор сообщества Rust Wellington и регулярно проводит учебные занятия по программированию на языке Rust не только в формате живого общения, но и по Интернету через Twitch и YouTube.
Об иллюстрации на обложке книги Рисунок на обложке этой книги имел подпись «Le maitre de chausson» («Мастер тапочек») или «The boxer» («Боксер»). Иллюстрация взята из собрания работ множества художников под редакцией Луи Кармера (Louis Curmer), опубликованного в Париже в 1841 году. Коллекция называется «LesFrançais peints par eux-mêmes», что переводится как «Французы на собственных рисунках». Каждая иллюстрация четко прорисована и раскрашена вручную, а богатое разнообразие рисунков, представленных в коллекции, живо напоминает нам о том, насколько обособленными в культурном отношении были регионы мира, города́, деревни и кварталы всего 200 лет назад. Будучи разобщенными, люди говорили на разных диалектах и языках. На улицах или в сельской местности просто по тому, как люди одеты, было легко определить, где они живут и каково их ремесло или социальное положение. С тех пор стиль одежды изменился, и разнообразие по регионам, столь богатое в то время, исчезло. Сейчас трудно отличить друг от друга жителей разных континентов, не говоря уже о разных городах или регионах. Возможно, мы обменяли культурное разнообразие на более яркую личную жизнь, проходящую, конечно же, в более разнообразном и быстро развивающемся техническом окружении. В наше время, когда одну компьютерную книгу трудно отличить от другой, издательство Manning подчеркивает изобретательность и инициативу компьютерного бизнеса за счет обложек своих книг, показывающих богатое разнообразие региональной жизни двухсотлетней давности, оживленное изображениями из таких, как упомянутая здесь, коллекций.
1
Введение в Rust
В этой главе рассматриваются следующие вопросы: Введение в свойства Rust и о том, для чего он создавался. Разбор синтаксиса Rust. Где следует, а где не следует применять Rust. Создание вашей первой программы на Rust. Сравнение Rust с объектно-ориентированными языками и системами програм-
мирования более широкого спектра. Знакомьтесь с Rust — это язык программирования, расширяющий ваши возможности. Углубленное знакомство с ним откроет вам не только язык программирования, обладающий невероятной скоростью и безопасностью, но и весьма приятный инструмент для программирования «на каждый день». Приступив к программированию на Rust, вы вряд ли остановитесь. И эта книга, «Rust в действии», поможет вашему становлению в качестве Rust-программиста. Но она не станет учить программированию с нуля. Книга рассчитана на тех, кто рассматривает Rust в качестве своего следующего языка, и на тех, кому нравится освоение практических примеров. Вот наиболее яркие примеры, включенные в эту книгу:
Визуализация множества Мандельброта.
Grep-клон.
Эмулятор центрального процессора.
Искусство генерации.
База данных.
Клиенты HTTP, NTP и hexdumps.
Интерпретатор языка LOGO.
Ядро операционной системы.
Из списка ясно, что книга позволит не только освоить Rust, но и познакомит с системным и низкоуровневым программированием. Изучая книгу «Rust в действии», вы сможете узнать о роли операционной системы, о том, как работает процессор, как компьютеры отслеживают время, что такое указатели и что такое тип данных. Вы получите представление о взаимодействии внутренних систем компьютера. Выйдя за рамки изучения синтаксиса Rust, вы также поймете, для чего был создан этот язык и для решения каких задач он предназначен.
24
Глава 1
1.1. Где используется Rust? Ежегодно, с 2016 по 2020 год, в опросе разработчиков в Stack Overflow Rust удостаивался звания «Самый любимый язык программирования». Видимо, именно поэтому Rust был принят ведущими разработчиками компьютерных технологий:
1
Amazon Web Services (AWS) пользуется Rust с 2017 года для своих решений по бессерверным вычислениям AWS Lambda и AWS Fargate. Благодаря этому Rust развил свои успехи. Для выпуска своего сервиса Elastic Compute Cloud (EC2) компания Amazon создала операционную систему Bottlerocket OS и AWS Nitro System 1.
Компания Cloudflare применяет Rust при разработке множества своих сервисов, включая общедоступную DNS, средства бессерверных вычислений и программы по проверке пакетов2.
Компания Dropbox применила Rust для перестройки своего внутреннего хранилища данных, управляющего эксабайтами данных3.
Компания Google применяет Rust при разработке таких компонентов Android, как модуль Bluetooth. Rust также используется для компонента Chrome OS под названием crosvm и играет важную роль в разработке новой операционной системы Google под названием Fuchsia4.
Компания Facebook использует Rust для повышения эффективности работы своих веб-, мобильных и API-сервисов, а также для разработки компонентов HHVM, виртуальной машины HipHop, используемой языком программирования Hack5.
Компания Microsoft пишет на Rust компоненты своей платформы Azure, включая демон безопасности для своей службы Интернета вещей (IoT)6.
В Mozilla язык Rust используется для совершенствования веб-браузера Firefox, база которого содержит 15 миллионов строк кода. Первые два проекта Mozilla Rust-в-Firefox — анализатор метаданных MP4 и система кодирования-декодирования текста, позволили повысить общую производительность и стабильность работы браузера.
Входящая в GitHub компания npm Inc. использует Rust, чтобы справиться с «более чем 1,3 миллиардов загрузок пакетов в день»7.
См. «How our AWS Rust team will contribute to Rust’s future successes», http://mng.bz/BR4J. См. «Rust at Cloudflare», https://news.ycombinator.com/item?id=17077358. 3 См. «The Epic Story of Dropbox’s Exodus From the Amazon Cloud Empire», http://mng.bz/d45Q. 4 См. «Google joins the Rust Foundation», http://mng.bz/ryOX. 5 См. «HHVM 4.20.0 and 4.20.1», https://hhvm.com/blog/2019/08/27/hhvm-4.20.0.html. 6 См. https://github.com/Azure/iotedge/tree/master/edgelet. 7 См. «Rust Case Study: Community makes Rust an easy choice for npm», http://mng.bz/xm9B. 2
Введение в Rust
25
Компания Oracle для устранения проблем с реализацией ссылок в Go разработала с применением Rust контейнерную среду выполнения8.
Компания Samsung через свою дочернюю компанию SmartThings использует Rust в своем подразделении по разработке серверной части прошивки для сервиса Интернета вещей (IoT).
Производительности Rust также вполне хватает для развертывания динамично развивающихся стартапов. Вот несколько примеров:
Sourcegraph использует Rust для подсветки синтаксиса на всех своих языках9.
Figma использует Rust в самых важных для производительности компонентах своего многопользовательского сервера10.
Parity использует Rust для разработки клиентской части блокчейна Ethereum11.
1.2. С какой целью была написана книга «Rust в действии» Что побудило меня написать «Rust в действии»? Как только были преодолены начальные трудности, дело стало налаживаться. Переговоры 2017 года, показанные ниже, сродни хорошему анекдоту. В них один из специалистов команды Google Chrome OS рассуждает, чего им стоило ввести язык в проект12: indy on Sept 27, 2017 Разве Rust официально разрешен в Google? zaxcellent on Sept 27, 2017 Вопрос по адресу: Rust не имеет официального разрешения в Google, но им все же пользуется целый ряд сотрудников. Склонить моих сотрудников к применению Rust в конкретном компоненте удалось после того, как я убедил их, что никакой другой язык не подойдет для решения поставленной задачи лучше Rust, в чем сам я нисколько не сомневался. Стоит отметить, что удачно встроить Rust в среду разработки Chrome OS стоило немалого труда. Неоценимую помощь в этом оказали разработчики Rust, которые давали исчерпывающие ответы на все мои вопросы. ekidd on Sept 27, 2017 > Склонить моих сотрудников к применению Rust в конкретном > компоненте удалось после того, как я убедил их, что никакой > другой язык не будет лучше его для решения данной задачи, > в чем сам я нисколько не сомневался.
8
См. «Building a Container Runtime in Rust», http://mng.bz/d40Q. См. «HTTP code syntax highlighting server written in Rust», https://github.com/sourcegraph/syntect_server. 10 См. «Rust in Production at Figma», https://www.figma.com/blog/rust-in-production-at-figma/. 11 См. «The fast, light, and robust EVM and WASM client», https://github.com/paritytech/parity-ethereum. 12 См. «Chrome OS KVM — A component written in Rust», https://news.ycombinator.com/item?id=15346557. 9
26
Глава 1
У меня похожий случай был с одним из моих проектов — с декодером субтитров vobsub, обеспечивавшим парсинг сложных бинарных данных, который я планировал со временем запустить в виде веб-сервиса. Естественно, я хотел убедиться в отсутствии слабых мест в моем коде. Код был написан на Rust, а затем я воспользовался командой 'cargo fuzz' для прогона программы и выявления недочетов. После прохода миллиарда (!) fuzz-итераций я обнаружил 5 дефектов (см. раздел 'vobsub' в trophy case for a list по адресу https://github.com/rust-fuzz/trophy-case). К счастью, ни _один_ из этих дефектов не мог вылиться в реальный эксплойт. Для каждого из них Rust успешно выявлял проблему и переводил ее в состояние управляемой паники. (При практическом применении это привело бы к полному перезапуску веб-сервера.) В результате я пришел к выводу: если мне понадобится язык, во-первых, без сборки мусора, но, во-вторых, которому я смогу доверять в ситуации, требующей особых мер безопасности, то Rust — прекрасный выбор. Возможность статической компоновки двоичных файлов (как с Go) — большой плюс. Manishearth on Sept 27, 2017 > К счастью, ни _один_ из этих дефектов не мог обернуться > лазейкой для хакера. Для каждого из них Rust успешно выявлял > проблему и переводил ее в управляемый сигнал тревоги. Если кому-то интересно, то у нас также есть некоторый опыт автоматического тестирования безопасности (фаззинга)кода в firefox. Фаззинг позволяет выявить массу тревожных моментов (и предпосылок для отладки или для «безопасного» переполнения). Однажды им был выявлен дефект в аналогичном коде Gecko, остававшийся незамеченным около десяти лет.
Из данного фрагмента можно понять, что признание языка шло в восходящем направлении усилиями специалистов, стремящихся преодолеть технические трудности в относительно небольших проектах. Затем опыт, накапливаемый за счет случаев успешного применения нового языка, использовался в качестве обоснования более амбициозных работ. За период с конца 2017 года Rust продолжал совершенствоваться и укреплять свои позиции. Он стал общепринятой составляющей технологического ландшафта Google, и теперь уже является официально допущенным языком в операционных системах Android и Fuchsia.
1.3. Вкус языка Этот раздел позволит вам немного распробовать работу с Rust. В нем будет показан порядок использования компилятора с переходом к созданию простой программы. А в следующих главах будут рассмотрены полноценные проекты. ПРИМЕЧАНИЕ Для установки Rust нужно воспользоваться официальными установщиками, предоставленными по адресу https://rustup.rs/.
Введение в Rust
27
1.3.1. Хитрый путь к «Hello, world!» Первым делом, осваивая новый язык, большинство программистов учатся выводить на консоль приветствие «Hello, world!». Вы не станете исключением, но сделаете это особым образом. Чтобы убедиться, что все находится в рабочем состоянии, нужно будет выявить досадные синтаксические ошибки. Если работа ведется под Windows, откройте командную строку Rust, доступную после установки Rust в меню Пуск, и запустите на выполнение следующую команду: C:\> cd %TMP%
Если вы работаете под Linux или macOS, откройте окно Terminal. Запустите в нем следующую команду: $ cd $TMP
Далее команды для всех операционных систем будут одинаковыми. При правильной установке Rust следующие три команды позволят отобразить на экране приветствие «Hello, world!» (а также множество других выходных данных): $ cargo new hello $ cd hello $ cargo run
Посмотрим, как выглядит весь сеанс при запуске cmd.exe под MS Windows: C:\> cd %TMP% C:\Users\Tim\AppData\Local\Temp\> cargo new hello Created binary (application) `hello` project C:\Users\Tim\AppData\Local\Temp\> cd hello C:\Users\Tim\AppData\Local\Temp\hello\> cargo run Compiling hello v0.1.0 (file:/ / / C:/Users/Tim/AppData/Local/Temp/hello) Finished dev [unoptimized + debuginfo] target(s) in 0.32s Running `target\debug\hello.exe` Hello, world!
А при запуске под Linux или macOS увидим в консоли следующее: $ cd $TMP $ cargo new hello Created binary (application) `hello` package $ cd hello $ cargo run Compiling hello v0.1.0 (/tmp/hello) Finished dev [unoptimized + debuginfo] target(s) in 0.26s Running `target/debug/hello` Hello, world!
Если все получилось, вас можно поздравить! Только что запущен ваш первый Rustкод без всякого программирования на Rust. Посмотрим, как это получилось.
28
Глава 1
Имеющийся в Rust инструмент под названием cargo предоставляет как систему сборки, так и диспетчер пакетов. То есть, cargo знает, как превратить ваш Rust-код в исполняемые двоичные файлы, а также может управлять процессом загрузки и компиляции проектных зависимостей. cargo new создает для вас проект, который построен по стандартному шаблону. Команда tree может показать исходную структуру проекта и файлы, созданные после ввода команды cargo new: $ tree hello hello ├── Cargo.toml └── src └── main.rs 1 directory, 2 files
Аналогичную структуру имеют все Rust-проекты, созданные с помощью cargo. В основном каталоге имеется файл Cargo.toml, содержащий описание метаданных проекта, таких как имя проекта, его версия и его зависимости. Исходный код попадает в каталог src. Для файлов исходного кода языка Rust используется расширение .rs. Для просмотра файлов, созданных командой cargo new, используется команда tree. Следующей выполненной командой была cargo run. Понять эту командную строку гораздо проще, но cargo фактически проводит куда более серьезную работу, чем можно было бы предположить. Вы требуете от cargo запустить проект. Если при вызове команды запускать было нечего, cargo принимает от вашего имени решение о компиляции кода в режиме отладки, чтобы предоставить вам максимальный объем информации об ошибках. Оказывается, в файле src/main.rs всегда содержится заготовка «Hello, world!». В результате компиляции получился файл по имени hello (или hello.exe). Этот файл был выполнен, и результат был выведен на экран. Выполнение команды cargo run привело также к добавлению к проекту новых файлов. Теперь у нас в основном каталоге проекта есть файл Cargo.lock и каталог target/. Оба они управляются инструментальным средством cargo. Поскольку это артефакты процесса компиляции, изучать их не нужно. В Cargo.lock указываются конкретные номера версий всех зависимостей, чтобы будущие сборки составлялись точно также, как и эта, пока содержимое Cargo.toml не изменится. Повторный запуск команды tree открывает новую структуру, созданную вызовом cargo run для компиляции проекта hello: $ tree --dirsfirst hello hello ├── src │ └── main.rs ├── target │ └── debug │ ├── build │ ├── deps │ ├── examples
Введение в Rust
29
│ ├── native │ └── hello ├── Cargo.lock └── Cargo.toml
Если все именно так и получилось, значит, вы молодец! После освоения хитрого способа получения «Hello, World!» давайте добьемся того же результата более длинным путем.
1.3.2. Ваша первая программа на Rust Для нашей первой программы нужно написать код, выводящий на экран следующий текст на нескольких языках: Hello, world! Grüß Gott! ハロー・ワールド
Первая строчка наверняка вам уже знакома. А две другие здесь, чтобы подчеркнуть ряд свойств, присущих языку Rust: легкую итерацию и встроенную поддержку Unicode. Для создания этой программы мы, как и раньше, воспользуемся cargo. Выполните следующие действия: 1. Откройте командную строку консоли. 2. Запустите команду cd %TMP% под MS Windows или же команду cd $TMP под другой ОС. 3. Запустите для создания нового проекта команду cargo new hello2. 4. Запустите команду cd hello2 для перехода в корневой каталог проекта. 5. Откройте в текстовом редакторе файл src/main.rs. 6. Замените содержимое этого файла текстом, показанным в листинге 1.1. Код следующего листинга имеется в хранилище исходного кода. Откройте файл ch1/ch1hello2/src/hello2.rs. Листинг 1.1. «Hello World!» на трех языках 1 fn greet_world() { 2 println!("Hello, world!"); 3 let southern_germany = "Grüß Gott!"; 4 let japan = "ハロー・ワールド"; 5 let regions = [southern_germany, japan]; 6 for region in regions.iter() { 7 println!("{}", ®ion); 8 } 9 } 10 11 fn main() {
(1) (2) (3) (4) (5) (6)
30
Глава 1
12 13 }
greet_world();
(7)
(1) Восклицательный знак свидетельствует об использовании макроса, с чем мы вскоре разберемся. (2) Для операции присваивания в Rust, которую правильнее было бы назвать привязкой переменной, используется ключевое слово let. (3) Поддержка Unicode предоставляется изначально самим языком. (4) Для литералов массива используются квадратные скобки. (5) Для возврата итератора метод iter() может присутствовать во многих типах. (6) Амперсанд «заимствует» region так, чтобы доступ предоставлялся только для чтения. (7) Вызов функции. Обратите внимание на круглые скобки, следующие за именем функции.
Теперь, когда у src/main.rs новое содержимое, запустите из каталога hello2/ команду cargo run. После ряда выходных данных, сгенерированных самим cargo, вы увидите появление трех приветствий: $ cargo run Compiling hello2 v0.1.0 (/path/to/ch1/ch1-hello2) Finished dev [unoptimized + debuginfo] target(s) in 0.95s Running `target/debug/hello2` Hello, world! Grüß Gott! ハロー・ワールド
Давайте уделим пару минут разбору ряда интересных моментов в коде Rust, показанном в листинге 1.2. Первым делом можно было бы заметить, что строки в Rust могут содержать весьма широкий диапазон символов. Строки гарантированно получат кодировку UTF-8. Значит, вам будет относительно несложно воспользоваться не только английским языком. Единственным символом, который может показаться неуместным, будет восклицательный знак после println. Привыкшим к языку Ruby он покажется признаком операции деструкции. В Rust он сигнализирует об использовании макроса. Пока макросы можно считать проcто какими-то необычными функциями. Они позволяют избежать применения шаблонного кода. В данном случае применяется макрос println!, выполняющий свои внутренние операции определения типов, что позволяет выводить на экран произвольные типы данных.
1.4. Загрузка исходного кода книги Чтобы следовать примерам, приводимым в книге, вам может понадобиться доступ к исходному коду листингов. Чтобы вам было удобнее, исходный код каждого примера можно получить из двух источников:
https://manning.com/books/rust-in-action.
https://github.com/rust-in-action/code.
Введение в Rust
31
1.5. На что похож Rust? Rust — язык программирования, позволяющий программистам Haskell и Java ладить друг с другом. Rust можно сравнить с высокоуровневыми, весьма выразительными динамичными языками вроде Haskell и Java, но при этом он способен достигать низкоуровневой, чуть ли не чисто аппаратной производительности. В разделе 1.3 уже рассматривалось несколько примеров «Hello, world!», а чтобы получить лучшее представление о свойствах Rust, давайте займемся чем-то посложнее. В листинге 1.2 дается краткий обзор того, на что способен Rust при обычной обработке текста. Исходный код листинга находится в файле ch1/ch1penguins/src/main.rs. Обратите внимание на следующие моменты:
Общие механизмы управления ходом выполнения программы — в их числе циклы for и ключевое слово continue.
Синтаксис методов — хотя Rust и не относится к объектно-ориентированным языкам, поскольку не поддерживает наследование, у него все же присутствует данная особенность.
Программирование функций высшего порядка — функции могут как принимать, так и возвращать функции. Например, строка 19 (.map(|field| field.trim())) включает замкнутое выражение, известное также как безымянная функция или лямбда-функция.
Сигнатуры типов — они встречаются редко, но все же иногда нужны в качестве подсказки компилятору (посмотрите, к примеру, на строку 27, начинающуюся с if let Ok(length)).
Условная компиляция — имеющиеся в листинге строки 21–24 (if cfg!(…);) не включаются в сборки конечных версий программы.
Подразумеваемое возвращение — Rust предоставляет ключевое слово return, но оно обычно опускается. Rust — язык, основанный на выражениях.
Листинг 1.2. Пример Rust-кода, демонстрирующего ряд простых приемов обработки CSV-данных 1 fn main() { 2 let penguin_data = "\ 3 common name,length (cm) 4 Little penguin,33 5 Yellow-eyed penguin,65 6 Fiordland penguin,60 7 Invalid,data 8 "; 9 10 let records = penguin_data.lines(); 11 12 for (i, record) in records.enumerate() { 13 if i == 0 || record.trim().len() == 0 {
(1) (2)
(3)
32
Глава 1
14 15 16 17 18 19 20 21 22 23
continue; } let fields: Vec = record .split(',') .map(|field| field.trim()) .collect(); if cfg!(debug_assertions) { eprintln!("debug: {:?} -> {:?}", record, fields);
(4) (5) (6) (7) (8)
(9) 24 25 26 27 28 29 30 31 }
} let name = fields[0]; if let Ok(length) = fields[1].parse::() { println!("{}, {}cm", name, length); }
(10) (11)
}
(1) Исполняемым проектам требуется функция main() (2) Отключение завершающего символа новой строки (3) Пропуск строки заголовка и строк, состоящих из одних пробелов (4) Начало со строки текста (5) Разбиение записи на поля (6) Обрезка пробелов в каждом поле (7) Сборка набора полей (8) cfg! проверяет конфигурацию в процессе компиляции. (9) eprintln! выводит данные на стандартное устройство сообщений об ошибках (stderr) (10) Попытка выполнения парсинга поля в виде числа с плавающей точкой (11) println! помещает данные на стандартное устройство вывода (stdout).
Кому-то из читателей листинг 1.2 может показаться странным, особенно если им еще не приходилось сталкиваться с кодом на Rust. Перед тем, как продолжить, следует кое-что пояснить:
В строке 17 переменная fields помечена типом Vec. Vec — сокращение от _vector_, типа коллекции, способного динамически расширяться. Знак подчеркивания (_) предписывает Rust вывести тип элементов.
В строках 22 и 28 Rust получает предписание по выводу информации на консоль. Макрос println! выводит свои аргументы на стандартное устройство вывода (stdout), а макрос eprintln! делает то же самое на стандартное устройство для сообщения об ошибках (stderr).
Макросы похожи на функции, но вместо возвращения данных они возвращают код. Макросы часто используются для упрощения общеупотреби-
Введение в Rust
33
тельных шаблонов. Для управления своим выводом макросы eprintln! и println! используют в качестве первого аргумента строковый литерал со встроенным миниязыком. Поле заполнения {} заставляет Rust воспользоваться методом представления значения в виде строки, который определил программист, а не представлением по умолчанию, доступным при указании поля заполнителя {:?}.
В строке 27 содержится ряд новых элементов. if let Ok(length) = fields[1].parse::() читается как «попытаться разобрать fields[1] в виде 32-разрядного числа с плавающей точкой, и в случае успеха присвоить число переменной length».
Конструкция if let — краткий метод обработки данных, предоставляющий также локальную переменную, которой присваиваются эти данные. Метод parse() возвращает Ok(T) (где T означает любой тип), если ему удается провести разбор строки; в противном случае он возвращает Err(E) (где E означает тип ошибки). Применение if let Ok(T) позволяет пропустить любые случаи ошибок, подобные той, что встречается при обработке строки Invalid,data.
Когда Rust не способен вывести тип из окружающего контекста, он запрашивает конкретное указание. В вызов parse() включается встроенная аннотация типа в виде parse::().
Преобразование исходного кода в исполняемый файл называется компиляцией. Чтобы скомпилировать Rust-код, нужно установить компилятор Rust и запустить его в отношении исходного кода. Для компиляции кода листинга 1.2 выполните следующее: 1. Откройте командную строку консоли (например, cmd.exe, PowerShell, Terminal или Alacritty). 2. Перейдите в каталог ch1/ch1-penguins (но не в ch1/ch1-penguins/src) исходного кода, загруженного вами в разделе 1.4. 3. Запустите на выполнение команду cargo run. Выведенная этой командой информация показана в следующем фрагменте кода: $ cargo run Compiling ch1-penguins v0.1.0 (../code/ch1/ch1-penguins) Finished dev [unoptimized + debuginfo] target(s) in 0.40s Running `target/debug/ch1-penguins` dbg: " Little penguin,33" -> ["Little penguin", "33"] Little penguin, 33cm dbg: " Yellow-eyed penguin,65" -> ["Yellow-eyed penguin", "65"] Yellow-eyed penguin, 65cm dbg: " Fiordland penguin,60" -> ["Fiordland penguin", "60"] Fiordland penguin, 60cm dbg: " Invalid,data" -> ["Invalid", "data"]
34
Глава 1
Наверное, вы заметили какие-то непонятные строки, начинающиеся с метки dbg:? Их можно убрать, компилируя выходную сборку с имеющимся в cargo флагом -release. Эта функция условной компиляции обеспечивается блоком cfg!(debug_assertions) { … } в строках 22–24 листинга 1.2. Сборки конечных версий выполняются намного быстрее, но компилируются гораздо дольше: $ cargo run --release Compiling ch1-penguins v0.1.0 (.../code/ch1/ch1-penguins) Finished release [optimized] target(s) in 0.34s Running `target/release/ch1-penguins` Little penguin, 33cm Yellow-eyed penguin, 65cm Fiordland penguin, 60cm
Выводимую информацию можно сделать еще короче, добавив к команде cargo флаг -q, являющийся сокращением слова quiet (молчание). Что из этого выйдет, показано в следующем фрагменте кода: $ cargo run -q --release Little penguin, 33cm Yellow-eyed penguin, 65cm Fiordland penguin, 60cm
Листинги 1.1 и 1.2 были выбраны, чтобы собрать в примерах как можно больше характерных для Rust и простых в понимании функций. Надеюсь, с их помощью было показано, что программы на Rust воспринимаются написанными на языке высокого уровня, но при этом отличаются высокой производительностью, присущей программам, написанным на языках низкого уровня. Давайте отвлечемся на время от конкретных свойств этого языка и рассмотрим идеи, положенные в его основу, а также посмотрим, как он вписывается в экосистему языков программирования.
1.6. В чем заключается особенность Rust? Отличительная особенность Rust как языка программирования — возможность предотвращения недопустимого доступа к данным в ходе компиляции. Исследовательские проекты Microsoft Security Response Center и проект браузера Chromium приписывают проблемам недопустимого доступа к данным примерно 70% серьезных ошибок систем безопасности13. В Rust данный класс ошибок полностью исключен. Этим гарантируется, что ваша программа будет безопасной для памяти без каких-либо издержек времени выполнения. Такой же уровень безопасности могут предоставить и другие языки, но это повлечет за собой дополнительные проверки, осуществляемые в ходе выполнения программы и замедляющие ее работу. В Rust удалось разорвать этот порочный круг, создав для него собственное пространство, показанное на рис. 1.1.
13
Дополнительную информацию можно найти в статье «We need a safer systems programming language», http://mng.bz/VdN5, и в статье «Memory safety», http://mng.bz/xm7B for more information.
Введение в Rust
35
Профессиональное сообщество Rust славится стремлением учитывать в процессе принятия решений все самые ценные идеи. Этот дух всеобщей причастности витает в сообществе повсеместно. Открытые сообщения всецело приветствуются. Весь обмен информацией внутри Rust-сообщества регулируется принятыми в нем этическими нормами. Даже сообщения об ошибках, выдаваемые компилятором Rust, на удивление содержательны.
Rust
Безопасность
Python
Большинство языков действует в пределах этого диапазона. Rust предоставляет и безопасность, и возможность управления
C
Возможность управления
Рис. 1.1. Rust обеспечивает как безопасность, так и возможность управления. Другим языкам приходится балансировать между этих двух показателей.
До конца 2018 года на главной странице Rust посетителей встречало сообщение: «Rust — язык системного программирования, работающий поразительно быстро, не допускающий ошибок сегментации и гарантирующий безопасность потоков». Затем сообщество изменило эту формулировку, сосредоточившись на интересах своих действующих и потенциальных пользователей (см. табл. 1.1). Таблица 1.1. Времена меняются, и слоган Rust меняется вместе с ними. По мере того, как укреплялись позиции языка Rust, в нем все активнее проступала такая идея: пусть этот язык способствует устремлениям тех программистов, которые ставят перед собой самые амбициозные цели До конца 2018 года
Затем
«Rust — язык системного программирования, работающий невероятно быстро, не допускающий ошибок сегментации и гарантирующий безопасность потоков».
«Язык, позволяющий каждому создавать надежное и эффективное программное обеспечение».
Rust позиционируется как язык системного программирования, которое, как правило, рассматривается в качестве особой ветви программирования, являющейся уделом узкого круга специалистов. Но многие Rust-программисты поняли, что его можно применять ко многим другим областям. Безопасность, производительность и
36
Глава 1
управляемость пригодятся во всех проектах разработки программного обеспечения. Более того, всеохватность, присущая Rust-сообществу, означает, что язык выигрывает от постоянного притока новых участников с различными интересами. Давайте конкретизируем три уже упомянутые цели создания языка: безопасность, продуктивность и управляемость. Что под ними понимается, и в чем их важность?
1.6.1. Цель создания Rust: безопасность В Rust-программах отсутствуют
Висячие указатели — прямые ссылки на данные, ставшие недействительными в ходе выполнения программы (см. листинг 1.3).
Состояния гонки — неспособность из-за изменения внешних факторов определить, как программа будет вести себя от запуска к запуску (см. листинг 1.4).
Переполнение буфера — попытка обращения к 12-му элементу массива, состоящего из 6 элементов (см. листинг 1.5).
Недействительность итератора — проблема, вызываемая проходом какого-то объекта-итератора, претерпевшего изменение уже в ходе итерации (см. листинг 1.6).
Когда программа компилируется в режиме отладки, Rust также обеспечивает защиту от целочисленного переполнения. В чем его суть? Дело в том, что целые числа могут представлять только конечный набор чисел, поскольку имеют в памяти фиксированную ширину. Целочисленное переполнение происходит, когда целочисленные значения упираются в свой предел и снова переходят к началу своего ряда. В следующем примере показан висячий указатель. Исходный код листинга находится в файле ch1/ch1-cereals/src/main.rs. Листинг 1.3. Попытка создания висячего указателя 1 #[derive(Debug)] 2 enum Cereal { 3 Barley, Millet, Rice, 4 Rye, Spelt, Wheat, 5 } 6 7 fn main() { 8 let mut grains: Vec = vec![]; 9 grains.push(Cereal::Rye); 10 drop(grains); 11 println!("{:?}", grains); 12 }
(1) (2)
(3) (4) (5) (6)
(1) Разрешение макросу println! вывести перечисление Cereal (2) enum (перечисление) — тип с фиксированным количеством допустимых вариантов
Введение в Rust (3) (4) (5) (6)
37
Инициализация пустого вектора Cereal Добавление элемента к вектору grains Удаление grains и его содержимого Попытка обращения к значению, которое уже удалено
В листинге 1.3 в grains имеется указатель, созданный в строке 8. Вектор Vec реализован с внутренним указателем на основной массив. Но код листинга не проходит компиляцию. При ее попытке выдается сообщение об ошибке с жалобой на попытку «позаимствовать» «перемещенное» значение. Интерпретация этого сообщения об ошибке и способ ее исправления будут рассмотрены далее. А сейчас посмотрим на то, что было выведено на экран при попытке скомпилировать код листинга 1.3: $ cargo run Compiling ch1-cereals v0.1.0 (/rust-in-action/code/ch1/ch1-cereals) error[E0382]: borrow of moved value: `grains` (1) --> src/main.rs:12:22 | 8 | let mut grains: Vec = vec![]; | ------- move occurs because `grains` has type (2) `std::vec::Vec`, which does not implement the `Copy` trait 9 | grains.push(Cereal::Rye); 10| drop(grains); | ------ value moved here (3) 11| 12| println!("{:?}", grains); | ^^^^^^ value borrowed here after move (4) error: aborting due to previous error (5) For more information about this error, try `rustc --explain E0382`. error: could not compile `ch1-cereals`. (6) (1) ошибка[E0382]: заимствование перемещенного значения: `grains` (2) перемещение произошло, потому что у `grains` тип `std::vec::Vec`, в котором не реализован типаж `Copy` (3) значение перемещено сюда (4) значение заимствовано здесь после перемещения (5) ошибка: прервано из-за предыдущей ошибки (6) Дополнительную информацию об ошибке можно получить, запустив команду `rustc -explain E0382` ошибка: скомпилировать `ch1-cereals` невозможно.
В листинге 1.4 показан пример состояния гонки. Если помните, это состояние возникает, когда из-за изменений внешних факторов невозможно определить характер поведения программы от запуска к запуску. Исходный код листинга находится в файле ch1/ch1-race/src/main.rs.
38
Глава 1
Листинг 1.4. Пример, в котором Rust предотвращает состояние гонки 1 use std::thread; 2 fn main() { 3 let mut data = 100; 4 5 thread::spawn(|| { data = 500; }); 6 thread::spawn(|| { data = 1000; }); 7 println!("{}", data); 8 }
(1)
(2) (2)
(1) Сведение многопоточности к локальной области видимости (2) thread::spawn() принимает в качестве аргумента замыкание
Если термин поток (thread) вам неизвестен, то суть в том, что данный код не является детерминированным. Узнать, какое значение будет у data при выходе из функции main(), невозможно. В строках 6 и 7 при вызове метода thread::spawn() создаются два потока. Каждый вызов получает в качестве аргумента замыкание, обозначенное вертикальными линиями и фигурными скобками (например, || {…} ). Поток, порожденный в строке 5, пытается установить для переменной data значение 500, а поток, порожденный в строке 6, пытается установить для переменной data значение 1000. Поскольку диспетчеризация потоков определяется не программой, а операционной системой, невозможно узнать, будет ли первым запущен тот поток, который был первым определен. Попытка компиляции кода из листинга 1.4 приводит к целому ряду тревожных сообщений об ошибках. Rust не позволяет иметь допуск по записи к данным сразу из нескольких мест приложения. Код пытается позволить себе это в трех местах: когда в основном потоке запускается main(), и по одному разу в каждом дочернем потоке, порожденном вызовом thread::spawn(). Сообщения, выданные компилятором, выглядят следующим образом: $ cargo run Compiling ch1-race v0.1.0 (rust-in-action/code/ch1/ch1-race) error[E0373]: closure may outlive the current function, but it borrows `data`, which is owned by the current function (1) --> src/main.rs:6:19 | 6 | thread::spawn(|| { data = 500; }); | ^^ ---- `data` is borrowed here (2) | | | may outlive borrowed value `data` | note: function requires argument type to outlive `static` (3) --> src/main.rs:6:5 |
Введение в Rust
39
6 | thread::spawn(|| { data = 500; }); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: to force the closure to take ownership of `data` (and any other referenced variables), use the `move` keyword (4) | 6 | thread::spawn(move || { data = 500; }); | ^^^^^^^ ... (5) error: aborting due to 4 previous errors (6) Some errors have detailed explanations: E0373, E0499, E0502. (7) For more information about an error, try `rustc --explain E0373`. error: could not compile `ch1-race`. (1) Компиляция ch1-race v0.1.0 (rust-in-action/code/ch1/ch1-race) ошибка [E0373]: замыкание может прожить дольше текущей функции, но оно заимствует переменную `data`, принадлежащую текущей функции (2) `data`, заимствованная здесь, может пережить заимствованное значение `data` (3) Примечание: чтобы пережить `static`, функции нужен тип аргумента (4) Подсказка: чтобы заставить замыкание завладеть `data` (и любой другой ссылочной переменной), воспользуйтесь ключевым словом `move` (5) Еще три ошибки опущены (6) Ошибка: прервано из-за четырех предыдущих ошибок (7) У некоторых ошибок имеются подробные объяснения: E0373, E0499, E0502. Дополнительную информацию об ошибке можно получить, запустив команду `rustc -explain E0373` ошибка: скомпилировать `ch1-race` невозможно
В листинге 1.5 показан пример переполнения буфера. Им описывается ситуация, при которой совершается попытка обращения к несуществующему или некорректному элементу памяти. В данном случае предпринимается попытка обращения к fruit[4], приводящая к сбою программы, поскольку в переменной fruit содержится только три фрукта. Исходный код листинга находится в файле ch1/ch1fruit/src/main.rs. Листинг 1.5. Пример выдачи тревожного сообщения при переполнении буфера 1 fn main() { 2 let fruit = vec!['_', '_', '_']; 3 4 let buffer_overflow = fruit[4]; 5 assert_eq!(buffer_overflow, '_') 6 }
(1) (2)
(1) В Rust вместо того, чтобы переменной было присвоено неправильное место в памяти, произойдет сбой компиляции (2) assert_eq!() проверяет равенство аргументов.
Глава 1
40
При компиляции кода листинга 1.5 будет получено следующее сообщение об ошибке: $ cargo run Compiling ch1-fruit v0.1.0 (/rust-in-action/code/ch1/ch1-fruit) Finished dev [unoptimized + debuginfo] target(s) in 0.31s Running `target/debug/ch1-fruit` thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 4', src/main.rs:3:25 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
(1)
(1) Компиляция chl-fruit v0.1.0 (/rust-in-action/code/chl/chl-fruit) Целевое действие [без оптимизации + отладочная информация] завершено за 0,31 с Запуск 'target/debug/chl-fruit' поток 'main' забил тревогу «выход индекса за допустимые границы: длина равна 3, а индекс равен 4», src/main.rs:3:25 примечание: для вывода обратной трассировки запустите команду с переменной среды окружения 'RUST BACKTRACE=l'
В следующем листинге показан пример недействительности итератора, получив шейся при проходе какого-то объекта-итератора, претерпевшего изменение уже в ходе итерации. Исходный код листинга находится в файле ch1/ch1-letteгs/src/main.rs. Листинг 1.6. Попытка в ходе итерации изменить итератор до его прохода 1 fn main() { 2 let mut letters = vec![ 3 "a", "b", "c" 4 ];
(1)
5 6
for letter in letters {
7
println!("{}", letter);
8 9
letters.push(letter.clone());
(2)
}
10 }
(1) Создание изменяемого вектора букв (2) Копирование каждой буквы и добавление ее к концу вектора letters
Код листинга 1.6 не проходит компиляцию, поскольку Rust не позволяет перемен ной l e tters изменяться в блоке итерации. Сообщение об ошибке выглядит так: $ cargo run Compiling ch1-letters v0.1.0 (/rust-in-action/code/ch1/ch1-letters) error[E0382]: borrow of moved value: `letters` (1) --> src/main.rs:8:7 |
Введение в Rust | 2 | | | | ... 6 | | | | | | | 7 | 8 | |
let mut letters = vec![ ----------- move occurs because `letters` has type `std::vec::Vec`, which does not implement the `Copy` trait for letter in letters { ------| `letters` moved due to this implicit call to `.into_iter()` help: consider borrowing to avoid moving into the for loop: `&letters` println!("{}", letter); letters.push(letter.clone()); ^^^^^^^ value borrowed here after move
41
(2)
(3)
error: aborting due to previous error For more information about this error, try `rustc --explain E0382`. error: could not compile `ch1-letters`. To learn more, run the command again with --verbose.
(4)
(1) Компиляция chl-letters v0.1.0 (/rust-in-action/code/chl/chl-letters) ошибка[Е0382]: заимствование перемещенного значения: 'letters' (2) Перемещение произо1шю из-за того, что 'letters' относится к типу 'std::vec::Vec, в котором не реализован типаж 'Сору' (3) перемещение 'letters' вызвано этим неявным вызовом ' .into_iter() '/ Подсказка: чтобы помешать перемещению в цикле for, подумайте над заимствованием: '&letters' (4) значение заимствовано здесь после перемещения. Ошибка: прервано из-за предыдущей ошибки Дополнительную информацию об ошибке можно получить, запустив команду 'rustc - explain Е0382' ошибка: скомпилировать 'chl-letters' невозможно.
Пусть язык сообщений об ошибках изобилует новыми терминами (заимствование, перемещение, типаж и т.д.), суть здесь в том, что Rust страхует программиста, уберегая его от ловушек, в которые попадают многие другие его коллеги. И не нуж но бояться: новые термины станут понятнее по мере изучения первых нескольких глав книги. Убежденность в безопасноети языка предоставляет программистам дополнитель ную степень свободы. Благодаря знанию, что крах программе не грозит, у них по является тяга к экспериментам. Эта свобода в Rust-сообществе породила выраже ние бесстрашный параллелизм.
1.6.2. Цель создания Rust: производительность Рассматривая варианты предполагаемых свойств Rust, его создатели выбрали то, что больше всего придется по нраву разработчику. Среди множества присущих языку свойств особо выделяется повышенная производительность. Но производи-
42
Глава 1
тельность программиста трудно продемонстрировать на примере, приводимом в книге. Давайте начнем с того, на чем могут споткнуться новички, - с применения присваивания(=) в выражении, где должна использоваться проверка на равенство (==): 1 fn main() { 2 let a = 10; 3 4 if a = 10 { 5 println!("a equals ten"); 6 } 7 }
В Rust предыдущий код не пройдет компиляцию. Компилятор Rust выдаст сле дующее сообщение: error[E0308]: mismatched types (1) --> src/main.rs:4:8 | 4 | if a = 10 { | ^^^^^^ | | | expected `bool`, found `()` | help: try comparing for equality: `a == 10` (2)
error: aborting due to previous error For more information aЬout this error, try 'rustc --explain ЕОЗОВ'. error: could not compile 'playground'. (3) То learn more, run the command again with --verbose. ошибка[ЕОЗОВ]: несоответствующие типы (1) ожидался 'bool', встретился '()'. Подсказка: попробуйте проверить на равенство: 'а == 10' (2) ошибка: прервано из-за предыдущей ошибки. Дополнительную информацию об ошибке можно получить, запустив команду 'rustc --explain Е0308'. Ошибка: скомпипировать 'playground' невозможно.
Поначалу сообщение об ошибке «несоответствующие ТИПЫ)) может показаться странным. Ведь можно же проверить переменные на равенство целым числам. Но если вдуматься, можно понять, почему тест i f получает неверный тип. Условие i f не получает целое число. Оно получает результат присваивания. В Rust это пус той тип: () (произносится как юнит [unit]) 14 •
14 Название «uпit)) раскрывает часть наследства Rust, являющегося потомком семейства языков программирования ML, включающего OCaml и F#. Это понятие имеет математические корни. Теоретически единственное значение имеется только у типа unit. Сравните его с булевым типом, у которого два значения, true или false, или же со строковым типом, у которого бесконечное количество допустимых значений.
Введение в Rust
43
Когда нет никакого другого значимого возвращаемого значения, выражение воз вращает () . Как показано в следующем фрагменте кода, в результате добавления в строке 4 второго знака равенства получается работоспособная программа, которая выводит на экран а equals ten: 1 fn main() { 2 let a = 10; 3 4 if a == 10 { (1) 5 println!("a equals ten"); 6 7 }
}
Использование допустимого оператора равенства (==) позволяет программе пройти компиляцию. В языке Rust имеется множество удобных свойств. В нем предлагаются обобщения, сложные типы данных, сопоставления с образцами и замыкания 15 • Кто уже работал с другими языками предварительной компиляции, вероятно, оценит систему сборки и полноценный диспетчер пакетов под названием cargo. На первый взгляд представляется, что cargo - это интерфейс rustc, Rust компилятора, но cargo предоставляет еще и ряд дополнительных утилит, в числе которых: •
cargo new, которая закладывает осцову Rust-пpoeктa еще cargo init, использующая текущий каталог).
•
cargo build,
•
cargo run,
•
cargo doc,
в новом каталоге (есть
которая загружает зависимости и компилирует код.
которая выполняет cargo build, а затем вдобавок к этому за пускает получившийся исполняемый файл.
которая создает НТМL-документацию для каждой зависимости, имеющейся в текущем проекте.
1.6.3. Цель создания Rust: управляемость Rust предлагает программистам детальный контроль над размещением структур данных в памяти и над схемами доступа к ним. В Rust, конечно же, используются весьма рациональные установки по умолчанию, соответствующие его философии «абстракций с нулевыми затратами», но они подходят не для всех ситуаций. Временами возникает острая потребность в управлении производительностью ва шего приложения. При этом важную роль может сыграть хранение данных в стеке, а не в куче. Возможно, будет смысл и в добавлении подсчета ссылок для создания общей ссылки на то или иное значение. Иногда для определенной схемы доступа 15 Даже если эти понятия вам не известны, продолжайте чтение. Объяснения последуют дальше. Это особенности языка, которые могут не встречаться в других языках программирования.
44
Глава 1
бывает полезно создать свой собственный тип указателя. Rust открывает широкий простор для проектирования и предоставляет инструменты, позволяющие реализо вать наиболее предпочтительное решение. ПРИМЕЧАНИЕ
Если понятия стека, кучи и подсчета ссылок вам еще не известны, не бросайте кни гу! Далее в ней будет выделено достаточно места для объяснения сути и порядка их совместного применения.
Код листинга 1.7 выводит строку а: 10, Ь: 20, с: 30, d: Mutex { data: 40 ). Во всех представлениях используются разные способы хранения целочисленных значений. По мере изучения следующих нескольких глав станут понятны все плю сы и минусы каждого способа. А сейчас важно запомнить, что перечень способов весьма обширен. Вы можете свободно выбирать именно тот тип, который больше всего подходит для вашего конкретного случая. В листинге 1.7 также показаны несколько способов создания целочисленных значе ний. Каждой формой предоставляется различная семантика и характеристики вре мени выполнения. Но программисты могут полностью сохранять контроль над всеми избранными компромиссами. Листинг 1.7. Несколько способов создания целочисленных значений 1 use std::rc::Rc; 2 use std::sync::{Arc, Mutex}; 3 4 fn main() {
(1)
5
let a = 10;
6
let b = Box::new(20);
(2)
7
let c = Rc::new(Box::new(30));
(3)
8
let d = Arc::new(Mutex::new(40));
(4)
9
println!("a: {:?}, b: {:?}, c: {:?}, d: {:?}", a, b, c, d);
10 }
( 1) (2) (3) (4)
Целое число в стеке Целое число в куче, также именуемое «упакованное целое число» Упакованное целое число, завернутое в счетчик ссыпок Целое число, завернутое в атомарный счетчик ссыпок и защищенное блокировкой взаимного исключения
Чтобы понять, почему Rust что-то делает так, а не иначе, будет, наверное, полезно обратиться к следующим трем принципиальным положениям: • Главный приоритет языка - безопасность. •
По умолчанию данные в Rust являются неизменяемыми.
•
Настоятельно рекомендуется проводить проверки в ходе компиляции. Безо пасность должна быть «абстракцией с нулевыми затратами)).
Введение в Rust
45
1. 7. Особые возможности Rust Мы считаем, что результат работы предопределяется используемыми инструмен тами. Rust позволяет создавать программные продукты, которые вы хотели бы, но боялись создавать. Так к каким же инструментам относится Rust? Из трех принци пиальных положений, рассмотренных в предыдущем разделе, можно вывести три наиболее значимые возможности языка: • Достижение высокой производительности. •
Выполнение одновременных (параллельных) вычислений.
•
Достижение эффективной работы с памятью.
1.7.1. Достижение высокой производительности Rust позволяет воспользоваться всей доступной производительностью вашего ком пьютера. Он знаменит тем, что не использует для обеспечения безопасности памяти сборщик мусора. К сожалению, обещание более быстрых программ упирается в фиксированную ско рость вашего центрального процессора. Поэтому, чтобы программы выполнялись быстрее, нужно уменьшить объем их работы. А между тем много места во всех смыслах занимает сам язык. Чтобы разрешить данное противоречие, Rust всецело полагается на компилятор. Сообщество Rust отдает предпочтение более объемному языку с компилятором, выполняющим больший объем работы, а не простому языку, где компилятор вы полняет меньший объем работы. Компилятор Rust тщательно оптимизирует как размер, так и скорость работы вашей программы. А еще в Rust применяется ряд менее заметных приемов: • Предоставление по умолчанию удобных для кэширования структур данных. Массивы обычно содержат данные в Rust-программах, а не в древовидных структурах с глубоким вложением, созданных с помощью указателей. Это называется программированием, ориентированным на данные. • Доступность современного диспетчера пакетов (cargo), упрощающего использование десятков тысяч пакетов с открытым исходным кодом. В этом смысле согласованность у С и С++ оставляет желать лучшего, и соз дание крупных проектов с массой зависимостей на этих языках обычно за труднено. •
Неизменная статическая диспетчеризация методов, пока не будет явного запроса на динамическую диспетчеризацию. Это позволяет компилятору проводить сильную оптимизацию кода, иногда вплоть до полного устране ния издержек на вызов функции.
46
Глава 1
1.7.2. Многопоточное выполнение программ Программистам сложно заставить компьютер выполнять несколько дел одновре менно. Если рассматривать действия операционной системы, то при серьезной ошибке программиста два независимых потока выполнения могут уничтожить друг друга. И тем не менее в сообществе Rust родилось выражение «безбоязненная кон курентность». Сфокусированность языка на безопасности позволяет преодолеть ограничения независимых потоков. При этом нет никакой глобальной блокировки интерпретатора (global interpreter lock, GIL), ограничивающей скорость потока. По следствия такого подхода будут рассмотрены во второй части книги.
1.7.3. Достижение эффективной работы с памятью Rust позволяет создавать программы, требующие минимального объема памяти. При необходимости можно воспользоваться структурами фиксированного размера и точно знать, как управляется каждый байт. Применение конструкций высокого уровня, таких как итераторы и обобщенные типы, влечет за собой минимальные издержки времени выполнения.
1.8. Недостатки Rust Было бы, конечно, проще заявить, что этот язык - универсальное средство разра ботки любых программных продуктов. Например, сказать про него следующее: •
«Высокоуровневый синтаксис в сочетании с производительностью как у низкоуровневых языков!».
•
«Параллелизм без сбоев!».
•
«Как язык С, но с идеальной безопасностью!».
Все эти тезисы (иногда явно преувеличенные), конечно, великолепны. Но при всех своих достоинствах Rust не лишен и ряда недостатков.
1.8.1. Циклические структуры данных Моделировать в Rust циклические данные, вроде произвольной структуры графа, весьма непросто. Реализация двусвязного списка - задача по информатике, ре шаемая на уровне бакалавриата. К тому же прогрессу в этой области сильно меша ют имеющиеся в Rust проверки безопасности. Новичкам на первых порах их зна комства с Rust реализации подобных структур данных лучше избегать.
1.8.2. Время, затрачиваемое на компиляцию Rust компилирует код медленнее, чем сопоставимые с ним языки. В его компиляторе имеется довольно сложная инструментальная цепочка, получающая ряд промежу точных представлений и отправляющая компилятору LLVM большой объем кода.
Введение в Rust
47
Единицей компиляции программы на Rust является не отдельный файл, а целый па кет (известный как крейт). Поскольку крейты могут включать в себя несколько мо дулей, они могут становиться весьма большим объектом компиляции. Это, конечно, позволяет оптимизировать весь крейт, но требует также его полной компиляции.
1.8.3. Строгость Лениться при программировании на Rust довольно сложно или же практически не возможно. Пока все не будет в порядке, программы просто не пройдут компиля цию. Компилятор в Rust строгий, но полезный. Возможно, со временем вы оцените эту особенность языка по достоинству. Если когда-либо уже приходилось программировать на динамическом языке, то вполне вероятно вам встречались и досадные сбои из-за неверно названной переменной. Rust вводит в подобные разочарования заранее, чтобы они не превращались во все возможные сбои, испытываемые пользователем вашей программы.
1.8.4. Объем языка Rust огромен! В нем богатая система типов, несколько десятков ключевых слов, а также имеется ряд функций, недоступных в других языках программирования. Совокупность этих факторов задает весьма крутую кривую обучения. Чтобы управ лять этим процессом, рекомендуется поэтапное изучение Rust. Следует начинать с минимального поднабора языка, выделяя по мере надобности время на усвоение подробностей. Именно такой подход принят в этой книге. Рассмотрение более серьезных понятий откладывается на более поздний срок.
1.8.5. Излишний ажиотаж Сообществом Rust не приветствуется быстрый рост приверженцев и излишняя шу миха вокруг языка. Но в почте ряда программных проектов все же оказываются письма с вопросом: «А вы не задумывались над тем, чтобы переписать все на Rust?)). К сожалению, программы на Rust - это всего лишь программы. Они не за щищены от проблем безопасности и не предлагают никакого универсального сред ства от всех бед, с которыми сталкиваются разработчики программных продуктов.
1.9. Примеры использования ТLS-безопасности Чтобы показать, что Rust не устраняет всех ошибок, рассмотрим две серьезные уяз вимости, угрожавшие чуть ли не всем устройствам, подключенным к Интернету, и посмотрим, смог ли бы Rust предотвратить их возникновение. Когда к 2015 году Rust приобрел известность, в реализациях SSL/TLS (а именно, в OpenSSL и в форке от Apple) были обнаружены серьезные бреши в безопасности, неформально названные HeartЬleed и goto fail;, обе уязвимости предоставляют возможность проверить бытующие в Rust утверждения о безопасности памяти.
48
Глава 1
Rust, скорее всего, помог бы в обоих случаях, но все же и на нем можно написать код, страдающий схожими проблемами.
1.9.1. HeartЬ/eed Уязвимость HeartЫeed, получившая официальное обозначение CVE-2014-016016, была вызвана неправильным переиспользованием буфера. Под буфером здесь по нимается пространство, зарезервированное в памяти для приема входных данных. Если между операциями записи содержимое буфера не будет очищено, то от чтения к чтению может произойти утечка данных. Почему так получается? Программисты гонятся за производительностью. Буферы переиспользуются для сведения к минимуму запросов к памяти со стороны прило жения. Представим, что нужно обработать некую секретную информацию от нескольких пользователей. По какой-то причине в ходе выполнения программы решено один и тот же буфер использовать повторно. Если после использования этот буфер не сбросить, то информация из ранее состоявшихся вызовов утечет к последующим вызовам. Вот выдержка из программы, в которой может проявиться такая ошибка: let buffer = &mut[0u8; 1024]; read_secrets(&user1, buffer); store_secrets(buffer);
(1) (2)
read_secrets(&user2, buffer); store_secrets(buffer);
(3 )
(1) Привязка к переменной buffer ссьmки (&) на изменяемый (mut) массив ([...]), содержащему 1,024 беззнаковых В-разрядных целых числа (u8), изначально установленных в О (2) Заполнение буфера байтами данных от userl (3) Буфер все еще содержит данные от userl, которые необязательно могут быть затерты данными от user2.
Rust не защищает вас от логических ошибок. Он гарантирует, что ваши данные ни когда не будут одновременно записаны в двух местах. Но не гарантирует, что ваша программа полностью избавлена от всех проблем безопасности.
1.9.2. Goto fail; Ошибка, обозначаемая, как goto fail; и получившая официальное обозначение CVE-2014-1266 17 , вызывается ошибкой программиста в совокупности с проблемами конструкции языка С (и, возможно, тем, что его компилятор не указывает на 16 17
См. «CVE-2014-0160 Detail», https://nvd.nist. gov/vuln/detail/CVE-2014-0160. См. «CVE-2014-1266 Detail», https://nvd.nist.gov/vuln/detail/CVE-2014-1266.
Введение в Rust
49
дефект). В итоге функция, разработанная для проверки пары криптографических ключей, проходит мимо всех проверок. Рассмотрим фрагмент, извлеченный из ис ходной функции S S LVer i f y S ignedServerKey E xchange и сохранивший изрядное ко личество весьма запутанного синтаксиса 18: 1 static OSStatus 2 SSLVerifySignedServerKeyExchange(SSLContext *ctx, 3
bool isRsa,
4
SSLBuffer signedParams,
5
uint8_t *signature,
6
UInt16 signatureLen)
7{ 8
OSStatus
9
...
(1)
err;
10 11
if ((err = SSLHashSHA1.update(
12
&hashCtx, &serverRandom)) != 0)
13
goto fail;
(2)
14 15
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
16
goto fail;
17 18
(3)
goto fail;
if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
19
goto fail;
20 21
err = sslRawVerify(ctx,
22
ctx->peerPubKey,
23
dataToSign,
/* plaintext \*/
24
dataToSignLen,
/* plaintext length \*/
25
signature,
26 27
signatureLen); if(err) {
28
sslErrorLog("SSLDecodeSignedServerKeyExchange: sslRawVerify "
29
"returned %d\n", (int)err);
30 31
goto fail; }
32 33 fail: 34
SSLFreeBuffer(&signedHashes);
35
SSLFreeBuffer(&hashCtx);
36
return err;
(4)
37 }
(1) Инициализация OSStatus проходным значением (например, О) (2) Серия защитных программных проверок 18
Оригинал доступен по адресу http://mng.bz/RК.Gj.
50
Глава 1
(3) Безусловный переход пропускает SSLHashSНAl.final() и (важный:) вызов sslRawVerify(). (4) Возврат проходного значения О даже для входных данных, которые бы провалили проверочный тест
Проблема в коде примера находится между строк 15 и 17. В языке С логические тесты не требуют фигурных скобок. Компиляторы С интерпретируют эти три стро ки следующим образом: if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0) { goto fail; } goto fail;
Поможет ли в такой ситуации Rust? Возможно. В данном конкретном случае имеющийся в Rust парсер выловил бы ошибку. Он не допускает логические тесты без фигурных скобок. Rust также выдает предупреждение, когда код недоступен. Но это еще не значит, что допустить ошибку в Rust просто невозможно. Когда про граммистов поджимают сроки, они склонны совершать ошибки. Как правило, по добный код проходит компиляцию и может быть запущен на выполнение. СОВЕТ Программируйте осмотрительнее.
1.1О. Для чего Rust подходит лучше всего? Хотя Rust разрабатывался как язык системного программирования, он все же язык общего назначения. Этот язык успешно применяется во многих рассматриваемых далее областях.
1.10.1. В утилитах командной строки Программистам, создающим утилиты командной строки, Rust дает три основных преимущества: минимальное время запуска, низкий уровень потребления памяти и простое развертывание. Программы быстро включаются в работу, поскольку Rust не нуждается в инициализации интерпретатора (как Python, Ruby и т.д.) или в вир туальной машине (как Java, С# и т.д.). Будучи языком чисто аппаратного уровня, Rust позволяет создавать программы, эффективно распоряжающиеся памятью 19 • Далее в книге будет показано, что мно гие типы имеют нулевой размер. То есть, по сути, они являются подсказками ком пилятору и вообще не потребляют память в запущенной на выполнение программе. Утилиты, написанные на Rust, по умолчанию компилируются как статические дво ичные файлы. Этот метод компиляции позволяет избежать зависимости от совме стно используемых библиотек, требующих установки перед запуском программы. 19
Бытует шутка, что Rust (ржавчина) максимально приближен к металлу.
Введение в Rust
51
Создание программ, запускаемых без установочных этапов, облегчает их распро странение.
1.10.2. В обработке данных Rust отлично справляется с текстом и с другими формами обработки данных. Про граммисты извлекают пользу из контроля над использованием памяти и из быстро го запуска. В середине 2017 года Rust предлагался как самый быстрый в мире обра ботчик регулярных выражений. В 2019 году проект по обработке данных Apache Апоw, положенный в основу экосистем обработки и анализа данных Python и R, принял проект DataFusion на основе Rust. Rust также положен в основу реализации нескольких поисковых систем, машин обработки данных и систем анализа регистрационных журналов. Его система типов и управление памятью позволяют создавать конвейеры данных с высокой пропуск ной способностью при низком и стабильном объеме используемой памяти. С по мощью потоковой передачи данных Apache Stoпn, Apache Kafka или Apache Hadoop небольшие по объему программы фильтрации могут легко встраиваться в более крупные платформы.
1.10.3. В расширяемых приложениях Rust хорошо приспособлен для расширения программ, написанных на динамиче ских языках. Он позволяет создавать расширения для JNI (Java Native Interface), для С или для Erlang/Elixir NIF-функций (native implemented functions - нативных реа лизованных функций). А С-расширения, как правило, отпугивают программистов. Расширения обычно довольно тесно интегрированы в среду выполнения. Стоит ошибиться и столкнешься с обвальным потреблением памяти из-за ее утечки или же со сбоем всей системы. Rust во многом избавляет от подобных беспокойств. • Sentry, компания, занимающаяся обработкой ошибок приложений, считает Rust великолепным кандидатом для того, чтобы заново написать на нем те компоненты системы, что написаны на Python и интенсивно используют ре сурсы центрального процессора20 • • Dropbox переписали на Rust механизм синхронизации файлов своего при ложения на стороне клиентов: «Укротить сложность синхронизации нам помогла не столько производительность [Rust], сколько эргономика и сфо кусированность на корректности»21 •
1.10.4. В средах с ограниченными ресурсами Область работы с микроконтроШiерами десятилетиями бьша оккупирована языком С. Но пришли времена интернета вещей (Internet of Things, IoT). И это может озна20
21
См. «Fixing Python Perfonnance with Rust)), http://mng.bz/ryxX. См. «Rewriting the heart of our sync engine)), http://mng.bz/Vdv5.
52
Глава 1
чать, что в сети окажутся многие миллиарды потенциально незащищенных вещей. Любой входной код синтаксического анализа будет регулярно проверяться на на личие слабых мест. Учитывая, насколько редко происходят обновления прошивки для этих устройств, очень важно, чтобы они были как можно более безопасными с самого начала. Rust может сыграть здесь весьма важную роль, добавив еще один уровень безопасности без дополнительных издержек времени выполнения.
1.10.5. В серверных приложениях Основная часть приложений, написанных на Rust, размещается на сервере. Они мо гут обслуживать веб-трафик или отвечать за поддержку бизнес-процессов, запус кающих свои операции. Есть также уровень сервисов, располагающихся между операционной системой и вашим приложением. Rust используется для написания систем управления базами данных, систем отслеживания, поисковых устройств и систем обмена сообщениями. Например: • На Rust написан реестр пакетов для сообществ JavaScript и node.js22 • •
Встраиваемая база данных sled (https://github.com/spacejam/sled) может на 16-ядерной машине справляться с рабочей нагрузкой в 1 миллиард опера ций, среди которых 5% - это операции записи.
•
Система полнотекстового поиска Tantivy может на 4-ядерном ПК проин дексировать 8 Гб английской Википедии примерно за 100 секунд23 •
1.10.6. В приложениях для ПК В конструкции Rust нет ничего, что помешало бы развернуть его для разработки программного обеспечения, ориентированного на пользователя. Приложением, ориентированным на пользователя, является Servo, механизм веб-браузера, слу живший инкубатором для ранней разработки Rust. Естественно, к этой категории приложений можно отнести и игры.
1.10.7. В автономном режиме Востребованность приложений, автономно работающих на персональных компью терах, сохраняется. Подобные приложения зачастую отличаются сложностью, трудностью в разработке и поддержке. Учитывая удобный подход к развертыванию и строгость Rust, этот язык, пожалуй, станет особой приправой для многих прило жений. Поначалу такие приложения на Rust будут создаваться независимыми раз работчиками, в том числе одиночками. По мере становления Rust будет развиваться и его экосистема в этой области. 22
См. «Community makes Rust an easy choice for npm: The npm Registry uses Rust for its CPU-bound bottlenecks», http://mng.bz/xm9B. 23 См. «Of tantivy's indexing», https://fulmicoton.com/posts/Ьehold-tantivy-part2/.
Введение в Rust
53
1.10.8. В мобильных приложениях Android, iOS и другие операционные системы для смартфонов, как правило, пре доставляют разработчикам весьма широкие возможности. В Android это возможно сти Java. В macOS разработчики обычно программируют на Swift. Но есть ведь и другой путь. Обе платформы позволяют запускать оптимизированные под них приложения. Чтобы приложения, такие как игры, могли развертываться на смартфонах, расчет делается на их разработку на С++. Rust может общаться с телефоном посредством того же интерфейса, причем без дополнительных издержек времени выполнения.
1.10.9. В веб-режиме Для вас, наверное, не секрет, что JavaScript - главный язык для веба. Но грядут изменения. Производители браузеров разрабатывают стандарт под названием WebAssemЬly (Wasm), который обещает обеспечить поддержку компиляции под множество языков. И Rust здесь в числе первых. Для переноса Rust-пpoeктa в брау зер требуются всего лишь две дополнительные команды командной строки. Иссле дования по использованию Rust в браузере посредством Wasm уже ведутся не сколькими компаниями, в частности CloudFlare и Fastly.
1.10.10. В системном программировании Вообще в системном программировании кроется сам смысл существования Rust. На Rust реализовано множество крупных программ, включая компиляторы ( самого Rust), движки видеоигр и операционные системы. В сообщество Rust входят созда тели генераторов парсеров, баз данных и форматов файлов. Rust оказался весьма продуктивной средой для программистов, разделяющих цели этого языка. В число трех выдающихся проектов в этой области входят: • Спонсируемая компанией Google разработка операционной системы для устройств Fuchsia OS24. • Активно изучаемая компанией Microsoft возможность написания на языке Rust низкоуровневых компонентов для Windows25• • Создаваемый в рамках Amazon Web Services (AWS) проект Bottlerocket, специализированной операционной системы для размещения контейнеров в облаке26 •
24
См. «Welcome to Fuchsia!», https://fuchsia.dev/. 25 См. «Using Rust in Windows», http://mng.bz/AOvW. 26
См. «Bottlerocket: Linux-based operating system purpose-built to run containers», https://aws.amazon.com/Ьottlerocket/.
54
Глава 1
1.11. Скрытая фишка Rust: его сообщество Для становления языка программирования одних программ недостаточно. Несо мненным успехом команды разработчиков Rust стало взращивание позитивного и доброжелательного сообщества языка. Куда ни пойди в мире Rust, везде встретишь вежливое и уважительное отношение.
1.12. Разговорник по Rust Общаясь с участниками Rust-сообщества, неизменно сталкиваешься с рядом поня тий, имеющих особое толкование. Как только удастся разобраться со следующими понятиями, станет легче осознать, почему развитие Rust пошло по собственному, оригинальному пути и почему были намечены именно те проблемы, которые с его помощью пытаются решить: • Доверие каждому - к участию приглашаются все программисты, незави симо от способностей или накопленного опыта. Программирование и, в ча стности, системное программирование, не должно быть уделом избранных. • Невероятная быстрота - Rust - быстрый язык программирования. На нем можно будет писать программы, соответствующие или превосходящие по производительности программы на сопоставимых с ним языках, но при этом получать более весомые гарантии безопасности. •
Безбоязненная конкурентность - многопоточное и параллельное про граммирование всегда считалось трудной задачей. Rust освобождает от це лого класса ошибок, преследующих концептуально близкие ему языки.
•
Никакого Rust 2. О - написанный сегодня код на языке Rust будет компили роваться и завтрашним Rust-компилятором. Rust задуман как надежный язык программирования, на который можно будет положиться спустя деся тилетия. Благодаря семантическому управлению версиями Rust никогда не утратит обратную совместимость, поэтому у него никогда не будет выпу щена новая основная версия.
•
Абстракции с нулевыми затратами - возможности, получаемые от Rust, не требуют затрат среды выполнения. Программируя на Rust, не нужно жертвовать скоростью в пользу безопасности.
Резюме ♦ Многие компании успешно реализовали на Rust довольно крупные программные проекты. ♦ Созданные на Rust программные продукты могут компилироваться для персо нального компьютера, браузера и сервера, а также для мобильных устройств и приборов, подключенных к интернету вещей.
Введение в Rust
55
♦ Язык Rust пришелся по душе разработчикам программного обеспечения. В оп росе разработчиков в Stack Overflow он неоднократно удостаивался титула «Са мый любимый язык программирования». ♦ Rust позволяет проводить эксперименты, не опасаясь негативных последствий. Он предоставляет гарантии корректности, на что без дополнительных затрат среды выполнения не могут пойти другие инструменты. ♦ Работая с Rust, нужно освоить всего лишь три инструментальных средства командной строки: ♦ Команда cargo, управляющая всем контейнером ♦ Команда rustup, управляющая установками Rust ♦ Команда rustc, управляющая компиляцией исходного кода на Rust ♦ В проекты на Rust могут вкрадываться ошибки, как и в любые другие. ♦ Код на языке Rust отличается стабильностью, быстротой выполнения и нетребо вательностью к ресурсам среды выполнения.
Часть I Особенности языка Rust Первая часть книги представляет собой краткое введение в язык программирования Rust. Изучив ее главы, можно будет получить неплохое представление о синтаксисе Rust и понять, что побуждает специалистов останавливать свой выбор на Rust. Также можно будет разобраться в сути некоторых фундаментальных отличий Rust от сопоставимых с ним языков.
2
Основы языка
В этой главе рассматриваются следующие вопросы: ♦ Освоение синтаксиса Rust. ♦ Изучение основных типов и структур данных. ♦ Создание утилит командной строки. ♦ Компиляция программ. Эта глава знакомит с основами программирования на Rust. Завершив ее изучение, вы получите возможность создавать утилиты командной строки и усвоите суть большинства программ на Rust. Здесь будет проработана основная часть синтакси са языка, но подробности того, почему все делается именно так, а не иначе, будут отложены на более поздний период освоения книги.
ПРИМЕЧАНИЕ
Наибольшую пользу от этой главы вынесут программисты с опытом работы на других языках программирования. Те же, кто имеет опыт работы на Rust, могут просто бегло просмотреть ее содержимое.
Новички здесь приветствуются. Сообщество Rust старается проявлять по отноше нию к ним особую отзывчивость. Временами, конечно, можно вне контекста осту питься на таких понятиях, как опускание времени жизни (lifetime elision), гигиени ческий макрос (hygienic macros), семантика перехода (move semantics) и алгебраи ческие типы данных (algebraic data types). Не стесняйтесь запросить помощь. Сообщество куда более гостеприимно, чем можно предположить, созерцая эти по лезные, но непонятные термины. В этой главе будет создана grep-lite - существенно урезанная версия широко рас пространенной утилиты grep. Наша программа grep-lite будет предназначена для поиска шаблонов в тексте и вывода на печать совпадающих строк. Ее простота по зволит сосредоточиться на уникальных особенностях языка Rust. В главе будет использован спиральный подход к обучению. Некоторые концепции будут обсуждаться по нескольку раз. С каждым новым проходом знания будут на ращиваться. На рис. 2.1 показана карта главы, не имеющая ничего общего с науч ным подходом. Настоятельно рекомендуется следовать приводимым в книге примерам. Для досту па к исходному коду листингов или для его загрузки можно воспользоваться лю бым из следующих двух источников: • https://manning.com/Ьooks/rust-in-action • https://github.com/rust-in-action/code
60
Глава 2 Сложные типы: struct и enum Управление ходом выполнения: if/else, match и организация циклов
Коллекции: векторы, массивы и кортежи
Функции и методы
Утилиты Rust: cargo и rustc
Элементарные типы: целые числа, текст и т.д. Инструментарий создания проектов: контейнеры и сторонние библиотеки
Рис. 2.1. Схематическое представление тем, рассматриваемых в главе. Начиная с элементарных типов, в главе будут последовательно с углублением представления рассмотрены несколько важных концепций
2.1. Создание работоспособной программы В каждом простом текстовом файле могут скрываться сверхвозможности: когда он состоит из надлежащих символов, его можно преобразовать во что-либо, интерпре тируемое центральным процессором. В этом, собственно, и состоит магия языка программирования. Цель этой главы - познакомить читателя с процессом преоб разования исходного кода Rust в работающую программу. Освоение этого процесса намного интереснее, чем кажется! И это настраивает на увлекательное и весьма познавательное путешествие. В конце пятой главы будет реализован виртуальный центральный процессор, который, кроме всего прочего, сможет интерпретировать созданные вами программы.
2.1.1. Компиляция одиночных файлов с помощью утилиты rustc В листинге 2.1 показана короткая, но все же полноценная Rust-пporpaммa. Для ее преобразования в работоспособный программный продукт будет использовано программное средство под названием компилятор. Его роль состоит в превращении исходного кода в машинный, а также в том, чтобы позаботиться о множестве раз нообразных формальностей, убеждающих операционную систему и центральный процессор в том, что они имеют дело с работоспособной программой. Rust-компи лятор называется rustc. Исходный код листинга 2.1 находится в файле ch2/ok.rs.
Основы языка
61
Листинг 2.1. Наверное, самая короткая работоспособная программа на Rust 1 fn main() { 2
println!("OK")
3 }
Для компиляции одиночного файла, написанного на Rust, в работоспособную про грамму выполните следующие действия: 1. Сохраните исходный код в файле. В данном случае воспользуйтесь именем фай ла ok.rs. 2. Убедитесь в том, что в исходном коде присутствует функция main (). 3. Откройте окно оболочки, например Tenninal, cmd.exe, Powershell, bash, zsh или какого-нибудь другого программного средства. 4. Выполните команду rustc , где - компилируемый файл. При успешной компиляции rustc не отправляет вывод на консоль. Он закулисно создает, как ему и положено, исполняемый файл, используя для имени выходного файла имя входного файла. Предположив, что код листинга 2.1 сохранен в файле ok.rs, давайте посмотрим, как выглядит весь этот процесс. Его краткая демонстрация представлена в следующем фрагменте кода: $ rustc ok.rs $ ./ok OK
(1)
(1) При работе под Windows указывается расширение имени файла - .ехе (например, ok.exe).
2.1.2. Компиляция Rust-npoeктoв с использованием cargo Чаще всего Rust-npoeкты размещаются в нескольких файлах. В них, как правило, включаются зависимости. Чтобы подготовиться к этому, воспользуемся инстру ментом, который выше rustc по уровню и носит название cargo. Этот инструмент способен управлять утилитой rustc (и делать многие другие вещи). Переход с рабочего процесса с одним файлом, проходящего под управлением rustc, на рабочий процесс, управляемый cargo, осуществляется в два этапа. Сначала ис ходный файл перемещается в пустой каталог, а затем выполняется команда cargo init.
Если предположить, что для начала берется файл ok.rs, созданный при выполнении действий, рассмотренных в предыдущем разделе, то подробный обзор данного процесса выглядит так: 1. Запускаем команду mkdir для создания пустого каталога (например, mkdir ok).
62
Глава 2
2. Перемещаем исходный код в каталог (например, mv ok. rs ok). 3. Меняем текущий каталог на (например, cd ok).
4. Запускаем команду cargo ini t.
Теперь для выполнения исходного кода проекта можно запустить команду cargo run. От работы с rustc это отличается, во-первых, тем, что скомпилированные ис полняемые файлы находятся в подкаталоге /target. А во-вторых, тем, что cargo выдает по умолчанию гораздо больший объем информации: $ cargo run Finished dev [unoptimized + debuginfo] target(s) in 0.03s Running `target/debug/ok` OK
Если заинтересуетесь закулисными делами cargo по управлению rustc, добавьте в свою команду ключ вывода подробностей ( -v) $ rm -rf target/ (1) $ cargo run -v Compiling ok v0.1.0 (/tmp/ok) Running `rustc --crate-name ok --edition=2018 ok.rs --error-format=json --json=diagnostic-rendered-ansi --crate-type bin --emit=dep-info,link -C embed-bitcode=no -C debuginfo=2 -C metadata=55485250d3e77978 -C extra-filename=-55485250d3e77978 --out-dir /tmp/ok/target/debug/deps -C incremental=/tmp/target/debug/incremental -L dependency=/tmp/ok/target/debug/deps -C link-arg=-fuse-ld=lld` Finished dev [unoptimized + debuginfo] target(s) in 0.31s Running `target/debug/ok` OK (1) Добавлено, чтобы спровоцировать cargo на компиляцию проекта с нуля
2.2. Взгляд на синтаксис Rust Rust банален и предсказуем везде, где только возможно. В нем есть переменные, числа, функции и другие известные составляющие, встречающиеся в других язы ках. Например, блоки в нем ограничиваются фигурными скобками ( { и } ), в каче стве оператора присваивания используется одинарный знак равенства ( = ), и этому языку совершенно безразличны пробелы.
Основы языка
63
2.2.1. Определение переменных и вызов функций Взглянем на другой короткий листинг и познакомимся с рядом основ: определени ем переменных с аннотациями типов и вызовом функций. Код листинга 2.2 выво дит на консоль а + ь = зо. В строках 2-5 этого листинга показано несколько син таксических вариантов для аннотации целочисленных типов данных. На практике нужно использовать тот из них, который больше всего подходит к конкретной си туации. Исходный код этого листинга находится в файле ch2/ch2-first-steps.rs. Листинг 2.2. Сложение целых чисел с использованием переменных и объявлением типов 1 fn main() { 2 let a = 10; 3 let b: i32 = 20; 4 let c = 30i32; 5 let d = 30_i32; 6 let e = add(add(a, b), add(c, d)); 7 8 println!("( a + b ) + ( c + d ) = {}", e); 9 } 10 11 fn add(i: i32, j: i32) -> i32 { 12 i + j 13 }
(1) (2)
(3) (4) (5)
(6) (7)
(1) ( 2) i 3) (4) 15)
Rust допускает гибкость относительно места расположения функции main() Типы могут быть выведены компилятором ... ... или объявлены программистом при создании переменных Числовые типы могут вк.точать аннотацию типа в ее буквальном виде Числа могут вк.точать знаки подчеркивания, предназначенные для улучшения читаемости и не оказывающие никакого функционального воздействия ( 6) Объявления .типов нужны при определении функций (7) Функции возвращают результат вычисления последнего выражения, то есть в ключевом слове return нет необходимости
ПРИМЕЧАНИЕ В этом листинге нужно остерегаться добавления к объявлению функции a dd() точки с запятой, поскольку это изменит семантику и приведет к возвращению () (unit-тиna), а не i32 Несмотря на то, что в листинге 2.2 всего лишь 13 строк кода, у него весьма богатое содержимое. Далее следует краткое описание того, что в нем происходит. А более подробный анализ будет изложен в остальной части главы. В строке 1 (fn maiп () {) с ключевого слова fn начинается определение функции. Точка входа во все Rust-программы - функция main ( J • Она не получает никаких
64
Глава 2
аргументов и не возвращает значений1 • Блоки кода, также известные как лексиче ские области видимости, определяются фигурными скобками: { и ). В строке 2(let а = 10;) let используется для привязки перемеююй. По умолча нию переменные неизменяемы, то есть предназначены только для чтения, а не для чтения-записи. А в конце инструкций ставится разделитель в виде точки с запятой(;). В строке 3( 1 е t ь : iз 2 = 2 о;) показано, что компилятору можно указывать кон кретный тип данных. Порой без этого просто не обойтись, поскольку компилятор не в состоянии за вас определить уникальный тип данных. В строке 4(let с = ЗОiЗ2;) показано, что числовые литералы Rust могут включать аннотации типов. Это может пригодиться при проходе по сложным числовым вы ражениям. А в строке 5(let с = зо_i32;) показано, что Rust позволяет использо вать с числовыми литералами знак подчеркивания. Это улучшает читаемость кода, но для компилятора не имеет никакого значения. В строке 6 (let е = add (add (а, Ь), add (с, d));) легко заметить, что вызов функции выглядит так же, как и в большинстве других языков программирования. Bcтpoкe8(println!("( а + Ь) + ( с + d ) = {)", e);) ee чacтьprintln!() является макросом, похожим на функцию, но возвращающим не значения, а код. При выводе информации на консоль у каждого типа входных данных есть свой собственный способ представления в виде текстовой строки. Макрос println! ) ( сам разбирается, какие именно методы следует вызывать для своих аргументов. В строках используются двойные("), а не одинарные кавычки('). А одинарные кавычки используются в Rust для отдельных символов, являющихся особым типом под названием char. При форматировании строк Rust в качестве поля для заполне ния использует {), а не С-подобныйprintf-cтиль с %s или другие варианты форма тирования. И, наконец, в строке 10(fn add (...) -> i32 !) показано, что синтаксис Rust для определения функций точно такой же, как и у тех языков программирования, в ко торых используется явное объявление типов. Параметры разделяются запятыми, а объявления типов следуют за именами переменных. Знак кинжала(->), называе мый также синтаксисом тонкой стрелки, указывает на тип возвращаемого значения.
2.3. Числа Компьютеры были связаны с числами куда дольше того времени, за которое вы об рели способность внятного произнесения фразы «транслятор формул)). В этом раз деле рассматриваются способы создания в Rust числовых типов и порядок прове дения с ними различных операций. 1 Технически это не совсем корректно, но на данном этапе вполне допустимо. Опытные Rust проrраммисты, знающие, что main() по умолчанию возвращает ) ( (unit-тип), а также может возвращать Result,могут оrраничиться беглым просмотром данной главы.
Основы языка
65
2.3.1. Целые и десятичные (с плавающей точкой) числа Для создания целых чисел (1, 2, ... ) и чисел с плавающей точкой (1 . о, 1.1, ...) в Rust используется довольно традиционный синтаксис. В операциях с числами применяется инфиксная нотация, означающая, что числовые выражения выглядят так, как многие привыкли их видеть в большинстве языков программирования. При работе с несколькими типами в Rust допускается применение для сложения одного и того же знака (+). Этот прием называется перегрузкой операторов. Ниже пере числяется ряд заметных отличий от других языков: • В языке Rust имеется большое количество числовых типов. У вас выработа ется привычка объявлять размер в байтах, что окажет влияние на количество чисел, представляемых типом, и на то, сможет ли он представлять отрица тельные числа. • Преобразования между типами всегда носят явный характер. Rust не про изводит автоматическое преобразование 16-разрядного целого числа в 32-раз рядное. • В Rust у чисел могут быть методы. Например, для округления 24. s к бли жайшему целому числу Rust-программисты используют не традиционный подход (r ound( 24. 5_f32) ), а вызов метода 24. 5_f32. round (). Здесь наличие суффикса типа объясняется необходимостью указания конкретного типа. Для начала рассмотрим небольшой пример. Его код находится в файле ch2/ch2-intro to-numbers.rs в сборнике примеров для этой книги. Код листинга 2.3 выводит на кон соль несколько строк: 20 + 21 + 22 = 63 1000000000000 42
Листинг 2.3. Числовые литералы и основные операции над числами в Rust 1 fn main() { 2 let twenty = 20; (2) 3 let twenty_one: i32 = 21; 4 let twenty_two = 22i32; (3) 5 6 let addition = twenty + twenty_one + twenty_two; 7 println!("{} + {} + {} = {}", twenty, twenty_one, twenty_two, addition); 8 (4) 9 let one_million: i64 = 1_000_000; (5) 10 println!("{}", one_million.pow(2)); 11 (6 ) 12 let forty_twos = [ 13 42.0, (7) 14 42f32, (8)
66
Глава 2 (9)
15 42.0_f32, 16 ]; 17 18 println!("{:02}", forty_twos[0]); 19 }
(10)
(1) (2) (3) (4) (5) (6)
Rust выводит тип за вас, если вами ничего не предложено ... ... путем добавления аннотаций типа (i32) ... ... или суффиксов типа Знаки подчеркивания улучшают читаемость и игнорируются компилятором У чисел есть методы Создание массива из чисел, где все они должны быть одного типа, путем заюnочения их в квадратные скобки (7) литералы чисел с плавающей точкой без явно указанной аннотации типа становятся 32- или 64-разрядными в зависимости от контекста. (8) литералы чисел с плавакщей точкой могут также иметь суффиксы типа ... (9) ... и необязательные знаки подчеркивания (10) Элементы массивов могут быть проиндексированы числами, начиная с О (нуля)
2.3.2. Записи целых чисел с основанием 2, 8 и 16 В Rust также имеется встроенная поддержка числовых литералов, позволяющая определять целые числа по основанию 2 (двоичные), по основанию 8 (восьмерич ные) и по основанию 16 (шестнадцатеричные). Эта нотация также доступна при форматировании таких макросов, как println ! . В коде листинга 2.4 показаны три стиля. Исходный код этого листинга находится в файле ch2/ch2-non-base2.rs. Он выводит на консоль следующий текст: base 10: 3 30 300 base 2:
11 11110 100101100
base 8:
3 36 454
base 16: 3 1e 12c
Листинг 2.4. Использование числовых литералов по основаниям 2, 8 и 16 1 fn main() { 2
let three = 0b11;
3
let thirty = 0o36;
4
let three_hundred = 0x12C;
(1) (2) (3)
5 6
println!("base 10: {} {} {}", three, thirty, three_hundred);
7
println!("base 2:
{:b} {:b} {:b}", three, thirty, three_hundred);
8
println!("base 8:
{:o} {:o} {:o}", three, thirty, three_hundred);
9
println!("base 16: {:x} {:x} {:x}", three, thirty, three_hundred);
10 }
Основы языка
67
(1) Префикс ОЬ указывает на двоичные (по основанию 2) записи чисел (2) Префикс Оо указывает на восьмеричные (по основанию В) записи чисел (3) Префикс Ох указывает на шестнадцатеричные (по основанию 16) записи чисел В двоичных (по основанию 2) записях чисел ОЫ l равно 3, поскольку 3 = 2 х 1 + 1 х 1. В восьмеричных (по основанию 8) записях чисел ОоЗб равно 30, поскольку 30 = 8 х 3 + 1 х 6. А в шестнадцатеричных (по основанию 16) записях чисел Ох12С равно 300, поскольку 300 = 256 х 1 + 16 х 2 + 1 х 12. Типы, представляющие скалярные числа, показаны в таблице 2.1. Таблица 2.1. Rust-munы для представления скалярных (одиночных) чисел
i32, i64
Целые числа со знаком в диапазоне от 8 до 64 разрядов.
uB, ulб, u32, u64
Целые числа без знака в диапазоне от 8 до 64 разрядов.
f32, f64
Числа с плавающей точкой в 32-разрядном и 64-разрядном вариантах.
isize, usize
Целые числа, предполагающие «исходную» разрядность центрального процессора. Например, в 64-разрядных центральных процессорах usize и isize будут шириной 64-разряда.
iB,
ilб,
Rust содержит полный набор числовых типов, сгруппированных в несколько се мейств: • Целые числа со знаком ( i), которые представляют как отрицательные, так и положительные целые числа. • Целые числа без зн_ака (u), которые представляют только положительные це лые числа, но могут быть вдвое шире своих собратьев со знаком. • Типы с плавающей точкой {f), которые представляют действительные числа со специальными комбинациями битов для представления значений беско нечности, отрицательной бесконечности и «не числю> («not а number»). Ширина целочисленного значения - это количество битов, используемых типом в оперативной памяти или в центральном процессоре. Типы, занимающие больше пространства, например u32 в сравнении с iB, могут представлять более широкий .::щапазон чисел. Но, как показано в таблице 2.2, это влечет за собой издержки, свя занные с необходимостью хранить для меньших чисел дополнительные нули. Таблица 2.2. Одно и то же число может быть представлено несколькими комбинациями битов Число
Тип
Битовая комбинация в памяти
20
u32
00000000000000000000000000010100
20 20
i8
fЗ2
00010100
01000001101000000000000000000000
68
Глава 2
Хотя речь у нас шла только о числах, мы практически уже получили достаточное представление о Rust, чтобы приступить к созданию прототипа нашей программы сопоставления с образцом. Но прежде чем заняться программой, посмотрим, что собой представляет сравнение чисел.
2.3.3. Сравнение чисел Числовыми типами Rust поддерживается большой набор сравнений, с которым вы, наверное, уже знакомы. Возможность поддержки этих сравнений обеспечивается особенностью языка, которая здесь еще не встречалась. Это так называемый типаж (или трейт)2 • Доступные операторы сравнения сведены в таблицу 2.3. Таблица 2.3. Математические операторы,
поддерживаемые числовыми типами Rust
Оператор
Синтаксис Rust
Пример
Меньше, чем()
>
2.0 > 1.0
Равно(=)
==
1.0= 1.0
Не равно(:;t)
!=
1.0 != 2.0
Равно или меньше, чем(�)
= 1.0
Эта поддержка не обходится без ряда оговорок. Соответствующие условия будут рассмотрены в оставшейся части раздела.
Невозможность проведения сравнения разных типов
Имеющиеся в Rust требования к безопасности типов не позволяют проводить срав нения между типами. Например, следующий код не пройдет компиляцию: fn main() { let a: i32 = 10; let b: u16 = 100; if a < b { println!("Ten is less than one hundred."); } }
2 Для особо дотошных: здесь задействованы типажи std: : cmp: : PartialOrd и std: : cmp: : PartialEq.
Основы языка
69
Чтобы задобрить компилятор, нужно воспользоваться оператором приведения опе ранда одного типа к другому типу. Такое приведение типа: ь as i 32 показано в следующем примере кода: fn main() { let a: i32 = 10; let b: u16 = 100; if a < (b as i32) { println!("Ten is less than one hundred."); } }
Безопаснее всего привести меньший тип к большему (например, 16-разрядный тип к 32-разрядному). Иногда это называют расширением. В данном случае мож но было бы провести сужение до Ulб, но это, как правило, более рискованное действие. ВНИМАНИЕ
Неосмотрительное приведение типов вызывает неожиданное поведение программы. Например, выражение 300 i 32 as iB возвращает 44.
Порой использование ключевого слова as накладывает слишком большие ограни чения. Восстановить более полный контроль над приведением типов можно за счет введения ряда формальностей. В следующем листинге показан Rust-мeтoд, заме няющий ключевое слово as в тех случаях, когда приведение может дать сбой. Листинг 2.5. Метод tr _into() выполняет преобразование типов 1 use std::convert::TryInto; (1) 2 3 fn main() { 4 let a: i32 = 10; 5 let b: u16 = 100; 6 7 let b_ = b.try_into() 8 .unwrap(); (2) 9 10 if a < b_ { 11 println!("Ten is less than one hundred."); 12 } 13 }
(1) Метод try_into() можно вызвать для тех типов, в которых он реализован (например, для ulб) (2) Метод try_into() возвращает тип Result, предоставляющий доступ к результату попытки преобразования
70
Глава 2
В листинге 2.5 представлены две новые концепции Rust: типажи и обработка оши бок. В строке 1 используется ключевое слово use, которое переносит типаж std: :convert::Tryinto в локальную область видимости. В результате этого про исходит разблокирование метода try_into (), вызываемого в отношении перемен ной ь. Здесь мы обойдемся без полного объяснения, почему так происходит. А пока считайте типаж своеобразной коллекцией методов. Читатели с опытом работы в объектно-ориентированных средах могут рассматривать типажи как абстрактные классы или интерфейсы. Если же имеется опыт программирования на функцио нальных языках, типажи можно рассматривать как классы типов. В строке 7 дается представление об обработке ошибок в Rust. Метод ь. try_into () возвращает значение типа i32, завернутое в значение типа Result, который будет представлен в главе 3. В нем может содержаться либо значение успеха, либо значе ние ошибки. Значение успеха может быть обработано методом unwrap (), в резуль тате чего здесь будет возвращено значение ь, имеющее тип i32. Если преобразова ние между ulб и i32 прошло неудачно, то вызов метода unsafe () приведет к сбою программы. Далее в книге будут изучены безопасные способы работы с данными типа Result, позволяющие не рисковать стабильностью работы программы. Отличительная особенность Rust - то, что он позволяет вызывать методы типа только тогда, когда типаж находится в локальной области видимости. Его предва рительное введение в эту область позволяет воспользоваться обычными операция ми, например сложением и присваиванием, без явного импорта. СОВЕТ
Чтобы понять, что входит в локальную область видимости по умолчанию, следует изу чить модуль std::prelude. Его документацию можно найти в сети по адресу https://doc.rust-lang.org/std/prelude/index.html.
Опасности, связанные с использованием чисел с плавающей точкой Типы чисел с плавающей точкой (например, f32 и f64) по неосмотрительности мо гут вызвать серьезные проблемы. Этому есть по крайней мере две причины: • Зачастую они соответствуют представляемым числам только приблизи тельно. Типы с плавающей точкой реализованы числами по основанию 2, но вычисления довольно часто требуется проводить в числах по основанию 1 О. Это несоответствие создает неопределенность. И еще, хотя значения с пла вающей точкой часто описываются как представляющие действительные числа, они имеют ограниченную точность. Для представления всех действи тельных чисел требуется бесконечная точность. • Они могут представлять значения, семантика которых не воспринимается на интуитивном уровне. В отличие от целых чисел, типы с плавающей точ кой имеют ряд значений, которые (конструктивно) плохо сочетаются друг с другом. Формально у них только частичное отношение эквивалентности.
Основы языка
71
Все это запрограммировано в системе типов Rust. Для типов f 32 и f64 реали зован только типаж std: :cmp: :Partia lEq, а для других числовых типов реа лизован также типаж std: :cmp:: Eq. Для предотвращения опасностей нужно следовать двум рекомендациям: • Избегать проверок чисел с плавающей точкой на равенство. • Проявлять осторожность, когда результаты могут быть математически неопределенными. Использование равенства для сравнения чисел с плавающей точкой может быть весьма проблематичным. Числа с плавающей точкой реализованы компьютерными системами, использующими двоичную (по основанию 2) математику, но от них часто требуется выполнять операции над десятичными (по основанию 10) числами. Проблема возникает из-за того, что многие важные для нас значения, например о .1, не имеют точного представления в двоичной системе счисления3 • Чтобы понять, в чем суть проблемы, рассмотрим следующий фрагмент кода. Будет ли он успешно выполнен или даст сбой? Вычисляемое выражение ( о . 1 + о . 2 == 0.3) является математической тавтологией, но при выполнении в большинстве сис тем оно дает сбой: fn main() { assert!(0.1 + 0.2 == 0.3);
(1)
}
(1) Вызов assert! приводит к сбою программы несмотря на то, что его аргумент вычисляетс я в true
Но это еще не все. Оказывается, тип данных может повлиять на успешное выпол нение программы или на ее сбой. Следующий код, доступный в файле ch2/ch2-add floats.rs, опрашивает внутренние комбинации битов каждого значения, чтобы найти различия. Затем он выполняет тест из предыдущего примера для типов f32 и fб4. Но проходит только один тест: 1 fn main() { 2 let abc: (f32, f32, f32) = (0.1, 0.2, 0.3); 3 let xyz: (f64, f64, f64) = (0.1, 0.2, 0.3); 4 5 println!("abc (f32)"); 6 println!(" 0.1 + 0.2: {:x}", (abc.0 + abc.1).to_bits()); 7 println!(" 0.3: {:x}", (abc.2).to_bits()); 8 println!(); 9 10 println!("xyz (f64)"); 11 println!(" 0.1 + 0.2: {:x}", (xyz.0 + xyz.1).to_bits()); 12 println!(" 0.3: {:x}", (xyz.2).to_bits()); 13 println!();
3 Возможно, это трудно себе представить, но многие значения, например 1/3 (одна треть), не имеют точного представления и в десятичной системе счисления.
72
Глава 2
14 15
assert!(abc.0 + abc.1 == abc.2);
(1)
16
assert!(xyz.0 + xyz.1 == xyz.2);
(2)
17 }
(1) Вьmолняется успешно (2) Приводит к сбою
При выполнении программа успешно выдает следующий краткий отчет, выявляю щий ошибку. После этого она дает сбой. Что примечательно, программа вьmетает в строке 14 при сравнении результата значений типа fб4: abc (f32) 0.1 + 0.2: 3e99999a 0.3: 3e99999a xyz (f64) 0.1 + 0.2: 3fd3333333333334 0.3: 3fd3333333333333 thread 'main' panicked at 'assertion failed: xyz.0 + xyz.1 == xyz.2', ➥ch2-add-floats.rs.rs:14:5 note: run with `RUST_BACKTRACE=1` environment variable to display (1) ➥a backtrace
(1) поток 'main' запаниковал: «сбой проверочного утверждения: xyz. О + xyz. 1 == xyz.2», ch2-add-floats.rs.rs:14:5 ПРИМЕЧАНИЕ:
для вывода обратной трассировки запустите команду с переменной среды окружения
. RUST_BACKTRACE=1'
Вообще-то безопаснее тестировать попадание математических операций в прием лемый допуск их истинного математического результата. Этот допуск часто назы вают эпсилоном. Rust включает ряд допусков, позволяющих сравнивать числовые значения с пла вающей точкой. Эти допуски определяются как f32: : EPSILON и fб4: : EPSILON. Что бы прояснить ситуацию, можно посмотреть на закулисное поведение Rust в сле дующем небольшом примере: fn main() { let result: f32 = 0.1 + 0.1; let desired: f32 = 0.2; let absolute_difference = (desired - result).abs(); assert!(absolute_difference > и «мнимую» части и обозначаются как + i5 • Например, 2.1 + -1.2i - это отдельно взятое комплексное число. На этом закончим с математикой и взглянем на код. Чтобы откомпилировать и запустить код листинга 2.6, сделайте следующее: 1. Выполните в терминале следующие команды: git clone action
--depth=l https://github.com/rust-in-action/code rust-in
cd rust-in-action/ch2/ch2-complex
cargo run
2. Для тех, кто предпочитает учиться, совершая самостоятельные действия: к тому же конечному результату приведут следующие инструкции: • Выполните в терминале следующие команды: cargo new ch2-complex cd ch2-compl ex
• Добавьте контейнер num версии 0.4 в раздел [ dependenci e s J файла Cargo.toml. Этот раздел должен приобрести следующий вид: [dependencies]
num = "0.4"
• Замените содержимое файла src/main.rs исходным кодом из листинга 2.6 (на ходится в файле ch2/ch2-complex/src/main.rs). • Выполните команду cargo run.
После нескольких промежуточных выводов информации команда cargo run долж на вывести на экран следующий текст: 13.2 + 21.02i Листинг 2.6. Вычисление значений с комплексными числами 1 use num::complex::Complex;
(1)
2 3 fn main() { 4
let a = Complex { re: 2.1, im: -1.2 };
5
let b = Complex::new(11.1, 22.2);
6
let result = a + b;
(2) (3)
7 8
println!("{} + {}i", result.re, result.im)
9 }
5
Инженеры-механики используют j, а не i.
(4)
Основы языка
(1) (2) (3) (4)
75
Ключевое слово use помещает тип Complex в локальную область видимости. У каждого типа в Rust имеется литеральный синтаксис. Статический метод new() реализован в большинстве типов. Доступ к полям с помощью оператора точки
Некоторые детали листинга требуют более пристального рассмотрения: • Ключевое слово use вводит контейнеры в локальную область видимости, а оператор пространства имен(::} ограничивает предмет импорта. В на шем случае требуется только один тип: Complex.
• В Rust нет конструкторов, вместо этого у каждого типа есть литеральная форма. Инициализировать типы можно путем использования имени типа (complex) и присвоения их полям (re, im) значений (например, 2 .1 или -1. 2) в фигурных скобках ( { 1 ). • Для упрощения программ метод глашение не часть языка Rust.
new ()
реализован у многих типов. Но это со
• Для доступа к полям Rиst-программистами используется оператор точки (.). Например, у типа num: :complex::Complex два поля: re представляет дей ствительную часть, а im - мнимую. Оба они доступны через оператор точки. В листинге 2.6 представлены также несколько новых команд. Ими демонстрируют ся две формы инициализации неэлементарных типов данных. Одна является литеральным синтаксисом, доступным как часть языка (строка 4). Другая - статическим методом new () , реализованным только по соглашению и не определенным как часть языка (строка 5). Статический метод - это функция, доступная для типа, но не для экземпляра этого типа6• Часто в реальном коде предпочтительнее вторая форма, поскольку авторы библио тек используют принадлежащий типу метод new () для установки значений по умолчанию. К тому же код получается менее загроможденным.
Упрощенный способ добавления к проекту сторонней зависимости Чтобы включить подкоманду c argo add, рекомендуется установить контейнер cargo-edit. Это можно сделать с помощью следующего кода: $ cargo install cargo-edit Updating crates.io index Installing cargo-edit v0.6.0 ... Installed package `cargo-edit v0.6.0` (executables `cargo-add`, `cargo-rm`, `cargo-upgrade`) 6 Хотя Rust не объектно-ориентированный язык (к примеру, в нем невозможно создать подчиненный класс), в нем используется часть терминологии из этой области. Нередко можно услышать, как Rust программисты обсуждают экземпляры, методы и объекты.
76
Глава 2
До сих пор зависимости к файлу Cargo.toml добавлялись вручную. Команда cargo a d d упрощает этот процесс, выполняя корректное редактирование файла от вашего имени: $ cargo add num Updating 'https://github.com/rust-lang/crates.io-index' index Adding num v0.4.0 to dependencies
Рассмотрев способы доступа к встроенным числовым типам и к тем типам, что дос тупны из сторонних библиотек, перейдем к обсуждению некоторых других воз можностей Rust.
2.4. Управление ходом выполнения программы Программы выполняются сверху вниз, кроме тех случаев, когда это вам не нужно. Чтобы изменить ход выполнения программы, в Rust имеется полноценный набор соответствующих механизмов. В этом разделе представлен их краткий обзор.
2.4.1. For: основной механизм итераций Цикл for в Rust-paбoчaя лошадка итераций. Последовательный перебор элемен тов коллекций, включая те из них, которые могут содержать бесконечное количест во значений, осуществляется довольно просто. Базовая форма имеет следующий вид: for item in container { // ... }
Эта базовая форма делает каждый последующий элемент в контейнере container доступным в качестве элемента i tem. В этом плане Rust копирует множество дина мических языков с простым в использовании высокоуровневым синтаксисом. Но здесь есть свои подводные камни. Вопреки интуитивным представлениям, как только блок заканчивается, очередной доступ к контейнеру становится некорректным. Несмотря на то, что переменная container остается в локальной области видимости, теперь ее время жизни истек ло. По причинам, объясняемым в главе 4, Rust считает, что раз блок закончился, то надобность в переменной container миновала. Когда чуть позже в программе возникнет желание воспользоваться переменной container еще раз, следует воспользоваться указателем. И опять же по причинам, объясняемым в главе 4, когда указатель опущен, Rust полагает, что переменная containe r больше не нужна. Чтобы добавить указатель на контейнер, нужно, как показано в следующем примере, поставить перед его именем знак амперсанда (&): for item in &container { // ... }
Основы языка
77
Если в ходе циклического перебора элементов нужно внести изменения в каждый элемент, можно воспользоваться указателем, допускающим изменения, включив в код ключевое слово mu t: for item in &mut collection { // ... }
Вдаваясь в подробности реализации Rust-конструкции цикла for, следует отме тить, что она расширяется компилятором в вызов метода. В следующей таблице показано, что каждая из трех форм for отображается на свой собственный метод (табл. 2.4). Таблица 2.4. Краткие формыfоr
Краткая форма
Ее эквивалент
Доступ
for item in col lection
for item in Intoiterator:: into iter(col lection )
По факту владения
for item in &collection
for item in collection. iter()
Только по чтению
for item in &mut co llection
for item in col lection.iter_mut ()
По чтению и записи
Безымянные циклы Если в блоке не используется локальная переменная, то по соглашению применяет ся знак подчеркивания (_). Использование этой схемы в сочетании с синтаксисом _исключающего диапазона_ (n .. m) и синтаксисом включающего диапазона (n .. =m) показывает, что целью является выполнение цикла фиксированное количество раз. Например: for _ in 0..10 { // ... }
Отказ от управления индексной переменной Во многих языках программирования привычное дело - использование последо вательных переборов путем использования временной переменной, увеличиваю щейся в конце каждой итерации. По соглашению эта переменная называется i (от слова индекс). Rust-вepcия этой схемы выглядит следующим образом: let collection = [1, 2, 3, 4, 5]; for i in 0..collection.len() { let item = collection[i]; // ... }
Это вполне легальный код на языке Rust. Данная схема важна и в тех случаях, ко гда последовательный перебор collection напрямую, с применением кода for
78
Глава 2
i tem in collection, невозможен. Но делать это обычно не рекомендуется. При неавтоматизированном подходе возникают две проблемы: • Производительность - индексирование значений с использованием синтак сиса collection [index] не обходится без издержек времени выполнения из за проверки границ. То есть, Rust проверяет, что индекс на данный момент присутствует в коллекции в виде достоверных данных. При непосредствен ном проходе элементов коллекции collection такая проверка не нужна. Что бы удостовериться в невозможности запрещенного доступа, компилятор мо жет включить анализ в ходе компиляции. • Безопасность - периодическое от случая к случаю обращение к коллекции collection чревато тем, что в нее могут быть внесены изменения. Непосред ственное использование в отношении collection цикла for позволяет Rust гарантировать, что collection останется в неприкосновенности со стороны других частей программы.
2.4.2. Continue: пропуск оставшейся части текущей итерации Ключевое слово continue действует вполне ожидаемым образом. Пример его ис пользования выглядит так: for n in 0..10 { if n % 2 == 0 { continue; } // ... }
2.4.3. While: выполнение цикла, пока не изменится состояние условия Цикл while продолжается до тех пор, пока выполняется условие. Условие, офици ально известное как предикат, может быть любым выражением, вычисляемым в true или false. Следующий (нерабочий) фрагмент кода берет пробы качества воз духа, чтобы избежать аномалий: let mut samples = vec![]; while samples.len() < 10 { let sample = take_sample(); if is_outlier(sample) { continue; } samples.push(sample); }
Основы языка
79
Использование while для остановки итерации по истечении времени В листинге 2.7 (исходный код которого находится в файле ch2/ch2-while-true-incr count.rs) предоставляется работоспособный пример использования while. Это, ко нечно, не идеальный метод реализации тестов производительности, но он может оказаться полезным средством в вашем наборе инструментов. В листинге выполне ние блока while продолжается, пока не исчерпан лимит времени. Листинг 2.7. Тестирование скорости увеличения счетчика вашим компьютером 1 use std::time::{Duration, Instant}; 2 3 fn main() { 4 let mut count = 0; 5 let time_limit = Duration::new(1,0); 6 let start = Instant::now(); 7 8 while (Instant::now() - start) < time_limit { 9 count += 1; 10 } 11 println!("{}", count); 12 }
(1)
(2) (3) ( 4)
(1) Эта форма импортирования нам пока не попадалась. Она переносит типы Duration и Instant из std::time в локальную область видимости (2) Создание значения продолжительности Duration, представляющего 1 секунду (3) Считывание времени по системным часам (4) Instant минус Instant возвращает продолжительность Duration
Отказ от применения while при создании бесконечных циклов Многие Rust-программисты не рискуют использовать следующую идиому для вы ражения бесконечного цикла. Предпочтительнее воспользоваться ключевым сло вом loop, рассматриваемым в следующем разделе. while true { println!("Are we there yet?"); }
2.4.4. Loop: основа для циклических конструкций Rust В Rust есть ключевое слово loop, предоставляющее более весомый контроль, чем при использовании циклов for и while. Предназначение loop - выполнять блок кода снова и снова, не делая перерывов на чай (или кофе). loop продолжает выпол нение, пока не встретится ключевое слово break или программа не будет прервана извне.
80
Глава 2
Синтаксис loop выглядит так: loop { // ... }
Как показано в следующем примере, loop часто встречается при реализации серве ров с долговременным режимом работы: loop { let requester, request = accept_request(); let result = process_request(request); send_response(requester, result); }
2.4.5. Break: прерывание цикла Ключевое слово break приводит к прерыванию цикла. При этом Rust работает при вычным образом: for (x, y) in (0..).zip(0..) { if x + y > 100 { break; } // ... }
Прерывания во вложенных циклах Прервать выполнение вложенного цикла можно с помощью меток циклов7 • Метка цикл,а, как показано в следующем примере, представляет собой идентификатор с префиксом в виде апострофа ( • ): 'outer: for x in 0.. { for y in 0.. { for z in 0.. { if x + y + z > 1000 { break 'outer; } // ... } } }
В Rust отсутствует ключевое слово goto, дающее возможность перехода в другие части программы. Оно может запутать контроль над ходом выполнения програм мы, поэтому его использование не рекомендуется. Единственное место, где такой 7
Такая же возможность предоставляется ключевым словом continue, но эта практика получила меньшее распространение.
Основы языка
81
прием широко используется, - это переход к разделу функции и его очищение при обнаружении условия ошибки. Чтобы воспользоваться этой схемой, нужно приме нять метки циклов.
2.4.6. lf, if else и else: условное ветвление Ранее мы уже занимались увлекательным поиском чисел в списке. При этом в про верке было задействовано ключевое слово i f. Пример его использования выглядит следующим образом: if item == 42 { // ... }
if допускает применение любого выражения, вычисляемого в булево значение (true или false). Когда нужно протестировать несколько значений, можно доба вить цепочку блоков if else. Блок e l se соответствует всему, чему еще не нашлось
соответствие. Например:
if item == 42 { // ... } else if item == 132 { // ... } else { // ... }
В Rust отсутствует концепция «правдивых» или «ложных>> типов. В других языках допускается, чтобы особые значения, например о или пустая строка, означали false, а другие значения означали true, но в Rust это не практикуется. Единствен ным значением, которое может быть true , является true, а за false может прини маться только f а 1 sе. Rust - язык, основанный на выражениях Так уж исторически сложилось в языках программирования, что все выражения возвращают значения, и почти все в них - выражения. Это наследие проявляется в некоторых конструкциях, недопустимых в других языках. В следующем фрагменте кода показано, что для Rust характерно обходиться в функциях без ключевого сло ва return: fn is_even(n: i32) -> bool { n % 2 == 0 }
И еще пример: Rust-программисты присваивают переменным значения из условных выражений: fn main() { let n = 123456; let description = if is_even(n) { "even"
82
Глава 2
} else { "odd" }; println!("{} is {}", n, description); // На экран выводится текст "123456 is even" }
Этот прием может распространяться и на другие блоки, включая match: fn main() { let n = 654321; let description = match is_even(n) { true => "even", false => "odd", }; println!("{} is {}", n, description); // На экран выводится текст "654321 is odd" }
Еще неожиданнее может показаться, что ключевым словом break также возвраща ется значение. Этим можно воспользоваться, чтобы позволить возвращать значения «бесконечным» циклам: fn main() { let n = loop { break 123; };
println!("{}", n); // На экран выводится текст "123" }
Можно спросить: а какие части Rust не являются выражениями и, следовательно, не возвращают значений? Выражениями не являются инструкции. В Rust они появ ляются в трех местах: • В выражениях, разделенных точкой с запятой(;). • При привязке имени к значению с помощью оператора присваивания (= ). • При объявлениях типов, куда включаются функции (fn) и типы данных, созданные с помощью ключевых слов struct и enum.
Официально первая форма называется инструкцией-выражением. А две остальные формы называются инструкциями-объявлениями. Отсутствие значения в Rust пред ставлено как () (unit-тип).
2.4.7. Match: соответствие образцу с учетом типов В Rust можно, конечно, воспользоваться блоками if-else, но match предоставляет этим блокам безопасную альтернативу. Если подходящий вариант не рассматри вался, match выдает предупреждение.
Основы языка
83
Кроме того, этот блок отличается наглядностью и лаконичностью: match item { 0
=> {},
(1)
10 ..= 20
=> {},
(2)
40
=> {},
(3)
=> {},
(4)
|
80
_ }
(1) Дпя поиска соответствия отдельно взятому значению предоставляется само значение. Оператор здесь не требуется. (2) Синтаксис .. = задает поиск соответствия включающему диапазону. (3) Вертикальная черта (1) задает поиск соответствия любому из двух значений, указанных по обе стороны от нее. (4) Знак подчеркивания (_) соответствует любому значению.
Блок match предлагает для тестирования нескольких возможных значений весьма развитый и лаконичный синтаксис. В качестве примеров можно привести: • Включающие диапазоны ( 1 о ..= 2 о) для задания соответствия любому зна чению внутри диапазона. • Булеву операцию «Илю) - OR ( 1) для задания соответствия любому из ука занных по обе стороны значений. • Знак подчеркивания U для соответствия чему угодно. аналог ключевого слова switch, встречающегося в других языках. Но в отличие от swi tch в языке С, match гарантирует, что все возможные варианты для типа будут обработаны явным образом. Отсутствие ответвления хода программы для каждого возможного типа вызывает ошибку компиляции. Кроме того, ma tch не «проваливается)) по умолчанию к следующему варианту. Как только соответствие будет найдено, он тут же возвращает управление основной программе. В листинге 2.8 показан более масштабный пример использования ma tch. Его ис ходный код находится в файле ch2/ch2-match-needles.rs. Программа выводит на экран следующие две строки:
match -
42: hit! 132: hit!
Листинг 2.8. Использование match для совершения действий при нахождении соответствия сразу несколькими значениями
1 fn main() { 2 let needle = 42; (1) 3 let haystack = [1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862]; 4 5 for item in &haystack { 6 let result = match item { (2)
84
Глава 2
7 42 | 132 => "hit!", 8 _ => "miss", 9 }; 10 11 if result == "hit!" { 12 println!("{}: {}", item, result); 13 } 14 } 15 }
(3) (4)
(1) Теперь переменная needle уже ЛИlllliЯЯ. (2) Это выражение match возвращает значение, которое может быть привязано к переменной. (3) Успех! 42 1 132 соответствует как 42, так и 132. (4) Джокер, соответствукщий чему угодно
Ключевое слово match играет в языке Rust весьма важную роль. С применением match выполнены внутренние определения многих управляющих структур (в част ности, циклов). А в сочетании с типом Option, который более подробно рассматри вается в следующей главе, match открывается нам во всей своей красе.
Как следует разобравшись с определением чисел и с приемами работы с некото рыми имеющимися в Rust механизмами управления ходом выполнения программы, давайте перейдем к наращиванию структуры программ с помощью функций.
2.5. Определение функций Вернемся к началу главы, где фрагмент кода из листинга 2.2 содержал небольшую функцию по имени add(). Она получает два значения типа i 32 и возвращает их сумму. Повторение кода функции показано в следующем листинге. Листинг 2.9. Определение функции (извлечение из листинга 2.2)
10 fn add(i: i32, j: i32) -> i32 { 11 i + j 12 }
(1)
(1) Функция add() получает два целочисленных параметра и возвращает целое число. Два аргумента привязываются к локальным переменным i и j.
Давайте пока сфокусируем внимание на синтаксисе каждого элемента листинга 2.9. На рис. 2.2 представлена визуализация каждой из частей. Разобраться в схеме смо жет любой специалист, имеющий опыт программирования на языках со строгой типизацией.
Основы языка
85
Rust-функции требуют указания типов параметров и типа возвращаемого функцией значения. Это те самые фундаментальные сведения, которые необходимы для большей части нашей работы с языком Rust. Давайте применим их к нашей первой более-менее серьезной программе. Идентификатор
Возврат
Параметры
fn add(i: i32, j: i32) -> i32 { Тип
Ключевое слово Идентификатор
Тип
Идентификатор
Эта стрелка показывает возврат Начало блока кода
Рис. 2.2. Синтаксис определения функций, применяемый в Rust
2.6. Использование указателей Если в предыдущей деятельности вам приходилось иметь дело только с динамиче скими языками программирования, синтаксис и семантика указателей могут вос приниматься с трудом. Возможно, будет непросто сложить мысленную картину происходящего. Из-за этого будет сложно понять, какие символы и куда нужно по мещать. К счастью, компилятор Rust - хороший тренер. Указатель - это значение, заменяющее другое значение. Представим, к примеру, что переменной является большой массив, дублирование которого влечет за собой солидные издержки. В некотором смысле указатель r - дешевая копия а. Но вме сто создания дубликата программа сохраняет адрес а в памяти. Когда требуются данные из а, указатель r можно разыменовать и открыть доступ к а. Соответст вующий код показан в следующем листинге. Листинг 2.10. Создание указателя на большой массив
fn main() let a = let r = let b =
{ 42; &a; a + *r;
println!("a + a = {}", b); }
(1) (2)
(3)
(1) r - указатель на а. (1) Добавление а к а (через разыменование r) и присваивание результата переменной Ь (2) Выводит на экран "а + а = 84"
86
Глава 2
Указатели создаются с помощью оператора указателей ( & ), а их разыменование происходит с помощью оператора разыменований ( *). Эти операторы действуют как унарные, то есть у них только один операнд. Один из недостатков исходного кода, написанного в виде АSСП-текста, заключается в совпадении символов умно жения и разыменования. Посмотрим все это в действии в качестве составной части более серьезного примера. В листинге 2.11 выполняется поиск числа ( needle, определенного в строке 2) в чи словом массиве (haystack, определенном в строке 3). После чего, будучи скомпи лированным, код выводит на консоль число 4 2. Код листинга находится в файле ch2/ch2-needle-in-haystack.rs.
Листинг 2.11. Поиск целого числа в целочисленном массиве
1 fn main() { 2 let needle = 0o204; 3 let haystack = [1, 1, 2, 5, 15, 52, 203, 877, 4140, 21147]; 4 5 for item in &haystack { (1) 6 if *item == needle { (2) 7 println!("{}", item); 8 } 9 } 10 }
(1) Последовательный (2) перебор указателей на элементы внутри haystack (3) Синтаксис *item возвращает объект указателя item. При каждой итерации значение item изменяется так, чтобы получался указатель на следующий элемент в haystack. При первой итерации *item возвращает 1, а при последней- 21147.
2.7. Проект: визуализация множества Мандельброта Пока еще в изучении Rust пройдены только первые шаги, но уже есть набор инст рументов, позволяющий создавать интересные картинки фракталов. Этим сейчас и займемся, воспользовавшись кодом листинга 2.12. А для начала сделаем следующее: 1. Для создания проекта визуализации множества Мандельброта выполним в окне терминала следующие команды: •
cd $ТМР (или cd %ТМР% в MS Windows)- для перехода в нейтральный каталог.
•
cargo new mandelbrot --vcs попе - для создания нового пустого проекта.
•
cd Maпdelbrot - для перехода в корневой каталог нового проекта.
Основы языка
87
cargo add num- для редактирования файла Cargo.toml и добавления в каче стве зависимости контейнера num (инструкции по включению этой функции в cargo изложены во врезке «Упрощенный способ добавления к проекту сто ронней зависимости» в разделе 2.3.4). 2. Заменим содержимое файла src/main.rs кодом листинга 2.12, который находится в файле ch2/ch2-mandelbrot/src/main.rs. •
3. Выполним команду cargo run. На экране терминала должно появиться множе ство Мандельброта: .................................•••*•**•............ ...................................•••***•••............... ..................................•••***+%+***•................ ...............................••••••••*$%%%%%*••••••.............. .............................••**+*••******%%%*****+•••••*•.......... .............................•••••*%%+*%%%%%%%%%%%%%%%x*+*+*••........... ............................•••••**++%%%%%%%%%%%%%%%%%%%%%%**••............ ................•*•••••••••••••••*+%%%%%%%%%%%%%%%%%%%%%%%%%%*•••............ ...............•••***•••**••••••*+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%+•............ ................••••*+%*%#xx%****x%%%%%%%%%%%%%%%%%%%%%%%%%%%%%**•............. ..............••••*++%%%%%%%%%%+*%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%*•.............. .......••••••••**+**%%%%%%%%%%%%+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%*••.............. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%*••••.............. .......••••••••**+**%%%%%%%%%%%%+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%*••.............. ..............••••*++%%%%%%%%%%+*%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%*•.............. ................••••*+%*%#xx%****x%%%%%%%%%%%%%%%%%%%%%%%%%%%%%**•............. ...............•••***•••**••••••*+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%+•............ ................•*•••••••••••••••*+%%%%%%%%%%%%%%%%%%%%%%%%%%*•••............ ............................•••••**++%%%%%%%%%%%%%%%%%%%%%%**••............ .............................•••••*%%+*%%%%%%%%%%%%%%%x*+*+*••........... .............................••**+*••******%%%*****+•••••*•.......... ...............................••••••••*$%%%%%*••••••.............. ..................................•••***+%+***•................ ...................................•••***•••...............
Листинг 2.12. Поиск целого числа в целочисленном массиве
1 use num::complex::Complex; 2 3 fn calculate_mandelbrot( 4 5 max_iters: usize, 6 x_min: f64, 7 x_max: f64, 8 y_min: f64, 9 y_max: f64, 10 width: usize, 11 height: usize, 12 ) -> Vec {
(1) (2) (3)
(4) (4) (4) (4) (5) (5)
88
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
Глава 2
let mut rows: Vec = Vec::with_capacity(width); for img_y in 0..height {
(7)
let mut row: Vec = Vec::with_capacity(height); for img_x in 0..width { let x_percent = (img_x as f64 / width as f64); let y_percent = (img_y as f64 / height as f64); let cx = x_min + (x_max - x_min) * x_percent; let cy = y_min + (y_max - y_min) * y_percent; let escaped_at = mandelbrot_at_point(cx, cy, max_iters); row.push(escaped_at); } all_rows.push(row); } rows } fn mandelbrot_at_point( cx: f64, cy: f64, max_iters: usize, ) -> usize { let mut z = Complex { re: 0.0, im: 0.0 }; let c = Complex::new(cx, cy); for i in 0..=max_iters { if z.norm() > 2.0 { return i; } z = z * z + c; } max_iters } fn render_mandelbrot(escape_vals: Vec) { for row in escape_vals { let mut line = String::with_capacity(row.len()); for column in row { let val = match column { 0..=2 => ' ', 2..=5 => '.', 5..=10 => '•', 11..=30 => '*',
(9)
(10) (11) (12) (13)
(14)
Основы языка
89
59 30..=100 => '+', 60 100..=200 => 'x', 61 200..=400 => '$', 62 400..=700 => '#', 63 _ => '%', 64 }; 65 66 line.push(val); 67 } 68 println!("{}", line); 69 } 70 } 71 72 fn main() { 73 let mandelbrot = calculate_mandelbrot(1000, 2.0, 1.0, -1.0, 74 1.0, 100, 24); 75 76 render_mandelbrot(mandelbrot); 77 } (1) Импортирование числового типа Complex из контейнера num и его подмодуля complex (2) Преобразование пространства вывода (сетки из строк и столбцов) в диапазон, окружающий множество Мандельброта (сплошное пространство возле точки (0,0)) (3) Если значение не исчезло до достижения максимального числа итераций, оно считается входящим в множество Мандельброта (4) Параметры, определякщие искомое нами пространство для поиска составлякщих множества (5) Параметры, представлякщие размер вывода в пикселях (6) Создание контейнера для хранения данных из каждой строки (7) Построчная итерация, позволяющая выполнять построчный вывод (8) Вычисление доли пространства, покрытого нашим выводом, и преобразование его в точки в пространстве поиска (9) Вызывается в отношении каждого пикселя (например, каждой строки и столбца, которые выводятся на стандартное устройство) (lО)Инициализация комплексного числа в начале координат с действительной (re) и мнимой (im) частями, установленными в О.О (11) Инициализация комплексного числа из координат, предоставляемых в качестве аргументов функции (12) Проверка условия выхода и вычисление расстояния от начала координат (0,0) в виде абсоrоотного значения комплексного числа (13) Многократное изменение z, чтобы проверить, находится ли с в множестве Мандельброта (14) Поскольку i больше не в области видимости, мы возврашаемся к max iters.
Итак, в этом разделе основы Rust были переведены в плоскость практического применения. Давайте продолжим наши исследования и изучим способы определе ния функций и типов.
90
Глава 2
2.8. Расширенные определения функций В функциях Rust может попадаться кое-то пострашнее, чем add (i: i32, j: i32) > i32 из листинга 2.2. Чтобы помочь тем, кому чаще приходится читать исходный код на Rust, нежели его создавать, в следующих разделах предоставляются допол нительные сведения.
2.8.1. Явные аннотации времени жизни Прежде всего позвольте ввести ряд более сложных обозначений. При чтении кода на языке Rust могут встретиться трудно воспринимаемые определения, похожие на иероглифы древних цивилизаций. В листинге 2.13 показан фрагмент листинга 2.14 с одним из таких примеров. Листинг 2.13. Сигнатура функции с явными аннотациями времени жизни
1 fn add_with_lifetimes(i: &'a i32, j: &'b i32) -> i32 { 2 *i + *j 3 }
На первых порах разобраться в том, что происходит в незнакомом синтаксисе, бы вает довольно сложно. Но со временем все проясняется. Начнем с объяснения про исходящего, а потом разберемся в его причинах. Следующий перечень состоит из разбора всех составляющих кода первой строки: •
этот фрагмент уже должен быть знаком. Из него можно сделать вывод, что add_with_lifetimes () - функ ция, возвращающая значение типа i32.
fn add_with_lifeti mes (...)
-> i32 -
• < ' а, ' Ь> - объявление двух переменных времени жизни, ' а и 'ь, в области видимости функции add_with_li fetimes (). Обычно о них говорят как о вре мени жизни а и времени жизни Ь. • i: & 'а i32 - привязка переменной времени жизни 'а к времени жизни i. Этот синтаксис читается как «параметр i является указателем на i32 с време нем жизни а>>. i з 2 - привязка переменной времени жизни ' ь к времени жизни j . Этот синтаксис читается как «параметр j является указателем на i32 с време нем жизни ь». Зачем привязывать переменную времени жизни к значению, пока что, наверное, непонятно. Основа проводимых в Rust проверок безопасности - система времени жизни, позволяющая убедиться, что все попытки обращения к данным являются допустимыми. Аннотации времени жизни позволяют программистам заявить о сво их намерениях. Все значения, привязанные к данному времени жизни, должны су ществовать вплоть до последнего доступа к любому значению, привязанному к этому же времени жизни. •
j:
& 'ь
Основы языка
91
Обычно система времени жизни работает без посторонней помощи. Хотя время жизни есть почти у каждого параметра, проверки в основном проходят скрытно, поскольку компилятор может определить время жизни самостоятельно8• Но в сложных случаях компилятору нужна помощь. Нередко попадаются функции, по лучающие в качестве аргументов сразу несколько указателей или возвращающие указатель, и тогда компилятор запросит помощь, выдав сообщение об ошибке. При вызове функции аннотации времени жизни не требуются. В примере, приве денном в полном объеме (см. следующий листинг), аннотации времени жизни можно увидеть в определении функции (строка 1 ), но не при ее использовании (строка 8). Исходный код листинга находится в файле ch2-add-with-lifetimes.rs. Листинг 2.14. Типичная сигнатура функции с явно указанными аннотациями времени жизни
1 2 3 4 5 6 7 8 9 10 11
fn add_with_lifetimes(i: &'a i32, j: &'b i32) -> i32 { (1) *i + *j } fn main() let a = let b = let res
{ 10; 20; = add_with_lifetimes(&a, &b);
(2)
println!("{}", res); }
(1) Сложение значений, на которые указывают i и j, а не сложение непосредственно самих указателей (2) &10 и &20 означают указатели соответственно на 10 и 20. При вызове функции обозначать время жизни не нужно.
В строке 2 - * i + * j - складываются значения, на которые ссылаются указатели, хранящиеся в переменных i и j . Обычно параметры времени жизни можно увидеть при использовании указателей. В иных случаях Rust может самостоятельно вывес ти времена жизни, но указатели требуют, чтобы программист обозначил свои наме рения. Использование двух параметров времени жизни (а и ь) показывает, что вре мена жизни i и j не связаны друг с другом. ПРИМЕЧАНИЕ Параметры времени жизни - это способ предоставить программисту возможность управления складывающейся ситуацией при сохранении кода, присущего высокоуров невым языкам.
8 Формально факт отказа от использования аннотации времени жизни называется пропуском времени жизни.
92
Глава 2
2.8.2. Обобщенные функции Еще один особый вид синтаксиса функций применяется при написании программи стами Rust-функций, предназначенных для обработки множества возможных типов вводимых данных. Пока нами рассматривались только функции, принимающие 32-разрядные целые числа (iз2). В следующем листинге показана сигнатура функ ции, которую можно вызывать со многими типами вводимых данных, при условии, что все они будут одного и того же типа. Листинг 2.15. Типовая сигнатура обобщенной функции fn add(i: T, j: T) -> T { i + j }
(1)
(1) Переменная типа Т вводится в угловых скобках (). Эта функция принимает два аргумента одного и того же типа и возвращает значение такого же типа.
Заглавные буквы вместо типа указывают на обобщенный тип. В соответствии с действующим соглашением в качестве заместителей используются произвольно выбираемые переменные т, u и v. А переменная Е часто применяется для обозначе ния типа ошибки. Более подробно обработка ошибок будет рассмотрена в главе 3. Обобщения позволяют использовать код многократно и могут существенно повы сить удобство работы со строго типизированным языком. К сожалению, код лис тинга 2.15 не пройдет компиляцию. Компилятор Rust пожалуется, что он не может сложить два значения любого типа т. Вот что он выдаст при попытке компиляции кода листинга 2.15: error[E0369]: cannot add `T` to `T` --> add.rs:2:5 | 2 | i + j | - ^ - T | | | T | help: consider restricting type parameter `T` | 1 | fn add(i: T, j: T) -> T { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
(1)
(2)
error: aborting due to previous error
(3)
For more information about this error, try `rustc --explain E0369`.
( 4)
(1) ошибка[Е0369]: невозможно прибавить 'Т' к 'Т' (2) подсказка: попробуйте ограничить параметр типа 'Т'
Основы языка
93
(3) ошибка: прервано из-за пре.ш,щущей ошибки (4) Дополнительную информацию об ошибке можно получить, запустив команду 'rustc - explain Е0369'
Дело в том, что т означает вообще любой тип, куда входят даже те типы, сложение в которых не поддерживается. На рис. 2.3 предоставляется визуальное отображение проблемы. В коде листинга 2.15 предпринимается попытка сослаться на внешнее кольцо, а сложение поддерживается только типами, находящимися во внутреннем кольце.
Все типы
Типы, поддерживающие сложение путем реализации std::ops::Add
Рис. 2.3. Операторы реализации имеются только у подмножества типов. При создании обобщенных функций, включающих такой оператор, типаж этого оператора должен быть включен в качестве типажного ограничения
А как указать, что в типе т должно быть реализовано сложение? Ответ на этот во прос требует введения новой терминологии. Все Rust-oпepaтopы, включая сложение, определены в типажах. Чтобы выставить требование, что тип т должен поддерживать сложение, в определение функции на ряду с переменной типа включается типажное ограничение. Пример такого синтак сиса показан в следующем листинге. Листинг 2.16. Сигнатура типа обобщенной функции с типажным ограничением fn add(i: T, j: T) -> T i + j }
{
Фрагмент предписывает, что в т должна быть реализация операции std: : ops: : Add. Использование одной и той же переменной типа т с типажным ограничением гарантирует, что аргументы i и j, а также воз вращаемое значение будут одного и того же типа и их типы поддерживают сложение. Что такое типаж? Это свойство языка, аналогичное интерфейсу, протоколу или контракту. Имеющие опыт объектно-ориентированного программирования счита ют типаж чем-то вроде абстрактного базового класса. Если же есть опыт функцио нального программирования, то имеющиеся в Rust типажи можно рассматривать
94
Глава 2
как некое подобие классов типов в языке Haskell. Но пока достаточно будет ска зать, что типажи позволяют типам заявить, что они используют стандартное пове дение. Все Rust-операции определяются с помощью типажей. Например, оператор сложе ния ( +) определен как типаж std: : ops: : Add. Типажи в достаточной мере представ лены в главе 3 и постепенно раскрываются все более и более подробно в процессе изучения книги. Повторюсь: все Rust-oпepaтopы являются удобным синтаксическим приемом для вызова методов типажа. Таким образом Rust поддерживает перегрузку оператора. В ходе компиляции выполняется преобразование выражения а + ь в а. add (Ь). В листинге 2.17 представлен полный пример, показывающий, что обобщенные функции могут вызываться множеством типов. Код листинга выводит на консоль следующие три строки: 4.6 30 15s
Листинг 2.17. Обобщенная функции с переменной типа и типажным ограничением 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
use std::ops::{Add}; use std::time::{Duration};
(1)
fn add(i: T, j: T) -> T { i + j }
(3)
(1) (2) (3) (4) (5)
Перенос типажа Add из std::ops в локальную область видимости Перенос типа Duration из std::time в локальную область видимости Аргументы функции add() могут принимать любой тип, реализукщий std::ops::Add. Вызов add() со значениями чисел с плавающей точкой Вызов add() с целыми числами
fn main() { let floats = add(1.2, 3.4); let ints = add(10, 20); let durations = add( Duration::new(5, 0), Duration::new(10, 0) ); println!("{}", floats); println!("{}", ints); println!("{:?}", durations);
(2)
(4) (5) (6) (6) (6)
(7)
}
Основы языка
95
(6) Вызов add{) со значениями типа Duration, представлякщими продолжительность от одной метки времени до другой (7) Поскольку в std::time::Duration отсутствует реализация типажа std::fmt::Display, мы можем вернуться к запросу std::fmt::Debug.
Как видите, в сигнатурах функций нетрудно и запутаться. В них нужно терпеливо разобраться. Надеюсь, теперь у вас в распоряжении есть все инструменты, позво ляющие в случае пробуксовки разбить код на части. Вот несколько принципов, по могающих разобраться в коде на языке Rust: • Названиями в нижнем регистре (i, j) обозначаются переменные.
• Отдельными буквами в верхнем регистре (т) обозначаются обобщенные пе ременные типа. • Названиями, начинающимися с букв в верхнем регистре (Add), обозначаются либо типажи, либо конкретные типы, например string или Dura tion. • Метками (' а) обозначаются параметры времени жизни.
2.9. Создание grep-lite Основная часть главы была посвящена числам. Пора переходить к другому практи ческому примеру. Воспользуемся им, чтобы немного узнать о том, как Rust обраба тывает текст. В листинге 2.18 показан первый подход к созданию grep-lite. Код этой программы находится в файле ch2-str-simple-pattern.rs. Его жестко заданные параметры несколько ограничивают гибкость, но он все же сможет послужить полезной иллюстрацией порядка работы со строковыми литералами. Код листинга выводит на консоль сле дующую строку: dark square is а picture feverishly turned--in search of what? Листинг 2.18. Поиск в строках простого образца 1 2 3 4 5 6 7 8 9 10 11 12 13 14
fn main() { let search_term = "picture"; let quote = "\ Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?"; (1) for line in quote.lines() { if line.contains(search_term) { println!("{}", line); } } }
(2)
96
Глава 2
(1) Многострочному тексту не нужен специальный синтаксис. Символ\ в строке 3 отключает действие символа новой строки. (2) Функция lines() возвращает итератор для перебора содержимого quote, где предмет каждой итерации - строка текста. В отношении действия символа новой строки Rust использует соглашения, принятые в каждой операционной системе.
Как видите, строковые значения в Rust могут многое делать сами по себе. Стоит выделить следующие особенности, продемонстрированные в коде листинга 2.18. С этих позиций мы будем расширять функциональные возможности нашего зарож дающегося приложения: • В строке 9 ( quote. lines ()) показывается построчная независимая от исполь зуемой платформы итерация. • В строке 10 (line. contains ()) показывается поиск текста с использованием синтаксиса метода. Исследование имеющейся в Rust богатой коллекции строковых типов Новичкам в Rust разобраться в строковых значениях весьма непросто. Подробности реализации нужно осваивать с самых низов, что затрудняет понимание. Текст в компьютерах представлен весьма сложным способом, и в Rust решено раскрыть часть этой сложности. При этом программисты получают полный контроль над си туацией со строками, но для изучающих язык возникают дополнительные трудности. Текст представлен отличающимися друг от друга типами string и &str. Поначалу работа со значениями обоих типов может вызывать недовольство, поскольку для выполнения одних и тех же действий требуются разные методы. Пока не заработает интуиция, придется справляться с досадными опечатками. И на начальном этапе изучения будет меньше проблем, если данные преобразовывать в тип string.
Тип string, наверное, ближе всего к тому, что в других языках называют строко вым типом. В нем поддерживаются знакомые операции, например конкатенация (слияние двух строк), добавление нового текста к существующей строке и обрезка пробелов. Тип str позволяет достичь высокого уровня производительности, но имеет относи тельно узкий функциональный спектр. После своего создания значения str не мо гут увеличиваться или уменьшаться. В этом смысле работа с ними похожа на дей ствия с простым массивом в памяти. Но, в отличие от такого массива, значения str гарантированно являются допустимыми символами в кодировке UTF-8.
Тип str обычно отображается в виде &str. Он называется строковым срезом (string slice) и содержит указатель на данные str и длину. Попытка присвоить переменной значение типа str даст сбой. Компилятору Rust нужно создавать переменные фик сированного размера внутри принадлежащего функции стекового фрейма. По скольку значения str могут иметь произвольную длину, хранить их можно только по ссылке в виде локальных переменных.
Основы языка
97
Для тех, у кого имеется опыт системного программирования, можно пояснить, что тип string для хранения представляемого им текста использует динамическое вы деление памяти. Создание значений типа &str позволяет обойтись без выделения памяти. string - это тип, находящийся во владении. Владение в Rust играет особую роль. Владелец может вносить любые изменения в данные и несет ответственность за удаление принадлежащих ему значений при выходе из области видимости (что подробно объясняется в главе 3). А &str - заимствованный тип. На практике это означает, что &str можно рассматривать как данные только для чтения, а String как данные для чтения и записи. Строковые литералы (например, «Rust in Action») имеют тип &str. Полная сигнату ра типа, включая параметр времени жизни, - & 'static str. Время жизни 'static имеет особые свойства. Своим названием оно также обязано деталям реализации. Исполняемые программы могут содержать часть памяти с жестко заданными зна чениями. Этот раздел известен как статическая память, поскольку в ходе выполне ния программы он доступен только для чтения. В работе со строковыми значениями могут попадаться и другие типы. В их краткий перечень входят9: отдельный символ, закодированный в 4 байтах. Внутреннее пред ставление char эквивалентно UCS-4/UTF-32. А в &str и string отдельные символы кодируются в UTF-8. Преобразование не обходится без издержек, но при этом значения char имеют фиксированную ширину и, следовательно, компилятору с ними легче работать. А символы в кодировке UTF-8 могут за нимать от 1 до 4 байтов.
• char -
•
[u8J - срез обычных байтов, обычно применяемый при работе с потоками двоичных данных.
вектор обычных байтов, обычно создаваемый при использовании данных типа [u8 J. Тип String соответствует vec, а тип str соответству ет [uBJ.
• vec -
строковое значение, определяемое используемой платформой. По поведению этот тип похож на String, но без гарантий, что кодировка будет UTF-8 и что в ней не будет нулевого байта (охоо).
• std::ffi: :osstring -
тип, похожий на строковый и предназначенный для работы с путями к элементам файловой системы.
• std: :path: :Path -
Чтобы полностью разобраться, в чем разница между String и &str, нужно пони мать, что такое массивы и векторы. Текстовые данные похожи на эти два типа, но с дополнительными удобными в работе методами, применяемыми в виде надстроек. 9
К сожалению, это далеко не полный перечень. Для конкретных случаев иногда требуется особая обработка.
98
Глава 2
Давайте добавим к grep-lite функцию вывода вместе с найденным соответствием номера его строки. В стандарте POSIX.1-2008 это эквивалентно результату указа ния для утилиты grep ключа -n(http://mng.bz/ZPdZ). Добавление нескольких строк кода к предыдущему примеру позволит увидеть вы вод на экран показанной далее строки. Код, добавляющий эту функцию, находится в листинге 2.19 и в файле ch2/ch2-simple-with-linenums.rs. 2: dark square is а picture feverishly turned--in search of what? Листинг 2.19. Приращение индексной переменной, задаваемое обычном кодом 1 fn main() { 2 let search_term = "picture"; 3 let quote = "\ (1) 4 Every face, every shop, bedroom window, public-house, and 5 dark square is a picture feverishly turned--in search of what? 6 It is the same with books. What do we seek through millions of pages?"; 7 let mut line_num: usize = 1; (2) 8 9 for line in quote.lines() { 10 if line.contains(search_term) { 11 println!("{}: {}", line_num, line); (3) 12 } 13 line_num += 1; (4) 14 } 15 }
(1) Символ обратного слэша отключает действие символа новой строки в строковом литерале (2) Объявление изменяемой переменной line_num путем указания инструкции let mut и ее инициализация значением 1 (3) Изменение формата вызова макроса println! с целью вывода на экран обоих значений (4) Локальное увеличение значения переменной line_num
В листинге 2.20 показан более удобный подход к приращению значения i. На вы ходе получается то же самое, но здесь в коде используется метод enumerate () и выстраивается цепочка методов. Метод e numerat e () получает итератор I, возвра щая другие значения (N, r), где N - число с начальным значением О, увеличи вающееся на единицу при каждой итерации. Исходный код этого листинга нахо дится в файле ch2/ch2-simple-with-enumerate.rs. Листинг 2.20. втоматическое приращение индексной переменной 1 fn main() { 2 let search_term = "picture"; 3 let quote = "\
Основы языка
99
4 Every face, every shop, bedroom window, public-house, and 5 dark square is a picture feverishly turned--in search of what? 6 It is the same with books. What do we seek through millions of pages?"; 7 (1) 8 for (i, line) in quote.lines().enumerate() { 9 if line.contains(search_term) { (2) 10 let line_num = i + 1; 11 println!("{}: {}", line_num, line); 12 } 13 } 14 } (1) Поскольку метод lines() возвращает итератор, к нему в цепочку можно пристроить метод enumerate(). (2) Вьmолнение дополнения в виде вычисления номера строки, позволяющее избежать вычислений при каждом проходе цикла.
Еще одна весьма полезная функция утилиты grep - вывод содержимого до и после той строки, в которой найдено соответствие. В GNU-реализации grep для этого ис пользуется ключ -с NUM. Чтобы добавить поддержку этой функции в grep-lite, нуж но освоить создание списков.
2.1 О. Создание списков с использованием массивов, слайсов и векторов Списки получили весьма широкое распространение. Чаще всего вам придется рабо тать с массивами и векторами. Массивы характеризуются фиксированной шириной и чрезвычайной скромностью в потреблении ресурсов. Векторы можно наращи вать, но им свойственны издержки времени выполнения из-за ведения дополни тельного учета. Чтобы понять механизмы, положенные в Rust в основу текстовых данных, полезно иметь хотя бы поверхностное представление о происходящем. Цель раздела - реализация поддержки вывода на экран n строк контекста, окру жающего найденное совпадение. Чтобы добиться желаемого, нужно сделать не большое отступление и подробнее разобраться с массивами, срезами и векторами. Самый полезный тип для этого упражнения - вектор. Но чтобы разобраться с век тором, нужно сперва понять, что собой представляют два его более простых собра та: массив и срез.
2.10.1. Массивы Массив, по крайней мере в Rust, представляет собой плотно упакованный набор однородных элементов. В массиве допускается замена элементов, но его размер изменяться не может. Поскольку типы переменной длины, вроде String, усложня ют рассмотрение, вернемся ненадолго к числам.
100
Глава 2
Для создания массивов применяются две формы. Мы можем предоставить список в квадратных скобках с запятыми в качестве разделителей (например, [ 1, 2, 3 J) или выражение повторения, где указываются два значения, разделенные точкой с запя той (например, [О; l00J). Значение слева (о) повторяется то количество раз, что указано справа (100). Все варианты показаны в листинге 2.21 в строках 2-5. Исход ный код этого листинга находится в файле сh2-Заггауs.гs. Программа выводит на консоль следующие четыре строки: [1, [1, [0, [0,
2, 2, 0, 0,
3]: 3]: 0]: 0]:
1 1 0 0
+ + + +
10 10 10 10
= = = =
11 11 10 10
2 2 0 0
+ + + +
10 10 10 10
= = = =
12 12 10 10
3 3 0 0
+ + + +
10 10 10 10
= = = =
13 13 10 10
([1, ([1, ([0, ([0,
2, 2, 0, 0,
3] 3] 0] 0]
= = = =
6) 6) 0) 0)
Листинг 2.21. Определение массивов и последовательный перебор их элементов 1 fn main() { 2 let one = [1, 2, 3]; 3 let two: [u8; 3] = [1, 2, 3]; 4 let blank1 = [0; 3]; 5 let blank2: [u8; 3] = [0; 3]; 6 7 let arrays = [one, two, blank1, blank2]; 8 9 for a in &arrays { 10 print!("{:?}: ", a); 11 for n in a.iter() { 12 print!("\t{} + 10 = {}", n, n+10); 13 } 14 15 let mut sum = 0; 16 for i in 0..a.len() { 17 sum += a[i]; 18 } 19 println!("\t({:?} = {})", a, sum); 20 } 21 }
В машинном представлении массивы являются простой структурой данных. Это непрерывный блок памяти с элементами одного и того же типа. И все же простота обманчива. У новичков освоение работы с массивами может вызвать ряд затруд нений: • Можно запутаться в обозначениях. Описание типа массива имеет следую щий вид: [ т; n J , где т - тип элементов, а n - неотрицательное целое число. Запись [f3 2 ; 12) обозначает массив из двенадцати 32-разрядных чисел с плавающей точкой. Нетрудно запутаться и со слайсами [ т J , не имеющими ко времени компиляции указания их длины.
Основы языка
• Тип [ив; з J отличается от типа ва имеет значение.
101 [ив; 4 J.
Для системы типов размер масси
• На практике основная часть работы с массивами приходится на другой тип, называемый слайсом (fтJ). Вся работа с самим слайсом ведется по ссылке ( & [ тJ ). И чтобы добавить во всю эту неразбериху еще и лингвистиче скую путаницу, и слайсы и ссылки на них называются слайсами. Особое внимание в Rust уделяется вопросам безопасности. При этом ведется про верка границ индексации массива. Запрос элемента, выходящего за границы, при водит к сбою (к панике в терминологии Rust), а не к возврату неверных данных.
2.10.2. Слайсы Слайсы представляют собой похожие на массивы объекты с динамическим разме ром. Понятие «динамический размер» означает, что их размер на момент компиля ции неизвестен. Но, как и массивы, они не могут расширяться и сокращаться. Ис пользование слова «динамический» в словосочетании «динамический размер» по смыслу ближе к динамической типизации, чем к изменению. Недостаток сведений к моменту компиляции объясняет различие в сигнатуре типа между массивом ( [ т; nJ) и слайсом ( [TJ ). Важность слайсов объясняется тем, что реализовать типажи для них проще, чем для массивов. А типажи в Rust используются программистами для добавления методов к объектам. Поскольку [ т; 1 J , ( т; 2 J ,••• , ( т; n J бывают разных типов, реализация типажей для массивов может стать слишком громоздкой. А создание слайса из мас сива дается легко и обходится дешево, поскольку слайс не нужно привязывать к какому-либо конкретному размеру. Еще один важный момент при использовании слайсов связан с их способностью действовать как представление массивов (и других слайсов). Термин «представле ние» здесь взят из описания технологии работы с базами данных и означает, что слайсы могут получать быстрый доступ только по чтению данных, что исключает необходимость копирования чего бы то ни было. Проблема слайсов в том, что Rust стремится к осведомленности о размере каждого объекта вашей программы, а слайсы определяются к моменту компиляции как не имеющие размера. На помощь приходят указатели. Как уже упоминалось при об суждении использования понятия «динамический размер», слайс в памяти имеет фиксируемый размер. Он состоит из двух usizе-компонентов (указателя и длины). Вот почему слайсы обычно упоминаются в их ссылочной форме, & [TJ (наподобие строчных слайсов, принимающих обозначение &str). ПРИМЕЧАНИЕ Пока что беспокоиться о различиях между массивами и слайсами не стоит. На практи ке это несущественно. Каждое из этих понятий - артефакт подробностей реализации. Эти подробности важны при работе с кодом, критичным к производительности, но не при изучении основ языка.
102
Глава 2
2.10.3. Векторы Векторы (vec) - это наращиваемые списки, состоящие из обобщенных типов т. Использование векторов нашло в коде Rust весьма широкое распространение. При выполнении программы на них тратится немного больше времени, чем на массивы, из-за дополнительного учета, необходимого для последующего изменения их раз мера. Но эти издержки на работу с векторами почти всегда компенсируются их до полнительной гибкостью. Перед нами стоит задача расширить возможности утилиты grep-lite. В частности, нужно хранить n строк контекста вокруг найденного соответствия. Конечно же, для реализации такой функции существует масса способов. Чтобы максимально упростить код, будет использоваться двухпроходная стратегия. При первом проходе будут созданы метки для соответствующих задаче поиска строк. А при втором проходе будут собраны строки в диапазоне n строк от каждой метки. Код листинга 2.22 (доступный в файле ch2 / ch2-introduction-vec.rs) - самый длинный из ранее встречавшихся примеров. И разобраться в нем нужно без лишней спешки. Наверное, самым запутанным синтаксисом будет Vec> в строке 15. Vec> - это вектор векторов (вида vec), где т - пара значений типа (usize, Str ing). Эта пара представляет собой кортеж, который будет использоваться для хранения номеров строк вместе с текстом, близ ким к найденному соответствию. Когда для переменной needle в строке 3 установ лено значение "оо", на консоль выводится следующий текст: 1: 2: 3: 4: 3: 4: 5: 6: 7:
Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?
Листинг 2.22. Включение вывода на экран контекстных строк с использованием Vec 1 2 3 4 5 6 7 8 9 10
fn main() { let ctx_lines = 2; let needle = "oo"; let haystack = "\ Every face, every shop, bedroom window, public-house, and dark square is a picture feverishly turned--in search of what? It is the same with books. What do we seek
Основы языка
11 through millions of pages?"; 12 13 let mut tags: Vec = vec![]; (1) 14 let mut ctx: Vec = vec![]; 16 17 for (i, line) in haystack.lines().enumerate() { (3) 18 if line.contains(needle) { 19 tags.push(i); 20 21 let v = Vec::with_capacity(2*ctx_lines + 1); (4) 22 ctx.push(v); 23 } 24 } 25 (5) 26 if tags.is_empty() { 27 return; 28 } 29 30 for (i, line) in haystack.lines().enumerate() { (6) 31 for (j, tag) in tags.iter().enumerate() { 32 let lower_bound = 33 tag.saturating_sub(ctx_lines); (7) 34 let upper_bound = 35 tag + ctx_lines; 36 37 if (i >= lower_bound) && (i println!("{}", line),
(4)
None => (),
(5)
} } }
(1) Помещение типа Regex из контейнера regex в локальную область видимости (2) Метод unwrap() вьmолняет развертывание значения типа Result, вызывая сбой при возникновении ошибки. Более серьезная обработка ошибок будет подробно рассмотрена в следуюших главах. (3) Замена метода contains() из листинга 2.23 блоком match, который нужен для обработки всех возможных случаев поиска (4) Some(T) - положительный вариант значения типа Option, означающий, что работа метода re.find() завершилась успешно: это соответствует всем значениям (5) None - отрицательный вариант значения типа Option, а unit-тип () может рассматриваться здесь как null-значение заместителя
Откройте окно командной строки и перейдите в корневой каталог проекта grep-lite. Выполнение команды cargo run приведет к выводу на экран информации, похожей на следующий текст: $ carqo run
Compiling grep-lite v0.1.0 (file:/ / /tmp/grep-lite) Finished dev [unoptimized + debuginfo] target(s) in 0.48s Running 'target/debug/grep-lite' dark square is а picture feverishly turned--in search of what?
Следует признать, что код, показанный в листинге 2.24, особых преимуществ от своих вновь приобретенных возможностей работы с регулярными выражениями так и не заимел. Надеюсь, что вы все же уверены в возможности включения этого кода в более сложные примеры.
Основы языка
107
2.11.2. Создание документации по сторонним контейнерам в локальной среде Обычно документацию по сторонним контейнерам можно найти в Интернете. И все же, наверное, полезно будет узнать, как создать локальную копию на случай, если Интернет нельзя будет призвать на помощь: 7. Перейдите в терминале в корневой каталог проекта: /tmp/grep-lite или %TMP%\grep lite
8. Выполните команду cargo doc. На консоли появится информация о ходе ее вы полнения: $ cargo doc Checking Documenting Checking Documenting Checking Documenting Checking Checking Documenting Documenting Checking Documenting Documenting Finished
lazy_static v1.4.0 lazy_static v1.4.0 regex-syntax v0.6.17 regex-syntax v0.6.17 memchr v2.3.3 memchr v2.3.3 thread_local v1.0.1 aho-corasick v0.7.10 thread_local v1.0.1 aho-corasick v0.7.10 regex v1.3.6 regex v1.3.6 grep-lite v0.1.0 (file:///tmp/grep-lite) dev [unoptimized + debuginfo] target(s) in 3.43s
Поздравляю. Теперь вы создали НТМL-документацию. Открыв в веб-браузере файл /tmp/grep-lite/targeUdoc/grep_lite/index.html (можно также попробовать запустить из ко мандной строки команду cargo doc --open), вы сможете просматривать докумен тацию для всех контейнеров, от которых зависит проект. Можно также изучить по лучившийся в итоге каталог, чтобы увидеть, что именно вам доступно: $ tree -d -L 1 target/doc/ target/doc/ ├── aho_corasick ├── grep_lite ├── implementors ├── memchr ├── regex ├── regex_syntax ├── src └── thread_local
2.11.3. Управление имеющимся в Rust набором инструментальных средств с помощью rustup Средство rustup является наряду с cargo еще одним полезным инструментом ко мандной строки. С помощью cargo осуществляется управление проектами, а rustup
108
Глава 2
позволяет управлять установкой (или установками) Rust. В ведении rustup находит ся инструментальный набор Rust, а также переход от одной версии компилятора к другой. Это открывает возможность компилировать проекты для множества разных платформ и проводить эксперименты с редко используемыми функциями компиля тора, сохраняя при этом стабильную версию программы. Средство rustup также упрощает доступ к документации Rust. Запуск команды rustup doc открывает в вашем веб-браузере локальную копию документации по стандартной библиотеке Rust.
2.12. Помержка аргументов командной строки Количество функций в нашей программе быстро увеличивается. Но пока в ней нет возможности указывать какие-либо параметры. Чтобы стать реальной утилитой, grep-lite должен иметь возможность взаимодействия с внешним миром. К сожалению, у Rust весьма скромная стандартная библиотека. В ней нет широкой поддержки не только работы с регулярными выражениями, но и обработки аргу ментов командной строки. Более привлекательный АРI-интерфейс можно получить от стороннего контейнера под названием clap (и не только от него). Раз способ добавления стороннего кода нам уже известен, давайте им воспользуем ся и позволим пользователям grep-lite выбирать свой собственный образец для по иска. (А выбор собственного источника ввода рассмотрим в следующем разделе.) Сначала добавьте зависимость от clap в файл Cargo.toml: $ carqo add clap@2
Updating 'https:/ /github.com/rust-lang/crates.io-index' index Adding clap v2 to dependencies Чтобы убедиться в добавлении контейнера в проект, можно просмотреть содержи мое файла Cargo.toml. Листинг 2.25. Проверка добавления зависимости в файл grep-lite/Cargo.tml
[package] name = "grep-lite" version = "0.1.0" authors = ["Tim McNamara "] [dependencies] regex = "1" clap = "2"
А теперь внесем поправки в файл src/main.rs. Листинг 2.26. Редактирование файла grep-lite/src/main.rs 1 use regex::Regex; 2 use clap::{App,Arg};
(1)
Основы языка
109
3 4 fn main() { 5
(2)
let args = App::new("grep-lite")
6
.version("0.1")
7
.about("searches for patterns")
8
.arg(Arg::with_name("pattern")
9
.help("The pattern to search for")
10
.takes_value(true)
11
.required(true))
12
.get_matches();
13 14
let pattern = args.value_of("pattern").unwrap();
15
let re = Regex::new(pattern).unwrap();
(3)
16 17
let quote = "Every face, every shop, bedroom window, public-house, and
18 dark square is a picture feverishly turned--in search of what? 19 It is the same with books. What do we seek through millions of pages?"; 20 21
for line in quote.lines() {
22
match re.find(line) {
23
Some(_) => println!("{}", line),
24
None => (),
25 26
} }
27 }
(1) Помещение объектов clap::App и clap::Arg в локальную область видимости (2) Постепенное создание синтаксического анализатора аргументов команды, где каждый аргумент принимает данные из Arg. В нашем случае нужен только один аргумент. (3) Извлечение аргумента pattern
После обновления проекта запуск команды на консоль следующих строк:
cargo run
должен привести к выводу
$ cargo run Finished dev [unoptimized + debuginfo] target(s) in 2.21 secs Running `target/debug/grep-lite` error: The following required arguments were not provided:
USAGE: grep-lite For more information try --help (1) ошибка: Не были предоставлены следующие обязательные аргументы:
ПРИМЕНЕНИЕ: grep-lite Для получения дополнительной информации воспользуйтесь ключом -help
(1)
110
Глава 2
Ошибка связана с тем, что получившемуся в результате компиляции исполняемому фай:лу не было передано достаточное количество аргументов. Для передачи аргу ментов в cargo поддерживается специальный синтаксис. В получившийся в резуль тате компиляции исполняемый двоичный файл передаются любые аргументы, ко торые появляются после двойной черточки--: $ cargo run -- picture Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs Running `target/debug/grep-lite picture` dark square is a picture feverishly turned--in search of what?
Но clap не ограничивается выполнением синтаксического разбора. Он создает для вас документацию по использованию утилиты. Запуск команды grep-lite --help предоставляет развернутую картину порядка использования утилиты: $ ./tarqet/deЬuq/qrep-lite --help
grep-lite 0.1 searches for patterns
(1)
USAGE: grep-lite FLAGS: -h, --help Prints help information -V, --version Prints version information ARGS: The pattern to search for (1) grep-lite 0.1 поиск образцов ПРИМЕНЕНИЕ:
grep-lite
КJПОЧИ: -h, --help Вывод справочной информации -V, --version Вывод информации о версии утилиты АРГУМЕНТЫ: Образец для поиска
2.13. Чтение данных из файлов Поиск текста был бы неполным без возможности поиска в файлах. Файловый ввод вывод имеет свои странности, поэтому он и оставлен напоследок. Прежде чем добавлять эту функцию в grep-lite, давайте рассмотрим отдельный пример, показанный в листинге 2.27. Код этого листинга находится в файле
Основы языка
111
Основная схема действий: открыть объект File, поместить его в Bu Буфер чтения BufRea d er обеспечивает буферизованный ввод-вывод, спо собствующий сокращению количество обращений к операционной системе, если жесткий диск перегружен. ch2-read-file.rs.
fReader.
Листинг 2.27. Построчное считывание данных из файла, задаваемое обычным кодом
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
use std::fs::File; use std::io::BufReader; use std::io::prelude::*; fn main() { let f = File::open("readme.md").unwrap(); let mut reader = BufReader::new(f); let mut line = String::new(); loop { let len = reader.read_line(&mut line) .unwrap(); if len == 0 { break }
(1)
(2)
(3)
println!("{} ({} bytes long)", line, len); line.truncate(0);
(4)
} }
(1) Создание объекта File, для которого требуются аргумент пути и обработка ошибок, если указанный файл не существует. При отсутствии файла readme.md происходит аварийное завершение работы программы. (2) Многократное использование одного и того же объекта String в течение всего времени жизни программы. (3) Поскольку при чтении с диска может быть выдана ошибка, нам нужна ее явная обработка. В нашем случае ошибки приводят к сбою программы. (4) Схлопывание String до нулевой длины, предотвращаюцее последующее сохранение строк.
Последовательный перебор содержимого файла, заданный обычным кодом, может стать весьма обременительным, несмотря на пользу от применения данного подхо да в отдельных случаях. Для общего случая построчной итерации в Rust предостав ляется вспомогательный итератор, показанный в следующем листинге. Исходный код этого листинга находится в файле ch2/ch2-bufreader-lines.rs.
112
Глава 2
Листинг 2.28. Построчное считывания данных из файла с использованием BufReader::lines()
1 2 3 4 5 6 7 8 9 10 11 12 13
use std::fs::File; use std::io::BufReader; use std::io::prelude::*; fn main() { let f = File::open("readme.md").unwrap(); let reader = BufReader::new(f); (1) for line_ in reader.lines() { (2) let line = line_.unwrap(); println!("{} ({} bytes long)", line, line.len()); }
}
(1) Здесь происходит незаметное изменение поведения. BufReader::lines() удаляет из каждой строки замыка�ацие символы новой строки. (2) Развертывание значения типа Result, но с риском сбоя программы в случае возникновения ошибки.
Теперь в список функций grep-lite можно добавить чтение из файла. В следующем листинге представлена полноценная программа, принимающая в качестве аргумен тов образец в виде регулярного выражения и входной файл. Листинг 2.29. Считывание строк из файла 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
use use use use use
std::fs::File; std::io::BufReader; std::io::prelude::*; regex::Regex; clap::{App,Arg};
fn main() { let args = App::new("grep-lite") .version("0.1") .about("searches for patterns") .arg(Arg::with_name("pattern") .help("The pattern to search for") .takes_value(true) .required(true)) .arg(Arg::with_name("input") .help("File to search") .takes_value(true)
Основы языка 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 }
113
.required(true)) .get_matches(); let pattern = args.value_of("pattern").unwrap(); let re = Regex::new(pattern).unwrap(); let input = args.value_of("input").unwrap(); let f = File::open(input).unwrap(); let reader = BufReader::new(f); for line_ in reader.lines() { let line = line_.unwrap(); match re.find(&line) { Some(_) => println!("{}", line), None => (), } }
(1)
(1) line - это значение типа Striпg, но re.find() принимает в качестве аргумента значение типа &str
2.14. Чтение из стандартного устройства ввода stdin Утилита командной строки бьmа бы неполной, если бы она не могла читать из стандартного устройства ввода. К сожалению, читатели, не уделившие достаточно го внимания предыдущим частям этой главы, могут не узнать синтаксис, приме ненный в строке 8. Короче говоря, вместо дублирования кода в функции main () в программе будет использоваться обобщенная функция, позволяющая абстрагиро ваться от подробностей того, с чем именно ведется работа: с файлами или со стан дартным устройством ввода stdin: Листинг 2.30. Поиск образца в файле или тексте из стандартного устройства ввода stdin 1 2 3 4 5 6 7 8 9 10 11
use use use use use use
std::fs::File; std::io; std::io::BufReader; std::io::prelude::*; regex::Regex; clap::{App,Arg};
fn process_lines(reader: T, re: Regex) { for line_ in reader.lines() { let line = line_.unwrap(); match re.find(&line) { (1)
114
Глава 2
12 Some(_) => println!("{}", line), 13 None => (), 14 } 15 } 16 } 17 18 fn main() { 19 let args = App::new("grep-lite") 20 .version("0.1") 21 .about("searches for patterns") 22 .arg(Arg::with_name("pattern") 23 .help("The pattern to search for") 24 .takes_value(true) 25 .required(true)) 26 .arg(Arg::with_name("input") 27 .help("File to search") 28 .takes_value(true) 29 .required(false)) 30 .get_matches(); 31 32 let pattern = args.value_of("pattern").unwrap(); 33 let re = Regex::new(pattern).unwrap(); 34 35 let input = args.value_of("input").unwrap_or("-"); 36 37 if input == "-" { 38 let stdin = io::stdin(); 39 let reader = stdin.lock(); 40 process_lines(reader, re); 41 } else { 42 let f = File::open(input).unwrap(); 43 let reader = BufReader::new(f); 44 process_lines(reader, re); 45 } 46 }
(1) line - это значение типа String, но re.find(} принимает в качестве аргумента значение типа &str
Резюме ♦ В Rust имеется полная подцержка элементарных типов, таких как целые числа и числа с плавающей точкой. ♦ Функции имеют строгую типизацию и требуют указания типов для своих пара метров и возвращаемых значений.
Основы языка
115
♦ Функциональные возможности Rust, такие как итерация и математические опе рации, зависят от типажей. Например, цикл for - это сокращение для типажа std:: iter:: Intoiterator.
♦ Типы, похожие на списки, адаптированы к конкретным случаям использования. Обычно все начинают осваивать их с vec.
♦ У всех Rust-программ одна функция входа: main () . ♦ У каждого контейнера есть файл Cargo.toml, в котором указаны его метаданные.
♦ Инструментальное средство cargo способно скомпилировать ваш код и получить его зависимости. ♦ Инструментальное средство rustup предоставляет доступ к разнообразным инст рументам компилятора и к документации языка.
3
Составные типы данных
В этой главе рассматриваются следующие вопросы: ♦ Составление данных с помощью структур. ♦ Создание перечисляемых типов данных. ♦ Добавление методов и обработка ошибок безопасным для типов образом. ♦ Определение и реализация общего поведения с помощью типажей. ♦ Освоение способов сохранения в тайне подробностей реализации. ♦ Использование cargo при создании документации для вашего проекта. Добро пожаловать в главу 3. Если предыдущая глава посвящалась атомам языка Rust, то здесь основное внимание будет уделено его молекулам. В этой главе основной акцент будет сделан на два ключевых строительных блока для Rust- программистов: struct и enum. Оба они являются формами составных типов данных. В сочетании друг с другом struct и enum могут составлять другие типы для создания более полезных конструкций, чем эти же типы, но без данного альянса. Точка на плоскости (х, у) составляется из двух чисел, х и у. Но поддержи вать в программе две переменные - х и у - нежелательно. Лучше бы отнестись к точке как к единому целому. Также в этой главе будут рассматриваться способы добавления методов к типам с помощью блоков impl. И наконец, более подробно будут рассмотрены типажи, являющиеся в Rust системой определения интерфейсов. В ходе изучения главы будут отработаны способы представления файлов в коде. Хотя концептуально в этом вопросе нет особых трудностей: если вы читаете эту книгу, то вполне вероятно, что вам уже приходилось работать с файлом через про граммный код, и все же есть еще немало любопытных моментов, способных заин тересовать читателей. Наша стратегия будет заключаться в создании имитаций все го, что будет рассматриваться с использованием нашего собственного воображае мого АРI-интерфейса. Затем, ближе к последней части главы, будет поднят вопрос взаимодействия с вашей реальной операционной системой и ее файловой системой или системами.
3.1. Использование простых функций для экспериментов с API Для начала посмотрим, как далеко можно зайти, используя уже известные нам ин струменты. В коде листинга 3.1 раскрываются вполне ожидаемые приемы, напри мер открытие и закрытие файла. Воспользуемся для моделирования самым про-
118
Глава 3
стым типом имитатора (mock): элементарным псевдонимом на основе string, со держащим имя файла и другие данные. Чтобы работа бьmа интереснее написания массы шаблонного кода, в код листинга 3.1 добавлены приемы программирования, соответствующие новым концепциям. Они покажут, как приручить компилятор в ходе экспериментов с конструкцией. Чтобы помешать компилятору выдавать предупреждения, в них будут задействова ны атрибуты(#! [Allow (unused_variaЫesJ J ). На примере функции r e ad показано, как можно определить функцию, которая вообще не возвращает значение. Факти чески ее код ничего не делает, но вскоре данная ситуация будет исправлена. Код листинга находится в файле chЗ/chЗ-not-quite-file-1.rs. Листинг 3.1. Использование псевдонимов типа, чтобы подставить вместо типа заглушку 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
#![allow(unused_variables)]
(1)
type File = String;
(2)
fn open(f: &mut File) -> bool { true
(3)
fn close(f: &mut File) -> bool { true } #[allow(dead_code)] fn read(f: &mut File, save_to: &mut Vec) -> ! { unimplemented!() } fn main() { let mut f1 = File::from("f1.txt"); open(&mut f1); //read(f1, vec![]); close(&mut f1); }
(3) (4) (5)
(6)
(7) (8)
(1) Отк.точение предупреждений компилятора при проработке замыслов. (2) Создание псевдонима типа. Компилятор, в отличие от вашего исходного кода, не увидит разницу между String и File. (3) Пока что будем считать, что работа этих двух функций всегда завершается успехом. (4) Подавление тревоги компилятора по поводу незадействованной функции. (5) Возвращаемый тип! показывает компилятору Rust, что эта функция вообще не возвращает значение. (6) Это макрос, при обнаружении которого происходит сбой программы.
Составные типы данных
119
(7) Благодаря объявлению типа в строке 3 File наследует все методы, принадпежащие String. (8) Вызывать этот метод не имеет никакого смысла.
Опираясь на заделы в коде листинга 3.1, нам еще многое предстоит построить. У нас, к примеру: • Не создан постоянный объект, который представлял бы файл. А в строко вом значении можно закодировать множество разных вещей. • Не предпринята попытка реализации функции read ( J. Если бы такая попыт ка была, то как бы мы смогли справиться со сбоем? • Функции орел() и close () возвращают значение типа bool. Возможно, есть способ предоставления более содержательного возвращаемого типа, в кото ром могло бы быть сообщение об ошибке на случай, если оно будет предос тавлено операционной системой. • Ни одна из наших функций не является методом. С точки зрения стилистики, бьmо бы неплохо вызывать f. open () , а не open ( f) . Давайте пройдемся по списку сверху вниз. Приготовьтесь к отступлениям от него в случае обнаружения ответвлений, полезных для проведения исследований. Специальные возвращаемые типы, имеющиеся в Rust Порой новичкам бывает трудно разобраться в некоторых типах возвращаемых значе ний. Их также непросто заметить, поскольку они состоят не из слов, а из символов. Тип, известный как unit, ( J , формально считается кортежем нулевой длины. Он ис пользуется для выражения того, что функция не возвращает никакого значения. Функции, которые не имеют возвращаемого типа, возвращают ( J , и выражения, заканчивающиеся точкой с запятой (; ), также возвращают ( ) . Например, функция report () в следующем блоке кода подразумеваемо возвращает тип unit: use std::fmt::Debug; fn report(item: T) { println!("{:?}", item);
(1) (2)
}
(1) Переменная item может быть любого типа с реализацией std::fmt::Debug. (2) {:?} предписывает макросу println! воспользоваться std::fmt::Debug, чтобы преобразовать item в выводимую на экран строку.
А в этом примере возвращение типа unit задается в явном виде: fn clear(text: &mut String) -> () { *text = String::from(""); (1) }
(1) Замена строкового значения, на которое указывает text, пустой строкой
120
Глава 3
Тип unit нередко появляется и в сообщениях об ошибках. Многие часто забывают, что последнее выражение в функции не должно заканчиваться точкой с запятой. Восклицательный знак, ! , известен как тип «Never» (никогда). Never показывает, что функция никогда ничего не возвращает, особенно при гарантированном сбое. Возьмем, к примеру, следующий код: fn dead_end() -> ! { panic!("you have reached a dead end"); } (1) Макрос panic! вызывает сбой программы. То есть функция гарантированно никогда не вернет управление вызвавшему ее коду.
В следующем примере создается бесконечный цикл, не позволяющий функции вернуть управление: fn forever() -> ! { loop { //... }; }
(1)
(1) Если в цикле отсутствует инструкция break, он никогда не завершится. Это не позволяет функции вернуть управление.
как и unit, иногда появляется в сообщениях об ошибках. Если указать, что функция возвращает тип, отличный от Never, и забыть добавить break в блок loop, компилятор Rust пожалуется на несоответствие типов. Never,
3.2. Моделирование файлов с помощью struct Создаваемая модель нуждается в каком-либо представлении. struct позволяет соз давать составной тип, образованный из других типов. В зависимости от опыта про граммирования вам могут быть больше знакомы такие понятия, как объект или за пись. Сначала давайте потребуем, чтобы у наших файлов были имена и от нуля и более байтов данных. Код листинга 3.2 выводит на консоль следующие две строки: File { name: "f1.txt", data: [] } f1.txt is 0 bytes long
Для представления данных в коде листинга 3.2 используется тип vec, пред ставляющий собой расширяемый список ив (однобайтовых) значений. В основной части функции main () показан порядок применения (например, доступ к полю). Код этого листинга находится в файле chЗ/chЗ-mock-file.rs. Листинг 3.2. Определение экземпляра struct для представления файлов 1 #[derive(Debug)] 2 struct File {
(1)
Составные типы данных
121
3 name: String, 4 data: Vec, (2) 5 } 6 7 fn main() { 8 let f1 = File { (3) 9 name: String::from("f1.txt"), ( 4) 10 data: Vec::new(), 11 }; 12 (5) 13 let f1_name = &f1.name; (5) 14 let f1_length = &f1.data.len(); 15 16 println!("{:?}", f1); 17 println!("{} is {} bytes long", f1_name, f1_length); 18 }
(1) Позволяет println! вывести File. Чтобы позволить File стать выводимой на экран строкой, типаж std::fmt::Debug работает внутри макроса вместе с элементом замены {:?}. (2) Использование Vec предоставляет доступ к ряду полезных возможностей, например к динамическому изменению размера, позволякщему имитировать запись в файл. (3) String::from генерирует собственные строки из строковых литералов, являкщихся слайсами. (4) Здесь макрос vec! имитирует пустой файл. (5) Обращение к полям с помощью оператора точки (.} . Доступ к полям по ссылке не допускает их использования после перемещения.
Разберем код листинга 3.2 более подробно: • В строках 1-5 определяется структура File. Определения включают поля и связанные с ними типы. Сюда также входит указание времени жизни каждого поля, которое здесь опущено. Явное указание времени жизни требуется, ко гда поле является ссылкой на другой объект. 8-11 создается наш первый экземпляр File. Здесь используется литеральный синтаксис, но обычно практикуется создание структур через вызов удобного метода. Один из таких удобных методов - string: : from(). Он получает значение другого типа, в данном случае это строковый слайс (&str), которое возвращается в виде экземпляра string. Но чаще всего ис пользуется метод Vec: : new (}.
• В строках
13-17 показывается порядок обращения к полям нового экземпля ра. К началу имени добавляется амперсанд, свидетельствующий о желании получить доступ к данным по ссылке. На языке Rust это означает, что пере менные fl_name и fl_length заимствуют данные, на которые они ссылаются.
• В строках
Наверное, уже понятно, что наша структура File вообще ничего не хранит на дис ке. Пока это нас вполне устраивает. Если интересно, внутреннее устройство этой
122
Глава 3
структуры показано на рис. 3.1. На рисунке два его поля (name и data) сами созда ны в виде структур. Если незнакомо понятие указатель (ptr), считайте, что указате ли - то же самое, что и ссылки. Указатели - это переменные, которые ссылаются на некоторые области памяти. Подробности рассматриваются в главе 6. Имя файла
Структура файла
Тип данных файла ptr
name
data
String
Vec
size
capacity
ptr
size
capacity
Представление в памяти [u8; name.size]
[u8; data.size]
...
...
Рис. 3.1. Взгляд на внутреннее устройство структуры File
Отложим вопросы взаимодействия с жестким диском или другим постоянным хра нилищем до конца этой главы. А пока переделаем, как и обещали, код листинга 3.1 и добавим тип File. Шаблон newtype Иногда можно просто обойтись ключевым словом t ype. Но как заставить компиля тор считать ваш новый «тиш> полноценным, обособленным типом, а не просто псевдонимом? Воспользуйтесь шаблоном newtype, представляющим собой упаков ку предопределенного базового типа в структуру s t ruct с одним полем (или, воз можно, в кортеж tuple). В следующем коде показано, как отличить имена сетевых узлов от обычных строк. Этот код находится в файле chЗ/chЗ-new-type-pattern.rs: struct Hostname(String); fn connect(host: Hostname) { println!("connected to {}", host.0); } fn main() { let ordinary_string = String::from("localhost"); let host = Hostname ( ordinary_string.clone() ); connect(ordinary_string); }
А вот как выглядит информация, выведенная компилятором rustc: $ rustc ch3-newtype-pattern.rs error[E0308]: mismatched types --> ch3-newtype-pattern.rs:11:13 | 11 | connect(ordinary_string);
Составные типы данных
123
(1) ^^^^^^^^^^^^^^^ expected struct `Hostname`, found struct `String` error: aborting due to previous error (2) For more information about this error, try `rustc --explain E0308`. (3)
|
(1) ожидалась структура 'Hostname', а обнаружена структура 'String'. (2) ошибка: прервано из-за предыдущей ошибки. (3) Дополнительную информацию об ошибке можно получить, запустив команду 'rustc - explain Е0308'.
Использование шаблона n ewtype может улучшить программу, не допустив молча ливое использование данных в неподходящих контекстах. Недостаток использова ния шаблона - необходимость поддержки каждым новым типом всего предпола гаемого поведения. Код может показаться слишком объемным. Теперь можно приступить к наращиванию функциональности кода первого листин га этой главы. В код листинга 3.3 (который находится в файле chЗ/chЗ-not-quite-file2.rs) добавлена возможность чтения файла с данными. В нем показан способ ис пользования структуры для имитации файла и моделирования чтения его содержи мого. Затем непрозрачные данные преобразуются в строку. Предполагается, что все функции всегда работают успешно, но код по-прежнему заполнен жестко заданны ми значениями. И все же что-то наконец-то выводится на экран. Фрагментарно вы вод программы выглядит следующим образом: File { name: "2.txt", data: [114, 117, 115, 116, 33] } 2.txt is 5 bytes long (1) *****
(1) Демонстрация этой строки испортила бы все удовольствие от результата! Листинг 3.3. Использование struct для имитации файла и моделирования чтения его содержимого
1 2 3 4 5 6 7 8 9 10 11 12 13 14
#![allow(unused_variables)] #[derive(Debug)] struct File { name: String, data: Vec, }
(1) (2)
fn open(f: &mut File) -> bool { true }
(3)
fn close(f: &mut File) -> bool { true
(3)
124 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
Глава 3 } fn read( f: &File, save_to: &mut Vec, ) -> usize { let mut tmp = f.data.clone(); let read_length = tmp.len(); save_to.reserve(read_length); save_to.append(&mut tmp); read_length
(4) (5) (6) (7)
} fn main() let mut name: data: };
{ f2 = File { String::from("2.txt"), vec![114, 117, 115, 116, 33],
let mut buffer: Vec = vec![];
}
open(&mut f2); let f2_length = read(&f2, &mut buffer); close(&mut f2);
(8)
let text = String::from_utf8_lossy(&buffer);
(9)
(8) (8)
println!("{:?}", f2); println!("{} is {} bytes long", &f2.name, f2_length); (10) println!("{}", text)
(1) Отключение предупреждений. (2) Разрешение File работать с println! и с его родственным макросом fmt! (пригодится в конце листинга). (3) Эти две функции пока остаются инертными. (4) Возвращение количества считанных байтов. (5) Создание копии данных в этом месте, поскольку save_to.append() сжимает ввод Vec. (6) Обеспечение наличия достаточного места для входящих данных. (7) Выделение достаточного количества данных в буфере save_to для хранения содержимого f. (8) Проведение интенсивной работы по взаимодействию с файлом. (9) Преобразование Vec в String. Байты не в кодировке UTF-8 заменяются символом �. (10) Представление байтов 114, 117, 115, 116 и d 33 в виде настоящего слова.
Составные типы данных
125
На данный момент в коде программы решены два из четырех вопросов, поднятых в конце рассмотрения листинга 3 .1: • Наша структура File стала настоящим типом. • Функция read () реализована, хотя и неэффективно с точки зрения расхода памяти. Остались еще два вопроса: • open() и close() возвращают bool. • Ни одна из функций не является методом.
3.3. Добавление методов к структуре struct путем использования блока impl В этом разделе кратко объясняется суть методов и рассматриваются способы их использования в Rust. Методы - это функции, связанные с некоторым объектом. С синтаксической точки зрения это просто функции, которым не нужно указывать один из аргументов. Вместо того чтобы вызывать open () и передавать объект File в качестве аргумента (read (f, Ьuffer)), методы позволяют главному объекту с помощью использования оператора точки' фигурировать в вызове функции в каче стве подразумеваемого аргумента (f. read (buffer)). Класы в других языках class File {
Struct и enum в Rust struct File {
Данные Методы }
Данные } impl File {
Методы }
Рис. 3.2. Иллюстрация синтаксических различий между Rust и большинством объектно-ориентированных языков. В Rust методы определяются отдельно от полей. 1 Между методами и функциями существует ряд теоретических различий, но подробное обсуждение этих компьютерных тем доступно в других книгах. Вкратце, функции считаются чистыми, то есть их поведение определяется исключительно их аргументами. Методы по своей сути нечисты, поскольку один из их аргументов представляет собой побочное явление. Хотя все это весьма туманно. Функции вполне способны самостоятельно устранять побочные явления. Более того, методы реализованы с помощью функций. И чтобы добавить исключение к исключению, в объектах иногда реализуются статические методы, в которые не включаются подразумеваемые аргументы.
126
Глава 3
Rust отличается от других языков, поддерживающих методы: в нем нет ключевого слова class. Типы, созданные с помощью блока struct (и рассматриваемого ниже enum), иногда кажутся классами, но поскольку они не поддерживают наследование, то, наверное, хорошо, что их назвали по-другому. Для определения методов Rust-программистами используется блок impl, который физически отличается в исходном коде от блоков struct и enum, с которыми уже приходилось иметь дело. Различия показаны на рис. 3.2.
3.3.1. Упрощение создания объектов за счет реализации метода new () Создание объектов с уместными значениями по умолчанию выполняется с помо щью метода new () . Каждую структуру можно создать, воспользовавшись литераль ным синтаксисом. Это, конечно, удобно для начального периода работы, но приво дит в большей части кода к ненужной многословности. Использование new ()- соглашение, принятое в сообществе Rust. В отличие от других языков, new не является ключевым словом и не имеет какого-либо особого статуса по сравнению с другими методами. Общее представление о соглашении можно получить из табл. 3.1. Таблица 3.1. Сравнение литерального синтаксиса Rust, применяемого для создания объектов, и использования метода new О
Текущее использование
С File::newO
File { narne: String: :frorn("fl.txt"),
File::new("fl.txt", vec! []);
data: Vec: : new(),
};
File { narne: String: :frorn("f2.txt"),
File: : new("f2. txt", vec! (114, 117, 115, 116, 33]);
data: vec! (114, 117, 115, 116,
33], );
Чтобы задействовать эти нововведения, воспользуемся блоком irnpl, показанным в следующем листинге (см. chЗ/chЗ-defining-files-neatly.rs). В результате запуска испол няемого файла на экране должно появиться то же сообщение, что и при запуске ко да из листинга 3.3, но с заменой исходного файла п. txt на f3. txt. Листинг 3.4. Использование блоков impl для добавления методов к структуре 1 #[derive(Debug)] 2 struct File { 3 name: String,
Составные типы данных 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
127
data: Vec, } impl File { fn new(name: &str) -> File { File { name: String::from(name), data: Vec::new(), } } }
(1) (2) (2) (2)
fn main() { let f3 = File::new("f3.txt"); let f3_name = &f3.name; let f3_length = f3.data.len();
(3)
println!("{:?}", f3); println!("{} is {} bytes long", f3_name, f3_length); }
(1) Поскольку File::new() - абсолютно нормальная функция, языку Rust нужно сообщить, что он вернет ИЗ ЭТОЙ функции File. (2) File::new() не ограничивается инкапсуляцией синтаксиса создания объекта, что считается вполне нормальным явлением. (3) Поля по умолчанию являются закрытыми, но к ним можно получить доступ в модуле, определякхцем структуру. Модульная система рассматривается в этой главе чуть позже.
Объединив эти новые приемы с уже имеющимся примером, получим в результате код, показанный в листинге 3.5 (см. chЗ/chЗ-defining-files-neatly.rs). Этот код выводит на консоль следующие три строки: File { name: "2.txt", data: [114, 117, 115, 116, 33] } 2.txt is 5 bytes long (1) *****
(1) Все еще скрывается! Листинг 3.5. Использование impl для повышения эргономики File 1 2 3 4 5 6 7 8
#![allow(unused_variables)] #[derive(Debug)] struct File { name: String, data: Vec, }
128
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
Глава 3
impl File { fn new(name: &str) -> File { File { name: String::from(name), data: Vec::new(), } } fn new_with_data( name: &str, data: &Vec, ) -> File { let mut f = File::new(name); f.data = data.clone(); f } fn read( self: &File, save_to: &mut Vec, ) -> usize { let mut tmp = self.data.clone(); let read_length = tmp.len(); save_to.reserve(read_length); save_to.append(&mut tmp); read_length }
(1)
(2)
} fn open(f: &mut File) -> bool { true }
(3)
fn close(f: &mut File) -> bool { true } fn main() { let f3_data: Vec = vec![ 114, 117, 115, 116, 33 ]; let mut f3 = File::new_with_data("2.txt", &f3_data); let mut buffer: Vec = vec![]; open(&mut f3);
(3)
Составные типы данных 55 56 57 58 59 60 61 62 63 }
let f3_length = f3.read(&mut buffer); close(&mut f3);
129 (4)
let text = String::from_utf8_lossy(&buffer); println!("{:?}", f3); println!("{} is {} bytes long", &f3.name, f3_length); println!("{}", text);
(1) Этот метод используется при желании смоделировать предварительное наличие данных в файле. (2) Замена аргумента f на self (3) Явный тип должен быть указан как vec!, поскольку вывести необходимый тип, преодолев границы функции, невозможно. (4) А это изменение в вызывающем коде.
3.4. Возвращение сообщений об ошибках В начале главы уже дважды высказывалось недовольство отсутствием возможности правильного обозначения ошибок: • Еще не было попытки реализации функции read (). А если бы она была, то как бы мы справились со сбоем? • Методы open () и close () возвращают boo l. А нельзя ли предоставить более сложный тип результата, чтобы в нем содержалось сообщение об ошибке, ес ли его выдает операционная система? Дело в том, что работа с оборудованием не отличается абсолютной надежностью. Даже если не учитывать аппаратные сбои, диск может быть заполнен или операци онная система может вмешаться и заявить об отсутствии прав на удаление опреде ленного файла. В этом разделе рассматриваются различные методы сигнализации о возникновении ошибки, начиная с подходов, общих для других языков, и заканчи вая тем, что характерно для языка Rust.
3.4.1. Изменение значения известной глобальной переменной Один из простейших способов сообщения об ошибке - проверка значения гло бальной переменной. Хотя известно, что в нем не всегда отражается истинная при чина ошибки, это обычный прием в системном программировании. Программисты, работающие на языке С, привыкли по возвращении управления из системных вызовов проверять значение переменной errno. Например, системный вызов close () закрывает дескриптор файла (целочисленное представление файла, присвоенное операционной системой) и может изменять значение errno. В раздел
130
Глава 3
стандарта POSIX, рассматривающий системный вызов c lose (), включен следую щий фрагмент: «Если функция close () прерывается сигналом, который должен быть перехвачен, она должна вернуть -1 с e r rno, установленным в EINTR, при этом состояние fildes [file descriptor, дескриптор файла] не будет определено. Если ошибка ввода-вывода про изошла при чтении или записи в файловую систему, то в ходе выполнения close () эта функция может вернуть -1 со значением e r rno, установленным в EIO; если возвра щается эта ошибка, то состояние fildes не указывается». - Основные спецификации Ореп Group (2018 г.)
Установка значения errno в EIO или в EINTR означает установку в некую магиче скую внутреннюю константу. Конкретные значения задаются произвольно и опре деляются для каждой операционной системы. Применительно к синтаксису Rust проверка глобальных переменных на наличие кодов ошибок будет выглядеть при мерно так, как показано в следующем листинге. Листинг 3.6. Код в стиле Rust, проверяющий коды ошибок, извлекаемые из глобальной, переменной static mut ERROR: i32 = 0;
(1)
// ... fn main() { let mut f = File::new("something.txt"); read(f, buffer); unsafe { if ERROR != 0 { panic!("An error has occurred while reading the file ") } } close(f); unsafe { if ERROR != 0 { panic!("An error has occurred while closing the file ") } }
(2) (3)
(2) (3)
} (1) Глобальная переменная static mut (изменяемая статическая) со статическим временем жизни, действительным в течение всего времени существования программы. (2) Для доступа к переменным static mut и внесения в них изменений требуется использование небезопасного блока un safe. Таким образом Rust снимает с себя всякую ответственность. (3) Проверка значения ERROR. Проверка на наличие ошибок основана на соглашении о том, что О означает отсутствие ошибок.
Составные типы данных
131
Старт
Установка ERROR в значение OK («все в порядке»)
main() Инициализация буфера в виде объекта Vec для Filе-объекта f
read() Чтение с диска, загрузка в буфер
Чтение прошло успешно?
Нет
Установка ERROR в not Ok («не все в порядке»)
Да Возвращение количества байтов, сохраненных в буфере
Все ли в порядке с ERROR?
Проблема: ошибка не закодирована в результате
Нет
Паника
Да Проблема: проверка на ошибки не выполняется
Проблема: обработка ошибок вдали от их источника
(Остальная часть программы)
Завершение
Завершение
Рис. 3.3. Визуализация кода листинга 3.7 с объяснением проблем при использовании глобальных кодов ошибок
132
Глава 3
В коде представленного далее листинга 3.7 вводится новый синтаксис. Самое важ ным в нем, наверное, - ключевое слово unsafe, значение которого будет рассмот рено чуть позже. А пока считайте unsafe предупреждением, а не индикатором не законных действий. Небезопасность означает «тот же уровень безопасности, кото рый всегда обеспечивается языком С». Есть также несколько других небольших дополнений к языку Rust, о которых уже известно: • Изменяемые глобальные переменные обозначаются с помощью static m ut.
• По соглашеmпо в именах глобальных перемеш1ых ВСЕ БУКВЫ ЗАГЛАВНЫЕ. • Ключевое слово const включается для тех значений, которые никогда не из меняются. На рис. 3.3 представлен визуальный обзор ошибок управления ходом выполнения программы и обработка ошибок в коде листинга 3. 7. Листинг 3.7. Использование глобальных переменных для распространения информации об ошибках 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
use rand::{random};
(1)
static mut ERROR: isize = 0;
(2)
struct File;
(3)
#[allow(unused_variables)] fn read(f: &File, save_to: &mut Vec) -> usize { if random() && random() && random() { unsafe { ERROR = 1; } } 0 } #[allow(unused_mut)] fn main() { let mut f = File; let mut buffer = vec![]; read(&f, &mut buffer); unsafe { if ERROR != 0 { panic!("An error has occurred!") } } }
(4) (5)
(6) (7)
(8)
Составные типы данных
133
(1) Перенос контейнера rand в локальную область видимости. (2) Инициализация ERROR в О. (3) Создание типа нулевого размера для замены структуры на время проведения экспериментов. (4) Возвращение true в одном случае из восьми вызовов этой функции. (5) Установка значения ERROR в 1 с уведомлением всей остальной системы о возникновении ошибки. (6) Сохранение изменяемости буфера для согласованности с другим кодом, даже если здесь он не упоминается. (7) Обращение к статическим изменяемым переменным (static mut) относится к небезопасным операциям.
Чтобы поэкспериментировать с проектом, показанным в листинге 3.7, понадобятся следующие команды: 1. git clone --depth=l ht tps:/ / github .com/rust-in-action/code rust-inaction - для загрузки исходного кода книги
2. cd rust-in-action/chЗ/globalerror - для перехода в каталог проекта 3. cargo run - для выполнения кода Если отдать предпочтение ручному варианту, придется выполнить чуть больше действий: 1. cargo new --vcs none globalerror - для создания нового пустого проекта. 2. cd globalerror - для перехода в каталог проекта. 3. cargo add rand@O. в - для добавления в качестве зависимости версии 0.8 кон тейнера rand (если будет получено сообщение об ошибке, что команда cargo add недоступна, запустите команду cargo install cargo-edi t). 4. В качестве дополнительного действия можно проверить, что теперь контейнер rand - это зависимость, просмотрев содержимое файла Cargo.toml в корневом каталоге проекта. В нем должны быть следующие две строки: 5. [dependencies] 6. rand = "0.8" 7.
Содержимое файла src/main.rs chЗ/globalerror/src/main.rs).
следует заменить кодом листинга 3.7 (см. файл
8. Теперь, когда исходный код на месте, можно запустить на выполнение команду cargo run.
На экране должна появиться примерно следующая информация: $ cargo run Compiling globalerror v0.1.0 (file:///path/to/globalerror) *Finished* dev [unoptimized + debuginfo] target(s) in 0.74 secs *Running* `target/debug/globalerror`
134
Глава 3
В большинстве случаев программа не делает абсолютно ничего. Иногда, если у книги много читателей с достаточной мотивацией, она напечатает более громкое сообщение: $ cargo run thread 'main' panicked at 'An error has occurred!',
(1)
src/main.rs:27:13 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
(2)
( 1) поток 'main' запаниковал: 'An error has occurred ! ' (2) примечание: для вывода обратной трассировки запустите команду с переменной среды окружения 'RUST BACKTRACE=l' Опытным программистам известно, что использование глобальной переменной во время системных вызовов обычно регулируется операционной системой. Как правило, в Rust такой стиль программирования не приветствуется, поскольку при нем не только нарушается безопасность типов (ошибки кодируются в виде простых целых чисел), но и в «награду» нерадивым программистам, забывающим проверить значение переменной errno, может проявиться нестабильность про грамм. И тем не менее о наличии этого важного стиля нужно быть в курсе, по скольку:
errno
• Системным программистам может понадобиться взаимодействие с глобаль ными значениями, определяемыми операционной системой. • Разработчикам программ, работающим с регистрами центрального процессо ра и другим низкоуровневым оборудованием, нужно привыкнуть к проверке флагов, позволяющей убедиться в успешном завершении операций. Разница между const и let
Если переменные, определенные с помощью let, неизменяемы, то зачем в Rust есть ключевое слово const? Не вдаваясь в подробности, ответим, что данные, опреде ляемые с let, могут изменяться. Rust позволяет типам обладать явно противоречи вым свойством внутренней изменчивости. Некоторые типы, например std: sync: : Arc и std: rc: : Rc, представляют собой не изменяемый фасад, но по прошествии времени изменяют свое внутреннее состоя ние. Что касается этих двух типов, то по мере того, как на них делаются ссылки, они увеличивают значение счетчика ссылок и уменьшают его значение, когда срок действия этих ссылок истекает. На уровне компилятора let больше относится к использованию псевдонимов, чем к неизменяемости. Использование псевдонимов в понятиях компилятора означает од новременное наличие нескольких ссылок на одно и то же место в памяти. Ссьшки на переменные, доступные только для чтения (их заимствования), объявленные с помо щью let, могут указывать на одни и те же данные. Ссылки для чтения-записи (изме няемые заимствования) гарантированно никогда не станут псевдонимами данных.
Составные типы данных
135
3.4.2. Использование возвращаемого типа Result Подход, принятый в Rust к обработке ошибок, заключается в использовании типа, который соответствует как стандартному случаю, так и случаю ошибки. Этот тип известен как Result. У него два состояния: Ok и Err. Этот двуглавый тип универса лен и используется во всей стандартной библиотеке. Вопрос о том, как один тип обладает двойным действием, будет рассмотрен чуть позже. А пока разберемся, как с ним работать. В код листинга 3.8 по сравнению с предыдущими пошаговыми изменениями внесены следующие новшества: • Функции, работающие с файловой системой, такие как open () в строке 39, возвращают Result. Фактически это позволяет возвращать два типа. Когда функция успешно выполняется, File возвращается внутри оболочки в виде Ok (File). Когда функция обнаруживает ошибку, она воз вращает string в своей собственной оболочке в виде Err (String). Использо вание string в качестве типа ошибки предоставляет простой способ сообще ния об ошибках. • Для вызова функций, возвращающих Result, требуется до полнительный метод (unwrap ()), позволяющий извлечь значение. Вызов unwrap () снимает оболочку с ok (File) для создания File. При обнаружении ошибки Err (String) программа даст сбой. Более сложные способы обработ ки ошибок описаны в главе 4. • Теперь open () и close () полностью владеют своими аргументами File. Пол ное объяснение понятия «владения» будет отложено до главы 4, а здесь оно заслуживает краткого пояснения. • Имеющиеся в Rust правила владения определяют, когда именно значения бу дут удаляться. Передача аргумента File в open () или в close () без добавле ния амперсанда, например &File или &mut File, передает право владения вы зываемой функции. Обычно это означает, что аргумент удаляется при завер шении выполнения функции, но эти две функции, кроме всего прочего, возвращают свои аргументы при завершении выполнения. • Теперь переменная f4 должна вернуть право владения. С изменениями функ ций open ()и close () связано изменение количества использований let f4. Переменная f4 теперь оживляется после каждого вызова open ()и close (). Без этого мы бы столкнулись с проблемами при использовании данных, ставших недействительными. Чтобы запустить код листинга 3.8, нужно из окна терминала выполнить следующие команды: $ git clone --depth=1 https:/ /github.com/rust-in-action/code rust-in-action $ cd rust-in-action/ch3/fileresult $ cargo run
136
Глава 3
То же самое можно сделать вручную, придерживаясь следующих рекомендуемых действий: 1. Перейдите в рабочий каталог /tmp, выполнив, к примеру, команду cd $ТМР (cd %ТМР% под MS Windows). 2. Запустите на выполнение команду cargo new --Ьin --vcs none f ileresult. 3. Убедитесь, что в файле контейнера Cargo.toml указана редакция 2018 года и в ка честве зависимости включен контейнер rand: 4. [package ]
5.
narne = "fileresult"
6. v e rsion =
"0 .1. О"
7. authors = ["Tirn McNarnara "] 8. e di t ion = "2018"
9. [de pend enci es] 10. rand = "0.8"
11. Замените содержимое fileresult/src/main.rs кодом листинга 3.8 (chЗ/fileresult/src/main.rs).
12. Запустите на выполнение команду cargo run. Выполнение команды cargo run приведет к выдаче отладочной информации, но не покажет ничего от самого исполняемого файла: $ cargo run Compiling fileresult v0.1.0 (file:/ / /path/to/fileresult) Finished dev [unoptimized + debuginfo] target(s) in 1.04 secs Running `target/debug/fileresult`
Листинг 3.8. Использование Result для пометки функций, подверженных ошибкам файловой системы 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
use rand::prelude::*; fn one_in(denominator: u32) -> bool { thread_rng().gen_ratio(1, denominator) } #[derive(Debug)] struct File { name: String, data: Vec, } impl File { fn new(name: &str) -> File { File {
(1)
(2) (3)
Составные типы данных 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
name: String::from(name), data: Vec::new() }
137
(4)
} fn new_with_data(name: &str, data: &Vec) -> File { let mut f = File::new(name); f.data = data.clone(); f } fn read( self: &File, save_to: &mut Vec, ) -> Result { let mut tmp = self.data.clone(); let read_length = tmp.len(); save_to.reserve(read_length); save_to.append(&mut tmp); Ok(read_length) }
(5)
(6)
} fn open(f: File) -> Result { if one_in(10_000) { let err_msg = String::from("Permission denied"); return Err(err_msg); } Ok(f) } fn close(f: File) -> Result { if one_in(100_000) { let err_msg = String::from("Interrupted by signal!"); return Err(err_msg); } Ok(f) }
(7)
(8)
fn main() { let f4_data: Vec = vec![114, 117, 115, 116, 33]; let mut f4 = File::new_with_data("4.txt", &f4_data); let mut buffer: Vec = vec![]; f4 = open(f4).unwrap(); let f4_length = f4.read(&mut buffer).unwrap();
(9) (9)
138 63 67 65 66 67 68 69 70 }
Глава 3 f4 = close(f4).unwrap();
(9)
let text = String::from_utf8_lossy(&buffer); println!("{:?}", f4); println!("{} is {} bytes long", &f4.name, f4_length); println!("{}", text);
(1) Перенос общих типажей и типов из контейнера rand в область видимости этого контейнера. (2) Вспомогательная функция, вызыва1СЩая спорадические ошибки. (3) thread_rng() создает локальный для потока генератор случай�.ых чисел; gen_ratio (n, m) возвращает булево значение с вероятностью n/m. (4) Стилистическое изменение для сокращения блока кода. (5) Первое появление Resu lt, где Т - целое число типа usize, а Е - строка типа String. Использование String позволяет возвращать произвольные сообщения об ошибках. (6) В этом коде read() никогда не дает сбоев, но read_length по-прежнему заключается в Ok, поскольку возвращается тип Result. (7) Ошибка возвращается один раз за 10 ООО вьmолнений (8) Ошибка возвращается один раз за 100 ООО выполнений (9) Извлечение Т из оболочки Ok, отход от Т
ПРИМЕЧАНИЕ
Вызов метода .unwrap () в отношении Resul t зачастую не приветствуется. При вы зове типа ошибки происходит выход из программы без полезного сообщения о харак тере этой ошибки. По мере изучения главы будут попадаться и более сложные меха низмы обработки ошибок. Использование Result позволяет с помощью компилятора соблюсти корректность программы: код не пройдет компиляцию, если не будет уделено время на обработ ку крайних случаев. Эта программа завершится ошибкой, но мы, по крайней мере, прояснили ситуацию. Итак, что же такое Result? Это перечисление enum, определенное в стандартной библиотеке Rust. Resul t имеет такой же статус, как и любой другой тип, но связан с остальным языком строгими соглашениями, принятыми сообществом. На вопрос: «А что такое enum?)) я с радостью отвечу, что это тема нашего следующего раздела.
3.5. Определение и использование перечисления enum Перечисление enum - это тип, способный представлять несколько известных вари антов. В классическом виде enum представляет несколько предопределенных из вестных вариантов, например масти игральных карт или планеты солнечной систе мы. Один из примеров такого перечисления показан в следующем листинге.
Составные типы данных
139
Листинг 3.9. Определение enum для представления мастей в колоде карт enum Suit { Clubs, Spades, Diamonds, Hearts, }
Если программировать на языке, использующем перечисления, еще не приходи лось, то для осознания их ценности придется немного потрудиться. Но практика программирования с их использованием даст, наверное, только лишь первичное представление о них. Рассмотрим задачу создания кода, анализирующего журналы регистрации событий. У каждого события есть имя, например UPDATE или DELETE. Вместо хранения в ва шем приложении этих значений в виде строк, что позже, когда сравнение строк станет слишком громоздким, может привести к досадным ошибкам, перечисления позволяют предоставить компилятору сведения о кодах событий. Позже будет по лучено предупреждение вроде: «Привет, я вижу, что рассмотрен запрос на UPDATE, но, похоже, забыт запрос на DELETE. Нужно исправить программу)). В листинге 3.10 показано начало приложения, анализирующего текст и выдающего структурированные данные. При запуске программа выводит на экран следующий результат. Код листинга находится в файле chЗ/chЗ-parse-log.rs: (Unknown, "BEGIN Transaction XK342") (Update, "234:LS/32231 {\"price\": 31.00} -> {\"price\": 40.00}") (Delete, "342:LO/22111")
Листинг 3.10. Определение enum и его использование для анализа журнала событий 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#[derive(Debug)] enum Event { Update, Delete, Unknown, }
(1)
(2) (2)
(2)
type Message = String;
(3)
fn parse_log(line: &str) -> (Event, Message) { let parts: Vec = line .splitn(2, ' ') .collect(); if parts.len() == 1 { return (Event::Unknown, String::from(line)) }
(4)
(5) (6) (7)
140 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
Глава 3 let event = parts[0]; let rest = String::from(parts[1]); match event { "UPDATE" | "update" => (Event::Update, rest), "DELETE" | "delete" => (Event::Delete, rest), _ => (Event::Unknown, String::from(line)), }
(8)
(8)
(9)
(9)
(10)
} fn main() { let log = "BEGIN Transaction XK342 UPDATE 234:LS/32231 {\"price\": 31.00} -> {\"price\": 40.00} DELETE 342:LO/22111"; for line in log.lines() { let parse_result = parse_log(line); println!("{:?}", parse_result); } }
(1) Задание вывода перечисления на экран с помощью автоматически сгенерированного кода. (2) Создание трех вариантов события Event, включая значение для нераспознанных событий. (3) Удобное имя для String при использовании в контексте этого контейнера. (4) Функция для парсинга строки и преобразования ее в полуструктурированные данные. (5) Vec требует от Rust вывести тип элемента. (6) collect() использует итератор из line.splitп() и возвращает Vec. (7) Если line.splitп() не разбивает журнал на две части, возвращается ошибка. (8) Назначает каждую часть parts переменной, чтобы упростить ее использование в будущем. (9) Когда сопоставляется известное событие, возвращаются структурированные данные. (10) Если тип события не распознается, возвращается вся строка.
У перечислений есть ряд интересных скрытых особенностей: • Они работают во взаимодействии с имеющимися в Rust возможностями со поставления с образцом, чтобы содействовать созданию надежного читаемо го кода (что можно увидеть в строках 19-26 листинга 3.10). • Как и структуры, перечисления поддерживают методы через блоки impl. • Перечисления Rust эффективнее набора констант. В варианты перечисления можно включать данные, придавая им структурный вид. Например: enum Suit { Clubs,
Составные типы данных Spades, Diamonds, Hearts,
141
(1)
} enum Card { King(Suit), Queen(Suit), Jack(Suit), Ace(Suit), Pip(Suit, usize), }
(2)
(2) (2)
(2)
(3)
(1) Последний элемент перечислений таюке заканчивается запятой, чтобы облегчить реструктуризацию. (2) На лицевых сторонах карт имеется масть. (3) У нефигурных карт имеется масть и достоинство
3.5.1. Использование enum для управления внутренним состоянием Узнав о способах определения и использования enurn, можно спросить, насколько перечисление может пригодиться применительно к моделированию файлов. Можно расширить наш тип File и позволить ему изменяться при открытии и закрытии. Код листинга 3 .11 (chЗ/chЗ-file-states.rs) выводит на консоль краткое предупрежде ние: Error checking is working File { name: "5.txt", data: [], state: Closed } 5.txt is 0 bytes long
Листинг 3.11. Перечисление, представляющее открываемый или закрываемый файл 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
#[derive(Debug,PartialEq)] enum FileState { Open, Closed, } #[derive(Debug)] struct File { name: String, data: Vec, state: FileState, } impl File { fn new(name: &str) -> File {
142 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
Глава 3 File { name: String::from(name), data: Vec::new(), state: FileState::Closed, } } fn read( self: &File, save_to: &mut Vec, ) -> Result { if self.state != FileState::Open { return Err(String::from("File must be open for reading")); } let mut tmp = self.data.clone(); let read_length = tmp.len(); save_to.reserve(read_length); save_to.append(&mut tmp); Ok(read_length) } } fn open(mut f: File) -> Result { f.state = FileState::Open; Ok(f) } fn close(mut f: File) -> Result { f.state = FileState::Closed; Ok(f) } fn main() { let mut f5 = File::new("5.txt"); let mut buffer: Vec = vec![]; if f5.read(&mut buffer).is_err() { println!("Error checking is working"); } f5 = open(f5).unwrap(); let f5_length = f5.read(&mut buffer).unwrap(); f5 = close(f5).unwrap(); let text = String::from_utf8_lossy(&buffer);
Составные типы данных
143
63 println!("{:?}", f5); 64 println!("{} is {} bytes long", &f5.name, f5_length); 65 println!("{}", text); 66 }
Перечисления могут стать эффективным помощником в решении задач по созда нию надежных, пользующихся доверием программ. Обратитесь к их использова нию в своем коде, если придется вводить «строго типизированные» данные, напри мер коды сообщений.
3.6. Определение общего поведения с помощью типажей Простое определение понятия «файл» не должно зависеть от носителя информации. Файлы поддерживают две основные операции: чтение и запись потоков байтов. Сконцентрированность на этих двух возможностях позволяет проигнорировать ме сто выполнения чтения и записи. Эти действия могут выполняться с жесткого дис ка, с кэша в оперативной памяти, по сети или через что-то, еще более экзотическое. Независимо от того, чем представлен файл - сетевым подключением, вращаю щейся металлической пластиной или суперпозицией электрона - можно опреде лить следующее правило: «Чтобы называться файлом, нужно иметь реализацию этих двух возможностей». Несколько раз уже предоставлялась возможность наблюдать типажи в действии. У типажей есть близкие родственники в других языках. Чаще всего они называются интерфейсами, протоколами, классами типов, абстрактными базовыми классами или, может быть, контрактами. При каждом использовании определения типа# [derive (Debug) J для него реализо вывался типаж Debug. Типажи буквально пронизывают Rust. Давайте посмотрим на способы их создания.
3.6.1. Создание типажа Read Типажи позволяют компилятору (и сторонним специалистам) узнать, что одну и ту же задачу пытаются выполнить несколько типов. Все типы, использующие # [de rive (Debug) J, выводятся на консоль посредством макроса println ! и его родст венников. Если реализация типажа Read разрешена нескольким типам, появляется возможность повторного использования кода и выполнения языком Rust его «вол шебных» абстракций с нулевыми затратами. Для краткости в листинге 3 .12 (сhЗ / chЗ-skeleton-read-trait.rs) показана упрощенная версия уже известного нам кода. В нем показано различие между ключевым словом trai t, которое используется для определений, и ключевым словом impl, которое прикрепляет типаж к конкретному типу. После создания из кода листинга 3.12 ис-
144
Глава 3
полняемого кода с помощью rustc и его выполнения на консоль выводится сле дующая строка: О byte(s) read from File Листинг 3.12. Определение основных характеристик типажа Read для File 1 2 3 4 5 6 7 8 9 10 11 12 13 14
#![allow(unused_variables)]
(1)
#[derive(Debug)] struct File;
(2)
trait Read { fn read( self: &Self, save_to: &mut Vec, ) -> Result; }
(3)
(4)
impl Read for File { fn read(self: &File, save_to: &mut Vec) -> Result { (5) 15 Ok(0) 16 } 17 } 18 19 fn main() { 20 let f = File{}; 21 let mut buffer = vec!(); 22 let n_bytes = f.read(&mut buffer).unwrap(); 23 println!("{} byte(s) read from {:?}", n_bytes, f); 24 }
(1) (2) (3) (4)
Отключение любых предупреждений, касающихся не используемых в функциях переменных. Определение заглушки типа File. Предоставление типажу конкретного имени. Блок типажа включает сигнатуры типов функций, которые-должны соблюдаться разработчиками. Псевдотип Self является заполнителем для типа, который впоследствии реализует Read. (5) Простое значение-заглушка, соответствующее требуемой сигна�уре типа.
В таких небольших примерах определение типажа и его реализация на одной и той же странице могут показаться слишком сложными. В коде листинга 3.12 File раз бит на три кодовых блока. Оборотная сторона медали - то, что многие общие ти пажи становятся по мере роста вашего опыта второй натурой. Как только станет известно, что именно типаж Par t i a lEq делает для одного типа, будет понятно, что он может сделать и для всех остальных типов.
Составные типы данных
145
Что Part ialEq делает для типов? Он позволяет проводить сравнения с помощью оператора равенства==. «Partial)) («Частичное))) допускает случаи, когда два точно совпадающих значения не должны рассматриваться как равные, например значение с плавающей запятой NAN или NULL в SQL. ПРИМЕЧАНИЕ Если заняться изучением форумов и документации сообщества Rust, можно заметить, что там сформированы свои собственные идиомы английской грамматики. Когда по падается предложение со следующей структурой« ... Т is Debug ... )), нужно понимать, что в т реализован типаж Debug.
3.6.2. Реализация std::fmt::Display для ваших собственных типов Макрос printl n ! , как и ряд других макросов, пребывает в семействе, использую щем один и тот же базовый механизм. Макросы println ! , print ! , write ! , writeln ! и format ! полагаются на типажи D isplay и Debug, а те в свою очередь полагаются на реализации типажей, предоставленные программистами, для преобразования кода { ) в то, что выводится на консоль. Возвращаясь через несколько страниц к коду листинга 3 .11, можно сказать, что тип File был составлен из нескольких полей и настраиваемого подтипа FileState. Ес ли помните, в этом листинге было показано использование типажа Debug, повто ряемое в следующем листинге. Листинг 3.13. Фрагменты из листинга 3.11 #[derive(Debug,PartialEq)] enum FileState { Open, Closed, } #[derive(Debug)] struct File { name: String, data: Vec, state: FileState, } //...
(1)
fn main() { let f5 = File::new("f5.txt"); //... println!("{:?}", f5);
(1) (2)
146
// ...
Глава 3 (1)
}
(1) Пропущенные строки оригинала (2) Отладка основана на использовании синтаксиса двоеточия и вопросительного знака.
Можно, конечно, положиться на автоматическую реализацию типажа Debug, но что делать, если нужно предоставить собственный текст? Display требует, чтобы в ти пах был реализован метод fmt, возвращающий fmt: : Resul t. Эта реализация пока зана в следующем листинге. Листинг 3.14. Использование std::fmt::Display для File и связанного с ним состояния FileState . impl Display for FileState { fn fmt(&self, f: &mut fmt::Formatter, ) -> fmt::Result { match *self { FileState::Open => write!(f, "OPEN"), FileState::Closed => write!(f, "CLOSED"), } } } impl Display for File { fn fmt(&self, f: &mut fmt::Formatter, ) -> fmt::Result { write!(f, "", self.name, self.state) } }
(1)
(1) (2)
(1) Чтобы реализовать std::fmt::Display, для вашего типа должен быть определен отдельный метод fmt. (2) При реализации внутренних типов Display принято полагаться на макрос write!.
В следующем листинге показан порядок реализации Display для структуры, вклю чающей поля, которые также нужны для реализации Display. Код листинга нахо дится в файле chЗ/chЗ-implementing-display.гs. Листинг 3.15. Фрагмент рабочего кода для реализации Display 1 #![allow(dead_code)] 2 3 use std::fmt;
(1) (2)
Составные типы данных 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
use std::fmt::{Display};
147 (3)
#[derive(Debug,PartialEq)] enum FileState { Open, Closed, } #[derive(Debug)] struct File { name: String, data: Vec, state: FileState, } impl Display for FileState { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { FileState::Open => write!(f, "OPEN"), (4) FileState::Closed => write!(f, "CLOSED"), (4) } } } impl Display for File { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "", self.name, self.state) (5) } } impl File { fn new(name: &str) -> File { File { name: String::from(name), data: Vec::new(), state: FileState::Closed, } } } fn main() { let f6 = File::new("f6.txt"); //... println!("{:?}", f6); println!("{}", f6); }
(6) (7)
148
Глава 3
(1) Отключение предупреждений, связанных с неиспользованием FileState::Open. (2) Перенос контейнера std::fmt в локальную область видимости с использованием fmt::Result. (3) Перенос Display в локальную область видимости, исключающий необходимость ставить префикс в виде fmt::Display. (4) Можно незаметно воспользоваться макросом write!, возложив на него всю рутинную работу. Display уже реализован в строках, так что нам почти ничего больше и не надо делать. (5) Можно положиться на эту реализацию Display в FileState. (6) Реализация Debug выводит знакомое сообщение наряду со всеми другими средствами реализации Debug:File { ... } . (7} Наша реализация Display следует своим собственным правилам, проявляя себя как . В книге будет встречаться множество вариантов использования типажей. Они по ложены в основу системы обобщений Rust и надежной проверки типов языка. С некоторыми отступлениями ими также поддерживается форма наследования, рас пространенная в большинстве объектно-ориентированных языков. Но пока следует помнить, что типажи представляют собой общее поведение, которое типы выбира ют с помощью синтаксиса impl Tra i t for Туре.
3. 7. Выставление своих типов на всеобщее обозрение Ваши контейнеры будут вступать во взаимодействие с другими контейнерами, соз даваемыми с течением времени. Возможно, появится желание упростить этот про цесс для себя в будущем, скрыв внутренние детали и документируя то, что и так общедоступно. В этом разделе описываются упрощающие этот процесс инструмен ты, доступные в языке и в cargo.
3.7.1. Protecting private data Защита личных данных По умолчанию Rust сохраняет конфиденциальность. Если создать библиотеку только с таким кодом, который был представлен до этого момента, импорт вашего контейнера не принесет никаких дополнительных преимуществ. Чтобы исправить ситуацию, воспользуйтесь ключевым словом pub, сделав информацию общедос тупной. В коде листинга 3 .16 представлено несколько примеров указания префикса pub для типов и методов. Результат будет не очень впечатляющим: File { name: "f7.txt", data: [], state: Closed }
Листинг 3.16. Использование pub для обозначения в File общедоступности полей name и state 1 #[derive(Debug,PartialEq)] 2 pub enum FileState {
(1)
Составные типы данных 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
149
Open, Closed, } #[derive(Debug)] pub struct File { pub name: String, data: Vec, pub state: FileState, } impl File { pub fn new(name: &str) -> File { File { name: String::from(name), data: Vec::new(), state: FileState::Closed } } }
(2)
(3)
fn main() { let f7 = File::new("f7.txt"); //... println!("{:?}", f7); }
(1) Предполагается, что если весь тип объявлен общедоступным, то варианты перечисления также являются общедоступными. (2) Если этот контейнер импортируется третьей стороной, то File.data остается закрытым. (3) Несмотря на то, что структура File общедоступна, ее методы таюке должны быть явно отмечены как общедоступные.
3.8. Создание встроенной документации ваших проектов Когда программные системы разрастаются, возрастает и важность документирова ния их прогресса. В этом разделе рассматривается добавление к вашему коду доку ментации и создание ее НТМL-версий. В листинге 3.17 будет показан уже знакомый код с рядом добавленных строк, на чинающихся с групп символов / / / или / / ! . Первая группа встречается гораздо ча ще. Она приводит к созданию документов, ссылающихся на элемент, который сле дует непосредственно за ней. Вторая группа относится к текущему элементу при сканировании кода компилятором. По соглашению она используется только для
150
Глава 3
аннотации текущего модуля, но также доступна и в других местах. Код этого лис тинга находится в файле chЗ-file-doced.rs. Листинг 3.17. Добавление к коду комментариев 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
//! Simulating files one step at a time. /// Represents a "file", /// which probably lives on a file system. #[derive(Debug)] pub struct File { name: String, data: Vec, }
(1)
(2)
impl File { /// New files are assumed to be empty, but a name is required. pub fn new(name: &str) -> File { File { name: String::from(name), data: Vec::new(), } } /// Returns the file's length in bytes. pub fn len(&self) -> usize { self.data.len() } /// Returns the file's name. pub fn name(&self) -> String { self.name.clone() } } fn main() { let f1 = File::new("f1.txt"); let f1_name = f1.name(); let f1_length = f1.len(); println!("{:?}", f1); println!("{} is {} bytes long", f1_name, f1_length); }
Составные типы данных
151
(1) //! показывает, что комментарий относится к текущему элементу того модуля, который только что был введен в компилятор. (2) /// показывает, что комментарий касается того, что следует непосредственно за ним.
3.8.1. Использование rustdoc для визуализации документов, касающихся одного исходного файла Возможно, вы не в курсе, что при установке Rust был также установлен и инстру мент командной строки под названием rustdoc. Этот инструмент похож на Rust компилятор специального назначения. Но вместо создания исполняемого кода он создает НТМL-версии вашей встроенной документации. Рассмотрим порядок его использования. Предполагая наличие кода листинга 3.17, сохраненного в файле chЗ-file-doced.rs, выполните следующие действия: • Откройте окно терминала. • Перейдите в каталог вашего исходного файла. • Запустите на выполнение команду rustdoc chЗ-file-doced. rs. Инструмент rustdoc создаст для вас каталог (doc/). Фактически точка входа в доку ментацию находится в подкаталоге doc/chЗ_file_doced/index.html. Когда программы начинают разрастаться и охватывать несколько файлов, ручной запуск rustdoc может быть проблематичен. К счастью, всю основную работу мож но возложить на cargo. Этот вопрос рассматривается в следующем разделе.
3.8.2. Использование cargo для визуализации документов для контейнера и его зависимостей С помощью cargo документация может быть представлена в виде расширенного НТМL-вывода. Cargo работает с контейнерами, а не с отдельными файлами, с кото рыми приходилось работать до сих пор. Чтобы обойти это условие, переместим проект в документацию контейнера: для самостоятельного создания контейнера выполните пункты следующей инструкции: 1. Откройте окно терминала. 2. Перейдите в рабочий каталог, например в /tmp/, или же, работая под Windows, наберите и запустите на выполнение команду cd %ТЕМР%. 3. Запустите на выполнение команды cargo new filebasics. 4. Должно получиться дерево каталогов проекта, имеющее следующий вид: filebasics ├──Cargo.toml └──src └──main.rs
152
Глава 3
5. Теперь сохраните исходный код из листинга 3.17 в файле filebasics/src/ m ain. rs, переписав уже находящийся там шаблонный код "Hello World! ".
Чтобы пропустить сразу несколько действий, клонируйте репозиторий. Запустите из окна терминала следующие команды: $ git clone https://github.com/rust-in-action/code rust-in-action $ cd rust-in-action/ch3/filebasics
Для создания НТМL-версии документации по контейнеру выполните следующие действия: 1. Перейдите в корневой каталог проекта (filebasics/), включающий файл Cargo.toml. 2. Запустите на выполнение команду cargo doc --open.
Rust приступит к компиляции НТМL-версии документации вашего кода. На консо ли должна появится примерно следующая информация: Documenting filebasics v0.1.0 (file:/ / /C:/.../Temp/filebasics) Finished dev [unoptimized + debuginfo] target(s) in 1.68 secs Opening С:\...\Temp\files\target\doc\filebasics\index.html Launching cmd /С
Рис. 3.4. Визуализация вывода команды cargo doc
Составные типы данных
153
Если добавить к команде ключ --open, ваш веб-браузер запустится автоматически. Отображаемая в нем документация показана на рис. 3.4. СОВЕТ Если в контейнере много зависимостей, процесс сборки может затянуться. В таком случае пригодится команда сагgо doc с ключом --no-deps. Добавление --no-deps может существенно сократить объем работы, выполняемой rustdoc.
Инструмент rustdoc поддерживает визуализацию форматированного текста, напи санного на Markdown. Это позволяет добавлять в документацию заголовки, списки и ссылки. Фрагменты кода, заключенные в тройные обратные кавычки (' , , ), полу чают выделение синтаксиса. Листинг 3.18. Документирование кода на Rust с помощью встроенных комментариев 1 //! Simulating files one step at a time. 2 3 4 impl File { 5 /// Creates a new, empty `File`. 6 /// 7 /// # Examples 8 /// 9 /// ``` 10 /// let f = File::new("f1.txt"); 11 /// ``` 12 pub fn new(name: &str) -> File { 13 File { 14 name: String::from(name), 15 data: Vec::new(), 16 } 17 } 18 }
Резюме ♦ Структура s t ruct - базовый составной тип данных. В сочетании с типажами структуры наиболее близки к объектам из других областей. ♦ Перечисление eпum эффективнее простого списка. Преимущество enum заключа ется в его способности работать с компилятором для рассмотрения всех крайних случаев. ♦ Методы добавляются к типам через блоки impl.
♦ В Rust можно использовать глобальные коды ошибок, но это может быть слиш ком обременительно и обычно не приветствуется.
154
Глава 3
♦ Тип результата
Resul t выступает в качестве механизма, которому сообщество Rust отдает предпочтение для сообщения о возможной ошибке.
♦ Типажи в Rust-программах обеспечивают общее поведение.
♦ Данные и методы остаются закрытыми до тех пор, пока они не будут объявлены общедоступными с помощью ключевого слова pub.
♦ Cargo можно использовать для создания документации по контейнеру и всем его зависимостям.
4
Время жизни, владение и заимствование
В этой главе рассматриваются следующие вопросы: ♦ Что означает понятие «время жизни» в программировании на Rust. ♦ Работа с контролером заимствований, а не против него. ♦ Несколько тактических приемов решения возникающих проблем. ♦ Осознание обязанностей владельца. ♦ Освоение приемов заимствований значений, принадлежащих коду, находящемуся в другом месте программы. В этой главе объясняется суть одной из концепций, на которой обычно спотыкают ся новички, приступающие к освоению языка Rust - имеющегося в нем контроле ра заимствований. Контролер заимствований (Ьоттоw checker) проверяет закон ность любого доступа к данным, что позволяет Rust избежать проблем с безопасно стью. Изучение порядка его работы, как минимум, сократит время разработки, помогая избежать ссор с компилятором. Но еще важнее то, что освоение работы с контролером заимствований позволяет с уверенностью создавать более крупные программные системы. Именно он положен в основу понятия «бесстрашный парал .1елизм». В этой главе будет объяснено, как работает эта система, и вы узнаете, как ее со блюдать при обнаружении ошибки. Для объяснения компромиссов, связанных с различными способами предоставления совместного доступа к данным, здесь ис пользуется отчасти возвышенный пример моделирования спутниковой группиров ки. В этой главе дается подробный разбор контроля заимствований. Но читателям, желающим быстро вникнуть в суть, может быть полезен ряд моментов. Проверка заимствований основана на трех взаимосвязанных понятиях: времени жизни, вла .Jении и заимствовании: • Владение - это распространенная метафора. Оно не имеет ничего общего с правами собственности. Владение в Rust связано с избавлением от значений, в которых больше нет надобности. Например, функция возвращает управле ние, необходимо освободить память, содержащую ее локальные переменные. Владельцы не могут запретить другим частям программы получать доступ к их значениям или же сообщать о хищении данных в какой-либо действую щий во всей программе механизм защиты данных. • Время жизни значения - это период, в течение которого доступ к этому зна чению - допустимое поведение. Локальные переменные функции живут до
156
Глава 4
тех пор, пока функция не вернет управление, а глобальные переменные могут жить в течение всего времени жизни программы. • Позаимствовать значение означает получить к нему доступ. Эта терминоло гия может показаться странной из-за отсутствия обязательств возвращения значения владельцу. Ее суть призвана подчеркнуть возможность общего дос тупа к значениям из многих частей программы при наличии у них одного владельца.
4.1. Реализация имитации наземной станции CubeSat Стратегическая задача главы - воспользоваться компилируемым примером. Затем будет внесено небольшое изменение, вызывающее ошибку, которая, как представ ляется, возникает без каких-либо изменений в ходе выполнения программы. Работа по устранению проблем должна дать более полное представление о всей концепции. Примером для изучения этой главы станет группировка микроспутников CubeSat. Если сведения о ней вам еще не попадались, ознакомьтесь со следующими опреде лениями: • CubeSat - миниатюрный, по сравнению с обычными, искусственный спутник, благодаря которому расширяется доступность космических исследований. • Наземная станция (Ground station)- посредник между операторами и сами ми спутниками. Она отслеживает радиосигналы, проверяет состояние каждо го спутника, входящего в группировку, и осуществляет двусторонний обмен сообщениями. После ввода в действие нашего кода она работает как шлюз между пользователем и спутниками. • Группировка (Constellation )- собирательное существительное для спутни ков на орбите.
Наземная станция
Рис. 4.1. CubeSats на орбите
На рисунке 4.1 показаны три спутника CubeSat. Чтобы смоделировать ситуацию, создадим переменную для каждого из них. На данный момент эта модель может
Время жизни, владение и заимствование
157
успешно реализовывать целые числа. Моделировать наземную станцию явным образом не нужно, поскольку отправка сообщений по группировке еще не осуще ствляется. Пока эта функция модели опускается. А переменные имеют следую щий вид: let sat_a = 0; let sat_b = 1; let sat_c = 2;
Для проверки состояния каждого из наших спутников будет использоваться функ ция-заглушка и перечисление, представляющее возможные сообщения о состоянии: #[derive(Debug)] enum StatusMessage { Ok, } fn check_status(sat_id: u64) -> StatusMessage { StatusMessage::Ok }
(1)
(1)
(1) На данный момент со всеми нашими спутниками все в порядке.
В реально работающей системе функция check_status () была бы слишком слож ной. Но для наших целей вполне достаточно возвращать всякий раз одно и то же значение. Вставив эти два фрагмента в целую программу, которая дважды «прове ряет>> наши спутники, мы получим примерно следующий листинг. Код листинга находится в файле ch4/ch4-check-sats-1.rs. Листинг 4.1. Проверка состояния всех наших спутников, представленных в виде целых чисел 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
#![allow(unused_variables)] #[derive(Debug)] enum StatusMessage { Ok, } fn check_status(sat_id: u64) -> StatusMessage { StatusMessage::Ok } fn main () { let sat_a = 0; let sat_b = 1; let sat_c = 2; let a_status = check_status(sat_a); let b_status = check_status(sat_b);
(1) (1) (1)
158 19 20 21 22 23 24 25 26
Глава 4 let c_status = check_status(sat_c); println!("a: {:?}, b: {:?}, c: {:?}", a_status, b_status, c_status); // "waiting" ... let a_status = check_status(sat_a); let b_status = check_status(sat_b); let c_status = check_status(sat_c); println!("a: {:?}, b: {:?}, c: {:?}", a_status, b_status, c_status);
27 } (1) Переменная каждого спутника представлена целым числом.
Выполнение кода листинга 4.1 должно проходить без осложнений. Код, пусть не охотно, но все же компилируется. На выходе получается следующий результат: a: Ok, b: Ok, c: Ok a: Ok, b: Ok, c: Ok
4.1.1. Выявление первой проблемы, связанной со временем жизни Давайте приблизимся к особенностям Rust и введем безопасность типов. Создадим вместо целых чисел тип, предназначенный для моделирования наших спутников. В жизни реализация типа CubeSat, наверное, будет включать массу информации о его местоположении, диапазоне радиочастот и многом другом. В следующем лис тинге наш выбор остановлен только на записи идентификатора. Листинг 4.2. Моделирование CubeSat в качестве его собственного типа #[derive(Debug)] struct CubeSat { id: u64, }
Получив определение структуры, внедрим его в наш код. Следующий листинг не пройдет (пока) компиляцию. Подробный разбор причин этого и есть цель основной части этой главы. Код листинга находится в файле ch4/ch4-check-sats-2.rs. Листинг 4.3. Проверка состояния наших спутников, представленных в виде целых чисел 1 #[derive(Debug)] 2 struct CubeSat { 3 id: u64, 4 } 5
(1)
Время жизни, владение и заимствование 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
159
#[derive(Debug)] enum StatusMessage { Ok, } fn check_status( sat_id: CubeSat ) -> StatusMessage { StatusMessage::Ok } fn main() { let sat_a = CubeSat { id: 0 }; let sat_b = CubeSat { id: 1 }; let sat_c = CubeSat { id: 2 };
26 27 28 29 30 31
(2)
(3) (3) (3)
let a_status = check_status(sat_a); let b_status = check_status(sat_b); let c_status = check_status(sat_c); println!("a: {:?}, b: {:?}, c: {:?}", a_status, b_status, c_status); // "waiting" ... let a_status = check_status(sat_a); let b_status = check_status(sat_b); let c_status = check_status(sat_c); println!("a: {:?}, b: {:?}, c: {:?}", a_status, b_status, c_status);
32 } ( 1)
Модификация 1: добавление определения . Модификация 2: использование нового типа в check_status(). (3) Модификация 3: создание трех новых экземпляров. (2)
При попытке скомпилировать код листинга 4.3 выдается примерно следующее ( специально укороченное) сообщение:
error[E0382]: use of moved value: `sat_a` (1) --> code/ch4-check-sats-2.rs:26:31 | 20 | let a_status = check_status(sat_a); | ----- value moved here (2) ... 26 | let a_status = check_status(sat_a); | ^^^^^ value used here after move | = note: move occurs because `sat_a` has type `CubeSat`, (4) (4) = which does not implement the `Copy` trait
(3)
160
Глава 4 (5)
... error: aborting due to 3 previous errors
(6)
(1) (2) (3) (4)
ошибка[Е0382]: использование перемещенного значения: ·sat_а· значение перемещено сюда значение, используемое здесь после перемещения = примечание: перемещение происходит, потому что у ·sat_а· тип 'CuЬeSat· , = в котором не реализован типаж ·сору' (5) Строки удалены для краткости (6) ошибка: прервано из-за трех предыдущих ошибок
Квалифицированный взгляд сможет извлечь пользу из сообщения компилятора. В нем раскрыта причина проблемы и даны рекомендации по ее устранению. Для менее опытного взгляда пользы здесь куда меньше. Констатируется факт использо вания «перемещенного» значения и настоятельно рекомендуется реализовать в CubeSat типаж Сору. Что же получается? Оказывается, хотя все и написано по английски, термин «move>> означает в Rust нечто специфическое. Физически здесь ничего не движется. Движение внутри кода Rust относит к переходу владения, а не к перемещению дан ных. Владение - это понятие, используемое в сообществе Rust для обозначения процесса времени компиляции, который проверяет, что каждое использование зна чения допустимо и что каждое значение полностью уничтожено. Каждое значение в Rust - это владение. В обоих листингах - 4.1 и 4.3 - sat а, sat_b и sat_c владеют данными, на которые они ссылаются. При вызове check_status () владение данными переходит от переменных в области видимости main () к переменной sat _id в функции check_status (). Существенная разница в том, что код листинга 4.3 помещает это целое число в структуру cuьesat 1 • Это из менение типа меняет формальную модель поведения программы. В следующем листинге представлена урезанная версия функции main () из листинга 4.3. Его код сфокусирован на sat_a и на попьпке показать, как владение переходит OTmain() Kcheck_status().
Листинг 4.4. Извлечение из листинга 4.3, сфокусированное на функции main() fn main() { let sat_a = CubeSat { id: 0 }; // ... let a_status = check_status(sat_a); // ...
(1) (2) (3) (2)
1 Помните фразу «абстракции с нулевыми затратами»? Один из способов ее проявления - отказ от добавления дополнительных данных в отношении значений внутри структур.
Время жизни, владение и заимствование // "waiting" ... let a_status = check_status(sat_a); // ...
161
(4) (2)
}
(1) (2) (3) (4)
Владение изначально возникает эдесь при создании объекта CubeSat. Строки опущены для краткости. Владение объектом переходит к check_status(), но не возвращается к main(). В строке 27 sat а больше не владелец объекта, что делает доступ недействительным.
Если значения не заимствованы, то допустима повторная привязка Имеющие опьп работы с такими языками программирования, как JavaScript (с 2015 го да), возможно, были удивлены, увидев, что переменные для каждого из CubeSat в листинге 4.3 были переопределены. В строке 20 a_status присваивается результат первого вызова check_status (sat_a). А в строке 26 этой переменной присваива ется результат второго вызова. Исходное значение перезаписывается. Это вполне допустимый Rust-кoд, но здесь также необходимо помнить о вопросах владения и времени жизни. В данном контексте перезапись возможна из-за отсут ствия заимствований, с которыми пришлось бы считаться. Попытка перезаписи значения, которое все еще доступно в другом месте программы, приводит к тому, что компилятор отказывается компилировать программу. На рис. 4.2 дано визуальное представление взаимосвязанных процессов потока управления, владения и времени жизни. В ходе вызова check_status (sat_a) вла .::�:ение переходит к функции check_status (). Когда check_status () возвращает сообщение StatusMessage, она удаляет значение sat_a. Здесь время жизни sat_a заканчивается. И все же после первого вызова check_status () переменная sat_a остается в локальной области видимости функции main ( J. Попытка получения дос тупа к этой переменной вызовет возмущение контролера зависимостей. Разница между временем жизни значения и его областью видимости, на что многие программисты приучены полагаться, может затруднить понимание ситуации. Пре .::�:отвращению и преодолению подобных проблем посвящена основная часть этой главы. Информация, представленная на рис. 4.2, помогает пролить свет на этот во прос.
4.1.2. Особое поведение элементарных типов Прежде чем продолжить повествование, есть смысл объяснить, почему код листин га 4.1 вообще проходил компиляцию. Ведь единственное изменение, внесенное в код листинга 4.3, заключалось в обертывании наших спутниковых переменных в пользовательский тип. Оказывается, у элементарных типов в Rust особое поведе ние. В них реализован типаж сору.
162
Глава 4 Ход выполнения программы листинга 4.4 main()
Время жизни sat_a
let sat_a = CubeSat { }
Создано и находится во владении main()
check_status(sat_a)
Владение переходит к check_status() .
drop(sat_a)
return StatusMessage::Ok
Повторное обращение к sat_a теперь недействительно; этот код не пройдет компиляцию
Подразумеваемое удаление находящихся во владении значений по завершении существования области видимости владельца. Этого удаления нет в исходном коде
check_status(sat_a)
drop(sat_a)
return StatusMessage::Ok
Рис. 4.2. Визуальное объяснение перехода владения в Rust
Типы, в которых реализован типаж сору, иногда копируются, что в иных обстоя тельствах бьmо бы недопустимо. Это, конечно, предоставляет ряд насущных удобств, но все же не обходится без ловушки для новичков. Вырастая из игрушеч ных программ, использующих целые числа, новичок сталкивается с тем, что его код внезапно ломается. Формально элементарные типы обладают семантикой копирования, а все другие типы имеют семантику перемещения. К сожалению, для изучающих Rust этот осо бый случай выглядит как поведение по умолчанию, поскольку новички обычно на чинают работать с элементарными типами. Различие двух концепций показано в кодах листингов 4.5 и 4.6. Код первого листинга компилируется и запускается, а код второго - нет. Единственное, чем отличаются коды листингов, - это исполь зование разных типов. В следующем листинге показаны не только элементарные типы, но и типы, реализующие сору. Листинг 4.5. Семантика копирования элементарных типов Rust 1 fn use_value(_val: i32) { 2 } 3
(1)
Время жизни, владение и заимствование
163
4 fn main() { 5 let a = 123 ; 6 use_value(a); 7 8 println!("{}", a); 9 10 }
(2)
(1) use_value() получает во владение аргумент _val. Функция usе_vаluе()универсальна, поскольку она используется в следующем примере. (2) Получение доступа к а после возвращения из вызова use_value()впoлнe законно.
В следующем листинге основное внимание уделяется тем типам, в которых не реа лизован типаж Сору. При использовании значений в качестве аргумента той функ ции, которая становится их владельцем, получить к этим значениям новый доступ из внешней области видимости уже невозможно. Листинг 4.6. Семантика перемещения для типов, не реализующих Copy 1 2 3 4 5 6 7 8 9 10 11 12 13
fn use_value(_val: Demo) { }
(1)
struct Demo { a: i32, } fn main() { let demo = Demo { a: 123 }; use_value(demo); println!("{}", demo.a);
(2)
}
(1) use_value() становится владельцем _val. (2) Доступ к demo.a невозможен даже после возвращения из вызова use_value().
4.2. Справочник по рисункам, используемым в этой главе Чтобы проиллюстрировать три взаимосвязанных концепции - область видимости, время жизни и владение - на рисунках, используемых в этой главе, применяются специальные обозначения. Эти обозначения показаны на рис. 4.3.
164
Глава 4
Символы
main() вызывается
sat_a
base
sat_b
StatusMessage::Ok
sat_c
Консоль
Пример main()
base, sat_a, sat_b, и sat_c создаются sat_c единственный аргумент
для check_status() Действия Создание значения Удаление значения Вызов функции fn()
check_status()
Символ появился
Сообщение выводится на консоль
Символ зачеркнут
StatusMessage::OK со-
Аргументы
sat_c возвращается
Имя функции
здается, а затем удаляется
base, sat_a, sat_b,
и sat_c удаляются
Return values Вывод на консоль
Рис. 4.3. Интерпретация изображений, применяемых в этой главе
4.3. Кто такой владелец? Есть ли у него какие-либо обязанности? В мире Rust понятие владения весьма ограничено. Владелец очищает память, когда заканчивается время жизни его значений. Когда значения выходят из области видимости или время их жизни по какой-либо другой причине заканчивается, вызываются их деструкторы. Деструктор - это функция, убирающая все следы значения из программы, удаляя ссылки и освобож дая память. В основной массе кодов Rust найти какие-либо деструкторы невозмож но. Компилятор внедряет соответствующий код сам, что является частью процесса отслеживания времени жизни каждого значения. Чтобы предоставить свой собственный настраиваемый деструктор для типа, реали зуется Drop. Потребность в нем обычно возникает, когда для выделения памяти ис пользуются небезопасные блоки. У Drop имеется один метод, drop (&mut self), ко торый можно использовать для выполнения любых необходимых завершающих действий. Вследствие такой системы значения не могут пережить своего владельца. В этой ситуации структуры данных, построенные с использованием ссылок, например деревья и графы, могут показаться несколько формальными. Если корневой узел дерева- владелец всего дерева, он не может быть удален без учета владения. Наконец, в отличие от локковского представления о личной собственности, владе ние не подразумевает контроля или суверенитета. Фактически «владельцы>> значе-
Время жизни, владение и заимствование
165
ний не имеют специального доступа к своим данным. Они также не могут воспре пятствовать проникновению к ним посторонних. Владельцы не могут возражать заимствованиям значений из других разделов кода.
4.4. Как происходит переход владения В Rust-пporpaммe передача владения от одной переменной к другой осуществляет ся двумя способами. Первый - по присваиванию2 • Второй - передача данных че рез функциональный барьер либо в качестве аргумента, либо в качестве возвра щаемого значения. При возвращении к исходному коду листинга 4.3 видно, что s a t_a начинает свою жизнь с владения объектом CubeSat: fn main() { let sat_a = CubeSat { id: 0 }; // ...
Затем объект CubeSat передается в качестве аргумента в функцию check _ s tatus (). В результате этого владение переходит к локальной переменной s a t_id: fn main() { let sat_a = CubeSat { id: 0 }; // ... let a_status = check_status(sat_a); // ...
Еще одна возможность заключается в том, что s а t_а уступает владение другой пе ременной в функции rnain () . Это может иметь следующий вид: fn main() { let sat_a = CubeSat { id: 0 }; // ... let new_sat_a = sat_a; // .... ..
И наконец, при наличии изменения в сигнатуре функции check_status() она также может передать владение CubeSat переменной в области видимости вызывающего кода вызова. Исходная функция имеет следующий вид:
fn check_status(sat_id: CubeSat) -> StatusMessage { StatusMessage::Ok }
А вот скорректированная функция, получающая уведомление о сообщении посред ством побочного эффекта: fn check_status(sat_id: CubeSat) -> CubeSat { println!("{:?}: {:?}", sat_id,
(1)
2 В Rust-сообществе предпочтение отдается термину «привязка переменной», поскольку он более точно отражает техническую сторону вопроса.
166
Глава 4 StatusMessage::Ok);
(2)
sat_id }
(1) Использование синтаксиса DеЬug-форматирования, поскольку наши типы имеют пометку # [derive(Debug)] (2) Возвращение значения с опусканием точки с запятой в конце последней строки.
С помощью скорректированной функции check_ s tatus (), используемой вместе с новой функцией main () , можно передать владение объектами cuьesa t обратно их исходным переменным. Соответствующий код показан в следующем листинге. Его источник находится в файле ch4/ch4-check-sats-3.rs. Листинг 4.7. Возвращение владения в исходную область видимости 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
#![allow(unused_variables)] #[derive(Debug)] struct CubeSat { id: u64, } #[derive(Debug)] enum StatusMessage { Ok, } fn check_status(sat_id: CubeSat) -> CubeSat { println!("{:?}: {:?}", sat_id, StatusMessage::Ok); sat_id } fn main () { let sat_a = CubeSat { id: 0 }; let sat_b = CubeSat { id: 1 }; let sat_c = CubeSat { id: 2 }; let sat_a = check_status(sat_a); let sat_b = check_status(sat_b); let sat_c = check_status(sat_c); // "waiting" ... let sat_a = check_status(sat_a); let sat_b = check_status(sat_b);
(1)
Время жизни, владение и заимствование
167
31 let sat_c = check_status(sat_c); 32 }
(1) Теперь, когда возвращаемое значение check_status() является исходным sat_a, новая привязка let сбрасывается.
Результат работы новой функции щим образом: CubeSat CubeSat CubeSat CubeSat CubeSat CubeSat
{ { { { { {
id: id: id: id: id: id:
0 1 2 0 1 2
}: }: }: }: }: }:
main ()
из листинга 4.7 теперь выглядит следую
Ok Ok Ok Ok Ok Ok
На рис. 4.4 показано визуальное представление перехода владения в коде листинга 4.7. Ход выполнения программы
Переходы владения
main()
sat_a
check_status()
sat_b
sat_c
В ходе инициализации создаются три экземпляра CubeSat
sat_id sat_a
переходит к sat_id
check_status()
переходит к sat_b
переходит к
При каждом вызове check_status() владение одним из экземпляров переходит локальной переменной функции sаt_id, а затем возвращается в main()
sat_id check_status()
sat_c
Рис. 4.4. Смена владения в коде листинга 4.7
4.5. Решение проблем, связанных с владением Изюминка Rust - система владения. Ею обеспечивается безопасность памяти без использования сборщика мусора. Но есть одно «НО)).
168
Глава 4
Если не понимать сути происходящего, в ней можно запутаться. Особенно если привычный стиль программирования переносится в новую парадигму. Помощь в освоении владения можно получить от использования четырех основных стратегий: • Если полное владение не требуется, используйте ссьшки. • Продублируйте значение. • Чтобы сократить количество долгоживущих объектов, выполните реструкту ризацию кода. • Заключите данные в тип, предназначенный для решения проблем с переходом владения. Чтобы изучить каждую из этих стратегий, давайте расширим возможности нашей спутниковой сети. Дадим наземной станции и нашим спутникам возможность от правлять и получать сообщения. Наше желание, показанное на рис. 4.5, состоит в следующем: на шаге 1 создать сообщение, затем на шаге 2 его передать. После ша га 2 не должно возникнуть никаких проблем с владением.
Шаг 1: base.send()
Шаг 2: sat_a.recv()
Рис. 4.5. Цель: подключить отправку сообщений, избегая проблем с владением
Игнорируя подробности реализации методов, нужно избежать кода, имеющего сле дующий вид. Переход владения значением от переменной sat_a к локальной пере менной в функции ьаsе. send () приводит к нежелательным последствиям, по скольку это значение больше не будет доступно для остальной части функции main ():
base.send(sat_a, "hello!"); sat_a.recv();
(l}
1. Переход владения от sa t_a к локальной переменной в base. send ()
Чтобы добраться до «игрушечной)) реализации, нужен ряд вспомогательных типов. В листинге 4.8 в CubeSat добавляется новое поле- mailbox (почтовый ящик). cuьesat.mailbox представляет собой структуру Mailbox, которая в своем поле messages содержит вектор сообщений Message. Делаем Message псевдонимом string, чтобы он приобрел функциональные возможности типа String, позволяя обойтись без их самостоятельной реализации.
Время жизни, владение и заимствование
169
Листинг 4.8. Добавление к нашей жизни типа Mailbox 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#[derive(Debug)] struct CubeSat { id: u64, mailbox: Mailbox, } #[derive(Debug)] enum StatusMessage { Ok, } #[derive(Debug)] struct Mailbox { messages: Vec, } type Message = String;
Создание экземпляра cuьesat немного усложнилось. Теперь для этого необходимо также создать связанную с ним структуру почтового ящика M ailbox и связанный с ней вектор сообщений vec. Соответствующее дополнение показано в следующем листинге. Листинг 4.9. Создание нового CubeSat с Mailbox CubeSat { id: 100, mailbox: Mailbox { messages: vec![] } }
Необходимо добавить еще один тип, представляющий саму наземную станцию. Пока что воспользуемся простой структурой, показанной в следующем листинге. Это позволит добавлять к ней методы, а впоследствии и поле, в котором будет со держаться почтовый ящик. Листинг 4.10. Определение структуры для представления нашей наземной станции struct GroundStation;
Теперь создание экземпляра Groundstati on должно существенно упроститься. Со ответствующая реализация показана в следующем листинге. Листинг 4.11. Создание новой наземной станции GroundStation {};
Теперь наши новые типы нужно вводить в работу. Как это сделать, показано в сле дующем разделе.
170
Глава 4
4.5.1. Если полное владение не требуется, используйте ссылки Чаще всего в код вносится уменьшение необходимого уровня доступа. Вместо за проса владения в определениях функций можно воспользоваться «заимствовани ем». Для доступа только по чтению следует использовать & т. А для доступа по чтению-записи- & mut т. Владение может потребоваться в более сложных случаях, например когда функци ям необходимо настроить время жизни своих аргументов. Сравнение двух разных подходов приведено в табл.4.1. Табли,-4а 4.1. Сравнение владения и ссылок на изменяемые значения
Использование владения
fn send(to: CubeSat, msg: Message)
Использование ссылки на изменяемое значение {
fn send(to: &mut CuЬeSat, msg: Message)
to.mailbox.messages.push{msg);
{
to.mailbox.messages.push{msg);
}
}
Владение значением переменной to переходит к функции send{). При возвращении из send() значение переменной to удаляется.
Добавление префикса &mut к типу CuЬeSat позволяет внешней области видимости сохранять владение данными, на которые указывает переменная to.
Отправка сообщений в конечном итоге будет заключена в метод, но с основатель ными функциями, реализация которых изменяет внутренний почтовый ящик cuьesat. Чтобы не усложнять код, вернем значение {)и будем надеяться на лучшее в случае проблем с передачей данных, вызванных солнечными ветрами. В следующем фрагменте показан ход выполнения программы, который желательно получить в конечном итоге. Наземная станция может отправить сообщение на sa t_ a с помощью метода s end {), после чего sat _а получает сообщение с помощью метода recv {) : base.send(sat_a, "hello!".to_string()); let msg = sat_a.recv(); println!("sat_a received: {:?}", msg); // -> Option("hello!")
Реализации этих методов показаны в следующем листинге. Чтобы добиться желае мого хода выполнения программы, добавьте реализации к типам GroundStation и CubeSat.
Время жизни, владение и заимствование
171
main()
"t0: {:?}"
hello there hello there
.send() hello there
.mailbox.messages.push()
() () "t1: {:?}"
.recv()
.mailbox.messages.pop()
Option( hello there )
there ) Option( hello
"t2: {:?}" "msg: {:?}" hello there
Рис. 4.6. План игры: во избежание проблем, связанных с владением, используйте ссылки
172
Глава 4
Листинг 4.12. Добавление методов GroundStation.send() и CubeSat.recv() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
impl GroundStation { fn send( &self, to: &mut CubeSat, msg: Message, ) { to.mailbox.messages.push(msg); } }
(1) (1) (1) (2)
impl CubeSat { fn recv(&mut self) -> Option { self.mailbox.messages.pop() } }
(1) &self указывает, что GroundStation.send() требуется ссылка на self с доступом только по чтению. Получатель берет изменяемое заимствование (&mut) экземrшяра CubeSat, а msg становится полноправным владельцем его экземrшяра Message. (2) Владение экземrшяром сообщения Message переходит от msg к локальной переменной функции messages.push().
Заметьте, что
изменяемый доступ к экземпляру cuьesat требуется как GroundStation. s e n d (),так и CubeSat. recv (), поскольку оба метода изменяют ис ходный вектор cuьesat .mes s ages. Владение отправляемым сообщением переходит к mes s a g e s .push (). Чуть позже это предоставит некую гарантию качества,уведом
ляя в случае получения доступа к сообщению после того,как оно уже было отправ лено. Как избежать проблем,связанных с владением,показано на рис. 4.6. Код листинга 4.13 (ch4/ch4-sat-mailbox.rs) объединяет все фрагменты кода, показан ные прежде в этом разделе,и выводит следующий результат. Сообщения с tO по t2 добавляются,чтобы помочь понять,как данные проходят через программу: t0: CubeSat { id: 0, mailbox: Mailbox { messages: [] } } t1: CubeSat { id: 0, mailbox: Mailbox { messages: ["hello there!"] } } t2: CubeSat { id: 0, mailbox: Mailbox { messages: [] } } msg: Some("hello there!")
Листинг 4.13. Избавление от проблем, связанных с владением с помощью ссылок 1 2 3 4 5 6 7 8
#[derive(Debug)] struct CubeSat { id: u64, mailbox: Mailbox, } #[derive(Debug)] struct Mailbox {
Время жизни, владение и заимствование 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
173
messages: Vec, } type Message = String; struct GroundStation; impl GroundStation { fn send(&self, to: &mut CubeSat, msg: Message) { to.mailbox.messages.push(msg); } } impl CubeSat { fn recv(&mut self) -> Option { self.mailbox.messages.pop() } } fn main() { let base = GroundStation {}; let mut sat_a = CubeSat { id: 0, mailbox: Mailbox { messages: vec![], }, }; println!("t0: {:?}", sat_a); base.send(&mut sat_a, Message::from("hello there!"));
(1)
println!("t1: {:?}", sat_a); let msg = sat_a.recv(); println!("t2: {:?}", sat_a); println!("msg: {:?}", msg); }
(1) У нас пока нет абсолютно удобного способа создания экземпляров сообщений. Вместо него мы воспользуемся методом String.from(), выполняющим преобразование &str в String (он же Message).
4.5.2. Сократите количество долгоживущих значений Если есть крупный долгоживущий объект, например глобальная переменная, то хранить его для каждого компонента программы, который в нем нуждается, весьма
174
Глава 4
неудобно. Вместо использования долгоживущих объектов стоит подумать о созда нии недолговечных отдельных объектов. Иногда проблемы, связанные с владением, можно решить за счет пересмотра конструкции всей программы. В случае с cuьesat у нас все предельно просто. Каждая из наших четырех перемен ных - base, s at_a, s a t_b и sat_c -живет в течение всего времени существования main (). А в производственной системе можно столкнуться с управлением сотнями различных компонентов и тысячами взаимодействий. Давайте разберемся, как по высить управляемость, работая по намеченному нами сценарию. План игры для этого раздела показан на рис. 4.7. Для реализации соответствующей стратегии создадим функцию, возвращающую идентификаторы cuьesat. Предполагается, что эта функция - своеобразный чер ный ящик, отвечающий за связь с неким хранилищем идентификаторов, например с базой данных. Как показано в следующем фрагменте кода, при необходимости связаться со спутником создается новый объект. То есть поддерживать живые объ екты на протяжении всего срока действия программы уже не нужно. От этого по лучается двойной выигрыш: можно позволить передать владение значениями на ших недолговечных переменных другим функциям: fn fetch_sat_ids() -> Vec { vec![1,2,3] }
(1)
(1) Возвращение вектора идентификаторов CubeSat.
Также создадим метод для Groundsta tion. Он позволит однократно создать экзем пляр cuьesat по запросу: impl GroundStation { fn connect(&self, sat_id: u64) -> CubeSat { CubeSat { id: sat_id, mailbox: Mailbox { messages: vec![] } } } }
Теперь мы немного приблизились к намеченному результату. Наша основная функция выглядит как следующий фрагмент кода. Фактически мы реализовали первую половину замысла, показанного на рис. 4.7. fn main() { let base = GroundStation(); let sat_ids = fetch_sat_ids(); for sat_id in sat_ids { let mut sat = base.connect(sat_id); base.send(&mut sat, Message::from("hello")); } }
Время жизни, владение и заимствование
175
main()
sat_ids()
Vec
for { hello there
0
(then 1 & 2)
.connect()
(then hello there
&
)
(...)
.send()
hello there
.mailbox.messages.push()
() () hello there
(
)
}
for { (then
&
)
.recv()
.mailbox.messages.pop()
Option(
hello there
)
Option( hello ) there "msg: {:?}"
Option(
hello there
)
(
)
}
Рис. 4.7. План игры: недолговечные переменные, позволяющие избежать проблем, связанных с владением
176
Глава 4
Но возникает проблема. Жизнь наших экземпляров CubeSat завершается в конце области цикла for вместе со всеми сообщениями, которые им отправляет база. Чтобы продолжить реализацию замысла об использовании недолговечных пере менных, сообщения должны находиться где-то за пределами экземпляров CubeSat. В реальной системе они будут жить в условиях невесомости в оперативной памяти устройства. В нашем не вполне реалистичном симуляторе мы поместим их в бу ферный объект, живущий на протяжении всего существования программы. Хранилищем сообщений будет vec (тип нашего почтового ящика Mailbox, определенный в одном из первых примеров кода этой главы). Мы, как по казано в следующем примере кода, изменим структуру сообщения Message, доба вив поля отправителя и получателя. Таким образом, наши теперь уже прокси экземпляры cuьesat смогут сопоставить свои идентификаторы для получения со общений: #[derive(Debug)] struct Mailbox { messages: Vec, } #[derive(Debug)] struct Message { to: u64, content: String, }
Нужно также заново реализовать отправку и получение сообщений. До сих пор объекты cuьesat имели доступ к собственному объекту почтового ящика. Цен тральная станция Ground.Station также имела возможность проникать в эти почто вые ящики для отправки сообщений. Теперь все это нужно изменить, поскольку для каждого объекта может существовать только одно изменяемое заимствование. В модификации, показанной в коде листинга 4.14, экземпляру Mailbox позволено изменять свой собственный вектор сообщения. Когда какой-либо из спутников пе редает сообщения, они берут изменяемое заимствование в почтовый ящик. Затем они уступают доставку объекту почтового ящика. Наши спутники, конечно, могут вызывать методы Mailbox, но согласно данному АРI-интерфейсу касаться каких либо внутренних данных почтового ящика Mailbox им запрещено. Листинг 4.14. Изменения, внесенные в Mailbox 1 impl GroundStation { 2 fn send( 3 &self, 4 mailbox: &mut Mailbox, 5 to: &CubeSat, 6 msg: Message,
Время жизни, владение и заимствование
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
177 (1)
) { mailbox.post(to, msg); } } impl CubeSat { fn recv( &self, mailbox: &mut Mailbox ) -> Option { mailbox.deliver(&self) } } impl Mailbox { fn post(&mut self, msg: Message) { self.messages.push(msg); } fn deliver( &mut self, recipient: &CubeSat ) -> Option { for i in 0..self.messages.len() { if self.messages[i].to == recipient.id { let msg = self.messages.remove(i); return Some(msg); } } None
(2)
(3)
(4)
(5)
(6)
} }
(1) Вызов Mailbox.post() для отправки сообщений с уступкой владения сообщением Message. (2) Вызов Mailbox.deliver() для получения сообщений с переходом владения сообщением Message. (3) Методу Mailbox.post() требуется изменяемый доступ к самому себе и владение сообщением Message. (4) Методу Mailbox.deliver() требуется общая ссылка на CuЬeSat, чтобы извлечь его поле id. (5) При обнаружении сообщения происходит ранний вЬDСод с сообщением, заключенным в Some посредством типа Option. (6) Е= сообщений не найдено, возвращается значение None.
178
Глава 4
ПРИМЕЧАНИЕ
Проницательный читатель может заметить, что в коде листинга 4.14 сформировался весьма серьезный антишаблон. Дело в том, что изменение в коллекцию s e l f .messages в строке 32 вносится в ходе итерации. Но в данном случае это вполне допустимо, по скольку в следующей строке выполняется возвращение из функции. Компилятор смо жет убедиться, что другой итерации уже не будет, и разрешит внести изменение.
Теперь с таким заделом можно реализовать стратегию с рисунка 4.7 в полном объ еме. Вся реализация плана иrры с недолговечными переменными показана в коде листинга 4.15 (ch4/ch4-shor1-lived-strategy.rs). Вывод, полученный при запуске скомпилированной версии кода, имеет следующий вид: CubeSat { id: 1 }: Some(Message { to: 1, content: "hello" }) CubeSat { id: 2 }: Some(Message { to: 2, content: "hello" }) CubeSat { id: 3 }: Some(Message { to: 3, content: "hello" })
Листинг 4.15. Реализация стратегии недолговечных переменных 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
#![allow(unused_variables)] #[derive(Debug)] struct CubeSat { id: u64, } #[derive(Debug)] struct Mailbox { messages: Vec, } #[derive(Debug)] struct Message { to: u64, content: String, } struct GroundStation {} impl Mailbox { fn post(&mut self, msg: Message) { self.messages.push(msg); } fn deliver(&mut self, recipient: &CubeSat) -> Option { for i in 0..self.messages.len() { if self.messages[i].to == recipient.id { let msg = self.messages.remove(i);
Время жизни, владение и заимствование
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
179
return Some(msg); } } None } } impl GroundStation { fn connect(&self, sat_id: u64) -> CubeSat { CubeSat { id: sat_id, } } fn send(&self, mailbox: &mut Mailbox, msg: Message) { mailbox.post(msg); } } impl CubeSat { fn recv(&self, mailbox: &mut Mailbox) -> Option { mailbox.deliver(&self) } } fn fetch_sat_ids() -> Vec { vec![1,2,3] }
fn main() { let mut mail = Mailbox { messages: vec![] }; let base = GroundStation {}; let sat_ids = fetch_sat_ids(); for sat_id in sat_ids { let sat = base.connect(sat_id); let msg = Message { to: sat_id, content: String::from("hello") }; base.send(&mut mail, msg); } let sat_ids = fetch_sat_ids();
180
Глава 4
75 for sat_id in sat_ids { 76 let sat = base.connect(sat_id); 77 78 let msg = sat.recv(&mut mail); 79 println!("{:?}: {:?}", sat, msg); 80 } 81 }
4.5.3. Продублируйте значение Наличие одного владельца для каждого объекта может свидетельствовать о серьез ной предварительной проработке замысла и (или) реструктуризации программы. Из действий, рассмотренных в предыдущем разделе, становится понятно, что для от каза от раннего конструкторского решения может потребоваться довольно большой объем работы. Одной из самых простых альтернатив реструктуризации может стать простое копи рование значения. Зачастую копирование не приветствуется, но в крайнем случае оно может оказаться полезным. Хорошим примером могут послужить элементар ные типы, такие как целые числа. Их дублирование обходится центральному про цессору настолько дешево, что Rust-компилятор, чтобы не заниматься переходом владения, всегда именно так и делает. Типы могут выбрать один из двух режимов дублирования: клонирование или копи рование. У каждого режима имеется свой типаж. Для клонирования это std:: clone:: Clone, а для копирования - std::marker:: Сору. Копирование вы полняется подразумеваемым образом. Если владение значением переходит во внут реннюю область видимости, то значение просто дублируется. (Биты объекта а реп лицируются для создания объекта ь.) Клонирование выполняется явным образом. Типы, в которых имеется реализация clone, содержат метод . clone (), которому разрешено делать все, что ему нужно для создания нового значения. Основные от личия одного режима от другого показаны в табл. 4.2. Таблица 4.2. Отличия клонирования от копирования
Клонирование (std::clone::Clone)
Копирование (std::marker::Copy)
Может быть медленным и затратным.
Всегда бывает быстрым и дешевым.
Никогда не бывает подразумеваемым. Всегда требует вызова метода . clone ().
Всегда бывает подразумеваемым.
Могут быть отличия от оригинала. Что именно означают клонирования, для их типов определяется автором контейнера.
Всегда создает идентичную копию. Копии являются побитными дубликатами оригинального значения.
Время жизни, владение и заимствование
181
Так почему же тогда Rust-программисты порой отказываются от использования Этому есть три основные причины: • Предполагается, что типаж сору практически не снижает производитель ность. С числами - да, но только не с типами произвольных размеров, таки ми как S tring. • Поскольку сору создает абсолютно точные копии, он не способен корректно интерпретировать ссылки. Простое копирование ссылки на т приведет к по пытке создания второго владельца т. Впоследствии будут проблемы с не сколькими попытками удаления т по мере удаления каждой ссылки. • Некоторые типы перегружают типаж Clone с целью предоставления чего-то похожего, но отличного от создания дубликатов. Например, std: : rc: : Rc использует Clone для создания дополнительных ссылок при вызове . clone () .
Cору?
ПРИМЕЧАНИЕ
При работе с Rust типажи std: : clone: :Clone и std: : marker: :Сору фигурируют обычно просто как Clone и Сору. Они включены в область видимости каждого контей нера через стандартную прелюдию.
Реализация копии Вернемся к начальному примеру (в листинге 4.3), вызвавшему исходную проблему с переходом владения. Повторим его для удобства, удалив для краткости sat_ь и sat_c: #[derive(Debug)] struct CubeSat { id: u64, } #[derive(Debug)] enum StatusMessage { Ok, } fn check_status(sat_id: CubeSat) -> StatusMessage { StatusMessage::Ok } fn main() { let sat_a = CubeSat { id: 0 }; let a_status = check_status(sat_a); println!("a: {:?}", a_status); let a_status = check_status(sat_a); println!("a: {:?}", a_status); }
(1) Ошибка кроется во втором вызове check_status(sat_a)
(1)
182
Глава 4
На этом раннем этапе программа состояла из типов, которые содержат типы, имеющие свою собственную реализацию сору. Это хорошо, поскольку означает, что в реализации этой функции для самих себя нет ничего сложного (см. следую щий листинг). Листинг 4.16. Получение Copy для типов, которые состоят из типов, реализующих Copy #[derive(Copy,Clone,Debug)] struct CubeSat { id: u64, }
(1)
#[derive(Copy,Clone,Debug)] enum StatusMessage { Ok, }
(1)
(1) #[derive(Copy,Clone,Debug)] информирует коМПИJiятор о необходимости добавить реализацию каждого из типажей.
В следующем листинге показана возможность реализации коничность блоков impl просто поражает.
Сору
Листинг 4.17. Реализация типажа Copy своими силами impl Copy for CubeSat { } impl Copy for StatusMessage { } impl Clone for CubeSat { fn clone(&self) -> Self { CubeSat { id: self.id } } } impl Clone for StatusMessage { fn clone(&self) -> Self { *self } }
(1) (2)
(3)
(1) Для реализации Сору нужно реализовать Clone. (2) При желании записать создание нового объекта можно и самим ... (3) ... но зачастую можно просто разыменовать self.
своими силами. Ла
Время жизни, владение и заимствование
183
Использование клонирования и копирования
Теперь, изучив способы реализации Clone и сору, давайте приступим к работе с ними. Как уже говорилось, копирование с помощью Сору является подразумевае мым действием. Вместо неизбежного в ином случае перехода владения при при своении и перемещении через границы функций данные просто копируются. Клонирование с помощью Clone требует явного вызова метода . clone (). В осо бых случаях, таких как тот, что показан в коде листинга 4.18, этот вызов служит полезным признаком, предупреждающим программиста о возможной затратности процесса. Код этого листинга находится в файле ch4/ch4-check-sats-clone-and copy-traits. rs.
Листинг 4.18. Использование Clone и Copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
#[derive(Debug,Clone,Copy)] struct CubeSat { id: u64, }
(1)
#[derive(Debug,Clone,Copy)] enum StatusMessage { Ok, }
(1)
fn check_status(sat_id: CubeSat) -> StatusMessage { StatusMessage::Ok } fn main () { let sat_a = CubeSat { id: 0 }; let a_status = check_status(sat_a.clone()); println!("a: {:?}", a_status.clone()); let a_status = check_status(sat_a); println!("a: {:?}", a_status); }
(2) (2)
(3)
(3)
(1) Копирование Сору подразумевает Юiонирование Clone, поэтому мы можем использовать любой из этих типажей в последук:щих действиях. (2) Клонирование каждого объекта ничуть не сложнее вызова метода .clone(). (3) Копирование Сору работает вполне ожидаемо.
184
Глава 4
4.5.4. Заключите данные в специальные типы Рассматривая в этой главе применяемую в Rust систему владения и способы пре одоления накладываемых ею ограничений, мы подошли к последней, весьма рас пространенной стратегии использования типов-оболочек, позволяющей по сравне нию со всеми исходными средствами создавать более гибкий код. Но для предос тавления свойственных Rust гарантий безопасности ее применение связано с издержками времени выполнения. Иначе говоря, Rust позволяет программистам останавливать свой выбор на сборке мусора3• Чтобы понять суть стратегии типов-оболочек, давайте введем в обращение тип оболочку s td: rc:: Rc, принимающий параметр типа т и обозначаемый обычно как Rc. Эта запись Rc читается как «R. С. of Т» и означает «значение типа Т с подсчетом ссылок». Rc предоставляет совместное владение т. Это совместное владение не позволяет удалять значение типа т из памяти до тех пор, пока не будут удалены все его владельцы. Из названия следует, что для отслеживания действующих ссылок используется подсчет ссылок. По мере создания каждой новой ссылки внутренний счетчик уве личивается на единицу. Когда ссылка сбрасывается, счет уменьшается на единицу. Когда счет достигает нуля, значение т также сбрасывается. Для заключения т в оболочку требуется вызов Rc: : new () . Этот прием показан в следующем листинге, код которого находится в файле ch4/ch4-rc-groundstation.rs. Листинг 4.19. Заключение типа, определяемого пользователем, в оболочку Rc 1 2 3 4 5 6 7 8 9 10
use std::rc::Rc;
(1)
#[derive(Debug)] struct GroundStation {} fn main() { let base = Rc::new(GroundStation {}); println!("{:?}", base);
(2) (3)
}
(1) Ключевое слово use переносит модули из стандартной библиотеки в локальную область видимости. (2) Заключение в оболочку предусматривает включение экземпляра GroundStation в вызов Rc::new(). (3) Вывод на экран строки «GroundStation». 3 Сборка мусора, или Garbage collectioп, часто обозначаемая сокращением GC, - это стратегия управления памятью, используемая многими языками программирования, включая Python и JavaScript, а также всеми языками, построенными на NM (Java, Scala, Kotlin) или CLR (С#, F#).
Время жизни, владение и заимствование
185
В Rc реализуется клонирование Clone. Внутренний счетчик увеличивается при каждом вызове base. c lone (). Каждый сброс уменьшает значение этого счетчика. Когда он достигает нуля, исходный экземпляр освобождается. :> 31;
Чтобы углубить представление о происходящем, рассмотрим все шаги в графиче ском виде: 1. Начнем со значения типа fЗ2: 1 let n: f32 = 42.42;
2. Интерпретируем биты битами:
f32
как
uз2,
чтобы получить возможность манипуляции
2 let n_bits: u32 = n.to_bits();
Нужно решить задачу, связанную с позицией знакового бита. В простом представлении в этом разряде закодировано либо число 4 294 967 296 (232), либо число 0, а не (210) либо 0.
3. Сдвигаем биты в n на 31 позицию вправо: 3 let sign_bit = n_bits >> 31;
Теперь знаковый бит находится в самом младшем значимом разряде.
202
Глава 5
5.4.3. Выделение экспоненты Чтобы выделить экспоненту, требуются две битовые манипуляции. Сначала вы полняется сдвиг вправо, чтобы перезаписать биты мантиссы(» 2 3 ). Затем приме няется маска «И))(& Oxff), чтобы исключить бит знака. Биты экспоненты также должны пройти этап декодирования. Чтобы декодировать показатель степени, нужно интерпретировать его 8 бит как целое число со знаком, а затем вычесть из результата 127. (Из раздела 5.3.2 известно, что 127 называется смещением.) Действия, указанные в последних двух абзацах, запрограммированы в коде следующего листинга. Листинг 5.8. Выделение экспоненты из f32 и его декодирование 1 let n: f32 = 42.42; 2 let n_bits: u32 = n.to_bits(); 3 let exponent_ = n_bits >> 23; 4 let exponent_ = exponent_ & 0xff; 5 let exponent = (exponent_ as i32) - 127;
И для более четкого представления процесса эти шаги отображаются в графиче ском виде: 1. Начнем со значения типа f32: 1 let n: f32 = 42.42;
2. Интерпретируем биты битами:
f32
как
u32,
чтобы получить возможность манипуляции
2 let n_bits: u32 = n.to_bits();
Проблема: биты экспоненты не занимают младшие разряды
3. Сдвигаем 8 битов экспоненты вправо, переписывая мантиссу: 3 let exponent_ = n_bits >> 23;
Проблема: в восьмом разряде остается знаковый бит
4. Отфильтровываем знаковый бит с помощью маски «И)). Через маску могут пройти только 8 крайних правых битов: 4 let exponent_ = exponent_ & 0xff;
Теперь знаковый бит удален
5. Интерпретируем оставшиеся биты как целое число со знаком и вычитаем опре деленное стандартом смещение: 5 let exponent = (exponent_ as i32) - 127;
Углубленное изучение данных
203
5.4.4. Выделение мантиссы Для выделения 23 бит мантиссы можно воспользоваться маской «И)), чтобы уда лить знаковый бит и экспоненту (& 0x7fffff). Но в этом нет необходимости, по скольку следующие шаги декодирования могут просто игнорировать биты как не существенные. К сожалению, этап декодирования мантиссы значительно сложнее этапа декодирования экспоненты. Для декодирования битов мантиссы каждый бит следует умножить на его вес и просуммировать результат. Вес первого бита равен 0,5, а вес каждого последующе го бита равен половине текущего веса; например, 0,5 (Г 1 ), 0,25 (Г2), •••, О,00000011920928955078125 (2-23). За исключением особых случаев, подразумевае мый 24-й бит, представляющий 1.0 (2---{)), всегда считается установленным. Особые случаи вызываются состоянием экспоненты: • Когда все биты экспоненты равны О, обработка битов мантиссы изменяется, чтобы представлять субнормальные числа (также известные как «денормали зованные числа))). На практике это изменение увеличивает количество спо собных к представлению десятичных чисел, близких к нулю. Формально суб нормальным считается число от О до наименьшего числа, которое в против ном случае могло бы представлять нормальное поведение. • Когда все биты экспоненты равны 1, десятичным числом является бесконеч ность (оо), отрицательная бесконечность (-оо) или не число (NAN}. Значения NAN указывают на особые случаи, когда числовой результат не определен ма тематически (например, О -;- О) или недопустим по иным причинам. • Операции со значениями NAN часто противоречат здравому смыслу. Напри мер, проверка равенства двух значений всегда дает значение false, даже если две битовые комбинации абсолютно одинаковы. Любопытно, что fЗ2 имеет примерно 4,2 миллиона (-222) битовых комбинаций, представляющих NAN. Код, реализующий не особые, а обычные случаи, представлен в следующем листинге. Листинг 5.9. Выделение мантиссы из f32 и его декодирование 1 2 3 4 5 6 7 8 9 10 11 12 13
let n: f32 = 42.42; let n_bits: u32 = n.to_bits(); let mut mantissa: f32 = 1.0; for i in 0..23 { let mask = 1 (u32, u32, u32) { let bits = n.to_bits(); let sign = (bits >> 31) & 1; let exponent = (bits >> 23) & 0xff; let fraction = bits & 0x7fffff ; (sign, exponent, fraction) } fn decode( sign: u32, exponent: u32, fraction: u32 ) -> (f32, f32, f32) { let signed_1 = (-1.0_f32).powf(sign as f32);
(5) (6)
(7)
let exponent = (exponent as i32) - BIAS; let exponent = RADIX.powf(exponent as f32);
(8)
for i in 0..23 { let mask = 1 f32 { 56 sign * exponent * mantissa 57 }
207
(10)
(1) Такие же константы можно получить из модуля std::f32. (2) Функция main() удобно расположена в начале файла. (3) Удаление 31-го ненужного бита путем их перемещения в никуда, чтобы остался только знаковый бит. (4) Удаление самого старшего бита путем фильтрации с применением маски логического «И», а затем сдвиг в никуда 23 ненужных битов. (5) Благодаря применению маски «И» сохраняются только 23 самых младших значащих бита. (6) Здесь та часть, что относится к мантиссе, называется fraction, поскольку по завершении декодирования она становится мантиссой. (7) Преобразование знакового бита в 1.0 или -1.0 (-1 со знаком). Скобки вокруг l.0_f32 нужны для разъяснения приоритета операторов, поскольку вызовы методов имеют более высокий приоритет, чем унарный минус. (8) Экспонента должна стать типом i32 на тот случай, если вычитание смещения экспоненты (BIAS) даст отрицательное число; затем она должна быть приведена к типу f32, чтобы получить возможность ее использования для возведения в степень. (9) Декодирование мантиссы с использованием логики, рассмотренной в разделе 5.4.4. (10) Небольшой обман за счет использования значений f32 на промежуточных этапах. Надеемся, это простительно.
Разобравшись в способах распаковки битов из байтов, вы будете в гораздо более выгодном положении, сталкиваясь на протяжении всей своей карьеры с интерпре тацией приходящих по сети нетипизированных байтов.
5.5. Форматы чисел с фиксированной точкой Десятичные числа могут быть представлены в формате не только с плавающей, но и с фиксированной точкой. Такой формат может пригодиться для представления дробных значений и может быть выбран для выполнения вычислений на процессо рах без блока с плавающей точкой (FPU), например на микроконтроллерах. В отли чие от чисел с плавающей точкой, десятичный разряд не перемещается для дина мической подстройки под разные диапазоны. В нашем случае числовой формат с фиксированной точкой будет использоваться для компактного представления зна чений в диапазоне -1 .. =1. Точность в нем, конечно, теряется, зато он существенно экономит пространство памяти2 • 2 В сообществе машинного обучения эта практика известна как квантование модели.
208
Глава 5
Q-формат - числовой формат с фиксированной точкой, использующий один байт • Он был создан компанией Texas Instruments для встраиваемых вычислительных устройств. Конкретная версия Q-формата, которую мы намереваемся реализовать, называется Q7. Это означает, что для представляемого числа доступно 7 битов плюс 1 знаковый бит. Десятичная природа типа будет замаскирована, для чего 7 битов будут помещены в формат i8. То есть, компилятор Rust сможет посодействовать в отслеживании знака значения. Кроме того, появится возможность без каких-либо затрат задействовать такие типажи, как PartialEq и Eq, которые предоставляют для нашего типа операторы сравнения. Следующий листинг - выборка из листинга 5.14, предоставляющая определение типа. Его код находится в файле ch5/ch5-q/src/lib.rs. 3
Листинг 5.11. Определение формата Q7 1 #[derive(Debug,Clone,Copy,PartialEq,Eq)] 2 pub struct Q7(i8);
(1)
(1) Q7 представляет собой кортежную структуру.
Структура, созданная из безымянных полей (например, Q7 (i в)), называется корте жем. Когда поля не предназначены для непосредственного доступа, этой структу рой предлагается краткая система представления. Хотя в листинге 5.11 это не пока зано, но структуры кортежей за счет добавления дополнительных типов, разделен ных запятыми, могут включать в себя несколько полей. Следует напомнить, что блок # [ der i ve (... ) J запрашивает у Rust от нашего имени реализацию сразу несколь ких типажей: • Debug - используется макросом println ! () (и не только им); позволяет пре образовать Q7 в строку с помощью синтаксиса 1 : ? J. • Clone - позволяет продублировать Q7 с помощью метода . clone () . Эта функция может задействоваться благодаря наличию в i8 реализации типажа Clone. •
Сору - позволяет выполнять экономное и подразумеваемое дублирование в тех ситуациях, где без него могли бы возникнуть ошибки владения. При этом Q7 формально из типа, использующего семантику перехода владения, стано вится типом, использующим семантику копирования.
• PartialEq - позволяет сравнивать значения Q7 с использованием оператора равенства (==). • Eq - указывает Rust, что все возможные значения Q7 можно сравнивать с любым другим возможным значением Q7. Q, довольно часто записываемый как (Q (стилем, называемым жирным шрифтом на классной доске), - математический символ для так называемых рациональных чисел, которые могут быть представлены как дробь из двух целых чисел, например 1/3. 3
Углубленное изучение данных
209
Q7 предназначен исключительно для компактного хранения и передачи данных. Его самая важная роль - преобразование в типы с плавающей точкой и обратно. Сле дующий листинг - выборка из листинга 5.14, которая показывает преобразование из формата fб4. Код листинга находится в файле ch5/ch5-q/src/lib.rs. Листинг 5.12. Преобразование f64 в Q7 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
impl From for Q7 { fn from (n: f64) -> Self { // assert!(n >= -1.0); // assert!(n = 1.0 { Q7(127) } else if n f64 { (n.0 as f64) * 2_f64.powf(-7.0) } }
(1) Принуждение к соответствию любого ввода, выходящего за указанные пределы (2) Эквивалент итерационного подхода, рассмотренного в разделе 5.3.5.
В листинге 5.12 в двух блоках implFrom for u языку Rust предписывается по рядок преобразования типа т в тип u. В этом листинге: • В строках 4 и 18 вводятся блоки impl From for u. Типаж std: : co nvert: : From включен в локальную область видимости как From, что является частью стандартной прелюдии. При этом требуется, чтобы в типе u бьша реализована функция from(), принимающая в качестве своего единст венного аргумента значение типа т. • В строках 6-7 представлен вариант обработки непредв�енных входных дан ных, приводящих к сбою. Здесь он не используется, но доступен для приме нения в ваших собственных проектах. • Код в строках 13-16 отсекает ввод, выходящий за границы допустимого. Из вестно, что при решении наших задач вводимых данных, выходящих за пре делы допустимого диапазона, не будет, поэтому риск потери информации воспринимается абсолютно спокойно.
210
Глава 5
СОВЕТ
Преобразования с использованием типажа From должны быть математически эквива лентными. Для преобразований типов, которые моrут завершиться ошибкой, нужно рассмотреть возможность реализации типажа std: : convert: : TryFrom.
Используя только что показанную реализацию From, можно также быстро реализовать преобразование из f 3 2 в Q7. Это преобразование присутствует в сле дующем листинге, являющемся выборкой из кода листинга 5.14. Его исходный код находится в файле ch5/ch5-q/src/lib.rs. Листинг 5.13. Преобразование из f32 в Q7 через f64 22 23 24 25 26 27 28 29 30 31 32
impl From for Q7 { fn from (n: f32) -> Self { Q7::from(n as f64) } }
(1)
impl From for f32 { fn from(n: Q7) -> f32 { f64::from(n) as f32 } }
(2)
(1) Конвертация значения из f32 в f64 абсолютно безопасна. Число, которое может быть представлено в 32-разрядном формате, таюке может быть представлено и в 64-разрядном формате. (2) Как правило, при преобразовании значения типа f64 в f32 есть риск потери точности. Для данного приложения этот риск не характерен, поскольку при преобразовании работа ведется только с числами от -1 до 1. Итак, рассмотрены оба типа с плавающей точкой. Но как узнать, что работа напи санного нами кода всецело отвечает нашим намерениям? А как проверить то, что написано? Оказывается, в Rust есть великолепная поддержка модульного тестиро вания, обеспечиваемая инструментальным средством cargo. Показанный код Q7 доступен в виде полного листинга. Но сначала, чтобы протес тировать код, войдите в корневой каталог контейнера и запустите команду c a r go test. Ниже показан результат работы кода листинга 5.14 (полного листинга): $ cargo test Compiling ch5-q v0.1.0 (file:///path/to/ch5/ch5-q) Finished dev [unoptimized + debuginfo] target(s) in 2.86 s Running target\debug\deps\ch5_q-013c963f84b21f92 running 3 tests test tests::f32_to_q7 ... ok test tests::out_of_bounds ... ok test tests::q7_to_f32 ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Углубленное изучение данных
211
Doc-tests ch5-q running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
В коде следующего листинга показана реализация формата Q 7 и его преобразование в типы f32 и f 6 4 и обратно. Код находится в файле ch5/ch5-q/src/lib.rs. Листинг 5.14. Полный код реализации формата Q7 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
#[derive(Debug,Clone,Copy,PartialEq,Eq)] pub struct Q7(i8); impl From for Q7 { fn from (n: f64) -> Self { if n >= 1.0 { Q7(127) } else if n f64 { (n.0 as f64) * 2f64.powf(-7.0) } } impl From for Q7 { fn from (n: f32) -> Self { Q7::from(n as f64) } } impl From for f32 { fn from(n: Q7) -> f32 { f64::from(n) as f32 } } #[cfg(test)] mod tests { use super::*; #[test] fn out_of_bounds() {
212 39 40 41 42 43 44 45 46 47 48 49* 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 }
Глава 5 assert_eq!(Q7::from(10.), Q7::from(1.)); assert_eq!(Q7::from(-10.), Q7::from(-1.)); } #[test] fn f32_to_q7() { let n1: f32 = 0.7; let q1 = Q7::from(n1); let n2 = -0.4; let q2 = Q7::from(n2); let n3 = 123.0; let q3 = Q7::from(n3); assert_eq!(q1, Q7(89)); assert_eq!(q2, Q7(-51)); assert_eq!(q3, Q7(127)); } #[test] fn q7_to_f32() { let q1 = Q7::from(0.7); let n1 = f32::from(q1); assert_eq!(n1, 0.6953125); let q2 = Q7::from(n1); let n2 = f32::from(q2); assert_eq!(n1, n2); }
Краткий обзор модульной системы Rust В Rust имеется мощная и эргономичная модульная система. Но чтобы не усложнять примеры кода, в этой книге она используется не слишком часто. Модульная систе ма основана на следующих положениях: • Модули объединяются в контейнеры. • Модули могут быть определены структурой каталогов проекта. Если каталог src/ содержит файл mod.rs, то его подкаталоги становятся модулями. • Модули также могут бьпь определены в файле с помощью ключевого слова mod. • Модули могут иметь произвольные вложения. • Все элементы модуля, включая его подмодули, по умолчанию являются за крытыми. Доступ к закрытым элементам можно получить как в самом моду ле, так и в любых его потомках.
Углубленное изучение данных
213
• К тому, что нужно сделать доступным, следует добавить в качестве префикса ключевое слово pub. У этого ключевого слова имеется ряд особенностей: ◊ pub (crate) предоставляет доступ к элементу другим модулям внутри кон тейнера (крейта). ◊ pub (super) предоставляет доступ к элементу со стороны родительского модуля. ◊ pub (in path) предоставляет доступ к элементу в пределах указанного пути. ◊ pub (self) явным образом сохраняет открытый доступ к элементу в его модуле. • Элементы из других модулей переносятся в локальную область видимости с помощью ключевого слова use.
5.6. Генерация случайных вероятностей из случайных байтов Вам предлагается выполнить интересное упражнение для проверки знаний, полу ченных при чтении предыдущих страниц. Представьте, что есть источник случай ных байтов (ив), и нужно преобразовать один из них в значение с плавающей точ кой (tз2) в диапазоне от О до 1. Простая интерпретация входящих байтов в виде типов f32 или f64 посредством mem::transmute приводит к существенным измене ниям масштаба. В следующем листинге показана операция деления, генерирующая из произвольного входящего байта значение f32 в диапазоне от О до 1. Листинг 5.15. Генерация из u8 значений f32 в интервале [0,1] с помощью деления fn mock_rand(n: u8) -> f32 { (n as f32) / 255.0 }
(1)
(1) 255 - максимальное значение, которое может быть представлено в формате u8 .
Поскольку деление - медленная операция, давайте поищем что-то более быстрое, чем простое деление на наибольшее значение, которое может быть представлено байтом. Вероятно, можно принять постоянное значение экспоненты, а затем сдви нуть входящие биты в мантиссу так, чтобы они образовали диапазон от О до 1. В коде листинга 5.16 показан результат манипуляции с битами, ставший лучшим моим достижением. При экспоненте -1, представленной как 0b0l l 11110 (126 по основанию 10), исход ный байт находится в диапазоне от 0,5 до 0,998. С помощью вычитания и умноже ния результат можно нормализовать до 0,0-0,996. Но, может быть, есть способ получше этого?
214
Глава 5
Листинг 5.16. Генерация из u8 значений f32 в интервале [0,1] 1 fn mock_rand(n: u8) -> f32 { 2 3 let base: u32 = 0b0_01111110_00000000000000000000000; 4 5 let large_n = (n as u32) 0.99609375 mid of input range: 01111111 -> 0.49609375 min of input range: 00000000 -> 0
Листинг 5.17. Генерация значения f32 без деления 1 2 3 4 5 6 7 8 9 10 11 12 13
fn mock_rand(n: u8) -> f32 { let base: u32 = 0b0_01111110_00000000000000000000000; let large_n = (n as u32) u16 { 8 self.current_operation 9 } 10 11 fn run(&mut self) { 12 // loop { 13 let opcode = self.read_opcode(); 14 15 let c = ((opcode & 0xF000) >> 12) as u8; 16 let x = ((opcode & 0x0F00) >> 8) as u8; 17 let y = ((opcode & 0x00F0) >> 4) as u8;
(1) (1) (1) (2) (3) (3) (3)
218
Глава 5
18 let d = ((opcode & 0x000F) >> 0) as u8; 19 20 match (c, x, y, d) { 21 (0x8, _, _, 0x4) => self.add_xy(x, y), 22 _ => todo!("opcode {:04x}", opcode), 23 } 24 // } 25 } 26 27 fn add_xy(&mut self, x: u8, y: u8) { 28 self.registers[x as usize] += self.registers[y 29 } 30 }
(1) (2) (3) (4) (5) (6)
(3)
(4) (5) (6)
as usize];
При вводе чтения из памяти функция read_opcode() усложняется. Пока в цикле этот код не запускается. Процесс декодирования кода операции полностью рассматривается в следуIСЩем разделе. Диспетчеризация вьmолнения в ту аппаратную схему, которая за него отвечает. Полный эмулятор содержит несколько десятков операций. Пока запуск этого кода в цикле откладывается до более подходящего момента.
Интерпретация кодов операций CHIP-8
Наш процессор должен уметь интерпретировать свой код операции (Ох8014). В этом разделе дается подробное объяснение процесса, используемого в CHIP-8, и рассматривается его соглашение об именах. Коды операций CHIP-8 представляют собой значения u16, состоящие из 4 полубай тов. Полубайт - это половина байта, то есть 4-битное значение. Поскольку в Rust нет 4-битного типа, разбиение значений u 16 на эти части дается непросто. Ситуа ция усложняется еще и тем, что полубайты CHIP-8 часто подвергаются рекомбина ции с целью формирования в зависимости от контекста 8-битных или 12-битных значений. Чтобы говорить о частях каждого кода операции стало проще, введем стандартную терминологию. Каждый код операции состоит из двух байтов: старшего и младше го. А каждый байт состоит из двух полубайтов, соответственно старшего и младше го полубайта. Визуальное представление этих терминов показано на рис. 5.2. Старший байт (u8) Младший байт (u8)
0 x 7 3 E E Старший полубайт (u4)
Младший полубайт (u4) Старший полубайт (u4)
Младший полубайт (u4)
Рис. 5.2. Термины, используемые для обозначения частей кодов операций CHIP-8
Углубленное изучение данных
219
В документах по CHIP-8 представлены такие переменные, как kk, nnn, х и у. Их роль, размещение и длина раскрыты в табл. 5.2. Таблица 5.2. Переменные, используемые в описаниях кодов операций СНIР-8
Переменная
Длина в битах
Размещение
Описание
n*
4
Количество байтов
х
младший байт, младший полубайт
4
старший байт, младший полубайт
Регистр ЦП
у
4
младший байт, старший полубайт
РегистрЦП
ct
4
старший байт, старший полубайт
Группа кодов операций
d t*t
4
младший байт, младший полубайт
Подгруппа кодов операций
8
младший байт, оба полубайта
Целое число
12
старший байт, младший полубайт и младший байт, оба полубайта
Адрес в памяти
kk
t
nnn t
* n и d размещаются в одном и том же месте, но используются во взаимоисклю чающих контекстах. t Имена переменных с и d используются только в этой книге, и отсутствуют в дру гой документации по CHIP-8. i Использованы в CPU RIA/3 (листинг 5.29). 0 x 7 3 E E Группа кодов операций (c)
Аргумент (kk)
Интерпретация: добавить 238 (0xEE) к регистру 3
Регистр (x)
0 x 1 2 0 0 Группа кодов Адрес (nnn) операций (c)
0 x 8 2 3 1 Группа кодов операций (c)
Регистр (x)
Подтип кода операции (d) Регистр (y)
Интерпретация: Перейти к адресу памяти (0x200)
Интерпретация: Выполнить поразрядную операцию OR (ИЛИ) с регистрами x и y. Сохранить результат в регистре x.
Рис. 5.3. Коды операций CHIP-8 декодируются несколькими способами. Какой из них используется, зависит от значения самого левого полубайта.
220
Глава 5
На рис. 5.3 показано, что есть три основных вида кодов операций. Процесс декоди рования состоит из определения, чему именно соответствует старший полубайт первого байта с последующем применением одной из трех стратегий. Чтобы из байтов извлечь полубайты, нужно воспользоваться поразрядными опера циями сдвига вправо(») и логического «И»(&). Эти операции были представлены в разделе 5.4, а конкретнее, в подразделах 5.4.1-5.4.3. Применение этих поразряд ных операций к текущей задаче показано в следующем листинге. Листинг 5.21. Извлечение переменных из кода операции fn main() { let opcode: u16 = 0x71E4; let let let let
c x y d
= = = =
(opcode (opcode (opcode (opcode
assert_eq!(c, assert_eq!(x, assert_eq!(y, assert_eq!(d,
& & & &
0xF000) 0x0F00) 0x00F0) 0x000F)
0x7); 0x1); 0xE); 0x4);
>> 12; >> 8; >> 4; >> 0;
let nnn = opcode & 0x0FFF; let kk = opcode & 0x00FF;
(1) (1) (1) (1) (2) (2) (2) (2) (3) (3)
assert_eq!(nnn, 0x1E4); assert_eq!(kk, 0xE4); }
(1) Выделение отдельных полубайтов с помощью оператора «И» (&) при помощи фильтрации битов, которые нужно сохранить, затем выполнение операции сдвига, перемещающей эти биты в самые младшие разряды. Дпя обозначения этих операций удобна шестнадцатеричная запись, поскольку каждая шестнадцатеричная цифра представляет 4 бита. Значение OxF позволяет выбирать все биты полубайта. (2) После обработки четыре полубайта из кода операции доступны в виде отдельных переменных. (3) Выделение нескольких полубайтов за счет увеличения щирины фильтра. Здесь нам поразрядный сдвиг вправо не нужен.
Теперь мы можем расшифровывать инструкции. Следующим шагом будет их вы полнение.
5.7.2. Полный листинг кода для CPU RIA/1: сумматор В следующем листинге представлен полный код сумматора, являющегося частью нашего постепенно формируемого эмулятора. Его исходный код находится в файле ch5/ch5-cpu 1 /src/main. rs.
Углубленное изучение данных
221
Листинг 5.22. Реализация начальной стадии эмулятора CHIP-8 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
struct CPU { current_operation: u16, registers: [u8; 2], } impl CPU { fn read_opcode(&self) -> u16 { self.current_operation } fn run(&mut self) { // loop { let opcode = self.read_opcode(); let let let let
c x y d
= = = =
((opcode ((opcode ((opcode ((opcode
& & & &
0xF000) 0x0F00) 0x00F0) 0x000F)
>> 12) as u8; >> 8) as u8; >> 4) as u8; >> 0) as u8;
match (c, x, y, d) { (0x8, _, _, 0x4) => self.add_xy(x, y), _ => todo!("opcode {:04x}", opcode), } // } } fn add_xy(&mut self, x: u8, y: u8) { self.registers[x as usize] += self.registers[y as usize]; } } fn main() { let mut cpu = CPU { current_operation: 0, registers: [0; 2], }; cpu.current_operation = 0x8014; cpu.registers[0] = 5; cpu.registers[1] = 10; cpu.run(); assert_eq!(cpu.registers[0], 15); println!("5 + 10 = {}", cpu.registers[0]); }
222
Глава 5
У нашего сумматора весьма скромные возможности. При выполнении операции выводится следующая строка: 5 + 10 = 15
5.7.3. CPU RIA/2: мультипликатор CPU RIA/1 может выполнять единственную инструкцию: сложение. CPU RIA/2, Мультипликатор, может последовательно выполнять несколько инструкций. Муль типликатор состоит из ОЗУ, полноценного основного цикла и переменной, указы вающей, какую инструкцию выполнять следующей, и эту переменную мы будем называть позицией в памяти- position_in_memory. По сравнению с кодом лис тинга 5.22 в код листинга 5.26 внесены следующие существенные изменения: • Добавлены 4 Кб памяти (строка 8). • Заданы полноценный основной цикл и условие остановки (строки 14-31). • На каждом шаге цикла происходит обращение к адресу памяти, указанному в position_in_memory и декодирование кода операции. Затем значение position_in_memory увеличивается до следующего адреса памяти, и код опе рации выполняется. ЦП продолжает работать до тех пор, пока не встретится условие остановки (код операции охоооо). • Из структуры CPU удалено поле текущей инструкции current_instruction, а вместо него вставлен раздел основного цикла, декодирующий байты из па мяти (строки 15-17). • Коды операций записываются в память (строки 51-53).
Расширение CPU для поддержки памяти Чтобы повысить ценность процессора, нужно внести ряд изменений. Для запуска компьютеру нужна память. В коде листинга 5.23, являющемся фрагментом листинга 5.26, дается определение CPU RIA/2. В его структуре имеются регистры общего назначения для вычислений (registers) и один регистр специального назначения (position_in_mernory). Для удобства системная память в виде поля mernory будет включена в структуру самого процессора. Листинг 5.23. Определение структуры CPU 1 struct CPU { 2 registers: [u8; 16], 3 position_in_memory: usize, 4 memory: [u8; 0x1000], 5 } (1)
(1)
Использование usize, а не ulб, отличается от исходной спецификации, но мы будем использовать usize, поскольку в Rust допускается использование этого типа для индексации.
Углубленное изучение данных
223
В CPU появились следующие нововведения: • 16 регистров, наличие которых означает, что для обращения к ним можно указывать адрес в виде одного шестнадцатеричного числа (от О до F). Это по зволяет представлять все коды операций в виде значений ulб. • Скромные для CHIP-8 4096 байт ОЗУ (0xl000 в шестнадцатеричном форма те). Это позволяет эквиваленту типа usize в CHIP-8 быть не шире 12 бит: 2 12 = 4096. Эти 12 бит становятся ранее упомянутой переменной nnn. В книге «Rust в действии» имеются два отступления от устоявшихся положений: • Используемое здесь «место в памяти» обычно называют «счетчиком команд». Новичкам порой трудно запомнить, для чего нужен этот счетчик. Поэтому в нашей книге используется название, отражающее его истинное назначение. • В соответствии со спецификацией CHIP-8 первые 512 байт (0xl00) зарезер вированы для системы, а остальные байты доступны для программ. Наша реализация снимает это ограничение. Чтение кодов операций из памяти После добавления в CPU оперативной памяти потребовалось обновление функции read _ opcod e ( J, показанное в следующем листинге, который является частью лис тинга 5.26. В нем код операции считывается из памяти путем объединения двух значений u8 в одно значение ulб. Листинг 5.24. Чтение кода операции из памяти 8 fn read_opcode(&self) -> u16 { 9 let p = self.position_in_memory; 10 let op_byte1 = self.memory[p] as u16; 11 let op_byte2 = self.memory[p + 1] as u16; 12 (1) 13 op_byte1 u16 { 9 let p = self.position_in_memory; 10 let op_byte1 = self.memory[p] as u16; 11 let op_byte2 = self.memory[p + 1] as u16; 12 13 op_byte1 > 12) as u8; 22 let x = ((opcode & 0x0F00) >> 8) as u8; 23 let y = ((opcode & 0x00F0) >> 4) as u8; 24 let d = ((opcode & 0x000F) >> 0) as u8; 25
(1) (2)
Углубленное изучение данных
26 match (c, x, y, d) { 27 (0, 0, 0, 0) => { return; }, 28 (0x8, _, _, 0x4) => self.add_xy(x, y), 29 _ => todo!("opcode {:04x}", opcode), 30 } 31 } 32 } 33 34 fn add_xy(&mut self, x: u8, y: u8) { 35 let arg1 = self.registers[x as usize]; 36 let arg2 = self.registers[y as usize]; 37 38 let (val, overflow) = arg1.overflowing_add(arg2); 39 self.registers[x as usize] = val; 40 41 if overflow { 42 self.registers[0xF] = 1; 43 } else { 44 self.registers[0xF] = 0; 45 } 46 } 47 } 48 49 fn main() { 50 let mut cpu = CPU { 51 registers: [0; 16], 52 memory: [0; 4096], 53 position_in_memory: 0, 54 }; 55 56 cpu.registers[0] = 5; 57 cpu.registers[1] = 10; 58 cpu.registers[2] = 10; 59 cpu.registers[3] = 10; 60 61 let mem = &mut cpu.memory; 62 mem[0] = 0x80; mem[1] = 0x14; 63 mem[2] = 0x80; mem[3] = 0x24; 64 mem[4] = 0x80; mem[5] = 0x34; 65 66 cpu.run(); 67 68 assert_eq!(cpu.registers[0], 35); 69 70 println!("5 + 10 + 10 + 10 = {}", cpu.registers[0]); 71 }
225
226
Глава 5
(1) Продолжение вьmолнения программы после обработки одной инструкции. (2) Увеличение значения position_in_memory для указания на следующую инструкцию. (3) Замыкание функции накоротко, чтобы ее вьmолнение заверпмлось при обнаружении кода операции ОхОООО. (4) Инициализация значениями нескольких регистров. (5) Загрузка кода операции Ох8014, прибавляющего значение регистра 1 к значению регистра О. (6) Загрузка кода операции Ох8024, прибавляющего значение регистра 2 к значению регистра О. (7) Загрузка кода операции Ох8034, прибавляющего значение регистра 3 к значению регистра О.
При запуске эмулятора CPU RIA/2 на экран выводятся впечатляющие математиче ские вычисления: 5 + 10 + 10 + 10 = 35
5.7.4. CPU RIA/3: блок вызова Почти все механизмы эмулятора уже созданы. В этом разделе будет добавлена возможность вызова функций. Но в отсутствие поддержки языков программирова ния любые программы все равно нужно писать в двоичном формате. В дополнение к реализации функций в этом разделе проверяется утверждение, сделанное в начале главы: функции также являются данными. Расширение CPU, позволяющее включить поддержку стека Для создания функций нужно реализовать ряд дополнительных кодов операций: • Код операции вызова, CALL (0x2nnn, где nnn - адрес памяти), который ус танавливает для position_in_memory значение nnn, являющееся адресом функции. • Код операции вызова возвращения, RETURN (охООЕЕ), который устанавлива ет для position_in_memory значение, соответствующее адресу предыдущего кода операции CALL. Чтобы допустить совместную работу этих кодов операций, CPU нужна специально выделенная память для хранения адресов. Она называется стеком. Каждый код операции CALL добавляет адрес в стек путем увеличения значения указателя стека и записи значения nnn в текущую позицию в стеке. Каждый код операции RETURN удаляет верхний адрес, уменьшая значение указателя стека. Соответствующие де тали реализации эмулятора CPU показаны в следующем листинге, являющемся ча стью листинга 5.29. Листинг 5.27. Включение стека и указателя стека 1 struct CPU { 2 registers: [u8; 16],
Углубленное изучение данных
3 4 5 6 7 }
position_in_memory: usize, memory: [u8; 4096], stack: [u16; 16], stack_pointer: usize,
227
(1) (2)
(1) Максимальный размер стека - 16 элементов. После 16 вызовов вложенных функций программа столкнется с переполнением стека. (2) Выбор ш�я указателя стека stack_pointer типа usize упрощает индексацию значений в стеке.
Определение функции и загрузка ее в память
Функция в компьютерной науке - простая последовательность байтов, содержа щая код, который исполняет центральный процессор4. Работа CPU начинается с первого кода операции и проходит весь путь до конца. В следующих нескольких фрагментах кода показан способ перехода от последовательности байтов к их пре образованию в код, исполняемый в CPU RIA/3. 1. Определим функцию, которая выполняет две операции: сложение и возвраще ние. Скромно, но наглядно. Это три кода операции. Внутреннее устройство функции, представленное в записи, напоминающей язык ассемблера, выглядит так: add_twice: 0x8014 0x8014 0x00EE
2. Преобразуем коды операций в типы данных Rust. Чтобы перевести эти три кода
операций в синтаксис массива Rust, поместим их в квадратные скобки и вос пользуемся запятой для каждого числа. Теперь функция стала массивом [ u 1 6;з J :
let add_twice: [u16;3] = [ 0x8014, 0x8014, 0x00EE, ];
3. На следующем этапе потребуется возможность работы с одним байтом, поэтому массив [ u1 6;з J следует разложить на массив [ u в; 6 J : let add_twice: [u8;6] = [ 0x80, 0x14, 0x80, 0x14, 0x00, 0xEE, ];
4. Загрузим функцию в оперативную память. Предполагая, что функцию нужно за
грузить по адресу 0xl00, можно воспользоваться одним из двух вариантов. При
4 Последовательность байтов также должна быть помечена как исполняемая. Процесс маркировки рассматривается в разделе 6.1.4.
228
Глава 5
наличии функции, доступной в виде слайса, можно скопировать ее в память с помощью метода сору_ f rom_s 1 i се (): fn main() { let mut memory: [u8; 4096] = [0; 4096]; let mem = &mut memory; let add_twice = [ 0x80, 0x14, 0x80, 0x14, 0x00, 0xEE, ]; mem[0x100..0x106].copy_from_slice(&add_twice); println!("{:?}", &mem[0x100..0x106]);
(1)
} (1) На экран выводится [128, 20, 128, 20, 0, 238]
Того же эффекта в памяти можно добиться без использования временного массива, воспользовавшись прямой перезаписью байтов: fn main() { let mut memory: [u8; 4096] = [0; 4096]; let mem = &mut memory; mem[0x100] = 0x80; mem[0x101] = 0x14; mem[0x102] = 0x80; mem[0x103] = 0x14; mem[0x104] = 0x00; mem[0x105] = 0xEE; println!("{:?}", &mem[0x100..0x106]);
(1)
}
(1) На экран выводится [128, 20, 128, 20, О, 238] Подход, показанный в последнем фрагменте кода, в точности такой же, как в функ ции main() в строках 96-98 листинга 5.29. Познакомившись с загрузкой функции в память, нужно узнать, как проинструктировать CPU осуществить ее вызов.
Реализация кодов операций вызова и возврата
Вызов функции осуществляется в три этапа: 1. Значение текущего адреса памяти сохраняется в стеке. 2. Значение указателя стека увеличивается. 3. В качестве текущего адреса памяти устанавливается значение нужного адреса. Для возврата из функции выполняются действия, обратные вызову: 1. Значение указателя стека уменьшается. 2. Из стека извлекается адрес памяти, откуда был вызов. 3. В качестве текущего адреса памяти устанавливается значение нужного адреса.
Углубленное изучение данных
следующем листинге, являющемся частью листинга уделено методам вызова, call () , и возврата, ret ().
В
229
5.29,
основное внимание
Листинг 5.28. Добавление методов cal() и ret() 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
fn call(&mut self, addr: u16) { let sp = self.stack_pointer; let stack = &mut self.stack; if sp > stack.len() { panic!("Stack overflow!") } stack[sp] = self.position_in_memory as u16; self.stack_pointer += 1; self.position_in_memory = addr as usize;
(1) (2) (3)
} fn ret(&mut self) { if self.stack_pointer == 0 { panic!("Stack underflow"); }
}
self.stack_pointer -= 1; let call_addr = self.stack[self.stack_pointer]; (4) self.position_in_memory = call_addr as usize; (4)
(1) Добавление текущего адреса памяти, position_in_memory, в стек. Этот адрес памяти на два байта больше места вызова, поскольку он увеличивается в теле метода run(). (2) Увеличение значения self.stack_pointer, чтобы предотвратить перезапись значения переменной self.position_in_memory до момента востребованности обрашения к нему при предстоящем возврате. (3) Изменение значения переменной self.position_in_memory для перехода на этот адрес. (4) Переход к тому месту в памяти, откуда был сделан вызов.
Полный листинг кода для CPU RIA/3: блок вызова Располагая всеми готовыми частями, объединим их в рабочую программу. Код лис тинга 5.29 позволяет вычислять жестко запрограммированное математич�ское вы ражение, выдавая следующий результат: 5 + (10 * 2) + (10 * 2) = 45
Это вычисление производится без использования уже, наверное, привычного вам исходного кода. Придется обходиться интерпретацией шестнадцатеричных чисел. Чтобы помочь во всем разобраться, на рис. 5.4 показано, что происходит внутри CPU в ходе выполнения вызова cpu. r un (). По стрелкам можно отследить состоя ние переменной cpu.position_in_memory в ходе выполнения программы.
230
Глава 5
Готовый эмулятор процессора RIA/3, Блок вызова показан в листинге 5.29. Его ис ходный код находится в файле ch5/ch5-cpu3/src/main.rs. 5
1 0x0000
21 00
9 21 00
2 6 0x0100
3 7
80 14
4 8
80 14
00 EE
0x1000
Легенда 5 Шаг программы
Управление потоком данных Код операции
80 0x1000
Значение в памяти (шестнадцатеричное) Адрес памяти Адресное пространство (не в масштабе)
Рис. 5.4. Иллюстрация хода выполнения функции, реализованной в CPU RINЗ и показанной в листинге 5.29. Листинг 5.29. Эмуляция CPU, включающая функции, определяемые пользователем 1 struct CPU { 2 registers: [u8; 16], 3 position_in_memory: usize, 4 memory: [u8; 4096], 5 stack: [u16; 16], 6 stack_pointer: usize, 7 } 8 9 impl CPU { 10 fn read_opcode(&self) -> u16 { 11 let p = self.position_in_memory; 12 let op_byte1 = self.memory[p] as u16; 13 let op_byte2 = self.memory[p + 1] as u16; 14 15 op_byte1 > 12) as u8; >> 8) as u8; >> 4) as u8; >> 0) as u8;
let nnn = opcode & 0x0FFF; // let kk = (opcode & 0x00FF) as u8; match (c, x, y, d) { ( 0, 0, 0, 0) => ( 0, 0, 0xE, 0xE) => (0x2, _, _, _) => (0x8, _, _, 0x4) => _ => }
{ return; }, self.ret(), self.call(nnn), self.add_xy(x, y), todo!("opcode {:04x}", opcode),
} } fn call(&mut self, addr: u16) { let sp = self.stack_pointer; let stack = &mut self.stack; if sp > stack.len() { panic!("Stack overflow!") } stack[sp] = self.position_in_memory as u16; self.stack_pointer += 1; self.position_in_memory = addr as usize; } fn ret(&mut self) { if self.stack_pointer == 0 { panic!("Stack underflow"); } self.stack_pointer -= 1; let addr = self.stack[self.stack_pointer]; self.position_in_memory = addr as usize; } fn add_xy(&mut self, x: u8, y: u8) { let arg1 = self.registers[x as usize]; let arg2 = self.registers[y as usize];
232
Глава 5
68 let (val, overflow_detected) = arg1.overflowing_add(arg2); 69 self.registers[x as usize] = val; 70 71 if overflow_detected { 72 self.registers[0xF] = 1; 73 } else { 74 self.registers[0xF] = 0; 75 } 76 } 77 } 78 79 fn main() { 80 let mut cpu = CPU { 81 registers: [0; 16], 82 memory: [0; 4096], 83 position_in_memory: 0, 84 stack: [0; 16], 85 stack_pointer: 0, 86 }; 87 88 cpu.registers[0] = 5; 89 cpu.registers[1] = 10; 90 91 let mem = &mut cpu.memory; 92 mem[0x000] = 0x21; mem[0x001] = 0x00; (1) 93 mem[0x002] = 0x21; mem[0x003] = 0x00; (2) 94 mem[0x004] = 0x00; mem[0x005] = 0x00; (3) 95 96 mem[0x100] = 0x80; mem[0x101] = 0x14; (4) 97 mem[0x102] = 0x80; mem[0x103] = 0x14; (5) 98 mem[0x104] = 0x00; mem[0x105] = 0xEE; (6) 99 100 cpu.run(); 101 102 assert_eq!(cpu.registers[0], 45); 103 println!("5 + (10 * 2) + (10 * 2) = {}", cpu.registers[0]); 104 }
(1) Установка для кода операции (2) Установка для кода операции (3) Установка для кода операции имеет, поскольку cpu.rnernory (4) Установка для кода операции к значению регистра О. (5) Установка для кода операции к значению регистра О. (6) Установка для кода операции
значения Ох2100: значения Ох2100: значения ОхОООО: инициализирована значения Ох8014:
вызов (CALL) функции по адресу OxlOO. вызов (CALL) функции по адресу OxlOO. остановка (НАLТ) (особого смысла не нулевыми байтами). добавление (ADD) значения регистра 1
значения Ох8014: добавление (ADD) значения регистра 1 значения ОхООЕЕ: возврат (RETURN).
Углубленное изучение данных
233
Разобравшись в системной документации, можно понять, что реальные функции намного сложнее простого перехода на предопределенное место в памяти. Опера ционные системы и архитектуры центральных процессоров различаются соглаше ниями о вызовах и имеющимися у них возможностями. Иногда операнды нужно помещать в стек, а иногда их нужно вставлять в определенные регистры. Но, не смотря на различия в конкретных технических приемах, сам процесс примерно по хож на то, с чем мы только что ознакомились. Остается только поздравить нас со столь глубоким погружением в тонкости работы процессора.
5.7.5. CPU 4: добавление всего остального Добавление нескольких кодов операций позволит реализовать в поэтапно разраба тываемом центральном процессоре умножение и многие другие функции. Более полную реализацию спецификации CHIP-8 можно увидеть в прилагаемом к книге исходном коде, который, в частности, находится в каталоге ch5/ch5-cpu4 по адресу https://github.com/rust-in-action/code.
И завершающим этапом изучения центральных процессоров и данных станет уяс нение способов управления ходом выполнения программы. В CHIP-8 ход выполне ния управляется путем сверки значений в регистрах с последующим изменением места в памяти, position_in_memory, в зависимости от результата. В CPU нет цик лов while или for. Их создание на языках программирования- искусство, прояв ляемое создателем компилятора.
Резюме ♦ В зависимости от типа данных одна и та же комбинация битов может представ лять несколько разных значений. ♦ У целочисленных типов в стандартной библиотеке Rust фиксированная ширина. При попытке превышения максимального значения целого числа возникает ошибка, называемая целочисленным переполнением. Уменьшение целого числа с выходом за пределы минимально допустимого значения называется целочис ленной потерей значимости. ♦ Компиляция программ при включенной оптимизации (например, при запуске команды cargo build --release) может подставить программы под целочис ленное переполнение и под потерю значимости, поскольку проверки в ходе их выполнения будут отключены. ♦ Порядок следования байтов (endianness) относится к расположению байтов в многобайтовых типах. Этот порядок, заложенный на аппаратном уровне, опре деляется производителями центральных процессоров. Программа, скомпилиро ванная для центральных процессоров с порядком следования байтов, который начинается с младшего разряда, даст сбой при попытке запуска на системе с центральным процессором, имеющим порядок следования байтов, который на чинается со старшего разряда.
234
Глава 5
♦ Десятичные числа в основном представлены типами чисел с плавающей точкой. Стандарт, которому Rust следует для своих типов f32 и fб4, - IEEE 754. Эти типы также известны как числа с плавающей точкой одинарной и двойной точ ности. ♦ В типах f32 и fб4 при сравнении одинаковых комбинаций битов может выяв ляться неравенство (например, f32: : NAN ! = FЗ2: : NAN), а при сравнении разных комбинаций битов может выявляться равенство (например, -0==0). Соответст венно, f32 и fб4 удовлетворяют только лишь частичному отношению эквива лентности. Программистам следует помнить об этом, проводя сравнение значе ний с плавающей точкой на предмет равенства. ♦ Для манипулирования внутренними компонентами структур данных применя ются поразрядные операции. Но их применение зачастую может быть крайне опасным. ♦ В Rust доступны также числовые форматы с фиксированной точкой. Числа в них представлены путем кодирования значения в виде номинатора и использования подразумеваемого деноминатора. ♦ При желании получить поддержку преобразования типов следует реализовать типаж std::convert::From. Но в тех случаях, когда преобразование может не пройти, предпочтительнее воспользоваться типажом std: :convert::TryFrom. ♦ Код операции центрального процессора - число, представляющее инструкцию, а не данные. Адреса памяти - также просто числа. А вызовы функций - про сто последовательность чисел.
6
Память
В этой главе рассматриваются следующие вопросы: ♦ Что такое указатели, и почему часть из них называют интеллектуальными. ♦ Что такое стек и куча. ♦ Как программа просматривает свою память. В этой главе рассматривается работа памяти компьютера в представлении систем ных программистов. Цель главы - предоставить полезное руководство по указате лям и доступным средствам управления памятью. Здесь будут раскрыты способы взаимодействия приложений с операционной системой. Программисты, усвоившие эту динамику, смогут воспользоваться своими знаниями для вывода программ на .максимальный уровень производительности при минимизации объема задейство ванной памяти. Память - общий ресурс, а операционная система - арбитр ее распределения. Что бы упростить себе задачу, операционная система вводит вашу программу в заблуж дение относительно доступного объема памяти и его размещения. Чтобы развеять этот туман, нужна предварительная теоретическая подготовка. Именно этому по священы первые два раздела главы. Все четыре раздела опираются на сведения предыдущих. Ни один из них не пред полагает никакого предварительного знакомства с излагаемой темой. Вам предсто ит освоить большой теоретический объем знаний, но все положения объясняются на примерах. В этой главе будет создано первое графическое приложение. Нового Rust-синтакси са в главе немного, поскольку к усвоению предоставляемых сведений нужно при .1ожить немало усилий. Здесь будут рассмотрены способы создания указателей, по рядок взаимодействия с операционной системой через ее собственный АРI интерфейс и порядок совместной работы с другими программами через интерфейс внешних функций Rust.
6.1. Указатели Указатели определяют порядок ссьшок компьютеров на те данные, к которым нет непосредственного доступа. Этой теме не стоит придавать какой-либо налет зага дочности. Указатели знакомы каждому, кто пользовался оглавлением книги. Указа тели - это просто числа, ссьшающиеся на что-либо иное. Если ранее сталкиваться с системным программированием не доводилось, при дется разбираться со многими понятиями, описывающими незнакомые концеп-
236
Глава 6
ции. К счастью, раскрыть суть рассматриваемой абстракции не так уж и сложно. Сначала нужно будет разобраться с условными обозначениями, применяемыми на рисунках этой главы. На рис. 6.1 представлены три концепции: • Стрелкой обозначается ссылка на место в памяти, определяемое в ходе вы полнения программы, а не в ходе компиляции. • Прямоугольниками обозначаются блоки памяти, а каждый блок соотносится с памятью шириной usize. Фрагменты памяти объемом в байт или даже в от дельный бит обозначаются другими фигурами. • Прямоугольником с закругленными углами под надписью «Значение» обо значаются три смежных блока памяти.
Указатель Значение
Указатели обычно обозначаются стрелками. Внутри компьютера они кодируются в виде целого числа (эквивалентного usize), являющегося адресом памяти объекта ссылки (данных, на которые ссылается указатель).
Рис. 6.1. Условные обозначения, используемые на рисунках главы для иллюстрации указателя. Чаще всего указатели в Rust встречаются в виде &Т и &mut Т, где Т-это тип значения.
Новички относятся к указателям настороженно. Их надлежащее применение требу ет достоверных сведений о месте программы в памяти. Представьте, что в оглавле нии началом главы 4 указана страница 97, но фактически глава начинается со стра ницы 107. Досадно, но вполне преодолимо. Компьютер не испытывает разочарований. Ему также не свойственна интуиция, подсказывающая, что его направили не туда. Он просто продолжает работу, полагая, что ему указано верное место в памяти. Боязнь указателей связана с воз можностью возникновения ошибки, не поддающейся отладке. Данные, хранящиеся в памяти программы, можно представить разбросанными где то на просторах физической оперативной памяти компьютера. Чтобы воспользо ваться оперативной памятью, должна быть некая система извлечения данных. Та кой системой является адресное пространство. Указатели кодируются в виде адресов памяти, представленных целыми числами типа usize. Адрес указывает на место в адресном пространстве. Пока что адресное пространство нам будет достаточно представить в виде всей оперативной памяти, вытянувшейся в одну большую строку. А зачем адреса памяти кодируются типом usize? Ведь 64-разрядных компьютеров с оперативной памятью размером 2 64 байт просто не бывает. Диапазон адресного пространства - это видимость, обеспечиваемая операционной системой и цен тральным процессором. Программам известна только упорядоченная последова тельность байтов, не зависящая от объема оперативной памяти, фактически дос-
Память
237
тупной в системе. Как это работает, станет ясно чуть позже, когда в этой главе дой дет очередь до раздела, повествующего о виртуальной памяти. ПРИМЕЧАНИЕ Еще один интересный пример- тип Option. Чтобы обеспечить для Option в скомпилированном двоичном файле нулевой расход памяти, в Rust используется оп тимизация нулевого указателя. Нулевым указателем представлен вариант None (ука затель на неверное место памяти), позволяющий варианту Some (Т) не иметь допол нительных косвенных ссылок.
В чем разница между ссылками, указателями и адресами памяти? В схожести ссылок, указателей и адресов памяти нетрудно запутаться: • Адрес памяти, часто сокращаемый просто до адреса, - это число, относя щееся к одному байту в памяти. Адреса памяти - абстракции, предоставляе мые языками Ассемблера. • Указатель, иногда называемый в развернутом виде обычным указателем, представляет собой адрес памяти, указывающий на значение какого-либо ти па. Указатели, по сути, - абстракции, предоставляемые языками более высо кого уровня. • Ссылка представляет собой указатель или, в случае с типами с динамически ми размерами, указатель и целое число с дополнительными гарантиями. Ссылки - абстракции, предоставляемые языком Rust. Компиляторы умеют определять диапазоны допустимых байтов для многих типов. Например, когда компилятор создает указатель на i32, он может проверить нали чие четырех байтов, в которых закодировано целое число. Это полезнее простого наличия адреса памяти, на который может быть (а может и не быть) указатель на какой-либо допустимый тип данных. К сожалению, ответственность за гарантию допустимости типов, размер которых неизвестен в ходе компиляции, несет про граммист. У Rust-ccылoк имеются существенные преимущества перед указателями: • Ссылки всегда указывают на решzыю существующие данные. Rust-ссылки могут использоваться только при наличии разрешенного доступа к данным, на которые они ссылаются. Полагаю, вам уже известен этот основной прин цип Rust! • Ссылки корректно выравнены по кратным usize. По техническим причинам центральные процессоры крайне негативно реагируют на требование извлечь данные без выравнивания памяти. Их работа резко замедляется. Для устране ния проблемы в типы Rust включаются байты заполнения, чтобы создание ссьшок на них не замедляло работу программы. • Ссылки гарантируют производительную работу с типами, имеющими дина мически изменяемый размер. Для типов, не имеющих фиксированную шири ну размещения в памяти, Rust гарантирует, что их размер будет сохраняться
238
Глава 6
рядом с внутренним указателем. Таким образом, Rust способен гарантиро вать, что программа никогда не переполнит пространство, выделяемое типу в памяти компьютера. ПРИМЕЧАНИЕ Адреса памяти отличаются от двух абстракций более высокого уровня тем, что у по следних имеется информация о типе данных, на которые они ссылаются.
6.2. Исследование типов ссылок и указателей, имеющихся в Rust В этом разделе рассматривается работа в Rust с несколькими типами указателей. При этом в книге «Rust в действии» превалирует стремление придерживаться сле дующих правил: • Ссылки - свидетельства о том, что Rust-компилятор предоставит свои гаран тии безопасности. • Указатели - более примитивный механизм. Тут также нужно понимать, что вся ответственность за обеспечение безопасности ложится на нас. (Считается, что это небезопасно.) • Обычные указатели - используются для типов, на небезопасную природу которых нужно указать явным образом. В этом разделе будет подробно рассмотрен общий фрагмент кода, представленный в листинге 6.1. Его код находится в файле ch6/ch6-pointer-intro.rs. В коде листинга имеются две глобальные переменные, в и с, на которые указывают ссылки. В этих ссылках содержатся, соответственно, адреса в и с. Сразу за кодом следует иллюст рация происходящего, представленная на рис. 6.2 и 6.3. Листинг 6.1. Имитация указателей со ссылками static B: [u8; 10] = [99, 97, 114, 114, 121, 116, 111, 119, 101, 108]; static C: [u8; 11] = [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, 0];
fn main() { let a = 42; let b = &B; let c = &C; println!("a: {}, b: {:p}, c: {:p}", a, b, c); }
(1) (2) (3)
(1) С целью упрощения в примере используется один и тот же ссылочный тип. В следующих примерах используются не обычные, а интеллектуальные указатели, для которых нужны другие типы. (2) Синтаксис{: р} требует от Rust отформатировать переменную в виде указателя и вывести на экран адрес памяти, на который указывает ее значение.
Память
239 Переменные c и b являются ссылками. Ссылки имеют ширину 4 байта на 32-разрядных процессорах и 8 байтов на 64-разрядных процессорах.
c
Исходя из предположения, что у а тип i32, под эту переменную отводится 4 байта памяти
b
a 42
C
116
B
99
97
114
114
121
116
111
119
101
104
97
110
107
115
102
105
115
104
0
108
Неполное представление адресного пространства программы
Рис. 6.2. Абстрактное представление совместной работы двух указателей со стандартным целым числом. Здесь важно понять, что программист заранее может не знать о расположении данных, на которые нацелен указатель.
В коде листинга 6.1 внутри функции rnain () имеются три переменные. Переменная а не представляет собой ничего особенного, это просто целое число. А вот две дру гие переменные намного интереснее. Переменные ь и с - ссылки. Они ссьmаются на два неявных массива данных: в и с. Давайте пока будем рассматривать Rust ссылки в качестве эквивалента указателей. Данные, выводимые на экран при одно кратном выполнении программы на 64-разрядном компьютере, имеют следующий вид: a: 42, b: 0x556fd40eb480, c: 0x556fd40eb48a
(1)
(3) При запуске кода на вьmолнение конкретные адреса памяти на вашем компьютере будут другими.
На рис. 6.3 тот же пример представлен в воображаемом адресном пространстве размером 49 байт. Ширина указателя в нем составляет два байта (16 бит). Нетрудно заметить, что переменные ь и с выглядят в памяти по-разному, несмотря на то что они того же типа, что и в листинге 6.1. Дело в том, что листинг вас обманывает. Вскоре будут предоставлены подробные сведения и пример кода, более точно от ражающие схему, показанную на рис. 6.3. Судя по рис. 6.2, изображение указателей на разобщенные массивы в виде стрелок не обошлось без проблем. Такие указатели, как правило, приуменьшают важность непрерывности адресного пространства и его совместного использования всеми переменными.
240
Глава 6
Переменная
c
b
a
Обычный указатель Интеллектуальный указатель
Абстрактный тип данных Конкретное представление
u16 (16 == 0x10) i16
Схема памяти
0x2A
0x2B
0
16
0x22
0x23
114
114
0x1A
0x1B
C Буфер с кодом завершения в виде нуля, являющийся внутренним представлением строк на языке С. Знание способов их преобразования в Rust-тиnы пригодится при работе с внешним кодом через интерфейс внешних функций.
Целое число
Поле адреса
Поле длины
u16 (32 == 0x20) i16
0x2C 0 0x24 121 0x1C
0x2D 10
0x2E 0
0x2F 32
0x25
0x26
0x27
116
111
119
0x1D
0x1E
0x1F
0
0x30
0x31
0 0x28
42 0x29
101
108
0x20
0x21
99
97
0x12
0x13
0x14
0x15
0x16
0x17
0x18
0x19
97
110
107
115
102
105
115
104
0x10
0x11
116
104
0xA
0x1
0xB
0xC
0x2
0x3
0xD
0x4
0xE
0x5
0xF
0x6
0x7
0x8
0x0
В совокупности в системе типов Rust, c и C представляют собой тип CStr.
NULL-бaйт — мертвая зона программы. Если указатель нацелен на это место, а затем он разыменовывается, то это обычно заканчивается аварийным завершением программы.
B Буфер фиксированной ширины размером в 10 байтов, содержащий байты без кода завершения. Буфер, используемый после типа указателя, часто называют вспомогательным массивом. В совокупности b и B близки к созданию Rust-тиna String, в котором также содержится параметр емкости.
Рис. 6.3. Наглядное представление адресного пространства программы, показанной в листинге 6.1. Здесь иллюстрируется взаимосвязанность адресов (которые обычно записываются в виде шестнадцатеричных чисел) и целых чисел (которые обычно записываются в десятичном формате). Светлыми клетками представлена неиспользуемая память.
Чтобы детальнее разобраться в происходящем во внутренней «кухне», у вывода на экран, осуществляемого кодом листинга 6.2, - расширенное информационное на полнение. Для демонстрации внутренних различий и более точного соотнесения друг с другом всего представленного на рис. 6.3 вместо ссылок в нем используются более сложные типы. При запуске кода листинга 6.2 на выполнение получается следующий результат: a
(an unsigned integer): location: 0x7ffe8f7ddfd0 size: 8 bytes value: 42
b (a reference to B): location: 0x7ffe8f7ddfd8 size: 8 bytes points to: 0x55876090c830
Память
c (a "box" for C): location: 0x7ffe8f7ddfe0 size: 16 bytes points to: 0x558762130a40 B (an array of 10 bytes): location: 0x55876090c830 size: 10 bytes value: [99, 97, 114, 114, 121, 116, 111, 119, 101, 108] C (an array of 11 bytes): location: 0x55876090c83a size: 11 bytes value: [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, 0
Листинг 6.2. Сравнение ссылок и Box с несколькими типами 1 use std::mem::size_of; 2 3 static B: [u8; 10] = [99, 97, 114, 114, 121, 116, 111, 119, 101, 108]; 4 static C: [u8; 11] = [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, 0]; 5 6 fn main() { (1) 7 let a: usize = 42; 8 (2) 9 let b: &[u8; 10] = &B; 10 (3) 11 let c: Box = Box::new(C); 12 13 println!("a (an unsigned integer):"); 14 println!(" location: {:p}", &a); 15 println!(" size: {:?} bytes", size_of::()); 16 println!(" value: {:?}", a); 17 println!(); 18 19 println!("b (a reference to B):"); 20 println!(" location: {:p}", &b); 21 println!(" size: {:?} bytes", size_of::()); 22 println!(" points to: {:p}", b); 23 println!(); 24 25 println!("c (a "box" for C):"); 26 println!(" location: {:p}", &c); 27 println!(" size: {:?} bytes", size_of::()); 28 println!(" points to: {:p}", c); 29 println!(); 30
241
242
Глава 6
31 32 33 34 35 36 37 38 39 40 41 }
println!("B println!(" println!(" println!(" println!();
(an array location: size: value:
of 10 bytes):"); {:p}", &B); {:?} bytes", size_of::()); {:?}", B);
println!("C println!(" println!(" println!("
(an array location: size: value:
of 11 bytes):"); {:p}", &C); {:?} bytes", size_of::()); {:?}", C);
(1) Тип usize соответствует размеру адреса памяти того процессора, для которого скомпилирован код. Такой процессор называют целью компиляции. (2) Тип Box представляет собой упакованный байтовый слайс. Когда значения упаковываются, владение ими переходит к владельцу упаковки.
Тем, кто интересуется декодированием текста в в и с, следует пояснить, что код листинга 6.3 - короткая программа, практически создающая структуру адреса па мяти, очень похожую на ту, что изображена на рис. 6.3. Здесь имеются пока еще не представленные несколько новых функций языка Rust и некий не вполне понятный синтаксис. Все это вскоре разъяснится. Листинг 6.3. Вывод на консоль данных из строк, представленных внешними источниками use std::borrow::Cow;
(1)
use std::ffi::CStr;
(2)
use std::os::raw::c_char;
(3)
static B: [u8; 10] = [99, 97, 114, 114, 121, 116, 111, 119, 101, 108]; static C: [u8; 11] = [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, 0]; fn main() { let a = 42;
( 4)
let b: String;
(5)
let c: Cow;
(6)
unsafe { let b_ptr = &B as *const u8 as *mut u8;
(7)
b = String::from_raw_parts(b_ptr, 10, 10); let c_ptr = &C as *const u8 as *const c_char;
(9)
Память
c = CStr::from_ptr(c_ptr).to_string_lossy(); }
243
(10)
println!("a: {}, b: {}, c: {}", a, b, c); }
(1) Тип интеллектуального указателя,считывающий данные из указанного места без своего предварительного копирования. (2) CStr - Си-подобный строковый тип,позволяющий Rust считывать строки,завершающиеся нулем. (3) c_char в Rust - псевдоним типа iB,и он может иметь нюансы,зависящие от конкретной платформы. (4) Представление каждой переменной,чтобы позже они стали доступны из макроса println!. Если создать Ь и с внутри блока unsafe,то далее они будут вне области видимости. (5) String - тип интеллектуального указателя,в котором содержится сам указатель на вспомогательный массив и поле для хранения его размера. 16) Cow принимает параметр типа для данных,на которые он указывает; str - тип, возвращаемый методом CStr.to_string_lossy(), поэтому здесь он уместен. (7) Ссыпки не могут быть напрямую приведены к *mut Т,типу, которые требуется методу String::from_raw_parts(). Но *const Т можно привести к *mut Т,получив синтаксис двойного приведения к типу. (8) Метод String::from_raw_parts() принимает указатель (* mutT) на массив байтов, размер и параметр емкости. (9) Преобразование *const uB в *const iB с псевдонимом c_char. Преобразование в iB работает,поскольку при этом значение остается меньше 128,не нарушая положения стандарта ASCII. (10) Концептуально метод CStr::from_ptr() берет на себя ответственность за чтение указателя до тех пор,пока ему не попадется О; затем из результата им создается значение типа Cow.
Используемый в листинге 6.3 тип Cow означает «копирование при записю). Этот тип интеллектуального указателя удобен в том случае, когда буфер предоставляется внешним источником. Уход от копирования увеличивает производительность в хо де выполнения программы. std:: f f i - модуль интерфейса внешней функции из стандартной библиотеки Rust. В использовании std: :os: : raw: : с_char; нет на стоятельной необходимости, просто благодаря этому проясняются намерения, за ложенные в код программы. В стандарте языка С не определена ширина его типа char, хотя на практике она составляет один байт. Получение псевдонима типа с_ char из модуля std: :os: : raw допускает отклонения. Чтобы провести детальный разбор кода листинга 6.3, нужно расширить ваш круго зор. Сначала следует разобраться с понятием обычного указателя, а затем рассмот реть ряд более функциональных альтернатив, созданных на его основе.
6.2.1. Обычные указатели, используемые в Rust Обычный указатель - адрес памяти. Стандартные гарантии Rust на него не рас пространяются, что делает его небезопасным. Например, в отличие от ссылок (&Т), обычные указатели могут иметь значение null.
244
Глава 6
Простив синтаксис за неоднозначность, обычные неизменяемые указатели станем обозначать как *const т, а изменяемые- как *mut т. Хотя каждый из них отно сится к одному типу, в их составе фигурируют три базовых элемента: *, const или mut. Их тип т в качестве обычного указателя на String выглядит как *const String. Обычный указатель на i32 выглядит как *mut i32. Но прежде чем присту пить к практическому применению указателей, полезно усвоить еще две вещи: • Разница между а *mut т и а *const т минимальна. Они могут свободно приводиться друг к другу и, как правило, обладают взаимозаменяемостью, действуя в исходном коде в качестве документации. • Rиst-ссылки (&mut т и &Т) при компиляции превращаются в обычные указа тели. То есть для достижения высокой производительности, присущей обыч ным указателям, можно вполне обойтись и без риска использования небезо пасных unsаfе-блоков. В следующем листинге показан небольшой пример, в котором ссылка на значение (&Т), приводится к созданию обычного указателя из значения типа iб4. Затем с по мощью синтаксиса ! : р J значение и его адрес в памяти выводятся на консоль. Листинг 6.4. Создание обычного указателя (*const T) fn main() { let a: i64 = 42; let a_ptr = &a as *const i64; println!("a: {} ({:p})", a, a_ptr);
(1) (2)
}
Приведение ссылки на переменную а (&а) к неизменному обычному указателю типа i64 (*const i64) (2) Вывод на консоль значения переменной а (42) и его адреса в памяти (Ox7ff ...) (1)
Иногда термины «указателы> и «адрес памяти» используются как синонимы. Это целые числа, представляющие собой место в виртуальной памяти. Но с позиции компилятора имеется одно важное отличие. Типы Rust-указателей *const т и *mut т всегда нацелены на начальный байт т, и им также известна ширина типа т в байтах. А адрес памяти может относиться к любому месту в памяти. Тип i64 имеет ширину 8 байт (64 бита при 8 битах на байт). Следовательно, если i64 хранится по адресу Ox7fffd, то для воссоздания целочисленного значения из оперативной памяти должен быть извлечен каждый из байтов диапазона Ox7ffd ..Ох8004. Процесс выборки данных из оперативной памяти называется разыменованием указателя. В следующем листинге адрес значения идентифици руется путем приведения ссылки на него в обычный указатель посредством std: :mem::transmute.
Память
245
Листинг 6.5. Определение адреса значения fn main() { let a: i64 = 42; let a_ptr = &a as *const i64; let a_addr: usize = unsafe { std::mem::transmute(a_ptr) };
(1)
println!("a: {} ({:p}...0x{:x})", a, a_ptr, a_addr + 7); }
(1) Интерпретация *const i64 как usize. Использование transmute() крайне опасно, но здесь этот метод используется, чтобы пока не вводить слишком большой объем синтаксиса.
Закулисно ссылки (&т и &mut т) реализуются в виде простых указателей. Им сопут ствуют дополнительные гарантии, и предпочтение следует неизменно отдавать только им. ВНИМАНИЕ Обращение к значению обычного указателя всегда сопряжено с опасностью, поэтому действовать нужно осмотрительно.
Использование обычных указателей в Rust-кoдe похоже на работу с пиротехникой. Результаты обычно фантастические, но порой болезненные, а иногда и трагические. Обычные указатели зачастую обрабатываются в Rust-кoдe библиотекой операцион ной системы или библиотекой стороннего производителя. Чтобы продемонстрировать происходящие с ними изменения, рассмотрим неболь шой пример с обычными указателями Rust. Создание указателя произвольного типа из любого целого числа вполне допустимо. В следующем фрагменте кода показано, что разыменование этого указателя должно происходить в unsafe-блoкe. Примене ние unsafe-блoкa подразумевает, что программист несет полную ответственность за любые последствия: fn main() { let ptr = 42 as *const Vec;
(1)
unsafe { let new_addr = ptr.offset(4); println!("{:p} -> {:p}", ptr, new_addr); } }
(1) Безопасное создание указателей возможно из любого целочисленного значения. Тип это не Vec, но здесь Rust просто игнорирует это обстоятельство.
i32
246
Глава 6
Повторим: обычные указатели небезопасны. Им присущи некоторые свойства, оп ределяющие крайнюю нежелательность их повседневного использования в Rust кoдe: • Обычные указатели не владельцы своих значений. При обращении к ним ком пилятор Rust не проверяет доступность данных, на которые они указывают. • Допускается использование нескольких обычных указателей на одни и те же данные. Каждый обычный указатель может иметь доступ к записи или к чте нию и записи данных. Это означает, что Rust не может гарантировать дейст вительность совместно используемых данных. Но несмотря на все эти предостережения, для использования обычных указателей есть ряд веских причин: • Без них просто не обойтись. Возможно, обычный указатель потребуется для какого-нибудь вызова операционной системы или стороннего кода. Обычные указатели получили широкое распространение в коде С, предоставляющем внешний интерфейс. • Нужно также учесть важность совместного доступа и первостепенную роль производительности в ходе выполнения программы. Возможно, в вашем приложении равный доступ к какой-либо переменной, вычисляемой с боль шими затратами, требуется сразу нескольким компонентам. Если вы соглас ны пойти на риск и допустить, что один из этих компонентов сможет нару шить работу какого-либо другого компонента некой допущенной в нем глу пой ошибкой, тогда в качестве крайнего варианта можно выбрать обычные указатели.
6.2.2. Экосистема указателей Rust Обычные указатели небезопасны, а есть ли более безопасный выбор? Альтернати вой может послужцть использование интеллектуальных указателей. Под ними в сообществе Rust подразумевается тип, обладающий помимо способности опреде лять адрес памяти несколькими дополнительными свойствами. Также по этой теме может встретиться термин тип-оболочка. Как правило, типы интеллектуальных указателей Rust служат оболочкой для обычных указателей и наделяют их допол нительной семантикой. В С-сообществах бытует более узкое определение интеллектуального указателя. Его авторы обычно подразумевают под этим понятием имеющиеся в С эквиваленты Rust-типов core: : ptr: : Unique, core: : ptr: : Shared и std: : rc: : Weak, знакомство с которыми состоится чуть позже. ПРИМЕЧАНИЕ Есть такое понятие, как «толстый указатель», которое относится к размещению в па мяти. Тонкие указатели, вроде обычных, имеют одинарную usizе-ширину. Толстые указатели обычно шире в два и более раз.
Память
247
Обычный указатель
Box
Rc
Arc
Двоюродные братья *mut T и *const T — свободные радикалы мира указателей. Невероятно быстрые, но крайне небезопасные.
Все хранит в упаковке Подходит для длительного хранения практически любого типа. Рабочая лошадка новой эры безопасного программирования.
Указатель с подсчетом ссылок. Rc — дельный, но скупой бухгалтер Rust. Он знает кто, что и когда позаимствовал.
Arc полномочный представитель Rust. Способен обеспечить совместное использование значений разными потоками, гарантируя, что они не будут мешать друг другу.
Сильные стороны
Слабые стороны
Сильные стороны
Слабые стороны
• Скорость
• Небезопасность
• Хранит значение в центральном хранилище в месте под названием «куча»
• Увеличение размера
• Возможность взаимодействовать с внешним миром
Сильные стороны • Совместный доступ к значениям
Слабые стороны • Увеличение размера • Издержки времени выполнения • Отсутствие потокобезопасности
Сильные стороны • Совместный доступ к значениям • Потокобезопасность
Слабые стороны • Увеличение размера • Издержки времени выполнения
Cell
RefCell
Cow
String
Будучи мастертом по превращениям, Cell позволяет изменять неизменяемые значения.
Позволяет изменять RefCel. Его поразительные способности не обходятся без определенных издержек
Зачем записывать какие-то данные, если их нужно только прочитать? Возможно, требуется всего лишь внести изменения. Именно для этого и применяется Cow (копирование при записи).
Действуя в качестве руководства по устранению неопределенностей пользовательского ввода, String демонстрирует способ создания безопасных абстракций.
Сильные стороны • Внутренняя изменчивость
Слабые стороны • Увеличение размера • Производительность
Сильные стороны • Внутренняя изменчивость • Может стать вложением в Rc или Arc, которые способны принимать только изменяемые ссылки
Слабые стороны • Увеличение размера • Издержки времени выполнения • Отсутсвие гарантий, выдаваемых в ходе компиляции
Сильные стороны • Позволяет избежать выполнение записей, когда используется только доступ для чтения
Слабые стороны • Может увеличиться в размере
Сильные стороны • Динамично разрастается по мере необходимости • Гарантирует корректное кодирование в ходе выполнения
Слабые стороны • Может превысить размер выделенной памяти
Arc
RawVec
Unique
Shared
Является основной системой хранения данных вашей программы. Vec обеспечивает упорядоченное хранение данных по мере создания и уничтожения.
Служит основной для Vec и других типов с динамическим размером. Знает, как в случае необходимости обеспечить размещение ваших данных.
Единственный владелец значения, уникальный указатель, гарантирующий полный контроль.
Совместное владение дается с трудом. Shared немного упрощает решение этой задачи
Сильные стороны • Динамично разрастается по мере необходимости
Слабые стороны • Может превысить размер выделенной памяти
Сильные стороны • Динамично разрастается по мере необходимости • При поиске свободного места работает с распределением памяти
Слабые стороны • Не применяется напрямую из вашего кода
Сильные стороны • Служит основой для таких типов, как String, требующих исключительного владения значениями
Слабые стороны • Не применяется в коде приложения напрямую.
Сильные стороны • Совместное владение • Возможность выравнивания памяти по ширине Т-типа, даже при пустом значении
Слабые стороны • Не применяется в коде приложения напрямую.
Рис. 6.4. Воображаемый пасьянс, описывающий характеристики типов интеллектуальных указателей Rust.
http://qrcoder.ru/code/?https%3A%2F%2Fzip.bhv.ru%2F286l_6-4.png&10&0
248
Глава 6
В стандартной библиотеке Rust есть широкий набор указателей и подобных им ти пов. У каждого из них своя роль, свои сильные и слабые стороны. Их уникальные свойства лучше раскрыть не в виде списка, а в форме карточного пасьянса, пока занного на рис. 6.4. В книге широко используется каждый из представленных здесь типов указателей. Поэтому их более полная трактовка будет дана по мере необходимости. А пока в раз деле «Сильные стороны» некоторых карт будут фигурировать два новых атрибута, заслуживающих рассмотрения: внутренняя изменчивость и совместное владение. При наличии внутренней изменчивости может понадобиться предоставить аргу мент методу, принимающему неизменяемые значения, но при этом нужно будет сохранить изменчивость. Если принести в жертву производительность в ходе вы полнения программы, то неизменяемость можно сымитировать. Если методу требу ется значение, которым он завладеет, заключите аргумент в Cell. Ссылки также могут быть заключены в RefCell. При использовании типов с подсчетом коли чества ссылок, Rc и Arc, принимающих только неизменяемые аргументы, заключение этих аргументов в cell или RefCell также считается в порядке вещей. Тип, полученный в результате этого, может иметь вид Rc. По лучается, что за существенно более высокую гибкость приходится платить двой ными издержками времени выполнения. При совместном владении некоторые объекты, например сетевое подключение или, может быть, доступ к некоторым службам операционной системы, довольно трудно в любой произвольный момент времени свести к схеме наличия единого места с доступом по чтению и записи. Если две части программы могут совместно обра шаться к такому вот единому ресурсу, код должен быть упрощен. Rust позволяет это сделать, но опять же за счет издержек времени выполнения.
6.2.3. Строительные блоки интеллектуальных указателей Может сложиться ситуация, когда нужно будет создать собственный тип интеллек туального указателя с оригинальной семантикой. Возможно, вышел новый иссле довательский труд, и нужно воспользоваться его результатами в своей работе. Или же проводятся свои исследования. Как бы то ни было, будет полезно узнать, что типы указателей в Rust могут расширяться, с прицелом на что они и разрабатыва лись. Все типы указателей, ориентированных на использование программистами, такие как вох, основаны на применении более простых типов, уходящих корнями в Rust, зачастую в его модули core или alloc. Кроме того, в Rust имеются аналоги типов интеллектуальных указателей языка С++. Вот несколько полезных исходных сведений для создания своих собственных типов интеллектуальных указателей: основа для таких типов, как String, Вох и поля
•
core::ptr::Unique указателя Vec.
•
core: : ptr: : Shared - основа для Rc и Arc, и он способен справиться с ситуациями, при которых нужен совместный доступ.
Память
249
Также в определенных ситуациях могут пригодиться следующие инструменты: • Структуры данных с глубокими взаимосвязями могут выиграть от примене ния std::rc::Weak ДЛЯ ОДНОПОТОЧНЫХ И std::arc::Weak ДЛЯ МНОГОПОТОЧНЫХ программ. Это позволяет получить доступ к данным в Rc и Arc без увеличе ния их счетчиков ссылок. Это может избавить от бесконечных циклов указа телей. • Основой типов Vec и VecDeq является тип alloc::raw_vec::RawVec. Будучи расширяемой двусторонней очередью, не встречавшейся пока в кни ге, он способен разумно выделять и освобождать память для любого заданно го типа. • Основой Cell и RefCell служит тип std::cell::UnsafeCell. Если требуется внутренняя изменчивость типов, то стоит изучить реализацию это го типа. Более подробное объяснение порядка создания новых безопасных указателей пред полагает изложение сведений о ряде внутренних компонентов Rust. У этих строи тельных блоков есть свои собственные строительные блоки. К сожалению, объяс нение каждой детали будет слишком большим отступлением от целей данной главы. ПРИМЕЧАНИЕ Любознательным следует изучить исходный код типов указателей стандартной биб лиотеки. Например, документацию по типу std::cell::RefCell можно найти по ад ресу https://doc.rust-ang.org/std/cell/struct.RefCell.html. Щелкнув на этой странице на кнопке [src], можно перейти к определению типа.
6.3. Предоставление программам памяти для размещения их данных В этом разделе предпринимается попытка избавления от мистики, окружающей понятия стека и кучи. Они часто встречаются в контексте, предполагающем, что вам уже известно, что это такое. Но здесь последует подробный рассказ об их сути, назначении и применении, позволяющем повысить компактность и быстродействие программ. Для тех, кто не любит вдаваться в подробности, раскроем существенную разницу между стеком и кучей: • Стек работает быстро. • Куча работает медленно. Это приводит к следующей аксиоме: «При сомнениях отдавайте предпочтение сте ку)). Чтобы поместить данные в стек, компилятору в ходе своей работы нужно знать размер типа.
250
Глава 6
В переводе на язык Rust это означает: «При сомнениях воспользуйтесь типом, реа лизующим свойство Sized». Усвоив суть этих понятий, пора разобраться с тем, ко гда можно пойти по медленному пути, и как избежать его прохождения, если тре буется выбрать более быстрый путь.
6.3.1. Стек Обычно при описании стека пользуются аналогией. Представьте себе стопку таре лок, стоящую в кухонном шкафу ресторана. Повара снимают тарелки из верхней части стопки, чтобы положить в них еду, а мойщики посуды ставят сверху новые тарелки. Элемент (тарелкой) вычислительного стека - стековый кадр (или фрейм), также известный как запись о распределении. Возможно, стек привычнее воспринимать в виде группы переменных и других данных. Как и многие другие понятия, бытую щие в вычислительной технике, стек и куча - аналогии, отражающие суть лишь частично. Несмотря на то, что стек часто сравнивают со стопкой обеденных таре лок, стоящих в шкафу, к сожалению, это представление страдает неточностью. На зовем ряд отличий: • Стек фактически содержит два уровня объектов: кадры стека и данные. • Стек предоставляет программистам доступ не только к верхнему элементу, но и к нескольким хранящимся в нем элементам. • Стек может включать в себя элементы произвольного размера, при том, что аналогия с обеденной тарелкой подразумевает, что все элементы должны быть одного размера. Так почему же стек сравнивают со стопкой? Все дело в схеме использования. С за писями в стеке обращаются по принципу «последней пришла - первой ушла>) (Last In, First Out - LIFO). Записи называются кадрами стека. Они создаются по мере выполнения вызовов функций. В ходе выполнения программы указатель стека внутри центрального процессора обновляется, отражая фактический адрес текущего кадра стека. Поскольку функции вызываются внутри функций, значение указателя стека по ме ре его роста уменьшается. Когда происходит возврат из функции, указатель стека увеличивается. Кадры стека содержат состояние функции на момент вызова. Когда функция вызы вается внутри функции, значения более старой фактически замораживаются во времени. Кадры стека также известны как кадры активации и, реже, как записи о распределении 1 • В отличие от обеденных тарелок, каждый кадр стека имеет разный размер. В нем имеется пространство для аргументов его функции, указатель на исходное место 1 Точнее, кадр активации называется кадром стека при его размещении в стеке.
Память
251
вызова и значения локальных переменных (за исключением тех данных, что раз мещены в куче). ПРИМЕЧАНИЕ Если непонятно значение термина «место вызова►>, обратитесь к разделу эмуляции центрального процессора в главе 5.
Чтобы детальнее разобраться в происходящем, проведем мысленный эксперимент. Представим себе дотошно-туповатого повара ресторана, который выстраивает оче редь из заказов, поступающих с каждого стола. Не надеясь на свою память, каждый новый заказ он записывает на отдельной странице блокнота. Когда приходит новый заказ, повар заносит в блокнот запись. По мере выполнения заказов страницы пере :шстываются на следующие заказы, стоящие в очереди. К великому сожалению для клиентов ресторана, записи ведутся по принципу LIFO. Надеюсь, попасть в зав трашней обеденной суматохе в число самых первых клиентов вам не придется. В этой аналогии блокнот играет роль указателя стека. Сам стек состоит из доку �ентов переменной длины, представляющих собой кадры стека. Как и эти кадры, ресторанные заказы содержат некие метаданные. Например, в качестве адреса воз врата может выступать номер стола. Основная роль стека - предоставить место для локальных переменных. В чем причина быстрой работы стека? Все переменные функции находятся в памяти ря .::�:ом друг с другом. Это ускоряет доступ. Улучшение эргономики функций, способных принимать
ТОЛЬКО String ИЛИ &str
Автор библиотеки может упростить код использующего ее приложения, если функции его разработчика могут принимать оба типа: как &str, так и string. К со жалению, у этих двух типов совершенно разные представления в памяти. Одному из них (&str ) память выделяется в стеке, а другому (string)- в куче. То есть типы нельзя просто так приводить друг к другу. Но это обстоятельство можно обойти с помощью Rust-обобщений. Рассмотрим пример проверки пароля. В данном примере надежным считается па роль длиной не менее 6 символов. Ниже показано, как проверить пароль, оценив его длину: fn is_strong(password: String) -> bool { password.len() > 5 }
Функция i s_strong может принимать только значение типа string. То есть сле дующий код работать не будет: let pw = "justok"; let is_strong = is_strong(pw);
Но здесь может помочь обобщенный код. В тех случаях, когда требуется доступ только по чтению, следует использовать функции с сигнатурой типа fn х (а: Т), а не fn х (а: String). Такую длинную сигнатуру типа следу-
252
Глава 6
ет читать так: «Будучи функцией, х получает аргумент пароля типат, где вт реали зуется AsRef». Средства реализации AsRef ведут себя как ссылки на str, даже если это и не соответствует действительности. Рассмотрим еще раз фрагмент кода из предыдущего листинга, который принимает любой тип т, имеющий реализацию AsRef. Теперь у него появилась новая сигнатура: fn is_strong(password: T) -> bool { password.as_ref().len() > 5 }
(1) Предоставление в качестве пароля значения типа String или &str Когда к аргументу требуется доступ по чтению и записи, в большинстве случаев можно воспользоваться родственным AsRef типажом AsMut. К сожалению, в данном примере & 'static str не может стать изменяемым, поэтому можно вос пользоваться другой стратегией: неявным преобразованием. Можно потребовать у Rust принимать только те типы, которые преобразуются в string . В следующем примере такое преобразование выполняется внутри функции, и к вновь созданному значению типа string применяется любая необходимая биз нес-логика. Тем самым проблему с неизменяемым значением &str можно решить обходным путем. fn is_strong(password: T) -> bool { password.into().len() > 5 }
Но эта стратегия неявного преобразования сопряжена с серьезными рисками. При необходимости неоднократного создания в конвейере строковой версии перемен ной пароля было бы гораздо эффективнее потребовать явного преобразования, вы полняемого в вызывающем приложении. Тогда значение типа Strin g будет создано единожды, а использовано многократно.
6.3.2. Куча В этом разделе рассматривается куча. Это область программной памяти для тех ти пов, размер которых в ходе компиляции еще не известен. Почему в ходе компиляции может быть неизвестен размер? В Rust на это есть две причины. Некоторые типы по мере надобности меняются в размере в обе стороны. Очевидные примеры - string и Vec. Есть и другие типы, неспособные сооб щить Rust-компилятору, сколько памяти под них выделять, несмотря на то что их размер в ходе выполнения программы не меняется. Их называют типами с динами чески определяемым размером. Зачастую в качестве примера приводятся слайсы ( [т J ). У слайсов на момент компиляции отсутствует длина. Слайс по сути - указа тель на какую-то часть массива. Но фактически слайсы представляют некоторое количество элементов этого массива.
Память
253
Еще одним примером может послужить типажный объект, который пока не рас сматривался в книге. Типажные объекты позволяют Rust-программистам имитиро вать некоторые особенности динамических языков, допуская помещение несколь ких типов в один и тот же контейнер.
Что такое куча? Полное представление о куче сложится после проработки следующего раздела, по священного виртуальной памяти. А пока выясним, что не является кучей. Как толь ко будет усвоен этот пункт, мы вернемся к выявлению истины.
Слово «куча)) подразумевает дезорганизацию. Ближайшей аналогией могут послу жить складские помещения в каком-нибудь среднем бизнесе. По мере внешних по ступлений (создания переменных) склад предоставляет место. В ходе выполнения предприятием своей работы поступившие материалы расходуются и складские по мещения становятся доступными для новых поступлений. В них бывают пустые места и, возможно, заметен легкий беспорядок. Но в целом чувствуется достаточ ная упорядоченность. Проясним еще одно ошибочное мнение: куча не имеет никакого отношения к структуре данных, также называемой кучей. Эта структура весьма часто использу ется для создания очередей с приоритетом. По сути это весьма продуманный инст румент, но сейчас речь совершенно не о нем. Дело в том, что куча - не структура данных. Это область памяти.
Итак, обозначив эти два различия, перейдем к объяснению. С позиции пользователя главной отличительной чертой кучи является то, что обращение к находящимся в ней переменным должно осуществляться через указатель, чего не требуется пере менным, доступным в стеке. Возьмем простой пример и рассмотрим две переменные: а и ь. Обе они представ ляют собой, соответственно, целые числа 40 и 60. Но одно из целых чисел находит ся в куче: let a: i32 = 40; let b: Box = Box::new(60);
Давайте посмотрим, в чем здесь принципиальная разница. Следующий код не пройдет компиляцию: let result = a + b;
Упакованное значение, присвоенное ь, доступно только через указатель. Чтобы по лучить доступ к этому значению, нам нужно его разыменовать. Унарным операто ром разыменования служит символ *, помещаемый перед именем переменной: let result = a + *b;
Поначалу такой синтаксис может вызвать недоумение, поскольку он же использу ется для умножения. Но со временем к нему привыкают. В следующем листинге показан полный пример, где создание переменных в куче подразумевает использо вание для этой цели такого типа указателя, как вох.
254
Глава 6
Листинг 6.6. Создание переменных в куче fn main() { let a: i32 = 40; let b: Box = Box::new(60); println!("{} + {} = {}", a, b, a + *b);
(1) (2) (3)
}
(1) 40 находится в стеке. (2) 60 находится в куче. (3) Для доступа к 60 требуется разыменование.
Чтобы понять, что такое ку'iа и что происходит в памяти в ходе выполнения про граммы, нужно рассмотреть небольшой пример. В нем все ограничивается создани ем нескольких чисел в куче, над которыми затем проводится операция сложения. В ходе своего выполнения программа из листинга 6.7 выдает простой результат в виде двух троек. Но фактически здесь важны не результаты, выданные програм мой, а то, что находится внутри ее памяти. Код следующего листинга находится в файле ch6/ch6-heap-via-box/src/main.rs. За ко дом (на рис. 6.5) следует изображение памяти программы в ходе ее выполнения. Посмотрим сначала на результат выполнения программы: 3 3
Листинг 6.7. Выделение и освобождение памяти в куче при помощи Box 1 use std::mem::drop; 2 3 fn main() { 4 let a = Box::new(1); 5 let b = Box::new(1); 6 let c = Box::new(1); 7 8 let result1 = *a + *b + *c; 9 10 drop(a); 11 let d = Box::new(1); 12 let result2 = *b + *c + *d; 13 14 println!("{} {}", result1, result2); 15 }
(1) (2) (2) (2)
(3) (4)
(1) Помещение функции drop(), используемой в принудительном порядке, в локальную область видимости. (2) Размещение значения в куче. (3) Унарный оператор разыменования * возвращает значение из упаковки, после чего в resultl содержится значение 3. (4) Вызов функции drор()высвобождает память для ее использования в иных целях.
Память
255 Ход выполнения программы
let a = Box::new(1) let b = Box::new(1) let c = Box::new(1) let result1 = *a + *b + *c; drop(a) let d = Box::new(1) Изменение состояния структуры памяти с течением времени 0xfff 10 0
10 0 0xff7
10 0
10 8
0xfef
10 0
10 0
10 0
10 8
10 8
10 8
10 8
11 0
110
110
11 0
3
3
3
0xfe7 0xfdf
10 0
0x120 0x118 0x110 0x108 0x100
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
Значения i32 размещаются Три целых числа складыв куче, а указатель на адрес ваются, и их сумма помекаждого значения помещает- щается в стек. ся в стек (при этом целочисленные значения находятся в упакованном виде).
Видимо упакованное значение не было удалено из кучи, но распределитель памяти не пометил это место как свободное для его повторного использования.
Порядок интерпретации диаграммы
Пространство, занятое a, повторно используется d.
Сверхспособности Rust
Стек представлен верхним блоком.
0xfff
Стек, в отличие от стопки, чье английское название он позаимствовал, растет вниз, а не в вверх
0xfef
В этом примере используется упрощенное адресное пространство, составляющее 4096 байт. В более реалистичной ситуации, например с 64-разрядным процессором, адресное пространство составляе 264 байт.
0x120
10 0 0xff7
Куча берет начало в нижней части адресного пространства плюс смещение, которое здесь равно 256 (0x100).
0xfe7 0xfdf
0x118 0x110 0x108 0x100
Куча представлена нижним блоком.
1
Пространство в диапазоне от 0 до смещения зарезервировано для исполняемых инструкций и переменных программы, сохраняемых в течение всего периода ее существования.
К этому моменту срок жизни переменной a истек. Доступ к этому адресу памяти теперь недействителен. Данные о нем еще будут присутствовать в стеке, но получить к нему доступ в безопасной экосистеме Rust невозможно.
Рис. 6.5. Взгляд на структуры памяти программы при выполнении кода листинга 6.7
256
Глава 6
В коде листинга 6.7 в кучу помещаются четыре значения и удаляется одно из них. Здесь используется новый или уже подзабытый синтаксис, заслуживающий рас смотрения или восстановления в памяти: • Использование синтаксиса вох: :new(TJ приводит к размещению тв куче. Понятие упаковки (вох), если не воспринимать его интуитивно, может ввести в заблуждение. • Что-то, что было упаковано, размещено в куче с указателем на него, поме щенным в стек. Это показано в первом столбце на рисунке 6.5, где число OxlOO по адресу Oxfff указывает на значение 1 по адресу OxlOO. Но ни одна фактическая упаковка байтов не содержит значения, и это значение каким либо образом не скрыто и не припрятано. • В результате использования синтаксиса std: :тет: :drop функция drop () пе реносится в локальную область видимости. Эта функция удаляет объекты до того, как завершится существование их области видимости. • Типы, реализующие Drop, содержат метод drop () , но в пользовательском ко де его явный вызов недопустим. Использование std: : mem: : drop является ис ключением из этого правила. • Звездочки перед именами переменных (*а, *Ь, *с и *d) являются унарными операторами разыменования. При разыменовании вох:: (Т) возвращается т. В нашем случае переменные а, ь, с и d - ссылки на целые числа. Каждый столбец на рис. 6.5 показывает, что происходит внутри памяти при выпол нении шести строк кода. Стек отображается в виде блоков в верней части, а куча в нижней. Некоторые детали на рисунке отсутствуют, но он должен помочь в полу чении интуитивного представления о взаимосвязи стека и кучи. ПРИМЕЧАНИЕ
При наличии опыта работы с отладчиком и желания исследовать происходящее ском пилируйте код без каких-либо оптимизаций, воспользовавшись командой cargo build (или cargo run), но только не cargo build --release. Использование ключа --release фактически приводит к оптимизации выделения памяти и выполне ния арифметики. При самостоятельном вызове rustc следует воспользоваться ко мандой rustc --codegen opt-level=O.
6.3.3. Что такое динамическое распределение памяти? У выполняемой программы всегда есть фиксированное количество байтов, с кото рым она может работать. Когда ей нужно больше памяти, следует отправлять за прос операционной системе. Такая схема, показанная на рис. 6.6, называется дина мическим распределением памяти. Этот процесс проходит в три этапа: 1. Запрос памяти у операционной системы с помощью системного вызова. В се мействе операционных систем UNIX этот системный вызов называется alloc (). А в MS Windows - HeapAlloc ().
Память
257 Под контролем программы
Вне контроля программы
Запрос на память
Программа
Распределитель
OS
Аппаратура
Интеллектуальный учет, проводимый распределителем и позволяющий избежать лишней работы операционной системы и оборудования компьютера.
Рис. 6.6. Концептуальное представление распределения динамической памяти. Запросы на память выдаются и завершаются на уровне программы, но в них вовлекается ряд других компонентов. На каждом этапе компоненты могут замкнуть процесс и быстро вернуть управление.
2. Использование выделенной памяти в программе. 3. Освобождение ненужной памяти с возвращением ее в оборот операционной сис темы с помощью функции free () в системах семейства UNIX и функции HeapFree () в системах Windows. Получается, что между программой и операционной системой имеется посредник распределитель, представляющий собой специализированную подпрограмму, ав томатически встроенную в вашу программу. Зачастую она выполняет оптимиза цию, позволяющую избежать большого объема работы со стороны операционной системы и центрального процессора. Давайте рассмотрим влияние динамического распределения памяти на производи тельность и стратегии уменьшения его негативного воздействия. Для начала вспомним причину разной производительности стека и кучи. Не забудем, что стек и куча - всего лишь концептуальные абстракции. Это не физические разделы памя ти вашего компьютера. Так чем же объясняется их разная производительность? В стеке скоростной доступ к данным обусловлен тем, что размещенные в нем ло кальные переменные функции располагаются в оперативной памяти рядом друг с другом. Иногда это называют сплошной раскладкой. Сплошная раскладка хорошо подходит для кэширования. А вот в куче значения переменных вряд ли будут располагаться рядом друг с другом. Более того, доступ к данным в куче невозможен без разыменования указателя. А это поиск в таблице страниц и переход в основную память. Все эти различия сведены в таблицу 6.1. Таблица 6.1. Упрощенная, новсе же полезная таб лица сравнения стека и кучи
Стек
Куча
Стек
Куча
Простой
Сложная
Быстрый
Медленная
Безопасный
Опасная*
Жестко заданный
Гибкая
*Ноне в безопасном Rust!
258
Глава 6
За счет компромисса, если придерживаться единого размера записей в нем на все время работы программы, работу со стеком можно ускорить. Структуры данных, размещенные в куче, обладают большей гибкостью. Поскольку доступ к ним осу ществляется через указатель, этот указатель можно изменить. Для количественной оценки оказываемого влияния нужно научиться измерять из держки. Для получения большого объема измерений нужна программа, создающая и уничтожающая множество значений. Давайте создадим игровую программу. На рис. 6.7 показан фоновый элемент видеоигры.
Рис. 6.7. Скриншоты с результатами запуска кода листинга 6.9
После запуска кода листинга 6.9 на экране должно появиться окно с темно-серым фоном. Белые точки, похожие на снежинки, начнут всплывать снизу и исчезать по мере приближения к верхней части экрана. Если посмотреть на выводимую на кон соль информацию, то там будут появляться потоки чисел. Значение этих потоков будет объяснено после обсуждения кода. Программа, показанная в листинге 6.9, состоит из трех основных разделов: • Распределителя памяти (со структурой ReportingAllocator), записывающего время, затрачиваемое на распределение динамической памяти. • Определителя структур World и Particle и их поведения с течением времени. • Функции main (), занимающейся созданием и инициал:изацией окна. Зависимости этой игровой программы (код которой находится в листинге 6.9) пока заны в следующем листинге. Его исходный код находится в файле ch6/ch6sizes/Cargo.toml. А исходный код листинга 6.9 находится в файле ch6/ch6-sizes/main.rs. Листинг 6.8. Построение зависимостей для кода листинга 6.9 [package] name = "ch6-particles" version = "0.1.0" authors = ["TS McNamara "] edition = "2018" [dependencies] piston_window = "0.117"
(1)
Память
259
piston2d-graphics = "0.39"
(2)
rand = "0.8"
(3)
(1) Предоставление оболочки для основных функций игрового движка piston, позволяющей без особого труда вырисовывать объекты на экране практически без какой-либо зависимости от среды выполнения программы (2) Предоставление векторной математики, играющей важную роль в имитации движения. (3) Предоставление генераторов случайных чисел и связанных с ними функций. Листинг 6.9. Графическое приложение для создания и уничтожения объектов в куче 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
use graphics::math::{Vec2d, add, mul_scalar};
(1)
use piston_window::*;
(2)
use rand::prelude::*;
(3)
use std::alloc::{GlobalAlloc, System, Layout};
(4)
use std::time::Instant;
(5)
#[global_allocator] static ALLOCATOR: ReportingAllocator = ReportingAllocator;
(6)
struct ReportingAllocator;
(7)
unsafe impl GlobalAlloc for ReportingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { let start = Instant::now(); let ptr = System.alloc(layout); let end = Instant::now(); let time_taken = end - start; let bytes_requested = layout.size();
(8)
eprintln!("{}\t{}", bytes_requested, time_taken.as_nanos()); ptr } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { System.dealloc(ptr, layout); } } struct World { current_turn: u64,
(9) (9)
260 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
Глава 6 particles: Vec, height: f64, width: f64, rng: ThreadRng, } struct Particle { height: f64, width: f64, position: Vec2d, velocity: Vec2d, acceleration: Vec2d, color: [f32; 4], } impl Particle { fn new(world : &World) -> Particle { let mut rng = thread_rng(); let x = rng.gen_range(0.0..=world.width); let y = world.height; let x_velocity = 0.0; let y_velocity = rng.gen_range(-2.0..0.0); let x_acceleration = 0.0; let y_acceleration = rng.gen_range(0.0..0.15); Particle { height: 4.0, width: 4.0, position: [x, y].into(), velocity: [x_velocity, y_velocity].into(), acceleration: [x_acceleration, y_acceleration].into(), color: [1.0, 1.0, 1.0, 0.99], }
(9) (9) (9) (9)
(10) (10) (10) (10) (10) (10) (10)
(11) (11) (12) (12) (13) (13)
(14) (14) (14) (15)
} fn update(&mut self) { self.velocity = add(self.velocity, self.acceleration); self.position = add(self.position, self.velocity); self.acceleration = mul_scalar( self.acceleration, 0.7 ); self.color[3] *= 0.995; }
(16) (16) (17) (17) (17) (17) (18)
Память 83 } 84 85 impl World { 86 fn new(width: f64, height: f64) -> World { 87 World { 88 current_turn: 0, 89 particles: Vec::::new(), 90 height: height, 91 width: width, 92 rng: thread_rng(), 93 } 94 } 95 96 fn add_shapes(&mut self, n: i32) { 97 for _ in 0..n.abs() { 98 let particle = Particle::new(&self); 99 let boxed_particle = Box::new(particle); 100 self.particles.push(boxed_particle); 101 } 102 } 103 104 fn remove_shapes(&mut self, n: i32) { 105 for _ in 0..n.abs() { 106 let mut to_delete = None; 107 108 let particle_iter = self.particles 109 .iter() 110 .enumerate(); 111 112 for (i, particle) in particle_iter { 113 if particle.color[3] < 0.02 { 114 to_delete = Some(i); 115 } 116 break; 117 } 118 119 if let Some(i) = to_delete { 120 self.particles.remove(i); 121 } else { 122 self.particles.remove(0); 123 }; 124 } 125 } 126 127 fn update(&mut self) { 128 let n = self.rng.gen_range(-3..=3); 129
261
(19)
(20) (21) (22)
(23) (23) (23) (24) (24) (24) (24) (24) (24) (24) (24) (24) (24) (24) (24)
(25)
262
Глава 6
130 if n > 0 { 131 self.add_shapes(n); 132 } else { 133 self.remove_shapes(n); 134 } 135 136 self.particles.shrink_to_fit(); 137 for shape in &mut self.particles { 138 shape.update(); 139 } 140 self.current_turn += 1; 141 } 142 } 143 144 fn main() { 145 let (width, height) = (1280.0, 960.0); 146 let mut window: PistonWindow = WindowSettings::new( 147 "particles", [width, height] 148 ) 149 .exit_on_esc(true) 150 .build() 151 .expect("Could not create a window."); 152 153 let mut world = World::new(width, height); 154 world.add_shapes(1000); 155 156 while let Some(event) = window.next() { 157 world.update(); 158 159 window.draw_2d(&event, |ctx, renderer, _device| { 160 clear([0.15, 0.17, 0.17, 0.9], renderer); 161 162 for s in &mut world.particles { 163 let size = [s.position[0], s.position[1], s.width, s.height]; 164 rectangle(s.color, size, ctx.transform, renderer); 165 } 166 }); 167 } 168 }
(1) graphics::math::Vec2d предоставляет математические операции и функции преобразования для 2D-векторов. (2) piston_window предоставляет инструменты для создания программы с графическим интерфейсом пользователя и рисования фигур. (3) rand предоставляет генераторы случайных чисел и связанные с ними функции. (4) std::alloc предоставляет средства для управления распределением памяти.
Память
263
(5) std::time предоставляет доступ к системным часам. (6) #[global_allocator] помечает следующее значение (ALLOCATOR) удовлетворяющим признаку GlobalAlloc. (7) Вывод на STDOUT времени, затраченного на каждое выделение памяти в ходе выполнения программы, что дает довольно точное представление о времени, затраченном на распределение динамической памяти. (8) Перенос фактического выделения памяти на системный распределитель памяти по умолчанию. (9) Содержание данных, необходимых в течение всего срока жизни программы. (10) Определение объекта в 2D-пространстве. (11) Начинается в случайном месте в нижней части окна. (12) Выполнение вертикального подъема с течением времени. (13) Увеличение скорости подъема с течением времени. (14) into () преобразует массивы типа [f64; 2] в Vec2d. (15) Вставка полнонасыщенного белого цвета с небольшой прозрачностью. (16) Перемещение частицы в следующую позицию. (17) Замедление скорости увеличения частицы при ее движении по экрану. (18) Придание частице.большей прозрачности с течением времени. (19) Использование Box вместо Particle, чтобы дополнительная память выделялась при создании каждой частицы. (20) Создание частицы Particle в качестве локальной переменной в стеке. (21) Овладение частицей, перемещение ее данных в кучу и создание ссылки на эти данные в стеке. (22) Помещение ссылки в self .shapes. (23) Чтобы было проще разместить на странице, particle_iter разбивается на свои собственные переменные. (24) Удаление за n итераций первой невидимой частицы. Если нет невидимых частиц, то удаление самых старых. (25) Возвращение случайного целого числа в диапазоне от -3 до 3 включительно.
Код листинга 6.9 довольно длинный, но вряд ли в нем содержится что-то сильно отличающееся от ранее увиденного. Ближе к концу этого примера представлен синтаксис Rust-замыкания. Если присмотреться к вызову window.draw_2d (), то там есть второй аргумент с именами двух переменных, заключенными в вертикальные линии ( 1 ctx, renderer , _device 1 { ... } ). Все, что находится между этими линиями, служит аргументами замыкания, а то, что находится в фигурных скобках, является его телом. Замыкание - это функция, которая определяется в строке и может обращаться к переменным из окружающей области видимости. Замыкания часто называют ано нимными или лямбда-функциями. Замыкания не редкость в Rust-кoдe, но в этой книге, чтобы примеры были понятнее читателям с опытом работы в сфере императивного или объектно-ориентиро ванного программирования, их применение сведено к необходимому минимуму. Более подробно замыкания рассматриваются в главе 11. А пока достаточно будет заметить, что замыкание - удобное сокращение для определения функций. Теперь давайте сосредоточимся на получении доказательств, что размещение переменных в куче (не один миллион раз) может повлиять на производительность кода.
264
Глава 6
6.3.4. Анализ влияния, оказываемого динамическим выделением памяти Если код листинга 6.9 запустить из окна терминала, то вскоре станут видны запол няющие экран два столбца чисел с количествами выделенных байтов и продолжи тельностью выполнения запросов в наносекундах. Как показано в следующем лис тинге, эту информацию можно отправить для дальнейшего анализа в файл, для чего его код перенаправляет вывод stdeтт из ch6-particle s в файл. Листинг 6.10. Создание отчета о выделении памяти $ cd ch6-particles $ cargo run -q 2> alloc.tsv
(1)
$ head alloc.tsv 4 219 5 83 48 87 9 78 9 93 19 69 15 960 16 40 14 70 16 53
(2)
(1) Запуск ch6-particles на вьmолнение в режиме отключенного вывода. (2) Просмотр первых 10 строк вывода.
В этом небольшом фрагменте интересно то, что скорость выделения памяти плохо коррелируется с ее размером. Если составить график, охватывающий каждое выде ление памяти в куче, картина становится еще яснее (см. рис. 6.8). Для создания собственной версии графика, изображенного на рис. 6.8, можно вос пользоваться сценарием gnuplot, настраиваемым по вашему усмотрению; его код показан в следующем листинге. Этот же код можно найти в файле ch6/alloc.plot. Листинг 6.11. Сценарий gnuplot, используемый для создания такого же графика как на рис. 6.8 set set set set
key off rmargin 5 grid ytics noxtics nocbtics back border 3 back lw 2 lc rgbcolor "#222222"
set xlabel "Allocation size (bytes)" set logscale x 2
Память
265
set xtics nomirror out set xrange [0 to 100000] set set set set
ylabel "Allocation duration (ns)" logscale y yrange [10 to 10000] ytics nomirror out
plot "alloc.tsv" with points \ pointtype 6 \ pointsize 1.25 \ linecolor rgbcolor "#22dd3131"
Продолжительность выделения памяти (в нс)
10000
1000
100
10 1
4
16
64
256
1024
4096
16384
65536
Объем выделяемой памяти (в байтах)
Рис. 6.8. График зависимости времени выделения памяти в куче от ее размера показывает, что между ними нет четкой взаимосвязанности. По сути время, необходимое для выделения памяти, непредсказуемо, даже если один и тот же объем памяти запрашивается неоднократно.
Не факт, что на выделение большего объема памяти потребуется больше времени, чем для выделения меньшего объема. Продолжительность выделения одного и того же объема памяти может отличаться более чем на порядок. Оно может занять как 100 наносекунд, так и 1000. Имеет ли это какое-либо значение? Трудно сказать. Если используется процессор с тактовой частотой 3 ГГц, то он способен выполнять 3 миллиарда операций в секун ду. Если между каждой из этих операций будет задержка в 100 наносекунд, то ком пьютер сможет выполнить за тот же период времени только 30 миллионов опера ций. Возможно, эти сотни микросекунд действительно важны для вашего приложе-
266
Глава 6
ния. Чтобы минимизировать выделение памяти в куче, существует ряд стратегий, в числе которых: • Использование .массивов неинициализированных объектов. Вместо создания по мере надобности новых объектов заранее создавайте большое количество объектов с нулевыми значениями. Когда настанет время активировать один из таких объектов, установите для него ненулевое значение. Данная стратегия может стать источником реальной опасности, поскольку имеющийся в Rust механизм проверки времени жизни будет проигнорирован. • Использование механизма распределения памяти, подстроенного под про филь доступа к памяти вашего приложения. Распределители памяти зачас тую лучше всего работают при каких-то вполне определенных выделяемых размерах. • Освоение arena: : Arena и arena: : TypedArena. Они позволяют создавать объ екты «на лету», а alloc () и free () вызываются только при создании и унич тожении arena.
6.4. Виртуальная память В этом разделе объясняется суть понятия виртуальной памяти и раскрывается ее предназначение. Эти знания позволят ускорить работу программ и создать про граммные средства, отвечающие возлагаемым на них требованиям. Если ускорить доступ центрального процессора к памяти, он сможет быстрее выполнять вычисле ния. Понимание динамики компьютерной архитектуры позволит эффективнее снабжать памятью центральный процессор.
6.4.1. История вопроса Я убил много времени на компьютерные игры. Какими бы приятными и сложными они мне ни казались, я часто задумывался, а не лучше ли было провести подростко вые годы, занимаясь чем-то более продуктивным. Тем не менее, воспоминаний ос талось много. Но часть из них все еще имеет горький привкус. Порой кто-то входил в игру и уничтожал всех направо и налево, сохраняя неверо ятно высокий уровень здоровья своего воина. А другие ему кричали: «Мошенник!», но сами рано или поздно все же терпели поражение. Меня же мучал вопрос: «В чем секрет? Как вообще настроить игру на это?» Проработав примеры данного раздела, вы бы могли создать ядро инструмента, спо собного проверять и изменять значения запущенной программы.
Термины, применяемые к виртуальной памяти
От терминологии, применяемой в этой области, веет особой таинственностью. За частую это связано с решениями, принятыми много десятилетий назад, во времена разработок самых первых компьютеров.
Память
267
Рассмотрим краткую справку по ряду наиболее важных терминов: • Страница - блок слов реальной памяти фиксированного размера. Обычно для 64-разрядных операционных систем ее размер составляет 4 Кб. • Слово - любой тип размером с указатель. Соответствует ширине регистров центрального процессора. В Rust типам шириной в слово соответствуют usize И isize. • Ошибка страницы - ошибка, выдаваемая центральным процессором при за просе допустимого адреса памяти, отсутствующего на данный момент в фи зической оперативной памяти. Служит сигналом для операционной системы о необходимости возвращения в память хотя бы одной страницы. • Подкачка - перенос страницы памяти, временно хранящейся на диске, в основную память по запросу. • Виртуальная память - видение программой своей памяти. Все данные, дос тупные программе, представляются в ее адресном пространстве операцион ной системой. • Реальная память - представление операционной системы о доступной фи зической памяти. Во многих технических текстах реальная память определя ется независимо от физической памяти, которая становится в большей степе ни термином из области электроники. • Таблица страниц - структура данных, поддерживаемая операционной сис темой для управления преобразованием виртуальной памяти в реальную. • Сегмент - блок в виртуальной памяти. Чтобы минимизировать пространст во, необходимое для перевода виртуальных адресов в физические и обратно, эта память разделена на блоки. • Ошибка сегментации - ошибка, выдаваемая центральным процессором при запросе недопустимого адреса памяти. • ММИ - диспетчер памяти, компонент центрального процессора, управляю щий преобразованием адресов памяти. Поддерживает кэш недавно преобра зованных адресов, который, по вышедшей из моды терминологии, назывался TLB - translation lookaside buffer, т.е., резервным буфером преобразования. Одним из понятий, не получившим до сих пор никакого технического определения в этой книге, является процесс. Если этот термин уже попадался ранее и вызывал вопрос, почему он оставлен без внимания, следует пояснить, что его рассмотрение оставлено до разговора о конкурентных вычислениях. А пока такое понятие, как «процесс)), и сходное с ним понятие «процесс операционной системы)) будут ис пользоваться для обозначения программы, запущенной на выполнение.
6.4.2. Шаг 1. Сканирование процессом собственной памяти Интуитивно представляется, что память программы - последовательность байтов, начинающаяся в ячейке памяти О, и заканчивающаяся в ячейке памяти п. Если про грамма сообщает об использовании оперативной памяти объемом 100 Кб, может
268
Глава 6
сложиться представление, что п будет где-то в районе 100 ООО. Давайте посмотрим, так ли это. Создадим небольшую программу командной строки, просматривающую память с нулевой по 10 000-ю ячейку. Поскольку программа небольшая, она не должна за нимать более 1О ООО байт. Но при выполнении программа не станет выполнять за думанное. К сожалению, она даст сбой. Причина будет рассмотрена в этом разделе. Код программы командной строки показан в листинге 6.12. Его можно найти в файле ch6/ch6-memscan-1/src/main.rs. Замысел заключается в побайтовом просмотре памяти запущенной программы, начиная с О. В коде представлен синтаксис созда ния обычных указателей и их разыменования (чтения данных). Листинг 6.12. Попытка побайтового сканирования памяти запущенной программы 1 fn main() { 2 let mut n_nonzero = 0; 3 4 for i in 0..10000 { 5 let ptr = i as *const u8; 6 let byte_at_addr = unsafe { *ptr }; 7 8 if byte_at_addr != 0 { 9 n_nonzero += 1; 10 } 11 } 12 13 println!("non-zero bytes in memory: {}", n_nonzero); 14 }
(1) (2)
(1) Преобразование i в *const Т, обычный указатель типа u8 для проверки обычных адресов памяти. Каждый адрес считается ячейкой, при этом игнорируется тот факт, что большинство значений занимает несколько байтов. (2) Разыменование указателя, считывание значения по адресу i. Иными словами, приказ: «Считать указанное значение».
При попытке разыменования NULL-указателя код листинга 6.12 дает сбой. Когда i равно О, разыменование ptr невозможно. Кстати, именно поэтому все разыменова ния обычных указателей должны происходить внутри небезопасного блока. А что, если начать с ненулевого адреса памяти? Учитывая, что программа пред ставляет собой исполняемый код, для итерации должно быть не менее нескольких тысяч байт ненулевых данных. Чтобы избежать разыменования NULL-указателя, код следующего листинга сканирует память процесса, начиная с 1. Листинг 6.13. Сканирование памяти процесса 1 fn main() { 2 let mut n_nonzero = 0;
Память 3 4 5 6 7 8 9 10 11 12 13 14 }
269
for i in 1..10000 { let ptr = i as *const u8; let byte_at_addr = unsafe { *ptr };
(1)
if byte_at_addr != 0 { n_nonzero += 1; } } println!("non-zero bytes in memory: {}", n_nonzero);
(1) Чтобы избежать исключения, связанного с NULL-указателем, начинаем с 1, а не с О.
К сожалению, полноценного решения проблемы у нас не получится. При выполне нии код листинга 6.13 по-прежнему вьmетает, и количество ненулевых байтов ни когда не выводится на консоль. Причина в так называемой ошибке сегментации. Ошибки сегментации выдаются, когда центральный процессор и операционная система обнаруживают попытку программы получить доступ к областям памяти, на которые она не имеет права. Области памяти разбиты на сегменты, чем и объясня ется название ошибки. Попробуем применить другой подход. Вместо попытки сканирования байтов да вайте поищем адреса того, в чьем существовании мы не сомневаемся. Мы потрати ли уйму времени на изучение указателей, так давайте же ими и воспользуемся. Ко дом листинга 6.14 создается несколько значений с проверкой их адресов. При каждом запуске кода листинга 6.14 могут генерироваться уникальные значе ния. Результат одного запуска выглядит следующим образом: GLOBAL: local_str: local_int: boxed_int: boxed_str: fn_int:
0x7ff6d6ec9310 0x7ff6d6ec9314 0x23d492f91c 0x18361b78320 0x18361b78070 0x23d492f8ec
Как видите, значения получились разбросанными в широком диапазоне адресов. Получается, что при наших надеждах на весьма скромные, всего лишь в несколько килобайт, потребности программы в оперативной памяти, несколько переменных разместились в адресах памяти со слишком большими значениями. Дело в том, что это виртуальные адреса. Из раздела, где куча сравнивалась со стеком, известно, что стек начинается в верх ней части адресного пространства, а куча - в его нижней части. В этом прогоне программы максимальным адресным значением было Ox7ffбdбec9 314. Это число примерно соответствует значению 264 + 2. Дело в том, что операционная система резервирует половину адресного пространства для себя.
270
Глава 6
Код следующего листинга с целью изучения адресного пространства программы возвращает адреса нескольких ее переменных. Исходный код этого листинга нахо дится в файле chб/chб-memscan-3/src/main.rs. Листинг 6.14. Вывод на экран адресов переменных, имеющихся в программе
(1)
static GLOBAL: i32 = 1000; fn noop() -> *const i32 { let noop_local = 12345; &noop_local as *const i32 }
(2) (3)
fn main() { let local_str = "a"; let local_int = 123; let boxed_str = Box::new('b'); let boxed_int = Box::new(789); let fn_int = noop(); println!("GLOBAL: println!("local_str: println!("local_int: println!("boxed_int: println!("boxed_str: println!("fn_int:
{:p}", {:p}", {:p}", {:p}", {:p}", {:p}",
(4) (4) (4) (4) (4)
&GLOBAL as *const i32); local_str as *const str); &local_int as *const i32); Box::into_raw(boxed_int)); Box::into_raw(boxed_str)); fn_int);}
(1) Создание глобальной статической переменной, имеющей в Rust-программах глобальный статус. (2) Создание локальной переменной, имеющей в Rust-программах локальный статус. (3) Создание локальной переменной в noop(), чтобы адрес памяти был у чего-то, что находится за пределами метода main(). (4) Создание различных значений нескольких типов, включая значения в куче.
Порядок обращения к адресам сохраненных значений вами уже должен быть усво ен. Но есть еще два небольших, возможно также уже усвоенных вами урока: • Бывают недопустимые адреса. При попытке обращения к адресам памяти, выходящим за границы допустимого диапазона, операционная система за вершает работу программы. • Адреса памяти не выбираются произвольным образом. При всей кажущейся сильной разбросанности значений в адресном пространстве значения сгруп пированы вместе по зонам. Прежде чем приступить к работе над мошеннической программой, давайте вернем ся назад и посмотрим на систему, закулисно переводящую виртуальные адреса в адреса реальной памяти.
Память
271
6.4.3. Преобразование виртуальных адресов в физические Программе для доступа к данным требуются виртуальные адреса, то есть единст венные адреса, к которым имеет доступ сама программа. Они переводятся в физи ческие адреса. Этот процесс не обходится без взаимодействия программы, опера ционной системы, центрального процессора, устройства оперативной памяти, а иногда жестких дисков и других устройств. За выполнение этого преобразования отвечает центральный процессор, но инструкции хранятся в операционной системе. Для решения именно этой задачи в центральном процессоре есть блок управления памятью (MMU). Для каждой выполняемой программы виртуальный адрес сопос тавляется с физическим. Соответствующие инструкции также хранятся по заранее заданному адресу памяти. То есть в худшем случае каждая попытка доступа к ад ресам сопряжена с двумя поисками в памяти. Но худшего случая все же можно избежать. В центральном процессоре поддерживается кэш недавно преобразованных адресов. Для ускорения доступа к памяти в нем имеется собственная (быстрая) память. По историческим причинам этот кэш известен как резервный буфер преобразования, для которого часто используется сокращение TLB. Программистам, оптимизирую щим производительность, лучше придерживаться компактных структур данных и избегать их глубокого вложения. Исчерпание емкости TLB (составляющей обычно около 100 страниц для процессоров х86) может обойтись весьма недешево. При изучении системы преобразования адресов вскрываются многочисленные, весьма непростые нюансы. Виртуальные адреса сгруппированы в блоки, называе мые страницами, имеющими обычно размер 4 Кб. Благодаря этому удается избе жать хранения сопоставлений одних адресов с другими для каждой отдельной пе ременной в каждой программе. Наличие единого размера для каждой страницы также помогает избежать явления, известного как фрагментация памяти, когда в доступной оперативной памяти появляются пустые места из непригодного для ис пользования пространства.
ПРИМЕЧАНИЕ Это только общее представление. Детали взаимодействия операционной системы и центрального процессора при управлении памятью в тех или иных средах могут суще ственно различаться. В частности, в ограниченных средах, таких как микроконтролле ры, может использоваться реальная адресация. Интересующиеся данным вопросом могут обратиться к области исследований компьютерной архитектуры.
Когда данные размещаются в страницах виртуальной памяти, операционная систе ма и центральный процессор могут воспользоваться некоторыми весьма интерес ными приемами. Например: • Наличие виртуалыюго адресного пространства позволяет операционной системе выходить за пределы допустимого. То есть допускается адаптация программ, чьи требования к памяти превышают физические возможности машины. • Пока неактивные на данный момент страницы не будут запрошены актив ной программой, их можно побайтно сбрасывать на диск. Свопинг, или пе-
272
Глава 6
рекачка страниц между диском и памятью, часто используется в периоды вы сокой востребованности оперативной памяти, но, в зависимости от задач опе рационной системы, может использоваться и при других обстоятельствах. • Оптимизация размера занимаемой памяти может выполняться и по другому, например путем сжатия данных. Программе ее память видна в ис ходном состоянии, а закулисно операционная система сжимает данные, нера ционально расходующие память. • Ускорить работу с памятью может совместное использование данных не сколькими программами. Если программа, к примеру, для вновь созданного массива запрашивает большой блок нулей, операционная система может ука зать на заполненную нулями страницу, которая в данный момент использует ся тремя другими программами. Ни одна из программ не знает, что к той же самой физической памяти обращаются и другие программы, и нули в их вир туальном адресном пространстве имеют разные позиции. • Постраничное разбиение может ускорить загрузку совместно используемых библиотек. В качестве частного случая предыдущего пункта, если совместно используемая библиотека уже загружена другой программой, операционная система может не загружать ее в память дважды, указав новой программе на прежние данные. • Постраничное разбиение памяти повышает безопасность работы в много программной среде. В этом разделе уже демонстрировалась недопустимость обращения к некоторым частям адресного пространства. Операционная сис тему может добавить и другие имеющиеся у нее свойства. При попытке запи си на страницу, доступную только для чтения, операционная система завер шит выполнение программы. Эффективное использование системы виртуальной памяти в стандартных програм мах предполагает рациональное представление данных в оперативной памяти. На этот счет имеется ряд рекомендаций: • Храните активно используемые части программы в блоках размером 4 Кб, что поспособствует их быстрому поиску. • Если размер в 4 Кб окажется неподходящим для вашего приложения, то сле дующим целевым показателем будет 4 Кб *100. То есть, если последовать этому простому совету, центральный процессор сможет поддерживать свой ТLВ-кэш в порядке, наиболее пригодном для вашей программы. • Не создавайте структур данных с глубоким вложением и с развесистыми ука зателями. Если указатель нацелен на другую страницу, производительность падает. • Проверьте порядок выполнения вложенных циклов. Центральные процессо ры считывают из устройства оперативной памяти небольшие блоки байтов, известные как строки кэша. Этим можно воспользоваться при обработке мас сива, проработав выполнение операции по столбцам или по строкам. Следует заметить, что виртуализация усугубляет складывающуюся ситуацию. Если приложение запускается на виртуальной машине, гипервизор вынужден, кроме все-
Память
273
го прочего, выполнять преобразования адресов для своих гостевых операционных систем. Именно поэтому многие процессоры поставляются с поддержкой виртуали зации, способной снизить эти дополнительные издержки. Запуск контейнеров на виртуальных машинах добавляет еще один уровень косвенности и, следовательно, не обходится без дополнительных задержек. Чтобы добиться высокой производи тельности на «голом железе)), нужно на нем и запускать приложения. Как исполняемый файл превращается в виртуальное адресное пространство программы? Структура исполняемых файлов (также называемых двоичными файлами) во мно гом похожа на схему адресного пространства, ранее показанную в разделе этой главы, посвященном куче и стеку. Сам процесс, конечно, зависит от применяемой операционной системы и формата файла, но типичный пример показан на следующем рисунке. Каждый из рассмот ренных нами сегментов адресного пространства описывается двоичными файлами. При запуске исполняемого файла операционная система загружает нужные байты в нужные места. Как только виртуальное адресное пространство будет создано, цен тральному процессору может быть предписано перейти к началу сегмента . text, и программа начнет выполняться. Исполняемый файл (ELF) Заголовок файла: описывает тип файла Заголовок программы: описывает сегменты памяти, .bss используемые программой, и их атрибуты .rodata Общепринятые сегменты: .bss
Виртуальное адресное пространство
Адресное пространство ядра: Недоступно программе
.data
Историческое название, которое изначально расшифровывалось как Block Started by Symbol (блок, начинающийся с символа). Место для неинициализированных статических переменных. Занимает небольшое пространство файла, обычно с указанием только лишь необходимой длины в байтах.
Переменные среды и аргументы командной строки: Для программы эти данные только для чтения
.rodata Означает «read-only data» (данные только для чтения). Место для инициализированных неизменяемых значений со статическим временем жизни, например строковых литералов (static T).
Стек: доступен для записи программой
.data Место для инициализированных изменяемых .text глобальных переменных со статическим временем жизни (static mut T). .bss .rodata .data .text
.text Место для исполняемого кода
Метаданные компоновщика: идентификаторы и другие данные
Блоки, обозначенные черным и глубоким темно-серым отенками, недоступны из кода программы.
Куча: доступен для записи программой
274
Глава 6
6.4.4. Шаг 2. Работа с операционной системой для сканирования адресного пространства Наша задача заключается в сканировании памяти программы в ходе ее выполнения. Как выяснилось, операционная система поддерживает инструкции сопоставления виртуального адреса с физическим. Можно ли попросить операционную систему сообщать о том, что происходит? Операционные системы предоставляют программам интерфейс, позволяющий де лать запросы, известные как системные вызовы. В Windows все функции проверки и управления памятью запущенного процесса предоставляются библиотекой КERNEL.DLL.
ПРИМЕЧАНИЕ
А почему речь эдесь идет о Windows? Дело в том, что многие программисты, рабо тающие на Rust, используют в качестве платформы MS Windows. Кроме того, у ее функций четко выраженные имена, не требующие длительного предварительного оз накомления, как при использовании POSIX API.
При запуске на выполнение кода листинга 6.16 должен появиться объемный вывод с множеством разделов, имеющий примерно следующий вид: MEMORY_BASIC_INFORMATION { BaseAddress: 0x00007ffbe8d9b000, AllocationBase: 0x0000000000000000, AllocationProtect: 0, RegionSize: 17568124928, State: 65536, Protect: 1, Type: 0 } MEMORY_BASIC_INFORMATION { BaseAddress: 0x00007ffffffe0000, AllocationBase: 0x00007ffffffe0000, AllocationProtect: 2, RegionSize: 65536, State: 8192, Protect: 1, Type: 131072
(1) (2) (2) (2) (2)
(1) Эта структура определена в Windows API. (2) Эти поля - целочисленное представление перечислений (enums), определенных в Windows API. Их можно декодировать в имена еnum-вариантов, но без добавления в листинг дополнительного кода это невозможно.
В следующем листинге показаны зависимости для кода листинга 6.16. Исходный код находится в файле ch6/ch6-meminfo-win/Cargo.toml.
Память
275
Листинг 6.15. Зависимости для кода листинга 6.16 [package] name = "meminfo" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] winapi = "0.2" # kernel32-sys = "0.2" #
(1) (2)
(1) Определение нескольких полезных псевдонимов типов. (2) Предоставление взаимодействия с КERNEL.DLL из Windows АР!.
Способ проверки памяти через Windows API показан в следующем листинге. Его исходный код находится в файле ch6/ch6-meminfo-win/src/main.rs. Листинг 6.16. Просмотр памяти программы use kernel32; use winapi; use winapi::{ DWORD, HANDLE, LPVOID, PVOID, SIZE_T, LPSYSTEM_INFO, SYSTEM_INFO, MEMORY_BASIC_INFORMATION as MEMINFO, }; fn main() { let this_pid: DWORD; let this_proc: HANDLE; let min_addr: LPVOID; let max_addr: LPVOID; let mut base_addr: PVOID; let mut proc_info: SYSTEM_INFO; let mut mem_info: MEMORY_BASIC_INFORMATION;
(1) (2) (2)
(3) (4) (5) (6) (6)
(7) (7) (7) (7) (7) (7) (7)
const MEMINFO_SIZE: usize = std::mem::size_of::(); unsafe { base_addr = std::mem::zeroed(); proc_info = std::mem::zeroed();
(В)
276
Глава 6 mem_info = std::mem::zeroed(); } unsafe { this_pid = kernel32::GetCurrentProcessId(); this_proc = kernel32::GetCurrentProcess(); kernel32::GetSystemInfo( &mut proc_info as LPSYSTEM_INFO ); }; min_addr = proc_info.lpMinimumApplicationAddress; max_addr = proc_info.lpMaximumApplicationAddress;
(9)
(10) (10) (10)
(11) (11)
println!("{:?} @ {:p}", this_pid, this_proc); println!("{:?}", proc_info); println!("min: {:p}, max: {:p}", min_addr, max_addr); loop { let rc: SIZE_T = unsafe { kernel32::VirtualQueryEx( this_proc, base_addr, &mut mem_info, MEMINFO_SIZE as SIZE_T) };
(12) (13)
if rc == 0 { break } println!("{:#?}", mem_info); base_addr = ((base_addr as u64) + mem_info.RegionSize) as PVOID; } }
(1) В Rust это будет u32. (2) Типы указателей для различных внутренних АР! без связанного типа. В Rust std::os::raw::c_void определяет указатели void; а R№!DLE - указатель на какой-то закулисный ресурс в Windows. (3) В Windows к именам типов данных часто добавляется префикс, обозначакхций их тип. Р означает указатель; LP означает длинный указатель (например, 64-разрядный). (4) u64 - usize для этой машины. (5) Указатель на структуру SYSTEM_INFO. (6) Ряд структур, определяемьD( внутри Windows. (7) Инициализация этих переменНЬD( из небезопасНЬD( блоков. Для их доступности во внешней области они доJDКНЫ бьrrь определены именно здесь. (8) Этот блок гарантирует инициализацию всей памяти. (9) В этом блоке кода выполняются системные вызовы.
Память
277
(10) Вместо возвращаемого значения в этой функции используется идиома языка С для предоставления результата вызывающей стороне. Мы предоставляем указатель на некоторую предопределенную структуру, а затем, чтобы увидеть результаты, считываем новые значения этой структуры после возвращения управления из функции. (11) Переименование этих переменных с целью удобства использования. (12) Этим циклом выполняется работа по сканированию адресного пространства. (13) Предоставление информации об определенном сегменте адресного пространства памяти запущенной программы, начиная с base addr.
Итак, появилась возможность просмотра адресного пространства без принудитель ного выхода из программы, осуществляемого операционной системой. Открытым остался вопрос: как проверить и изменить отдельно взятые переменные?
6.4.5. Шаг 3. Чтение и запись в память процесса Операционные системы предоставляют инструменты для чтения и записи памяти даже из других программ. Это необходимо для ЛТ-компиляторов, отладчиков и программ, помогающих людям «мошенничать)) в играх. В Windows, если восполь зоваться псевдокодом Rust, это выглядит примерно так: let pid = some_process_id; OpenProcess(pid); loop address space { *call* VirtualQueryEx() to access the next memory segment *scan* the segment by calling ReadProcessMemory(), looking for a selected pattern *call* WriteProcessMemory() with the desired value }
В Linux благодаря АРI-функциям process_ vm_readv () и process_vm_ writev () вопрос решается еще проще. Эти функции аналогичны имеющимся в Windows функциям ReadProcessMemory() и WriteProcessMemory(). Управление памятью непростая тема, требующая раскрытия множества уровней абстракции. В этой гла ве предпринята попытка сосредоточиться на наиболее важных элементах, нужных программисту. Теперь, когда в блоге попадется очередной пост, посвященный тех нике программирования на языках низкого уровня, вы уже сможете разобраться в применяемой там терминологии.
Резюме ♦ Для центрального процессора указатели, ссьmки и адреса памяти идентичны друг другу, но на уровне языка программирования они имеют существенные различия.
278
Глава 6
♦ Строки и многие другие структуры данных реализованы с помощью базового массива, на который нацелен указатель. ♦ Понятие «интеллектуальный указатель» применяется к структурам данных, ко торые ведут себя как указатели, но при этом обладают дополнительными воз можностями. Применение таких указателей практически всегда сопряжено с до полнительными издержками. Кроме всего прочего, их данные могут включать поля длины и емкости в целочисленных выражениях, а также такие более слож ные вещи, как блокировки. ♦ В Rust есть весьма богатая коллекция типов интеллектуальных указателей. Ти пы, имеющие большее количество функций, обычно требуют более существен ных затрат времени на выполнение программы. ♦ Типы интеллектуальных указателей, имеющиеся в стандартной библиотеке, со ставлены из строительных блоков, которыми при необходимости можно вос пользоваться для определения собственных интеллектуальных указателей. ♦ Куча и стек - абстракции, предоставляемые операционными системами и язы ками программирования. На уровне центрального процессора их просто не су ществует. ♦ Для изучения поведения программы операционные системы часто предоставля ют такие механизмы, как распределение памяти.
7
Файлы и хранилища
В этой главе рассматриваются следующие вопросы: ♦ Представление данных на физических устройствах хранения. ♦ Запись структур данных в нужный формат файла. ♦ Создание инструмента для чтения из файла и проверки его содержимого. ♦ Создание рабочего хранилища типа «ключ-значение>>, защищенного от повреждений. Постоянное хранение данные на цифровых носителях дается сложнее, чем кажется. В этой главе рассматривается ряд особенностей решения этой задачи. Чтобы пере нести информацию, содержащуюся в недолговечных электрических зарядах в опе ративной памяти, на постоянный или временный носитель информации с возмож ностью ее последующего восстановления, требуется несколько уровней программ ных перенаправлений. В этой главе вводится несколько новых концепций, например рассматриваются способы структурирования проектов в контейнеры библиотек, предназначенных для Rust-разработчиков. Решение этой задачи обусловлено амбициозностью одного из проектов. К концу главы будет создано рабочее хранилище типа «ключ значение)) с гарантированной устойчивостью к сбоям оборудования на любом эта пе. В ходе изучения главы будет проработан ряд сопутствующих интересных задач. К примеру, будет реализована битовая проверка четности и исследовано хеширова ние значений. Но для начала давайте выясним, можно ли создать в файлах комби нации из простых последовательностей байтов.
7 .1. Что такое формат файла? Форматы файлов - это стандарты для работы с данными как с единой упорядо ченной последовательностью байтов. Носители данных, например жесткие диски, работают быстрее при последовательном чтении или записи крупных блоков дан ных. Это существенно отличается от работы со структурами данных в памяти, где компоновка данных оказывает меньшее влияние на процесс. Форматы файлов характеризуются широким разнообразием построения занимае мых пространств с различными компромиссами между производительностью, удо бочитаемостью и переносимостью. Одни форматы обладают легкой переносимо стью и возможностью самостоятельного раскрытия своей сущности. Другие же ограничиваются доступностью в какой-нибудь одной среде и не могут быть прочи-
280
Глава 7
таны сторонними инструментами, но при этом обеспечивают высокую производи тельность работы с ними. В таблице 7 .1 показано, как выглядят пространства, занимаемые файлами ряда форматов. В каждой строке таблицы показан внутренний узор форматов файлов, созданных из одного и того же исходного текста. Закодировав цветом каждый имеющийся в файле байт, можно увидеть структурные различия между всеми представлениями. Таблица 7.1. Внутренний вид цифровых версий книги Ушzьяма Шекспира «Много шума из ничего», созданных электронной библиотекой проекта «Гуттенберг».
Простая текстовая версия пьесы содержит только печатные символы, которые просматриваются в виде темно-серых пятен для букв и знаков препинания и белых пятен для пробелов. Визуально изображение похоже на какую-то зашум ленность. В нем нет внутренней структуры. Это связа но с различием в длине слов естественного языка, которыми представлен файл. Если бы это был файл с постоянно повторяющейся структурой, например с форматом, предназначенным для хранения массивов чисел с плавающей запятой, то он бы выглядел совершенно иначе. Формат EPUB фактически представляет собой сжатый ZIР-архив с уникальным расширением имени файла. В файле много байтов, выпадающих из категории вы водимых на печать, о чем свидетельствует наличие светло-серых пикселей. Формат MOBI включает четыре полосы нулевых байтов (ОхОО), представленных черными пикселями. Эти полосы, скорее всего, представляют собой резуль тат компромисса, на который пошли разработчики. Пустые байты можно посчитать потраченным впустую пространством. Но, вероятно, они добавлены как отступы, чтобы в дальнейшем можно было легко проанализировать разделы файла. Еще одна примечательная особенность этого файла его размер. Он значительно больше, чем у других вер сий пьесы. Это может означать, что в файле содержит ся не только текст. Варианты включают в себя элемен ты отображения, например шрифты или ключи шиф рования, обеспечивающие защиту от копирования информации, содержащейся в файле.
Файлы и хранилища
281
Таблица 7.1 (окончание) НТМL-файл содержит большое количество пробелов. Они обозначены белыми пикселями. В языках размет ки, к которым относится и HTML, пробелы обычно добавляются для облегчения чтения кода.
7 .2. Создание собственных форматов файлов для хранения данных При работе с данными, предназначенными для длительного хранения, правильнее было бы воспользоваться проверенной базой данных. И все же во многих системах для хранения данных используются простые текстовые файлы. Напри�ер, при про ектировании файлов конфигурации закладывается возможность их чтения как че ловеком, так и машинами. В экосистеме Rust имеется довольно мощная поддержка преобразования данных во многие дисковые форматы.
7.2.1. Запись данных на диск с помощью serde и формата blncode Контейнер serde выполняет сериализацию и десериализацию Rust-значений, поме щая их во многие форматы и извлекая их обратно. У каждого формата свои пре имущества: многие из них обладают удобочитаемостью, в то время как другие соз даны с прицелом на компактность, позволяющую добиться высокоскоростной пе редачи по сети. Как ни удивительно, исn:ользование контейнера serde не требует особых церемо ний. Возьмем, к примеру, статистику о нигерийском городе Калабар и сохраним ее в нескольких выходных форматах. Предположим для начала, что в нашем коде со держится структура City. Контейнер serde предоставляет типажи Serialize и De serialize, и основная часть кода реализует их с помощью следующей производной аннотации: #[derive(Serialize)] struct City { name: String, population: usize, latitude: f64,
(1)
282
Глава 7
longitude: f64, } (1) Предоставление инструментов, позволяющих внешним форматам взаимодействовать с кодом Rust.
Заполнение этой структуры данными о Калабаре не вызывает затруднений. Реали зация показана в следующем фрагменте кода: let calabar = City { name: String::from("Calabar"), population: 470_000, latitude: 4.95, longitude: 8.33, };
Теперь преобразуем переменную calabar в значение типа string с кодировкой JSON. Преобразование займет всего одну строку кода: let as_json = to_json(&calabar).unwrap();
Арсенал форматов в контейнере serde намного шире простого JSON. В коде лис тинга 7.2 (показан далее в этом разделе) есть аналогичные примеры для двух менее известных форматов: CBOR и Ьincode. Они компактнее JSON, но за счет того, что их доступность ограничивается исключительно машинным чтением. Ниже показан результат, отформатированный для размещения на странице, выда ваемой при выполнении кода листинга 7.2. Он обеспечивает просмотр байтов пе ременной calab a r в нескольких кодировках: $ cargo run Compiling ch7-serde-eg v0.1.0 (/rust-in-action/code/ch7/ch7-serde-eg) Finished dev [unoptimized + debuginfo] target(s) in 0.27s Running `target/debug/ch7-serde-eg` json: {"name":"Calabar","population":470000,"latitude":4.95,"longitude":8.33} cbor: [164, 100, 110, 97, 109, 101, 103, 67, 97, 108, 97, 98, 97, 114, 106, 112, 111, 112, 117, 108, 97, 116, 105, 111, 110, 26, 0, 7, 43, 240, 104, 108, 97, 116, 105, 116, 117, 100, 101, 251, 64, 19, 204, 204, 204, 204, 204, 205, 105, 108, 11, 110, 103, 105, 116, 117, 100, 101, 251, 64, 32, 168, 245, 194, 143, 92, 41] bincode: [7, 0, 0, 0, 0, 0, 0, 0, 67, 97, 108, 97, 98, 97, 114, 240, 43, 7, 0, 0, 0, 0, 0, 205, 204, 204, 204, 204, 204, 19, 64, 41, 92, 143, 194, 245, 168, 32, 64] json (as UTF-8): {"name":"Calabar","population":470000,"latitude":4.95,"longitude":8.33} cbor (as UTF-8): �dnamegCalabarjpopulation+�hlatitude�@������ilongitude�@ ��\) bincode (as UTF-8): Calabar�+������@)\���� @
Файлы и хранилища
283
Чтобы скачать проект, введите в консоли следующие команды: $ git clone https://github.com/rust-in-action/code rust-in-action $ cd rust-in-action/ch7/ch7-serde-eg
Для самостоятельного создания проекта сформируйте структуру каталогов, похо жую на следующий фрагмент, и заполните ее содержимым, взятым из листингов 7.1 и 7.2, находящихся в каталоге ch7/ch7-serde-eg: ch7-serde-eg ├── src │ └── main.rs └── Cargo.toml (1)
См. листинг 7. 2.
(2)
См. листинг 7 .1.
(1) (2)
Листинг 7.1. Объявление зависимостей и установка метаданных для кода листинга 7.2 [package] name = "ch7-serde-eg" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] bincode = "1" serde = "1" serde_cbor = "0.8" serde_derive = "1" serde_json = "1"
Листинг 7.2. Сериализация Rust-структуры в несколько форматов 1 2 3 4 5 6 7 8 9 10 11 12 13 14
use use use use
bincode::serialize as to_bincode; serde_cbor::to_vec as to_cbor; serde_json::to_string as to_json; serde_derive::{Serialize};
#[derive(Serialize)] struct City { name: String, population: usize, latitude: f64, longitude: f64, } fn main() {
(1) (1) (1) (2)
284
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 }
Глава 7
let calabar = City { name: String::from("Calabar"), population: 470_000, latitude: 4.95, longitude: 8.33, }; let as_json = to_json(&calabar).unwrap(); let as_cbor = to_cbor(&calabar).unwrap(); let as_bincode = to_bincode(&calabar).unwrap();
(3) (3) (3)
println!("json:\n{}\n", &as_json); println!("cbor:\n{:?}\n", &as_cbor); println!("bincode:\n{:?}\n", &as_bincode); println!("json (as UTF-8):\n{}\n", String::from_utf8_lossy(as_json.as_bytes()) ); println!("cbor (as UTF-8):\n{:?}\n", String::from_utf8_lossy(&as_cbor) ); println!("bincode (as UTF-8):\n{:?}\n", String::from_utf8_lossy(&as_bincode) );
(1) Эти функции переименованы для краткости строк, в которых они используются. (2) Предписание контейнеру serde_derive выдать код, необход;имый для вьmолнения преобразования из City в памяти в City на диске. (3) Сериа.лизация в разные форматы.
7.3. Реализация клона hexdump Удобная утилита для проверки содержимого файла - hexdump, получающая поток байтов зачастую из файла, а затем выводящая эти байты в виде пар шестнадцате ричных чисел. Пример показан в табл. 7 .2. Как известно из предыдущих глав, дву мя шестнадцатеричными числами могут быть представлены все десятичные числа от О до 255, то есть все комбинации битов в одном байте. Назовем наш клон fview (то есть, сокращенно, просмотр файлов). Таблица 7.2. Утилита fviеwвдействии fview-ввод
fn main() { println!("Hello, world!"); }
Файлы и хранилища
285
Таблица 7.2 (окончание)
Fviеw-вывод [ОхОООООООО] Оа 66 6е 20 6d 61 69 6е 28 29 20 7Ь Оа 20 20 20 [Ох00000010] 20 70 72 69 6е 74 6с 6е 21 28 22 48 65 6с 6с 6f [Ох00000020] 2с 20 77 6f 72 6с 64 21 22 29 ЗЬ Оа 7d Тем, кто не привык к шестнадцатеричной системе записи, вывод fview может быть непонятен. А читатели с соответствующим опытом смогут заметить отсутствие байтов со значением выше Ох7е (127). Также, за исключением значения охоа (10), имеется несколько байтов со значением меньше Ох21 (33). А значение охоа - сим вол новой строки (\n). Эти комбинации байтов служат признаками того, что источ ник ввода - обычный текст. В листинге 7.4 представлен исходный код, из которого собирается полная версия fview. Но, чтобы перейти к полноценной программе, необходимо добавить не сколько новых Rust-функций, для чего придется выполнить ряд дополнительных шагов. Начнем с листинга 7.3, в котором в качестве входных данных используется строко вый литерал, а на выходе получаются данные, показанные в таблице 7.2. В нем де монстрируется использование многострочных строковых литералов, возможное благодаря импортированию типажа std: :io через std: :io: :prelude. В результате при использовании свойства std:: io::Read появляется возможность чтения в каче стве файлов значений, имеющих тип & [u8J. Исходный код листинга находится в файле ch7/ch7-fview-str/src/main.rs. Листинг 7.3. Клон утилиты he dump, имитирующий файловый ввод-вывод и имеющий жестко закодированный ввод. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
use std::io::prelude::*; const BYTES_PER_LINE: usize = 16; const INPUT: &'static [u8] = br#" fn main() { println!("Hello, world!"); }"#; fn main() -> std::io::Result { let mut buffer: Vec = vec!(); INPUT.read_to_end(&mut buffer)?; let mut position_in_input = 0; for line in buffer.chunks(BYTES_PER_LINE) { print!("[0x{:08x}] ", position_in_input); for byte in line { print!("{:02x} ", byte); }
(1)
(2)
(3) (4)
(5)
286
Глава 7
19 20 21 22 23 24 }
(6)
println!(); position_in_input += BYTES_PER_LINE; } Ok(())
(1) prelude импортирует часто используемые в операциях ввода-вывода свойства чтения и записи (Read и Write). Типажи можно включать и самостоятельно, но ввиду их распространенности стандартная библиотека предоставляет эту удобную кодовую строку, чтобы помочь сохранить компактность записи кода. (2) Если многострочные строковые литералы составлены из простых строковых литералов (с префиксом r и разделителями#), то нейтрализация двойных кавычек им не нужна. Дополнительный префикс Ь указывает, что литерал следует рассматривать как байты (&[u8]), а не как текст UTF-8 (&str). (3) Освобождение места под внутренний буфер для данных, вводимых в программу. (4) Считывание введенных данных и вставка их во внутренний буфер. (5) Запись текущей позиции с заполнением слева до 8 нулей. (6) Сокращение для выдачи на устройство стандартного вывода символа новой строки.
Теперь, посмотрев на предполагаемые действия нашего клона f v i ew, давайте рас ширим его возможности для чтения реальных файлов. В следующем листинге представлена основа клона утилиты hexdump, где демонстрируется способ откры тия файла в Rust и просмотра его содержимого. Исходный код находится в файле ch7/ch 7-fview/src/main.rs.
Листинг 7.4. Открытие файла в Rust и последовательный перебор его содержимого 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
use std::fs::File; use std::io::prelude::*; use std::env; const BYTES_PER_LINE: usize = 16;
(1)
fn main() { let arg1 = env::args().nth(1); let fname = arg1.expect("usage: fview FILENAME"); let mut f = File::open(&fname).expect("Unable to open file."); let mut pos = 0; let mut buffer = [0; BYTES_PER_LINE]; while let Ok(_) = f.read_exact(&mut buffer) { print!("[0x{:08x}] ", pos); for byte in &buffer { match *byte {
Файлы и хранилища
20 21 22 23 24 25 26 27 28 29 }
287
0x00 => print!(". "), 0xff => print!("## "), _ => print!("{:02x} ", byte), } } println!(""); pos += BYTES_PER_LINE; }
(1) Изменение этой константы меняет внешний вид вывода программы.
В листинге 7.4 представлены новые конструкции языка Rust. Давайте рассмотрим, что собой представляет часть этих конструкций: •
while let Ok (_) { ... ) - с помощью этой структуры управления ходом вы полнения программы цикл продолжается до тех пор, пока f. read_exact () не вернет Err, что случится, когда закончатся байты для чтения.
• f. read_exact () - метод из типажа Read, передающий данные из источника (в данном случае f) в буфер, предоставленный в качестве аргумента. Его ра бота завершается при заполнении буфера. По сравнению с методом chunks (), который использовался в коде листинга 7.3, метод f. read_exact () расширяет возможности программиста по управлению па мятью, но имеет ряд особенностей. Если размер буфера превышает количество бай тов, доступных для чтения, метод возвращает ошибку, а состояние буфера остается неопределенным. Код листинга 7.4 также имеет ряд стилистических дополнений: • Для обработки аргументов командной строки без использования сторонних библиотек в нем используется функция std: :env: :args (). Она возвращает итератор для последовательного перебора аргументов, предоставленных про грамме. У итераторов есть метод nth (), извлекающий элемент, находящийся в п-й позиции. • Каждый метод итератора nth () возвращает option. Когда п превышает длину итератора, возвращается None. Для обработки значений Option исполь зуются вызовы метода expect (). • Метод expect (J считается более удобной версией метода unwrap (J. Метод expect () получает в качестве аргумента сообщение об ошибке, а метод unwrap () просто внезапно впадает в панику. Непосредственное использование функции std: : env: : args () означает, что ввод не проверяется. Для нашего простого примера это несущественно, но в более круп ных программах эту проблему следует учитывать.
288
Глава 7
7.4. Файловые операции, проводимые в Rust Пока основной упор в этой главе делался на преобразование данных в последова тельность байтов. Давайте уделим немного внимания другому уровню абстрак ции - файлу. Основные операции с файлами - их открытие и чтение - мы рас смотрели в предыдущих главах. В этом разделе изучается ряд других полезных ме тодов, обеспечивающих более тонкую работу с файлами.
7 .4.1. Открытие файла в Rust и управление его режимом доступности Файлы - абстракция, поддерживаемая операционной системой. С ее помощью над скоплением простых байтов выстраивается фасад из имен и иерархий. Файлы также предоставляют свой уровень безопасности. К ним прилагаются раз решения, которые обеспечивает операционная система. Это (по крайней мере, тео ретически) именно то, что мешает веб-серверу, работающему под собственной учетной записью пользователя, читать файлы, принадлежащие кому-то другому. Основной тип для работы с файловой системой - std:: fs::File. Для создания файла доступны два метода: open () и crea te () . Если известно, что файл уже суще ствует, используется open () . Различия методов представлены в таблице 7.3. Таблица 7.3. Создание значений Fi 1 ев Rust и оказание влияния на базовую файловую систему
Метод
Возвращаемое значение, если файл уже существует
Влияние на основной файл
File::open
Ok(File)*
Открыт в исходном виде в режиме ТОЛЬКО для чтения.
File::create
Ok(File)*
Все существующие байты усекаются, и происходит открытие в начале нового файла.
Возвращаемое значение, если файл не существует Err
Ok(File)*
* Предполагается, что пользователю с данной учетной записью разрешены все эти действия.
Если нужен более жесткий контроль, можно воспользоваться функцией std:: fs::Openoptions. Он позволяет подстроиться под любое предполагаемое применение. В листинге 7.16 представлен рабочий пример запроса на включение режима добавления. Приложению нужен открытый для записи файл, доступный также для чтения, и если его еще не существует, он создается. Ниже показана часть кода из листинга 7.16, где для создания записываемого файла показано использова ние функции std::fs::OpenOptions. При открытии файл не усекается.
Файлы и хранилища
289
Листинг 7.5. Использование std::fs::OpenOptions для создания записываемого файла let f = OpenOptions::new()
(1)
.read(true) .write(true) .create(true)
(2)
.append(true) .open(path)?;
(5) (6)
(3) (4)
(1) Пример шаблона Builder, где каждый: метод возврашает новый экзеМI1ЛЯр структуры OpenOptions с соответствующим набором параметров. (2) Открытие файла для чтения. (3) Разрешение записи. Эта необя зательная строка, но здесь она нужна для предполагаемого добавления данных в файл. (4) Создание файла в текущем пути, если файл еще не существует. (5) Предотвращение удаления содержимого, уже записанного на дИСК. (6) Открытие файла в текущем пути после распаковки промежуточного значения Res ult.
7 .4.2. Безопасное взаимодействие с файловой системой с помощью std::fs::Path В стандартной библиотеке Rust существуют типобезопасные варианты str и String: std::path::Path и std::path::PathBuf . Ими можно воспользоваться для четко обозначенной кроссплатформенной работы с разделителями путей. Path по зволяет указывать адреса файлов, каталогов и связанных с ними абстракций, таких как символические ссылки. Значения Path и PathBuf поначалу зачастую являются простыми строковыми типами, допускающими преобразование с помощью стати ческого метода f rom ( ) : let hello = PathBuf::from("/tmp/hello.txt")
А потом взаимодействие с этими вариантами раскрывает методы, специфичные для путей: hello.extension()
(1)
(7) Возврашает Some("txt")
Для тех, кто раньше использовал программный код для управления путями, обраще ние со всем АРI-интерфейсом не представляет особой сложности, поэтому вдаваться в лишние подробности мы здесь не станем. Но все же, наверное, стоит разобраться с тем, почему он включен в язык, ведь во многих других он просто отсутствует.
ПРИМЕЧАНИЕ
Если разбираться с тонкостями реализации std::fs::Path и std::fs::PathBuf, то выяснится, что они, соответственно, являются надстройками над std::f f i::osstr и std::f f i::osstring. То есть, Path и PathBuf не гарантируют совместимости с UTF-8.
290
Глава 7
Зачем же использовать Path вместо прямого управления строками? На это есть ряд веских причин: • Четко выражеююе намерение. В Path содержатся такие полезные методы, как set_e xtension (), которые описывают предполагаемый результат. Дру гим программистам это может упростить чтение кода. А простое управление строками не обеспечивает такого уровня самодокументирования. • Переносимость. В одних операционных системах пути файловой системы считаются нечувствительными к регистру, а в других - нет. Использование соглашений, действующих в одной операционной системе, может привести к проблемам позже, когда пользователи понадеются на соблюдение соглаше ния, действующего на их хост-системе. Кроме того, у разных операционных систем могут быть разные разделители путей. То есть использование простых строк может привести к проблемам переносимости программ. Для выполне ния сравнения требуются точные совпадения. • Упрощение отладки. Попытка простого извлечения / tmp из пути /tmp/h e l lo. txt может привести к появлению мелких ошибок, проявляю щихся только в ходе выполнения программы. К тому же некорректный под счет числовых значений индекса после разделения строки символами / при водит к ошибке, которую невозможно отловить в ходе компиляции. Чтобы проиллюстрировать скрытые ошибки, рассмотрим случай работы с раздели телями. Применение слэшей внедрялось в современные операционные системы по разному и в разные времена: • \ обычно используется в MS Windows. • / характерен для UNIХ-подобных операционных систем. • : был разделителем пути в классической Мае OS. • > использовался в операционной системе Stratus VOS. Сравнение применения двух строк, std: :String и std: :path: : Path, показано в табл. 7.4. Таблица 7.4. Использование std: :String и std: :path:: Path для извлечения родительского каталога файла fn main() { let hello = String::from("/tmp/ hello.txt"); let tmp_dir =
(1)
hello.split("/").nth(0); println!("{:?}", tmp_dir); }
(2)
(1) Разбиение hello по обратным слэшам с последующим извлечением нулевого элемента получившегося вектора Vec
use std::path::PathBuf; fn main() { let mut hello = PathBuf::from("/tmp/hello.txt"); hello.pop(); println!("{:?}", hello.display()); }
(1) Отбрасывание hello из исходного значения
(1) (2)
Файлы и хранилища
291
Таблица 7.4 (окончание) (2) Ошибка ! На выходе получается Some ( 11 11 )
(2) Успех! На выходе получается 11 /tmp 11
Простой код с применением String позволяет использовать знакомые методы, но чреват внесением мелких ошибок, незаметных в ходе компиляции. В данном случае для доступа к родительскому каталогу (/tmp) использовался неверный номер индекса.
Использование path::Path не придает коду невосприимчивость к скрытым ошибкам, но, несомненно, способствует сведению вероятности их появления к минимуму. Path предоставляет специальные методы для самых распространенных операций, например для установки расширения файла.
7 .5. Реализация хранилища «ключ-значение)) с архитектурой, структурированной по записям и доступной только для добавления Настало время для более серьезных занятий. Давайте приоткроем завесу техноло гий баз данных. Попутно изучим внутреннюю архитектуру семейства систем баз данных, используя модель, структурированную по записям (log-structured) и пред назначенную только для добавления (append-only). Системы баз данных с лог-структурой и только с добавлением имеют важное зна чение в качестве конкретных примеров, поскольку они спроектированы с прицелом на исключительную устойчивость и предложение оптимальной производительно сти чтения. Несмотря на то, что данные хранятся на непостоянных носителях, на пример, на флэш-накопителях или классических жестких дисках, базы данных, ис пользующие эту модель, могут гарантировать абсолютную сохранность данных, а также невредимость файлов данных из резервных копий.
7.5.1. Модель «ключ-значение» Хранилище «ключ-значение» под названием actionkv, реализуемое в этой главе, предназначено для хранения и извлечения последовательности байтов ( [ив J ) про извольной длины. Каждая последовательность состоит из двух частей: ключа и значения. Поскольку внутреннее представление типа &str - [uBJ, в таблице 7.5 показана текстовая нотация, а не ее двоичный эквивалент. Таблица 7.5. Ключи и значения, сопоставляющие страны с их столицами.
Ключ 11 Coo k Islands11
Значение 1 "Avarua
11Fiji 11
11 Suv 11
1
a
Ключ 1
ti11
1 Kiriba
11Niue11
Значение 1 Sou th Tarawa11 1
1
Alofi 1
1
1
292
Глава 7
Модель «ключ-значение» позволяет выполнять простые запросы, например: «Сто лица Фиджи?», но не позволяет оперировать расширенными вариантами типа: «Как выглядит список столиц всех тихоокеанских островных государств?>>
7.5.2. Представление actionkv v1: хранилище ключей и значений в памяти с интерфейсом командной строки Первая версия нашего хранилища ключей и значений, actionkv, предоставляет нам API, который будет использоваться далее в этой главе, а также вводит в обиход ос новной код библиотеки, который не будет изменяться, поскольку на нем построены следующие две системы. Но, до перехода к этому коду, необходимо рассмотреть ряд предварительных условий. В отличие от других проектов, представленных в этой книге, в этом проекте снача ла используется шаблон библиотеки (cargo new --lib actionkv), имеющий сле дующую структуру: actionkv ├── src │ ├── akv_mem.rs │ └── lib.rs └── Cargo.toml
Использование контейнера библиотеки позволяет программистам создавать в своих проектах многократно используемые абстракции. В наших целях для нескольких исполняемых файлов будет использоваться один и тот же файл lib.rs. Чтобы в бу дущем не возникало путаницы, необходимо дать описание исполняемым двоичным файлам, создаваемым в рамках проекта actionkv. С этой целью, как показано в строках 14--16 следующего листинга, название разде ла Ьin в файле проекта Cargo.toml нужно заключить в парные квадратные скобки ([ [ЬinJ J). Две квадратные скобки указывают на то, что раздел можно повторять. Исходный код листинга находится в файле ch7/ch7-actionkv/Cargo.toml. Листинг 7.6. Определение зависимостей и других метаданных 1 2 3 4 5 6 7 8 9 10
[package] name = "actionkv" version = "1.0.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] byteorder = "1.2" crc = "1.7"
(1) (2)
Файлы и хранилища
11 12 13 14 15 16 17
293
[lib] name = "libactionkv" path = "src/lib.rs"
(3) (3) (3)
[[bin]] name = "akv_mem" path = "src/akv_mem.rs"
(4)
(1) Расширение типов Rust дополнительными типажами для их записи на диск с последуJОЩИМ их считыванием обратно в программу повторяемым и простым в использовании способом. (2) Предоставление желаемой функции контрольной суммы. (3) В этом разделе Cargo.toml можно указать имя создаваемой библиотеки. Следует учесть, что в контейнере может быть только одна библиотека. (4) Раздел [[Ьin]], которЬD{ может быть много, определяет исполняемый файл, созданный из этого контейнера. Синтаксис двойной квадратной скобки необходим, поскольку он четко описывает Ьin как раздел, имеющий один или несколько элементов.
В нашем проекте actionkv будет несколько файлов. На рис. 7.1 показана их взаимо связанность и порядок совместной работы для создания исполняемого файла akv_mem, упомянутого в разделе [ [ЬinJ J файла Cargo.toml этого проекта.
Cargo.toml
Даёт описание
src/lib.rs
src/akv_mem.rs
Компилируется как
Компилируется как
byteorder crc
Импортируется
libactionkv
Импортируется
Внешние контейнеры
avk_mem[.exe]
Артефакт финальной сборки
Рис. 7.1. Схема совместной работы в проекте actionkv разных файлов и их зависимостей. Относящийся к проекту файл Cargo.toml координирует множество действий, которые в конечном итоге приводят к созданию исполняемого файла.
7 .6. Actionkv v1: интерфейсный код Открытый АРI-интерфейс actionkv состоит из четырех операций: получения, уда ления, вставки и обновления. Все они представлены в таблице 7.6.
294
Глава 7
Таблица 7.6. Операции, поддерживаемые actionkv v1
Команда
Описание
get
Извлекает значение ключа из хранилища
insert
Добавляет в хранилище пару «ключ-значение»
delete
Удаляет из хранилища пару «ключ-значение))
u pd at e
Заменяет старое значение новым
Трудности с наименованиями Что должен API предоставлять при доступе к сохраненным парам «ключ значение)>: get, ret rieve или, может быть, fetch? А что предоставлять для значе ний настроек: i nsert, st ore или set? На этот счет actionkv держит нейтралитет, перекладывая подобные решения на АРI-интерфейс, предоставляемый s td::collec tions::HashMap.
В следующем листинге, являющемся частью листинга 7.8, показаны соображения по наименованиям, упомянутым в предыдущей врезке. В нашем проекте для эф фективной работы с аргументами командной строки и для попадания в нужную внутреннюю функцию используются имеющиеся в Rust средства сопоставления. Листинг 7.7. Демонстрация общедоступного API 32 match action { 33 "get" => match store.get(key).unwrap() { 34 None => eprintln!("{:?} not found", key), 35 Some(value) => println!("{:?}", value), 36 }, 37 38 "delete" => store.delete(key).unwrap(), 39 40 "insert" => { 41 let value = maybe_value.expect(&USAGE).as_ref(); 42 store.insert(key, value).unwrap() 43 } 44 45 "update" => { 46 let value = maybe_value.expect(&USAGE).as_ref(); 47 store.update(key, value).unwrap() 48 } 49 50 _ => eprintln!("{}", &USAGE), 51 }
(1) Аргумент командной строки, задающий действие, имеет тиn &str.
(1)
(2)
(3)
Файлы и хранилища
295
(2) В макросе println! необходимо использовать синтаксис отладки ({:?I), потому что в типе [u8] содержатся произвольные байты и отсутствует реализация Display. (3) Это будущее обновление, которое можно будет добавить для совместимости с имекщимся в Rust HashМap, где insert возвращает старое значение, если оно существует.
Полностью код actionkv vl представлен в листинге 7.8. Следует заметить, что вся нелегкая работа по взаимодействию с файловой системой переложена на экземШIЯр A ctionкv, называемый sto r e. Работа Actionкv рассматривается в разделе 7.7. Ис ходный код листинга находится в файле ch7/ch7-actionkv1/src/akv_mem.rs. Листинг 7.8. Приложение командной строки, представляющее собой хранилище ключей и значений в памяти 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
use libactionkv::ActionKV; #[cfg(target_os = "windows")] const USAGE: &str = " Usage: akv_mem.exe FILE get KEY akv_mem.exe FILE delete KEY akv_mem.exe FILE insert KEY VALUE akv_mem.exe FILE update KEY VALUE ";
(1) (2) (2) (2) (2)
(2) (2) (2)
#[cfg(not(target_os = "windows"))] const USAGE: &str = " Usage: akv_mem FILE get KEY akv_mem FILE delete KEY akv_mem FILE insert KEY VALUE akv_mem FILE update KEY VALUE "; fn main() { let args: Vec = std::env::args().collect(); let fname = args.get(1).expect(&USAGE); let action = args.get(2).expect(&USAGE).as_ref(); let key = args.get(3).expect(&USAGE).as_ref(); let maybe_value = args.get(4); let path = std::path::Path::new(&fname); let mut store = ActionKV::open(path).expect("unable to open file"); store.load().expect("unable to load data"); match action {
296
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 } 52 }
Глава 7
"get" => match store.get(key).unwrap() { None => eprintln!("{:?} not found", key), Some(value) => println!("{:?}", value), }, "delete" => store.delete(key).unwrap(), "insert" => { let value = maybe_value.expect(&USAGE).as_ref(); store.insert(key, value).unwrap() } "update" => { let value = maybe_value.expect(&USAGE).as_ref(); store.update(key, value).unwrap() } _ => eprintln!("{}", &USAGE),
(1) Несмотря на присутствие src/lib.rs в нашем проекте, он считается таким же контейнером, как и любой другой контейнер в файле src/bin.rs. (2) Атрибут cfg позволяет пользователям Windows видеть в своей справочной документации правильное расширение файла. Этот атрибут рассматривается в следуюцем разделе.
7.6.1. Настройка продукта условной компиляции В Rust предоставляются широкие возможности изменения продукта компиляции в зависимости от заданной компилятору целевой архитектуры. Как правило, речь идет о целевой операционной системе, но можно воспользоваться и возможностя ми, предоставляемыми целевым процессором. Изменение продукта компиляции в зависимости от заданных условий самого процесса компиляции называют условной компиляцией. Для добавления в проект условной компиляции нужно аннотировать исходный код атрибутами cfg. Атрибут cfg работает вместе с целевым параметром, предостав ляемым rustc в ходе компиляции. В листинге 7.8 в качестве краткой документации по утилитам командной строки для нескольких операционных систем предоставляется стандартная строка исполь зования. Она повторяется в следующем листинге, где для предоставления в коде двух определений const USAGE используется условная компиляция. Когда проект создается под Windows, строка использования содержит расширение файла .ехе. В получаемые на выходе двоичные файлы включаются только те данные, которые имеют отношение к их целевому назначению.
Файлы и хранилища
297
Листинг 7.9. Демонстрация использования условной компиляции 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
#[cfg(target_os = "windows")] const USAGE: &str = " Usage: akv_mem.exe FILE get KEY akv_mem.exe FILE delete KEY akv_mem.exe FILE insert KEY VALUE akv_mem.exe FILE update KEY VALUE "; #[cfg(not(target_os = "windows"))] const USAGE: &str = " Usage: akv_mem FILE get KEY akv_mem FILE delete KEY akv_mem FILE insert KEY VALUE akv_mem FILE update KEY VALUE ";
Оператора
отрицания
для
этих сопоставлений нет. То есть код работать не будет. Вместо этого для указания со ответствия используется синтаксис, похожий на вызов функции. Для отрицания следует использовать выражение # [cfg (not(...)) J. Для сопоставления с элемен тами списка доступны также выражения # [cfg (all (...)) J и # [cfg (any (...)) J. Кроме всего этого, атрибуты cfg можно настроить при вызове cargo или rustc с по мощью аргумента командной строки --cfg ATTRIBUTE. Перечень условий, которые могут вызвать изменения продукта компиляции, весьма широк. В таблице 7. 7 дано описание лишь некоторых из них. #[cfg(target_os!="windows")]
Таблица 7. 7. Параметры, доступные для сопоставления с атрибутами cfg Атрибут
Допустимые параметры
Примечания
target_ arch
aarch64, a r m, mips, powe rp c, p owerpc64, х86, х86-64
Неполный перечень.
targ et_os
target-family
ta rget_ env
target-endian
and roid, Ьitrig, d ragonfly, freebsd, haiku, ios, linux, m acos, netbsd, redox, openbsd, windows
Неполный перечень.
unix, windows
"" , g nu, msvc, musl
Ьig, little
Зачастую указывается пустая строка("").
298
Глава 7
Таблица 7. 7 (окончание)
Атрибут
Допустимые параметры
target_pointer-width
32, 64
target_has_atomic
8, 16, 32, 64, ptr
target_vendor
apple, ре, unknown
test
debug_assertions
Примечания Размер (в битах) указателя целевой архитектуры. Используется для типов isize, usize, *const и *mut. Целочисленные размеры, поддерживаемые для атомарных операций. В ходе атомарных операций на центральный процессор возлагается за предотвращение условий гонки при совместном использовании данных ценой снижения производительности. Слово «атомарная» в отношении понятия операции используется в смысле «неделимая». Без вариантов; просто используется простая проверка на булево значение. Без вариантов; просто используется простая проверка на булево значение. Этот атрибут предназначается для неоптимизированных сборок и используется для поддержки макроса debug_assert !
7.7. Понимание сути actionkv: контейнер libactionkv Приложение командной строки, созданное в разделе 7.6, направляет свой продукт структуре libactionkv: :Actionкv. На Actionкv возлагается управление взаимо действием с файловой системой, а также кодирование данных в дисковый формат и декодирование их из него. Отношения между компонентами показаны на рис. 7.2.
7.7.1. Инициализация структуры ActionKV Листинг 7.10 - часть листинга 7.8, показывающая процесс инициализации libac tionkv: : ActionKV.
Файлы и хранилища
299 end user
Взаимодействует с akv_mem[.exe]
Компилируется из src/bin.rs
Импортируется libactionkv
Компилируется из src/lib.rs
Рис. 7.2. Отношения между libactionkv и другими компонентами проекта actionkv
Для создания экземпляра libac tionkv::Actionкv нужно сделать следующее: 1. Указать файл, где хранятся данные 2. Загрузить из данных файла индекс внутренней памяти Листинг 7.10. Инициализация libactionkv::ActionKV 30 let mut store = ActionKV::open(path) 31 .expect("unable to open file"); 32 33 store.load().expect("unable to load data");
(1) (2)
(1) Открытие файла по указанному пути path (2) Создание индекса в памяти путем загрузки данных из файла, указанного в пути path
При выполнении обоих шагов возвращается значение типа Resul t, с чем связано наличие вызова метода . expec t (). А теперь взглянем на код Actionкv::open () и ActionKV:: load (). Метод open () открывает файл, сохраненный на диске, а метод load () загружает смещения на любые ранее имевшиеся данные в индекс памяти. В коде используются два псевдонима: ByteStr и ByteStr ing: type ByteStr = [u8];
Псевдоним ByteStr применяется для данных, которые обычно используются в ка честве строки, но при этом имеют двоичную форму (обычных байтов). Его тексто вый эквивалент - встроенный тип str. В отличие от str, ByteStr не гарантирует присутствие в своем содержимом надлежащего текста в кодировке UTF-8. Как str, так и [u8J (или его псевдоним Bytestr), рассматриваются в естественной среде в виде &str и & [u8J (или &Bytestr). И те, и другие называются слайсами. type ByteString = Vec;
300
Глава 7
Псевдоним Bytestring будет востребован, когда потребуется воспользоваться ти пом, сходным по поведению с типом S t r ing. В нем также могут содержаться про извольные двоичные данные. Использование Actionкv: : open( J показано в сле дующем листинге, являющемся частью листинга 7.16. Листинг 7.11. Использование ActionKV::open() 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
type ByteString = Vec;
(1)
type ByteStr = [u8];
(2)
#[derive(Debug, Serialize, Deserialize)] pub struct KeyValuePair { pub key: ByteString, pub value: ByteString, }
(3)
#[derive(Debug)] pub struct ActionKV { f: File, pub index: HashMap, }
(4)
impl ActionKV { pub fn open(path: &Path) -> io::Result { let f = OpenOptions::new() .read(true) .write(true) .create(true) .append(true) .open(path)?; let index = HashMap::new(); Ok(ActionKV { f, index }) }
79 pub fn load(&mut self) -> io::Result { 80 81 let mut f = BufReader::new(&mut self.f); 82 83 loop { 84 let position = f.seek(SeekFrom::Current(0))?; 85 86 let maybe_kv = ActionKV::process_record(&mut f); 87 88 let kv = match maybe_kv { 89 Ok(kv) => kv, 90 Err(err) => {
(5)
(6) (7)
Файлы и хранилища
91 match err.kind() { 92 io::ErrorKind::UnexpectedEof => { 93 break; 94 } 95 _ => return Err(err), 96 } 97 } 98 }; 99 100 self.index.insert(kv.key, position); 101 } 102 103 Ok(()) 104 }
301
(8)
(1) В этом коде обрабатывается большой объем данных типа Vec. Поскольку все это происходит по той же схеме, что и обработка данных типа String, то здесь вполне уместно применить псевдоним ByteString. (2) ByteStr является для &str тем же, чем ByteString является для Vec. (3) Предписание компилятору на создание сериализованного кода, позволяющего KeyValuePair записать данные на диск. Сериализация и десериализация рассматриваются в разделе 7.2.1. (4) Поддержка отображения ключей на места в файле. (5) ActionКV::load() заполняет индекс структуры ActionКV, отображая ключи на места в файле. (6) File::seek () возвращает количество байтов с начала файла. Оно становится значением индекса. (7) ActionКV::process_record() считывает запись в файле по ее текушей позиции. (8) Неожиданность носит относительный характер. Возможно, приложением не ожидалась встреча с концом файла, но для нас файлы конечны, следовательно, мы будем полагаться на возможность встречи с концом файла.
Что такое EOF (конец файла)? В Rust при проведении операций с файлами может возвращаться ошибка типа std: : io: :ErrorKind: :UnexpectedEof, но что означает Eof? Это конец файла (end of file, EOF), то есть соглашение, которое операционная система предоставляет при ложениям. Специального маркера или разделителя в конце файла нет. EOF- это нулевой байт (оuв). При чтении данных из файла операционная система сообщает приложению о количестве байтов, успешно считанных из хранилища. Если успешного считывания байтов с диска не произошло и при этом не возникла никакая ошибочная ситуация, то операционная система, а, стало быть, и приложе ние, предполагают, что достигнут конец файла- EOF. Так получается благодаря тому, что ответственность за взаимодействие с физиче скими устройствами берет на себя операционная система. Когда файл считывается приложением, оно уведомляет операционную систему о своем намерении получить доступ к диску.
302
Глава 7
7.7.2. Работа с отдельно взятой записью В actionkv для представления данных на диске используется опубликованный стан дарт. Он представляет собой реализацию серверной части хранилища Bitcask, кото рая была разработана для исходной реализации базы данных Riak. Bitcask принад лежит к семейству форматов файлов, известных в литературе как лог-структури рованные хеш-таблицы. Что такое Riak? Riak - база данных из разряда NoSQL, разработанная на пике движения NoSQL и конкурировавшая с такими аналогичными системами, как MongoDB, Apache CouchDB и Tokyo Tyrant. Ее особенность -устойчивость при сбоях. Хотя она медленнее своих собратьев, ею гарантируется абсолютная сохранность данных. Частично эта гарантия обусловливается удачным выбором формата данных. Bitcask размещает каждую запись в предписанном порядке. Отдельно взятая запись в формате файла Bitcask показана на рис. 7.3. Заголовок фиксированной длины
Ключ переменной длины
Значение переменной длины
Role
Имя переменной
checksum
key_len
u32
u32
value_len
key
value
u32
[u8; key_len]
[u8; value_len]
Структура Тип данных
В Rust указывать тип массива с помощью переменной недопустимо, но здесь такое добавление применено, чтобы продемонстрировать взаимосвязь заголовка и тела каждой записи
Рис. 7.3. Отдельно взятая запись
в формате файла Bitcask. Чтобы провести разбор записи, нужно прочитать информацию в заголовке, затем воспользоваться ею для чтения тела. В завершение нужно сверить контрольную сумму содержимого тела с контрольной суммой, представленной в заголовке. У каждой пары «ключ-значение» имеется префикс длиной 12 байтов. В этих байтах описываются ее длина (key_len + val_len) и содержимое (checksurn). Обработкой этих данных в Actionкv занимается функция process_record(). Сна чала она считывает 12 байтов, представляющих три целочисленных значения: контрольную сумму, длину ключа и длину значения. Затем эти значения исполь зуются для считывания остальных данных с диска и проверки соответствия ожи даемому. Код этого процесса показан в следующем листинге, являющемся частью листинга 7.16. Листинг 7.12. Концентрация внимания на метод ActionKV::process_record() 43 fn process_record( 44
f: &mut R
(1)
Файлы и хранилища
303
45 ) -> io::Result { 46
let saved_checksum =
47 48
f.read_u32::()?; let key_len =
49 50
f.read_u32::()?; let val_len =
51
f.read_u32::()?;
52
let data_len = key_len + val_len;
(2) (2) (2) (2) (2) (2)
53 54
let mut data = ByteString::with_capacity(data_len as usize);
55 56
{
57
f.by_ref()
58
(3)
.take(data_len as u64)
59
.read_to_end(&mut data)?;
60
}
61
debug_assert_eq!(data.len(), data_len as usize);
62 63
let checksum = crc32::checksum_ieee(&data);
64
if checksum != saved_checksum {
65
panic!(
66
"data corruption encountered ({:08x} != {:08x})",
67
checksum, saved_checksum
68 69
(5)
); }
70 71
let value = data.split_off(key_len as usize);
72
let key = data;
(6)
73 74
Ok( KeyValuePair { key, value } )
75 }
(1) f может быть .rnобым типом с реализацией Read, например типом, считывакщим файлы, но также может быть и &[u8]. (2) Контейнер byteorder позволяет считывать находящиеся на диске целочисленные значения предопределенным образом (см. следу!СЩИЙ раздел). (3) Необходимость использования f.by_ref() обусловлена созданием take(n) нового значения Read. Использование в этом недолговечном блоке ссылки избавляет от проблем владения. (4) Тестирование с помощью макроса debug_assert! в оптимизированных сборках отключено, что позволяет в отладочных сборках получать больше проверок в ходе вьmолнения программы. (5) Контрольная сумма checksum (являющаяся числом) позволяет проверить, что байты, считанные с диска, соответствуют ожидаемому результату. Этот процесс рассматривается в разделе 7.7.4. (6) Метод split_off(n) разбивает Vec на две части по границе n.
304
Глава 7
7.7.3. Запись многобайтных двоичных данных на диск в гарантированном порядке следования байтов Одна из задач нашего кода - приобретение им возможностей сохранения много байтных данных на диске предопределенным образом. Казалось бы, ничего слож ного, но порядок чтения данных у разных вычислительных платформ разный. Одни считывают 4 байта значения типа i 32 слева направо, а другие - справа налево. Ес ли программа разработана под запись на одном компьютере, а загружена на другом, то это может вылиться в серьезную проблему. Экосистема Rust позволяет решить данный вопрос. Контейнер byteorder способен расширять типы, реализующие типажи стандартной библиотеки std::io:: Read и std:: i o::Write. Эти типажи обычно ассоциируются с std::io::File, но реализу ются и другими типами, например [uBJ и Tcpstream. Расширения способны гаран тировать порядок интерпретации многобайтных последовательностей как с пря мым, так и с обратным порядком следования байтов. Чтобы понять, как работает byteorder, нужно проследить за тем, что происходит с нашим хранилищем «ключ-значение)). В листинге 7.14 показано учебное приложе ние, демонстрирующее работу кода. В строках 11-23 продемонстрирован способ записи в файл, а в строках 28-35 - способ чтения из него. Две ключевые строки: use byteorder::{LittleEndian}; use byteorder::{ReadBytesExt, WriteBytesExt};
byteorder::LittleEndian и его собратья BigEndian и NativeEndi an (неиспользуе мые в коде листинга 7.14) - типы, указывающие на способ записи многобайтных данных на диск и способ считывания их с него. И byteorder::ReadBytesExt, и byteorder: :WriteBytesExt - это типажи. В некотором смысле их присутствие в коде незаметно. Эти методы без лишних церемоний расширяют такие элементарные типы, как f32 и ilб. Их внесение в область видимости с помощью инструкции use сразу же повы шает эффективность типов, реализованных в исходном коде byteorder (на практи ке имеются в виду элементарные типы). Rust, являясь статически типизированным языком, выполняет эти преобразования в ходе компиляции. С позиции программы, запущенной на выполнение, целочисленные типы всегда способны записываться на диск в предопределенном порядке. В ходе выполнения код листинга 7 .14 выводит на экран комбинации байтов, соз данные в процессе записи 1_u32, 2_i B и з. O_f32 в прямом порядке. Получается следующая картина: [1, 0, 0, 0] [1, 0, 0, 0, 2] [1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 8, 64]
В следующем листинге показаны метаданные для проекта, код которого есть в лис тинге 7.14. Его исходный код находится в файле ch7/ch7-write123/Cargo.toml. А исход ный код листинга 7.14- в файле ch7/ch7-write123/src/main.rs.
Файлы и хранилища
305
Листинг 7.13. Метаданные для кода листинга 7.14 [package] name = "write123" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] byteorder = "1.2"
Листинг 7.14. Запись целочисленных значений на диск 1 use std::io::Cursor; 2 use byteorder::{LittleEndian}; 3 use byteorder::{ReadBytesExt, WriteBytesExt};
(1) (2) (3)
4 5 fn write_numbers_to_file() -> (u32, i8, f64) { 6
let mut w = vec![];
(4)
7 8
let one: u32
= 1;
9
let two: i8
= 2;
10
let three: f64 = 3.0;
11 12
w.write_u32::(one).unwrap();
13
println!("{:?}", &w);
(5)
14 15
w.write_i8(two).unwrap();
16
println!("{:?}", &w);
(6)
17 18
w.write_f64::(three).unwrap();
19
println!("{:?}", &w);
(5)
20 21
(one, two, three)
22 } 23 24 fn read_numbers_from_file() -> (u32, i8, f64) { 25
let mut r = Cursor::new(vec![1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 8,
26
let one_ = r.read_u32::().unwrap();
27
let two_ = r.read_i8().unwrap();
28
let three_ = r.read_f64::().unwrap();
64]);
29 30
(one_, two_, three_)
31 } 32 33 fn main() { 34
let (one, two, three) = write_numbers_to_file();
306 35
Глава 7 let (one_, two_, three_) = read_numbers_from_file();
36 37
assert_eq!(one, one_);
38
assert_eq!(two, two_);
39
assert_eq!(three, three_);
40 }
(1) Поскольку в файлах поддерживается позиционирование seek(), перемещение назад и вперед на разные позиции, то типу Vec, чтобы он стал имитацией файла, нужна такая же возможность. Здесь эта роль отводится io::Cursor, что позволяет обращаться со значением Vec, находящимся в памяти, так же, как с файлом. (2) Используется в качестве аргумента типа для различных имеющихся в программе методов read_ * () и write_* () (3) Типажи, предоставляющие read_* () и write_* () (4) Переменная w означает «writer» (средство записи). (5) Запись значений на диск. Эти методы возвращают распаковываемые здесь значения io::Result, поскольку сбоя не будет, если только не случится серьезной неисправности компьютера, на котором запущена программа. (6) Однобайтные типы iB и uB не принимают параметра порядка следования байтов.
7.7.4. Проверка ошибок ввода-вывода с помощью контрольных сумм В actionkv v 1 отсутствует метод проверки соответствия данных, считанных с диска, тому, что было записано на него. А что, если при исходной записи случились сбои? Тогда восстановить исходные данные вряд ли получится, но, если удастся обнару жить проблему, можно будет предупредить пользователя. Решить эту задачу позволит использование технологии под названием «контроль ная суммю). Работает она следующим образом: • Сохранение на диске. Перед записью данных на диск к сохраняемым данным применяется проверочная функция (коих великое множество). Ее результат (контрольная сумма) записывается вместе с исходными данными. • Для самих байтов контрольной суммы эта сумма не вычисляется. Сбой в ходе записи самих байтов контрольной суммы будет позже замечен с выдачей ошибки. • Чтение с диска. Считывание данных и сохраненной контрольной суммы с применением к данным функции проверки. После считывания сравниваются результаты двух проверочных функций. Если они не совпадают, вьщается ошибка, и данные должны рассматриваться поврежденными. Так какой же проверочной функцией следует воспользоваться? Как и многое дру гое в компьютерном мире, все зависит от обстоятельств. Идеальная функция кон трольной суммы должна: • Возвращать одинаковый результат при одних и тех же данных на входе. • Всегда возвращать разные результаты при разных данных на входе.
Файлы и хранилища
307
• Работать быстро. • Реализовываться просто. В таблице 7.8 сравниваются различные подходы к работе с контрольной суммой. Короче говоря: • С битом четности работать просто и быстро, но у него имеется предрасполо женность к ошибкам. • CRC32 (контроль циклическим избыточным кодом, возвращающий 32 бита) намного сложнее, но его результат внушает больше доверия. • Криптографические хэш-функции еще сложнее. Хотя работа с ними идет значительно медленнее, они обеспечивают высокий уровень надежности. Таблица 7.8. Упрощенная оценка различных функций контрольной сум«ы
Технология контрольной суммы
Размер результата
Бит четности
1 бит
CRC32
32 бита
Криптографическая хэш-функция
128-512 бит (или больше)
Простота
Скорость
Надежность
***** ***** *****
***** ***** *****
***** ***** *****
Функции, с которыми можно столкнуться в реальных условиях, зависят от среды выполнения вашего приложения. В наиболее распространенных областях могут использоваться более простые системы - бит четности или CRC32. Реализация проверки бита четности В этом разделе рассматривается одна из самых простых схем контрольной суммы: проверка бита четности. Она заключается в подсчете единиц в потоке битов. При этом сохраняется бит, показывающий, каким числом был выражен подсчет: четным или нечетным.
Биты четности традиционно используются для выявления ошибок в зашумленных системах связи, например при передаче данных посредством аналоговых систем, таких как радиосвязь. Скажем, у АSСП-кодировки текста есть особое свойство, вы ражающееся в удобстве применения этой схемы. Для хранения ее 128 символов требуются 7 бит (128 = 27). То есть в каждом байте остается один резервный бит. Системы также могут включать биты четности в более длинные потоки байтов. В листинге 7.15 представлена сильно упрощенная реализация рассматриваемой проверки. Функция parity_Ьit () в строках 1-10 получает произвольный поток байтов и возвращает значение типа uB, показывающее, каким был результат под счета входящих битов: четным или нечетным.
308
Глава 7
При выполнении код листинга 7 .15 выдает следующую информацию: input: [97, 98, 99]
(1)
97 (0b01100001) has 3 one bits 98 (0b01100010) has 3 one bits 99 (0b01100011) has 4 one bits output: 00000001 input: [97, 98, 99, 100] 97 (0b01100001) has 3 one bits
(2)
98 (0b01100010) has 3 one bits 99 (0b01100011) has 4 one bits 100 (0b01100100) has 3 one bits result: 00000000
(1) Внутри Rust-компилятора input: [97, 98, 99] представлен как Ь"аЬс". (2) input: [97, 98, 99, 100] представлен как b"abcd".
ПРИМЕЧАНИЕ Исходный код следующего листинга находится в файле ch7/ch7-parityblt/src/main.rs.
Листинг 7.15. Реализация проверки бита четности 1 fn parity_bit(bytes: &[u8]) -> u8 { 2
(1)
let mut n_ones: u32 = 0;
3 4
for byte in bytes {
5
let ones = byte.count_ones();
6
n_ones += ones;
7
(2)
println!("{} (0b{:08b}) has {} one bits", byte, byte, ones);
8
}
9
(n_ones % 2 == 0) as u8
(3)
10 } 11 12 fn main() { 13
let abc = b"abc";
14
println!("input: {:?}", abc);
15
println!("output: {:08x}", parity_bit(abc));
16
println!();
17
let abcd = b"abcd";
18
println!("input: {:?}", abcd);
19
println!("result: {:08x}", parity_bit(abcd))
20 }
(1) Получение в качестве аргумента bytes байтового слайса и возвращение на выходе одиночного байта. Этой функции совсем нетрудно было бы возвратить булево значение, но возвращение значения типа u8 позволяет впоследствии применить к результату битовое смещение в какую-либо желаемую позицию.
Файлы и хранилища
309
(2) Все имеющиеся в Rust целочисленные типы оснащены методами count_ones() и count_zeros(). (3) Существует множество способов оптимизации этой функции. Один весьма простой подход заключается в применении жестко закодированного массива const [u8; 256], состоящего из нулей и единиц и соответствующего предполагаемому результату, с последующей индексацией этого массива КаждЫМ байтом.
7.7.5. Вставка в существующую базу данных новой пары «ключ-значение)) В разделе 7.6 выяснилось, что наш код должен поддерживать четыре операции: вставку, получение, обновление и удаление. Поскольку мы поддерживаем конст рукцию только с добавлением, получается, что две последние операции могут быть реализованы как варианты вставки. Возможно, вы заметили, что в ходе выполнения функции load () внутренний цикл продолжается до конца файла. Это позволяет перезаписывать устаревшие данные, включая удаления, более поздними обновлениями. Практически, вставка новой за писи - противоположность функции process_record (), рассмотренной в разделе 7.7.2. Например: 164 pub fn insert( 165
&mut self,
166
key: &ByteStr,
167
value: &ByteStr
168 ) -> io::Result { 169
let position = self.insert_but_ignore_index(key, value)?;
170 171
self.index.insert(key.to_vec(), position);
172
Ok(())
(1)
173 } 174 175 pub fn insert_but_ignore_index( 176
&mut self,
177
key: &ByteStr,
178
value: &ByteStr
179 ) -> io::Result { 180
let mut f = BufWriter::new(&mut self.f);
(2)
181 182
let key_len = key.len();
183
let val_len = value.len();
184
let mut tmp = ByteString::with_capacity(key_len + val_len);
185 186
for byte in key {
187 188 189
tmp.push(*byte); }
(3) (3) (3)
310 190
Глава 7 for byte in value {
191 192
tmp.push(*byte); }
(3) (3) (3)
193 194
let checksum = crc32::checksum_ieee(&tmp);
195 196
let next_byte = SeekFrom::End(0);
197
let current_position = f.seek(SeekFrom::Current(0))?;
198
f.seek(next_byte)?;
199
f.write_u32::(checksum)?;
200
f.write_u32::(key_len as u32)?;
201
f.write_u32::(val_len as u32)?;
202
f.write_all(&mut tmp)?;
203 204
Ok(current_position)
205 }
(1) key.to_vec() преобразует &ByteStr в ByteString. (2) Тип std::io::BufWriter собирает в единый пакет множество мелких вызовов write(), сокращая количество операций с диском за счет сведения их в одну операцию. Тем самым при сохранении аккуратности кода приложения повьnnается пропускная способ ность программы. (3) Последовательный перебор элементов одной коллекции для заполнения другой такой же коллекции немного неудобен, но со своей задачей справляется.
7.7.6. Полный код листинга для actionkv Вся тяжелая работа в наших хранилищах «ключ-значение» возлагается на libac tionkv. В ходе рассмотрения материала раздела 7.7 основная часть проекта уже изу чена. В следующем листинге, код которого находится в файле ch7/ch7actionkv1/src/lib.rs, представлен весь код проекта. Листинг 7.16. Проект actionkv (весь код) 1 use std::collections::HashMap; 2 use std::fs::{File, OpenOptions}; 3 use std::io; 4 use std::io::prelude::*; 5 use std::io::{BufReader, BufWriter, SeekFrom}; 6 use std::path::Path; 7 8 use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; 9 use crc::crc32; 10 use serde_derive::{Deserialize, Serialize}; 11 12 type ByteString = Vec; 13 type ByteStr = [u8];
Файлы и хранилища
311
14 15 #[derive(Debug, Serialize, Deserialize)] 16 pub struct KeyValuePair { 17
pub key: ByteString,
18
pub value: ByteString,
19 } 20 21 #[derive(Debug)] 22 pub struct ActionKV { 23
f: File,
24
pub index: HashMap,
25 } 26 27 impl ActionKV { 28
pub fn open(
29 30
path: &Path ) -> io::Result {
31
let f = OpenOptions::new()
32
.read(true)
33
.write(true)
34
.create(true)
35
.append(true)
36
.open(path)?;
37
let index = HashMap::new();
38 39
Ok(ActionKV { f, index }) }
40 41 42 43 44
fn process_record( f: &mut R ) -> io::Result { let saved_checksum =
45 46
f.read_u32::()?; let key_len =
47 48
f.read_u32::()?; let val_len =
49 50
(1)
f.read_u32::()?; let data_len = key_len + val_len;
51 52
let mut data = ByteString::with_capacity(data_len as usize);
53 54
{
55
f.by_ref()
56
.take(data_len as u64)
57
.read_to_end(&mut data)?;
58
}
59
debug_assert_eq!(data.len(), data_len as usize);
60 61
let checksum = crc32::checksum_ieee(&data);
(2)
312
Глава 7
62
if checksum != saved_checksum {
63
panic!(
64
"data corruption encountered ({:08x} != {:08x})",
65
checksum, saved_checksum
66
);
67
}
68 69
let value = data.split_off(key_len as usize);
70
let key = data;
71 72 73
Ok(KeyValuePair { key, value }) }
74 75
pub fn seek_to_end(&mut self) -> io::Result {
76 77
self.f.seek(SeekFrom::End(0)) }
78 79
pub fn load(&mut self) -> io::Result {
80
let mut f = BufReader::new(&mut self.f);
81 82
loop {
83
let current_position = f.seek(SeekFrom::Current(0))?;
84 85
let maybe_kv = ActionKV::process_record(&mut f);
86
let kv = match maybe_kv {
87
Ok(kv) => kv,
88
Err(err) => {
89
match err.kind() {
90
io::ErrorKind::UnexpectedEof => {
91
break;
92
(3)
}
93
_ => return Err(err),
94
}
95
}
96
};
97 98
self.index.insert(kv.key, current_position);
99
}
100 101 102
Ok(()) }
103 104 105 106 107 108
pub fn get( &mut self, key: &ByteStr ) -> io::Result { let position = match self.index.get(key) {
(4)
Файлы и хранилища 109
313
None => return Ok(None),
110
Some(position) => *position,
111
};
112 113
let kv = self.get_at(position)?;
114 115 116
Ok(Some(kv.value)) }
117 118
pub fn get_at(
119
&mut self,
120 121
position: u64 ) -> io::Result {
122
let mut f = BufReader::new(&mut self.f);
123
f.seek(SeekFrom::Start(position))?;
124
let kv = ActionKV::process_record(&mut f)?;
125 126 127
Ok(kv) }
128 129
pub fn find(
130
&mut self,
131
target: &ByteStr
132
) -> io::Result {
133
let mut f = BufReader::new(&mut self.f);
134 135
let mut found: Option = None;
136 137 138
loop { let position = f.seek(SeekFrom::Current(0))?;
139 140
let maybe_kv = ActionKV::process_record(&mut f);
141
let kv = match maybe_kv {
142
Ok(kv) => kv,
143
Err(err) => {
144
match err.kind() {
145
io::ErrorKind::UnexpectedEof => {
146
break;
147
}
148
_ => return Err(err),
149
}
150 151
} };
152 153
if kv.key == target {
154 155
found = Some((position, kv.value)); }
(5)
314
Глава 7
156 157
// important to keep looping until the end of the file,
158
// in case the key has been overwritten
159
}
160 161 162
Ok(found) }
163 164
pub fn insert(
165
&mut self,
166
key: &ByteStr,
167 168
value: &ByteStr ) -> io::Result {
169
let position = self.insert_but_ignore_index(key, value)?;
170 171
self.index.insert(key.to_vec(), position);
172 173
Ok(()) }
174 175
pub fn insert_but_ignore_index(
176
&mut self,
177
key: &ByteStr,
178
value: &ByteStr
179 180
) -> io::Result { let mut f = BufWriter::new(&mut self.f);
181 182
let key_len = key.len();
183
let val_len = value.len();
184
let mut tmp = ByteString::with_capacity(key_len + val_len);
185 186
for byte in key {
187 188
tmp.push(*byte); }
189 190
for byte in value {
191 192
tmp.push(*byte); }
193 194
let checksum = crc32::checksum_ieee(&tmp);
195 196
let next_byte = SeekFrom::End(0);
197
let current_position = f.seek(SeekFrom::Current(0))?;
198
f.seek(next_byte)?;
199
f.write_u32::(checksum)?;
200
f.write_u32::(key_len as u32)?;
201
f.write_u32::(val_len as u32)?;
202
f.write_all(&tmp)?;
Файлы и хранилища
315
203 204 205
Ok(current_position) }
206 207
#[inline]
208
pub fn update(
209
&mut self,
210
key: &ByteStr,
211 212
value: &ByteStr ) -> io::Result {
213 214
self.insert(key, value) }
215 216
#[inline]
217
pub fn delete(
218
&mut self,
219 220
key: &ByteStr ) -> io::Result {
221 222
self.insert(key, b"") }
223 }
(1) process_record() предполагает, что f уже находится в нужном месте файла. (2) Здесь требуется f.by_ref(), поскольку .take(n) создает новый экземпляр Read. Использование внутри этого блока ссылки позволяет обойти проблемы владения стороной. (3) Неожиданность (Unexpected) здесь относительная. Приложение этого может и не ожидать, но для нас конечность файлов вполне очевидна. (4) Упаковка Option в Result, чтобы учесть возможность ошибки ввода-вывода, а также допустить пропущенные значения. (5) Неожиданность (Unexpected) здесь относительная. Приложение этого может и не ожидать, но для нас конечность файлов вполне очевидна.
Если вам удалось добраться до этих глубин, можете поздравить себя с реализацией хранилища «ключ-значение», способного весьма успешно справляться с хранением и извлечением всего, что вы в него поместите.
7.7.7. Работа с ключами и значениями с использованием HashMap и BTreeMap Работать с парами «ключ-значение» приходится практически в каждом языке про граммирования. К вящей пользе обучающихся всего мира у этой задачи и у под держиваемых при ее решении структур данных множество имен: • В компьютерных кругах попадаются люди, предпочитающие использовать термин «хэш-таблица». • В Perl и Ruby все это носит название «хэшю).
316
Глава 7
• В Lua принято обратное, и используется термин «таблица». • Во многих сообществах структура носит метафорическое название, например «словарь>> или «отображение». • Есть и такие сообщества, где предпочитают давать название по той роли, ко торую играет структура. • В РНР этому дается описание в виде ассоциативных массивов. • Объекты JavaScript обычно реализуются как коллекция пар «ключ-значение», поэтому в этом языке достаточно общего термина «объект». • В статических языках название обычно дается в зависимости от способа реа лизации. • В С++ и Java различаются хэш-карты и древовидные карты. В Rust для определения двух реализаций одного и того же абстрактного типа дан ных используются термины нashMap и втrееМар. Rust в этом отношении ближе все го к С++ и Java. В этой книге понятия коллекции из пар «ключ-значение» и ассо циативного массива будут относиться к абстрактным типам данных. Понятие хэш таблицы будет отнесено к ассоциативным массивам, реализованным с помощью хэш-таблиц, а HashMap будет означать Rust-реализацию хэш-таблиц.
Что такое хэш? И что такое хэширование? Если вы когда-либо испытывали смущение от такого понятия, как «хэш», разо браться в его сути поможет его применение к решениям, связанным с реализацией отображения на значения нецелочисленных ключей. Наверное, прояснить сказан ное помогут следующие определения: • нashMap реализуется с помощью хэш-функции. Компьютерные специалисты поймут, что под этим обычно подразумевается определенная модель поведе ния. У хэш-карты имеется, как правило, постоянное время поиска, формально обозначаемое как о ( 1 ) , причем буква «о» пишется в верхнем регистре. (Хотя, как вскоре будет показано, когда базовая хэш-функция сталкивается с каки ми-либо патологическими случаями, производительность хэш-таблицы мо жет пострадать.) • Хэш-функция - отображение значений переменной длины на значения фик сированной длины. На практике значение, возвращаемое хэш-функцией, явля ется целым числом. Затем это значение фиксированной длины можно исполь зовать для создания эффективной таблицы соответствия. Эта внутренняя таб лица соответствия известна под названием хэш-таблицы. В следующем примере показана базовая хэш-функция для &str, которая просто ин терпретирует первый символ строки как целое число без знака. То есть первый символ строки используется этой функцией в качестве хэш-значения: fn basic_hash(key: &str) -> u32 { let first = key.chars() .next()
(1) (2)
Файлы и хранилища
317 .unwrap_or('\0');
unsafe { std::mem::transmute::(first) } }
(3) (4) (4)
(1) Итератор .chars() преобразует строку в серию символьных значений, каждое длиной 4 байта. (2) Возвращение значения типа Option с распаковкой либо в Some(char), либо, для пустых строк, в None. (3) Если строка пустая, по умолчанию предоставляется значение NULL. Функция unwrap_or() ведет себя как unwrap(), но при встрече с None не паникует, а предоставляет значение. (4) Интерпретация образа в памяти для first как u32, даже если тип этой переменной char.
В качестве входного параметра функция basic_hash может принимать любое стро ковое значение, а это бесконечный набор возможных параметров, и вполне опреде ленным образом возвращать для всех них результат фиксированной длины. И это здорово! Но при всей своей быстроте работы функция basic_hash имеет ряд суще ственных недостатков. Если несколько входных параметров начинаются с одного и того же символа (на пример, Tonga и тuvalu), на выходе будет одинаковый результат. Такое происходит всякий раз, когда бесконечное пространство входных параметров отображается на конечное пространство, но в данном случае это имеет крайне негативные последст вия. Текст естественного языка не имеет равномерного распределения. Хэш-таблицы, включая имеющуюся в Rust карту HashMap, справляются с этой осо бенностью, которую называют хэш-коллизией. Для ключей с одинаковым хэш значением в этих таблицах предоставляется место для резервных копий. Обычно это резервное хранилище относится к типу vec, и называется хранилищем кол лизий. При возникновении коллизии выполняется обращение к хранилищу колли зий и происходит его сквозное сканирование. По мере увеличения хранилища это линейное сканирование занимает все больше и больше времени. Злоумышленники могут воспользоваться этой особенностью для перезагрузки компьютера, выпол няющего хэш-функцию. Получается, что во избежание атак более быстрые хэш-функции выполняют мень ший объем работы. Также они будут лучше работать, когда их входные параметры находятся в рамках определенного диапазона. Для полного понимания внутреннего устройства реализации хэш-таблиц пришлось бы изложить слишком много подробностей, не вписывающихся в рамки этой врез ки. Но для программистов, желающих добиться оптимальной производительности и задействованности памяти для своих программ, это весьма увлекательная тема.
318
Глава 7
7.7.8. Создание HashMap и ее заполнение значениями В следующем листинге показана коллекция пар «ключ-значение», закодированная в формате JSON. Для демонстрации использования ассоциативного массива в ней используются сведения о некоторых полинезийских островных государств и их столицах. Листинг 7.17. Демонстрация использования ассоциативного массива в JSON-записи { "Cook Islands": "Avarua", "Fiji": "Suva", "Kiribati": "South Tarawa", "Niue": "Alofi", "Tonga": "Nuku'alofa", "Tuvalu": "Funafuti" }
Буквальный синтаксис в стандартной библиотеке Rust для нashMap не предоставля ется. Для вставки элементов и их последующего извлечения нужно руководство ваться примером, показанным в листинге 7.18, исходный код которого находится в файле ch7/ch7-pacific-basic/src/main.rs. При выполнении кода листинга 7.18 на консоль выводится следующая строка: Capital of Tonga is: Nuku'alofa Листинг 7.18. Пример основных операций с HashMap 1 use std::collections::HashMap; 2 3 fn main() { 4
let mut capitals = HashMap::new();
5 6
capitals.insert("Cook Islands", "Avarua");
7
capitals.insert("Fiji", "Suva");
8
capitals.insert("Kiribati", "South Tarawa");
9
capitals.insert("Niue", "Alofi");
10
capitals.insert("Tonga", "Nuku'alofa");
11
capitals.insert("Tuvalu", "Funafuti");
(1)
12 13
let tongan_capital = capitals["Tonga"];
(2)
14 15
println!("Capital of Tonga is: {}", tongan_capital);
16 }
(1) Объявления ключей и значений здесь не требуются, поскольку они выводятся Rust компилятором. (2) В HashМap реализуется Index, позволяющий извлекать значения путем применения индексного стиля с квадратными скобками.
Файлы и хранилища
319
Временами запись всего этого в виде вызовов методов может показаться слишком многословной. При поддержке расширенной экосистемы Rust имеется возможность вставки JSON-cтpoк в код Rust. Лучше, чтобы преобразование выполнялось в ходе компиляции, что позволит избавить программу от снижения производительности в ходе ее выполнения. При выполнении кода листинга 7 .19 на консоль также выво дится одна строка: (1)
Capital of Tonga is: "Nuku'alofa"
(1) Использование двойных кавычек обусловлено тем, что макрос json! возвращает String в оболочке, а это ее представление по умолчанию.
В следующем листинге для включения JSОN-литералов в исходный Rust-кoд ис пользуется контейнер serde-json. Исходный код листинга находится в файле ch7/ch7pacific-json/src/main.rs.
Листинг 7.19. Включение JSON-литералов с помощью serde-json (1) (1)
1 #[macro_use] 2 extern crate serde_json; 3 4 fn main() { 5
"Cook Islands": "Avarua",
7
"Fiji": "Suva",
8
"Kiribati": "South Tarawa",
9
"Niue": "Alofi",
10 11 12
(2)
let capitals = json!({
6
"Tonga": "Nuku'alofa", "Tuvalu": "Funafuti" });
13 14
println!("Capital of Tonga is: {}", capitals["Tonga"])
15 }
(1) Включение контейнера serde json и использование его макросов, благодаря чему макрос json! попадает в область видимости. (2) Для реализации String-значения макрос json! получает JSОN-литерал и ряд Rust выражений. Все это преобразуется им в Rust-значение типа serde_json::Value, являк:щееся перечислением, способным представить каждый тип в JSОN-спецификации.
7.7.9. Извлечение значений из HashMap и BTreeMap Основным преимуществом, предоставляемым хранилищем «ключ-значение», явля ется возможность доступа к его значениям. Воспользоваться им можно двумя спо собами. В целях демонстрации представим, что мы проинициализировали столицы из листинга 7.19. Ранее показанный подход заключается в доступе к значению по средством использования синтаксиса квадратных скобок: capitals["Tonga"]
(1) Возвращает "Nuku' alofa"
(1)
320
Глава 7
В результате возвращается ссылка на значение, предназначенная только для чтения и дезориентирующая при работе с примерами, содержащими строковые литералы, поскольку их статус в качестве ссылок не столь очевиден. В синтаксисе, использо ванном в Rust-документации, для описания используется &V, где & означает ссылку только для чтения, а v- тип значения. Если ключ отсутствует, программа запани кует. ПРИМЕЧАНИЕ
Индексная нотация поддерживается всеми типами, реализующими типаж Index. Вы зов capi tals [ "Tonga"] - удобный синтаксический аналог выражения capi
tals. index ( "Tonga") .
В отношении HashMap можно также воспользоваться методом . get (). Он возвраща ет Option, предоставляя возможность справиться с отсутствием значений. На пример: capitals.get("Tonga")
(1)
(1) Возвращает Some ("Nuku' alofa") В нashMap поддерживаются и другие важные операции, в том числе: • Удаление пар «ключ-значение)) с помощью метода . remove () • Последовательный перебор ключей, значений и пар {
50
let index_as_bytes = a.get(&INDEX_KEY)
(1)
51
.unwrap()
(2)
52
.unwrap();
(2)
53 54
let index_decoded = bincode::deserialize(&index_as_bytes);
55 56
let index: HashMap = index_decoded.unwrap();
57 58
match index.get(key) {
59
None => eprintln!("{:?} not found", key),
60
Some(&i) => {
61
let kv = a.get_at(i).unwrap();
62
println!("{:?}", kv.value)
63
}
64 65
} }
(3) (3) (3)
(3) (3) (3)
324
Глава 7
(1) INDEX_КEY - внутреннее скрытое имя индекса в базе данных. (2) Необходимость в двух вызовах unwrap() обусловливается тем, что a.index относится к структуре HashМap, возвращающей тип Option, а сами значения хранятся внутри Option, чтобы упростить возможные будущие удаления. (3) Теперь извлечение значения не обходится без предварительной выборки индекса и последующего определения верного места на диске.
В следующем листинге показано хранилище «ключ-значение)), сохраняющее дан ные индекса между запусками. Его исходный код находится в файле ch7/ch7actionkv2/sгc/akv_disk.гs
Листинг 7.24. Сохранение индексных данных между запусками 1 use libactionkv::ActionKV; 2 use std::collections::HashMap; 3 4 #[cfg(target_os = "windows")] 5 const USAGE: &str = " 6 Usage: 7
akv_disk.exe FILE get KEY
8
akv_disk.exe FILE delete KEY
9
akv_disk.exe FILE insert KEY VALUE
10
akv_disk.exe FILE update KEY VALUE
11 "; 12 13 #[cfg(not(target_os = "windows"))] 14 const USAGE: &str = " 15 Usage: 16
akv_disk FILE get KEY
17
akv_disk FILE delete KEY
18
akv_disk FILE insert KEY VALUE
19
akv_disk FILE update KEY VALUE
20 "; 21 22 type ByteStr = [u8]; 23 type ByteString = Vec; 24 25 fn store_index_on_disk(a: &mut ActionKV, index_key: &ByteStr) { 26
a.index.remove(index_key);
27
let index_as_bytes = bincode::serialize(&a.index).unwrap();
28
a.index = std::collections::HashMap::new();
29
a.insert(index_key, &index_as_bytes).unwrap();
30 } 31 32 fn main() { 33
const INDEX_KEY: &ByteStr = b"+index";
34 35
let args: Vec = std::env::args().collect();
36
let fname = args.get(1).expect(&USAGE);
Файлы и хранилища
325
37
let action = args.get(2).expect(&USAGE).as_ref();
38
let key = args.get(3).expect(&USAGE).as_ref();
39
let maybe_value = args.get(4);
40 41
let path = std::path::Path::new(&fname);
42
let mut a = ActionKV::open(path).expect("unable to open file");
43 44
a.load().expect("unable to load data");
45 46
match action {
47
"get" => {
48
let index_as_bytes = a.get(&INDEX_KEY)
49
.unwrap()
50
.unwrap();
51 52
let index_decoded = bincode::deserialize(&index_as_bytes);
53 54
let index: HashMap = index_decoded.unwrap();
55 56
match index.get(key) {
57
None => eprintln!("{:?} not found", key),
58
Some(&i) => {
59
let kv = a.get_at(i).unwrap();
60
println!("{:?}", kv.value)
61
(1)
}
62
}
63
}
64 65
"delete" => a.delete(key).unwrap(),
66 67
"insert" => {
68
let value = maybe_value.expect(&USAGE).as_ref();
69
a.insert(key, value).unwrap();
70
store_index_on_disk(&mut a, INDEX_KEY);
71
(2)
}
72 73
"update" => {
74
let value = maybe_value.expect(&USAGE).as_ref();
75
a.update(key, value).unwrap();
76
store_index_on_disk(&mut a, INDEX_KEY);
77 78 79
(2)
} _ => eprintln!("{}", &USAGE), }
80 }
(1) Для вывода значений на консоль приходится использовать Debug, поскольку значение типа [u8] содержит произвольные байты. (2) Индекс также нуждается в обновлении при каждом изменении данных.
326
Глава 7
Резюме ♦ Преобразование структур данных в памяти в потоки обычных байтов для хране ния в файлах или отправки по сети и обратное этому преобразование называют ся сериализацией и десериализацией. Для решения этих двух задач выбор в Rust чаще всего падает на serde.
♦ Взаимодействие с файловой системой практически всегда подразумевает обра
ботку значений типа std::io::Result. Значение типа Result используется для обработки ошибок, не возникающих в обычном потоке управления.
♦ У путей файловой системы имеются свои собственные типы: s td: : ра th: : Раth и std::path::PathBuf. Необходимость изучения связанного с этим дополнитель ного материала компенсируется тем, что их реализация позволяет избежать весьма распространенных ошибок, возникающих при трактовке путей в качестве обычных строковых значений.
♦ Чтобы снизить риск повреждения данных во время передачи и хранения, нужно пользоваться контрольными суммами и битами четности. ♦ Использование библиотечного контейнера упрощает управление сложными про граммными проектами. Библиотеки пригодны для совместного использования проектами, и их модульность можно повысить. ♦ Для обработки пар «ключ-значение» в стандартной библиотеке Rust имеются две основные структуры данных: HashMap и втrееМар. Если известно, что функ ции, предлагаемые BtreeMap, не востребованы, воспользуйтесь HashMap.
♦ Атрибут cfg и макрос cfg ! позволяют компилировать код под конкретную платформу.
♦ Чтобы вывести информацию о стандартной ошибке (stderr), нужно воспользо ваться макросом eprintln ! . Его API идентичен API макроса println ! , который используется для вывода данных, предназначенных для стандартного вывода (stdout).
♦ Тип option используется при указании на возможное отсутствие значения, на пример при запросе элемента из пустого списка.
8 Работа в сети
ко
нц еп
ци
я
В этой главе рассматриваются следующие вопросы: ♦ Реализация сетевого стека. ♦ Обработка нескольких типов ошибок в локальной области видимости. ♦ Когда следует использовать типажные объекты. ♦ Реализация в Rust конечных автоматов.
кт ое st Пр Ru
ая те в Се
РАЗДЕЛ
нц -ко
ия
ц еп
Сетевые протоколы HTTP HTTP GET c reqwest Типажные объекты Микро RPG TCP HTTP GET с std::net::TcpStream DNS DNS-преобразователь Расширенная обработка ошибок Обработчик ошибок парсинга MAC address Генератор МАС-адреса Конечный автомат в Rust HTTP GET с обычным TCP
Рис. 8.1. Схема материалов главы, посвященной работе в сети. Здесь выдерживается разумный баланс теории и практических примеров.
Здесь неоднократно будут рассматриваться способы создания НТТР-запросов, и при этом всякий раз будет вскрываться тот или иной уровень абстракции. Снача ла будет использоваться простая в усвоении библиотека, а затем начнется избавле-
328
Глава 8
ние от всяческих надстроек, пока не состоится переход к работе с простыми ТСР-пакетами. К концу путешествия вы научитесь отличать IР-адрес от МАС-адре са и узнаете, почему состоялся переход от IPv4 к IPvб. Кроме того, в этой главе будет рассмотрено множество особенностей, присущих языку Rust, большинство из которых будут касаться расширенной техники обра ботки ошибок, которая необходима для включения контейнеров-надстроек. Обра ботке ошибок будет посвящено несколько страниц, на которых состоится подроб ное знакомство с типажными объектами. Работу в сети трудно уместить в одной главе. Каждый уровень можно назвать фракталом сложностей. Надеюсь, специалисты по сетевым технологиям не станут указывать мне на недостатки при рассмотрении столь разносторонней темы. Обзор тем, изучаемых в главе, представлен на рис. 8.1. Рассматриваемые проекты будут касаться реализации DNS-разрешения и создания МАС-адресов, совмести мых со стандартами, а также нескольких примеров создания НТТР-запросов. Чтобы упростить усвоение материала, обратимся к ролевой игре.
8.1. Все о сетевой работе в семи абзацах Давайте не будем пытаться освоить весь спектр работы в сети, а сконцентрируемся на том, что имеет практическое значение. Большинство наших читателей наверняка сталкивались с веб-программированием, которое вряд ли обходилось без взаимо действия с какой-либо средой разработки. Посмотрим, что это такое. НТТР- это протокол, понятный всем средам разработки веб-приложений. Углуб ленное изучение НТТР позволит добиться от наших веб-сред более высокой произ водительности и кроме того поможет существенно упростить диагностику возни кающих проблем. Сетевые протоколы для содержимого, получаемого по Интерне ту, показаны на рис. 8.2. Сеть состоит из уровней. Новичкам не следует пугаться множества используемых сокращений. Главное запомнить, что нижние уровни не в курсе, что делается на верху, а верхние не знают, что происходит внизу. Нижние уровни получают поток байтов и передают его дальше. А верхние уровни не интересуются способами от правки сообщений, они только выражают потребность в их отправке. Рассмотрим пример: НТТР. Эта аббревиатура известна как протокол уровня при ложений. Его задача - транспортировка содержимого форматов HTML, CSS, JavaScript, модулей WebAssemЬly, изображений, видео и т.д. Упомянутые здесь форматы зачастую включают в себя другие встроенные форматы, полученные в результате применения стандартов сжатия и кодирования. В сам НТТР часто избы точно включается информация, предоставляемая одним из нижележащих уровней под названием ТСР. Между НТТР и ТСР находится уровень TLS (Transport Layer Security - безопасность транспортного уровня), который заменил протокол безо пасных соединений SSL (Secure Sockets Layer), добавив к аббревиатуре НТТР бук ву «S» - HTTPS.
Работа в сети
329
Способ общения компьютеров /IP I CP S ь T ль O л де оде Мо М
ОПИСАНИЕ
Вертикальное расположение обычно указывает на то, что в этом месте взаимодействуют два уровня.
ПРИЛОЖЕНИЕ
Я, Д ЕКО Д Д АН
5
ИРО ВА
НЫ Е
НИ ЕИ
ПР ЕЗ Е
ТРАНСПОРТИРОВКА
НТ А
ЦИ Я
4
3
2 1
СТА Н
КОНТАКТЫ
Пустоты означают, что с верхнего уровня можно сразу перейти на более низкий уровень. Например, для работы НТТР не требуется доменное имя или ТLS-безопасность.
Д И ЗАК ТЫ ОН АР
К исключениям относится шифрование, предоставляемое TLS Сетевая адресация обеспечивается IPv4 или IPv6, а виртуальные уровни в большей степени игнорируют физические каналы. (Физические свойства все же проявляются в верхних уровнях в виде задержек и степени безотказности.)
ИНТЕРНЕТ
СПОСОБ ПРОЧТЕНИЯ
Ф АЙЛ Ы
Т КС ТЕ
Чтобы сообщение было получено, каждый уровень должен быть пройден снизу вверх. А для отправки сообщения надо проделать обратный путь
6
7
СОЕДИНЕНИЕ
Иногда случаются взаимопроникновения уровней. Например, НТМL-файлы могут включать директивы, перезаписывающие те директивы, что предоставляются протоколом НТТР
ЛОКАЛЬНАЯ ДЕ КОМП РЕ СИ
Ы
Вид сетевого стека. Каждый уровень зависит от уровней, расположенных под ним.
LEGEND Протокол, рассматриваемый в этой главе
Е
ВИ
И ЯЦ СЛ
Я
О ДЕ
Этот протокол доступен, но не может быть развернут.
АН
Представление сотен других протоколов, имеющихся на этом уровне.
ПР Я М АЯ ПО ТР Т ОК ОВ О
Протокол, используемый на этом уровне
Рис. 8.2. Ряд уровней сетевых протоколов, задействованных в отправке содержимого через Интернет. На рисунке сравниваются некоторые общепринятые модели, включая семиуровневую модель OSI и четырехуровневую модель TCP/IP.
330
Глава 8
TLS обеспечивает обмен зашифрованными сообщениями по незашифрованному соединению. TLS реализован в виде надстройки над ТСР. А ТСР является над стройкой над многими другими протоколами. Они полностью определяют порядок интерпретации разности электрических потенциалов в нули и единицы. Но все еще сложнее, чем представляется на данном этапе. Эти уровни, как вы, наверное, смог ли заметить при работе с ними в качестве пользователя компьютера, склонны к взаимопроникновению, растекаясь друг по другу, словно акварельные краски. HTML включает в себя механизм дополнения или перезаписи пропущенных или указанных НТТР-директив: атрибут -тега http-equiv. НТТР может спускать корректировки в адрес ТСР. НТТР-заголовок "Connection: keep-alive" предпи сывает ТСР сохранять подключение после получения этого НТТР-сообщения. И подобные взаимодействия случаются по всему стеку. На рис 8.2 представлен один из видов сетевого стека. Бывают и более простые его представления. И даже эта не самая простая картинка сильно упрощена. Как бы то ни было, но мы постараемся уместить в одной главе реализацию как можно большего количества уровней. К концу главы у вас появится возможность отправки НТТР-запросов с помощью виртуального сетевого устройства и само стоятельно созданной минимальной реализации ТСР, используя также созданный своими руками DNS-преобразователь.
8.2. Создание НТТР GЕТ-запроса с использованием reqwest Первая наша реализация состоится с использованием высокоуровневой библиоте ки, нацеленной на использование протокола НТТР. В ход пойдет библиотека reqwest, поскольку именно она предназначена в основном для облегчения Rust программистам решения задачи по созданию НТТР-запроса. Пусть rеqwеst-реализация и самая лаконичная, но функционально она считается наиболее полноценной. Наряду с возможностью корректной интерпретации НТТР заголовков она также справляется с другими задачами, например с перенаправле ниями контента. А самое важное, понимает, как правильно следует обращаться с протоколом TLS. Вдобавок к расширенным возможностям сетевой работы reqwest проверяет коди ровку содержимого и обеспечивает его отправку в ваше приложение в виде прием лемого значения типа string. На что-либо подобное не способна ни одна из наших низкоуровневых реализаций. Структура проекта для листинга 8.2 имеет следующий вид: ch8-simple/ ├── src │ └── main.rs └── Cargo.toml
Работа в сети
331
В следующем листинге представлены метаданные для кода листинга 8.2. Исходный код листинга находится в файле ch8/ch8-simple/Cargo.toml. Листинг 8.1. Метаданные контейнера для кода листинга 8.2 [package] name = "ch8-simple" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018"
[dependencies] reqwest = "0.9"
Порядок создания НТТР-запроса с помощью библиотеки reqwest показан в сле дующем листинге. Его исходный код находится в файле ch8/ch8-simple/src/main.rs. Листинг 8.2. Создание HTTP-запроса с помощью reqwest 1 use std::error::Error; 2 3 use reqwest; 4 5 fn main() -> Result { 6
let url = "http:/ /www.rustinaction.com/";
7
let mut response = reqwest::get(url)?;
(1)
8 9 10
let content = response.text()?; print!("{}", content);
11 12
Ok(())
13 }
(1) Box представляет собой типажный объект, рассматриваемый в разделе 8.3.
Знакомым с веб-программированием код листинга 8.2 должен быть понятен. Метод reqwest::get () выдает НТТР GЕТ-запрос на URL-aдpec, представленный значени ем переменной url. В переменной response содержится структура, представляю щая ответ сервера. Метод response . t e x t () возвращает значение типа Resul t, пре доставляющее доступ к телу НТТР, после проверки содержимого на принадлеж ность К типу String.
Но возникает вопрос: «А что собой представляет та часть возвращаемого типа Re sul t, которая относится к ошибке, то есть, что такое Box? В данном случае мы имеем дело с типажным объектом, позволяющим Rust под держивать полиморфизм в ходе выполнения программы. Типажные объекты яв-
332
Глава 8
ляются посредниками конкретных типов. Синтаксис Box означает Вох (указатель) на любой тип с реализацией std::error:Error. Использование библиотеки, разбирающейся в НТТР, позволяет нашим программам обходиться без многих конкретных деталей. Например, нам не нужно: • Знать, когда закрыть подключение. В НТТР есть правила сообщения каждой из сторон о завершении подключения. При самостоятельном создании запро сов это нам недоступно, поскольку в таком случае подключение поддержива ется как можно дольше, в надежде, что оно будет закрыто сервером. • Выполнять преобразование потока байтов в контент. Правила преобразо вания тела сообщения из [uBJ в string (или, может быть, в изображение, ви део или какой-нибудь другой контент) выполняются в качестве части прото кола. Самостоятельная обработка может быть весьма трудоемкой, поскольку НТТР позволяет использовать контент, сжатый несколькими способами и за кодированный в ряд простых текстовых форматов. • Вставлять или пропускать номера портов. По умолчанию НТТР использует порт 80. Библиотеки, предусматривающие работу с НТТР, в том числе и reqwest, позволяют не указывать номера портов. Но при создании запросов вручную с созданием ТСР-модулей все приходится указывать в явном виде. • Выполнять разрешение IР-адресов. К примеру, протоколу ТСР фактически неизвестны доменные имена вроде www.rustinaction.com. Разрешение IР-адреса www. rustinaction. com библиотека выполняет за нас.
8.3. Типажные объекты В этом разделе рассматриваются подробности типажных объектов. Также здесь ос вещается разработка RРG-проекта и создание очередной самой продаваемой в мире фэнтезийной ролевой игры. Если же нужно сосредоточиться на работе в сети, пере ходите к разделу 8.4. В нескольких следующих абзацах нам без разумного количества жаргонных слов бу дет просто не обойтись. Соберитесь, и у вас все получится. Начнем с представления типажных объектов, но при этом не станем подробно выяснять, что это такое, а раз беремся в том, чего добиваются с их помощью, и чем они, собственно, занимаются.
8.3.1. На что способны типажные объекты? Несмотря на то, что у типажных объектов множество различных применений, их непосредственная польза выражается в предоставлении возможности создания кон тейнеров для множества типов. При том, что игроки в нашей ролевой игре могут остановить свой выбор на одной из многих рас и каждая раса определена в своей собственной структуре struct, желательно обходиться с этими расами как с одним и тем же типом. Выражение vec здесь не будет работать, потому что просто вставить типы т, u и v в Vec без введения какого-либо типа объекта-оболочки невозможно.
Работа в сети
333
8.3.2. Что такое типажные объекты? Типажные объекты добавляют в язык Rust форму полиморфизма, то есть допуска ют посредством динамической диспетчеризации совместное использование интер фейса сразу несколькими типами. А обобщения допускают полиморфизм посредст вом статической диспетчеризации. Выбор между обобщениями и типажными объ ектами обычно основывается на компромиссах между дисковым пространством и временем: • Обобщения используют больше дискового пространства и характеризуются более высоким темпом выполнения программы. • Типажные объекты занимают меньше дискового пространства, но из-за кос венности указателя влекут за собой незначительные издержки времени вы полнения. Типажные объекты относятся к типам с динамически изменяемым размером, что означает их вполне естественное присутствие за указателем. Типажные объекты существуют в трех формах: &dyn Trai t, &rnut dyn Trait и Box 1 • Глав ной отличительной особенностью этих трех форм является то, что Box типажный объект, находящийся в чьем-то владении, а представители двух других форм заимствуются.
8.3.3. Создание небольшой ролевой игры: rрg-проект Начальный код игры находится в листинге 8.4. Персонажи игры могут относиться к одной из трех рас: люди (humans), эльфы (elves) и гномы (dwarves). Все они пред ставлены соответственно структурами Hurnan, Elf и Dwarf.
Персонажи взаимодействуют с вещами, представленными типом Thing2 • Этот тип является перечислением, представляющим на данный момент мечи и всякие безде лушки. Пока имеется только одна форма взаимодействия: наложение заклинаний. Чтобы наложить на вещь заклинание, нужно вызвать метод enchant (): character.enchant(&mut thing)
Если наложение заклинания проходит успешно, вещь начинает ярко светиться. При ошибке вещь превращается в безделушку. Партия персонажей создается в коде листинга 8.4 с применением следующего синтаксиса: 58 let d = Dwarf {}; 59 let e = Elf {}; 60 let h = Human {}; 61 62 let party: Vec = vec![&d, &h, &e];
(1)
1 В старом Rust-кoдe встречаются формы &Trai t и Box. Этот синтаксис допускается, но официально считается устаревшим. Добавление ключевого слова dyn входит в число настоятельных рекомендаций. 2 Названия даются непросто.
334
Глава 8
(1) Хотя d, е и h - разные типы, использование аннотации типа &dyn Enchanter предписывает компилятору считать каждое значение типажным объектом. Теперь все они ОДНОГО типа.
Для произнесения заклинания нужно выбрать заклинателя (spellcaster). Для этого мы воспользуемся контейнером rand: 58 let spellcaster = party.choose(&mut rand::thread_rng()).unwrap(); 59 spellcaster.enchant(&mut it)
Метод choose () берется из типажа r a n d: : seq: : SliceRandorn, помещаемого в об ласть видимости в листинге 8.4. Затем случайным образом выбирается один из уча стников. После чего участник пытается наложить заклинание на объект i t. Компи ляция и запуск кода листинга 8.4 приводят примерно к следующему: $ cargo run ... Compiling rpg v0.1.0 (/rust-in-action/code/ch8/ch8-rpg) Finished dev [unoptimized + debuginfo] target(s) in 2.13s Running `target/debug/rpg` Human mutters incoherently. The Sword glows brightly. (2) Elf mutters incoherently. The Sword fizzes, then turns into a worthless (3) trinket.
$ target/debug/rpg
(1) Человек что-то бормочет. Меч начинает ярко светиться. (2) Повторная выдача команды без перекомпиляции. (3) Эльф что-то бормочет. Меч шипит и превращается в безделушку.
Метаданные для нашей фэнтезийной ролевой игры показаны в следующем листин ге. Исходный код rрg-проекта находится в файле ch8/ch8-rpg/Cargo.toml. Листинг 8.3. Метаданные контейнера для rpg-проекта [package] name = "rpg" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] rand = "0.7"
В листинге 8.4 представлен пример использования типажного объекта, позволяю щего контейнеру содержать несколько типов. Его исходный код находится в файле ch8/ch8-rpg/src/main.rs.
Работа в сети
335
Листинг 8.4. использование типажного объекта &dyn Enchanter 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
use rand; use rand::seq::SliceRandom; use rand::Rng; #[derive(Debug)] struct Dwarf {} #[derive(Debug)] struct Elf {} #[derive(Debug)] struct Human {} #[derive(Debug)] enum Thing { Sword, Trinket, } trait Enchanter: std::fmt::Debug { fn competency(&self) -> f64; fn enchant(&self, thing: &mut Thing) { let probability_of_success = self.competency(); let spell_is_successful = rand::thread_rng() .gen_bool(probability_of_success);
(1)
print!("{:?} mutters incoherently. ", self); if spell_is_successful { println!("The {:?} glows brightly.", thing); } else { println!("The {:?} fizzes, \ then turns into a worthless trinket.", thing); *thing = Thing::Trinket {}; } } } impl Enchanter for Dwarf { fn competency(&self) -> f64 { 0.5 } } impl Enchanter for Elf { fn competency(&self) -> f64 { 0.95
(2)
(3)
336 47
Глава 8 }
48 } 49 impl Enchanter for Human { 50
fn competency(&self) -> f64 {
51 52
0.8
(4)
}
53 } 54 55 fn main() { 56
let mut it = Thing::Sword;
57 58
let d = Dwarf {};
59
let e = Elf {};
60
let h = Human {};
61 62
let party: Vec = vec![&d, &h, &e];
63
let spellcaster = party.choose(&mut rand::thread_rng()).unwrap();
(5)
64 65
spellcaster.enchant(&mut it);
66 }
(1) Функция gen_bool() вьщает булево значение, где частота появления true задается его аргументом. Например, при значении 0.5 значение true возвращается в 50% случаев. (2) Гномы - слабые заклинатели, и их заклинания то и дело не действуют. (3) Наложение заклинаний эльфами редко бывает неудачным. (4) Люди умеют накладывать заклинания. Оимбки случаются крайне редко. (5) Участники разных типов могут содержаться в одном и том же векторе Vec, поскольку всеми ими реализуется типаж Enchanter.
Типажные объекты - весьма эффективная конструкция языка. По сути они пре доставляют способ навигации по жесткой системе типов языка Rust. Далее, в ходе более глубокого разбирательства с этой особенностью, будут попадаться разные жаргонные выражения. Например, типажные объекты являются формой стирания типов. Во время вызова enchant () компилятор не имеет доступа к исходному типу.
Сравнение типажа и типа Новичков синтаксис Rust сбивает с толку одинаковым внешним видом типажных объектов и параметров типа. Но типы и типажи используются в разных местах. Рассмотрим, к примеру, следующие две строки кода: use rand::Rng; use rand::rngs::ThreadRng;
Хотя у обеих строк имеется некоторое отношение к генераторам случайных чисел, они совершенно разные. r aпd: : Rng является типажом, а r and: : rngs: : ThreadRng структурой. Типажные объекты мешают распознаванию этого различия еще больше. При использовании в качестве аргумента функции в одних и тех же местах форма &dyn Rng является ссьшкой на что-то, имеющее реализацию типажа Rng, а &ThreadRng
Работа в сети
337
является ссылкой на значение ThreadRng. Со временем различие между типажами и типами становится очевиднее. Приведем несколько типичных случаев использова ния типажных объектов: • Создание коллекций гетерогенных объектов. • Возвращение значения. Типажные объекты позволяют функциям возвращать несколько конкретных типов. • Поддержка динамической диспетчеризации, при этом вызываемая функция определяется в ходе выполнения программы, а не в ходе компиляции ее кода. До выхода Rust редакции 2018 года ситуация бьша еще более запутанной. Ключе вого слова dyn не бьшо. То есть для выбора между &Rng и &ThreadRng нужен был контекст. Типажные объекты не являются объектами в понятиях, привычных программисту, работающему с объектно-ориентированными языками. Возможно, они ближе к миксинам. Типажные объекты не существуют сами по себе, они - агенты какого то другого типа. Еще одной аналогией может послужить одноэлементный объект (синглтон), кото рому другим конкретным типом делегированы некоторые полномочия. В листинге 8.4 типажному объекту &Enchanter делегировано действие от имени трех конкрет ных типов.
8.4. ТСР Ниже уровня НТТР находится ТСР (Transmission Control Protocol - протокол управления передачей). Для создания ТСР-запросов стандартная библиотека Rust предоставляет нам кроссплатформенные инструменты. Давайте ими и воспользу емся. Файловая структура для кода листинга 8.6, создающего НТТР GЕТ-запрос, имеет следующий вид: ch8-stdlib ├── src │ └── main.rs └── Cargo.toml
В следующем листинге показаны метаданные для кода листинга 8.6. Его исходный код находится в файле ch8/ch8-stdliЬ/Cargo.toml. Листинг 8.4. Метаданные проекта к листингу 8.6 [package] name = "ch8-stdlib" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies]
338
Глава 8
В следующем листинге показан порядок использования стандартной библиотеки Rust при создании НТТР GЕТ-запроса с помощью std: :net: :Tcpstream. Его ис ходный код находится в файле ch8/ch8-stdliЬ/src/main.rs. Листинг 8.6. Создание HTTP GET-запроса 1 use std::io::prelude::*; 2 use std::net::TcpStream; 3 4 fn main() -> std::io::Result { 5
let host = "www.rustinaction.com:80";
(1)
6 7 8
let mut conn = TcpStream::connect(host)?;
9 10
conn.write_all(b"GET / HTTP/1.0")?;
11
conn.write_all(b"\r\n")?;
(2)
12 13
conn.write_all(b"Host: www.rustinaction.com")?;
14
conn.write_all(b"\r\n\r\n")?;
(3)
15 16
std::io::copy(
17
&mut conn,
18 19
&mut std::io::stdout() )?;
(4) (4) (4)
(4)
20 21
Ok(())
22 }
(1) Здесь требуется явное указание nорта (80). TcpStream не знает, что это станет НТТР-запросом. (2) Во многих сетевых протоколах комбинация символов \r\n означает переход на новую строку. (3) Две пустые новые строки означают конец запроса. (4) std::io::copy() передает байты от механизма чтения в механизм записи. Некоторые комментарии к коду листинга 8.6: • В строке 1 0 имеется указание на использование НТТР 1.0. Применение этой версии НТТР гарантирует закрытие подключения сразу же, как только сервер отправит ответ. Но в НТТР 1.0 не поддерживаются запросы с удержанием подключения типа "keep alive". А вот указание на НТТР 1.1 для этого кода не подойдет, поскольку сервер откажется закрыть подключение до получения им следующего запроса, а клиентом такой запрос отправляться не будет. • В строке 13 включено имя хоста. Это можно посчитать излишним, поскольку точно такое же имя использовалось при подключении, заданном в строках 7-8. Но все же следует помнить, что соединение устанавливается по IP, у которо-
Работа в сети
339
го нет имен хостов. Когда TcpStream: : connect () выполняет подключение к серверу, им используется только IР-адрес. Добавление НТТР-заголовка Host позволяет вернуть эту информацию в контекст.
8.4.1. Что такое номер порта? Номера портов - чисто виртуальные понятия. Это просто значения типа ulб. Но мера портов позволяют по одному и тому же IР-адресу размещать сразу несколько служб.
8.4.2. Преобразование имени хоста в IР-адрес Пока что нашей программе на Rust предоставлялось имя хоста www.rustinaction.com. Но для отправки сообщений по Интернету нужен адрес по протоколу интернета IР-адрес. Протоколу ТСР совершенно непонятны доменные имена. Чтобы преобра зовать доменное имя в IР-адрес, мы полагаемся на систему доменных имен, Domain Name System (DNS), а выполняемый этой системой процесс преобразования назы вается разрешением доменного имени. Мы можем разрешать имена, запрашивая сервер, а он рекурсивно запрашивать дру гие серверы. DNS-запросы могут выполняться через ТСР, включая шифрование с помощью TLS, но также отправляться через UDP (протокол пользовательских дата грамм). Здесь будет использоваться DNS, поскольку эта система в целях обучения наиболее полезна. Чтобы объяснить, как работает преобразование доменного имени в IР-адрес, созда дим небольшое приложение, выполняющее такое преобразование. Назовем это приложение resolve (разрешение). Его код находится в листинге 8.9. В приложении используются открытые DNS-сервисы, но аргумент -s позволяет вам без особого труда добавить еще и свой собственный сервис.
Провайдеры открытых DNS На момент написания книги провайдерами открытых DNS-cepвepoв выступали не сколько компаний. Примерно одинаковый уровень услуг может быть предоставлен по любому из нижеперечисленных IР-адресов: • 1.1.1.1 и 1.0.0.1 от Cloudflare • 8.8.8.8 и 8.8.4.4. от Google • 9.9.9.9 от Quad9 (основана компанией IВМ) • 64.6.64.6 и 64.6.65.6 от VeriSign Приложение resolve понимает только небольшую часть протокола DNS, но для дос тижения наших целей этого вполне достаточно. Для выполнения всей тяжелой ра боты в проекте задействован внешний контейнер под названием trust-dns. В этом контейнере реализуются положения документа RFC 1035, содержащие определение DNS и нескольких более поздних RFС-документов с довольно точным использова нием полученной из них терминологии. Некоторые полезные для понимания тер мины приведены в таблице 8.1.
340
Глава 8
Таблица 8.1. Термины, использованные в документе RFC 1035, контейнере trust_dns и листинге 8.9, и их взаимосвязанность
Представление в коде
Термин
Определение
Доменное имя, Domainname
Определено в Доменное имя - это почти то, trust_dns::domain::Name о чем вы, вероятно, думаете, когда используете термин pub struct Name { «доменное имя» в повседневной is_fqdn: bool, (1) речи. labels: Vec, Техническое определение вклю чает ряд особенностей, таких как ) корневой домен, который коди(1) fqdn означает полное доменное имя. руется в виде точки, и имена доменов, которые должны быть нечувствительны к регистру символов.
Сообщение, Message
Сообщение - контейнер Определено в для запросов к DNS-cepвepaм trust_dns::domain::Name (называемых запросами) и отве struct Message { тов клиентам (называемых отве header: Header, тами). queries: Vec, В сообщении должен быть заго ловок, а другие поля носят не обязательный характер. Его представлением является струк тура Message, включающая не сколько полей Vec. Их не нужно заключать в Option для представления отсутствующих значений, поскольку они могут иметь нулевую длину.
Тип сообщения, Тип сообщения определяет со общение как запрос или как от Message type вет. Запросы также могут быть обновлениями, игнорируемыми нашим кодом.
answers: Vec, name_servers: Vec, additionals: Vec, sig0: Vec,
(1)
edns: Option,
(2)
}
sigO является записью с криптографической подписью для проверки целостности сообщения. Определение дано в документе RFC 2535. (2) edns показывает, включает ли сообщение расширенную версию DNS. (1)
Определен в trust_dns::op::MessageType pub enum MessageType { Query, Response, }
Работа в сети
341
Таблица 8.1 (окончание) Термин
Определение
Идентификатор сообщения, Message ID
Номер,используемый отправителями для связывания запросов и ответов.
Тип записи ре сурса,Resource record type
Тип записи ресурса относится к кодам DNS,с которыми вы, вероятно,сталкивались,если когда-либо занимались конфигурированием доменного имени.
Представление в коде ul 6
Определен в trust_dns::rr::record_type:: RecordType рuЬ enurn RecordType { А,
Примечательно то,как в trust_d.ns обрабатываются недо пустимые коды. В перечислении RecordType содержится вариант Unknown (ulб),который может использоваться для непонятных ему кодов.
АААА, АNАМЕ, ANY, //
...
Unknown (ulб), ZERO, }
Запрос,Query
В структуре Query содержатся Определен в trust_dns:: ор::Query имя домена и тип записи,для pub struct Query ( которых выполняется поиск narne: Narne, DNS-сведений. В этих типажах также описывается класс DNS query_type: RecordType, и они позволяют запросам отли query_class: DNSClass, чать сообщения,отправленные } через Интернет,от других транспортных протоколов.
Код операции, Opcode
Перечисление OpCode в каком-то смысле является подтипом MessageType. Это механизм расширяемости,обеспечиваю щий будущую функциональ ность. Например,в документе RFC 1035 определены коды опе раций Query и Status,но ос тальные коды получили свое определение в более поздних документах.Коды операций Notify и Update определены соответственно в документах RFC 1996 и RFC 2136.
Определен в trust_dns:: ор::OpCode pub enurn OpCode { Query, Status, Notify, Update, }
342
Глава 8
К сожалению, использование протокола влечет за собой использование множества вариантов, типов и подтипов, что, как мне представляется, является следствием отображения реальной обстановки. В листинге 8.7, который является частью лис тинга 8.9, показывается процесс создания сообщения, обращающегося со следую щей просьбой: «Дорогой DNS-cepвep, каков будет 1Рv4-адрес для domain_name?». В коде листинга создается DNS-сообщение, а контейнер trust-dns запрашивает 1Рv4адрес для domain _name. Листинг 8.7. Создание DNS-сообщения на языке Rust 35 let mut msg = Message::new();
(1)
36 msg 37
.set_id(rand::random::())
38
.set_message_type(MessageType::Query)
39
.add_query(
40
Query::query(domain_name, RecordType::A)
41
)
42
.set_op_code(OpCode::Query)
43
.set_recursion_desired(true);
(1) (2) (3) (4) (5)
(2) (3) (4)
(5)
Message - контейнер для запросов (или ответов). Генерирование случайного числа типа u16. В одно сообщение могут быть включены сразу несколько запросов. Эквивалентным типом для IРv6-адресов является АААА. Заставляет DNS-cepвep запраnмвать неизвестный ему ответ у других DNS-cepвepoв.
Теперь мы в состоянии осмысленно изучить код. Он имеет следующую структуру: • Анализ аргументов командной строки. • Создание DNS-сообщения с использованием типов, принадлежащих контей неру trust_dns. • Преобразование структурированных данных в поток байтов. • Отправка этих байтов по линиям связи. После этого нужно принять ответ от сервера, декодировать входящие байты и вы вести результат на консоль. Обработка ошибок из-за большого количества вызовов unwra p ()и expect () имеет слегка неприглядный вид. К этому вопросу мы еще вер немся в разделе 8.5. В конечном результате получится довольно простое приложе ние командной строки. Для запуска нашего приложения resolve требуется соблюсти небольшую церемо нию. При предоставлении доменного имени приложение выдает IР-адрес: $ resolve www.rustinaction.com 35.185.44.232
Работа в сети
343
Исходный код проекта представлен в листингах 8.8 и 8.9. При проведении экспе риментов с проектом для ускорения процесса может появиться желание воспользо ваться рядом особенностей cargo: $ cargo run -q -- www.rustinaction.com 35.185.44.232
(1)
(1) Отправка аргументов, указанных справа от сдвоенного дефиса, (--) исполняемому файлу, получаемому в результате компиляции. Ключ -q подавляет вывод на консоль всей промежуточной информации.
Чтобы скомпилировать приложение resolve из официального хранилища исходного кода, нужно выполнить на консоли следующие команды: $ git clone https://github.com/rust-in-action/code rust-in-action Cloning into 'rust-in-action'... $ cd rust-in-action/ch8/ch8-resolve $ cargo run -q -- www.rustinaction.com 35.185.44.232
(1)
(1) Загрузка зависимостей проекта и компиляция кода могут занять некоторое время. Ключ -q подавляет вывод всей промежуточной информации. Добавление сдвоенного дефиса (--) приводит к отправке следующих за ними аргументов скомпилированному исполняемому коду.
Для компиляции и сборки с нуля, а также для создания структуры проекта нужно выполнить следующие действия: 1. Ввести в командной строке показанные ниже команды: $ carqo new resolve Created binary (application) 'resolve' package
$ carqo install carqo-edit ...
$ cd
resolve
$ carqo add
[email protected]
Updating 'https:/ /github.com/rust-lang/crates.io-index' index Adding rand v0.6 to dependencies
$ carqo
add clap@2
Updating 'https:/ /github.com/rust-lang/crates.io-index' index Adding rand v2 to dependencies
$ carqo add [email protected] --no-default-features Updating 'https:/ /githuЬ.com/rust-lang/crates.io-index' index Adding trust-dns v0.16 to dependencies
344
Глава 8
2. Проверить после создания структуры соответствие содержимого вашего файла Cargo.toml коду листинга 8.8, доступного в файле ch8/ch8-resolve/Cargo.toml. 3. Заменить содержимое файла src/main.rs кодом листинга 8.9. Этот код можно взять из файла ch8/ch8-resolve/src/main.rs. Следующий фрагмент кода дает представление о взаимосвязанности листингов и файлов проекта: ch8-resolve ├── Cargo.toml
(1)
└── src └── main.rs
(2)
(1) См. листинг 8.8 (2) См. листинг 8. 9 Листинг 8.8. Контейнер метаданных для приложения resolve [package] name = "resolve" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] rand = "0.6" clap = "2.33" trust-dns = { version = "0.16", default-features = false }
Листинг 8.9. Утилита командной строки, предназначена для разрешения IP-адресов из имени хостов 1 use std::net::{SocketAddr, UdpSocket}; 2 use std::time::Duration; 3 4 use clap::{App, Arg}; 5 use rand; 6 use trust_dns::op::{Message, MessageType, OpCode, Query}; 7 use trust_dns::rr::domain::Name; 8 use trust_dns::rr::record_type::RecordType; 9 use trust_dns::serialize::binary::*; 10 11 fn main() { 12
let app = App::new("resolve")
13
.about("A simple to use DNS resolver")
14
.arg(Arg::with_name("dns-server").short("s").default_value("1.1.1.1"))
15
.arg(Arg::with_name("domain-name").required(true))
Работа в сети 16
345
.get_matches();
17 18 19 20 21
let domain_name_raw = app .value_of("domain-name").unwrap(); let domain_name = Name::from_ascii(&domain_name_raw).unwrap();
(1) (1) (1)
(1)
22 23 24 25
let dns_server_raw = app .value_of("dns-server").unwrap(); let dns_server: SocketAddr =
(2) (2) (2)
26
format!("{}:53", dns_server_raw)
(2)
27
.parse()
(2)
28
.expect("invalid address");
(2)
29 30 31 32 33
let mut request_as_bytes: Vec = Vec::with_capacity(512); let mut response_as_bytes: Vec = vec![0; 512];
(3) (3) (3) (3)
34 35
let mut msg = Message::new();
36
msg
37
.set_id(rand::random::())
38
.set_message_type(MessageType::Query)
39
.add_query(Query::query(domain_name, RecordType::A))
40
.set_op_code(OpCode::Query)
41
.set_recursion_desired(true);
(4)
(5)
42 43 44 45
let mut encoder = BinEncoder::new(&mut request_as_bytes);
(6)
msg.emit(&mut encoder).unwrap();
46 47
let localhost = UdpSocket::bind("0.0.0.0:0")
48
.expect("cannot bind to local socket");
49
let timeout = Duration::from_secs(3);
50
localhost.set_read_timeout(Some(timeout)).unwrap();
51
localhost.set_nonblocking(false).unwrap();
52 53
let _amt = localhost
54
.send_to(&request_as_bytes, dns_server)
55
.expect("socket misconfigured");
56 57
let (_amt, _remote) = localhost
58
.recv_from(&mut response_as_bytes)
59
.expect("timeout reached");
60 61 62
let dns_message = Message::from_vec(&response_as_bytes) .expect("unable to parse response");
(7)
346
Глава 8
63 64
for answer in dns_message.answers() {
65
if answer.record_type() == RecordType::A {
66
let resource = answer.rdata();
67
let ip = resource
68
.to_ip_addr()
69
.expect("invalid IP address received");
70
println!("{}", ip.to_string());
71
}
72
}
73 }
(1) (2) (3) (4)
Преобразование аргумента командной строки в типизированное доменное имя. Преобразование аргумента командной строки в типизированный DNS-cepвep. Объяснение причины использования двух форм инициализации дается после листинга. Message - представление DNS-сообщения, являющегося контейнером для запросов и другой информации, например, ответов. (5) Указание на то, что это DNS-зaпpoc, а не DNS-ответ. Оба они при передаче по линии связи имеют одинаковое представление, но не в случае системы типов, принятой в Rust. (6) Преобразования вида сообщения Message в простые байты с помощью BinEncoder. (7) 0.0.0.0:0 означает прослушивание всех адресов на произвольном порту. Конкретный порт выбирается операционной системой.
В листинге 8.9 имеется бизнес-логика, с которой стоит разобраться. В повторенных далее строках 30-33 используются две формы инициализации vec. Зачем? 30
let mut request_as_bytes: Vec =
31
Vec::with_capacity(512);
32
let mut response_as_bytes: Vec =
33
vec![0; 512];
Результат одной формы немного отличается от результата другой: •
Vec: :wit h_capacity(512)
•
vec! [О; 512]
создает V ec с длиной О и емкостью512.
создаетvесс длиной512 и емкостью512.
В результате в обоих случаях получается вроде бы одинаковый массив, но разница в длине играет важную роль. При вызове метода r ecv_ from () в строке 58 контей нер t rust-dns проверяет наличие достаточного места в r esponse_as_byt e s. В этой проверке, приводящей к сбою, используется поле длины. Знание тонкостей ини циализации может пригодиться для оправдания ожиданий АРI-интерфейсов.
Как DNS поддерживает подключения по UDР-протоколу UDP не предназначен для долговременных подключений. В отличие от ТСР, все сообщения являются недолговечными и однонаправленными. Иными словами, UDP не поддерживает двусторонний (дуплексный) обмен данными. Но DNS требу ется отправить клиенту ответ от DNS-cepвepa.
Работа в сети
347
Чтобы наладить двусторонний обмен данными по UDР-протоколу, обе стороны в зависимости от контекста должны действовать и как клиенты, и как серверы. Этот контекст определяется протоколом, построенным на основе UDP. В DNS для полу чения ответа сервера клиент становится сервером. Схема информационных потоков этого процесса представлена в следующей таблице. Этап
Роль DNS-клиента
Роль DNS-cepвepa
Запрос отправлен от DNS-клиента
UDР-клиент
UDP-cepвep
Ответ отправлен от DNS-cepвepa
UDP-cepвep
UDР-клиент
Подведем итоги. Нашей главной задачей в этом разделе было создание НТТP запросов. НТТР построен на ТСР. Поскольку мы располагаем только лишь доменным именем (www.rustinaction.com), при выполнении запроса возникает необходимость в использовании DNS. А данные в DNS в основном поставляются через UDP, поэтому нам нужно было сделать небольшое отступление и узнать, что такое UDP.
А теперь, похоже, настало время вернуться к ТСР. Но, прежде чем представится такая возможность, придется освоить приемы комбинирования типов ошибок, ис точником которых являются сразу несколько зависимостей.
8.5. Способы обработки ошибок, наиболее удобные для помещения в библиотеки Система обработки ошибок, принятая в Rust, отличается безопасностью и сложно стью построений, но при этом не обходится без возникновения ряда проблем. Ко гда в функцию включаются значения типа Resul t из двух предыдущих контейне ров, оператор в виде вопросительного знака (?) больше не работает, поскольку ему понятен только один тип. Это обстоятельство играет важную роль при реструкту ризации кода разрешения доменных имен с целью его совместной работы с нашим кодом ТСР. В этом разделе рассматривается целый ряд возможных проблем, а также стратегии их разрешения.
8.5.1. Проблема: невозможность возвращения нескольких типов ошибок Возвращение Result успешно работает только при наличии одного типа ошибок Е. Но, как только возникает потребность возвращения нескольких типов ошибок, ситуация резко усложняется. СОВЕТ При работе с отдельными файлами код лучше компилировать с помощью команды rustc , отказавшись от использования cargo build. Например, если файл называется io-error.rs, то в командной строке оболочки следует набрать rustc io-error.rs && ./io-error[.exe].
348
Глава 8
Для начала рассмотрим небольшой пример, касающийся простого случая использо вания единственного типа ошибки. Попробуем открыть несуществующий файл. При запуске на выполнение код листинга 8.10 выведет на консоль краткое сообще ние в синтаксисе Rust: $ rustc ch8/misc/io-error.rs && ./io-error Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Особого опыта при этом мы, конечно, не приобретем, но зато получим шанс изу чить новую особенность языка. В следующем листинге показан код, выдающий единственный тип ошибки. Этот код находится в файле ch8/misc/io-error.rs. Листинг 8.10. Программа на языке Rust, неизменно выдающая ошибку ввод-вывода 1 use std::fs::File; 2 3 fn main() -> Result { 4
let _f = File::open("invisible.txt")?;
5 6
Ok(())
7 }
А теперь введем в функцию mai n ( > еще один тип ошибки. Код следующего лис тинга приводит к ошибке компиляции, но для получения компилируемого кода мы просмотрим несколько вариантов. Этот код находится в файле ch8/misc/mt.iltierror.rs. Листинг 8.11. Функция, пытающаяся возвратить сразу несколько типов Result 1 use std::fs::File; 2 use std::net::Ipv6Addr; 3 4 fn main() -> Result { 5
let _f = File::open("invisible.txt")?;
(1)
let _localhost = "::1"
(2)
6 7 8
.parse::()?;
(2)
9 10
Ok(())
11 } (1) File::open() возвращает Result. (2) "" . parse::() возвращает Result.
Чтобы скомпилировать код листинга 8.11, войдите в каталог ch8/misc и воспользуй тесь командой rustc. В результате будет выдано весьма суровое, но все же полез ное сообщение об ошибке: $ rustc multierror.rs error[E0277]: `?` couldn't convert the error to `std::io::Error`
Работа в сети
349
--> multierror.rs:8:25 | 4 | fn main() -> Result { |
-------------------------- expected `std::io::Error` because of this
... 8 |
.parse::()?;
|
^ the trait `From` is not implemented for `std::io::Error`
| = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait = help: the following implementations were found:
= note: required by `from` error: aborting due to previous error For more information about this error, try `rustc --explain E0277`.
Если не знать, чем занимается оператор в виде вопросительного знака (? ), понять суть ошибки будет непросто. Почему здесь несколько сообщений, касающихся s td: : convert: : From? Начнем с того, что оператор? является удобной синтаксиче ской заменой макроса t ry! , выполняющего две функции: •
При обнаружении Ok (value ) это выражение вычисляется в значение v alue.
•
При обнаружении E r r (err), t ry! или? выполняет возвращение сразу же по сле попытки преобразования e r r в тип e rror, определение которого находит ся в вызывающей функции.
В Rust-подобном псевдокоде макрос t ry! можно определить следующим образом: macro try { match expression { Result::Ok(val) => val,
(1)
Result::Err(err) => {
}
let converted = convert::From::from(err);
(2)
return Result::Err(converted);
(3)
}); }
(1) Когда выражение соответствует Result::Ok(val), используется val. (2) Когда выражение соответствует Result::Err(err), вьmолняется преобразование err в тип error, принадлежащий внешней функции, после чего управление сразу же возвращается этой функции. (3) Возвращение осуществляется не в сам макрос try!, а в вызывавшую функцию.
350
Глава 8
Посмотрев на код листинга 8.11 еще раз, можно увидеть, что макрос try ! работает так же, как и оператор ? : 4 fn main() -> Result { 5
let _f = File::open("invisible.txt")?;
(1)
let _localhost = "::1"
(2)
6 7 8
(2)
.parse::()?;
9 10
Ok(())
11 }
(1) File::open() возвращает std::io::Error, следовательно, в преобразовании нет необходимости. (2) parse() предоставляет оператор ? вместе с std::net::AddrParseError. Порядок преобразования std::net::AddrParseError в std::io::Error определен не был, поэтому программа не пройдет компиляцию. 11 11•
Вдобавок к тому, что оператор ? избавляет от необходимости использования при извлечении значения или возвращении ошибки явно указанного шаблона сопостав ления, в нем также в случае надобности предпринимается попытка преобразования его аргументов в тип ошибки. Поскольку сигнатурой основной функции приложе ния является main () -> Resul t< () , std: : i o : : Erro r>, Rust предпринимает по пытку преобразования значения типа std: : net: :AddrPar seError, произведенное методом parse: : (), в std:: io: :Error. Но волноваться не стоит, все это можно исправить! Чуть раньше, в разделе 8.3, были представлены типажные объек ты. А теперь у нас есть возможность найти им достойное применение. Прогресс намечается за счет использования в функции main () в качестве варианта ошибки выражения вox. Ключевое слово dyn - сокращение от слова «динамичный)) (dynamic) с намеком на то, что предоставляемая гибкость не обой дется без издержек времени выполнения. При запуске кода листинга 8.12 на вы полнение получается следующий вывод на консоль: $ rustc chB/misc/traiterror.rs && ./traiterror
Error: 0s { code: 2, kind: NotFound, message: No such file or directory 1 Какой-никакой, но все же прогресс. Мы вернулись к ошибке, с которой начали, но преодолели ошибку компилятора, чего, собственно, и добивались. 11
1
)
Двигаясь дальше, посмотрим на код листинга 8.12. В нем для упрощения обработки ошибок в случае их происхождения из нескольких вышестоящих контейнеров в возвращаемом значении реализуется типажный объект. Исходный код листинга находится в файле chB/misc/traiterror.rs. Листинг 8.12. Использование типажного объекта в возвращаемом значении 1 use std::fs::File; 2 use std::error::Error; 3 use std::net::Ipv6Addr;
Работа в сети
351
4 5 fn main() -> Result {
(1)
6 7
let _f = File::open("invisible.txt")?;
(2)
8 9 10
let _localhost = "::1" .parse::()?
(3)
11 12
Ok(())
13 }
(1) Типажный объект, Box, является представителем любого типа, реализующего тип Error. (2) Типом 01Ш16ки является std::io::Error. (3) Типом ОIШ16ки является std ::net::AddrParseError.
Необходимость заключения типажных объектов в вох обусловливается тем, что их размер (в байтах в стеке) на момент компиляции неизвестен. Что касается кода лис тинга 8.12, типажный объект может быть производным либо от File: :open (), либо от ": : 1". pars e (). А что произойдет на самом деле, зависит от обстоятельств, скла дывающихся в ходе выполнения программы. У вох есть известный размер в стеке. И смысл его применения заключается в том, чтобы указывать на то, для чего этот размер неизвестен, в том числе и на типажные объекты.
8.5.2. Заключение в оболочку нижестоящих ошибок путем определения нашего собственного типа ошибки Проблема, которую мы пытаемся разрешить, заключается в том, что в каждой из наших зависимостей определяется ее собственный тип ошибки. Наличие в одной функции сразу нескольких типов ошибок не дает возвращать значение типа Resul t. Первой рассмотренной нами стратегией было применение типажных объектов, но потенциально эти объекты имеют весьма существенный недостаток. Использование типажных объектов известно также как затирание типов. При этом Rust теряет сведения о том, что ошибка берет свое начало в вышестоящих контей нерах. Использование Box в качестве варианта ошибки, закладываемо го в Resul t, означает, что вышестоящие типы ошибок в некотором смысле теряют ся. Исходные ошибки теперь преобразуются в один и тот же тип. Вышестоящие ошибки можно сохранить, приложив для этого дополнительные уси лия, отвечающие нашим интересам. Эти ошибки нужно объединить, создав свой собственный тип. Когда чуть позже в них возникнет потребность (предположим, для сообщения о них пользователю), их можно будет извлечь с помощью сопостав ления с образцом. Процесс будет иметь следующий вид: 1. Определение перечисления, включающего вышестоящие ошибки в виде вариантов. 2. Снабжение перечисления аннотацией# [d erive(Debug)].
352
Глава 8
3. Реализация Display. 4. Реализация E rror, достающаяся почти задаром, поскольку уже реализованы De bug и Display.
5. Использование в коде map_err () с целью преобразования вышестоящих ошибок в ваш сборный тип ошибки. ПРИМЕЧАНИЕ
Ранее функция map_err () еще не попадалась. Ее предназначение будет рассмотре но в этом разделе чуть позже.
Можно, конечно, остановиться на предыдущих действиях, но есть еще один необя зательный дополнительный шаг, улучшающий эргономику. Чтобы избавиться от необходимости вызова функции map_err (), нужно реализовать типаж std: : con ver t :: Frorn. Для начала вернемся к коду листинга, который, как известно, дает сбой: use std::fs::File; use std::net::Ipv6Addr; fn main() -> Result { let _f = File::open("invisible.txt")?; let _localhost = "::1" .parse::()?; Ok(()) }
Причина сбоя в том, что '"'.parse: :() не возвращает std: :io: :Error. В конечном итоге желательно получить код, похожий на код следующего листинга. Листинг 8.13. Гипотетический вариант желаемого кода 1 use std::fs::File; 2 use std::io::Error;
(1)
3 use std::net::AddrParseError;
(1)
4 use std::net::Ipv6Addr; 5 6 enum UpstreamError{ 7
IO(std::io::Error),
8
Parsing(AddrParseError),
9 } 10 11 fn main() -> Result { 12
let _f = File::open("invisible.txt")?
13
.maybe_convert_to(UpstreamError);
14
Работа в сети 15
353
let _localhost = "::1"
16
.parse::()?
17
.maybe_convert_to(UpstreamError);
18 19
Ok(())
20 }
(1) Помещение вЬШiестоящих ошибок в локальную область видимости
Определение перечисления, включающего вышестоящие ошибки в качестве вариантов Первым делом нужно возвратить тип, способный содержать типы вышестоящих ошибок. В Rust с этой задачей вполне справляется перечисление. Код листинга 8.13 не проходит компиляцию, но выполняет именно это действие. При этом мы немно го подправим импорт: use std::io; use std::net; enum UpstreamError{ IO(io::Error), Parsing(net::AddrParseError), }
Снабжение перечисления аннотацией # [derive (DeЬug)] Внесение следующего изменения не составит особого труда. Хорошо, когда все действие ограничивается одной строкой кода. Чтобы снабдить перечисление анно тацией, добавим к коду# [de r i v e (Debug)]: use std::io; use std::net; #[derive(Debug)] enum UpstreamError{ IO(io::Error), Parsing(net::AddrParseError), }
Реализация std: :fmt: :Display Давайте немного схитрим и реализуем Display путем простого использования oe Ьug. Поскольку, как известно, ошибки без реализации Debug не обходятся, доступ к нему открыт. Обновленный код приобретает следующий вид: use std::fmt; use std::io; use std::net;
354
Глава 8
#[derive(Debug)] enum UpstreamError{ IO(io::Error), Parsing(net::AddrParseError), } impl fmt::Display for UpstreamError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:?}", self) } } impl error::Error for UpstreamError { }
(2)
(1) Помещение типажа std::error::Error в локальную область видимости (2) Полагаемся на реализации методов по умолчанию. Все недостающее будет заполнено компилятором.
Особенно краток блок impl, где мы полагаемся на реализации по умолчанию, пре доставляемые компилятором. Благодаря наличию реализаций по умолчанию каж дого метода, определенного в s t d: :error: :Error, можно заставить компилятор сделать всю работу за нас.
Работа в сети
355
Использование map_err ()
Следующей корректировкой станет добавление к нашему коду функции m ap_ err (), позволяющей преобразовать вышестоящую ошибку в сборный тип ошибок. Если вернуться к коду листинга 8.13, нам нужно получить функцию main () , имеющую примерно следующий вид: fn main() -> Result { let _f = File::open("invisible.txt")? .maybe_convert_to(UpstreamError); let _localhost = "::1" .parse::()? .maybe_convert_to(UpstreamError); Ok(()) }
Предложить такое, конечно, невозможно, но можно дать вот это: fn main() -> Result { let _f = File::open("invisible.txt") .map_err(UpstreamError::IO)?; let _localhost = "::1" .parse::() .map_err(UpstreamError::Parsing)?; Ok(()) }
Новый код работает! И вот как он это делает. Функция map_ err () отображает ошибку на функцию. (Здесь в качестве функций могут использоваться варианты нашего перечисления upstreamError.) Заметьте, что оператор ? нужно ставить в самом конце. Иначе функция может возвратить управление еще до того, как у кода будет возможность преобразовать ошибку. В листинге 8.14 представлен новый код. При его запуске на выполнение на консоль выводится следующее сообщение: $ rustc ch8/misc/wraperror.rs && ./wraperror
Error: IO(Os { code: 2, kind: NotFound, message: "No such file or directory" 1) Дабы сохранить безопасность типов, можно воспользоваться новым кодом, пока занным в следующем листинге. Его можно найти в файле ch8/misc/wraperror.rs. Листинг 8.14. Заключение вышестоящих ошибок в оболочку нашего собственного типа 1 use std::io; 2 use std::fmt; 3 use std::net; 4 use std::fs::File; 5 use std::net::Ipv6Addr; 6 7 #[derive(Debug)]
356
Глава 8
8 enum UpstreamError{ 9
IO(io::Error),
10
Parsing(net::AddrParseError),
11 } 12 13 impl fmt::Display for UpstreamError { 14
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
15 16
write!(f, "{:?}", self) }
17 } 18 19 impl error::Error for UpstreamError { } 20 21 impl From for UpstreamError { 22
fn from(error: io::Error) -> Self {
23 24
UpstreamError::IO(error) }
25 } 26 27 impl From for UpstreamError { 28 29
fn from(error: net::AddrParseError) -> Self { UpstreamError::Parsing(error)
358 30
Глава 8 }
31 } 32 33 fn main() -> Result { 34
let _f = File::open("invisible.txt")?;
35
let _localhost = "::1".parse::()?;
36 37
Ok(())
38 }
8.5.3. Фокусы с unwrap () и expect ()
Последним подходом к работе с несколькими типами ошибок является использова ние методов unwrap () и expect (). Теперь, располагая в функции инструментарием для обработки нескольких типов ошибок, можно продолжить наше путешествие. ПРИМЕЧАНИЕ Применение данного подхода имеет смысл при написании функции main () , но ис пользовать его авторам библиотек не рекомендуется. Пользователям вряд ли понра вится сбой их программ по неподконтрольным им причинам.
8.6. МАС-адреса Несколькими страницами ранее, в листинге 8.9, была реализована программа по разрешению доменных имен с использованием DNS-cepвepoв. В ней выполнялось преобразование хост-имени, например www.rustinaction.com в IР-адрес. То есть теперь у нас есть IР-адрес для подключения. Интернет-протокол позволяет устройствам контактировать друг с другом посредст вом их IР-адресов. Но это еще не все. У каждого аппаратного устройства также имеется уникальный идентификатор, который не зависит от сети, к которой оно подключено. А зачем им второй идентификатор? Ответ на этот вопрос имеет как технические, так и исторические корни. Сети Ethemet и Интернет входили в наш обиход независимо друг от друга. Точкой приложения Ethemet бьши локальные сети (local area networks, LANs). Интернет раз рабатывался для обеспечения связи между сетями, а Ethemet был системой адреса ции, понятной устройствам, совместно использующим физический канал (или, как в случае с WiFi, Bluetooth и другими беспроводными технологиями, радиосвязь). Возможно, лучшим объяснением будет то, что МАС-адреса (МАС - сокращение от Media Access Control, - надзор за доступом к среде) используют устройства, совместно потребляющие электросвязь (рис. 8.3). Но есть несколько отличий: • IР-адреса, в отличие от МАС-адресов, имеют иерархическую структуру.
Адреса с близкими номерами физически или организационно могут быть не смежными.
Работа в сети
359
• МА С-адреса составляются из 48 бит (6 байтов). А IР-адреса состоят из 32 бит (4 байтов) для 1Pv4 и из 128 бит (16 байтов) для 1Pv6. Первый переданный байт По схеме Rust-синтаксиса МАС-адрес указывается как [u8; 6]
Флаг одиночного или группового адресата Флаг локальности или универсальности Универсальные адреса Организация Локальные адреса Общие поля
Устройство
Роль конкретных битов изменяется в соответствии с установкой флага локальности или универсальности
Устройство Флаги
Рис. 8.3. Схема размещения в памяти МАС-адресов
У МАС-адресов две формы: • Универсалыю администрируемые (wiu универсальные) адреса устанавлива ются при изготовлении устройств. Производители используют префикс, на значенный органом регистрации IEEE, и выбираемую ими схему для остав шихся битов. • Локально администрируемые (wiu локальные) адреса позволяют устройст вам создавать свои собственные МА С-адреса без регистрации. Когда МАС адрес устройства устанавливается в программе самостоятельно, нужно убе диться, что адрес установлен в локальной форме. МАС-адреса имеют два режима: одиночный и групповой. Поведение передачи для этих форм идентично. Различие делается при принятии устройством решения о приеме кадра или отказе от его приема. Кадр - термин, используемый протоколом Ethemet для байтового слайса на данном уровне. Аналогами кадра могут послужить такие понятия, как пакет, упаковка и конверт. Разница показана на рис. 8.4. Одиночные адреса предназначены для переноса информации между двумя точка ми, находящимися в непосредственном контакте (скажем, между ноутбуком и ро утером). Точки беспроводного доступа несколько усложняют ситуацию, но сути дела не меняют. Групповые адреса могут быть приняты несколькими получателя ми, а у одиночных только один получатель. Сам термин «одиночный)) не вполне корректен. В отправке Ethemet-пaкeтa участвует более двух устройств. Использо вание одиночного адреса меняет только положение вещей, складывающееся при получении пакетов, но не то, какие данные передаются по проводным линиям (или по радиоволнам).
360
Глава 8 Сравнение одиночных и групповых МАС-адресов
Поведение при отправке согласовано в обоих режимах
Поведение при получении зависит от режима Групповой режим
Одиночный режим Отправитель включает MAC-адрес назначения в кадр
>_
Маршрутизатор передает MAC-адрес назначения всем устройствам отслеживающим порт, включенный в кадр
Прием кадра одиночным устройством Кадр может быть принят несколькими устройствами
Другие устройства игнорируют кадр
Что определяет режим? Хотя от точки беспроводного доступа отходят три стрелки, на самом деле по радио ведется только одна передача
В MAC-адресах одиночный или групповой режим устанавливается в наименьшем значимом разряде первого переданного байта При установке в 1 Mac-адрес находится в групповом режиме
Рис. 8.4. Разница между одиночными и групповыми МАС-адресами
8.6.1. Создание МАС-адресов Когда в разделе 8.8 зайдет речь о чистом ТСР-протоколе, в листинге 8.22 будет создан код виртуального аппаратного устройства. Чтобы кого-то убедить, что с на ми можно вести диалог, нужно научиться назначать нашему виртуальному устрой ству МАС-адрес. Такой адрес для нас создается в проекте macgen, код которого по казан в листинге 8.17. А в следующем листинге показаны метаданные для этого проекта. Его код находится в файле ch8/ch8-mac/Cargo.toml. Листинг 8.16. Контейнер метаданных для проекта macgen [package] name = "ch8-macgen" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] rand = "0.7"
Код проекта macgen, являющегося нашим генератором МАС-адресов, показан в следующем листинге. Его можно найти в файле ch8/ch8-mac/src/main.rs.
Работа в сети
361
Листинг 8.17. Создание macgen, генератора MAC-адресов 1 extern crate rand; 2 3 use rand::RngCore; 4 use std::fmt; 5 use std::fmt::Display; 6 7 #[derive(Debug)] 8 struct MacAddress([u8; 6]);
(1)
9 10 impl Display for MacAddress { 11
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
13
let octet = self.0;
14
write!(
15
f,
16
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
17
octet[0], octet[1], octet[2],
18
octet[3], octet[4], octet[5]
19 20
) }
21 } 22 23 impl MacAddress { 24
pub fn new() -> MacAddress {
25
let mut octets: [u8; 6] = [0; 6];
26
rand::thread_rng().fill_bytes(&mut octets);
27
octets[0] |= 0b_0000_0010;
28
octets[0] &= 0b_1111_1110;
29
MacAddress { 0: octets }
30
(1)
(2)
(3)
}
31 } 32 33 impl Into for MacAddress { 34
fn into(self) -> wire::EthernetAddress {
35 36
wire::EthernetAddress { 0: self.0 } }
37 }
(1) Генерация случайного числа. (2) Обеспечение установки бита локального адреса в 1. (3) Обеспечение установки бита одиночного режима в О.
Порядок взаимодействия с сервером для выполнения НТТР-запроса показан в сле дующем листинге. Его код находится в файле ch8/ch8-mget/src/http.rs.
370
Глава 8
Листинг 8.22. Самостоятельное создание HTTP-запроса с использованием TCP-примитивов 1 use std::collections::BTreeMap; 2 use std::fmt; 3 use std::net::IpAddr; 4 use std::os::unix::io::AsRawFd; 5 6 use smoltcp::iface::{EthernetInterfaceBuilder, NeighborCache, Routes}; 7 use smoltcp::phy::{wait as phy_wait, TapInterface}; 8 use smoltcp::socket::{SocketSet, TcpSocket, TcpSocketBuffer}; 9 use smoltcp::time::Instant; 10 use smoltcp::wire::{EthernetAddress, IpAddress, IpCidr, Ipv4Address}; 11 use url::Url; 12 13 #[derive(Debug)] 14 enum HttpState { 15
Connect,
16
Request,
17
Response,
18 } 19 20 #[derive(Debug)] 21 pub enum UpstreamError { 22
Network(smoltcp::Error),
23
InvalidUrl,
24
Content(std::str::Utf8Error),
25 } 26 27 impl fmt::Display for UpstreamError { 28
fn fmt(&self, f: &mut fmt::Formatter {}
90
Err(smoltcp::Error::Unrecognized) => {}
91
Err(e) => {
92
eprintln!("error: {:?}", e);
93
}
94
}
95 96
{
97
let mut socket = sockets.get::(tcp_handle);
98 99
state = match state {
100
HttpState::Connect if !socket.is_active() => {
101
eprintln!("connecting");
102
socket.connect((addr, 80), random_port())?;
103
HttpState::Request
104
}
105 106
HttpState::Request if socket.may_send() => {
107
eprintln!("sending request");
108
socket.send_slice(http_header.as_ref())?;
109
HttpState::Response
110
}
111 112
HttpState::Response if socket.can_recv() => {
113
socket.recv(|raw_data| {
114
let output = String::from_utf8_lossy(raw_data);
115
println!("{}", output);
116
(raw_data.len(), ())
117
})?;
118
HttpState::Response
119
}
120 121
HttpState::Response if !socket.may_recv() => {
122
eprintln!("received complete response");
123
break 'http;
124
}
125
_ => state,
126
}
127
}
128 129
phy_wait(fd, iface.poll_delay(&sockets, timestamp))
130 131
.expect("wait error"); }
132 133 134 }
Ok(())
Работа в сети
373
И наконец, в следующем листинге показан код, выполняющий DNS-разрешение. Его можно найти в файле ch8/ch8-mget/src/dns.rs. Листинг 8.23. Создание DNS-запросов для преобразования доменных имен в IP-адреса 1 use std::error::Error; 2 use std::net::{SocketAddr, UdpSocket}; 3 use std::time::Duration; 4 5 use trust_dns::op::{Message, MessageType, OpCode, Query}; 6 use trust_dns::proto::error::ProtoError; 7 use trust_dns::rr::domain::Name; 8 use trust_dns::rr::record_type::RecordType; 9 use trust_dns::serialize::binary::*; 10 11 fn message_id() -> u16 { 12
let candidate = rand::random();
13
if candidate == 0 {
14
return message_id();
15
}
16
candidate
17 } 18 19 #[derive(Debug)] 20 pub enum DnsError { 21
ParseDomainName(ProtoError),
22
ParseDnsServerAddress(std::net::AddrParseError),
23
Encoding(ProtoError),
24
Decoding(ProtoError),
25
Network(std::io::Error),
26
Sending(std::io::Error),
27
Receving(std::io::Error),
28 } 29 30 impl std::fmt::Display for DnsError { 31
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
32 33
write!(f, "{:#?}", self) }
34 } 35 36 impl std::error::Error for DnsError {} 37 38 pub fn resolve( 39
dns_server_address: &str,
40
domain_name: &str,
41 ) -> Result { 42
let domain_name =
(1)
374
Глава 8
43
Name::from_ascii(domain_name)
44
.map_err(DnsError::ParseDomainName)?;
45 46
let dns_server_address =
47 48
format!("{}:53", dns_server_address);
(2)
let dns_server: SocketAddr = dns_server_address
49
.parse()
50
.map_err(DnsError::ParseDnsServerAddress)?;
51 52
let mut request_buffer: Vec =
53 54
Vec::with_capacity(64); let mut response_buffer: Vec =
55
vec![0; 512];
(3) (3) (4) (4)
56 57
let mut request = Message::new();
58
request.add_query(
59 60
Query::query(domain_name, RecordType::A) );
61 62
(5)
(5) (5)
request
63
.set_id(message_id())
64
.set_message_type(MessageType::Query)
65
.set_op_code(OpCode::Query)
66
.set_recursion_desired(true);
(6)
67 68 69
let localhost = UdpSocket::bind("0.0.0.0:0").map_err(DnsError::Network)?;
70 71
let timeout = Duration::from_secs(5);
72
localhost
73
.set_read_timeout(Some(timeout))
74
.map_err(DnsError::Network)?;
(7)
75 76
localhost
77
.set_nonblocking(false)
78
.map_err(DnsError::Network)?;
79 80
let mut encoder = BinEncoder::new(&mut request_buffer);
81
request.emit(&mut encoder).map_err(DnsError::Encoding)?;
82 83
let _n_bytes_sent = localhost
84
.send_to(&request_buffer, dns_server)
85
.map_err(DnsError::Sending)?;
86 87 88 89
loop { let (_b_bytes_recv, remote_port) = localhost .recv_from(&mut response_buffer)
(8)
Работа в сети 90
375
.map_err(DnsError::Receving)?;
91 92
if remote_port == dns_server {
93
break;
94 95
} }
96 97
let response =
98
Message::from_vec(&response_buffer)
99
.map_err(DnsError::Decoding)?;
100 101
for answer in response.answers() {
102
if answer.record_type() == RecordType::A {
103
let resource = answer.rdata();
104
let server_ip =
105
resource.to_ip_addr().expect("invalid IP address received");
106
return Ok(Some(server_ip));
107 108
} }
109 110
Ok(None)
111 }
(1) Возвращение к методам, используемым по умолчанию. (2) Попытки выстроить внутренние структуры данных, используя обычный текстовый ввод. (3) Поскольку наш DNS-зaпpoc будет небольшим по объему, для его хранения понадобится совсем немного места. (4) В DNS, работакхцей по протоколу UDP, используется максимальный размер пакета, составляющий 512 байт. (5) DNS-сообщения могут содержать несколько запросов, но здесь используется только один. (6) Просьба к DNS-cepвepy делать запросы от нашего имени, если ответ ему неизвестен. (7) При привязке к порту О просьба к операционной системе выделить порт от нашего имени. (8) Есть небольшая вероятность, что на наш порт от неизвестного отправителя будет получено еще одно UDР-сообщение. Чтобы избежать такой возможности, пакеты с неожиданных IР-адресов будут игнорироваться.
Проект mget можно назвать весьма масштабным продуктом. В нем объединяется все рассмотренное в этой главе, занимающее десятки строк кода, и все же он менее эффективен, чем вызов request: : get (url), который был сделан в коде листинга 8.2. Надеюсь, благодаря ему вам откроется несколько весьма интересных направле ний для проведения собственных исследований. Не стоит удивляться, если найдет ся еще ряд требующих освоения тем, касающихся работы в сети. А пока вы молод цы, что сумели справиться с изучением такой длинной и сложной главы.
376
Глава 8
Резюме ♦ Работа в сети отличается особой сложностью. Точность таких стандартных мо делей, как OSI, можно признать лишь частично.
♦ Типажные объекты допускают полиморфизм в ходе выполнения программы. Обычно программисты предпочитают обобщения, поскольку типажные объекты не обходятся без незначительных издержек времени выполнения. Но не все так однозначно. Использование типажных объектов может сэкономить пространст во, поскольку скомпилировать нужно только одну версию каждой функции. А меньшее количество функций способствует также высокой степени согласо ванности кэш-памяти. ♦ Порядок использования байтов определяется сетевыми протоколами. Чтобы га рантировать сохранение полного контроля, предпочтение в основном следует отдавать использованию & [uB ]-литералов (Ь"..."), а не &str-литералов (" ... ").
♦ В рамках одной области видимости существуют три основных стратегии обра
ботки нескольких типов вышестоящих ошибок: • Создание внутреннего типа-оболочки и реализация типажа From для каждого из вышестоящих типов. • Изменение типа возвращаемого значения, чтобы воспользоваться типажным объектом, реализующим std::error:Error. • Использование метода . unwrap () и его собрата . expect ().
♦ Конечные автоматы в Rust весьма элегантно моделируются с помощью перечис ления и цикла. Следующее состояние указывается при каждом проходе цикла за счет возвращения соответствующего варианта перечисления.
♦ Чтобы обеспечить в UDP двусторонний обмен данными, у каждой стороны диа лога должна быть возможность действовать как в качестве клиента, так и в каче стве сервера.
9
Время и хронометраж
В этой главе рассматриваются следующие вопросы: ♦ Понятие о компьютерном хронометраже. ♦ Способы представления меток времени в операционных системах. ♦ Синхронизация атомных часов с протоколом сетевого времени (Network Time Protocol, NТР). В этой главе будет создан NТР-клиент, запрашивающий текущее время по всемир ной сети у общедоступных тайм-серверов. Это будет полноценный клиент, кото рый можно включать в процесс загрузки вашего собственного компьютера для его синхронизации с окружающим миром. Поняв, как работает время в компьютерах, вы получите мощное подспорье в борьбе по созданию жизнеспособных приложений. Системные часы могут спешить или отставать. Знание причин позволит предвидеть данные события и подготовиться к их проявлению. На компьютере также имеются несколько физических и виртуальных часов. Чтобы понять пределы их возможностей и сферы их применения, требуется определенный объем знаний. Разобравшись в этих вопросах, можно будет проявить здоровый скептицизм в отношении микротестов производительности и в оценках работы другого кода, чувствительного ко времени. Ряд самых сложных программных разработок связан с распределенными системами, вынужденными согласовывать время. При наличии ресурсов Google можно под держивать сетевые атомные часы, обеспечивающие синхронизацию мирового вре мени в пределах 7 мс. Ближайшей альтернативой с открытым исходным кодом яв ляется CockroachDB (https://www.cockroachlabs.com/). Там в основу положен NТР-про токол, у которого может быть задержка (по всему миру) примерно в десятки миллисекунд. Но его пользы это ничуть не умаляет. При развертывании в локаль ной сети NТР позволяет компьютерам согласовывать время с точностью до не скольких миллисекунд или даже менее того. Что касается Rust, то в этой главе много времени уделено взаимодействию с внут ренними компонентами операционной системы. Вы станете увереннее использо вать небезопасные блоки и обычные указатели. А также познакомитесь с контейне ром chrono, ставшим фактическим стандартом применения высокоуровневых опе раций для работы с временем и часами.
378
Глава 9
9.1. Предыстория вопроса Нетрудно представить, что день состоит из 86 400 секунд (60 с х 60 мин х 24 ч = = 86 400 с). Но Земля вращается неравномерно. Продолжительность каждого дня колеблется из-за влияния Луны и других эффектов, например крутящего момента на границе ядра Земли и ее мантии. Программные средства не хотят мириться с этим несовершенством. В большинстве систем предполагается, что в основной своей массе секунды имеют равную про должительность. Несоответствие порождает ряд проблем. В 2012 году после добавления дополнительной секунды в часы многочисленных сервисов, в числе которых были Reddit и инфраструктура Hadoop Mozilla, они отка зались работать. А порой часы могут возвращаться в прежнее время (но в этой гла ве путешествия во времени не рассматриваются). К двукратному появлению одной и той же метки времени готовы далеко не все программные системы. Это затрудня ет отладку регистрационных журналов. Но для выхода из подобного тупика есть два варианта: • Поддержка фиксировашюй продолжительности каждой секунды. Это под ходит компьютерам, но вызывает раздражение у людей. Со временем пол день смещается в сторону заката или восхода солнца. • Подгонка продолжительности каждого года, чтобы из года в год положе ние солнца относительно полудня оставалось неизменным. Это подошло бы людям, но сильно раздражало бы компьютеры. На практике можно выбрать оба варианта, как это и делается в данной главе. Меж дународные атомные часы используют свой собственный часовой пояс с секундами фиксированной продолжительности, который называется международным атомным временем - TAI (фр. Temps Atomique Intemational). Во всем остальном использу ются периодически корректируемые часовые пояса, называемые универсальным скоординированным временем - UTC. Т AI используется международными атомными часами и поддерживает год фикси рованной длины. UTC добавляет к ТAI дополнительные секунды примерно раз в 18 месяцев. В 1972 году разница между ТAI и UTC составляла 1О секунд. К 2016 году она составила 36 секунд. Кроме проблем с непостоянной скоростью вращения Земли, отслеживание точного времени затрудняется физикой вашего собственного компьютера. Также в вашей системе работают (как минимум) два тактовых генератора. Один из них представ ляет собой устройство с батарейным питанием, называемое часами реального вре мени. Другой известен как системное время, увеличивающееся на основе аппарат ных прерываний, выдаваемых материнской платой компьютера. Где-то в вашей системе имеется быстро колеблющийся кристалл кварца.
Время и хронометраж
379
Работа с аппаратными платформами без часов реального времени В компьютере Raspbeпy Pi нет часов реального времени с питанием от батареи. Когда он включается, системные часы устанавливаются на время эпохи. То есть в нем идет подсчет секунд, прошедших с 1 января 1970 года. Для определения теку щего времени во время загрузки в нем используется NTP. А если нет сетевого подключения? С подобной ситуацией столкнулся проект Cacophony Project (https://cacophony.org.nz/), в рамках которого разрабатываются уст ройства для поддержки местных видов птиц Новой Зеландии с применением ком пьютерного зрения для точного определения видов вредителей. Главным датчиком устройства является тепловизионная камера. Видеоряд должен быть с точными метками времени. Для этого команда Cacophony Project решила добавить на изготавливаемую ими плату дополнительные часы реального времени, Raspbeпy Pi Hat. Внутреннее устройство прототипа автоматизированной системы обнаружения вредителей проекта Cacophony Project показано на следующем рисунке.
Тепловизионная камера
Компьютер Raspberry Pi Плата расширения Cacophony Project Pi Hat
Плата часов реального времени Микросхема часов реального времени
380
Глава 9
9.2. Источники времени Компьютеры не могут посмотреть на настенные часы и определить, который теперь час. Им нужно разобраться в этом самим. Чтобы объяснить происходящее, рас смотрим общий порядок работы цифровых часов, а затем то, как компьютерные системы справляются с рядом затруднений, вроде работы без источника питания. Цифровые часы состоят из двух основных частей. Первая часть представлена неким компонентом, тикающим через определенные промежутки времени. Вторая часть пара счетчиков. Один из них увеличивается по мере выдачи тикав, а во втором происходят посекундные приращения. Определение «сейчас» в цифровых часах означает сравнение количества секунд с некоторой заранее определенной началь ной точкой. Отправная точка известна как эпоха. Если не брать в расчет встроенное оборудование, у выключенного компьютера продолжают работать маленькие часы с батарейным питанием. За счет энергии ба тарейки кристалл кварца совершает быстрые колебания. Часы измеряют эти коле бания и обновляют свои внутренние счетчики. В работающем компьютере источ ником регулярных тикав становится тактовая частота процессора. Ядро централь ного процессора работает с фиксированной частотой 1 • В самом оборудовании доступ к счетчику может быть получен через инструкции центрального процессора и (или) путем обращения к предопределенным регистрам процессора2• Использование часов процессора может вызвать ряд проблем в особых научных и других областях высокой точности, например при профилировании поведения при ложения. Когда в компьютерах используются несколько процессоров, что зачастую имеет место в системах высокопроизводительных вычислений, тактовая частота каждого процессора немного отличается от этого показателя у других процессоров. Более того, работа процессоров может носить неупорядоченный характер. То есть создатель программного пакета для сравнительного анализа или профилирования не может знать, сколько времени занимает выполнение функции между двумя вре менными метками. Инструкции центрального процессора, запрашивающие теку щую метку времени, возможно, уже подверглись смещению.
9.3. Определения К сожалению, нам в этой главе не обойтись без специальных терминов: • Абсолютное время - представляет собой то время, которое бы прозвучало в ответ на вопрос: «Который час?>>. Также оно называется временем настенных часов и календарным временем. 1 Для экономии энергии многие процессоры динамически подстраивают свою тактовую частоту, но с позиции часов это происходит достаточно редко, и считается несущественным явлением. 2 Например, в процессорах семейства Intel поддерживается инструкция RDTSC, означающая Read Time Stamp Counter (чтение счетчика отметки времени).
Время и хронометраж
381
• Часы реального времени - физические часы, встроенные в материнскую плату компьютера и отслеживающие время при отключении питания. Они также известны как СМОS-часы. • Системные часы - представление о времени с позиции операционной сис темы. После загрузки операционная система берет на себя функции хроно метража от часов реального времени. • Приложения получают представление о времени исходя из системного вре мени. Системные часы уходят вперед или назад, поскольку их можно устано вить на другое время вручную. Эти колебания времени могут сбить некото рые приложения с толку. • С монотонным приростом - часы, никогда не показывающие одно и то же время дважды. Это полезное свойство, позволяющее компьютерному прило жению помимо всего прочего никогда не иметь повторяющихся меток време ни у записей регистрационного журнала. К сожалению, отказ от корректи ровки времени означает постоянную привязку к перекосу местных часов. Следует учесть, что системным часам не свойственен монотонный ход. • Устойчивые часы - это часы, дающие две гарантии: секунды в них имеют равную продолжительность, а сами они имеют монотонный ход. Показание устойчивых часов вряд ли совпадет со временем системных часов или абсо лютным временем. Обычно при загрузке компьютера их показание устанав ливается в ноль, а затем нарастает по мере увеличения значения внутреннего счетчика. Хотя потенциально для определения абсолютного времени они бесполезны, их свойства пригодятся для вычисления продолжительности ме жду двумя моментами времени. • Часы высокой точности - часы идут точно, если у них равная продолжи тельность секунд. Разница в показаниях двух часов называется перекосом. Перекос высокоточных часов по отношению к атомным часам, являющимся высшим инженерным достижением человечества по сохранению точного времени, незначителен. • Часы высокого разрешения - обеспечивают точность до 1 О или менее нано секунд. Тактовые генераторы с высоким разрешением обычно реализуются в микросхемах центральных процессоров, поскольку устройств, способных поддерживать время на такой высокой частоте, совсем немного. Процессорам это под силу. Их единицы работы измеряются циклами, а циклы имеют оди наковую продолжительность. Ядру центрального процессора с тактовой час тотой 1 ГГц для вычисления одного цикла требуется одна наносекунда. • Быстрые часы - устройства, быстро считывающие показания времени, при нося точность в жертву скорости.
382
Глава 9
9.4. Кодирование времени Время в компьютере можно представлять множеством способов. Обычно для этого используется пара 32-разрядных целых чисел. В первом подсчитывается количест во прошедших секунд. Во втором - доли секунды. Точность той части, где пред ставлены доли секунды, зависит от рассматриваемого устройства. Начало отсчета носит произвольный характер. Чаще всего началом эпохи в UNIХ-по добных системах считается 1 января 1970 года универсального скоординированно го времени - UTC. Альтернативными вариантами являются 1 января 1900 года (где используется протокол сетевого времени- NTP), 1 января 2000 года для бо лее поздних приложений и 1 января 1601 года (начало григорианского календаря). Использование целых чисел фиксированной ширины дает два ключевых преиму щества и не обходится без двух основных недостатков: • К преимуществам относятся: ◊ Простота - формат легок в понимании. ◊ Эффективность - целочисленная арифметика- любимое дело централь ного процессора. • В числе недостатков: ◊ Фиксированный диапазон - все типы с фиксированным целым числом конечны, следовательно, время в конечном итоге снова возвращается к нулю. ◊ Неточность - целые числа дискретны, а время непрерывно. Разные сис темы идут на разные компромиссы, связанные с точностью до секунды, что приводит к ошибкам округления. Важно также отметить непоследовательность общего подхода. В реальных услови ях для представления секундного компонента отмечаются следующие особенности: • В метках времени UNIX используется 32-разрядное целое число, представ ляющее количество миллисекунд, прошедших с начала эпохи (например, с 1 января 1970 года). • В структурах MS Windows FILEТIME (начиная с Windows 2000) использует ся 64-разрядное целое число без знака, представляющее 100-наносекундное приращение, начиная с 1 января 1601 года (UTC). • В контейнере chronos Rust-сообщества используется 32-разрядное число со знаком, в котором для представления часовых поясов в необходимых случаях реализуется NaiveTime с перечислением3 •
3 Странностей у chronos сравнительно мало, но одна из них - скрытый переход секунд в поле наносекунд.
Время и хронометраж
383
(означающий тип времени, который также называют простым време нем или календарным временем) в стандартной библиотеке С (libc) варьиру ется следующим образом:
• time_t
◊ В libc разработки Dinkumware предоставляется unsigned long int (то есть 32-разрядное целое число без знака). ◊ В libc от GNU включается long int (то есть 32-разрядное целое число со знаком). ◊ В libc от АVR используется 32-разрядное целое число без знака, а началом эпохи считается полночь 1 января 2000 года (UTC). Для дробных частей обычно используется тот же тип, что и для целых секунд, но никаких гарантий на этот счет не дается. А теперь взглянем на часовые пояса.
9.4.1. Представление часовых поясов На часовые пояса мир поделен не по техническим, а по политическим принципам. И, похоже, все пришли к согласию сохранять еще одно целое число, представляю щее собой смещение от UTC в секундах.
9.5. clock v0.1.0: учим приложение сообщать о времени Приступая к программированию нашего NТР-клиента, первым делом узнаем, как считывать время. На рис. 9.1 вкратце показано, как это делается в приложении. Который час?
Который час?
Уже 9:12 Приложение
Который час?
Уже 9:12 Библиотека libc
Уже 9:12 Операционная система
Оборудование
Рис. 9.1. Приложение получает информацию о времени из операционной системы, пользуясь, как правило, функциями, предоставляемыми реализацией системной библиотеки libc.
Может показаться, что для полноценного примера код листинга 9.2, считывающий системное время в локальном часовом поясе, слишком невелик. Но его запуск на выполнение приводит к получению текущей метки времени, отформатированной в соответствии со стандартом ISO 8601. Конфигурация для него представлена в сле дующем листинге, код которого можно найти в файле ch9/ch9-clock0/Cargo.toml. Листинг 9.1. Контейнер конфигурации для кода листинга 9.2 [package] name = "clock" version = "0.1.0"
384
Глава 9
authors = ["Tim McNamara "] edition = "2018" [dependencies] chrono = "0.4"
Код следующего листинга считывает системное время и выводит его на консоль. Его можно найти в файле ch9/ch9-clock0/src/main.rs. Листинг 9.2. Считывание системного времени и вывод его на консоль 1 use chrono::Local; 2 3 fn main() { 4
let now = Local::now();
5
println!("{}", now);
(1)
6 }
(1) Запрос времени в локальном часовом поясе системы
В шести строках кода листинга 9.2 совсем не видно большого объема весьма не простой работы, проделываемой для получения конечного результата. Ее основная суть будет рассмотрена в ходе изучения главы. А пока вполне достаточно будет узнать, что вся магия процесса предоставлена контейнером chrono: : Local. Им воз вращается типизированное значение, содержащее часовой пояс. ПРИМЕЧАНИЕ Работа с метками времени, не включающими часовые пояса или же выполнение дру гих форм недопустимой арифметики работы со временем, приводит к тому, что про грамма отказывается проходить компиляцию.
9.6. clock v0.1.1: форматирование меток времени в соответствии с ISO 8601 и стандартами электронной почты Создаваемое здесь приложение под названием clock (часы) будет показывать теку щее время. Его полная версия представлена в листинге 9. 7. По ходу изучения главы приложение будет постепенно совершенствоваться, в него будет добавлена уста новка времени вручную, а также через NTP. Но пока в следующем примере кода показан результат компиляции и запуска кода из листинга 9.8 при установке для него ключа --use -standard timest amp. $ cd ch9/ch9-clock1 $ cargo run -- --use-standard rfc2822 warning: associated function is never used: `set`
Время и хронометраж
385
--> src/main.rs:12:8 | 12 |
fn set() -> ! {
|
^^^
| = note: `#[warn(dead_code)]` on by default warning: 1 warning emitted Finished dev [unoptimized + debuginfo] target(s) in 0.01s Running `target/debug/clock --use-standard rfc2822` Sat, 20 Feb 2021 15:36:12 +1300
9.6.1. Реструктуризация кода clock v0.1.0 с целью более широкой архитектурной поддержки Стоит уделит немного времени закладке основ для более крупного приложения, в которое со временем превратится наше приложение clock. Внесем сначала не большое косметическое изменение. Вместо использования функции считывания времени и его настройки воспользуемся статическими методами структуры clock. Изменения, вносимые в код листинга 9.2, показаны в следующем листинге, являю щемся частью листинга 9. 7. Листинг 9.3. Считывание времени из локальных системных часов 2 use chrono::{DateTime}; 3 use chrono::{Local}; 4 5 struct Clock; 6 7 impl Clock { 8
fn get() -> DateTime {
9 10
(1)
Local::now() }
11 12
fn set() -> ! {
13 14
unimplemented!() }
15 }
(1) DateTime - это DateTime с информацией о локальном часовом поясе.
А что на самом деле представляет собой тип, возвращаемый функцией s e t () ? Вос клицательный знак ( ! ) показывает компилятору, что функция никогда ничего не возвращает (возвращаемого значения у нее быть не может). Это называется типом «Никогда)). Если в ходе выполнения программы попадется макрос u n implemented ! () (или его более компактный собрат todo ! () ), программа запаникует.
386
Глава 9
На данном этапе clock деЙствует исключительно как пространство имен. Здесь до бавление структуры предоставляет возможность последующего расширения про граммы. По мере доработки приложения Clock может пригодиться для содержания какого-либо состояния между вызовами или для реализации какого-либо типажа, поддерживающего новые функциональные возможности. ПРИМЕЧАНИЕ Структура без полей известна как тип с нулевым размером (zero-sized type), или ZST. В получающемся в результате компиляции приложении она вообще не занимает ника кой памяти и является исключительно конструкцией времени компиляции.
9.6.2. Форматирование времени В данном разделе форматирование времени рассматривается как приведение к мет ке времени UNIX или к отформатированной строке, соответствующей соглашениям ISO 8601, RFC 2822 и RFC 3339. В следующем листинге, являющемся частью лис тинга 9.7, показывается, как создаются метки времени с использованием функцио нальности, предоставляемой chrono. Затем метки времени отправляются на стан дартный вывод. Листинг 9.4. Демонстрация методов, используемых для форматирования меток времени 48
let now = Clock::get();
49
match std {
50
"timestamp" => println!("{}", now.timestamp()),
51
"rfc2822"
=> println!("{}", now.to_rfc2822()),
52
"rfc3339"
=> println!("{}", now.to_rfc3339()),
53
_ => unreachable!(),
54
}
Наше сlосk-приложение (благодаря chrono) поддерживает три формата времени: метку времени (timestamp), rfc2822 и rfc3339: • Метка времени (timestamp) - является форматом, показывающим количест во секунд, прошедших с начала эпохи, известным также как метка времени UNIX. • rfc2822 - соответствует RPC 2822 (https://tools.ietf.org/html/rfc2822), то есть спо собу форматирования времени в заголовках сообщений электронной почты. • ifc3339- соответствует RFC 3339 (https://tools.ietf.org/html/rfc3339), то есть спо собу форматирования, чаще всего связываемому со стандартом ISO 860. А вот ISO 8601 - немного более строгий стандарт. Каждая метка времени, соот ветствующая стандарту RFC 3339, соответствует и метке времени стандарта ISO 8601, но обратное утверждение неверно.
Время и хронометраж
387
9.6.3. Предоставление полноценного интерфейса командной строки Аргументы командной строки являются частью среды, предоставляемой приложе нию его операционной системой к моменту запуска этого приложения на выполне ние. Они представляют собой простые строки. В Rust предоставляется поддержка доступа к обычным значениям типа Vec посредством std::env::args, но разработка аналитической логики для приложений среднего размера может ока заться весьма трудоемкой. Нашему коду желательно уметь проверять определенные входные данные, чтобы желаемый формат выходных данных входил в число фактически поддерживаемых приложением clock. Но проверка входных данных может раздражать сложностью своей разработки. Дабы не разочаровывать читателей, в clock используется контей нер clap. Для начала пригодятся два основных типа: clap::Арр и clap::Arg. Каждое значе ние clap::Arg является аргументом командной строки и параметрами, которые он может представлять. А clap::Арр собирает все в одно представление. Для под держки открытого API из таблицы 9.1 в коде, показанном в листинге 9.5, исполь зуются три структуры Arg, объединяемые в одно значение Арр. Листинг 9.5 - часть листинга 9.7. В нем показан способ реализации API, исполь зующего clap и представленного в таблице 9.1. Таблица 9.1. Примеры запуска приложения clock на выполнение из командной строки. Каждой команде требуется поддержка нашим средством синтаксического анализа. Использование
Описание
Пример данных на выходе
Clock
Использование по умолчанию. Выводит текущее время.
2018-06-l?Tll:25:19...
clock get
Предоставление действия get с форматом по умолчанию.
2018-06-l?Tll:25:19 •..
clock get --usestandard timestamp
Предоставление действия get со стандартом форматирования.
1529191458
clock get -s timestamp
Предоставление действия get и стандарта форматирования с более краткой формой записи.
1529191458
clock set
Предоставление действия set с явным указанием правил анализа, используемым по умолчанию.
clock set --usestandard timestamp
Предоставление действия set в явном виде и указание на то, что входные данные будут меткой времени UNIX.
388
Глава 9
Листинг 9.5. Использование clap для анализа аргументов командной строки 18
let app = App::new("clock")
19
.version("0.1")
20
.about("Gets and (aspirationally) sets the time.")
21
.arg(
22
Arg::with_name("action")
23
.takes_value(true)
24
.possible_values(&["get", "set"])
25
.default_value("get"),
26
)
27
.arg(
28
Arg::with_name("std")
29
.short("s")
30
.long("standard")
31
.takes_value(true)
32
.possible_values(&[
33
"rfc2822",
34
"rfc3339",
35
"timestamp",
36
])
37
.default_value("rfc3339"),
38
)
39
.arg(Arg::with_name("datetime").help(
40
"When is 'set', apply . \
41 42
(1)
Otherwise, ignore.", ));
43 44
let args = app.get_matches();
(1) Обратный слэш требует от Rust проигнорировать символ новой строки с последующим отступом.
Контейнер clap автоматически создает за нас часть пользовательской документации для нашего приложения clock. Ее вывод на консоль инициируется ключом --hel p.
9.6.4. clock v0.1.1: полный проект В следующем сеансе работы с терминалом показан процесс загрузки проекта clock v0.1.1 из открытого Git-репозитория и его компиляции. В нем также имеется фраг мент обращения к ключу --help, упомянутому в предыдущем разделе: $ git clone https://github.com/rust-in-action/code rust-in-action $ cd rust-in-action/ch9/ch9-clock1 $ cargo build ... Compiling clock v0.1.1 (rust-in-action/ch9/ch9-clock1)
Время и хронометраж
389
warning: associated function is never used: `set`
(1)
--> src/main.rs:12:6 | 12 |
fn set() -> ! {
|
^^^
| = note: `#[warn(dead_code)]` on by default warning: 1 warning emitted (2)
$ cargo run -- --help ... clock 0.1 Gets and sets (aspirationally) the time. USAGE: clock.exe [OPTIONS] [ARGS] FLAGS: -h, --help
Prints help information
-V, --version
Prints version information
OPTIONS: -s, --use-standard
[default: rfc3339] [possible values: rfc2822, rfc3339, timestamp]
ARGS:
[default: get]
When is 'set', apply .
[possible values: get, set]
Otherwise, ignore. $ target/debug/clock
(3)
2021-04-03T15:48:23.984946724+13:00
(1) Это предупреждение устраняется в clock v0.1.2. (2) Аргументы, указанные справа от двойного дефиса (--), отправляются получаюцемуся в результате компиляции исполняемому коду. (3) Непосредственное выполнение target/debug/clock.
Для поэтапного создания проекта придется приложить немного больше усилий. Поскольку clock v0.1.1 является проектом, управляемым cargo, он придерживается стандартной структуры: clock ├── Cargo.toml └── src └── main.rs
390
Глава 9
Для ее самостоятельного создания следует выполнить три действия: 1. Ввести в командную строку следующие команды: $ cargo new clock $ cd clock $ cargo install cargo-edit $ cargo add clap@2 $ cargo add [email protected]
2. Сравнить содержимое файла Cargo.toml, принадлежащего вашему проекту, с ко дом листинга 9.6. Оно, за исключением поля авторства, должно быть точно та ким же. 3. Заменить содержимое src/main.rs кодом листинга 9.7. Содержимое файла Cargo.toml, принадлежащего проекту, показано в следующем листинге. Его код находится в файле ch9/ch9-clock1/Cargo.toml. Далее следует код листинга 9. 7, соответствующий содержимому принадлежащего проекту файла src/main.rs. Этот код находится в файле ch9/ch9-clock1/src/main.rs. Листинг 9.6. Контейнер конфигурации для clock v0.1.1 [package] name = "clock" version = "0.1.1" authors = ["Tim McNamara "] edition = "2018" [dependencies] chrono = "0.4" clap = "2"
Листинг 9.7. Создание форматированных дат из командной строки, clock v0.1.1 1 use chrono::DateTime; 2 use chrono::Local; 3 use clap::{App, Arg}; 4 5 struct Clock; 6 7 impl Clock { 8
fn get() -> DateTime {
9 10
Local::now() }
11 12
fn set() -> ! {
13 14 15 }
unimplemented!() }
Время и хронометраж
391
16 17 fn main() { 18
let app = App::new("clock")
19
.version("0.1")
20
.about("Gets and (aspirationally) sets the time.")
21
.arg(
22
Arg::with_name("action")
23
.takes_value(true)
24
.possible_values(&["get", "set"])
25
.default_value("get"),
26
)
27
.arg(
28
Arg::with_name("std")
29
.short("s")
30
.long("use-standard")
31
.takes_value(true)
32
.possible_values(&[
33
"rfc2822",
34
"rfc3339",
35
"timestamp",
36
])
37
.default_value("rfc3339"),
38
)
39
.arg(Arg::with_name("datetime").help(
40
"When is 'set', apply . \
41
Otherwise, ignore.",
42
));
43 44
let args = app.get_matches();
45 46
let action = args.value_of("action").unwrap();
47
let std = args.value_of("std").unwrap();
(1) (1)
48 49
if action == "set" {
50 51
unimplemented!() }
52 53
let now = Clock::get();
54
match std {
55
"timestamp" => println!("{}", now.timestamp()),
56
"rfc2822" => println!("{}", now.to_rfc2822()),
57
"rfc3339" => println!("{}", now.to_rfc3339()),
58 59 60 }
_ => unreachable!(), }
(2)
392
Глава 9
(1) Предоставление значения по умолчанию каждому аргументу посредством default_value("get") и default_value("rfc3339"). Вызов unwrap() в этих двух строках кода не представляет никакой опасности. (2) Раннее прерывание, поскольку мы еще не готовы к установке времени.
9.7. clock v0.1.2: установка времени Установка времени усложняется тем, что у каждой операционной системы есть для этого свой собственный механизм. Следовательно, чтобы создать кроссплатфор менный инструмент, нужно воспользоваться условной компиляцией для конкрет ной операционной системы.
9.7.1. Общее поведение В листинге 9.11 представлены два варианта установки времени. Оба они следуют общему шаблону: 1. Анализ аргумента командной строки для создания значения DateTime. 2. Часовой пояс FixedOffset обеспечен chrono, который используется в качестве посредника для «любого часового пояса, указанного пользователем)). Контейнеру chrono в ходе компиляции неизвестно, какой из часовых поясов будет выбран. 3. Преобразование DateTime в DateTime, чтобы получить возможность сравнения часовых поясов. 4. Создание структуры для конкретной операционной системы, используемой в ка честве аргумента для необходимого системного вызова (под которым понимает ся вызов функции, предоставляемой операционной системой). 5. Установка времени системы в небезопасном блоке, применение которого необ ходимо из-за делегирования ответственности операционной системе. 6. Вывод на консоль обновленного времени.
ВНИМАНИЕ
Для телепортации системных часов в другое время в данном коде используются функции. Такие перескоки могут вызвать нестабильность системы.
Некоторые приложения рассчитаны на монотонно возрастающее время. Более ра зумный (но и более сложный) подход состоит в подгонке продолжительности се кунды в течение n секунд до достижения желаемого времени. Функционально это реализуется в структуре Clock, представленной в разделе 9.6.1.
9.7.2. Установка времени для операционных систем, использующих libc В РОSIХ-совместимых операционных системах время может быть установлено с помощью вызова функции settimeofday (), предоставляемой libc, стандартной
Время и хронометраж
393
библиотекой языка С, имеющей множество исторических связей с операционными системами семейства UNIX. Фактически язык С бьm разработан для написания UNIX. Даже сегодня взаимодействие с потомками UNIX требует использования инструментов, предоставляемых этим языком. Для понимания кода листинга 9 .11, рассматриваемого в следующих разделах, Rust-программистам нужно преодолеть два психологических барьера: • Свыкнуться с малопонятными типами, предоставляемыми libc. • Смириться с непривычной манерой представления аргументов в качестве указателей. Соглашения о наименовании типов, действующие в libc В libc используются соглашения о наименовании типов, отличные от аналогичных Rust-соглашений. В libc при обозначении типов предпочтение отдается именам в символах нижнего регистра, а стиль PascalCase не используется. То есть, там, где в Rust использовалось бы название TimeVal, в libc ему соответствовало бы имя timeval. При работе с псевдонимами типов соглашение немного меняется. К име нам псевдонимов типов в libc добавляется знак подчеркивания, за которым следует буква t L t ). В следующих фрагментах кода показано несколько импортируемых из libc типов и Rust-эквивалентов, служащих для создания таких же типов. В строке 64 листинга 9.8 используется следующий код: libc::{timeval, time_t, suseconds_t};
В нем представлены два псевдонима типов и определение структуры. В Rust синтаксисе они определяются следующим образом: #![allow(non_camel_case_types)] type time_t = i64; type suseconds_t = i64; pub struct timeval { pub tv_sec: time_t, pub tv_usec: suseconds_t, }
t ime_t представляет секунды, прошедшие с начала эпохи. А suseconds_t пред ставляет дробную составляющую текущей секунды.
Типы и функции, относящиеся к хронометражу, включают множество косвенных указаний. Код задуман простым в реализации, то есть с прицелом на предоставле ние местным разработчикам (конструкторам оборудования) возможностей измене ния тех аспектов, которые требуются их платформам. Это делается путем повсеме стного использования псевдонимов типов вместо приверженности к применению определенного целочисленного типа.
394
Глава 9
Код clock не для Windows Библиотека libc предоставляет удобную функцию s ettimeofday, которая использу ется в коде листинга 9.8. В файл cargo.toml, принадлежащий проекту, нужно доба вить еще две строки, чтобы поместить в контейнер привязку к libc, необходимую для платформ, отличных от Windows [target.'cfg(not(windows))'.dependencies]
(1)
libc = "0.2" (1)Эти две строки можно добавить к концу файла.
В следующем листинге, являющимся частью листинга 9.11, показано, как устано вить время с применением стандартной библиотеки языка С libc. В листинге преду сматривается использование операционных систем Linux и BSD, или других им подобных систем. Листинг 9.8. Установка времени в среде libc 62 #[cfg(not(windows))] 63 fn set(t: DateTime) -> () { 64
use libc::{timeval, time_t, suseconds_t};
65
use libc::{settimeofday, timezone }
(1) (2)
(2)
66 67
let t = t.with_timezone(&Local);
68
let mut u: timeval = unsafe { zeroed() };
69 70
u.tv_sec = t.timestamp() as time_t;
71
u.tv_usec =
72
t.timestamp_subsec_micros() as suseconds_t;
73 74
unsafe {
75
let mock_tz: *const timezone = std::ptr::null();
76 77
settimeofday(&u as *const timeval, mock_tz); }
78 }
(1) Исходное значение для t берется из командной строки, и оно уже прошло синтаксический анализ. (2) Похоже, параметр timezone функции settimeofday() относится к разряду исторических несуразиц. Его ненулевое значение приводит к возникновению ошибки.
Чтобы избежать захламления глобальной области видимости, импорт, характерный для применяемой операционной системы, осуществляется внутри функции. Функ ция libc:: s ettimeofday изменяет значение системных часов, а для взаимодействия С ней используются ТИПЫ suseconds_t, time_t, timeval И timezone. В этом коде весьма неосмотрительно опущена проверка на успешное завершение вызова функции s et timeofday, хотя вероятность неблагополучного исхода все же имеется. Ситуация будет исправлена на следующем этапе разработки приложения clock.
Время и хронометраж
395
9.7.3. Установка времени в MS Windows Код для MS Windows похож на своих liЬс-собратьев. Он более многословен, по скольку структура для установки времени не ограничивается только лишь полями секунд и долей секунд. Приблизительный эквивалент библиотеки libc называется kemel32.dll и доступен после включения в проект контейнера winapi. Целочисленные типы Windows API В Windows предлагается свой собственный взгляд на так называемые целочислен ные типы. В этом коде используется только лишь тип WORD, но может будет полезно вспомнить также и о двух других распространенных типах, появившихся во време на использования в компьютерах 16-разрядных центральных процессоров. Соот ветствие типов из keme132.dll Rust-типам показано в следующей таблице. Windows-тип
Rust-тиn
Примечания
WORD
ulб
Как повелось с истоков создания Windows, обозначает ширину «слова» центрального процессора
DWORD
u32
Двойное слово
QWORD
u64
Четверное слово
LARGE_INTEGER
i64
Тип, определенный в качестве своеобразной подпорки, позволяющей обмениваться кодом 32-разрядным и 64-разрядным платформам
ULARGE_INTEGER
u64
Беззнаковая версия LARGE_INTEGER
Представление времени в Windows В Windows существует несколько типов обозначения времени. Но в нашем прило жении clock наибольший интерес представляет SYSTEMTIME. Еще один предостав ляемый тип - FILETIME. Во избежание путаницы их описание приведено в сле дующей таблице. Windows-тип
Rust-тип
Примечания
SУSТЕМТIМЕ
winapi::SYSTEМТIМE
Содержит поля для года, месяца, дня недели, дня месяца, часа, минуты, секунды и миллисекунды.
FILETIМE
winapi::FILETIМE
Является аналогом libc: :timeval. Содержит поля секунд и миллисекунд. В документации Microsoft имеется предупреждение, что на 64-разрядных платформах его использование без сложного приведения типов может вызвать досадные ошибки переполнения, поэтому здесь он не используется.
396
Глава 9
Код clock для Windows Из-за многочисленности полей в структуре SYSTEMTIME ее создание занимает не много больше времени. Конструкция этой структуры показана в следующем лис тинге. Листинг 9.9. Установка времени с помощью API-интерфейса Windows-библиотеки kernel32.dll 19
#[cfg(windows)]
20
fn set(t: DateTime) -> () {
21
use chrono::Weekday;
22
use kernel32::SetSystemTime;
23
use winapi::{SYSTEMTIME, WORD};
24 25
let t = t.with_timezone(&Local);
26 27
let mut systime: SYSTEMTIME = unsafe { zeroed() };
28 29
let dow = match t.weekday() {
(11
30
Weekday::Mon => 1,
(11
31
Weekday::Tue => 2,
(1)
32
Weekday::Wed => 3,
(1)
33
Weekday::Thu => 4,
(1)
34
Weekday::Fri => 5,
(1)
35
Weekday::Sat => 6,
(1)
36
Weekday::Sun => 0,
(1)
37
};
38 39
let mut ns = t.nanosecond();
(2)
40
let mut leap = 0;
(2)
41
let is_leap_second = ns > 1_000_000_000;
(2)
if is_leap_second {
(2)
42 43 44
ns -= 1_000_000_000;
(2)
45
leap += 1;
(2)
46
}
47 48
systime.wYear = t.year() as WORD;
49
systime.wMonth = t.month() as WORD;
50
systime.wDayOfWeek = dow as WORD;
51
systime.wDay = t.day() as WORD;
52
systime.wHour = t.hour() as WORD;
53
systime.wMinute = t.minute() as WORD;
54
systime.wSecond = (leap + t.second()) as WORD;
55
systime.wMilliseconds = (ns / 1_000_000) as WORD;
56 57
let systime_ptr = &systime as *const SYSTEMTIME;
(2)
Время и хронометраж
397
58 59
unsafe {
60
SetSystemTime(systime_ptr);
61
}
62
(3) (3) (3)
}
(1) Метод weekday() предоставляется типажом chrono::Datelike. Таблица преобразований находится в документации разработчика компании Microsoft. (2) В качестве особенности реализации chrono представляет високосные секунды путем добавления дополнительной секунды в поле наносекунд. Это следует учесть, чтобы, как того требует Windows, преобразовать наносекунды в миллисекунды. (3) С позиции Rust-компилятора предоставление кому-либо еще прямого доступа к памяти считается небезопасным. Rust не в состоянии гарантировать достойность поведения ядра Windows.
9.7.4. clock v0.1.2: листинг полного кода В clock v0.1.2 соблюдается та же повторяемая здесь структура проекта, что и в v0.1.1. Чтобы добиться поведения, соответствующего той или иной платформе, в файл Cargo.toml следует внести некоторые правки. clock ├── Cargo.toml
(1)
└── src └── main.rs
(2)
(1) См. листинг 9.10 (2) См. листинг 9 .11
Полный исходный код проекта представлен в листингах 9 .1О и 9 .11. Его можно за грузить соответственно из файлов ch9/ch9-clock0/Cargo.toml и ch9/ch9-clock0/src/main.rs. Листинг 9.10. Контейнер конфигурации для кода листинга 9.11 [package] name = "clock" version = "0.1.2" authors = ["Tim McNamara "] edition = "2018" [dependencies] chrono = "0.4" clap = "2" [target.'cfg(windows)'.dependencies] winapi = "0.2" kernel32-sys = "0.2" [target.'cfg(not(windows))'.dependencies] libc = "0.2"
398
Глава 9
Листинг 9.11. Кроссплатформенный код установки системного времени 1 #[cfg(windows)] 2 use kernel32; 3 #[cfg(not(windows))] 4 use libc; 5 #[cfg(windows)] 6 use winapi; 7 8 use chrono::{DateTime, Local, TimeZone}; 9 use clap::{App, Arg}; 10 use std::mem::zeroed; 11 12 struct Clock; 13 14 impl Clock { 15
fn get() -> DateTime {
16 17
Local::now() }
18 19
#[cfg(windows)]
20
fn set(t: DateTime) -> () {
21
use chrono::Weekday;
22
use kernel32::SetSystemTime;
23
use winapi::{SYSTEMTIME, WORD};
24 25
let t = t.with_timezone(&Local);
26 27
let mut systime: SYSTEMTIME = unsafe { zeroed() };
28 29
let dow = match t.weekday() {
30
Weekday::Mon => 1,
31
Weekday::Tue => 2,
32
Weekday::Wed => 3,
33
Weekday::Thu => 4,
34
Weekday::Fri => 5,
35
Weekday::Sat => 6,
36 37
Weekday::Sun => 0, };
38 39
let mut ns = t.nanosecond();
40
let is_leap_second = ns > 1_000_000_000;
41 42
if is_leap_second {
43 44 45
ns -= 1_000_000_000; }
Время и хронометраж 46
systime.wYear = t.year() as WORD;
47
systime.wMonth = t.month() as WORD;
48
systime.wDayOfWeek = dow as WORD;
49
systime.wDay = t.day() as WORD;
50
systime.wHour = t.hour() as WORD;
51
systime.wMinute = t.minute() as WORD;
52
systime.wSecond = t.second() as WORD;
53
systime.wMilliseconds = (ns / 1_000_000) as WORD;
54 55
let systime_ptr = &systime as *const SYSTEMTIME;
56 57
unsafe {
58
SetSystemTime(systime_ptr);
59 60
} }
61 62
#[cfg(not(windows))]
63
fn set(t: DateTime) -> () {
64 65
use libc::{timeval, time_t, suseconds_t}; use libc::{settimeofday, timezone};
66 67
let t = t.with_timezone(&Local);
68
let mut u: timeval = unsafe { zeroed() };
69 70
u.tv_sec = t.timestamp() as time_t;
71
u.tv_usec =
72
t.timestamp_subsec_micros() as suseconds_t;
73 74
unsafe {
75
let mock_tz: *const timezone = std::ptr::null();
76
settimeofday(&u as *const timeval, mock_tz);
77 78
} }
79 } 80 81 fn main() { 82
let app = App::new("clock")
83
.version("0.1.2")
84
.about("Gets and (aspirationally) sets the time.")
85
.after_help(
86
"Note: UNIX timestamps are parsed as whole \
87
seconds since 1st January 1970 0:00:00 UTC. \
88
For more accuracy, use another format.",
89
)
90
.arg(
91 92
Arg::with_name("action") .takes_value(true)
399
400
Глава 9
93
.possible_values(&["get", "set"])
94
.default_value("get"),
95
)
96
.arg(
97
Arg::with_name("std")
98
.short("s")
99
.long("use-standard")
100
.takes_value(true)
101
.possible_values(&[
102
"rfc2822",
103
"rfc3339",
104
"timestamp",
105
])
106
.default_value("rfc3339"),
107
)
108
.arg(Arg::with_name("datetime").help(
109
"When is 'set', apply . \
110
Otherwise, ignore.",
111
));
112 113
let args = app.get_matches();
114 115
let action = args.value_of("action").unwrap();
116
let std = args.value_of("std").unwrap();
117 118
if action == "set" {
119
let t_ = args.value_of("datetime").unwrap();
120 121
let parser = match std {
122
"rfc2822" => DateTime::parse_from_rfc2822,
123
"rfc3339" => DateTime::parse_from_rfc3339,
124
_ => unimplemented!(),
125
};
126 127
let err_msg = format!(
128
"Unable to parse {} according to {}",
129
t_, std
130
);
131
let t = parser(t_).expect(&err_msg);
132 133 134
Clock::set(t) }
135 136
let now = Clock::get();
137 138 139
match std { "timestamp" => println!("{}", now.timestamp()),
Время и хронометраж 140
"rfc2822" => println!("{}", now.to_rfc2822()),
141
"rfc3339" => println!("{}", now.to_rfc3339()),
142
401
_ => unreachable!(),
143
}
144 }
9.8. Более совершенные способы обработки ошибок Читателей, ранее работавших с операционными системами, некоторые фрагменты кода в разделе 9.7 могут, наверное, чем-то насторожить. Кроме всего прочего, в них отсутствует проверка факта успешного завершения вызовов функций settimeof day () И SetSystemTime ().
Сбой установки времени может происходить по нескольким причинам. Наиболее вероятно, что у пользователя, пытающегося установить время, не окажется на это соответствующего разрешения. Надежнее всего будет возвращать из функции Clock: : s e t( t) значение типа Resul t. Поскольку для этого нужно будет внести из менения в две функции, на подробное объяснение которых уже потрачено время, давайте представим обходной путь, в котором вместо этого используются отчеты об ошибках операционной системы: fn main() { // ... if action == "set" { // ... Clock::set(t); let maybe_error = std::io::Error::last_os_error(); let os_error_code = &maybe_error.raw_os_error();
(1) (1)
match os_error_code { Some(0) => (),
(2)
Some(_) => eprintln!("Unable to set the time: {:?}", maybe_error), None => (), } } }
(1) Разбор Rust-типa maybe_error с целью его преобразования в простое значение типа i32, с которым проще искать соответствия. (2) Поиск соответствия простому целому числу позволяет не импортировать перечисление, но жертвует безопасностью тилов. Код, предназначенный для серьезной работы, таких уступок делать не должен.
402
Глава 9
После вызова Clock: : set (t) Rust просто общается с операционной системой по средством std:: io::Error::last_os_error (), проверяя, не выдан ли код ошибки.
9.9. clock v0.1.3: вычисление разницы показания часов с показанием протокола сетевого времени Network Time Protocol (NTP) Формально достижение консенсуса относительно правильного времени называется синхронизацией часов. Существует несколько международных стандартов синхро низации часов. Основное внимание в этом разделе будет уделено самому известно му из них - протоколу сетевого времени (Network Time Protocol, NTP). NTP существует с середины 1980-х годов и зарекомендовал себя как очень ста бильный протокол. В первых четырех редакциях его оперативный формат не пре терпел никаких изменений, постоянно сохраняя обратную совместимость. NTP ра ботает в двух режимах, которые условно можно описать как «всегда включею) и «запрос-ответ)). Режим «всегда включею) позволяет нескольким компьютерам прийти к согласо ванному определению момента и работать в одноранговой сети. Для этого нужно, чтобы на каждом устройстве была программа или служба, запущенная в фоновом режиме, но обеспечить жесткую синхронизацию можно и в локальных сетях. Режим «запрос-ответ)) намного проще. Локальные клиенты запрашивают время в одном сообщении, а затем анализируют ответ, отслеживая прошедшее время. Затем клиент может сравнить исходную метку времени с меткой времени, отправленной с сервера, учесть любые задержки, вызванные временем отклика сети, и внести лю бые необходимые корректировки, чтобы перевести локальные часы, настроив их на время сервера. А к какому серверу должен подключаться ваш компьютер? NTP работает по иерар хической схеме. В центре - небольшая сеть атомных часов. Кроме них есть еще и национальные пулы серверов. NTP позволяет клиентам запрашивать время у компьютеров, находящихся ближе к атомным часам. Но это лишь часть пути. Допустим, ваш компьютер делает запрос в адрес десяти компьютеров относительно их позиции времени. После этого мы рас полагаем десятью утверждениями о времени, а сетевые задержки для каждого ис точника будут отличаться друг от друга!
9.9.1. Отправка NТР-запросов и интерпретация ответов Рассмотрим ситуацию в системе «клиент-сервер)), при которой вашему компьютеру нужно скорректировать свое собственное время.
Время и хронометраж
403
Для каждого компьютера, с которым проводится сверка (назовем такие компьюте ры тайм-серверами), используются два вида сообщений: • Сообщение от вашего компьютера в адрес каждого тайм-сервера является за просом. • Отклик известен как ответ. Этими двумя сообщениями генерируются четыре метки времени. Заметьте, что все это происходит последовательно: • Т1 - Метка времени клиента, соответствующая моменту отправки запроса. В коде фигурирует как t 1. • Т2 - Метка времени тайм-сервера, соответствующая моменту получения за проса. В коде фигурирует как t2. • Т3 - Метка времени тайм-сервера, соответствующая моменту отправки отве та. В коде фигурирует как tЗ. • Т4 - Метка времени клиента, соответствующая моменту получения ответа. В коде фигурирует как t 4. Названия Т 1 -Т4 обозначены в спецификации RFC 2030. Метки времени показаны на рис. 9.2. Т4 — запись времени локального компьютера на момент получения второго сообщения.
Т1 — запись времени локального компьютера на момент отправки первого сообщения.
T1
T4
Локальный компьютер T1 указывается, но Заголовок, показывающий, на практике не имеет абсолютно никачто сообщение является кого значения запросом времени
Заголовок, показывающий, что сообщение Сервером отправляются является ответом метки времени T1, T2 и T3
T1
T1 Outbound message
T2
T3
Inbound message
T2
T3
Удаленный сервер Т2 и Т3 записываются удаленным сервером при фиксации времени получения первого и отправки второго сообщения Время
Рис. 9.2. Метки времени, определенные в стандарте NTP
Чтобы понять, что все это означает в программном коде, обратимся к следующему листингу. Код в строках 2-12 занят установкой подключения. В строках 14-21 вы дается значение Т 1 - Т4.
404
Глава 9
Листинг 9.12. Определение функции, отправляющей NTP-сообщения 1 fn ntp_roundtrip( 2
host: &str,
3
port: u16,
4 ) -> Result { 5
let destination = format!("{}:{}", host, port);
6
let timeout = Duration::from_secs(1);
7 8
let request = NTPMessage::client();
9
let mut response = NTPMessage::new();
10 11
let message = request.data;
12 13
let udp = UdpSocket::bind(LOCAL_ADDR)?;
14
udp.connect(&destination).expect("unable to connect");
15 let t1 = Utc::now();
(1)
18
udp.send(&message)?;
(2)
19
udp.set_read_timeout(Some(timeout))?;
20
udp.recv_from(&mut response.data)?;
16 17
21 22
(3)
let t4 = Utc::now();
23 24
let t2: DateTime =
25
response
26
.rx_time()
27
.unwrap()
28
.into();
29 30
let t3: DateTime =
31
response
32
.tx_time()
33
.unwrap()
34
.into();
(4)
(4) (4) (4) (4) (5) (5) (5)
(5) (5)
35 36
Ok(NTPResult {
37
t1: t1,
38
t2: t2,
39
t3: t3,
40 41
t4: t4, })
42 }
(1) Этот код слегка халтурит, не раскодируя в исходящем сообщении значение tl. Но на практике все срабатывает и без этого, немного экономя трудозатраты. (2) Отправка полезных данных запроса (определенных в каком-либо другом месте) на сервер.
Время и хронометраж
405
(3) Блокировка приложения до момента готовности получения данных. (4) rx_time() обозначает метку времени получения и является временем получения сервером клиентского сообщения. (5) tx_time() обозначает метку времени передачи и является временем отправки сервером ответа.
Значение Т 1 - Т4 инкапсулировано в коде листинга 9.12 в виде NTPResult, и это все, что требуется, чтобы определить, совпадает ли местное время со временем сервера. В протоколе содержится больше информации, относящейся к обработке ошибок, но здесь, дабы не усложнять ситуацию, все это опускается. А во всем остальном мы получаем вполне приличный NТР-клиент.
9.9.2. Корректировка местного времени по ответу сервера Если предположить, что наш клиент получил как минимум один NТР-ответ, но все же надеемся, что больше, остается только вычислить «правильное» время. Но по стойте, а какое время будет правильным? У нас ведь имеются только относитель ные метки времени. А универсальной «истины», к которой нам дали доступ, как не было, так и нет. ПРИМЕЧАНИЕ Те, кому не нравятся буквы греческого алфавита, могут просто бегло просмотреть или вообще пропустить следующие несколько абзацев. В NТР-документации для разрешения сложившейся ситуации представлены два уравнения. Наша цель - вычислить два значения. Оба вычисления показаны в таб лице 9.2. • Смещение по времени -наш конечный интерес. В официальной документа ции оно обозначено буквой 0 (тета). Когда у 0 положительное значение, на ши часы спешат. Если же оно отрицательное - они опаздывают. • Задержка, вызванная загруженностью сети, ожиданием и другими помеха ми. Обозначается буквой Б(дельта). Большое значение Б подразумевает более веские сомнения в показаниях. В нашем коде это значение используется для отслеживания серверов с быстрым ответом. Таблица 9.2. Способы вычисления д и е в NTP 8 = (Тг Т 1 }-(Тз -Т2)
Выражение (Т4 - Т 1 } позволяет вычислить общее время, затраченное на стороне клиента. А (Т 3 - Т 2) - время, затраченное на стороне сервера. Разность, получаемая при вычитании одного вычисленного времени из другого (например, 8), является оценкой разницы между часами плюс задержка, вызванная прохождением по сети и обработкой.
0 = ((Т2 -Т 1 ) + (Т4 -Тз))/ 2
Мы берем среднее значение из двух пар временных меток.
406
Глава 9
Математики могут быть обескуражены, поскольку им всегда хочется знать, каково точное время на самом деле. Но это невозможно. У нас есть только утверждения. NTP рассчитан на многократное применение в течение дня, при этом участники переводят свои часы постепенно. При достаточном числе корректировок 0 стре мится к О, а значение 8 остается относительно стабильным. Стандарт дает весьма подробное и четкое описание формулы корректировок. На пример, эталонная реализация NTP включает вполне практичную фильтрацию, по зволяющую ограничить влияние вредных факторов и ложных результатов. Но мы пойдет на хитрость и просто возьмем среднее значение разностей, взвешенных по 1/02, тем самым сильно снизив негативное влияние на конечный результат со сто роны медленных серверов. Чтобы свести вероятность каких-либо негативных ре зультатов к минимуму: • Будем проводить корректировку времени по широко известным «хорошим» серверам. В частности, чтобы снизить шансы на отправку в наш адрес недос товерных результатов, воспользуемся тайм-серверами, которые содержатся основными поставщиками операционных систем, и другими надежными ис точниками. • Не допустим силы-юго влияния на конечный результат со стороны какого либо отдельно взятого результата. Установим ограничение в 200 мс на лю бую корректировку местного времени. В следующем листинге, являющемся частью листинга 9.15, показан процесс обра щения к нескольким тайм-серверам. Листинг 9.13. Корректировка времени в соответствии с получаемыми ответами 175 fn check_time() -> Result { 176
const NTP_PORT: u16 = 123;
177 178
let servers = [
179
"time.nist.gov",
180
"time.apple.com",
181
"time.euro.apple.com",
182
"time.google.com",
(1)
183
"time2.google.com",
184
/ /"time.windows.com",
(1)
185
];
186 187
let mut times = Vec::with_capacity(servers.len());
188 189 190
for &server in servers.iter() { print!("{} =>", server);
191 192 193
let calc = ntp_roundtrip(&server, NTP_PORT);
(2)
Время и хронометраж 194
match calc {
195
Ok(time) => {
196
println!(" {}ms away from local system time", time.offset());
197
times.push(time);
198
}
199
Err(_) => {
200
println!(" ? [response took too long]")
201
}
202 203
407
}; }
204 205
let mut offsets = Vec::with_capacity(servers.len());
206
let mut offset_weights = Vec::with_capacity(servers.len());
207 208
for time in × {
209
let offset = time.offset() as f64;
210
let delay = time.delay() as f64;
211 212
let weight = 1_000_000.0 / (delay * delay);
213
if weight.is_finite() {
214
offsets.push(offset);
215
offset_weights.push(weight);
216 217
(3)
} }
218 219
let avg_offset = weighted_mean(&offsets, &offset_weights);
220 221
Ok(avg_offset)
222 }
(1) В тайм-серверах Google дополнительная секунда реализуется не ее добавлением, а увеличением длины секунды. То есть в какой-то из дней примерно каждЬiе 18 месяцев этот сервер вьщает время, отличное от времени других серверов. (2) На момент написания книги тайм-сервер Microsoft показывал время, на 15 секунд опережавшее время своих собратьев. (3) Подавление результатов от медленных серверов за счет СУШественного снижения их относительного рейтинга.
9.9.3. Преобразования между представлениями о времени, использующими различные степени точности и эпохи Контейнер chrono представляет дробную часть секунды с точностью до наносе кунд, а в NTP моменты времени могут быть представлены с разницей примерно в 250 пикосекунд. Подсчет ведется чуть ли не в четыре раза точнее! Использование различных внутренних представлений подразумевает возможную потерю точности при преобразованиях.
408
Глава 9
Механизм, сообщающий языку Rust о возможности преобразования одного типа в другой, находится в типаже From. В нем предоставляется метод from (), встреча с которым происходит на ранних стадиях освоения Rust (например, в виде кода String::from("Hello, world!")).
В следующем листинге показаны три фрагмента из кода листинга 9.15, в которых представлена реализация типажа std::conver t ::From. Этот код позволяет осуще ствлять вызовы . into (), показанные в строках 28 и 34 листинга 9.12. Листинг 9.14. Преобразования между chrono::DateTime и метками времени NTP 19 const NTP_TO_UNIX_SECONDS: i64 = 2_208_988_800;
(1)
22 #[derive(Default,Debug,Copy,Clone)] 23 struct NTPTimestamp { 24
seconds: u32,
25
fraction: u32,
(2) (2) (2)
(2)
26 }
52 impl From for DateTime { 53
fn from(ntp: NTPTimestamp) -> Self {
54
let secs = ntp.seconds as i64 - NTP_TO_UNIX_SECONDS;
55
let mut nanos = ntp.fraction as f64;
56
nanos *= 1e9;
57
nanos /= 2_f64.powi(32);
58 59 60
(3) (3)
Utc.timestamp(secs, nanos as u32) }
61 } 62 63 impl From for NTPTimestamp { 64
fn from(utc: DateTime) -> Self {
65
let secs = utc.timestamp() + NTP_TO_UNIX_SECONDS;
66
let mut fraction = utc.nanosecond() as f64;
67
fraction *= 2_f64.powi(32);
68
fraction /= 1e9;
69 70
NTPTimestamp {
71
seconds: secs as u32,
72
fraction: fraction as u32,
73 74
} }
75 }
(1) Количество секунд между 1 января 1900 года (началом эпохи NTP) и 1 января 1970 года (началом эпохи UNIX).
(3) (3)
Время и хронометраж
409
(2) Наш внутренний тип, представляющий метку времени NTP. (3) Эти преобразования можно реализовать с использованием операций с битовым сдвигом, пожертвовав при этом читаемостью кода.
У From есть аналог Into. Реализация From позволяет Rust автоматически, за исклю чением сложных случаев, генерировать реализацию rnto. Но вполне вероятно, что и в таких случаях разработчики уже обладают знаниями, необходимыми для само стоятельного внедрения Into, и поэтому не нуждаются в помощи.
9.9.4. clock v0.1.3: листинг полной версии кода Полный код нашего приложения clock показан в листинге 9.15. Представленная во всей своей красе финальная версия этого приложения может показаться слишком солидной. Хотелось бы надеяться, что для вас в ней уже не будет никаких элемен тов Rust-синтаксиса, требующих дополнительного усвоения. Исходный код лис тинга находится в файле ch9/ch9-clock3/src/main.rs. Листинг 9.15. Листинг полной версии приложения командной строки clock, представляющего собой NTP-клиента 1 #[cfg(windows)] 2 use kernel32; 3 #[cfg(not(windows))] 4 use libc; 5 #[cfg(windows)] 6 use winapi; 7 8 use byteorder::{BigEndian, ReadBytesExt}; 9 use chrono::{ 10
DateTime, Duration as ChronoDuration, TimeZone, Timelike,
11 }; 12 use chrono::{Local, Utc}; 13 use clap::{App, Arg}; 14 use std::mem::zeroed; 15 use std::net::UdpSocket; 16 use std::time::Duration; 17 18 const NTP_MESSAGE_LENGTH: usize = 48;
(1)
19 const NTP_TO_UNIX_SECONDS: i64 = 2_208_988_800; 20 const LOCAL_ADDR: &'static str = "0.0.0.0:12300"; 21 22 #[derive(Default, Debug, Copy, Clone)] 23 struct NTPTimestamp { 24
seconds: u32,
25
fraction: u32,
26 }
(2)
410
Глава 9
27 28 struct NTPMessage { 29
data: [u8; NTP_MESSAGE_LENGTH],
30 } 31 32 #[derive(Debug)] 33 struct NTPResult { 34
t1: DateTime,
35
t2: DateTime,
36
t3: DateTime,
37
t4: DateTime,
38 } 39 40 impl NTPResult { 41
fn offset(&self) -> i64 {
42
let duration = (self.t2 - self.t1) + (self.t4 - self.t3);
43 44
duration.num_milliseconds() / 2 }
45 46
fn delay(&self) -> i64 {
47
let duration = (self.t4 - self.t1) - (self.t3 - self.t2);
48 49
duration.num_milliseconds() }
50 } 51 52 impl From for DateTime { 53
fn from(ntp: NTPTimestamp) -> Self {
54
let secs = ntp.seconds as i64 - NTP_TO_UNIX_SECONDS;
55
let mut nanos = ntp.fraction as f64;
56
nanos *= 1e9;
57
nanos /= 2_f64.powi(32);
58 59 60
Utc.timestamp(secs, nanos as u32) }
61 } 62 63 impl From for NTPTimestamp { 64
fn from(utc: DateTime) -> Self {
65
let secs = utc.timestamp() + NTP_TO_UNIX_SECONDS;
66
let mut fraction = utc.nanosecond() as f64;
67
fraction *= 2_f64.powi(32);
68
fraction /= 1e9;
69 70
NTPTimestamp {
71
seconds: secs as u32,
72 73
fraction: fraction as u32, }
Время и хронометраж 74
411
}
75 } 76 77 impl NTPMessage { 78
fn new() -> Self {
79
NTPMessage {
80
data: [0; NTP_MESSAGE_LENGTH],
81 82
} }
83 84
fn client() -> Self {
85
const VERSION: u8 = 0b00_011_000;
86
const MODE: u8
= 0b00_000_011;
(3) (3)
87 88
let mut msg = NTPMessage::new();
89 90
msg.data[0] |= VERSION;
91
msg.data[0] |= MODE;
92 93
msg }
(4) (4) (5)
94 95
fn parse_timestamp(
96
&self,
97
i: usize,
98
) -> Result {
99
let mut reader = &self.data[i..i + 8];
100
let seconds
= reader.read_u32::()?;
101
let fraction
= reader.read_u32::()?;
(6)
102 103
Ok(NTPTimestamp {
104
seconds:
105
fraction: fraction,
106 107
seconds,
}) }
108 109
fn rx_time(
110 111
&self ) -> Result {
112 113
(7)
self.parse_timestamp(32) }
114 115
fn tx_time(
116 117
&self ) -> Result {
118 119
self.parse_timestamp(40) }
(8)
412
Глава 9
120 } 121 122 fn weighted_mean(values: &[f64], weights: &[f64]) -> f64 { 123
let mut result = 0.0;
124
let mut sum_of_weights = 0.0;
125 126
for (v, w) in values.iter().zip(weights) {
127
result += v * w;
128 129
sum_of_weights += w; }
130 131
result / sum_of_weights
132 } 133 134 fn ntp_roundtrip( 135
host: &str,
136
port: u16,
137 ) -> Result { 138
let destination = format!("{}:{}", host, port);
139
let timeout = Duration::from_secs(1);
140 141
let request = NTPMessage::client();
142
let mut response = NTPMessage::new();
143 144
let message = request.data;
145 146
let udp = UdpSocket::bind(LOCAL_ADDR)?;
147
udp.connect(&destination).expect("unable to connect");
148 149
let t1 = Utc::now();
150 151
udp.send(&message)?;
152
udp.set_read_timeout(Some(timeout))?;
153
udp.recv_from(&mut response.data)?;
154
let t4 = Utc::now();
155 156 157
let t2: DateTime = response
158
.rx_time()
159
.unwrap()
160 161 162
.into(); let t3: DateTime = response
163
.tx_time()
164
.unwrap()
165
.into();
166
Время и хронометраж 167
Ok(NTPResult {
168
t1: t1,
169
t2: t2,
170
t3: t3,
171 172
t4: t4, })
173 } 174 175 fn check_time() -> Result { 176
const NTP_PORT: u16 = 123;
177 178
let servers = [
179
"time.nist.gov",
180
"time.apple.com",
181
"time.euro.apple.com",
182
"time.google.com",
183
"time2.google.com",
184 185
/ /"time.windows.com", ];
186 187
let mut times = Vec::with_capacity(servers.len());
188 189
for &server in servers.iter() {
190
print!("{} =>", server);
191 192
let calc = ntp_roundtrip(&server, NTP_PORT);
193 194
match calc {
195
Ok(time) => {
196
println!(" {}ms away from local system time", time.offset());
197
times.push(time);
198
}
199
Err(_) => {
200
println!(" ? [response took too long]")
201
}
202 203
}; }
204 205
let mut offsets = Vec::with_capacity(servers.len());
206
let mut offset_weights = Vec::with_capacity(servers.len());
207 208
for time in × {
209
let offset = time.offset() as f64;
210
let delay = time.delay() as f64;
211 212
let weight = 1_000_000.0 / (delay * delay);
413
414
Глава 9
213
if weight.is_finite() {
214
offsets.push(offset);
215
offset_weights.push(weight);
216 217
} }
218 219
let avg_offset = weighted_mean(&offsets, &offset_weights);
220 221
Ok(avg_offset)
222 } 223 224 struct Clock; 225 226 impl Clock { 227
fn get() -> DateTime {
228 229
Local::now() }
230 231
#[cfg(windows)]
232
fn set(t: DateTime) -> () {
233
use chrono::Weekday;
234
use kernel32::SetSystemTime;
235
use winapi::{SYSTEMTIME, WORD};
236 237
let t = t.with_timezone(&Local);
238 239
let mut systime: SYSTEMTIME = unsafe { zeroed() };
240 241
let dow = match t.weekday() {
242
Weekday::Mon => 1,
243
Weekday::Tue => 2,
244
Weekday::Wed => 3,
245
Weekday::Thu => 4,
246
Weekday::Fri => 5,
247
Weekday::Sat => 6,
248 249
Weekday::Sun => 0, };
250 251
let mut ns = t.nanosecond();
252
let is_leap_second = ns > 1_000_000_000;
253 254
if is_leap_second {
255 256
ns -= 1_000_000_000; }
257 258
systime.wYear = t.year() as WORD;
259
systime.wMonth = t.month() as WORD;
Время и хронометраж 260
systime.wDayOfWeek = dow as WORD;
261
systime.wDay = t.day() as WORD;
262
systime.wHour = t.hour() as WORD;
263
systime.wMinute = t.minute() as WORD;
264
systime.wSecond = t.second() as WORD;
265
systime.wMilliseconds = (ns / 1_000_000) as WORD;
266 267
let systime_ptr = &systime as *const SYSTEMTIME;
268
unsafe {
269
SetSystemTime(systime_ptr);
270 271
} }
272 273
#[cfg(not(windows))]
274
fn set(t: DateTime) -> () {
275
use libc::settimeofday;
276
use libc::{suseconds_t, time_t, timeval, timezone};
277 278
let t = t.with_timezone(&Local);
279
let mut u: timeval = unsafe { zeroed() };
280 281
u.tv_sec = t.timestamp() as time_t;
282
u.tv_usec = t.timestamp_subsec_micros() as suseconds_t;
283 284
unsafe {
285
let mock_tz: *const timezone = std::ptr::null();
286
settimeofday(&u as *const timeval, mock_tz);
287 288
} }
289 } 290 291 fn main() { 292
let app = App::new("clock")
293
.version("0.1.3")
294
.about("Gets and sets the time.")
295
.after_help(
296
"Note: UNIX timestamps are parsed as whole seconds since 1st \
297
January 1970 0:00:00 UTC. For more accuracy, use another \
298
format.",
299
)
300
.arg(
301
Arg::with_name("action")
302
.takes_value(true)
303
.possible_values(&["get", "set", "check-ntp"])
304
.default_value("get"),
305
)
306
.arg(
415
416
Глава 9
307
Arg::with_name("std")
308
.short("s")
309
.long("use-standard")
310
.takes_value(true)
311
.possible_values(&["rfc2822", "rfc3339", "timestamp"])
312
.default_value("rfc3339"),
313
)
314
.arg(Arg::with_name("datetime").help(
315
"When is 'set', apply . Otherwise, ignore.",
316
));
317 318
let args = app.get_matches();
319 320
let action = args.value_of("action").unwrap();
321
let std = args.value_of("std").unwrap();
322 323
if action == "set" {
324
let t_ = args.value_of("datetime").unwrap();
325 326
let parser = match std {
327
"rfc2822" => DateTime::parse_from_rfc2822,
328
"rfc3339" => DateTime::parse_from_rfc3339,
329
_ => unimplemented!(),
330
};
331 332
let err_msg =
333
format!("Unable to parse {} according to {}", t_, std);
334
let t = parser(t_).expect(&err_msg);
335 336
Clock::set(t);
337 338
} else if action == "check-ntp" {
339
let offset = check_time().unwrap() as isize;
340 341
let adjust_ms_ = offset.signum() * offset.abs().min(200) / 5;
342
let adjust_ms = ChronoDuration::milliseconds(adjust_ms_ as i64);
343 344
let now: DateTime = Utc::now() + adjust_ms;
345 346 347
Clock::set(now); }
348 349 350 351
let maybe_error = std::io::Error::last_os_error(); let os_error_code =
Время и хронометраж 352
417
&maybe_error.raw_os_error();
353 354
match os_error_code {
355
Some(0) => (),
356
Some(_) => eprintln!("Unable to set the time: {:?}", maybe_error),
357 358
None => (), }
359 360
let now = Clock::get();
361 362
match std {
363
"timestamp" => println!("{}", now.timestamp()),
364
"rfc2822" => println!("{}", now.to_rfc2822()),
365
"rfc3339" => println!("{}", now.to_rfc3339()),
366 367
_ => unreachable!(), }
368 }
(1) 12*4 байтов (ширина двенадцати 32-разрядных целых чисел). (2) 12300 - порт NTP, используемый по умолчанию. (3) Поля NTP разделяются знаками подчеркивания: индикатор дополнительной секунды (2 бита), версия (3 бита) и режим (3 бита). (4) В первом байте каждого NТР-сообщения содержатся три поля, но устанавливать нужно только два из них. (5) Теперь значение msg.data[0] равно 0001_1011 (27 в десятичном исчислении). (6) Получение фрагмента до первого байта. (7) RX означает получение. (8) ТХ означает передачу.
Резюме ♦ Отслеживание прошедшего времени дается нелегко. Цифровые часы в конечном
итоге полагаются на нечеткие сигналы от аналоговых систем.
♦ Представить время весьма непросто. Библиотеки и стандарты базируются на разных представлениях о требуемой точности и начале отсчета. ♦ Установить истину в распределенной системе довольно сложно. Как бы нам ни хотелось, но единого арбитра, определяющего время, не существует. Можно лишь надеяться, что все компьютеры в нашей сети имеют достаточно близкое представление о времени. ♦ Структура, не имеющая полей, называется типом нулевого размера, или ZST.
В скомпилированном коде приложения она вообще не занимает никакой памяти, и является исключительно конструкцией времени компиляции.
418
Глава 9
♦ Rust позволяет создавать кроссплатформенные приложения. Это делается за счет добавления реализаций функций, предназначенных для той или иной плат формы с применением четких сfg-аннотаций. ♦ Взаимодействие с внешними библиотеками, такими как АРI-интерфейсы, пре доставляемые операционной системой, редко обходится без этапа преобразова ния типа. Система типов Rust не распространяется на библиотеки, созданные не для него! ♦ Для вызова функций операционной системы используются системные вызовы. Они не обходятся без сложного взаимодействия операционной системы, цен трального процессора и приложения. ♦ В Windows API обычно используются весьма информативные идентификаторы в смешанном регистре PascalCase, а в операционных системах семейства POSIX довольно лаконичные идентификаторы с символами в нижнем регистре. ♦ Предположения о значении таких понятий, как эпоха и часовой пояс, должны уточняться. Порой истинные значения совсем не соответствуют первым впечат лениям. ♦ Время может идти вспять. Никогда не пишите приложение, которое полагается на монотонно увеличивающееся время, не убедившись, что оно запрашивает у операционной системы время на часах с монотонно увеличивающимися показа ниями.
10 Процессы, потоки и контейнеры В этой главе рассматриваются следующие вопросы: ♦ Программирование конкурентных вычислений в Rust. ♦ Как различать процессы, потоки и контейнеры. ♦ Каналы и передача сообщений. ♦ Очереди задач. Пока в этой книге удавалось практически обойтись без упоминания двух фунда ментальных понятий системного программирования: потоков и процессов. Вместо них использовалось единственное понятие: программа. Эта глава призвана расши рить наш словарный запас. Процессы, потоки и контейнеры - абстракции, созданные для одновременного выполнения нескольких задач. Ими обеспечиваются конкурентные вычисления. Их родственное понятие, параллелизм, означает одновременное использование не скольких физических ядер центрального процессора. Как ни странно, система конкурентных вычислений может выстраиваться и на од ном ядре центрального процессора. Поскольку доступ к данным из памяти и опера ции ввода-вывода занимают много времени, потоки, запрашивающие данные, мо гут быть переведены в заблокированное· состояние. Их возвращение в активное со стояние происходит при доступности данных. Внедрить в компьютерную программу конкурентные вычисления, то есть одновре менное выполнение сразу нескольких задач, довольно сложно. Эффективное ис пользование конкурентных вычислений не обходится без новых концепций и ново го синтаксиса. Цель этой главы- вселить в вас уверенность в возможность освоения более слож ного материала. Вы получите четкое представление о различных инструментах, доступных вам в качестве программиста приложений. Здесь предстоит ознакомле ние с готовой к использованию стандартной библиотекой и довольно качественно сконструированными контейнерами crossbeam и rayon. Но это не даст вам доста точно знаний для реализации ваших собственных контейнеров конкурентных вы числений. Глава имеет следующую структуру: • В разделе 10.1 представлен синтаксис замыканий Rust. Замыкания также на зывают безымянными или лямбда-функциями. Их синтаксис играет весьма важную роль, поскольку на него, в целях поддержки принятой в Rust модели конкурентных вычислений, полагаются и стандартная библиотека, и многие (если не все) внешние контейнеры.
420
Глава 10
• В разделе 10.2 дается краткий урок по порождению потоков. Раскрывается суть потока и способы его создания (порождения). Также рассматривается вопрос о том, почему программистов предостерегают от порождения десят ков тысяч потоков. • В разделе 10.3 рассматривается разница между функциями и замыканиями. Их отождествление может породить путаницу в сознании программистов, не достаточно освоивших Rust, поскольку в других языках они зачастую мало отличаются друг от друга. • В главе 10.4 рассматривается разработка крупного проекта. В ней с ис пользованием нескольких стратегий будет реализован многопоточный парсер и генератор кода. Попутно, в качестве приятного бонуса, вы освоите проце дурное искусство. • В завершение главы будет дан обзор других форм конкурентных вычислений. В частности, будут рассмотрены понятия процессов и контейнеров.
10.1. Безымянные функции Глава большая, поэтому сначала стоит рассмотреть ряд моментов, касающихся ба зового синтаксиса и практических примеров, а потом уже вернуться к освоению многочисленного концептуального и теоретического материала. Потоки и другие формы кода, допускающие одновременное выполнение, исполь зуют особую форму определения функций, применение которой в книге до этого в основном избегалось. Согласно теперешним представлениям определение функции выглядит так: fn add(a: i32, b: i32) -> i32 { a + b }
Примерный эквивалент в форме лямбда-функции имеет следующий вид: let add = |a,b| { a + b };
Лямбда-функции обозначаются парой вертикальных линий ( 1 ... 1 ), за которыми следуют фигурные скобки ( { ... J ). Пара вертикальных линий позволяет определять аргументы. Лямбда-функции в Rust могут читать значения переменных из своей области видимости. Это замыкания. В отличие от обычных функций, лямбда-функции не могут определяться в гло бальной области видимости. В следующем листинге показано, как обойти это пра вило, определив такую функцию внутри main (). Здесь даются определения двух функций: обычной функции и лямбда-функции - а затем проверяется, приводит ли это к одному и тому же результату. Листинг 10.1. Определение двух функций и проверка результата fn add(a: i32, b: i32) -> i32 { a + b }
Процессы, потоки и контейнеры
421
fn main() { let lambda_add = |a,b| { a + b }; assert_eq!(add(4,5), lambda_add(4,5)); }
При запуске кода листинга 10.1 на выполнение все проходит удачно (и без види мых признаков). А теперь посмотрим, как применить эти функциональные возмож ности в реальной работе.
10.2. Порождение потоков Потоки являются первичным механизмом, который операционная система предос тавляет для выполнения конкурентных вычислений. Современные операционные системы гарантируют каждому потоку равноправный доступ к центральному про цессору. Умение создавать потоки (зачастую называемое порождением потоков) и понимание оказываемого ими влияния относятся к фундаментальным навыкам про граммистов, желающих использовать многоядерные процессоры.
10.2.1. Введение в замыкания В Rust для порождения потока безымянная функция передается в функцию s td: : thr e a d: : spawn () . Согласно описанию в разделе 10.1 безымянные функции определяются с помощью двух вертикальных линий, применяемых для передачи аргументов, и последующих фигурных скобок, охватывающих тело функции. По скольку spawn () никаких аргументов не принимает, зачастую будет встречаться следующий синтаксис: thread::spawn(|| { // ... });
Когда порожденному потоку требуется доступ к переменным, определенным в ро дительской области видимости, называемой захватом (capture), Rust зачастую на стаивает на перемещении захватов в замыкание. Чтобы обозначить намерение пе редачи владения, в безымянной функции применяется ключевое слово move: thread::spawn(move || {
( 1)
// ... });
(1) Ключевое слово move позволяет безымянной функции получать доступ к переменным из более широкой области видимости.
А зачем нужно ключевое слово move? Замыкания, порожденные в подпотоках, по тенциально могут пережить область видимости своего вызова. Поскольку Rust все гда гарантирует действительность доступа к данным, ему нужно, чтобы владение перешло к самому замыканию. Пока не сложится окончательное представление о
422
Глава 10
том, как все это работает, нужно следовать нескольким рекомендациям по исполь зованию захватов: • Во избежание конфликтов в ходе компиляции следует реализовать типаж Сору.
• Значениям, происходящим во внешних областях видимости, может понадо биться статическое время жизни. • Порожденные подпотоки могут пережить своих родителей. А значит, владе ние должно переходить к подпотоку с применением ключевого слова rnove.
10.2.2. Порождение потока Находясь в режиме ожидания, простая задача оставляет центральный процессор в спящем режиме на 300 мс (миллисекунд). Если процессор имеет тактовую частоту 3 ГГц, ему придется отдыхать примерно 1 миллиард циклов. Электроны будут пре бывать в полном бездействии. При выполнении код листинга 10.2 выведет на кон соль общую продолжительность выполнения обоих потоков (в показаниях «настен ных часою>): 300.218594rns Листинг 10.2. Засыпание потока на 300 мс 1 use std::{thread, time}; 2 3 fn main() { 4
let start = time::Instant::now();
5 6 7 8 9
let handler = thread::spawn(|| { let pause = time::Duration::from_millis(300); thread::sleep(pause.clone()); });
10 11
handler.join().unwrap();
12 13
let finish = time::Instant::now();
14 15
println!("{:02?}", finish.duration_since(start));
16 }
Опыт программирования в многопоточной среде предполагает знакомство с функ цией сращивания join, показанной в строке 11. Эта функция используется доволь но часто, но что она означает? Функция j oin - расширение образного представления потока. При порождении потоков говорят, что они ответвились от своего родительского потока. Срастить потоки (с помощью join) означает снова слить их.
Процессы, потоки и контейнеры
423
На практике слияние означает ожидание завершения другого потока. Функция j oin () предписывает операционной системе отложить планирование выполнения вызы вающего потока до завершения выполнения другого потока.
10.2.3. Эффект от порождения нескольких потоков В идеале добавление второго потока удваивает объем работы, с которым можно справиться за то же время. Работа каждого потока может выполняться независи мо. К сожалению, реальность далека от идеала. В силу чего родился миф, что по токи создаются медленно и громоздки в обслуживании. Цель раздела - развеять этот миф. При должном использовании потоки проявляют себя только с лучшей стороны. В листинге 10.3 показан код программы, измеряющей общее время, затрачиваемое двумя потоками на выполнение того задания, которое было выполнено одним по током в программе, показанной в листинге 10.2. Если на добавление потоков ухо дит много времени, то ожидается, что на выполнение кода листинга 10.3 уйдет больше времени. Нетрудно заметить, что влияние от создания двух потоков вместо одного весьма незначительное. При выполнении кода листинга 10.3 выводится почти такой же результат, как и при выполнении кода листинга 10.2: 300.242328ms
// Сравните с 300.218594 мс у кода листинга 10.2
Разница между этими двумя прогонами на моем компьютере составила 0,24 мс. Ко нечно, назвать это надежным эталонным тестом нельзя, и тем не менее, здесь пока зано, что создание потока не сильно снижает производительность. Листинг 10.3. Создание двух потоков для выполнения заданной нами работы 1 use std::{thread, time}; 2 3 fn main() { 4
let start = time::Instant::now();
5 6 7
let handler_1 = thread::spawn(move || { let pause = time::Duration::from_millis(300);
8 9
thread::sleep(pause.clone()); });
10 11 12
let handler_2 = thread::spawn(move || { let pause = time::Duration::from_millis(300);
13 14
thread::sleep(pause.clone()); });
15 16
handler_1.join().unwrap();
17
handler_2.join().unwrap();
18
424 19
Глава 10 let finish = time::Instant::now();
20 21
println!("{:?}", finish.duration_since(start));
22 }
Тем, кто уже был в теме, возможно, приходилось слышать, что потоки «не масшта бируются». Что это значит? Каждому потоку нужна своя собственная память, следовательно, память системы в конечном счете может быть исчерпана. Но даже не доходя до предела, создание потока начинает вызывать замедление в других областях. По мере увеличения чис ла потоков растет объем работы по их диспетчеризации со стороны операционной системы. Когда приходится регулировать работу множества потоков, решение о том, какой поток запускать следующим, занимает больше времени.
10.2.4. Эффект от порождения множества потоков Порождение потоков не обходится без издержек. Оно потребляет память и время центрального процессора. Переключение между потоками также обесценивает дан ные, хранящиеся в кэш-памяти.
Засечка времени возвращения из серии по обычным часам (мс)
На рис. 10.1 показаны данные, сгенерированные при удачном запуске на выполне ние кода листинга 10.4. До серии из 400 потоков картина отклонений сохраняется практически в неизменном виде. Далее уже никто не знает, сколько времени уйдет на переход в 20-миллисекундный спящий режим. 120 110 100 90 80 70 60 50 40 30 20 0
100
200
300
400
500
600
700
800
Количество потоков, порожденных в серии
Рис. 10.1. Продолжительность ожидания перехода потоков
в 20-миллисекундный спящий режим
900
1000
Засечка времени возвращения из серии по обычным часам (мс)
Процессы, потоки и контейнеры
425
500
400
300
200
100
0 0
100
200
300
400
500
600
700
800
900
1000
Количество потоков, порожденных в серии
Засечка времени возвращения из серии по обычным часам (мс)
Рис. 10.2. Сравнение времени, затрачиваемого на ожидание в течение 20 мс с использованием стратегии спящего режима (обозначено кружками) и с использованием стратегии пустого цикла (обозначено плюсами). На этой диаграмме показаны различия, возникающие при конкуренции сотен потоков. 50
+ 40
+ +
+
+ + 30
+ +
+
+
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + + +
20
10
0 0
5
10
15
20
25
30
Количество потоков, порожденных в серии
Рис. 10.3. Сравнение времени, затрачиваемого на ожидание в течение 20 мс с использованием стратегии спящего режима (обозначено кружками) и с использованием стратегии пустого цикла (обозначено плюсами). Эта диаграмма показывает различия, возникающие, когда число потоков превышает количество ядер (6).
426
Глава 10
Если кому-то покажется, что спящий режим нельзя считать характерной рабочей нагрузкой, то на рис. 10.2 представлена еще одна, более показательная диаграмма. Здесь от каждого потока требуется вход в пустой цикл. На рис. 10.2 имеется ряд особенностей, требующих краткого пояснения. Во-первых, для первых примерно семи серий из версии с пустым циклом возвращение происходит за время, близкое к 20 мс. Но спящий режим в операционных системах не отличается особой акку ратностью. Если для потока нужна более короткая пауза сна или если приложение чувствительно к хронометражу, воспользуйтесь пустым циклом 1 • Во-вторых, многопоточность, интенсивно потребляющая ресурсы центрального процессора, не может существенно превышать количество физических ядер. Было проведено тестирование на 6-ядерном процессоре (lntel i7-8750H) с отключенной гиперпоточностью. На рис. 10.3 показано, что, как только число потоков превысит количество ядер, производительность быстро падает.
10.2.5. Воспроизведение результатов Посмотрев на эффекты многопоточности, давайте взглянем на код, генерировав ший входные данные для диаграмм, показанных на рисунках 10.1-10.2. Результаты можно воспроизвести, записав код листингов 10.4 и 10.5 в два файла, после чего проанализировав полученные данные. Листинг 10.4, чей исходный код находится в файле c10/ch10-multijoin/src/main.rs, пред назначен для приостановки потоков на 20 мс с помощью спящего режима (sleep), являющегося запросом к операционной системе на приостановку потока до истече ния определенного времени. Листинг 10.5, чей исходный код находится в файле cl0/chl0-busyth reads/sr c/main.rs, предназначен для использования стратегии ожидания занятости (также известной как ждущий цикл или пустой цикл) для вы держивания паузы в 20 мс. Листинг 10.4. Использование thread::sleep для приостановки потоков на 20 мс 1 use std::{thread, time}; 2 3 fn main() { 4 5
for n in 1..1001 { let mut handlers: Vec = Vec::with_capacity(n);
6 7
let start = time::Instant::now();
8
for _m in 0..n {
9
let handle = thread::spawn(|| {
10
let pause = time::Duration::from_millis(20);
11
thread::sleep(pause);
1 Можно также применять и то, и другое: основную часть времени использовать спящий режим, а ближе к концу - пустой цикл.
Процессы, потоки и контейнеры 12
427
});
13
handlers.push(handle);
14
}
15 16
while let Some(handle) = handlers.pop() {
17
handle.join();
18
}
19 20
let finish = time::Instant::now();
21 22
println!("{}\t{:02?}", n, finish.duration_since(start)); }
23 }
Листинг 10.5. Использование стратегии ожидания с применением пустого цикла 1 use std::{thread, time}; 2 3 fn main() { 4
for n in 1..1001 {
5
let mut handlers: Vec = Vec::with_capacity(n);
6 7
let start = time::Instant::now();
8
for _m in 0..n {
9
let handle = thread::spawn(|| {
10
let start = time::Instant::now();
11
let pause = time::Duration::from_millis(20);
12
while start.elapsed() < pause {
13
thread::yield_now();
14
}
15
});
16
handlers.push(handle);
17
}
18 19
while let Some(handle) = handlers.pop() {
20
handle.join();
21
}
22 23
let finish = time::Instant::now();
24 25
println!("{}\t{:02?}", n, finish.duration_since(start)); }
26 }
Механизм управления ходом выполнения программы, выбранный нами в строках 19-21, выглядит немного странно. Вместо последовательного перебора вектора h a ndlers выполняется вызов метода рор (), после чего происходит слияние подпо-
428
Глава 10
тока с основным потоком. В следующих двух фрагментах кода происходит сравне ние более привычного цикла for (листинг 10.6) с фактически используемым меха низмом управления (листинг 10.7). Листинг 10.6. Что ожидают увидеть в коде листинга 10.5 19 for handle in &handlers { 20
handle.join();
21 }
Листинг 10.7. Код, фактически использованный в листинге 10.5 19 while let Some(handle) = handlers.pop() { 20
handle.join();
21 }
Зачем был использован более сложный механизм управления? Полезно, наверное, будет вспомнить, что как только подпоток сливается с основным потоком, он пере стает существовать. А Rust не позволит нам сохранить ссылку на то, чего не суще ствует. Следовательно, чтобы вызвать j oin () в отношении описателя потока внут ри вектора описателей handlers, описатель потока должен быть удален из han dlers. Возникает проблема. Цикл for не позволяет изменять уже пройденные данные. А вот цикл while при вызове handle r s .рор () позволяет многократно по лучать изменяемый доступ. В коде листинга 10.8 показана нерабочая реализация стратегии пустого цикла. Ее несостоятельность обусловлена использованием более привычного механизма управления с использованием цикла for, который был отвергнут в коде листинга 10.5. Исходный код находится в файле c10/ch10-busythreads-broken/sгc/main.rs. Полу чаемый вывод на консоль показан сразу после листинга. Листинг 10.8. Использование стратегии ожидания с применением пустого цикла 1 use std::{thread, time}; 2 3 fn main() { 4 5
for n in 1..1001 { let mut handlers: Vec = Vec::with_capacity(n);
6 7
let start = time::Instant::now();
8
for _m in 0..n {
9
let handle = thread::spawn(|| {
10
let start = time::Instant::now();
11
let pause = time::Duration::from_millis(20);
12
while start.elapsed() < pause {
13
thread::yield_now();
Процессы, потоки и контейнеры 14
}
15
});
16
429
handlers.push(handle);
17
}
18 19
for handle in &handlers {
20
handle.join();
21
}
22 23
let finish = time::Instant::now();
24
println!("{}\t{:02?}", n, finish.duration_since(start));
25
}
26 }
А вот как выглядит информация, выведенная на консоль при попытке компиляции кода листинга 10.8: $ cargo run -q error[E0507]: cannot move out of `*handle` which is behind a shared reference --> src/main.rs:20:13 | 20 |
handle.join();
|
^^^^^^ move occurs because `*handle` has type
`std::thread::JoinHandle`, which does not implement the `Copy` trait error: aborting due to previous error For more information about this error, try `rustc --explain E0507`. error: Could not compile `ch10-busythreads-broken`. To learn more, run the command again with --verbose.
В сообщении об ошибке говорится, что ссылку здесь использовать нельзя. Дело в том, что несколько других потоков могут также воспользоваться своими собствен ными ссылками на базовые потоки. А эти ссылки должны быть действительными. Прозорливые читатели знают, что вообще-то есть более простой способ обойти данную проблему, чем тот, который использовался в коде листинга 10.5. Как пока зано в следующем листинге, нужно просто убрать амперсанд. Листинг 10.9. Вот чем можно было бы воспользоваться в коде листинга 10.5 19 for handle in handlers { 20 21 }
handle.join();
430
Глава 10
Здесь мы столкнулись с одним из редких случаев, где использование ссылки на объект вызывает больше проблем, чем использование объекта напрямую. Непо средственный последовательный перебор описателей сохраняет право владения. Тем самым уходят в сторону любые опасения по поводу совместного доступа и можно выполнить задуманное.
Уступка управления с помощью thread::yield_now() Стоит напомнить, что пустой цикл в коде листинга 10.5 включает в себя незнако мый код, повторяющийся в коде следующего листинга. Посмотрим, для чего он нужен. Листинг 10.10. Демонстрация текущего выполнения уступки управления со стороны потока 14 while start.elapsed() < pause { 15
thread::yield_now();
16 }
s td: :thread: :yield_now () - это сигнал операционной системе о снятии текущего потока с диспетчеризации. Тем самым позволяется продолжить выполнение других потоков, пока текущий будет ждать истечения 20 мс. Недостатком уступки управ ления является отсутствие информации о возможности возобновления выполнения потока ровно через 20 мс. Альтернатива уступке управления-функция std: :sync: :atomic: :spin_loop_hint (). Функция spin_loop_hint () не обращается к операционной системе, отправляя сиг нал непосредственно центральному процессору, который может воспользоваться подсказкой для прекращения функционирования, экономя тем самым потребление энергии. ПРИМЕЧАНИЕ Инструкция spin_loop_hint () действует не для каждого процессора. На тех плат формах, где она не поддерживается, просто ничего не происходит.
10.2.6. Совместно используемые переменные В наших сравнительных тестах в каждом потоке создавались переменные паузы p ause. Если не понятно, о чем речь, в следующем листинге представлен фрагмент листинга 10.5. Листинг 10.11. кцентирование внимания на бессмысленности создания экземпляров time::Duration 9 let handle = thread::spawn(|| { 10
let start = time::Instant::now();
11
let pause = time::Duration::from_millis(20);
12
while start.elapsed() < pause {
(1)
Процессы, потоки и контейнеры 13
431
thread::yield_now();
14
}
15 });
(1) Создавать эту переменную в каждом потоке нет никакого смысла.
Хотелось бы написать какой-нибудь код вроде того, что показан в следующем лис тинге; его код находится в файле ch10/ch10-sharedpause-broken/src/main.rs. Листинг 10.12. Попытка совместного использования переменной в нескольких подпотоках 1 use std::{thread,time}; 2 3 fn main() { 4
let pause = time::Duration::from_millis(20);
5
let handle1 = thread::spawn(|| {
6
thread::sleep(pause);
7
});
8
let handle2 = thread::spawn(|| {
9
thread::sleep(pause);
10
});
11 12
handle1.join();
13
handle2.join();
14 }
Если запустить код листинга 10.12 на выполнение, будет получено развернутое и на удивление весьма полезное сообщение об ошибке: $ cargo run -q error[E0373]: closure may outlive the current function, but it borrows `pause`, which is owned by the current function --> src/main.rs:5:33 | 5 |
let handle1 = thread::spawn(|| {
|
^^ may outlive borrowed value `pause`
6 |
thread::sleep(pause);
|
----- `pause` is borrowed here
| note: function requires argument type to outlive `'static` --> src/main.rs:5:19 | 5 |
let handle1 = thread::spawn(|| {
|
___________________^
6 | | 7 | |
thread::sleep(pause); });
| |______^
432
Глава 10
help: to force the closure to take ownership of `pause` (and any other references variables), use the `move` keyword | 5 |
let handle1 = thread::spawn(move || {
|
^^^^^^^
error[E0373]: closure may outlive the current function, but it borrows `pause`, which is owned by the current function --> src/main.rs:8:33 | 8 |
let handle2 = thread::spawn(|| {
|
^^ may outlive borrowed value `pause`
9 |
thread::sleep(pause);
|
----- `pause` is borrowed here
| note: function requires argument type to outlive `'static` --> src/main.rs:8:19 | 8 |
let handle2 = thread::spawn(|| {
|
___________________^
9 | | 10| |
thread::sleep(pause); });
| |______^ help: to force the closure to take ownership of `pause` (and any other referenced variables), use the `move` keyword | 8 |
let handle2 = thread::spawn(move || {
|
^^^^^^^
error: aborting due to 2 previous errors For more information about this error, try `rustc --explain E0373`. error: Could not compile `ch10-sharedpause-broken`. To learn more, run the command again with --verbose.
Исправить ситуацию можно, следуя указаниям, изложенным в разделе 10.2.1, в ко торых предписывалось добавление ключевого слова mov e в код создания замыка ния. Это слово, переключающее замыкание на использование mоvе-семантики, до бавлено в код следующего листинга. А это, в свою очередь, зависит от реализации типажа сору. Листинг 10.13. Использование переменной, определенной в родительской области, сразу в нескольких экземплярах 1 use std::{thread,time}; 2 3 fn main() {
Процессы, потоки и контейнеры 4
let pause = time::Duration::from_millis(20);
5
let handle1 = thread::spawn(move || {
6
433
thread::sleep(pause);
7
});
8
let handle2 = thread::spawn(move || {
9
thread::sleep(pause);
10
});
11 12
handle1.join();
13
handle2.join();
14 }
Разобраться в том, почему это работает, будет весьма интересно. Чтобы узнать все тонкости этого приема, обязательно прочтите следующий раздел.
10.3. Отличие замыканий от функций Замыкания ( 1 1 {)) отличаются от функций (fn). Поэтому они не взаимозаменяемы, что может усложнить обучение языку. Замыкания и функции имеют разные внутренние представления. Замыкания, по сути, являются безымянными структурами, реализующими типаж std: : ops: : FnOnce, а также, возможно, типажи std: : ops: : Fn и std: : ops: : FnMut. В исходном коде эти структуры не видны, но в них содержатся любые переменные из окружения замы кания, использующиеся внутри него. А вот функции реализованы как указатели на функции, указывающие на код, а не на данные. Код в этом смысле, по сути, является памятью компьютера, помеченной как исполняемый фрагмент. Ситуация усугубляется еще и тем, что замыкания, не содержащие никаких переменных из своего окружения, также являются указателя ми на функции. Принуждение компилятора к раскрытию типа замыкания В исходном коде конкретный тип Rust-замыкания увидеть невозможно. Он созда ется компилятором. Чтобы получить этот тип, нужно вызвать ошибку компилятора, воспользовавшись примерно следующим кодом: 1 fn main() { 2
let a = 20;
3 4
let add_to_a = |b| { a + b };
5
add_to_a == ();
(1)
(2)
6 }
(1) Замыкания - это значения, и их можно присваивать переменной. (2) Экспресс-метод проверки типа значения, при котором предпринимается попытка вьmолнения с ним недопустимой операции. Компилятор тут же реагирует выдачей сообщения об ошибке.
434
Глава 10
Помимо всех других компилятор при попытке скомпилировать фрагмент как / t mp/a-plus- b. rs выдает и эту ошибку: $ rustc /tmp/a-plus-b.rs error[E0369]: binary operation `==` cannot be applied to type `[closure@/tmp/a-plus-b.rs:4:20: 4:33]` --> /tmp/a-plus-b.rs:6:14 | 6 |
add_to_a == ();
|
-------- ^^ -- ()
|
|
|
[closure@/tmp/a-plus-b.rs:4:20: 4:33]
error: aborting due to previous error For more information about this error, try `rustc --explain E0369`.
10.4. Аватары, процедурно генерируемые из многопоточного парсера и генератора кода В этом разделе создается приложение с применением синтаксиса, рассмотренного в разделе 10.2. Предположим, что нужно, чтобы у пользователей нашего приложения по умолчанию были уникальные графические аватары. В качестве одного из под ходов для этого можно взять имена, под которыми они регистрировались, и полу чить дайджест от хэш-функции, а затем воспользоваться этими данными в качестве входных параметров для некоторой процедурной логики создания. Используя дан ный подход, можно получить для всех визуально похожие, но совершенно разные исходные аватары. В нашем приложении будут создаваться параллельные линии. Делаться это будет путем использования символов, задействованных в обозначении шестнадцатерич ных цифр, в качестве кодов операций для LОGО-подобного языка.
10.4.1. Как запустить проект render-hex, и как выrлядит его предполагаемый вывод В этом разделе будут созданы три варианта. Как показано в следующем листинге, все они будут вызываться одинаково. В этом же листинге показан результат вызова нашего проекта render-hex (см. листинг 10.18): $ git clone https:/ /github.com/rust-in-action/code rust-in-action ... $ cd rust-in-action/ch10/ch10-render-hex $ cargo run -- $( >
echo 'Rust in Action' |
(1)
>
sha1sum |
(1)
>
cut -f1 -d' '
(1)
Процессы, потоки и контейнеры
435
> ) (2)
$ ls 5deaed72594aaa10edda990c5a5eed868ba8915e.svg
Cargo.toml
Cargo.lock
src
target
$ cat 5deaed72594aaa10edda990c5a5eed868ba8915e.svg
(1) Создание входных данных из алфавита шестнадцатеричных чисел (то есть из 0-9 и A-F). (2) В проекте создается имя файла, соответствующее входным данным. (3) Проверка вывода.
Уникальное изображение генерируется любой допустимой последовательностью байтов, представленной в шестнадцатеричном формате. Файл, созданный с помо щью echo "Rust in Action" 1 sha256sum визуализируется, как показано на рисунке 10.4. Для отображения SVG-файлов их нужно открыть в веб-браузере или в программе для работы с векторными изображениями, например в Inkscape (https://inkscape.org/).
10.4.2. Обзор однопоточной версии render-hex Проект render-hex конвертирует свои входные данные в файл SVG. Формат SVG-файла предназначен для краткого описания графических примитивов с помо щью математических операций. Этот файл можно просмотреть в любом веб браузере и во многих графических пакетах. На данном этапе с многопоточностью в программе мало что связано, поэтому многие детали будут опущены. У программы имеется простой конвейер, состоящий из четырех шагов: 1. Получение входных данных от STDIN. 2. Анализ введенных данных на присутствие операций, описывающих движение пера по листу бумаги. 3. Преобразование операций перемещения в SVG-эквивалент. 4. Создание SVG-файла.
436
Глава 10
Рис. 10.4. SНА256-дайджест строки «Rust in Action» в виде диаграммы
А почему нельзя создавать данные пути к файлу из входных данных напрямую? Разделение этого процесса на два этапа позволяет выполнить больше преобразова ний. Созданный конвейер управляется непосредственно в main (). Функция main о для render-hex показана в следующем листинге (см. листинг 10.18). В ее коде выполняется анализ аргументов командной строки и происходит управ ление конвейером по созданию SVG. Исходный код находится в файле ch10/ch10render-hex/src/main.rs.
Листинг 10.14. Функция main() приложения render-he 166 fn main() {
(1) (1)
167
let args = env::args().collect::();
168
let input = args.get(1).unwrap();
169
let default = format!("{}.svg", input);
(1)
170
let save_to = args.get(2).unwrap_or(&default);
(1)
172
let operations = parse(input);
(2)
173
let path_data = convert(&operations);
(2)
174
let document = generate_svg(path_data);
(2)
175
svg::save(save_to, &document).unwrap();
(2)
171
176 }
(1) Анализ аргументов командной строки. (2) Конвейер создания SVG.
Процессы, потоки и контейнеры
437
Анализ входных данных Задача, поставленная в этом разделе, - преобразование шестнадцатеричных цифр в инструкции для виртуального пера, перемещающегося по холсту. Соответствую щие инструкции представлены в перечислении Operation, показанном в следую щем фрагменте кода. ПРИМЕЧАНИЕ Дабы избежать противоречий с терминологией, используемой для прорисовки пути в спецификации SVG, вместо термина «инструкция» используется термин «операция»
(Ope ration).
21 #[derive(Debug, Clone, Copy)] 22 enum Operation { 23
Forward(isize),
24
TurnLeft,
25
TurnRight,
26
Home,
27
Noop(usize),
28 }
Чтобы проанализировать этот код, каждый байт нужно рассматривать как незави симую инструкцию. Цифры преобразуются в расстояния, а буквы меняют ориента цию рисунка: 123 fn parse(input: &str) -> Vec { 124
let mut steps = Vec::::new();
125
for byte in input.bytes() {
126
let step = match byte {
127
b'0' => Home,
128
b'1'..=b'9' => {
129
let distance = (byte - 0x30) as isize;
130
Forward(distance * (HEIGHT/10))
131
},
132
b'a' | b'b' | b'c' => TurnLeft,
133
b'd' | b'e' | b'f' => TurnRight,
134
_ => Noop(byte),
135 136
}
(1) (2) (2) (3)
};
137
steps.push(step);
138
}
139
steps
140 }
(1) В ASCII цифры начинаются с Ох30 (48 в десятичном исчислении). Выражение byte Ох30 преобразует значение u8 из Ь'2'в 2. Выполнение этой операции для всего диапазона u8 может обернуться паникой, но здесь мы в безопасности благодаря гарантии, предоставляемой нашим сопоставлением с образцом.
438
Глава 10
(2) Существует множество возможностей добавления дополнительных инструкций для создания более сложных диаграмм без усложнения синтаксического анализа. (3) Хотя появление недопустимых символов не ожидается, они все же могут быть во входном потоке. Операция Noop позволяет отделить синтаксический анализ от производства вывода.
Интерпретация инструкций
Структура Art i s t обслуживает состояние диаграммы. Концептуально Artis t (ху дожник) удерживает перо в координатах х и у и перемещает его в направлении heading:
49 #[derive(Debug)] 50 struct Artist { 51
x: isize,
52
y: isize,
53
heading: Orientation,
54 }
Для перемещения в Artist реализуется несколько методов проекта render-hex, два из которых выделены в следующий листинг. Имеющиеся в Rust выражения соот ветствия используются для краткой ссылки на внутреннее состояние и его измене ния. Исходный код этого листинга находится в файле ch10-render-hex/src/main.rs. Листинг 10.15. Движение пера в структуре Artist 70
fn forward(&mut self, distance: isize) {
71
match self.heading {
72
North => self.y += distance,
73
South => self.y -= distance,
74
West
=> self.x += distance,
75
East
=> self.x -= distance,
76 77
} }
78 79
fn turn_right(&mut self) {
80
self.heading = match self.heading {
81
North => East,
82
South => West,
83
West
=> North,
84
East
=> South,
85 86
} }
Функция con vert() из листинга 10.16, являющегося частью проекта render-hex (см. листинг 10.18), использует структуру Artist. Ее роль заключается в преобра зовании Vec из parse () в Vec. Чуть позже ее вывод исполь-
Процессы, потоки и контейнеры
439
зуется для создания SVG. В качестве дани уважения к языку LOGO Artist получа ет имя локальной переменной turtle (черепаха). Исходный код листинга находит ся в файле chl 0-render-hex/src/main.rs. Листинг 10.16. Фиксация внимания на функции convert() 131 fn convert(operations: &Vec) -> Vec { 132
let mut turtle = Artist::new();
133
let mut path_data: Vec = vec![];
134
let start_at_home = Command::Move(
135
Position::Absolute, (HOME_X, HOME_Y).into()
136
);
137
path_data.push(start_at_home);
(1)
138 139
for op in operations {
140
match *op {
(2)
141
Forward(distance) => turtle.forward(distance),
142
TurnLeft => turtle.turn_left(),
143
TurnRight => turtle.turn_right(),
(2)
144
Home => turtle.home(),
(2)
145
Noop(byte) => {
146
(2)
eprintln!("warning: illegal byte encountered: {:?}", byte)
147
},
148
};
149
let line = Command::Line(
(3)
150
Position::Absolute,
(3)
151
(turtle.x, turtle.y).into()
(3)
152
);
153
path_data.push(line);
(3)
154 155
turtle.wrap();
156
}
157
path_data
(4)
158 }
(1) (2) (3) (4)
Дпя начала черепаха turtle помещается в центр области рисования. Команда сразу не создается. Вместо этого изменяется внутреннее состояние turtle. Создание Command::Line (прямой линии к текущему положению черепахи turtle). Если черепаха turtle находится эа границами холста, возвращаем ее в центр.
Создание SVG Создание SVG-файла - процесс чисто технического плана. Вся работа выполняет ся функцией generate_sv g () (строки 161-192 листинга 10.18).
440
Глава 10
Документы SVG очень похожи на НТМL-документы, хотя их теги и атрибуты от личаются. Тег - самый важный для достижения наших целей. У него есть атрибут d (ct - сокращение от данных, «data))), который описывает, как следует ри совать путь. Функция conve rt () создает Vec, отображаемый непосредст венно на данные пути.
Исходный код однопоточной версии render-hex У проекта render-hex необычная структура. Весь проект находится в довольно большом файле main.rs, управляемом Cargo. Чтобы загрузить исходный код проекта из общедоступного репозитория кода, нужно воспользоваться следующими коман дами: $ git clone https:/ /github.com/rust-in-action/code rust-in-action Cloning into 'rust-in-action'... $ cd rust-in-action/ch10/ch10-render-hex
Или же можно создать проект самостоятельно, запуская команды, показанные в следующем фрагменте кода, с последующим копированием кода из листинга 10.18 в файл src/main.rs: $ cargo new ch10-render-hex Created binary (application) `ch10-render-hex` package $ cd ch10-render-hex $ cargo install cargo-edit Updating crates.io index Downloaded cargo-edit v0.7.0 Downloaded 1 crate (57.6 KB) in 1.35s Installing cargo-edit v0.7.0 ... $ cargo add [email protected] Updating 'https:/ /github.com/rust-lang/crates.io-index' index Adding svg v0.6 to dependencies
Теперь для вас создана стандартная структура проекта, которую можно сравнить со следующей схемой: ch10-render-hex/ ├── Cargo.toml └── src └── main.rs
Метаданные для нашего проекта показаны в следующем листинге. Нужно убедить ся, что содержимое файла Cargo.toml вашего проекта в точности соответствует коду этого листинга. Его код находится в файле ch10/ch10-render-hex/Cargo.toml.
Процессы, потоки и контейнеры
441
Листинг 10.17. Метаданные для проекта render-he [package] name = "render-hex" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] svg = "0.6"
Однопоточная версия render-hex представлена в следующем листинге. Его исход ный код находится в файле ch10-render-hex/src/main.rs; Листинг 10.18. Исходный код render-he 1 use std::env; 2 3 use svg::node::element::path::{Command, Data, Position}; 4 use svg::node::element::{Path, Rectangle}; 5 use svg::Document; 6 7 use crate::Operation::{ 8
Forward,
9
Home,
10
Noop,
11
TurnLeft,
12
TurnRight
13 }; 14 use crate::Orientation::{ 15
East,
16
North,
17
South,
18
West
19 }; 20 21 const WIDTH: isize = 400;
(2)
22 const HEIGHT: isize = WIDTH;
(2)
23 24 const HOME_Y: isize = HEIGHT / 2; 25 const HOME_X: isize = WIDTH / 2;
(3 ) (3)
26 27 const STROKE_WIDTH: usize = 5;
(4)
28 29 #[derive(Debug, Clone, Copy)] 30 enum Orientation { 31
North,
(5)
(1) (1) (1) (1) (1) (1) (1) (1) (1) (1) (1) (1) (1) (1)
442
Глава 10
32
East,
33
West,
34
South,
(5) (5)
(5)
35 } 36 37 #[derive(Debug, Clone, Copy)] 38 enum Operation { 39
Forward(isize),
40
TurnLeft,
41
TurnRight,
42
Home,
43
Noop(u8),
(6) (7)
(8)
44 } 45 46 #[derive(Debug)] 47 struct Artist { 48
x: isize,
49
y: isize,
50
heading: Orientation,
(9)
51 } 52 53 impl Artist { 54
fn new() -> Artist {
55
Artist {
56
heading: North,
57
x: HOME_X,
58
y: HOME_Y,
59 60
} }
61 62
fn home(&mut self) {
63
self.x = HOME_X;
64 65
self.y = HOME_Y; }
66 67
fn forward(&mut self, distance: isize) {
68 69
North => self.y += distance,
70
South => self.y -= distance,
71
West => self.x += distance,
72
East => self.x -= distance,
73 74
(10)
match self.heading {
} }
75 76 77 78
fn turn_right(&mut self) { self.heading = match self.heading { North => East,
(10)
Процессы, потоки и контейнеры 79
South => West,
80
West => North,
81
East => South,
82 83
443
} }
84 85
fn turn_left(&mut self) {
86 87
North => West,
88
South => East,
89
West => South,
90
East => North,
91 92
(11)
self.heading = match self.heading {
} }
93 94
fn wrap(&mut self) {
95
if self.x < 0 {
96
(12)
self.x = HOME_X;
97
self.heading = West;
98
} else if self.x > WIDTH {
99
self.x = HOME_X;
100
self.heading = East;
101
}
102 103
if self.y < 0 {
104
self.y = HOME_Y;
105
self.heading = North;
106
} else if self.y > HEIGHT {
107
self.y = HOME_Y;
108
self.heading = South;
109 110
} }
111 } 112 113 fn parse(input: &str) -> Vec { 114
let mut steps = Vec::::new();
115
for byte in input.bytes() {
116
let step = match byte {
117
b'0' => Home,
118
b'1'..=b'9' => {
119
let distance = (byte - 0x30) as isize;
120
Forward(distance * (HEIGHT / 10))
121
}
122
b'a' | b'b' | b'c' => TurnLeft,
123
b'd' | b'e' | b'f' => TurnRight,
124 125
_ => Noop(byte), };
(13)
(14)
444
Глава 10
126
steps.push(step);
127
}
128
steps
129 } 130 131 fn convert(operations: &Vec) -> Vec { 132
let mut turtle = Artist::new();
133 134
let mut path_data = Vec::::with_capacity(operations.len());
135
let start_at_home = Command::Move(
136
Position::Absolute, (HOME_X, HOME_Y).into()
137
);
138
path_data.push(start_at_home);
139 140
for op in operations {
141
match *op {
142
Forward(distance) => turtle.forward(distance),
143
TurnLeft => turtle.turn_left(),
144
TurnRight => turtle.turn_right(),
145
Home => turtle.home(),
146
Noop(byte) => {
147
eprintln!("warning: illegal byte encountered: {:?}", byte);
148
},
149
};
150 151
let path_segment = Command::Line(
152
Position::Absolute, (turtle.x, turtle.y).into()
153
);
154
path_data.push(path_segment);
155 156
turtle.wrap();
157
}
158
path_data
159 } 160 161 fn generate_svg(path_data: Vec) -> Document { 162
let background = Rectangle::new()
163
.set("x", 0)
164
.set("y", 0)
165
.set("width", WIDTH)
166
.set("height", HEIGHT)
167
.set("fill", "#ffffff");
168 169
let border = background
170
.clone()
171
.set("fill-opacity", "0.0")
Процессы, потоки и контейнеры 172
.set("stroke", "#cccccc")
173
.set("stroke-width", 3 * STROKE_WIDTH);
445
174 175
let sketch = Path::new()
176
.set("fill", "none")
177
.set("stroke", "#2f2f2f")
178
.set("stroke-width", STROKE_WIDTH)
179
.set("stroke-opacity", "0.9")
180
.set("d", Data::from(path_data));
181 182
let document = Document::new()
183
.set("viewBox", (0, 0, HEIGHT, WIDTH))
184
.set("height", HEIGHT)
185
.set("width", WIDTH)
186
.set("style", "style=\"outline: 5px solid #800000;\"")
187
.add(background)
188
.add(sketch)
189
.add(border);
190 191
document
192 } 193 194 fn main() { 195
let args = env::args().collect::();
196
let input = args.get(1).unwrap();
197
let default_filename = format!("{}.svg", input);
198
let save_to = args.get(2).unwrap_or(&default_filename);
199 200
let operations = parse(input);
201
let path_data = convert(&operations);
202
let document = generate_svg(path_data);
203
svg::save(save_to, &document).unwrap();
204 }
(1) Типы перечислений Operation и Orientation будут определены позже. Включение их с ключевым словом use избавляет исходный код от сильного зашумления. (2) HEIGHT и WIDTH устанавливают границы рисунка. (3) Константы НОМЕ_У и НОМЕ_Х позволяют осуществлять легкую nереустановку начала рисунка. Здесь У - вертикальная координата, а Х - горизонтальная. (4) STROКE_WIDTH - параметр для SVG-вывода, определяющий внешний вид каждой нарисованной линии. (5) Использование описаний вместо числовых значений позволяет избежать применения математических выражений. (6) Расширение операций, доступных вашим программам для получения более сложного результата. (7) Использование isize позволяет нам расширить пример с целью реализации операции Reverse без добавления нового варианта.
446
Глава 10
(8) Использование Noop при обнаружении недопустимого ввода. Чтобы появилась запись сообщения об оll!Ибках, здесь сохраняется недопустимый байт. (9) Структура Artist сохраняет текущее состояние. (10) Функция forward() изменяет self в выражении match. Это отличается от turn_left() и turn_right(), где self изменяется вне выражения match. (11) Функция forward() изменяет self в выражении match. Это отличается от turn_left() и turn_right(), где self изменяется вне выражения match. (12) Функция wrар()гарантирует, что рисунок не выйдет за границы. (13) В ASCII цифры начинаются с Ох30 (48). Выражение byte - Ох30 преобразует значение u8 из Ь'2'в 2. Выполнение этой операции для всего диапазона u8 может обернуться паникой, но здесь мы в безопасности благодаря гарантии, предоставляемой наll!ИМ сопоставлением с образцом. (14) Хотя появление недопустимых символов не ожидается, они все же могут быть во входном потоке. Операция Noop позволяет отделить синтаксический анализ от производства вывода.
10.4.3. Порождение потока для каждой логической задачи Наш проект render-hex (листинг 10.18) также предоставляет несколько возможно стей для параллелизма. Мы сосредоточимся на одной из них, касающейся функции parse (). Начнем с того, что добавление параллелизма - двухэтапный процесс, включающий: 1. Выполнение реструктуризации кода с целью использования функционального стиля. 2. Использование контейнера rayon и его метода p a r_i ter (). Применение функционального стиля программирования Первый шаг к добавлению параллелизма- замена нашего цикла for. Взамен f o r воспользуемся набором инструментов для создания vec с конструкциями функ ционального программирования, включающими в себя методы map () и collect (), а также функции более высокого порядка, обычно создаваемые с помощью замы каний. Чтобы сравнить два стиля, рассмотрим отличия функции parse () в листинге 10.18 (в файле ch10-render-hex/src/main.rs), повторенной в следующем листинге, и ее реали зацию в более функциональном стиле в листинге 10.20 (в файле ch10 -render-hex
function/src/main .rs).
Листинг 10.19. Реализация parse() с применением императивных конструкций программирования 113 fn parse(input: &str) -> Vec { 114
let mut steps = Vec::::new();
115
for byte in input.bytes() {
116
let step = match byte {
117
b'0' => Home,
118
b'1'..=b'9' => {
Процессы, потоки и контейнеры 119
447
let distance = (byte - 0x30) as isize;
120
Forward(distance * (HEIGHT / 10))
121
}
122
b'a' | b'b' | b'c' => TurnLeft,
123
b'd' | b'e' | b'f' => TurnRight,
124
_ => Noop(byte),
125
};
126
steps.push(step);
127
}
128
steps
129 }
Листинг 10.19. Реализация parse() с применением конструкций функционального программирования 99 fn parse(input: &str) -> Vec { 100 101
input.bytes().map(|byte|{ match byte {
102
b'0' => Home,
103
b'1'..=b'9' => {
104
let distance = (byte - 0x30) as isize;
105
Forward(distance * (HEIGHT/10))
106
},
107
b'a' | b'b' | b'c' => TurnLeft,
108
b'd' | b'e' | b'f' => TurnRight,
109 110
_ => Noop(byte), }}).collect()
111 }
Листинг 10.20 короче, декларативнее и ближе к идиоматическому Rust. На поверх ностном уровне основное изменение состоит в том, что больше не нужны этапы создания временной переменной. Необходимость в этом устраняется за счет парт нерства map () и collect() : map () применяет функцию к каждому элементу итера тора, а collect() сохраняет выходные данные итератора в vec. Но в этой реструктуризации есть и более фундаментальное изменение, чем устра нение временных переменных, предоставляющее компилятору Rust больше воз можностей для оптимизации выполнения вашего кода. Итераторы в Rust - весьма эффективная абстракция. Работа с их методами напря мую позволяет компилятору Rust создавать оптимальный код, занимающий мини мум памяти. Например, метод map () принимает замыкание и применяет его к каж дому элементу итератора. Хитрость Rust в том, что функцией map () возвращается итератор. Это позволяет связать множество преобразований вместе. Примечатель но, что, несмотря на возможность появления map () сразу в нескольких местах ва шего исходного кода, Rust зачастую оптимизирует эти вызовы функций в скомпи лированном двоичном файле.
448
Глава 10
Когда указан каждый шаг, который должен предприниматься программой, напри мер когда в коде используются циклы for, количество мест, в которых компилятор может принимать решения, ограничено. Итераторы дают возможность делегиро вать компилятору больше работы. Способность делегирования и откроет вскоре доступ к параллелизму. Использование параллельного итератора Здесь мы собираемся схитрить и применить контейнер от Rust-сообщества под на званием rayon. Он разработан специально для добавления в ваш код параллелизма данных, заключающегося в применении одной и той же функции (или замыкания!) к разным данным (таким как vec).
ЕсJШ основной проект render-hex вы уже проработаJШ, добавьте контейнер rayon в за висимости, связанные с cargo, запустив на вьmолнение команду cargo a d d r ayon@l: $ cargo add rayon@1 Updating 'https://github.com/rust-lang/crates.io-index' index Adding rayon v1 to dependencies
(1) Если команда cargo add недоступна, запустите на вьmолнение команду cargo install cargo-edit.
Убедитесь, что код раздела [ dependencies J в файле Cargo.toml вашего проекта соот ветствует коду следующего листинга, исходный код которого находится в файле ch10-render-hex-parallel-iterator/Cargo. toml.
Листинг 10.21. Добавление ra on в зависимости, указанные в Cargo.toml 7 [dependencies] 8 svg = "0.6.0" 9 rayon = "1"
В заголовок файла main.rs добавьте r ayon и его prelude (см. листинг 10.23), пере мещая тем самым в область применения контейнера сразу несколько типажей. То гда в вашем распоряжении появится метод p a r_bytes (), применяемый для строко вых слайсов, и метод par_i ter (), применяемый для байтовых слайсов. Эти методы допускают совместную обработку данных сразу несколькими потоками. Исходный код листинга находится в файле ch10-render-hex-parallel-iterator/main.rs. Листинг 10.22. Добавление контейнера ra on в наш проект render-he 3 use rayon::prelude::*; 100 fn parse(input: &str) -> Vec { 101
input
102
.as_bytes()
103
.par_iter()
(1) (2)
Процессы, потоки и контейнеры 104
449
.map(|byte| match byte {
105
b'0' => Home,
106
b'1'..=b'9' => {
107
let distance = (byte - 0x30) as isize;
108
Forward(distance * (HEIGHT / 10))
109
}
110
b'a' | b'b' | b'c' => TurnLeft,
111
b'd' | b'e' | b'f' => TurnRight,
112
_ => Noop(*byte),
113
})
114
.collect()
(3)
115 }
(1) Преобразование слайса входной строки в байтовый слайс. (2) Преобразование байтового слайса в параллельный итератор. (3) Переменная byte относится к типу &uB, а варианту Operation ::Noop(uB) требуется разыменованное значение.
Использование par_i ter () в rayon - это своеобразный «чит-режим», доступный всем Rust-программистам благодаря мощному свойству std::iter::Iterator. Входящая в rayon функция par_iter () гарантирует абсолютное исключение со стояния гонки. Но что делать, если нет итератора?
10.4.4. Использование пула потоков и очереди задач Порой у нас просто нет хорошего итератора, к которому нужно было бы применить функцию. Тогда приходится вспоминать еще об одном шаблоне - очереди задач. Он позволяет порождать задачи в произвольном месте и отделять код обработки задачи от кода их создания. В этих условиях группа рабочих потоков может выби рать себе задачи сразу же по завершении выполнения своих текущих задач. Существует множество подходов к моделированию очереди задач. Можно создать vec и Vec и организовать совместное использование потоками ссы лок на задачи и результаты. А чтобы потоки не переписывали данные друг друга, нужна стратегия защиты данных. Самым распространенным инструментом защиты данных, совместно используемых потоками, является Arc. Если проводить полный разбор этого выраже ния, то это ваше значение т (например, в данном случае, Vec или vec), защищенное мьютексом std: :sync: :Mutex, который, в свою оче редь, заключен в std::sync: :Arc. Мьютекс (мutex)- это взаимоисключающая блокировка. В данном контексте понятие взаимного исключения означает, что осо бых прав нет ни у кого. Блокировка, удерживаемая любым потоком, предотвращает доступ к данным со стороны всех других потоков. Как ни странно, но от других потоков должен быть защищен и сам Mutex. Поэтому мы обращаемся за дополни тельной поддержкой к Arc, обеспечивающей безопасный многопоточный доступ к
Mutex.
450
Глава 10
Объединения Mutex и Arc в один тип не состоялось, дабы не лишать программистов возможности проявления дополнительной гибкости. Рассмотрим структуру с не сколькими полями. Mutex может понадобиться только для одного поля, но в Arc можно заключить всю структуру. Такой подход обеспечивает более быстрый дос туп к чтению полей, не защищенных Mutex. А отдельно взятый Mutex сохраняет максимальную защиту того поля, к которому имеется доступ по чтению и записи. Подход с применением блокировки хотя и возможен, но слишком громоздок. Более простая альтернатива - применение каналов. У каналов две конечных точки: отправляющая и принимающая. У программистов нет доступа к тому, что происходит внутри канала. Но размещение данных на от правляющей стороне означает, что они когда-либо в будущем появятся на прини мающей стороне. Каналы можно использовать в качестве очереди задач, поскольку они допускают отправку сразу нескольких элементов, даже если получатель не го тов принимать какие-либо сообщения. Природа каналов весьма абстрактна. Они скрывают свою внутреннюю структуру, предпочитая делегировать доступ двум вспомогательным объектам. Один способен отправлять, пользуясь функцией send (), а другой - получать, пользуясь функцией recv (). Важно то, что у нас нет доступа к тому, как каналы передают любую ин формацию, пропускаемую через канал.
ПРИМЕЧАНИЕ По соглашению, действующему в среде операторов радиосвязи и телеграфа, отпра витель (Sender) называется tx (сокращение от передачи - transmission), а получатель (Receiver) - гх.
Односторонняя связь В этом разделе используется реализация каналов не из модуля std:: sync::mpsc стандартной библиотеки Rust, а из контейнера crossbeam. Оба АРI-интерфейса пре доставляют один и тот же API, но crossbeam обеспечивает более богатую функцио нальность и гибкость. Давайте немного отвлечемся и выясним порядок использова ния каналов. Если вы отдаете предпочтение их использованию в качестве очереди задач, то чтение этого раздела можно пропустить. Реализация каналов обеспечивается стандартной библиотекой, но мы воспользуем ся контейнером crossbeam от стороннего производителя, поскольку он дает немного больше возможностей. Например, он включает как огра1-1иче1-11-1ые, так и 1-1еогра1-1и че1-11-1ые очереди. Ограниченная очередь оказывает противодействие в условиях кон куренции, предотвращая перегрузку потребителя. Ограниченные очереди (состоя щие из типов фиксированной ширины) имеют предопределенное максимальное ис пользование памяти. Но у них есть одна негативная особенность. Они заставляют производителей очереди ждать, пока не освободится достаточное место. Это может выразиться в непригодности ограниченных очередей для не терпящих ожидания асинхронных сообщений. Весьма простым примером может послужить проект channels-intro (см. листинги 10.23 и 10.24). Рассмотрим сеанс работы с консолью, демонстрирующий запуск
Процессы, потоки и контейнеры
451
проекта channels-intro из его общедоступного репозитория исходного кода и пре доставляющий вполне ожидаемый результат: $ git clone https://github.com/rust-in-action/code rust-in-action Cloning into 'rust-in-action'... $ cd ch10/ch10-channels-intro $ cargo run ... Compiling ch10-channels-intro v0.1.0 (/ch10/ch10-channels-intro) Finished dev [unoptimized + debuginfo] target(s) in 0.34s Running `target/debug/ch10-channels-intro` Ok(42)
Для самостоятельного создания проекта нужно выполнить следующие инструкции: 1. Ввести из командной строки эти команды: $ $ $ $
cargo new channels-intro cargo install cargo-edit cd channels-intro cargo add [email protected]
2. Убедиться, что код в файле Cargo.toml этого проекта соответствует коду листинга 10.23. 3. Заменить содержимое файла src/main.rs кодом листинга 10.24. Код проекта содержится в следующих двух листингах. В листинге 10.23 показано содержимое файла Cargo.toml. А в листинге 10.24 показан код создания канала для сообщений в формате i32, поступающих из рабочего потока. Листинг 10.23. Метаданные Cargo.toml для channels-intro [package] name = "channels-intro" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] crossbeam = "0.7"
Листинг 10.24. Создание канала для приема сообщений формата i32 1 #[macro_use] 2 extern crate crossbeam; 3 4 use std::thread; 5 use crossbeam::channel::unbounded; 6 7
(1)
452
Глава 10
8 fn main() { 9
let (tx, rx) = unbounded();
10 11
thread::spawn(move || {
12
tx.send(42)
13
.unwrap();
14
});
15 16
select!{
17
recv(rx) -> msg => println!("{:?}", msg),
18
(2) (3)
}
19 }
(1) Предоставление макроса sel ect!, упрощающего получение сообщений. (2) Предоставление макроса select!, упрощающего получение сообщений. (3) recv (rx) - синтаксис, определяемый макросом.
По проекту channels-intro нужны следующие пояснения: • Создание канала с помощью crossbeam включает вызов функции, возвра щающей Sender и Recei ver. В коде листинга 10.24 компилятор опре деляет параметр типа. tx присваивается тип Send er, а r x - тип Re cei ver.
• Макрос select ! получил свое название от других систем обмена сообщения ми, таких как АР! сокетов POSJX. Он позволяет основному потоку заблоки роваться и ждать сообщения. • В макросах могут определяться свои собственные правила синтаксиса. Именно поэтому в макросе selec t! используется синтаксис (recv(rx)->), не относящийся к допустимому синтаксису Rust.
Что можно отправить по каналу? Канал можно мысленно представить в виде сетевого протокола. Но по сети доступ на передача только данных типа [uB J. Этот поток байтов, прежде чем его содержи мое можно будет интерпретировать, нуждается в анализе и проверке. Каналы сложнее простой потоковой передачи байтов ( [uBJ ). Поток байтов непро зрачен и требует для извлечения структуры синтаксического анализа. Каналами предлагается вся мощь системы типов языка Rust. Для сообщений рекомендуется использовать перечисление, поскольку оно предлагает полное тестирование на на дежность и имеет компактное внутреннее представление.
Двунаправленная связь Двунаправленную (дуплексную) связь моделировать с одним каналом неудобно. Проще обратиться к созданию двух наборов отправителей и получателей, по одно му для каждого направления.
Процессы, потоки и контейнеры
453
Примером этой двухканальной стратегии может послужить проект channels complex. Код его реализации показан в листингах 10.25 и 10.26, которые, соответ ственно, находятся в файлах ch10/ch10-channels-complex/Cargo.toml и ch10/ch10-channels complex/src/main.rs.
При выполнении программы channels-complex на консоль выводятся три строки. Сеанс, демонстрирующий запуск проекта из общедоступного репозитория исход ного кода, выглядит следующим образом: $ git clone https://github.com/rust-in-action/code rust-in-action Cloning into 'rust-in-action'... $ cd ch10/ch10-channels-complex $ cargo run ... Compiling ch10-channels-intro v0.1.0 (/ch10/ch10-channels-complex) Finished dev [unoptimized + debuginfo] target(s) in 0.34s Running `target/debug/ch10-channels-complex` Ok(Pong) Ok(Pong) Ok(Pong)
Кому-то из читателей нравится, наверное, все делать самостоятельно. Если это про вас, то выполняйте следующие инструкции: 1. Введите в командной строке эти команды: $ $ $ $
cargo new channels-intro cargo install cargo-edit cd channels-intro cargo add [email protected]
2. Убедитесь, что код в файле Cargo.toml соответствует коду листинга 10.25. 3. Замените содержимое файла src/main.rs кодом листинга 10.26. Листинг 10.25. Метаданные проекта channels-comple [package] name = "channels-complex" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] crossbeam = "0.7"
Листинг 10.26. Отправка сообщений в созданный поток и из него 1 #[macro_use] 2 extern crate crossbeam; 3
454
Глава 10
4 use crossbeam::channel::unbounded; 5 use std::thread; 6 7 use crate::ConnectivityCheck::*; 8 9 #[derive(Debug)] 10 enum ConnectivityCheck { 11
Ping,
12
Pong,
13
Pang,
(1) (1) (1) (1) (1)
14 } 15 16 fn main() { 17
let n_messages = 3;
18
let (requests_tx, requests_rx) = unbounded();
19
let (responses_tx, responses_rx) = unbounded();
20 21
thread::spawn(move || loop {
22
match requests_rx.recv().unwrap() {
23
Pong => eprintln!("unexpected pong response"),
24
Ping => responses_tx.send(Pong).unwrap(),
25
Pang => return,
26
}
27
});
(2)
(3)
28 29
for _ in 0..n_messages {
30
requests_tx.send(Ping).unwrap();
31
}
32
requests_tx.send(Pang).unwrap();
33 34
for _ in 0..n_messages {
35
select! {
36
recv(responses_rx) -> msg => println!("{:?}", msg),
37 38
} }
39 }
(1) Определение заранее обусловленного типа сообщения упрощает его последующую интер претацию. (2) Поскольку весь поток управления представлен выражением, Rust допускает здесь при менение ключевого слова loop. (3) Сообщение Pang указывает, что поток должен быть закрыт.
Реализация очереди задач Обсудив каналы, пора применить их к решению задачи, впервые представленной в листинге 10.18. Стоит заметить, что код листинга 10.28, который вскоре последует,
Процессы, потоки и контейнеры
455
немного сложнее, чем код подхода с параллельным итератором, представленный в листинге 10.24. В следующем листинге показаны метаданные для реализации на основе канала очереди задач, предназначенной для render-hex. Соответствующий исходный код находится в файле ch 10/ch 10-render-hex-threadpool/Cargo. toml. Листинг 10.27. Метаданные очереди задач на основе канала для render-he [package] name = "render-hex" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] svg = "0.6" crossbeam = "0.7" #
(1)
(1) Контейнер crossbeam - новая зависимость проекта.
В следующем листинге основное внимание уделяется функции p arse (). Остальной код такой же, как и в листинге 10.18. Исходный код находится в файле ch10/ch10-
render-hex-threadpool/src/main.rs.
Листинг 10.28. Фрагменты кода очереди задач на основе канала для render-he 1 use std::thread; 2 use std::env; 3 4 use crossbeam::channel::{unbounded}; (1)
99 enum Work { 100
Task((usize, u8)),
(2)
101
Finished,
(3)
102 } 103 104 fn parse_byte(byte: u8) -> Operation { 105
match byte {
106
b'0' => Home,
107
b'1'..=b'9' => {
108
let distance = (byte - 0x30) as isize;
109
Forward(distance * (HEIGHT/10))
110
},
111
b'a' | b'b' | b'c' => TurnLeft,
112
b'd' | b'e' | b'f' => TurnRight,
113 114
_ => Noop(byte), }
(4)
456
Глава 10
115 } 116 117 fn parse(input: &str) -> Vec { 118
let n_threads = 2;
119
let (todo_tx, todo_rx) = unbounded();
(5)
120
let (results_tx, results_rx) = unbounded();
(6)
121
let mut n_bytes = 0;
122
for (i,byte) in input.bytes().enumerate() {
123
todo_tx.send(Work::Task((i,byte))).unwrap();
124
n_bytes += 1;
125
(7)
(8)
}
126 127
for _ in 0..n_threads {
128 129
todo_tx.send(Work::Finished).unwrap();
(9) (9) (9)
}
130 131
for _ in 0..n_threads {
132
let todo = todo_rx.clone();
133
let results = results_tx.clone();
134
thread::spawn(move || {
135
(10) (10)
loop {
136
let task = todo.recv();
137
let result = match task {
138
Err(_) => break,
139
Ok(Work::Finished) => break,
140
Ok(Work::Task((i, byte))) => (i, parse_byte(byte)),
141
};
142
results.send(result).unwrap();
143 144
}
145
});
146
}
147
let mut ops = vec![Noop(0); n_bytes];
148
for _ in 0..n_bytes {
149
(11)
let (i, op) = results_rx.recv().unwrap();
150
ops[i] = op;
151
}
152
ops
153 }
(1) Создание типа для сообщений, отправляемых по каналам. (2) В поле usize этого кортежа указывается позиция обработанного байта. Необходимость обусловливается возможностью возвращения без соблюдения прежнего порядка. (3) Предоставление рабочим потокам сообщения-маркера, указывающего на то, что пора завершить работу. (4) Упрощение логики за счет извлечения той функции, которую должны выполнять работники.
Процессы, потоки и контейнеры
(5) (6) (7) (8) (9) (10) (11)
457
Создание одного канала дпя вьmолнения задач. Создание одного канала дпя возврата декодированных инструкций. Заполнение очереди задач работой. Отслеживание количества невьmолненных задач. Отправка каждому потоку сигнала о том, что пора заверuмть работу. При клонировании каналы могут бьгrь разделены между потоками. Поскольку результаты могут бьгrь возвращены в произвольном порядке, здесь проводится инициализация вектора Vec, который перезаписывают наим входящие результаты. Использование вектора, а не массива, обусловлено тем, что именно он используется сигнатурой типа, и нам не хочется из-за этой новой реализации переделывать всю программу.
Когда вводятся независимые потоки, порядок выполнения задач становится нео пределенным. Чтобы справиться с этой проблемой, код листинга 10.28 немного усложнен. Ранее для команд, интерпретируемых из входных данных, был создан пустой век тор vec. После синтаксического анализа функция main () неоднократно добавляла элементы с помощью принадлежащего вектору метода push (). Теперь в строке 147 происходит полная инициализация вектора. Его содержимое не имеет значения. Все будет перезаписано. И тем не менее, чтобы гарантировать, что ошиб ка не приведет к повреждению файла SVG, я решил воспользоваться типажом com mand: : Noop.
10.5. Конкурентные вычисления и виртуализация задач В этом разделе объясняется разница между моделями конкурентных вычислений. На рис. 10.5 показан ряд компромиссных решений. «Зеленый» поток
Поток
Процесс
Контейнер
Виртуальная машина
Изоляция Затраты на переключение Необходимые ресурсы Конкурентность
Рис. 10.5. Компромиссы, связанные с различными формами изоляции задач в вычислениях. Как правило, увеличение уровня изоляции приводит к росту накладных расходов.
Основное преимущество более затратных форм виртуализации задач - изоляция. А что подразумевается под этим понятием?
458
Глава 10
Изолированные задачи не могут помешать друг другу. Вмешательство бывает раз ных форм. В качестве примеров можно привести повреждение памяти, насыщение сети и затор при сохранении данных на диске. Если поток заблокирован в ожида нии консольного вывода на экран, ни одна из сопрограмм, действующих в этом по токе, не сможет работать. Изолированные задачи не могут получать доступ к данным друг друга без разреше ния. Независимые потоки в одном процессе совместно используют адресное про странство памяти, и все потоки имеют равный доступ к данным в этом пространст ве. А вот процессам запрещено проверять память друг друга. Изолированные задачи не могут вызвать сбой другой задачи. Сбой в одной задаче не должен распространяться на другие системы. Если процесс вызывает панику ядра, завершаются все процессы. При проведении работы на виртуальных машинах задачи могут выполняться, даже если другие задачи работают нестабильно. Изоляция носит постоянный характер. Полная изоляция нецелесообразна. Она означает невозможность ввода-вывода. Более того, изоляция часто реализуется про граммным путем. Запуск дополнительного программного обеспечения подразуме вает дополнительные издержки времени выполнения.
Небольшой словарь терминов, относящихся к конкурентным вычислениям В этой области используется множество специальных понятий. Рассмотрим краткое введение в некоторые важные термины и способы их использования: • Программа. Программа или приложение - это наименование изделия. Это имя, используемое для обозначения программного пакета. Когда программа запускается на выполнение, операционная система создает процесс. • Исполняемый. Имеется в виду файл, который можно загрузить в память, а за тем запустить на выполнение. Запуск исполняемого файла означает создание процесса и потока для него, а затем изменение указателя инструкции цен трального процессора для его нацеливания на первую инструкцию исполняе мого файла. • Задача. В этой главе термин «задача» используется в абстрактном смысле. Его значение меняется по мере изменения уровня абстракции: О Когда речь идет о процессах, задача - один из потоков процесса. О Когда говорится о потоке, задача может быть вызовом функции. О Когда речь заходит об операционной системе, под задачей может пони маться запущенная программа, которая может состоять из нескольких процессов. • Процесс. Запущенные программы выполняются в виде процессов. У процесса есть свое собственное виртуальное адресное пространство, как минимум один поток и множество регистраторов событий, управляемых операционной системой. Для каждого процесса управляются дескрипторы файлов, перемен-
Процессы, потоки и контейнеры
459
ные среды окружения и приоритеты диспетчеризации. У процесса есть вир туальное адресное пространство, исполняемый код, открытые дескрипторы системных объектов, контекст безопасности, уникальный идентификатор процесса, переменные среды, класс приоритета, минимальный и максималь ный размер рабочего набора и по крайней мере один поток выполнения. • Каждый процесс запускается с одним потоком, часто называемым основным, но может создавать дополнительные из любого своего потока. Выполняемые программы начинают свою жизнь как единый процесс, но нередко для вы полнения заданной работы порождаются подпроцессы. • Поток. Образное выражение используется в качестве подсказки, что потоки могут работать вместе как единое целое. • Поток выполнения. Совокупная последовательность инструкций центрально го процессора. Одновременно может выполняться сразу несколько потоков, но инструкции в последовательности предназначены для выполнения друг за другом. • Сопрограмма. Известная также, как зеленый поток и легковесный поток, со программа является признаком задач, переключающихся внутри потока. За переключение между задачами отвечает не операционная система, а сама программа. Важно различать две теоретические концепции: ◊ Конкурентность, являющуюся одновременным выполнением нескольких задач любого уровня абстракции. ◊ Параллелизм, являющийся одновременным выполнением нескольких потоков на нескольких процессорах. Помимо основной терминологии часто встречаются также взаимосвязанные терми ны: асинхронное программирование и неблокирующий ввод-вывод. Неблокирующие средства ввода-вывода, помещающие данные из нескольких сокетов в очередь и периодически подвергающие их групповому опросу, предоставляются многими операционными системами. Для рассматриваемых терминов используются сле дующие определения: • Неблокирующий ввод-вывод. Обычно поток, запрашивающий данные у уст ройств ввода-вывода, например у сети, выводится из-под диспетчеризации. Поток помечается заблокированным на время ожидания поступления данных. • При программировании с использованием неблокирующего ввода-вывода выполнение потока может продолжаться даже при ожидании данных. Но возникает противоречие. Как поток может продолжать выполнение, если у него нет входных данных для обработки? Ответ кроется в асинхронном про граммировании. • Асинхронное программирование. Под ним подразумевается программирова ние для тех случаев, когда поток управления заранее не определен. Вместо него на последовательность выполнения кода влияют события, не зависящие от самой программы. Эти события обычно связаны с вводом-выводом, на-
460
Глава 10
пример с сигналом драйвера устройства о его готовности, или связаны с функциями, возвращение из которых происходит в другом потоке. • Обычно модель асинхронного программирования дается разработчику на много сложнее, но зато она позволяет быстрее справиться с рабочими нагруз ками при большом количестве операций ввода-вывода. Увеличение скорости обусловливается меньшим количеством системных вызовов. При этом подра зумевается и меньшее количество контекстных переключений между про странством пользователя и пространством ядра.
10.5.1. Потоки Поток является самым низким уровнем изоляции, воспринимаемым операционной системой, которая может выполнять диспетчеризацию потоков. Более мелкие фор мы конкурентных вычислений операционная система просто не видит. Под ними подразумеваются такие, возможно, уже знакомые вам термины, как сопрограммы и зеленые потоки. Переключением между задачами здесь занимается сам процесс. Факт обработки программой сразу нескольких задач операционная система просто игнорирует. А для потоков и других форм конкурентных вычислений требуются контекстные переключения.
10.5.2. Что такое контекстное переключение? Переключение между задачами на одном уровне виртуализации называется кои текстны.,1и переключением. Для переключения с одного потока на другой требуется очистка регистров центрального процессора, очистка кэш-памяти центрального процессора и сброс значений переменных в операционной системе. По мере увели чения изоляции растут и издержки переключения контекста. Центральные процессоры могут выполнять инструкции только в последовательном режиме. Чтобы выполнить несколько задач, компьютер, к примеру, должен иметь возможность щелкать на кнош rustc --print sysroot C:\> cd C:\> dir llvm*.exe /s /b
(1)
(1) следует заменить тем, что было выведено на экран предьщущей командой.
Отлично, среда настроена. Если у вас возникли проблемы, попробуйте переустано вить компоненты с нуля.
11.2. FledgeOS-0: получение хоть чего-то работоспособного Чтобы полностью разобраться в проекте FledgeOS, нужно немного терпения. Не смотря на лаконичность, код включает в себя множество, наверное, новых для вас концепций, поскольку они обычно не встречаются программистам, использующим операционную систему. Прежде чем приступить к работе с кодом, давайте посмот рим, как запускается FledgeOS.
11.2.1. Первая загрузка FledgeOS - далеко не самая мощная операционная система в мире. По правде го воря, она вообще мало на что похожа. Но, по крайней мере, это графическая среда. Как видно из рисунка 11.1, она создает бледно-голубой прямоугольник в верхнем левом углу экрана.
Ядро операционной системы
467
Чтобы запустить fledgeos-0, выполните из командной строки следующие команды: $ git clone https://github.com/rust-in-action/code rust-in-action Cloning into 'rust-in-action'... ... $ cd rust-in-action/ch11/ch11-fledgeos-0 $ cargo +nightly run (1) ... Running: qemu-system-x86_64 -drive format=raw,file=target/fledge/debug/bootimage-fledgeos.bin (1) Добавление +nightly гарантирует использвование компилятора nighltly.
Рис. 11.1. Ожидаемый результат от запуска fledgeos-0 (листинги 1 1.1-1 1.4)
Пока что не стоит беспокоиться о том, как изменить цвет прямоугольника в левом верхнем углу, поскольку подробности ретропрограммирования будут рассмотрены чуть позже. На данный момент уже хорошо, что есть возможность скомпилировать свою собственную версию Rust, ядра операционной системы с использованием это го Rust, загрузчика, который помещает ваше ядро в нужное место, и заставить все это работать вместе. Добиться этого результата - уже большое достижение. Как упоминалось ранее, создание программы, ориентированной на еще не существующее ядро операцион ной системы, - весьма непростая задача.
468
Глава 11
Для ее решения требуется пройти несколько этапов: 1. Создать доступное для машинного чтения определение соглашений, исполь зуемых операционной системой, таких как предполагаемая архитектура цен трального процессора. Это целевая платформа, также известная как цель ком пилятора или просто цель. Цели вам уже встречались. Чтобы получить список того, во что можно скомпилировать Rust-кoд, попробуйте выполнить команду rustup t arget list.
2. Чтобы создать новую цель, скомпилируйте Rust-кoд для определения цели. Нам будет достаточно подмножества Rust под названием core, которое исключает стандартную библиотеку (контейнеры под std).
3. Скомпилируйте ядро операционной системы для новой цели, используя «но вый» Rust. 4. Скомпилируйте загрузчик, который может загрузить новое ядро.
5. Запустите загрузчик в виртуальной среде, которая, в свою очередь, запускает ядро. К счастью, все это делает за нас контейнер bootimage. Благодаря такой вот полной автоматизации можно сосредоточиться на более интересных вещах.
11.2.2. Инструкции по компиляции Чтобы воспользоваться общедоступным исходным кодом, следует выполнить дей ствия, рассмотренные в разделе 11.1.3. То есть выполнить из командной строки следующие команды: $ git clone https://github.com/rust-in-action/code rust-in-action Cloning into 'rust-in-action'... ... $ cd rust-in-action/ch11/ch11-fledgeos-0
А для самостоятельного создания проекта рекомендуется выполнить следующие действия: 1. Запустить на выполнение из командной строки следующие команды: $ $ $ $ $ $
cargo new fledgeos-0 cargo install cargo-edit cd fledgeos-0 mkdir .cargo cargo add [email protected] cargo add [email protected]
2. Добавить к концу принадлежащего проекту файла Cargo.toml следующий фраг мент кода. Сравнить результат с кодом листинга 11.1, который можно загрузить из файла ch11/ch11-fledgeos-O/Cargo.toml: [package.metadata.bootimage] build-command = ["build"]
Ядро операционной системы
469
run-command = [ "qemu-system-x86_64", "-drive", "format=raw,file={}" ]
3. Создать в корневом каталоге проекта новый файл fledge.json, заполнив его со держимым листинга 11.2, которое можно загрузить из файла ch11/ch11-fledgeos0/fledge.json.
4. Создать новый файл .cargo/config.toml с содержимым листинга 11.3, код которого доступен в файле ch11/ch11-fledgeos-0/.cargo/config.toml. 5. Замените содержимое файла src/main.rs кодом листинга 11.4, который доступен в файле ch11/ch11-fledgeos-0/src/main.rs.
11.2.3. Листинги исходного кода В исходном коде проектов FledgeOS (code/ch11/ch11-fledgeos-*) используется немного другая структура, отличная от большинства саrgо-проектов. Соответствующая схе ма на типичном примере fledgeos-0 имеет следующий вид: fledgeos-0 ├── Cargo.toml
(1) (2)
├── fledge.json ├── .cargo │
└── config.toml
└── src
(4)
└── main.rs
(1) (2) (3) (4)
См. См. См. См.
листинг листинг листинг листинг
(3)
11.1 11.2 11.3 11.1
В проектах есть два дополнительных файла: • В корневом каталоге проекта имеется файл fledge.json. В нем задается цель компилятора, которая будет создаваться bootimage и его соратниками. • В файле .cargo/config.toml содержатся дополнительные параметры конфигура ции. Они сообщают cargo, что для этого проекта необходимо скомпилировать сам модуль std:: core, а не полагаться на его предустановку. В следующем листинге представлен принадлежащий проекту файл Cargo.toml, код которого доступен в файле ch11/ch11-fledgeos-0/Cargo.toml. Листинг 11.1. Метаданные для проекта fledgeos-0
[package] name = "fledgeos" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018"
470
Глава 11
[dependencies] bootloader = "0.9" x86_64 = "0.13" [package.metadata.bootimage] build-command = ["build"] (1) run-command = [ "qemu-system-x86_64", "-drive", "format=raw,file={}" ]
(1) Обновление cargo run дпя вызова сеанса QEМU. Путь к образу операционной системы, созданного во время сборки, заменяют фигурные скобки ({}).
Принадлежащий нашему проекту файл Cargo.toml имеет отчасти уникальное содер жимое. Он включает в себя новый реестр [package .met adata .bootimage], в кото ром содержатся несколько, может быть, не вполне понятных директив. В этом рее стре предоставляются инструкции для контейнера bootimage, который является за висимостью от bootloader: •
bootimage - создает
образ загрузочного диска из ядра Rust.
•
build-command - заставляет booti mage воспользоваться командой car g o build, а не командой car g o xbuild, предназначенной для кросс-компиляции.
•
r u n_command - Заменяет стандартное
поведение car g o r un на использование QEMU вместо прямого вызова исполняемого файла. СОВЕТ Дополнительная информация о настройке bootimage находится в документации по ад ресу https://github.com/rust-osdev/bootimage/.
В следующем листинге показано определение цели нашего ядра. Его код находится в файле ch11/ch11-fledgeos-0/fledge.json. Листинг 11.2. Определение ядра FledgeOS
{ "llvm-target": "x86_64-unknown-none", "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128", "arch": "x86_64", "target-endian": "little", "target-pointer-width": "64", "target-c-int-width": "32", "os": "none", "linker": "rust-lld", "linker-flavor": "ld.lld", "executables": true,
Ядро операционной системы
471
"features": "-mmx,-sse,+soft-float", "disable-redzone": true, "panic-strategy": "abort" }
Кроме всего прочего, в определении целевого ядра указывается, что это 64-разряд ная операционная система, созданная для процессоров семейства х86-64. Эту JSОN спецификацию Rust-компилятор понимает. СОВЕТ Получить дополнительную информацию о настраиваемых целях можно в разделе «Custom Targets» сайта rustc book, который находится по адресу https://doc.rust lang.org/staЫe/rustc/targets/custom.html.
Следующий листинг, доступный в файле ch11/ch11-fledgeos-0/.cargo/config.toml, пре доставляет дополнительную конфигурацию для сборки FledgeOS. Нам нужно за ставить cargo скомпилировать язык Rust для целевого компилятора, который был определен в предыдущем листинге. Листинг 11.3. Дополнительная конфигурация времени сборки для cargo
[build] target = "fledge.json" [unstable] build-std = ["core", "compiler_builtins"] build-std-features = ["compiler-builtins-mem"] [target.'cfg(target_os = "none")'] runner = "bootimage runner"
Теперь наконец-то мы готовы ознакомиться с исходным кодом ядра. Следующий листинг, доступный в файле ch11/ch11-fledgeos-0/src/main.rs, задает процесс загрузки, а затем записывает значение охзо в заранее определенный адрес памяти. О том, как это работает, станет известно в разделе 11.2.5. Листинг 11.4. Создание ядра операционной системы, раскрашивающего блок цвета
1 2 3 4 5 6 7 8 9 10
#![no_std] #![no_main] #![feature(core_intrinsics)]
(1)
use core::intrinsics; use core::panic::PanicInfo;
(2)
#[panic_handler] #[no_mangle] pub fn panic(_info: &PanicInfo) -> ! {
(1) (2)
(3)
472
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
(1) (2) (3) (4) (5) (6)
Глава 11
intrinsics::abort(); }
(4)
#[no_mangle] pub extern "C" fn _start() -> ! { let framebuffer = 0xb8000 as *mut u8; unsafe { framebuffer .offset(1) .write_volatile(0x30); }
(5) (6)
loop {} }
Подготовка программы к работе без операционной системы. Разблокирование внутренних функций компилятора LLVМ. Разрешение обработчику паники проверить, где именно возникла паника. Завершение работы программы. Увеличение адреса указателя на 1, до ОхЬ8001. Установка голубого фона.
Код листинг 11.4 сильно отличается от кода ранее встречавшихся Rust-проектов. Вот некоторые отличия от обычных программ, предназначенных для выполнения под управлением операционной системы: • Из центральных функций FledgeOS управление никогда не возвращается. Его просто некуда возвращать. Никаких других запущенных программ не суще ствует. Чтобы указать на это обстоятельство, возвращаемый тип наших функций - ЭТО ТИП «НИКОГДЮ) ( ! ). • Если программа даст сбой, зависнет весь компьютер. Единственное, на что способна наша программа при возникновении ошибки, - это завершение ра боты. Мы указываем на это, полагаясь на принадлежащую LLVM функцию abort (). Более подробно все это объясняется в разделе 11.2.4. • Стандартную библиотеку нужно отключить, воспользовавшись аннотацией ! [no_stdJ. Поскольку наше приложение в вопросах динамического распре деления памяти полагаться на операционную систему уже не может, важно избегать любого кода, предусматривающего динамическое выделение памя ти. Аннотация ! [no_std] исключает стандартную библиотеку Rust из нашего контейнера. В результате чего возникает побочный эффект, заключающийся в недоступности для программы многих типов, например vec. • Нужно разблокировать нестабильный core_intrinsics API, воспользовавшись атрибуто.м #! [core_intrinsicsJ. Часть компилятора Rust предоставляется LLVM, компилятором, созданным в рамках проекта LLVM, который предос-
Ядро операционной системы
473
тавляет Rust часть своих внутренних компонентов, известных как внутренние функции (intrinsic functions). Поскольку внутренние компоненты LLVM не подпадают под гарантии стабильности Rust, существует риск, что предлагае мое языку Rust может измениться. Стало быть, в нашей программе мы долж ны воспользоваться набором инструментов nightlу-компилятора и выбрать нестабильный API явным образом. • Нужно отключить выполнение имеющихся в Rust соглашений по именованию символов, воспользовавшись для этого атрибутшн #! [no_mangleJ. Имена символов - это строки в скомпилированном двоичном файле. Для сосуще ствования во время выполнения программы сразу нескольких библиотек важно, чтобы эти имена не совпадали. Обычно Rust избегает таких совпаде ний, создавая символы с помощью процесса, называемого изменением имени. А в нашей программе это действие нужно запретить, иначе процесс загруз ки может дать сбой. • Нужно выбрать соглашения о вызовах языка С, воспользовавшись аннотаци ей extern "С". Соглашение о вызовах операционной системы относится, по мимо прочего, к способу размещения аргументов функции в памяти. Rust не определяет свое соглашение о вызовах. Аннотируя функцию _start () с по мощью extern "С", мы заставляем Rust воспользоваться соглашениями о вы зовах языка С. Без этого процесс загрузки может завершиться ошибкой. • Нужно, чтобы прямая запись в память изменяла отображение на экране. Традиционно для настройки вывода на экран операционные системы исполь зовали упрощенную модель. Предопределенный блок памяти, известный как буфер кадра, контролировался видеооборудованием. При изменении буфера кадра, соответственно, изменялось и отображение на экране. Одним из стан дартов, который использует наш загрузчик, является VGA (массив видеогра фики). В качестве начала буфера кадра загрузчик устанавливает адрес охьвооо. Изменения в памяти буфера отображаются на экране. Подробные объяснения последуют в разделе 11.2.5. • Нужно воспользоваться атрибутом #! [no_mainJ и отключить обязатель ное наличие в программе функции main (). Фактически эта функция играет особую роль, поскольку ее аргументы предоставляются функцией, которая обычно включается компилятором (_start () ), а ее возвращаемые значения интерпретируются до выхода из программы. Поведение main () - часть сре ды выполнения Rust. Дополнительные сведения содержатся в разделе 11.2.6.
Где можно получить дополнительную информацию о разработке операционной системы Команда cargo bootimage бережет наши нервы. С ее помощью весьма сложный процесс запускается через совершенно простой интерфейс, состоящий из одной команды. Но особо дотошным, возможно, захочется узнать, что же происходит за кулисами. Тогда следует в благе Филиппа Опперманна (Philipp Oppennann) найти публикацию «Writing an OS in Rust», воспользовавшись адресом https://os.phil-
474
Глава 11
и изучить небольшую экосистему инструментов, появившихся на основе этой публикации, обратившись по адресу https://github.com/rust-osdev/.
opp.com/,
Теперь, после оживления нашего первого ядра, давайте немного разберемся в том, как оно работает. Сначала посмотрим, как можно справиться с паникой.
11.2.4. Способы справиться с паникой Rust не позволяет компилировать программу, не имеющую механизма борьбы с паникой. Обычно средства подавления паники вставляются им самим. Это входит в перечень действий среды выполнения Rust, но мы начали код с аннотации# [no_stdJ. Отказ от стандартной библиотеки полезен тем, что он значительно упрощает ком пиляцию, но при этом приходится самостоятельно справляться с паникой. Сле дующий листинг является частью листинга 11.4. Он знакомит нас с функцией обра ботки паники. Листинг 11.5. Концентрация внимания на обработке паники для FledgeOS
1 2 3 4 5 6 7 8 9 10 11 12 13 14
#![no_std] #![no_main] #![feature(core_intrinsics)] use core::intrinsics; use core::panic::PanicInfo; #[panic_handler] #[no_mangle] pub fn panic(_info: &PanicInfo) -> ! { unsafe { intrinsics::abort(); } }
Функции intrinsics:: abort () есть альтернатива. В качестве обработчика паники можно использовать бесконечный цикл, показанный в следующем листинге. Не достаток данного подхода - то, что любые ошибки в программе заставляют ядро центрального процессора работать на все сто процентов до тех пор, пока не будет выполнено ручное отключение. Листинг 11.6. Использование бесконечного цикла в качестве обработчика паники
#[panic_handler] #[no_mangle] pub fn panic(_info: &PanicInfo) -> ! { loop { } }
Ядро операционной системы
475
Структура Panicinfo предоставляет информацию о месте возникновения паники. Эта информация включает имя файла и номер строки исходного кода. Это нам при годится при реализации приемлемых средств обработки паники.
11.2.5. Вывод информации на экран с использованием VGА-совместимого текстового режима В режиме загрузки контейнер bootloader устанавливает некие магические байты с обычным ассемблерным кодом. При запуске байты интерпретируются оборудова нием, которое переключает свое отображение на текстовое поле 80х25. При этом также устанавливается буфер фиксированной памяти, который интерпретируется оборудованием для вывода информации на экран. VGА-совместимый текстовый режим за 20 секунд Обычно дисплей разбивается на сетку ячеек 80х25. Каждая ячейка представлена в памяти 2 байтами. В Rust-подобном синтаксисе эти байты включают несколько по лей, показанных в следующем фрагменте кода: struct VGACell { is_blinking: u1, background_color: u3, is_bright: u1, character_color: u3, character: u8, }
(1) (1) (1) (1) (2)
(1) Эти четыре поля занимают в памяти один байт. (2) Доступные символы взяты из кодировки 437 кодовой страницы, которая (в некотором приближении) является расширением ASCII. Текстовый режим VGA имеет 16-цветную палитру, где 3 бита составляют основные 8 цветов. Цвета переднего плана также имеют дополнительный яркий вариант, по казанный ниже: #[repr(u8)] enum Color { Black = 0, Blue = 1, Green = 2, Cyan = 3, Red = 4, Magenta = 5, Brown = 6, Gray = 7, }
White = 8, BrightBlue = 9, BrightGreen = 10, BrightCyan = 11, BrightRed = 12, BrightMagenta = 13, Yellow = 14, DarkGray = 15,
476
Глава 11
Эта инициализация во время загрузки позволяет легко отображать информацию на экране. Каждая из точек в сетке 8Oх25 сопоставлена с ячейками памяти. Эта об ласть памяти называется буфером кадра. Наш загрузчик обозначает охьвооо началом буфера кадра размером 4000 байт. Для фактической установки значения наш код использует два новых метода: offset () и write_volatile () - которые раньше нам еще не встречались. Следующий лис тинг, являющийся частью листинга 11.4, показывает порядок их использования. Листинг 11.7. Концентрация внимания на изменение буфера кадра VGA 18
let mut framebuffer = 0xb8000 as *mut u8;
19
unsafe {
20
framebuffer
21
.offset(1)
22 23
.write_volatile(0x30); }
Кратко эти два новых метода можно объяснить так: • Перемещение по адресному пространству с помощью offset (). Метод offset () , относящийся к типу указателя, выполняет перемещение по адресному пространству с шагом, совпадающим с размером указателя. Например, вызов . offset ( 1) для *mut ив(изменяемый указатель на ив) добавляет к его адресу единицу. Когда тот же вызов выполняется к *mut u32 (изменяемый указатель на uз2), адрес указателя перемещается на 4 байта. • Принудительная запись значения в память с помощью write_volatile(). Указатели предоставляют метод write_volatile (), который выполняет «не уловимую» запись. Неуловимость не позволяет оптимизатору компилятора оптимизировать инструкцию записи. Умный компилятор может просто за метить, что мы везде используем множество констант, и инициализировать программу так, чтобы память просто была установлена на желаемое нами значение. В следующем листинге показан еще один способ записи framebuffer.offset (1) .write volatile (ОхЗО). Здесь используются оператор разыменования(*) и само стоятельная установка памяти на охзо. Листинг 11.8. Самостоятельное увеличение указателя 18
let mut framebuffer = 0xb8000 as *mut u8;
19
unsafe {
20 21
*(framebuffer + 1) = 0x30; }
(1) Устанавливает в ячейке памяти ОхЬВООl значение ОхЗО
(1)
Ядро операционной системы
477
Стиль программирования, показанный в листинге 11.8, легче узнают программи сты, ранее много работавшие с указателями. Использование этого стиля требует особой осмотрительности. Поскольку offset () защиты типов не обеспечивает, опечатка может легко привести к повреждению памяти. Подробный стиль про граммирования, показанный в коде листинга 11. 7, также удобнее будет использо вать программистам с меньшим опытом выполнения арифметических операций с указателями. В нем самом просматривается его же намерение.
11.2.6 _start (): функция main () для FledgeOS Ядро операционной системы не включает понятие функции main () в привычном для нас смысле. Во-первых, основной цикл ядра операционной системы никогда не возвращает управление. А куда ему его возвращать? По соглашению программы при выходе в операционную систему возвращают код ошибки. Но в самих опера ционных системах нет других операционных систем, которым можно было бы пре доставить код выхода. Во-вторых, запуск программы в main () также является со глашением. Но для ядер операционной системы этого соглашения также не суще ствует. Чтобы запустить ядро операционной системы, нам понадобится некоторое программное средство, взаимодействующее с процессором напрямую. Такое сред ство называется загрузчиком - bootloader. Компоновщик предполагает наличие одного определенного символа, являющегося точкой входа в программу: _start. Он связывает _start с функцией, которая опре делена в вашем исходном коде. В обычной среде функция _start () выполняет три задачи. Во-первых, это переза грузка системы. Например, во встроенной системе _start () может очистить реги стры и сбросить память до О. Его вторая задача - вызов main (). В-третьих, вызов функции _exi t (), которая все подчищает за main (). Наша функция _start () два последних задания не выполняет. Второе задание выполнять не требуется, по скольку функциональность приложения достаточно проста, чтобы уложиться в _start (). А в третьем задании нет необходимости, как, собственно, и в самой функции main () . Если бы это задание было вызвано, то управление из него никогда бы не вернулось.
11.3. fledgeos-1: избавление от цикла занятости Теперь, после закладки основ, можно приступить к добавлению функций в FledgeOS.
11.3.1. Экономия ресурсов за счет прямого взаимодействия с центральным процессором Прежде чем продолжить, необходимо устранить один серьезный недостаток FledgeOS: неуемное потребление ресурсов. Функция _start () из листинга 11.4 фактически загружает ядро процессора на 100%. Этого можно избежать, отправив центральному процессору инструкцию остановки (hl t).
478
Глава 11
Команда остановки, называемая в технической литературе HLT, уведомляет цен тральный процессор, что работы для него больше нет. Он возобновляет работу, ко гда прерывание запускает новое действие. Как показывает листинг 11.9, использо вание контейнера х84_64 позволяет нам отдавать инструкции процессору напря мую. В этом листинге, являющемся частью листинга 11.1О, контейнер х86_64 используется для доступа к инструкции hl t. Она передается процессору во время основного цикла_s tart (), чтобы предотвратить чрезмерное энергопотребление. Листинг 11.9. Использование инструкции hlt 7 use x86_64::instructions::{hlt}; 17 #[no_mangle] 18 pub extern "C" fn _start() -> ! { 19
let mut framebuffer = 0xb8000 as *mut u8;
20
unsafe {
21
framebuffer
22
.offset(1)
23
.write_volatile(0x30);
24
}
25
loop {
26 27
hlt();
(1)
}
28 }
(1) Это экономит электроэнергию.
Если не использовать hlt, центральный процессор будет работать со 100% загруз кой, не выполняя никакой полезной работы. Это превратит компьютер в очень до рогой обогреватель.
11.3.2. Исходный код fledgeos-1 Код fledgeos-1 в основном аналогичен коду fledgeos-0, за исключением того, что его файл s rc/maiп. r s включает дополнения из предыдущего раздела. Новый файл представлен в следующем листинге и доступен для загрузки из файла /ch11/ch11fledgeos-1/src/ main.rs. Чтобы скомпилировать код проекта, следует повторить дейст вия, изложенные в инструкции в разделе 11.2.1, заменив ссылки на fledgeos-0 на fledgeos-1. Листинг 11.10. Исходный код проекта для fledgeos-1 1 #![no_std] 2 #![no_main] 3 #![feature(core_intrinsics)] 4 5 use core::intrinsics; 6 use core::panic::PanicInfo;
Ядро операционной системы
479
7 use x86_64::instructions::{hlt}; 8 9 #[panic_handler] 10 #[no_mangle] 11 pub fn panic(_info: &PanicInfo) -> ! { 12
unsafe {
13 14
intrinsics::abort(); }
15 } 16 17 #[no_mangle] 18 pub extern "C" fn _start() -> ! { 19
let mut framebuffer = 0xb8000 as *mut u8;
20
unsafe {
21
framebuffer
22
.offset(1)
23
.write_volatile(0x30);
24
}
25
loop {
26 27
hlt(); }
28 }
Контейнер х86_64 дал возможность вставлять в наш код инструкции ассемблера. Стоит также изучить еще один подход: использование внутреннего ассемблера. Этот подход вкратце будет продемонстрирован в разделе 12.3.
11.4. fledgeos-2: самостоятельная обработка исключений В следующей итерации FledgeOS улучшены возможности обработки ошибок. FledgeOS по-прежнему дает сбой при возникновении ошибки, но теперь у нас есть структура для создания чего-то более сложного.
11.4.1. Почти что правильная обработка исключений FledgeOS не может управлять какими-либо исключениями, выдаваемыми цен тральным процессором при обнаружении ненормальной работы. Для обработки исключений наша программа должна определить свою собственную специализиро ванную функцию. Специализированные функции вызываются в каждом кадре стека, когда он выпол няет откат после выдачи исключения. Это означает, что стек вызовов просматрива ется, вызывая специализированную функцию на каждом этапе. Роль специализиро ванной функции состоит в том, чтобы определить, может ли текущий кадр стека обработать исключение. Обработка исключений также известна как перехват ис ключения.
480
Глава 11
ПРИМЕЧАНИЕ
Что такое откат стека? Когда функции вызываются, кадры стека накапливаются. Об ратное движение по стеку называется откатом. В конечном итоге откат стека приведет к _start ( ). Поскольку строгая обработка исключений для FledgeOS не требуется, мы реализу ем только самый минимум. В листинге 11.11, являющемся частью листинга 11.12, предоставляется фрагмент кода с минимальным обработчиком. Вставьте его в main.rs. Пустая функция означает, что любое исключение фатально, поскольку ни что здесь не будет помечено как обработчик. При возникновении исключения нуж но просто бездействовать. Листинг 11.11. Минималистичная специализированная процедура обработки исключений 4 #![feature(lang_items)] 18 #[lang = "eh_personality"] 19 #[no_mangle] 20 pub extern "C" fn eh_personality() { }
ПРИМЕЧАНИЕ
Что такое языковой элемент? Элементы языка - это Rust-элементы, реализованные в виде библиотек вне самого компилятора. Поскольку стандартная библиотека с помощью # [no_std] здесь убрана, придется часть ее функций реализовать самостоятельно. Многие сказали бы: «А что, нам делать больше нечего, кроме как готовый код пи сать заново?)) Но можно, по крайней мере, утешиться тем, что мы все здесь делаем неправильно.
11.4.2. Исходный код fledgeos-2 Код fledgeos-2 основывается на коде fledgeos-0 и fledgeos-1. В его файл src/main.rs включены дополнения из предыдущего листинга. Новый файл представлен в сле дующем листинге и доступен для загрузки из файла code/ch11/ch11-fledgeos2/src/main.гs. Чтобы скомпилировать код проекта, следует повторить действия, изло женные в инструкции в разделе 11.2.1, заменив ссьmки на fledgeos-0 на fledgeos-2. Листинг 11.12. Исходный код fledgeos-2 1 2 3 4 5 6 7 8 9 10 11
#![no_std] #![no_main] #![feature(core_intrinsics)] #![feature(lang_items)] use core::intrinsics; use core::panic::PanicInfo; use x86_64::instructions::{hlt}; #[panic_handler] #[no_mangle]
Ядро операционной системы
481
12 pub fn panic(_info: &PanicInfo) -> ! { 13
unsafe {
14 15
intrinsics::abort(); }
16 } 17 18 #[lang = "eh_personality"] 19 #[no_mangle] 20 pub extern "C" fn eh_personality() { } 21 22 #[no_mangle] 23 pub extern "C" fn _start() -> ! { 24
let framebuffer = 0xb8000 as *mut u8;
25 26
unsafe {
27
framebuffer
28
.offset(1)
29 30
.write_volatile(0x30); }
31 32
loop {
33 34
hlt(); }
11.5. fledgeos-3: текстовый вывод Давайте выведем текст на экран. Тогда, если действительно возникнет паника, мы сможем сообщить о ней должным образом.
Рис. 11.2. Вывод текста на экран, произведенный fledgeos-3
482
Глава 11
В этом разделе процесс отправки текста в буфер кадра будет рассмотрен более под робно. Результат работы fledgeos-3 показан на рис. 11.2.
11.5.1. Вывод на экран цветного текста Для начала создадим тип числовых констант цвета, которые будут использоваться позже в листинге 11.16. Использование перечисления вместо определения серии константных значений обеспечивает повышенную безопасность типов. В некото ром смысле оно добавляет семантическую связь меЖду значениями. Все они рас сматриваются как члены одной группы. В следующем листинге определяется перечисление, представляющее цветовую па литру VGА-совместимого текстового режима. Сопоставление битовых шаблонов и цветов определяется стандартом VGA, и наш код должен ему соответствовать. Листинг 11.13. Представление связанных числовых констант в виде перечисления (1)
9 #[allow(unused)] 10 #[derive(Clone,Copy)]
(2)
11 #[repr(u8)]
(3)
12 enum Color { 13
Black = 0x0,
White = 0xF,
14
Blue = 0x1,
BrightBlue = 0x9,
15
Green = 0x2,
BrightGreen = 0xA,
16
Cyan = 0x3,
BrightCyan = 0xB,
17
Red = 0x4,
BrightRed = 0xC,
18
Magenta = 0x5,
BrightMagenta = 0xD,
19
Brown = 0x6,
Yellow = 0xE,
20
Gray = 0x7,
DarkGray = 0x8
21 }
(1) Все варианты цвета в нашем коде использоваться не будут, поэтому предупреждения можно отключить. (2) Используем семантику копирования. (3) Указание компилятору использовать для представления значений один байт.
11.5.2. Управление представлением перечислений в памяти Мы довольствовались тем, что способ представления перечисления отдавали на откуп компилятору. Но бывают моменты, когда нужно брать все в свои руки. Внешние системы часто требуют, чтобы наши данные соответствовали их требова ниям. В коде листинга 11.13 представлен пример подгонки перечисления цветов из VGА совместимой палитры текстового режима под единый формат u8. Он лишает ком пилятора возможности выбирать, какой битовый шаблон (формально называемый
Ядро операционной системы
483
дискриминантом) связывать с конкретными вариантами. Чтобы прописать пред ставление, добавьте атрибут repr. Затем можно будет указать любой целочислен ный тип (i32, u8, ilб, ulб, ... ), а также некоторые особые варианты. Использование предписанного представления имеет ряд недостатков. В частности, оно снижает вашу гибкость и при этом также не дает компилятору оптимизировать использование пространства памяти. Некоторые перечисления с одним вариантом не требуют представления. Они появляются в исходном коде, но в запущенной на выполнение программе места не занимают.
11.5.3. Зачем использовать перечисления? Цвета можно моделировать по-разному. Например, можно создавать числовые кон станты, которые в памяти выглядят одинаково. Ниже показана одна из таких воз можностей: const BLACK: u8 = 0x0; const BLUE: u8 = 0x1; // ... ...
Использование перечисления приносит дополнительную защиту. Использовать не допустимое значение в нашем коде по сравнению с непосредственным использова нием значения u8 становится намного сложнее. Вы это увидите, когда в листинге 11.17 будет представлена структура Cursor.
11.5.4. Создание шрифта для вывода информации в буфер кадра VGA Для вывода на экран здесь будет использоваться структура cursor, обрабатываю щая простые манипуляции с памятью и способная преобразовать наш тип Color в то, что ожидается стандартом VGA. В следующем листинге, являющемся еще од ной частью листинга 11.16, показано, что этот тип управляет интерфейсом между нашим кодом и буфером кадра VGA.. Листинг 11.14. Определение структуры Cursor и методов для работы с ней 25 struct Cursor { 26
position: isize,
27
foreground: Color,
28
background: Color,
29 } 30 31 impl Cursor { 32
fn color(&self) -> u8 {
33
let fg = self.foreground as u8;
(1)
34
let bg = (self.background as u8) ! { 69
let text = b"Rust in Action";
70 71
let mut cursor = Cursor {
72
position: 0,
73
foreground: Color::BrightCyan,
74
background: Color::Black,
75
};
76
cursor.print(text);
77 78
loop {
79 80 81 }
hlt(); }
Ядро операционной системы
485
11.5.6. Исходный код fledgeos-3 fledgeos-3 продолжает развиваться на базе fledgeos-0, fledgeos-1 и fledgeos-2. В его файл src/main.rs включены дополнения из этого раздела. Весь файл представлен в следующем листинге и доступен для загрузки из файла code/ch11/ch11-fledgeos3/src/main.rs. Чтобы скомпилировать код проекта, следует повторить действия, изло женные в инструкции в разделе 11.2.1, заменив ссылки на fledgeos-0 на fledgeos-3. Листинг 11.16. Теперь FledgeOS выводит на экран текст 1 #![feature(core_intrinsics)] 2 #![feature(lang_items)] 3 #![no_std] 4 #![no_main] 5 6 use core::intrinsics; 7 use core::panic::PanicInfo; 8 9 use x86_64::instructions::{hlt}; 10 11 #[allow(unused)] 12 #[derive(Clone,Copy)] 13 #[repr(u8)] 14 enum Color { 15
Black = 0x0,
White = 0xF,
16
Blue = 0x1,
BrightBlue = 0x9,
17
Green = 0x2,
BrightGreen = 0xA,
18
Cyan = 0x3,
BrightCyan = 0xB,
19
Red = 0x4,
BrightRed = 0xC,
20
Magenta = 0x5,
BrightMagenta = 0xD,
21
Brown = 0x6,
Yellow = 0xE,
22
Gray = 0x7,
DarkGray = 0x8
23 } 24 25 struct Cursor { 26
position: isize,
27
foreground: Color,
28
background: Color,
29 } 30 31 impl Cursor { 32
fn color(&self) -> u8 {
33
let fg = self.foreground as u8;
34
let bg = (self.background as u8) ! { 56
unsafe {
57 58
intrinsics::abort(); }
59 } 60 61 #[lang = "eh_personality"] 62 #[no_mangle] 63 pub extern "C" fn eh_personality() { } 64 65 #[no_mangle] 66 pub extern "C" fn _start() -> ! { 67
let text = b"Rust in Action";
68 69
let mut cursor = Cursor {
70
position: 0,
71
foreground: Color::BrightCyan,
72
background: Color::Black,
73
};
74
cursor.print(text);
75 76
loop {
77 78 79 }
hlt(); }
Ядро операционной системы
487
11.6. fledgeos-4: специализированная обработка паники Наш обработчик паники, повторенный в следующем фрагменте кода, вызывает функцию core::intrinsics::abort(). Это приводит к немедленному выключению компьютера без предоставления каких-либо дополнительных данных: #[panic_handler] #[no_mangle] pub fn panic(_info: &PanicInfo) -> ! { unsafe { intrinsics::abort(); } }
11.6.1. Реализация обработчика паники, сообщающего пользователю об ошибке. Для тех, кто занимается разработкой встраиваемых систем или хочет запустить Rust на микроконтроллерах, важно научиться сообщать о возникновении паники. Для начала хорошо бы освоить применение типажа core::fmt::Write. Его можно для отображения сообщения (см. рис. 11.3) связать с обработчиком паники.
11.6.2. Повторная реализация panic{) с использованием core::fmt::Write Результат, показанный на рисунке 11.3, выдается кодом листинга 11.17. Теперь при выполнении функции panic () проходит двухэтапный процесс. На первом этапе panic () очищает экран.
Рис. 11.3. Вывод сообщения при возникновении паники
488
Глава 11
А на втором этапе задействован макрос core: :wri t e ! . Этот макрос в качестве пер вого аргумента (cursor) принимает объект назначения, реализующий свойство core:: fmt: :Write. Обработчик паники, сообщающий, что при использовании те кущего процесса произошла ошибка, показан в следующем листинге, являющемся частью листинга 11.19. Листинг 11.17. Очистка экрана и вывод сообщения 61 pub fn panic(info: &PanicInfo) -> ! { 62
let mut cursor = Cursor {
63
position: 0,
64
foreground: Color::White,
65
background: Color::Red,
66
};
67
for _ in 0..(80*25) {
(1)
68
cursor.print(b" ");
(1)
69
}
(1)
70
cursor.position = 0;
(2)
71
write!(cursor, "{}", info).unwrap();
(3)
loop {}
(4)
72 73 74 }
(1) (2) (3) (4)
Очистка экрана путем его заполнения красным фоном. Сброс позиции курсора. Вывод Panicinfo на экран. Вход в бесконечный цикл, позволяющий пользователю прочитать сообщение и перезапустить компьютер вручную.
11.6.3. Реализация core::fmt::Write Реализация core:: fmt : :Write включает вызов одного метода: write_str (). В этом типаже определено несколько других методов, но компилятор может автоматиче ски сгенерировать их, как только станет доступна реализация write_st r (). Реали зация, код которой показан в следующем листинге, повторно использует метод p rint () и преобразует закодированные в UTF-8 значения типа &str в & [u8 J, ис пользуя для этого метод t o_byte s (). Код листинга находится в файле ch11/ch11fledgeos-4/src/main.rs.
Листинг 11.18. Реализация core::fmt::Write для структуры Cursor 54 impl fmt::Write for Cursor { 55
fn write_str(&mut self, s: &str) -> fmt::Result {
56
self.print(s.as_bytes());
57 58 59 }
Ok(()) }
Ядро операционной системы
489
11.6.4. Исходный код fledge-4 В следующем листинге показан более удобный для пользователя код обработки паники для FledgeOS. Код листинга находится в файле ch11/ch11-fledgeos-4/src/main.rs. Как и в случае с более ранними версиями, для компиляции проекта повторите дей ствия, указанные в инструкции в разделе 11.2.1, но замените ссьmки на fledgeos-0 на fledgeos-4 . Листинг 11.19. Полный листинг кода FlendgeOS с более совершенной обработкой паники 1 #![feature(core_intrinsics)] 2 #![feature(lang_items)] 3 #![no_std] 4 #![no_main] 5 6 use core::fmt; 7 use core::panic::PanicInfo; 8 use core::fmt::Write; 9 10 use x86_64::instructions::{hlt}; 11 12 #[allow(unused)] 13 #[derive(Copy, Clone)] 14 #[repr(u8)] 15 enum Color { 16
Black = 0x0,
White = 0xF,
17
Blue = 0x1,
BrightBlue = 0x9,
18
Green = 0x2,
BrightGreen = 0xA,
19
Cyan = 0x3,
BrightCyan = 0xB,
20
Red = 0x4,
BrightRed = 0xC,
21
Magenta = 0x5,
BrightMagenta = 0xD,
22
Brown = 0x6,
Yellow = 0xE,
23
Gray = 0x7,
DarkGray = 0x8
24 } 25 26 struct Cursor { 27
position: isize,
28
foreground: Color,
29
background: Color,
30 } 31 32 impl Cursor { 33
fn color(&self) -> u8 {
34
let fg = self.foreground as u8;
35
let bg = (self.background as u8) fmt::Result {
56
self.print(s.as_bytes());
57 58
Ok(()) }
59 } 60 61 #[panic_handler] 62 #[no_mangle] 63 pub fn panic(info: &PanicInfo) -> ! { 64
let mut cursor = Cursor {
65
position: 0,
66
foreground: Color::White,
67
background: Color::Red,
68
};
69
for _ in 0..(80*25) {
70
cursor.print(b" ");
71
}
72
cursor.position = 0;
73
write!(cursor, "{}", info).unwrap();
74 75
loop { unsafe { hlt(); }}
76 } 77 78 #[lang = "eh_personality"] 79 #[no_mangle] 80 pub extern "C" fn eh_personality() { } 81 82 #[no_mangle] 83 pub extern "C" fn _start() -> ! { 84 85 }
panic!("help!");
Ядро операционной системы
491
Резюме ♦ Написание программы, предназначенной для работы без операционной системы, может ощущаться как программирование в бесплодной пустыне. Функциональ ные возможности, наличие которых считалось вами само собой разумеющимся, например динамическая память или многопоточность, в данном случае будут недоступны. ♦ В средах вроде встроенных систем, не имеющих динамического управления па мятью, стандартную библиотеку Rust следует отключать, применяя для этого аннотацию#! [no std]. ♦ При взаимодействии с внешними компонентами символы наименования стано вятся значимыми. Чтобы отказаться от возможностей Rust по изменению имен, воспользуйтесь атрибутом#! [no_mangle]. ♦ Внутренними представлениями Rust можно управлять с помощью аннотаций. Например, аннотирование перечисления с помощью # ! [repr ( [uBJ) заставляет значения упаковываться в один байт. Если это не сработает, Rust откажется компилировать программу. ♦ Вам доступны манипуляции с обычными указателями, но есть и типобезопасные альтернативы. Там, где это целесообразно, воспользуйтесь для правильного под счета количества байтов, которые нужно пройти по адресному пространству, методом offset (). ♦ Внутренние компоненты компилятора всегда доступны вам по цене востребова ния nightlу-компилятора. Обращайтесь к встроенным функциям компилятора, таким как intrinsics:: abort (), для обеспечения той функциональности про граммы, которая обычно недоступна. ♦ cargo следует рассматривать как расширяемый инструмент. Это средство всегда находится в центре рабочего процесса Rust-программиста, но его стандартное поведение при необходимости может быть изменено. ♦ Для доступа к обычным машинным инструкциям, таким как HTL, можно вос пользоваться вспомогательными контейнерами, например х86_64, или же пола гаться на запуск встроенного ассемблера. ♦ Не бойтесь экспериментировать. С современными инструментами, такими как QEMU, худшее, что может случиться, - это сбой вашей крошечной операцион ной системы с необходимостью заняться ее немедленным перезапуском.
12
Сигналы, прерывания и исключения
В этой главе рассматриваются следующие вопросы: ♦ Что такое прерывания, исключения, ловушки и сбои. ♦ Как драйверы устройств информируют приложения о готовности данных. ♦ Как передавать сигналы между приложениями, запущенными на выполнение. В этой главе описывается процесс, посредством которого внешний мир взаимодей ствует с вашей операционной системой. Сеть постоянно прерывает выполнение программы, когда байты готовы к отправке. Это означает, что после подключения к базе данных (или в любое другое время) операционная система может потребовать, чтобы ваше приложение обработало сообщение. Здесь рассматривается этот процесс и способы подготовки к нему ваших программ. В главе 9 говорилось, что цифровые часы периодически уведомляют операционную систему о ходе времени. А здесь объясняется, что происходит с этими уведомле ниями. Через концепцию сигналов в главе вводится понятие одновременного за пуска нескольких приложений. Сигналы возникли как часть традиций операцион ной системы UNIX. Их можно использовать для отправки сообщений между раз ными запущенными программами. Мы совместно рассмотрим обе концепции: и сигналов, и прерываний - поскольку модели программирования у них схожи. Но проще начать с сигналов. Несмотря на то, что в этой главе основное внимание уделяется операционной системе Linux, ра ботающей на процессорах семейства х86, это не означает, что пользователи других операционных систем не смогут руководствоваться ее указаниями.
12.1. Глоссарий Изучить порядок взаимодействия процессоров, драйверов устройств, приложений и операционных систем весьма непросто. Здесь бытует множество специальных тер минов. Хуже того, все термины похожи, и, конечно, разобраться в них мешает еще и то, что они часто подменяют друг друга. Рассмотрим несколько терминов, ис пользуемых в этой главе. Их взаимосвязанность показана на рис. 12.1: • Аварийный сбой (Abort) - невосстанавливаемое исключение. Если приложе ние выдает аварийный сбой, оно завершает работу. • Сбой (Fault) - восстанавливаемое исключение, ожидаемое при выполнении обычных операций, таких как ошибка страницы, случающаяся, когда адрес
494
Глава 12
памяти недоступен, и данные должны быть извлечены из микросхем основ ной памяти. Этот процесс известен как работа с виртуалыюй па.мятью и рас смотрен в разделе 4 главы 6. • Исключение (Exception) - обобщающее понятие, включающее в себя аварий ные завершения, сбои и ловушки. Исключения, формально называемые син хронными прерываниями, иногда рассматриваются в качестве формы преры вания. • Аппаратное прерывание (Hardware interrupt) - прерывание, выдаваемое та кими устройствами, как клавиатура или контроллер жесткого диска. Обычно используется устройствами для уведомления центрального процессора о том, что данные доступны для чтения с устройства. • Прерывание (Jnterrupt) - понятие аппаратного уровня, используемое в двух смыслах. Оно может относиться только к синхронным прерываниям, в числе которых аппаратные и программные прерывания. В него, в зависимости от контекста, также входят исключения. Обычно прерывания обрабатываются операционной системой. • Сигнал (Signal) - понятие уровня операционной системы, используемое для обозначения прерывания потока управления приложением. Сигналы обраба тываются приложениями. Прерывания Зачастую один термин используется для обозначения обеих концепций. Исключения lntel определяет три формы классов исключений. Аварийные сбои Невосстанавливаемое Двойной сбой (второй сбой возник при обработке первого)
Сбои Восстанавливаемое Ошибка страницы
Ловушки Восстанавливаемое
Прерывания
Определяется программой
Определяется аппаратурой
Центральный процессор получает инструкцию INT (на x86)
Устройства, сообщающие что данные готовы
Ошибка страницы
Рис. 12.1. Визуальная систематика порядка взаимодействия прерываний, исключений, ловушек и сбоев в семействе процессоров Intel х86. Обратите внимание на отсутствие сигналов. Сигналы это не прерывания.
Сигналы, прерывания и исключения
495
• Программное прерывание (Software interrupt) - прерывание, выдаваемое программой. В семействе центральных процессоров х86 компании Intel про граммы могут выдавать прерывание с помощью инструкции INT. Кроме все го, программные прерывания используются отладчиками для установки точек останова. • Ловушка (Trap) - восстанавливаемое исключение, к примеру, целочислен ное переполнение, обнаруженное центральным процессором. Целочисленное переполнение объясняется в разделе 5.2. ПРИМЕЧАНИЕ
Значение термина «исключение» может отличаться от понятий из вашего предыдуще го опыта программирования. В языках программирования термин «исключение» часто используется для обозначения любой ошибки, тогда как в отношении процессоров он имеет особое значение.
12.1.1. Сравнение сигналов и прерываний Важнее всего различать два понятия: сигналы и прерывания. Сигнал - это абст ракция на программном уровне, связанная с операционной системой. А прерыва ние - это абстракция, относящаяся к процессору, и тесно связанная с оборудова нием системы. Сигналы - форма ограниченного межпроцессного взаимодействия. Они не содер жат контента, но их присутствие на что-то указывает. Они аналогичны физическо му звуковому сигналу. Зуммер не передает контент, но человек, который нажимает его кнопку, все равно знает о том, что что-то задумано, поскольку при этом издает ся очень резкий звук. Чтобы усилить путаницу, сигналы часто называют программ ными прерываниями. Но в этой главе использование термина «прерывание)) в от ношении сигнала всячески избегается. Есть две формы прерываний, различаемые по происхождению. Одна из форм пре рывания возникает внутри центрального процессора во время его работы в резуль тате попытки обработки недопустимых инструкций и попытки доступа к недопус тимым адресам памяти. Эта первая форма технически известна как синхронное пре рывание, но вам, возможно, попадалось ее более распространенное название исключение. Вторая форма прерывания генерируется аппаратными устройствами, например клавиатурами и акселерометрами. Обычно именно ее и подразумевают под поняти ем прерывания. Это форма прерывания может проявиться в любое время и фор мально известна как асинхронное прерывание. Как и сигналы, эта форма прерыва ния также может быть сгенерирована программным способом. Прерывания могут быть специализированными. Ловушка - это ошибка, обнаружен ная центральным процессором, поэтому он дает операционной системе возможность восстановления. А сбой - еще одна форма устранимой проблемы. Если центрально му процессору дается адрес памяти, из которого он не может считать данные, он уве домляет об этом операционную систему и запрашивает обновленный адрес.
496
Глава 12
Прерывания заставляют изменять поток управления приложением, а вот многие сигналы при желании можно проигнорировать. При получении прерывания цен тральный процессор переходит к коду обработчика независимо от текущего со стояния программы. Местоположение кода обработчика заранее определяет систе ма BIOS и операционная система в процессе загрузки. Обработка сигналов по принципу обработки прерываний Непосредственная обработка прерываний означает манипулирование ядром опера ционной системы. Поскольку в учебной среде лучше обойтись без этого, мы позво лим себе вольное обращение с терминологией. Поэтому далее в этой главе сигналы будут рассматриваются как прерывания.
Но к чему все упрощать? Написание компонентов операционной системы требует настройки ядра. Любые выводы из строя означают, что наша система может полно стью перестать отвечать и не будет никакого конкретного способа исправить сло жившуюся ситуацию. С более прагматичной точки зрения отказ от настройки ядра означает, что мы упускаем возможность изучения совершенно нового набора инст рументов компилятора. Но, как будто специально для нас, код обработки сигналов похож на код обработки прерываний. Практическая работа с сигналами позволит ограничивать любые ошибки в коде только нашим приложением без риска вывода из строя всей систе мы. Общая схема выглядит так: 1. Моделирование стандартного потока управления вашего приложения. 2. Моделирование прерванного потока управления и определение ресурсов, тре бующих, при необходимости, полного отключения. 3. Написание обработчика прерывания (сигнала), обновляющего какое-либо со стояние и выполняющего быстрый возврат. 4. Делегирование в обычном порядке трудоемких операций одним лишь изменени ем значения глобальной переменной, регулярно проверяемого основным циклом программы. 5. Изменение стандартного потока управления вашего приложения для поиска флага GO (NO GO), который обработчик сигнала мог бы изменить.
12.2. Влияние прерываний на приложения Рассмотрим этот вопрос на небольшом примере кода. В следующем листинге пока зано простое вычисление суммы двух целых чисел. Листинг 12.1. Программа, вычисляющая сумму двух целых чисел 1 fn add(a: i32, b:i32) -> i32 { 2 a + b 3 }
Сигналы, прерывания и исключения
497
4 5 fn main() { 6
let a = 5;
7
let b = 6;
8
let c = add(a,b);
9 }
Значение с вычисляется всегда, независимо от количества аппаратных прерываний. Но время, затраченное программой на это вычисление и засекаемое по настенным часам, становится неопределенным, поскольку центральный процессор каждый раз выполняет разные задачи. Когда происходит прерывание, центральный процессор тут же останавливает вы полнение программы и переходит к обработчику прерывания. В следующем лис тинге (проиллюстрированном на рис. 12.2) дается подробное описание всего про исходящего, когда при выполнении кода листинга 12.1, показанного между строка ми 7 и 8, возникает прерывание. Нормальное выполнение программы Поток управления в обычной обстановке представляет собой линейную последовательность инструкций. Вызовы функций и инструкции возврата действительно заставляют центральный процессор выполнять переходы по разным местам памяти, но порядок событий может быть предопределен. main() let a = 5;
Прерванное выполнение программы Напрямую аппаратное прерывание на программу не влияет, хотя может немного повлиять на производительность, поскольку операционная система вынуждена отработать с оборудованием.
main() let a = 5;
let b = 6;
let b = 6; ?
Пунктиром отмечен путь работы процессора при выполнении программы.
.... add(a,b)
.... add(a,b)
let c = ...
let c = ...
add(a: i32, b: i32) -> i32
add(a: i32, b: i32) -> i32
a + b
a + b
RETURN
RETURN
Программе неизвестно, чем занят процессор. После завершения других задач выполнение продолжается в обычном режиме.
В Rust наличие инструкции возврата просто подразумевается.
Рис. 12.2. Использование сложения для демонстрации потока управления при обработке сигналов
498
Глава 12
Листинг 12.2. Отображение последовательности операций в коде листинга 12.1 при обработке прерывания 1 #[allow(unused)] 2 fn interrupt_handler() { 3
(1)
/ / ..
4 } 5 6 fn add(a: i32, b:i32) -> i32 { 7
a + b
8 } 9 10 fn main() { 11
let a = 5;
12
let b = 6;
13 14
/ / Key pressed on keyboard!
15
interrupt_handler()
16 17
let c = add(a,b);
18 }
(1) Несмотря на то, что обработчик прерывания представлен в этом листинге в виде дополнительной функции, обычно он определяется операционной системой.
Следует помнить одно важное обстоятельство: с позиции программы изменения незначительны. Она вообще ничего не знает о прерывании потока управления. И точное представление этой программы- по-прежнему код листинга 12.1.
12.3. Программные прерывания Программные прерывания генерируются программами, отправляющими конкрет ные инструкции центральному процессору. Для этого в Rust можно вызвать макрос asm ! . Краткое представление о синтаксисе можно получить, изучив следующий код, доступный в файле ch12/asm.rs: #![feature(asm)] use std::asm; fn main() { unsafe { asm!("int 42"); } }
(1) Включение нестабильной функции.
(1)
Сигналы, прерывания и исключения
499
При запуске скомпилированного исполняемого файла операционная система выда ет следующую ошибку: $ rustc +nightly asm.rs $ ./asm Segmentation fault (core dumped)
Начиная с версии Rust 1.50, макрос asm ! считается нестабильным и требует запуска имеющегося в Rust nightlу-компилятора, для установки которого нужно воспользо ваться командой rustup: $ rustup install niqhtly
12.4. Аппаратные прерывания У аппаратных прерываний есть особый поток управления. Для уведомления цен трального процессора устройства взаимодействуют со специализированной микро схемой, известной как программируемый контроллер прерываний (ProgrammaЫe Interrupt Controller, PIC). Порядок передачи прерывания от аппаратных устройств к приложению показан на рис. 12.3. Клавиша нажата! Микрочип внутри клавиатуры преобразует электрический импульс в значение u32
Сообщение получено! PIC решает, сразу уведомить процессор или же дождаться запроса данных.
Прервано! Сохранение состояния регистра и переход к инструкции обработчика прерывания с передачей управления операционной системе.
Ядро готово! Запрос данных от контроллера клавиатуры.
В счастливом неведении: все идет как обычно
ОС
Приложение
Клавиатура Микрофон
Программируемый контроллер прерываний (PIC)
ЦП
Сеть ...
Материнская плата компьютера
Программные средства
Рис. 12.3. Порядок уведомления приложений о прерывании, сгенерированном аппаратным устройством. После того как операционная система была уведомлена о готовности данных, она напрямую связывается с устройством (в данном случае с клавиатурой) для считывания данных в свою память.
12.5. Обработка сигналов Сигналы требуют, чтобы им тут же уделили внимание. Отказ в обработке сигнала обычно приводит к завершению работы приложения.
12.5.1. Поведение по умолчанию Иногда лучше всего позволить системе сделать всю работу по умолчанию. Код, который не нужно писать, не содержит случайно допущенных вами ошибок.
500
Глава 12
Для большинства сигналов поведение по умолчанию - закрытие приложения. Ко гда приложение не предоставляет специальной функции-обработчика (в этой главе будет показано, как она создается), операционная система считает сигнал призна ком аварийной ситуации, при выявлении которой дела у приложения складываются весьма печально: его работа завершается. Такой сценарий показан на рис. 12.4.
Рис. 12.4. Приложение, защищающее себя от множества нежелательных сигналов.
Обработчики сигналов - благосклонные гиганты компьютерного мира. Обычно они не мешают, но они есть, когда вашему приложению нужно защитить свой замок. Не являясь частью повседневного потока управления, обработчики сигналов очень полезны в нужное время. Обработке поддаются не все сигналы. Самый злобный из них- SIGKILL.
Ваше приложение может получать три общих сигнала, перечисленных ниже с ука занием предполагаемых действий: • SIGINT - завершает программу (обычно создается человеком). • SIGTERМ - завершает программу (обычно создается другой программой). • SIGKILL - немедленно завершает программу без возможности восстановления. Вам будут попадаться и другие, менее распространенные, сигналы. Для удобства более подробный перечень приведен в таблице 12.2. Следует заметить, что три перечисленных здесь примера связаны главным образом с завершением работающей программы. Но так происходит не всегда.
12.5.2. Приостановка и возобновление работы программы Достойных упоминания специальных сигнала два: SIGSTOP и SIGCONT. Сигнал SIGSTOP останавливает выполнение программы, и она пребывает в приостановлен ном состоянии, пока не будет получен сигнал SIGCONT. В системах UNIX этот сиг нал используется для управления заданиями. Также о них полезно знать на тот слу чай, когда требуется самостоятельное вмешательство для приостановки работаю щего приложения с последующей возможностью возобновления его работы. В следующем фрагменте кода показана структура проекта sixty, разрабатываемого в этой главе. Чтобы загрузить проект, введите в консоли следующие команды: $ qit clone https:/ /qithuЬ.com/rust-in-action/code rust-in-action $ cd rust-in-action/chl2/chl2-sixty
Сигналы, прерывания и исключения
501
Чтобы начать проект самостоятельно, создайте показанную ниже структуру катало гов, и заполните ее кодом листингов 12.3 и 12.4: ch12-sixty ├── src └── main.rs
(1)
└── Cargo.toml
│
(2)
(1) См. листинг 12.4 (2) См. листинг 12.3
В следующем листинге показан контейнер исходных метаданных для проекта sixty. Его исходный код находится в каталоге ch12/ch12-sixty/. Листинг 12.3. Контейнер метаданных для проекта si t
[package] name = "sixty" version = "0.1.0" authors = ["Tim McNamara "] [dependencies]
В следующем листинге представлен код для создания базового приложения со сро ком жизни 60 секунд, выводящего на консоль ход своего выполнения. Его исход ный код находится в файле ch12/ch12-sixty/src/main.rs. Листинг 12.4. Базовое приложение, получающее сигналы SIGSTOP и SIGCONT 1 use std::time; 2 use std::process; 3 use std::thread::{sleep}; 4 5 fn main() { 6
let delay = time::Duration::from_secs(1);
7 8
let pid = process::id();
9
println!("{}", pid);
10 11
for i in 1..=60 {
12
sleep(delay);
13 14
println!(". {}", i); }
15 }
После сохранения кода из листинга 12.4 на диск открываются две консоли. В пер вой нужно выполнить команду cargo run. Появится 3-5-значное число, за которым следует счетчик, увеличивающийся каждую секунду. Число в первой строке -
502
Глава 12
идентификатор процесса (PID). Работа и ожидаемый результат показаны в табли це 12.1. Таблица 12.1. Порядок приостановки и повторного запуска процессов с помощью сигналов SIGSTOP и SIGCONT
Шаг 1 2
Консоль 1
Консоль 2
Выполнение приложения
Отправка сигналов
$ cd ch12/ch12-sixty
$ cargo run 23221 . 1 . 2 . 3 . 4
3 4
$ kill -SIGSTOP 23221 [1] + Stopped cargo run
$
5 6
. 5
$ kill -SIGCONT 23221
. 6 . 7 . 8
⋮
. 60
Для получения последовательности выполнения программы, показанной в таблице 12.1, сделайте следующее: 1. Перейдите в консоли 1 в каталог проекта (созданного из кода листингов 12.3 и 12.4). 2. Скомпилируйте и запустите проект. 3. cargo выводит на экран отладочную информацию, которая здесь не приводится. После запуска программа sixty выводит PID, а затем посекундно выводит на консоль числа. Поскольку при инициировании работы выдавался идентификатор процесса, в таблице в колонке вывода фигурирует число 23221. 4. Выполните в консоли 2 команду kill, указав для нее ключ -SIGSTOP. 5. Кто не знает, команда оболочки kill предназначена для отправки сигналов. Ей присвоили название самой распространенной роли, останавливающей вы полнение программ сигналом SIGKILL или сигналом SIGTERМ. Числовой аргу мент (23221) должен соответствовать PID, предоставленному на шаге 2.
Сигналы, прерывания и исключения
503
6. Консоль 1 возвращается в режим командной строки, поскольку на первом плане больше ничего не работает. 7. Возобновите выполнение программы, отправив на PID, указанный на шаге 2, сигнал S IGCONТ. 8. Программа возобновит работу счетчика. Ее работа завершится, как только счет чик дойдет до числа 60, если выполнение не будет прервано нажатием комбина ции клавиш Ctrl-C (SI G INТ).
Сигналы SIGSTOP и SIGCONТ представляют особый интерес. Но давайте все же про должим разбираться с более типичным поведением при получении сигналов.
12.5.3. Перечень всех сигналов, nоАQерживаемых операционной системой А какие еще есть сигналы, и как действуют их обработчики, запускаемые по умол чанию? Ответ можно запросить у команды kill: $ kill -l 1) SIGHUP 6) SIGABRT 11) SIGSEGV 16) SIGURG 21) SIGTTIN 26) SIGVTALRM 31) SIGUSR2
// -l означает list.
2) 7) 12) 17) 22) 27) 32)
SIGINT SIGEMT SIGSYS SIGSTOP SIGTTOU SIGPROF SIGRTMAX
3) 8) 13) 18) 23) 28)
SIGQUIT SIGFPE SIGPIPE SIGTSTP SIGIO SIGWINCH
4) 9) 14) 19) 24) 29)
SIGILL SIGKILL SIGALRM SIGCONT SIGXCPU SIGPWR
5) 10) 15) 20) 25) 30)
SIGTRAP SIGBUS SIGTERM SIGCHLD SIGXFSZ SIGUSR1
Как же их много в системе Linux! Хуже того, стандартным поведением обработчи ков обладают далеко не все сигналы. К счастью, большинству приложений не тре буется установка обработчиков для многих (а иногда и вообще для всех) из этих сигналов. Сокращенный список сигналов, чаще всего встречающихся при обычном программировании, приведен в таблице 12.2. Таблица 12.2. Список наиболее часто встречающихся сигналов,
действия их обработчиков по умолчанию и 1шмбинации клавиш для их отправки из командной строки
Сигнал
Читается как
Действие об- Комментарий работчика по умолчанию
SIGHUP
Hung up
Завершение работы
Произошел от телефона, основанного на цифровой коммутации. В настоящее время часто отправляется фоновым приложениям (демонам или службам) с просьбой перечитать их файлы конфигурации, и кроме того, запущенным программам при выходе из оболочки.
Комбинация клавиш Ctrl-D
504
Глава 12
Таблица 12.2 (окончание)
Сигнал
Читается как
Действие об- Комментарий работчика по умолчанию
Комбинация клавиш
SIGINT
lnterrupt (или, возможно, interactive)
Завершение работы
Генерируемый пользователем сигнал для завершения работы запущенного приложения
Ctrl-C
SIGTERМ
Tenninate
Завершение работы
Просит приложение корректно завершить работу
SIGКILL
Kill
Завершение работы
Это действие невозможно остано-
SIGQUIT
Quit
SIGTSTP
вить
Записывает состояние памяти на диск как дамп памяти, а затем завершает работу
Ctrl-\
Tenninal stop
Выдерживание Терминал запрашивает приостапаузы новку выполнения приложения
Ctrl-Z
SIGSTOP
Stop
Выдерживание Это действие невозможно остановить паузы
SIGCONT
Continue
Возобновление выполнения после паузы
ПРИМЕЧАНИЕ Сигналы SIGКILL и SIGSTOP имеют особый статус: они не могут быть обработаны или заблокированы приложением. Все остальные действия по сигналам программы могут обойти.
12.6. Обработка сигналов с помощью настраиваемых действий Обработка сигналов, проводимая по умолчанию, носит ограниченный характер, и при этом получение сигнала ничем хорошим для приложений не заканчивается. К примеру, если внешние ресурсы, такие как подключения к базам данных, остают ся открытыми, то по завершении выполнения приложения они могут оказаться без должной очистки. Чаще всего обработчики сигналов используются для того, чтобы разрешить прило жению аккуратно завершить свою работу. К числу общих задач, востребованных при завершении работы приложения, относятся: • Сброс на жесткий диск всех данных, ожидавших записи. • Закрытие всех сетевых подключений. • Отмена регистрации в любом распределенном диспетчере или рабочей очереди.
Сигналы, прерывания и исключения
505
Чтобы остановить текущую работу и завершить выполнение приложения, требует ся обработчик сигнала. Чтобы настроить обработчик сигнала, нужно создать функ цию с сигнатурой f ( i32) -> (). То есть в качестве единственного аргумента функ ция должна принимать целое число типа i32 и не возвращать значения. При этом возникает ряд проблем, связанных с разработкой программного кода. Об работчик сигналов не может получить доступ к какой-либо информации из прило жения, кроме данных об отправленном сигнале. Следовательно, отсутствие инфор мации о состоянии оборудования, задействованного приложением, не позволяет заранее знать, что конкретно нужно отключать.
Помимо архитектурных есть еще и дополнительные ограничения. Обработчики сигналов ограничены по времени и объему. В подмножестве функций, доступных для общего кода, они также должны работать быстро, на что есть ряд причин: • Обработчики сигналов могут заблокировать обработку других сигналов того же типа. • Быстрота снижает вероятность параллельной работы с другим обработчиком сигналов иного типа. Обработчики сигналов имеют ограниченную сферу разрешенных действий. Напри мер, им следует избегать выполнения любого кода, который сам может сгенериро вать сигналы. Чтобы выйти за рамки этих ограничений, обычно используется постоянно прове ряемая в ходе выполнения программы глобальная переменная в виде флага с буле вым значением. Если флаг установлен, функцию можно вызвать для корректного завершения работы приложения в контексте приложения. Чтобы этот шаблон рабо тал, нужно соблюсти два требования: • Единственной задачей обработчика сигнала должно быть изменение значения флага. • Приложение должно регулярно проверять флаг, чтобы определить, был ли он изменен. Чтобы избежать состояний гонки, вызванных одновременной работой нескольких обработчиков сигналов, эти обработчики сигналов обычно практически ничего не делают. В этом деле распространен прием установки флага посредством глобаль ной переменной.
12.6.1. Применение в Rust глобальных переменных Rust упрощает использование глобальных переменных (то есть переменных, дос тупных в любом месте программы), объявляя переменную с ключевым словом static в глобальной области видимости. Предположим, нам нужно создать гло бальное значение sнuт_ oowN, которое можно установить в значение true, когда об работчик сигнала считает, что выполнение программы нужно срочно завершить. Для этого можно воспользоваться следующим объявлением: static mut SHUT_DOWN: bool = false;
506
Глава 12
ПРИМЕЧАНИЕ
static mut, несмотря на грамматическую несуразицу, читается как «изменяемый статический». Глобальные переменные доставляют Rust-программистам определенную проблему. Доступ к ним (даже просто для чтения) небезопасен. А код, заключенный в небезо пасные блоки, может сильно загромождать программу. Эта неприглядная картина служит сигналом для предусмотрительных программистов: от глобальных состоя ний по возможности лучше отказываться. В листинге 12.6 представлен пример static mut переменной, чтение которой пока зано в строке 12, а запись - в строках 7-9. Вызов функции rand:: random () в стро ке 8 выдает булевы значения. В результате получается серия точек. Примерно в 50% случаев получается вывод, который похож на показанный в следующем сеансе консоли 1 : $ git clone https://github.com/rust-in-action/code rust-in-action $ cd rust-in-action/ch12/ch2-toy-global $ cargo run -q .
В следующем листинге представлены метаданные для кода листинга 12.6. Исход ный код метаданных находится в файле ch12/ch12-toy-global/Cargo.toml . Листинг 12.5. Контейнер метаданных для кода листинга 12.6 [package] name = "ch12-toy-global" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] rand = "0.6"
Наш учебный пример представлен в следующем листинге. Его исходный код нахо дится в файле ch12/ch12-toy-global/src/main.rs. Листинг 12.6. Доступ к глобальным переменным (к изменяемой статике) в Rust
1 use rand; 2 3 static mut SHUT_DOWN: bool = false; 4 5 fn main() { 1 Вывод предполагает наличие хорошего генератора случайных чисел, используемого Rust по умолчанию. Это предположение сбудется, если довериться генератору случайных чисел вашей операционной системы.
Сигналы, прерывания и исключения 6
507
loop {
7
unsafe {
8
SHUT_DOWN = rand::random();
9
}
10
(1) (2)
print!(".");
11 12
if unsafe { SHUT_DOWN } {
13
break
14
};
15
}
16
println!()
17 }
(1) Для чтения и записи static mut переменной требуется небезопасный блок. (2) rand::random() - краткая форма вызова rand::thread_rng() .gen() для получения случайного значения. Требуемый тип выводится из типа SHUT_DOWN.
12.6.2. Использование глобальной переменной для указания на инициирование завершения выполнения программы Поскольку обработчики сигналов должны быть быстрыми и простыми, мы проде лаем минимально возможный объем работы. В следующем примере будет установ лена переменная для указания программе на необходимость завершения работы. Этот прием демонстрируется в коде листинга 12.8, разделенном на следующие три функции: •
regis t er_signal_handle r s () - обеспечивает связь с операционной систе мой через libc, являясь обработчиком для каждого сигнала. Эта функция ис пользует указатель на функцию, которая относится к функции как к данным. Указатели на функции объясняются в разделе 11.7.1.
обрабатывает входящие сигналы. Эта функция не зави сит от того, какой именно сигнал отправлен, но мы будем работать только с сигналом SIGTERM.
• handle_signals () -
•
main() -
циклу.
инициализирует программу и выполняет итерацию по основному
При запуске получившийся в результате компиляции исполняемый файл показыва ет, где он находится. Трассировка показана в следующем сеансе работы с консо лью: $ git clone https://github.com/rust-in-action/code rust-in-action $ cd rust-in-action/ch12/ch12-basic-handler $ cargo run -q 1 SIGUSR1 2 SIGUSR1
508
Глава 12
3 SIGTERM 4 *
(1)
(1) Надеюсь, вы простите мне этот взрыв дешевого ASCII-apтa.
ПРИМЕЧАНИЕ
При неправильной регистрации обработчика сигнала на выходе может появиться со общение Terminated. Проверьте, что в начале функции main () был добавлен вызов функции r eg is t e r_signa l_handler (). В листинге 12.8 это сделано в строке 38.
В следующем листинге показаны пакет и зависимости для кода листинга 12.8. Его исходный код находится в файле ch12/ch12-basic-handler/Cargo.toml. Листинг 12.7. Контейнер настроек для кода листинга 12.10 [package] name = "ch12-handler" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] libc = "0.2"
При выполнении код следующего листинга использует обработчик сигнала для из менения глобальной переменной. Его можно найти в файле ch12/ch12-basic handler/src/main.rs.
Листинг 12.8. Создание обработчика сигнала, изменяющего глобальную переменную 1 #![cfg(not(windows))] 2
(1)
3 use std::time::{Duration}; 4 use std::thread::{sleep}; 5 use libc::{SIGTERM, SIGUSR1}; 6 7 static mut SHUT_DOWN: bool = false; 8 9 fn main() { 10
register_signal_handlers();
(2)
11 12
let delay = Duration::from_secs(1);
13 14
for i in 1_usize.. {
15
println!("{}", i);
16
unsafe {
17
if SHUT_DOWN {
(3)
Сигналы, прерывания и исключения 18
509
println!("*");
19
return;
20
}
21
}
22 23
sleep(delay);
24 25
let signal = if i > 2 {
26
SIGTERM
27
} else {
28
SIGUSR1
29
};
30 31
unsafe {
32
libc::raise(signal);
33
(4)
}
34
}
35
unreachable!();
36 } 37 38 fn register_signal_handlers() { 39
unsafe {
40
libc::signal(SIGTERM, handle_sigterm as usize);
41 42
(4)
libc::signal(SIGUSR1, handle_sigusr1 as usize); }
43 } 44 45 #[allow(dead_code)]
(5)
46 fn handle_sigterm(_signal: i32) { 47
register_signal_handlers();
48 49
(6)
println!("SIGTERM");
50 51
unsafe {
52 53
SHUT_DOWN = true;
(7)
}
54 } 55 56 #[allow(dead_code)]
(5)
57 fn handle_sigusr1(_signal: i32) { 58
register_signal_handlers();
(6)
59 60
println!("SIGUSR1");
61 }
(1) Указание на то, что этот код не работает под Windows. (2) Этот вызов ДO.JDl(eH состояться как можно раньше, иначе сигнаnы будут обрабатываться некорректно.
510
Глава 12
(3) (4) (5) (6)
Доступ к изменяемой статике небезопасен. Вызов функций 1ibc небезопасен; их действия не подконтрольны Rust. Без этого атрибута rustc предупреждает, что эти функции никогда не вызываюrся. Перерегистрация должна произойти как можно скорее, чтобы минимизировать изменения сигнала, влияющие на сам обработчик сигналов. (7) Модифицировать изменяемую статику небезопасно. В строках 40 и 41 предыдущего листинга в вызовах 1ibc:: signa1 () есть особен ности, требующие пояснения. 1ibc::signa1 принимает в качестве аргументов имя сигнала (на самом деле являющееся целым числом) и нетипизированный ука затель на функцию (известный в языке С под термином указатель на функцию типа void), и связывает сигнал с функцией. Указатели на функции в Rust созда ются с помощью ключевого слова fn. Функции handle_sigterm() и han d1e_sigusrl () имеют тип fn (i32)-> (). Чтобы убрать любую информацию о ти пе, их нужно привести к значениям типа usize. Более подробно указатели на функции рассмотрены в разделе 12.7.1.
в чем разница между const и static
Статические и постоянные значения кажутся похожими. Главное различие между ними состоит в следующем: • Значения static появляются в памяти только в одном месте. • Значения const могут дублироваться в тех местах, где они доступны. Дублирование соnst-значений может быть оптимизацией, удобной для процессора. Тем самым обеспечивается локальность данных и повышение производительности кэша. Зачем же создавать путаницу из похожих по смыслу имен для двух разных сущно стей? Отнесем это к разряду возможных исторических случайностей. Слово static относится к сегменту адресного пространства, в котором находятся переменные. Статические значения находятся вне пространства стека, в области, где хранятся строковые литералы, ближе к нижней части адресного пространства. Это означает, что доступ к статической переменной практически наверняка подразумевает разы менование указателя. Константа в соnst-значениях относится к самому значению. При доступе из кода, если компилятор посчитает, что это приведет к более быстрому доступу, данные могут дублироваться во все необходимые места.
12.7. Отправка сигналов, определяемых в приложении Сигналы могут использоваться как ограниченная форма обмена сообщениями. Оп ределения для SIGUSRl и SIGUSR2B могут задаваться в бизнес-правилах. В проекте они ни для чего серьезного не предназначены. В коде листинга 12.8 сигнал SIGUSRl используется для выполнения простой задачи. Он приводит к простому выводу на
Сигналы, прерывания и исключения
511
консоль строки SIGUSRl. Более практичное использование сигналов, определяемых в приложении, -уведомление однорангового приложения о готовности неких дан ных к дальнейшей обработке.
12.7.1. Общие сведения об указателях на функции и их синтаксисе В коде листинга 12.8 содержится, вероятно, весьма странный синтаксис. Например, выражение handle_sigterm as usize в строке 40, похоже, является приведением функции к целому числу. Так что же там происходит? Адрес, по которому хранится функция, преобразуется в целое число. Используемое в Rust ключевое слово fn создает указатель на функцию. Читатели, внимательно изучившие главу 5, поймут, что функции - это просто данные. Иными словами, функции - последовательность байтов, имеющая смысл для центрального процессора. Указатель функции - указатель на начало такой последовательности. Чтобы освежить память, вернитесь к главе 5, особенно к раз делу 5.7. Указатель - тип данных, заменяющий объект ссылки. В исходном коде приложе ния указатели содержат не только адрес целевого значения, но и его тип. А в ском пилированном двоичном файле информация о типе отсутствует. Внутреннее пред ставление указателей -это целое число типа usize. Получается, передавать указа тели очень выгодно. В языке С использование указателей на функции может показаться некой магией. А в Rust они маскируются у всех на виду. Каждое объявление fn фактически объявление указателя на функцию. Следова тельно, код листинга 12.9 приемлем и при его выполнении на консоль должно вы водится нечто похожее на следующую строку: $ rustc ch12/fn-ptr-demo-1.rs && ./fn-ptr-demo-1 noop as usize: 0x5620bb4af530
ПРИМЕЧАНИЕ Получившееся на выходе число Ox5620bb4af530 является адресом памяти (в шест надцатеричной системе счисления), с которого начинается функция noop (). На ва шем компьютере это число будет другим.
Порядок преобразования функции в usize показан в следующем листинге, код ко торого находится в файле ch12/noop.rs. В нем можно увидеть, как значение типа usize используется в качестве указателя на функцию. Листинг 12.9. Приведение указателя на функцию к типу usize fn noop() {} fn main() { let fn_ptr = noop as usize; println!("noop as usize: 0x{:x}", fn_ptr); }
512
Глава 12
А какой тип у указателя на функцию, созданного из выражения fn noop () ? Для описания указателей на функции в Rust опять же используется синтаксис сигнату ры функций. В случае выражения fn noop () типом является *const fn() -> (). Его можно прочитать как «константный указатель на функцию, не принимающую ар гументов и возвращающую значение типа uni t». Константный указатель не может изменяться. Значение типа uni t - значение, заменяющее в Rust понятие «ничто». В листинге 12.10 показан код, приводящий указатель на функцию к типу usize, а затем выполняющий обратную операцию. Данные, выводимые им на консоль, показаны в следующем фрагменте кода и должны содержать две почти одинаковые строки: $ rustc chl2/fn-ptr-demo-2.rs && ./fn-ptr-demo-2 noop as usize: 0x55ab3fdЬ05c0 noop as *const Т: 0x55ab3fdЬ05c0
ПРИМЕЧАНИЕ
На вашем компьютере эти два числа будут другими, но одинаковыми.
Листинг 12.10. Приведение указателя на функцию к типу usize
fn noop() {} fn main() { let fn_ptr = noop as usize; let typed_fn_ptr = noop as *const fn() -> (); println!("noop as usize: 0x{:x}", fn_ptr); println!("noop as *const T: {:p}", typed_fn_ptr); }
(1)
(1) Обратите внимание на использование выражения (:р}, являющегося модификатором формата указателя.
12.8. Игнорирование сигналов В таблице 12.2 показано, что большинство сигналов по умолчанию приводят к за вершению запущенной программы. Это может помешать работающей программе выполнить ее работу. (Иногда приложение лучше знает, что нужно делать!) В таких случаях многие сигналы можно проигнорировать. Кроме SIGSTOP и SIGKILL функции libc::signal () вместо указателя на функцию может быть предоставлена константа srG_IGN . Пример ее использования - проект ignore. Код его файла Cargo.toml показан в листинге 12.11, а файла src/main.гs в листинге 12.12. Оба эти файла находятся в каталоге проекта ch12/ch12-ignore. При выполнении кода проект выводит на консоль следующую строку: $ cd ch12/ch12-ignore $ cargo run -q ok
Сигналы, прерывания и исключения
513
Проект ignore показывает, как можно проигнорировать избранные сигналы. В стро ке 6 листинга 12.12 в качестве обработчика сигнала для l ibc::s i gnal () предостав ляется libc::SIG_IGN (сокращение от signal ignore). Переключение на поведение по умолчанию выполняется в строке 13. Здесь снова вызывается функция 1 ibc::s ignal () , но на этот раз в качестве обработчика сигнала уже фигурирует аргумент S IG_DFL (сокращение от signal default). Листинг 12.11. Метаданные для для проекта ignore [package] name = "ignore" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] libc = "0.2"
Листинг 12.12. Игнорирование сигналов с помощью libc::SIG_IGN 1 use libc::{signal,raise}; 2 use libc::{SIG_DFL, SIG_IGN, SIGTERM}; 3 4 fn main() { 5
unsafe {
(1)
6
signal(SIGTERM, SIG_IGN);
(2)
7
raise(SIGTERM);
(3)
8
}
9 10
println!("ok");
11 12
unsafe {
13
signal(SIGTERM, SIG_DFL);
14
raise(SIGTERM);
15
(4) (5)
}
16 17
println!("not ok");
(6)
18 }
(1) Здесь нужен небезопасный: блок, поскольку Rust не контролирует то, что происходит за пределами функций. (2) Игнорирование сигнала SIGTERМ. (3) libc::raise() позволяет коду вьщавать сигнал; в данном случае - самому себе. (4) Переключение SIGTERМ на действие по умолчанию. (5) Завершение программы. (6) Вьmолнение до этого кода никогда не доходит, поэтому эта строка никогда не выводится на консоль.
514
Глава 12
12.9. Завершение работы из глубокой вложенности в стеке вызовов Что делать, если программа находится глубоко в середине стека вызовов и не в со стоянии его раскрутить? При получении сигнала программе перед завершением (или принудительным завершением) может понадобиться выполнить какой-нибудь код очистки. Иногда это называют нелокальной передачей управления. Операцион ные системы на UNIX-ocнoвe предоставляют ряд инструментов, позволяющих вос пользоваться этим механизмом с помощью двух системных вызовов - setjmp и longjmp:
• setjmp устанавливает местоположение маркера. • longjmp выполняет переход в ранее отмеченное место.
Но к чему такие программно-гимнастические ухищрения? Дело в том, что иногда применение подобных низкоуровневых приемов - единственный выход из затруд нительного положения. Это уже ближе к «темному искусству» системного про граммирования. Процитируем страницу руководства: «setjmp () и longjmp () пригодятся для работы с ошибками и прерываниями, встре чающимися в подпрограмме низкого уровня>>. - Проект документации Linux: setjmp(З)
Эти два средства позволяют программе обойти обычный поток управления и теле портироваться через код. Иногда ошибка возникает глубоко в стеке вызовов. Если программа реагирует на ошибку слишком долго, операционная система может про сто прервать ее выполнение, и данные этой программы могут остаться в несогласо ванном состоянии. Во избежание этого можно воспользоваться вызовом longjmp и передать управление непосредственно коду обработки ошибок. Чтобы понять важность такого приема, рассмотрим, что происходит в стеке вызо вов обычной программы в ходе нескольких вызовов рекурсивной функции, создан ных кодом листинга 12.13. Каждый вызов dive () добавляет еще одно место, к ко торому впоследствии должно возвращаться управление. Посмотрим на левую часть таблицы 12.3. Системный вызов longjmp, используемый в коде листинга 12.17, об ходит сразу несколько уровней стека вызовов. Его влияние на стек вызовов показа но в правой части таблицы 12.3. Таблица 12.3. Сравнение предполагаемых результатов выполнения кодов листингов 12.13 и 12.17
--------------------,-------------------, Код листинга 12.13 выдает симметричный узор. Каждый уровень возникает в резуль тате вложенного вызова dive () и удаляет ся при возвращении из вызова.
Код листинга 12.17 выдает совершенно иную картину. После нескольких вызовов dive () управление телепортируется об ратно в функцию main () без возвращения вызовов к di ve ()
Сигналы, прерывания и исключения
515
Таблица 12.3 (окончание)
#
#
##
##
###
###
####
early return!
#####
finishing!
### ## #
В левой части таблицы 12.3 при вызове функций стек вызовов увеличивается на один элемент, а затем уменьшается на один элемент при возврате управления из каждой функции. В правой части код переходит непосредственно от третьего вызо ва к вершине стека вызовов. В следующем листинге показана работа стека вызовов с изменением его размеров в ходе выполнения программы. Код этого листинга находится в файле ch10/ch10callstack/src/main. rs.
Листинг 12.13. Демонстрация работы стека вызовов 1 fn print_depth(depth:usize) { 2
for _ in 0..depth {
3
print!("#");
4
}
5
println!("");
6 } 7 8 fn dive(depth: usize, max_depth: usize) { 9 10
print_depth(depth); if depth >= max_depth {
11
return;
12 13
} else {
14
dive(depth+1, max_depth);
15
}
16
print_depth(depth);
17 } 18 19 fn main() { 20 21 }
dive(0, 5);
516
Глава 12
Чтобы добиться этого, нужно приложить массу усилий. В самом языке Rust нет ин струментов, позволяющих выполнить подобные трюки с потоком управления. Тут нужен доступ к ряду инструментов, предоставляемых Rust-компилятором. Он предлагает специальные функции прикладных программ, известные как встроен ные. Использование встроенной функции совместно с кодом Rust требует особых настроек, после которых она работает точно так же, как и стандартная функция.
12.9.1. Представление проекта sjlj Проект sjlj демонстрирует способы, позволяющие переиначить обычный поток управления функцией. При некотором содействии операционной системы и компи лятора можно фактически создать ситуацию, при которой функция может переме щаться в любое место программы. Эта функциональная возможность используется в коде листинга 12.17 для обхода нескольких уровней стека вызовов, позволяя вы вести на консоль данные, показанные в правой части таблицы 12.3. Поток управле ния для проекта sjlj показан на рис. 12.5.
main()
register_signal_handler()
ptr_to_jmp_buf()
unsafe { setjmp() }
sеtjmp() действует как точка входа и как точка выхода. После вызова функции longjmp() функция sеtjmp() возвращает управление во второй раз.
dive()
handle_signals()
return_early()
unsafe { longjmp() }
В эти разделы программы можно попасть только путем выдачи сигнала SIGUSR1. В нашей программе это делается своими силами путем применения функции libc:: signal(), но, в принципе, ничто и ни на каком из этапов не препятствует выдаче точно такого же сигнала со стороны внешнего процесса.
println!("early return!")
println!("finishing")
Рис. 12.5. Поток управления проекта sjlj. Поток управления программой может быть перехвачен с помощью сигнала, а затем запущен с места, указанного в функции setjmp ()
Сигналы, прерывания и исключения
517
12.9.2. Настройка встроенных функций для их использования в программе Листинг 12.17 использует две встроенные функции: setjmp () и longjmp (). Чтобы включить их в наших программах, ящик должен быть помечен указанным атрибу том. Соответствующий код показан в следующем листинге. Листинг 12.14. трибут уровня контейнера, востребованный в main.rs #![feature(link_llvm_intrinsics)]
Но тут же возникают два вопроса, ответы на которые вскоре последуют: • Что такое встроенная функция? • Что такое LLVM? Кроме того, Rust должен узнать от нас о функциях, предоставляемых LLVМ. По скольку ему о них ничего не известно, кроме сигнатур их типов, следовательно, любое их использование должно происходить в unsafe-блoкe. Порядок сообщения Rust о функциях LLVM показан в следующем листинге, исходный код которого находится в файле ch12/ch12-sjlj/src/main.rs. Листинг 12.15. Объявление внутренних функций LLVM, используемое в коде листинга 12.17 extern "C" { #[link_name = "llvm.eh.sjlj.setjmp"] pub fn setjmp(_: *mut i8) -> i32; #[link_name = "llvm.eh.sjlj.longjmp"] pub fn longjmp(_: *mut i8);
(1) (2) (1)
}
(1) Предоставление компоновщику конкретных инструкций о том, где искать определения функций. (2) Поскольку имя аргумента не используется, здесь, чтобы оно было явно указанным, применяется символ подчеркивания (_).
Этот фрагмент кода хоть и небольшой, но все же довольно сложный: • extern "С" означает следующее: «Этот блок кода должен подчиняться со глашениям языка С, а не языка Rust». • Атрибут link_nam e сообщает компоновщику, где найти две объявляемые на ми функции. • eh в llvm.eh.sjlj .setjmp означает обработку исключений, а sjlj означает setjmp/longjmp. • *mut i8 - указатель на байт со знаком. Имеющие опыт программирования на языке С могут узнать здесь указатель на начало строки (например, тип *char).
518
Глава 12
Что такое встроенная функция? Встроенные функции (intrinsics) не часть языка и доступны только через компиля тор. Сам Rust в значительной степени независим от цели, а вот компилятор имеет доступ к целевой среде. Этот доступ может поспособствовать получению дополни тельных функций. Например, компилятор разбирается в характеристиках централь ного процессора, на котором будет выполняться компилируемая программа. Ком пилятор может через встроенные функции предоставить программе доступ к инст рукциям этого процессора. К числу встроенных функций относятся: • Атомарные операции. Многие процессоры предоставляют специальные ин струкции, предназначенные для оптимизации определенных рабочих нагру зок. Например, центральный процессор может гарантировать, что обновление целого числа будет атомарной операцией. Под атомарностью здесь подразу мевается неделимость. При работе с кодом конкурентных вычислений это может сыграть весьма важную роль. • Обработка исключений. Возможности центральных процессоров по управле нию исключениями различаются. Соответствующие средства могут исполь зоваться разработчиками языков программирования для создания настраи ваемого потока управления. В их числе встроенные функции setjmp и longjmp, представленные далее в этой главе. Что такое LLVM? С позиции Rust-программистов LLVM можно рассматривать как подкомпонент Rust-компилятора rustc. LLVM - это внешний инструмент, связанный с rustc. Rust программисты могут использовать предоставляемые им инструменты. Один из на боров инструментов, предоставляемых LLVM, - встроенные функции. LLVM сам по себе является компилятором. Его роль показана на рис. 12.6. Внешние контейнеры
Целевой центральный процессор
Среда окружения
Вс
Ин фо рм
оч
ир уе т
Инструмент
Текстовый cargo редактор Пр ои
зво
Артефакт
ди
Вызывает
ся ет м та тво Чи едс ср по
т
Исходный код
вызывает
rustc Пр
ои зв
од и
т
llvm
ни
ис
rustc вызывает
ся Пр ет ои ру зво зи твом и ди м с т ти ред п с О по
LLVM IR
ета
Неофициально упоминается как rustc
linker ис
ни та че
о Вс
Пр ои
зво
ди
т
Исполняемый файл
Сборка
В отношении библиотечных контейнеров это двоичные файлы, которые позже можно будет скомпоновать с другими контейнерами
Рис. 12.6. Ряд основных шагов, необходимых для создания исполняемого
файла из исходного кода Rust. LLVM является важной, но не ориентированной на пользователя частью процесса.
Сигналы, прерывания и исключения
519
LLVM преобразует код, созданный rustc в виде кода на LLVM IR (промежуточном языке), в машиночитаемый язык ассемблера. Ситуация усложняется тем, что для сборки нескольких контейнеров нужен еще один инструмент, называемый компо новщиком. При работе под Windows язык Rust использует программу link.exe, пре доставляемую компанией Microsoft в качестве компоновщика. В других операци онных системах используется компоновщик GNU ld. Более подробное исследование LLVM подразумевает изучение rustc и компиляции в целом. Как и в случаях со многими другими вещами, чтобы приблизиться к исти не, необходимо исследовать всю фрактальную область. То есть для изучения каж дой подсистемы, похоже, потребуется изучение еще одного набора подсистем. Бо лее подробное объяснение здесь было бы, наверное, весьма интересным, но все же отвлекало от основной цели.
12.9.3. Приведение указателя к другому типу Одна из наиболее загадочных частей Rust-синтаксиса - приведение одних типов указателей к другим. С этим вопросом придется столкнуться при подробном изуче нии кода листинга 12.17. Но из-за сигнатур типов setjmp() и longj mp () могут воз никнуть проблемы. В следующем фрагменте кода, являющемся частью кода лис тинга 12.17, можно увидеть, что обе функции принимают в качестве аргумента ука затель типа *mut i8: extern "C" { #[link_name = "llvm.eh.sjlj.setjmp"] pub fn setjmp(_: *mut i8) -> i32; #[link_name = "llvm.eh.sjlj.longjmp"] pub fn longjmp(_: *mut i8); }
Необходимость использования в качестве входного аргумента значения типа *mut i8 создает проблему, поскольку в нашем Rust-кoдe есть ссылка только на буфер перехода (например, &jmp_buf)2• Процесс разрешения этого конфликта рассматри вается в следующих нескольких абзацах. Тип jmp_buf определяется следующим образом: const JMP_BUF_WIDTH: usize = mem::size_of::() * 8; type jmp_buf = [i8; JMP_BUF_WIDTH];
(1)
(1) На 64-разрядных машинах эта константа имеет ширину 64 бита (8 32-разрядных - 32 бита (8 х 4 байта).
х
8 байтов), а на
Тип jmp_buf - псевдоним типа ДJIЯ массива из значений типа i8, состоящего из 8 целочисленных значений типа usize. Типу jmp_buf отводится роль хранилища со стояНЮI программы, позволяющего при необходимости повторно заполнить значения2
Для особо дотошных читателей поясняем, что j mp_buf для этого буфера - вполне обычное имя.
520
Глава 12
ми регистры центрального процессора. В коде листинга 12.17 есть только одно значе ние jmp_buf, глобально изменяемая статика с именем RETURN_HERE, определяемая в строке 14. Способ инициирования jmp_buf показан в следующем примере: static mut RETURN_HERE: jmp_buf = [0; JMP_BUF_WIDTH];
Так как же мы относимся к RETURN_HERE в качестве указателя? В Rust-кoдe RETURN_HERE имеет вид ссылки (&RETURN_HERE). А LLVM ожидает, что эти байты будут представлены как *mut iB. Для преобразования применяются четыре шага, упакованные в одну строку: unsafe { &RETURN_HERE as *const i8 as *mut i8 }
Давайте выясним, в чем суть этих четырех шагов: 1. Все начинается с &RETURN_HERE - доступной только для чтения ссылки на гло бальную статическую переменную типа [ iB; в J на 64-разрядных машинах или [ iв; 4 J на 32-разрядных машинах. 2. Затем выполняется преобразование этой ссылки в *const i8. В Rust приведение одних типов указателей к другим их типам считается безопасным, но для опре деления этого указателя требуется unsafe-блoк. 3. После этого выполняется преобразование *const iB в *mut iB. То есть место в памяти объявляется изменяемым (доступным по чтению и записи). 4. И наконец, преобразование заключается в unsafe-блoк, поскольку в нем ведется работа с доступом к глобальной переменной. А почему бы не воспользоваться чем-то вроде &mut RETURN_HERE as *mut i8? Rust-компилятор весьма трепетно относится к предоставлению LLУМ доступа к своим данным. Предлагаемый здесь подход, начинающийся со ссылки, предназна ченной только для чтения, упрощает работу с Rust.
12.9.4. Компиляция кода проекта sjlj Теперь уже сложилась такая ситуация, при которой все возможные недоразумения относительно кода листинга 12.17 должны быть незначительными. Поведение, ко торое мы пытаемся воспроизвести, еще раз показано в следующем фрагменте кода: $ git clone https://github.com/rust-in-action/code rust-in-action $ cd rust-in-action/ch12/ch12-sjlj $ cargo run -q # # early return! finishing!
И последнее замечание: для правильного хода компиляции проекту sjlj требуется, чтобы rustc был на nightly-кaнaлe. Если возникнет ошибка «iF ! [featureJ may not Ье used on the staЫe rele ase channel» («iF ! [ feature], не может использо ваться на канале стабильного выпуска>), то для установки nightly-кaнaлa нужно воспользоваться командой rustup install nightly. После этого можно будет вое-
Сигналы, прерывания и исключения
521
пользоваться nightlу-компилятором, добавив к cargo аргумент +nightly. Появле ние этой ошибки и избавление от нее показано в следующем выводе на консоль:
$ cargo run -q error[E0554]: #![feature] may not be used on the stable release channel --> src/main.rs:1:1 | 1 | #![feature(link_llvm_intrinsics)] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: aborting due to previous error For more information about this error, try `rustc --explain E0554`. $ rustup toolchain install nightly ... $ cargo +nightly run -q # ## ### early return! finishing!
12.9.5. Исходный код проекта sjlj В следующем листинге компилятор LLVM используется для доступа к функциям операционной системы l ongjmp. Функция l ongjmp позволяет программам выходить из своего фрейма стека и переходить в любое место в пределах своего адресного пространства. Код листинга 12.6 находится в файле ch12/ch12-sjlj/Cargo.toml, а лис тинга 12.17 - в файле ch12/ch12-sjlj/src/main.rs. Листинг 12.16. Метаданные для проекта sjli [package] name = "sjlj" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] libc = "0.2"
Листинг 12.17. Использование механизма внутреннего (встроенного) компилятора LLVM
1 #![feature(link_llvm_intrinsics)] 2 #![allow(non_camel_case_types)] 3 #![cfg(not(windows))]
(1)
522
Глава 12
4 5 use libc::{ 6
SIGALRM, SIGHUP, SIGQUIT, SIGTERM, SIGUSR1,
7 }; 8 use std::mem; 9 10 const JMP_BUF_WIDTH: usize = 11
mem::size_of::() * 8;
12 type jmp_buf = [i8; JMP_BUF_WIDTH]; 13 14 static mut SHUT_DOWN: bool = false;
(2)
15 static mut RETURN_HERE: jmp_buf = [0; JMP_BUF_WIDTH]; 16 const MOCK_SIGNAL_AT: usize = 3;
(3)
17 18 extern "C" { 19
#[link_name = "llvm.eh.sjlj.setjmp"]
20
pub fn setjmp(_: *mut i8) -> i32;
21 22
#[link_name = "llvm.eh.sjlj.longjmp"]
23
pub fn longjmp(_: *mut i8);
24 } 25 26 #[inline]
(4)
27 fn ptr_to_jmp_buf() -> *mut i8 { 28
unsafe { &RETURN_HERE as *const i8 as *mut i8 }
29 } 30 31 #[inline]
(4)
32 fn return_early() { 33
let franken_pointer = ptr_to_jmp_buf();
34
unsafe { longjmp(franken_pointer) };
35 } 36 37 fn register_signal_handler() { 38
unsafe {
39 40
libc::signal(SIGUSR1, handle_signals as usize); }
41 } 42 43 #[allow(dead_code)] 44 fn handle_signals(sig: i32) { 45
register_signal_handler();
46 47
let should_shut_down = match sig {
48
SIGHUP => false,
49
SIGALRM => false,
50
SIGTERM => true,
(5)
Сигналы, прерывания и исключения 51
SIGQUIT => true,
52
SIGUSR1 => true,
53 54
_ => false, };
55 56
unsafe {
57 58
SHUT_DOWN = should_shut_down; }
59 60
return_early();
61 } 62 63 fn print_depth(depth: usize) { 64
for _ in 0..depth {
65
print!("#");
66
}
67
println!();
68 } 69 70 fn dive(depth: usize, max_depth: usize) { 71
unsafe {
72
if SHUT_DOWN {
73
println!("!");
74
return;
75
}
76
}
77
print_depth(depth);
78 79
if depth >= max_depth {
80 81
return; } else if depth == MOCK_SIGNAL_AT {
82
unsafe {
83
libc::raise(SIGUSR1);
84 85
} } else {
86
dive(depth + 1, max_depth);
87
}
88
print_depth(depth);
89 } 90 91 fn main() { 92
const JUMP_SET: i32 = 0;
93 94
register_signal_handler();
95 96
let return_point = ptr_to_jmp_buf();
97
let rc = unsafe { setjmp(return_point) };
523
524 98
Глава 12 if rc == JUMP_SET {
99 100
dive(0, 10); } else {
101 102
println!("early return!"); }
103 104
println!("finishing!")
105 }
(1) (2) (3) (4)
Компилируется только на поддерживаемых платформах. Если true, программа завершает работу. Допускает глубину рекурсии, равную трем. Атрибут #[inline] помечает функцию доступной для встраивания и является методом оптимизации компилятора с целью устранения издержек на вызовы функций. (5) Этот код небезопасен, поскольку Rust не может гарантировать безопасность того, что LLVМделает с памятью в RETURN_HERE. (6) Требование к libc связать handle_signals с сигналом SIGUSRl.
12.10. Заметка о применении этих методов на платформах, не использующих сигналы. Сигналы - UNIХ-производная. На других платформах сообщения от операцион ной системы обрабатываются иначе. В MS Windows, например, приложения ко мандной строки должны предоставлять ядру функцию обработчика через SetCon soleCtrlHandler. Затем эта функция-обработчик вызывается при отправке сигнала приложению. Независимо от конкретного механизма показанный в этой главе высокоуровневый подход должен быть вполне переносимым на другие платформы. Шаблон выглядит следующим образом: • Ваш центральный процессор выдает прерывания, требующие ответа от опе рационной системы. • Операционные системы зачастую делегируют ответственность за обработку прерываний через какую-то систему обратного вызова. • Система обратного вызова означает создание указателя на функцию.
12.11. Пересмотр исключений В начале главы мы обсудили различие между сигналами, прерываниями и исклю чениями. Непосредственно сами исключения почти не рассматривались. Мы счита ли их особым классом прерываний. А сами прерывания были смоделированы как сигналы.
Сигналы, прерывания и исключения
525
Чтобы завершить эту главу (и всю книгу), мы исследовали ряд функций, доступных в rustc и LLVM. В основной части этой главы предоставляемые ими возможности использовались для работы с сигналами. В Linux сигналы являются основным ме ханизмом, который операционная система использует для связи с приложениями. При рассмотрении всего этого с позиции Rust основное время было потрачено на взаимодействие с libc и с unsаfе-блоками, на распаковку указателей на функции и на настройку глобальных переменных.
Резюме ♦ Аппаратные устройства, например сетевая карта компьютера, уведомляют при ложения о данных, готовых к обработке, отправляя прерывание в центральный процессор. ♦ Указатели на функции - это указатели, нацеленные не на данные, а на испол няемый код. В Rust они обозначаются ключевым словом fn. ♦ Операционные системы семейства Unix осуществляют управление заданиями с помощью двух сигналов: SIGSTOP и SIGCONТ. ♦ Чтобы снизить риск возникновения состояний гонки, вызванных одновременной работой сразу нескольких обработчиков сигналов, эти обработчики должны вы полнять минимально возможный объем работы. Типовой шаблон такого поведе ния - установка флага на основе использования глобальной переменной. Со стояние этого флага должно периодически проверяться в основном цикле про граммы. ♦ Для создания в Rust глобальной переменной нужно воспользоваться «изменяе мой статикой>>. А для доступа к этой изменяемой статике требуется unsafe-блoк. ♦ В языках программирования операционная система, сигналы и компилятор мо гут использоваться для реализации обработки исключений с помощью систем ных ВЫЗОВОВ setjmp И longjmp. ♦ Без ключевого слова unsafe программы на Rust не смогли бы эффективно взаи модействовать с операционной системой и с другими сторонними компонентами.