131 85 52MB
Russian Pages 525 с. [530] Year 2023
Rust in Action SYSTEMS PROGRAМMING CONCEPTS AND TECHNIQUES
TIM
McNAМARA
111 MANNING SHELTER ISLAND
Тим Макнамара
Rust в действии
Санкт-Петербург « БХВ-Петербург»
2023
УДК 004.4 ББК
32.973.26-02 М15 М15
Макнамара Т.
Rust в действии: Пер. с англ. -СПб.: БХВ-Петербург, 2023. - 528 с.: ил.
ISBN 978-5-9775-1166-7 Книга о прикладных аспектах языка программирования Rust, описывающая внутреннее устройство языка и сферы его использования. Rust рассматривается как современное дополнение для С при программировании ядра ОС и при системном программировании, а также как низкоуровневый скоростной язык, обеспечиваю щий максимальную производительность. Объяснены тонкости работы с процессо ром, многопоточное программирование, работа с памятью, а также взаимодействие с Linux. Изложенный материал позволяет как писать современные приложения на Rust с нуля, так и внедрять Rust в сложившуюся базу кода. Книга ориентирована на специалистов по С, Linux, системному программи рованию и на всех, кто желает освоить Rust и сразу приступить к работе с ним. УДКОО4.4 ББК 32.973.26-02
Группа подготовки издания:
Руководитель проекта Зав. редакцией Перевод с английского Редактор Компьютерная верстка Оформление обложки
Олег Сивченко Людмила Гауль Николая Вильчинского Дарья Кустовская Натш,ьи Смирновой Зои Канторович
Original English language edition puЫished Ьу Manning PuЫications. Copyright (с) 2021 Ьу Manning PuЫications. Russian-language edition copyright (с) 2022 Ьу ВНV. All rights reserved. Оригинальное издание на английском языке опубликовано Manning PuЫications. © 2021 Manning PuЫications. Издание на русском языке © 2022 ООО «БХВ». Все права защищены.
Подписано в печать 03.11.22. 1 Формат 70х100 /16. Печать офсетная. Усл. печ. л. 42,57. Тираж 1500 экз. Заказ № 5510. "БХВ-Петербург", 191036, Санкт-Петербург, Гончарная ул., 20. Отпечатано с готового оригинал-макета ООО "Принт-М", 142300, М.О., г. Чехов, ул. Полиграфистов, д. 1
ISBN 978-1-61729-455-6 (англ.) ISBN 978-5-9775-1166-7 (рус.)
© Manning PuЬlications, 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. Примеры использования ТLS-безопасности ......................................................... 47 1.9.1. HeartЫeed ........................................................................................................ 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.1О.7. В автономном режиме ................................................................................. 52 1.10.8. В мобильных приложениях ......................................................................... 53 1.10.9. В веб-режиме ................................................................................................ 53 1.10.10. В системном программировании .............................................................. 53 1.11. Скрытая фишка Rust: его сообщество .................................................................. 54 1.12. Разговорник по Rust ............................................................................................... 54 Резюме .............................................................................................................................. 54 ЧАСТЬ 1. ОСОБЕННОСТИ ЯЗЫКА
Rusт ......................................................................... 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.1О. Создание списков с использованием массивов, слайсов и векторов ................ 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 ЧАСТЬ 11. ДЕМИСТИФИКАЦИЯ СИСТЕМНОГО ПРОГРАММИРОВАНИЯ ................... 189
Глава 5. Углубленное изучение данных ................................................................. 191 5.1. Комбинации битов и типы .................................................................................... 191 5.2. Жизнь целых чисел ................................................................................................ 194 5.2.1. Усвоение порядка следования байтов ........................................................ 197 5.3. Представление десятичных чисел ........................................................................ 198 5.4. Числа с плавающей точкой ................................................................................... 199 5.4.1. Взгляд на fЗ2 изнутри .................................................................................. 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 и формата Ьincode................... 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 vl: хранилище ключей и значений в памяти с интерфейсом командной строки ........................................................ 292 7.6. Actionkv vl : интерфейсный код ............................................................................ 293 7.6.1. Настройка продукта условной компиляции .............................................. 296 7.7. Понимание сути actionkv: контейнер libactionkv ................................................ 298 7.7.1. Инициализация структуры ActionКV ........................................................ 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. Работа с ключами и значениями с использованием HashМap и BTreeMap ............................................................................................ 315 7.7.8. Создание HashМap и ее заполнение значениями ...................................... 318 7.7.9. Извлечение значений из HashМap и BTreeMap ........................................ 319 7.7.10. Что выбрать: HashМap или BTreeMap? ................................................... 320 7.7.11. Добавление к actionkv v2.0 индекса базы данных .................................. 322 Резюме ............................................................................................................................ 326
Глава 8. Работа в сети ................................................................................................ 327 8.1. Все о сетевой работе в семи абзацах .................................................................... 328 8.2. Создание НТТР GЕТ-запроса с использованием reqwest ................................... 330 8.3. Типажные объекты ................................................................................................. 332 8.3.1. На что способны типажные объекты? ....................................................... 332 8.3.2. Что такое типажные объекты? .................................................................... 333 8.3.3. Создание небольшой ролевой игры: rрg-проект ....................................... 333 8.4. ТСР .......................................................................................................................... 337 8.4.1. Что такое номер порта? ............................................................................... 339 8.4.2. Преобразование имени хоста в IР-адрес .................................................... 339
Оглавление
11
8.5. Способы обработки ошибок, наиболее удобные для помещения в библиотеки .................................................................................................................. 347 8.5.1. Проблема: невозможность возвращения нескольких типов ошибок .................................................................................................................... 347 8.5.2. Заключение в оболочку нижестоящих ошибок путем определения нашего собственного типа ошибки................................................ 351 8.5.3. Фокусы с unwrap() и expect() ...................................................................... 358 8.6. МАС-адреса ............................................................................................................ 358 8.6.1. Создание МАС-адресов ............................................................................... 360 8.7. Реализация конечных автоматов с помощью перечислений ............................. 362 8.8. Чистый ТСР ............................................................................................................ 363 8.9. Создание виртуального сетевого устройства ...................................................... 363 8.1О. «ЧИСТЫЙ)) НТТР.................................................................................................... 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 Тime Protocol (NTP) ................................. 402 9.9.1. Отправка NТР-запросов и интерпретация ответов ................................... 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. WebAssemЬly .............................................................................................. 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. Вывод информации на экран с использованием VGА-совместимого текстового режима .............................................................. 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-сообщества. В ходе разработки материалов книги через live Book поступили тысячи читательских исправлений, вопросов и предложений. И вклад каждого читателя помог мне усовершенствовать текст. Спасибо. Особое чувство благодарности я испытываю к небольшому числу читателей, со многими из которых мы стали друзьями. Это Ай Майга (Ai Maiga), Ана Хобден (Ana Hobden), Эндрю Мередит (Andrew Meredith), Андрей Лесников (Andrey Lesnik6v), Энди Гроув (Andy Grove), Артура Ж. Перес (Arturo J. Perez), Брюс Мит ченер (Bruce Mitchener), Сесиль Тонглет (Cecile Tonglet), Дэниел Карозоне (Daniel Carosone), Эрик Ридж (Eric Ridge), Эстебан Кубер (Esteban Kuber), Флориан Гилчер (Florian Gilcher), Ян Бэттерсби (lan Battersby), Джейн Ласби (Jane Lusby), Хавьер Виола (Javier Viola), Джонатан Тернер (Jonathan Tumer), Лачезар Лечев (Lachezar Lechev), Лучано Маммино (Luciano Mammino), Люк Джоне (Luke Jones), Натали Блумфилд (Natalie Bloomfield), Олександр Каленюк (Oleksandr Kaleniuk), Оливия Ифрим (Olivia Ifrim), Пол Фариа (Paul Faria), Пол Дж. Саймондс (Paul J. Symonds), Филипп Гневош (Philipp Gniewosz), Род Элиас (Rod Elias), Стивен Оутс (Stephen Oates), Стив Клабник (Steve Кlabnik), Таннер Аллард (Tanner Allard), Томас Лакни (Thomas Lockney) и Уильям Браун (William Brown); для меня общение с вами на протяжении последних четырех лет было особой привилегией. Я выражаю сердечную благодарность рецензентам книги, среди которых Афшин Мехрабани (Afshin Mehrabani), Аластер Смит (Alastair Smith), Брайс Дарлинг (Bryce Darling), Кристоффер Финк (Christoffer Fink), Кристофер Хаупт (Christopher Haupt), Дамиан Эстебан (Damian Esteban), Федерико Эрнандес (Federico Hemandez), Герт Ван Лаэтхем (Geert Van Laethem), Джефф Лим (Jeff Lim), Йохан Лизеборн (Johan Lisebom), Джош Коэн (Josh Cohen), Конарк Моди (Konark Modi), Марк Купер (Marc Cooper), Морган Нельсон (Morgan Nelson), Рамнивас Ладдад (Ramnivas Laddad), Риккардо Москетти (Riccardo Moschetti), Санкет Найк (Sanket Naik), Сумант Тамбе (Sumant Tambe), Тим ван Дерзен (Тim van Deurzen), Том Барбер (Tom Barber), Уэйд Джонсон (Wade Johnson), Уильям Браун (William Brown), Уильям Уиллер (William Wheeler) и Ив Дорфсман (Yves Dorfsman). Все ваши комментарии были прочитаны. А многие улучшения на последних этапах работы над книгой стали возможны бла годаря вашим содержательным отзывам.
Особой похвалы за их терпение, профессионализм и позитивный настрой заслужи вают два представителя команды Manning: Элеша Хайд (Elesha Hyde) и Фрэнсис Буран (Frances Buran), которые умело провели книгу через длинную череду черно вых вариантов. Также спасибо всем остальным редакторам разработки, в числе которых Берт Бейте (Bert Bates), Джерри Куч (Jerry Kuch), Михаэла Батинич (Mihaela Batinic), Ребекка Райнхарт (Rebecca Rinehart), Рене ван деп Берг (Rene van den Berg) и Тим ван Дейр зен (Tim van Deurzen). Моя благодарность распространяется также на производст венных редакторов, в числе которых Бенджамин Берг (Benjamin Berg), Дейрдре Хиам (Deirdre Hiam), Дженнифер Хоул (Jennifer Houle) и Пол Уэллс (Paul Wells). В процессе выполнения принятой в Manning программы раннего доступа (МЕАР программы) книга претерпела 16 выпусков, что было бы невозможно без поддерж ки большой группы специалистов. Спасибо вам, Александар Драгосавлевич (Alek sandar Dragosavljevic), Ана Ромак (Ana Romac), Элеонора Гарднер (Eleonor Gardner), Иван Мартинович (lvan Martinovic), Лори Вейдерт (Lori Weidert), Марко Райкович (Marko Rajkovic), Матко Хрватин (Matko Hrvatin), Мехмед Пашич (Meh med Pasic), Мелисса Айс (Melissa Ice), Михаэла Батинич (Mihaela Batinic), Оуэн Ро бертс (Owen Roberts), Радмила Эрцеговац (Radmila Ercegovac) и Рейхана Маркано вич (Rejhana Markanovic). Спасибо также маркетинговой команде в составе Бранко Латинчича (Branko Latin cic), Кэндис Гиллхулли (Candace-Gillhoolley), Коди Танкерсли (Cody Tankersley), Лукаса Вебера (Lucas Weber) и Степана Юрековича (Stjepan Jurekovic). Вы были для меня великим источником поддержки. Отзывчивость и польза чувствовались и от расширенного состава команды Manning. Спасибо вам, Айра Дукжич (Aira Ducic), Эндрю Уолдрон (Andrew Wal dron), Барбара Мирецки (Barbara Mirecki), Бранко Латинчич (Branko Latincic), Бре кин Эли (Breckyn Ely), Кристофер Кауфманн (Christopher Kaufmann), Деннис Да линник (Dennis Dalinnik), Эрин Туи (Erin Twohey), Ян Хаф (lan Hough), Иосип Ма рас (Josip Maras), Джулия Куинн (Julia Quinn), Лапа Класик (Lana Кlasic), Линда Котлярская (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, «Работа в сети», объясняет способы, применяемые компьютерами для обмена данными посредством многократной повторной реализации НТТР с из бавлением при каждом шаге от очередного уровня абстракции. ♦ Глава 9, «Время и хронометраж», исследует процессы отслеживания времени в цифровом компьютере. Примеры включают создание работоспособного NТР клиента. ♦ Глава 10, «Процессы, потоки и контейнеры», объясняет, что такое процессы, потоки и связанные с ними абстракции. Примеры включают создание приложе ния черепашьей графики и средства синтаксического анализа, работающего в режиме параллельных вычислений. ♦ Глава 11, «Ядро операционной системы», дает описание роли операционной системы и способов начальной загрузки компьютеров. Примеры включают ком пиляцию своего собственного загрузчика и ядра операционной системы. ♦ Глава 12, «Сигналы, прерывания и исключения», объясняет порядок связи внешнего мира с центральным процессором и операционными системами. Книга предназначена для последовательного чтения. Предполагается, что во всех последующих главах используются знания, полученные в предыдущих. Но проекты из каждой главы не связаны друг с другом. Поэтому по всем проектам, касающимся интересующих вас тем, можно проходить в произвольном порядке.
О книге
21
О программном коде Примеры кода, приведенные в этой книге, написаны на языке Rust в редакции 2018 го да и протестированы под управлением Windows и UЬuntu Linux. Никакого специ ального программного обеспечения, кроме рабочей установки Rust, не требуется. Инструкции по установке изложены в главе 2. Эта книга содержит множество примеров исходного кода как в пронумерованных листингах, так и в виде обычного текста. В обоих случаях, чтобы исходный код от личался от обычного текста, он отформатирован таким вот шрифтом фиксированной ши риньL Иногда код также выделяется жирным шрифтом, чтобы отметить его фраг менты, изменившиеся по сравнению с предыдущими действиями, рассмотренными в главе, например когда к существующей строке кода добавляется новая функция. Во многих случаях первоначальный исходный код был переформатирован: чтобы он уместился на книжной странице, в нем были переработаны отступы и добавле ны разрывы строк. Но иногда и этого было недостаточно, поэтому в листингах попадаются маркеры продолжения строки(-+). Кроме того, при описании кода в тексте комментарии, имевшиеся в листингах исходного кода, зачастую удаляют ся. Для выделения важных понятий многие листинги сопровождаются примеча ниями к коду.
Дискуссионный форум liveBook Приобретение этой книги открывает свободный доступ к закрытому веб-форуму, запущенному издательством Manning PuЫications, где можно оставлять отзывы о книге, задавать вопросы технического плана и получать помощь от автора и от других пользователей: ♦ Чтобы попасть на форум, перейдите по адресу https://livebook.manning.com/Ьook/rust-in-action/welcome/v-16/. ♦ Дополнительные сведения о форумах, запущенных издательством Manning, и о правилах поведения на них можно получить по адресу https://livebook.manning.сот/#!/discussion. Придерживаясь обязательств, принятых перед читателями, издательство Manning обеспечивает место, где может состояться содержательный диалог между отдель ными читателями, а также между читателями и автором. При этом автор не обязан общаться с каким-то определенным количеством участников форума, поскольку его собственное участие остается добровольным (и неоплачиваемым). Чтобы не потерять авторский интерес к форуму, предлагаем попробовать задать автору не сколько сложных вопросов! Пока книга находится в печати, форум и архивы пре дыдущих обсуждений будут доступны с веб-сайта издателя.
22
О книге
Другие онлайн-ресурсы Тима Макнамару можно найти в социальной сети по метке @timClicks. Его основные каналы - Twitter (https://twitter.com/timclicks), УouTube (https://youtube.com/c/timclicks) и Twitch (https://twitch.tv/timclicks). Также можно свободно подключиться к его Discord cepвepy по адресу https://discord.gg/vZBX2bDa7W.
Об авторе Тим Макнамара (Tim McNamara) учился программировать, чтобы помогать осуще ствлению проектов гуманитарной помощи по всему миру прямо из своего дома в Новой Зеландии. За последние 15 лет Тим стал экспертом в области интеллекту ального анализа текста, обработки естественного языка и обработки данных. Он организатор сообщества Rust Wellington и регулярно проводит учебные занятия по программированию на языке Rust не только в формате живого общения, но и по Интернету через Twitch и УouTube.
Об иллюстрации на обложке книги Рисунок на обложке этой книги имел подпись «Le maitre de chausson» («Мастер та почею)) или «The Ьохеп) («Боксер))). Иллюстрация взята из собрания работ множе ства художников под редакцией Луи Кармера (Louis Curmer), опубликованного в Париже в 1841 году. Коллекция называется «LesFraщ:ais peints par eux-memeю), что переводится как «Французы на собственных рисунках)). Каждая иллюстрация четко прорисована и раскрашена вручную, а богатое разнообразие рисунков, представ ленных в коллекции, живо напоминает нам о том, насколько обособленными в культурном отношении бьmи регионы мира, города, деревни и кварталь1 всего 200 лет назад. Будучи разобщенными, люди говорили на разных диалектах и языках. На улицах или в сельской местности просто по тому, как люди одеты, было легко определить, где они живут и каково их ремесло или социальное положение. С тех пор стиль одежды изменился, и разнообразие по регионам, столь богатое в то время, исчезло. Сейчас трудно отличить друг от друга жителей разных континен тов, не говоря уже о разных городах или регионах. Возможно, мы обменяли куль турное разнообразие на более яркую личную жизнь, проходящую, конечно же, в более разнообразном и быстро развивающемся техническом окружении. В наше время, когда одну компьютерную книгу трудно отличить от другой, изда тельство Manning подчеркивает изобретательность и инициативу компьютерного бизнеса за счет обложек своих книг, показывающих богатое разнообразие регио нальной жизни двухсотлетней давности, оживленное изображениями из таких, как упомянутая здесь, коллекций.
1
Введение в Rust
----------------------1111111111111111111111111111111111111111D\'ll\!11!1!!51l!!lillillllli1laliml�m�
В этой главе рассматриваются следующие вопросы: ♦ Введение в свойства Rust и о том, для чего он создавался. ♦ Разбор синтаксиса Rust. ♦ Где следует, а где не следует применять Rust. ♦ Создание вашей первой программы на Rust. ♦ Сравнение Rust с объектно-ориентированными языками и системами программирования более широкого спектра. Знакомьтесь с Rust - это язык программирования, расширяющий ваши возможно сти. Углубленное знакомство с ним откроет вам не только язык программирования, обладающий невероятной скоростью и безопасностью, но и весьма приятный инст румент для программирования «на каждый день». Приступив к программированию на Rust, вы вряд ли остановитесь. И эта книга, «Rust в действию), поможет вашему становлению в качестве Rust-программиста. Но она не станет учить программированию с нуля. Книга рассчитана на тех, кто рассматривает Rust в качестве своего следующего языка, и на тех, кому нравится освоение практических примеров. Вот наиболее яркие примеры, включенные в эту книгу: •
Визуализация множества Мандельброта.
•
Grер-клон.
•
Эмулятор центрального процессора.
•
Искусство генерации.
•
База данных.
•
Клиенты НТТР, NTP и hexdumps.
•
Интерпретатор языка LOGO.
•
Ядро операционной системы.
Из списка ясно, что книга позволит не только освоить Rust, но и познакомит с сис темным и низкоуровневым программированием. Изучая книгу «Rust в действию), вы сможете узнать о роли операционной системы, о том, как работает процессор, как компьютеры отслеживают время, что такое указатели и что такое тип данных. Вы получите представление о взаимодействии внутренних систем компьютера. Выйдя за рамки изучения синтаксиса Rust, вы также поймете, для чего был создан этот язык и для решения каких задач он предназначен.
24
Глава 1
1.1. Где используется Rust? Ежегодно, с 2016 по 2020 год, в опросе разработчиков в Stack Overflow Rust удо стаивался звания «Самый любимый язык программирования». Видимо, именно по этому Rust был принят ведущими разработчиками компьютерных технологий: •
Amazon Web Services (AWS) пользуется Rust с 2017 года для своих решений по бессерверным вычислениям АWS Lambda и АWS Fargate. Благодаря этому Rust развил свои успехи. Для выпуска своего сервиса Elastic Compute Cloud (ЕС2) компания Amazon создала операционную систему Bottlerocket OS и АWS Nitro System 1•
•
Компания Cloudflare применяет Rust при разработке множества своих сер висов, включая общедоступную DNS, средства бессерверных вычислений и программы по проверке пакетов2•
•
Компания Dropbox применила Rust для перестройки своего внутреннего хранилища данных, управляющего эксабайтами данных3 •
•
Компания Google применяет Rust при разработке таких компонентов An droid, как модуль Bluetooth. Rust также используется для компонента Chrome OS под названием crosvm и играет важную роль в разработке новой операционной системы Google под названием Fuchsia4 •
•
Компания Facebook использует Rust для повышения эффективности работы своих веб-, мобильных и АРI-сервисов, а также для разработки компонен тов HHVM, виртуальной машины HipHop, используемой языком програм мирования Hack5•
•
Компания Microsoft пишет на Rust компоненты своей платформы Azure, включая демон безопасности для своей службы Интернета вещей (loT)6•
•
В Mozilla язык Rust используется для совершенствования веб-браузера Fire fox, база которого содержит 15 миллионов строк кода. Первые два проекта Mozilla Rust-в-Firefox - анализатор метаданных МР4 и система кодирова ния-декодирования текста, позволили повысить общую производительность и стабильность работы браузера.
•
Входящая в GitHub компания npm Inc. использует Rust, чтобы справиться с «более чем 1,3 миллиардов загрузок пакетов в день»7 •
1 См. «How our АWS Rust team will contribute to Rust's future successes)), http://mng.bz/BR4J. 2 См. «Rust at Cloudflare)), https://news.ycombinator.corn/item?id=l 7077358. 3
См. «The Epic Story ofDropbox's Exodus From the Amazon C\oud Empire)), http://mng.bz/d45Q. 4 См. «Goog\e joins the Rust Foundation)), http://mng.bz/ryOX. 5
См. «ННVМ 4.20.0 and 4.20. \ )), https://hhvm.corn/Ыog/2019/08/27/hhvm-4.20.0.html. 6 См. https://github.corn/Azure/iotedge/tree/master/edgelet. 7 См. «Rust Case Study: Community makes Rust an easy choice for npm)), http://mng.bz/xm9B.
Введение в Rust
•
25
Компания Oracle для устранения проблем с реализацией ссылок в Go разра ботала с применением Rust контейнерную среду выполнения8 •
•
Компания Samsung через свою дочернюю компанию SmartThings использу ет Rust в своем подразделении по разработке серверной части прошивки для сервиса Интернета вещей (loT). Производительности Rust также вполне хватает для развертывания динамично раз вивающихся стартапов. Вот несколько примеров: • Sourcegraph использует Rust для подсветки синтаксиса на всех своих языках9• •
Figma использует Rust в самых важных для производительности компонен тах своего многопользовательского сервера 10•
•
Parity использует Rust для разработки клиентской части блокчейна Ethereum 1 1•
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 в среду разработки Chrorne OS стоило немалого труда. Неоценимую помощь в этом оказали разработчики Rust, которые давали исчерпывающие ответы на все мои вопросы. ekidd on Sept 27, 2017 > Склонить моих сотрудников к применению Rust в конкретном > компоненте удалось после того, как я убедил их, что никакой > другой язык не будет лучше его для решения данной задачи, > в чем сам я нисколько не сомневался. См. «Building а Container Runtime in Rust», http://nшg.bz/d40Q. 9 См. «НТТР code syntax highlighting server written in Rust», https://github.com/sourcegraph/syntect_server. 10 См « . Rust in Production at Figma», https://www.figma.com/Ьlog/rust-in-production-at-figma/. 1 1 См «T . he fast, light, and robust EVM and WASM client», https://github.com/paritytech/parity-ethereum. 1 2 См. «C hrome OS KVM - А component written in Rust», https://news.ycombinator.com/item?id= 15346557. 8
26
Глава 1
У меня похожий случай был с одним из моих проектов - с декодером субтитров vobsuЬ, обеспечивавшим парсинг сложных бинарных данных, который я планировал со временем запустить в виде веб-сервиса. Естественно, я хотел убедиться в отсутствии слабых мест в моем коде. Код был написан на Rust, а затем я воспользовался командой 'cargo fuzz' для прогона программы и выявления недочетов. После прохода миллиарда (!) fuzz-итераций я обнаружип 5 дефектов (см. раздел 'vobsub' в trophy case for а list по адресу https://github.com/rust-fuzz/trophy-case). К счастью, ни один из этих дефектов не мог вылиться в реальный эксплойт. Для каждого из них Rust успешно выявлял проблему и переводил ее в состояние управляемой паники. (При практическом применении это привело бы к полному перезапуску веб-сервера.) В результате я пришел к выводу: если мне понадобится язык, во-первых, без сборки мусора, но, во-вторых, которому я смогу доверять в ситуации, требующей особых мер безопасности, то Rust - прекрасный выбор. Возможность статической компоновки двоичных файлов (как с Go) - большой плюс. Manishearth оп Sept 27, 2017
> К счастью, ни один из этих дефектов не мог обернуться > лазейкой для хакера. Для каждого из них Rust успешно выявлял > проблему и переводил ее в управляемый сигнал тревоги.
Если кому-то интересно, то у нас таюке есть некоторый опыт автоматического тестирования безопасности (фаззинга)кода в firefox. Фаззинг позволяет выявить массу тревожных моментов (и предпосылок для отладки или для «безопасного» переполнения). Однажды им был выявлен дефект в аналогичном коде Gecko, остававшийся незамеченным около десяти лет.
Из данного фрагмента можно понять, что признание языка шло в восходящем на правлении усилиями специалистов, стремящихся преодолеть технические трудно сти в относительно небольших проектах. Затем опыт, накапливаемый за счет слу чаев успешного применения нового языка, использовался в качестве обоснования более амбициозных работ.
За период с конца 201 7 года Rust продолжал совершенствоваться и укреплять свои позиции. Он стал общепринятой составляющей технологического ландшафта Google, и теперь уже является официально допущенным языком в операционных системах Android и Fuchsia.
1.3. Вкус языка Этот раздел позволит вам немного распробовать работу с Rust. В нем будет показан порядок использования компилятора с переходом к созданию простой программы. А в следующих главах будут рассмотрены полноценные проекты. ПРИМЕЧАНИЕ Для установки Rust нужно воспользоваться официальными установщиками, предос тавленными по адресу https://rustup.rs/.
Введение в Rust
27
1.3.1. Хитрый путь к «Hello, world!» Первым делом, осваивая новый язык, большинство программистов учатся выводить на консоль приветствие «Hello, world!)). Вы не станете исключением, но сделаете это особым образом. Чтобы убедиться, что все находится в рабочем состоянии, нужно будет выявить досадные синтаксические ошибки. Если работа ведется под Windows, откройте командную строку Rust, доступную после установки Rust в меню Пуск, и запустите на выполнение следуюшую команду: С:\> cd %ТМР%
Если вы работаете под Linux или macOS, откройте окно Terminal. Запустите в нем следующую команду: $ cd $ТМР
Далее команды для всех операционных систем будут одинаковыми. При правиль ной установке Rust следующие три команды позволят отобразить на экране привет ствие «Hello, world!)) (а также множество других выходных данных): $ carqo new hello $ cd hello $ carqo run
Посмотрим, как выглядит весь сеанс при запуске cmd.exe под MS Windows: С:\> cd %ТМР% 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 $ТМР $ carqo new hello Created Ьinary (application) 'hello' packaqe $ cd hello $ carqo run
Compiling hello v0.1.0 (/tmp/hello) Finished dev [unoptimized + debuginfo] target(s) in 0.26s Running 'target/debug/hello' Hello, world!
Если все получилось, вас можно поздравить! Только что запущен ваш первый Rust кoд без всякого программирования на Rust. Посмотрим, как это получилось.
28
Глава 1
Имеющийся в Rust инструмент под названием cargo предоставляет как систему сборки, так и диспетчер пакетов. То есть, cargo знает, как превратить ваш Rust-кoд в исполняемые двоичные файлы, а также может управлять процессом загрузки и компиляции проектных зависимостей. cargo new создает для вас проект, который построен по стандартному шаблону. Команда tree может показать исходную структуру проекта и файлы, созданные после ввода команды cargo new: $ tree hello
hello � Cargo . tornl L src L rnain.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 и каталог targeU. Оба они управляются инструментальным средством cargo. Поскольку это артефак ты процесса компиляции, изучать их не нужно. В Cargo.lock указываются конкрет ные номера версий всех зависимостей, чтобы будущие сборки составлялись точно также, как и эта, пока содержимое Cargo.toml не изменится. Повторный запуск команды tree открывает новую структуру, созданную вызовом cargo run для компиляции проекта hello: $ tree --dirsfirst hello
hello � src L rnain.rs 1 � target L debug j � build 1 � deps 1 � exarnples 1
Введение в Rust
1
f--- native L_
f--- Cargo.lock L_
29
hello
Cargo.toml
Если все именно так и получилось, значит, вы молодец! После освоения хитрого способа получения «Hello, World!» давайте добьемся того же результата более длинным путем.
1.3.2. Ваша первая программа на Rust Для нашей первой программы нужно написать код, выводящий на экран следую щий текст на нескольких языках: Hello, world! Gri.Ш Gott! /\□-. 'J-JI., t,:'
Первая строчка наверняка вам уже знакома. А две другие здесь, чтобы подчеркнуть ряд свойств, присущих языку Rust: легкую итерацию и встроенную поддержку Unicode. Для создания этой программы мы, как и раньше, воспользуемся cargo. Выполните следующие действия: 1. Откройте командную строку консоли. 2. Запустите команду cd %ТМР% под MS Windows или же команду cd $ТМР под другой ос. 3. Запустите для создания нового проекта команду cargo new hello2.
4. Запустите команду cd hel lo2 для перехода в корневой каталог проекта. 5. Откройте в текстовом редакторе файл src/main.rs. 6. Замените содержимое этого файла текстом, показанным в листинге 1.1. Код следующего листинга имеется в хранилище исходного кода. Откройте файл ch1/ch1 hello2/src/hello2.rs.
1 fn greet_world() { 2 println! ("Hello, world!"); 3 let southern_germany = "GriiB Gott!"; 4 let japan = 11/\ □- • 'J-JI., t,:' 11 ; let regions = [southern_germany, japan]; 5 for region in regions.iter() { 6 7 println! (" {) ", ®ion); 8
9 10 11 fn main()
(1) (2)
(3) (4) (5) (6)
30
Глава 1 greet_world() ;
12 13
(7)
(1) Восклицательный: знак свидетельствует об использовании макроса, с чем мы вскоре разберемся. (2) Для операции присваивания в Rust, которую правильнее было бы назвать привязкой переменной, используется ключевое слово let. (3) Подцержка Unicode предоставляется изначально самим языком. (4) Для литералов массива используются квадратные скобки. (5) Для возврата итератора метод iter() может присутствовать во многих типах. (6) Амперсанд «заимствует» region так, чтобы доступ предоставлялся только для чтения. (7) Вызов функции. Обратите внимание на круглые скобки, следуIОЩИе за именем функции.
Теперь, когда у src/main.rs новое содержимое, запустите из каталога hello2/ команду cargo run. После ряда выходных данных, сгенерированных самим cargo, вы увиди те появление трех приветствий: $ cargo run
Compiling hello2 v0.1.0 (/path/to/chl/chl-hello2) Finished dev [unoptimized + debuginfo] target(s) in 0.95s Running 'target/debug/hello2' Hello, world! Gri..iB Gott!
/\□-.
'J-JI, �
Давайте уделим пару минут разбору ряда интересных моментов в коде Rust, пока занном в листинге 1.2. Первым делом можно было бы заметить, что строки в Rust могут содержать весьма широкий диапазон символов. Строки гарантированно получат кодировку UTF-8. Значит, вам будет относительно несложно воспользоваться не только английским языком. Единственным символом, который может показаться неуместным, будет восклица тельный знак после println. Привыкшим к языку Ruby он покажется признаком операции деструкции. В Rust он сигнализирует об использовании макроса. Пока макросы можно считать просто какими-то необычными функциями. Они позволя ют избежать применения шаблонного кода. В данном случае применяется макрос prin tln ! , выполняющий свои внутренние операции определения типов, что позво ляет выводить на экран произвольные типы данных.
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 и не относится к объектно-ориентирован ным языкам, поскольку не поддерживает наследование, у него все же при сутствует данная особенность. •
Програ;wмирование функций высшего порядка - функции могут как при нимать, так и возвращать функции. Например, строка 19 (.map( 1 field 1 field.trim())) включает замкнутое выражение, известное также как безымян ная функция или лямбда-функция.
•
Сигнатуры типов - они встречаются редко, но все же иногда нужны в ка честве подсказки компилятору (посмотрите, к примеру, на строку 27, начи нающуюся с if let Ok (length) ).
•
Условная компиляция - имеющиеся в листинге строки 21-24 ( if cfg ! не включаются в сборки конечных версий программы.
•
Подразумеваемое возвращение - Rust предоставляет ключевое слово return, но оно обычно опускается. Rust - язык, основанный на выражениях.
1 fn rnain() { let penguin_data = "\ comrnon narne,length (crn) 3 4 Little penguin,33 5 Yellow-eyed penguin,65 Fiordland penguin,60 6 7 Invalid, data
(1)
2
8
9
10 11 12 13
(2)
"·
let records = penguin_data.lines(); for (i, record) in records.enurnerate() if i == О 11 record.trirn() .len()
о {
(3)
( ... );)
Глава 1
32 continue;
14 15 16 17 18 19 20 21 22 23
let fields: Vec< > = record . split ( ' , ' ) . map ( 1 field I field. trim ()) . collect (); if cfg! (debug_assertions) { eprintln! ("debug: { :?) -> { :?I", record, fields);
24 25 26 27 28 29 30 31
let name = fields[0]; if let Ok(length) = fields[l].parse::() println ! (" { 1, {lcm", name, length);
(4) (5) (6) (7) (8)
(9)
(10) (11)
(1) Исполняемым проектам требуется функция main () (2) Отключение завершающего символа новой строки (3) Пропуск строки заголовка и строк, состоящих из одних пробелов (4) Начало со строки текста (5) Разбиение записи на поля (6) Обрезка пробелов в каждом поле (7) Сборка набора полей (8) cfg! проверяет конфигурацию в процессе компиляции. (9) eprintln! выводит данные на стандартное устройство сообщений об ошибках (stderr) (10) Попытка выполнения парсинга поля в виде числа с плавающей точкой (11) println! помещает данные на стандартное устройство вывода (stdout).
Кому-то из читателей листинг 1.2 может показаться странным, особенно если им еще не приходилось сталкиваться с кодом на Rust. Перед тем, как продолжить, сле дует кое-что пояснить: •
В строке 17 переменная f i e l ds помечена типом vec. vec - сокращение от _vector_, типа коллекции, способного динамически расширяться. Знак подчеркивания (_) предписывает Rust вывести тип элементов.
•
В строках 22 и 28 Rust получает предписание по выводу информации на консоль. Макрос println! выводит свои аргументы на стандартное устрой ство вывода (stdout), а макрос eprintln ! делает то же самое на стандартное устройство для сообщения об ошибках (stdeп).
•
Макросы похожи на функции, но вместо возвращения данных они возвра щают код. Макросы часто используются для упрощения общеупотреби-
33
Введение в Rust
тельных шаблонов. Для управления своим выводом макросы eprintln ! и pr int ln ! используют в качестве первого аргумента строковый литерал со встроенным миниязыком. Поле заполнения ! } заставляет Rust воспользо ваться методом представления значения в виде строки, который определил программист, а не представлением по умолчанию, доступным при указании поля заполнителя ! : ? } • •
•
•
В строке 27 содержится ряд новых элементов. if l et ok (length) читается как «попытаться разобрать f ie l ds [ 1 J в виде 32-разрядного числа с плавающей точкой, и в случае успеха присво ить число переменной l ength)). field s [ 1 J • parse::()
Конструкция if let - краткий метод обработки данных, предоставляю щий также локальную переменную, которой присваиваются эти данные. Метод parse () возвращает Ok(Т) (где т означает любой тип), если ему уда ется провести разбор строки; в противном случае он возвращает Err (Е) (где Е означает тип ошибки). Применение if let Ok(Т) позволяет пропустить любые случаи ошибок, подобные той, что встречается при обработке строки Invalid , data.
Когда Rust не способен вывести тип из окружающего контекста, он запра шивает конкретное указание. В вызов parse() включается встроенная анно тация типа в виде parse:: ( ). Преобразование исходного кода в исполняемый файл называется комnШlяцией. Чтобы скомпилировать Rust-кoд, нужно установить компилятор Rust и запустить его в отношении исходного кода. Для компиляции кода листинга 1.2 выполните следующее: 1. Откройте командную строку консоли (например, cmd.exe, PowerShell, Terminal или Alacritty). 2. Перейдите в каталог ch1/ch1-penguins (но не в загруженного вами в разделе 1.4.
ch1/ch1-penguins/src)
исходного кода,
3. Запустите на выполнение команду cargo run. Выведенная этой командой ин формация показана в следующем фрагменте кода: $ carqo run
Compiling chl-penguins v0.1.0 (.. /code/chl/chl-penguins) Finished dev [unopt imized + debuginfo ] target(s) in 0.40s Running 'target/debug/chl-penguins' dЬg: " Little penguin,33" -> ["Little penguin", "33"] Little penguin, 33cm dЬg: " Yell ow-eyed penguin,65" -> ["Yellow-eyed penguin", "65"] Yell ow-eyed penguin, 65cm dЬg: " Fiordland penguin, 60" -> ["Fio rdland penguin", "60"] Fiordland penguin, 60cm dЬg: " Inval id ,data" -> ["Invalid", "data"]
34
Глава 1
Наверное, вы заметили какие-то непонятные строки, начинающиеся с метки dbg:? Их можно убрать, компилируя выходную сборку с имеющимся в cargo флагом - r elease. Эта функция условной компиляции обеспечивается блоком cfg ! (debug_as s e r t ions) ( ... 1 в строках 22-24 листинга 1.2. Сборки конечных версий выполняются намного быстрее, но компилируются гораздо дольше: $ cargo run --release
Cornpiling chl-penguins v0.1.0 ( .../code/chl/chl-penguins) Finished release [optirnized) target(s) in 0.34s Running 'target/release/chl-penguins' Little penguin, 33cm Yellow-eyed penguin, 65cm Fiordland penguin, 60cm
Выводимую информацию можно сделать еще короче, добавив к команде c argo флаг -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 а safer systems programming lan guage)), http://mng.bzNdN5, и в статье «Memory safety)), http://mng.bz/xm7B for more information.
35
Введение в Rust
Профессиональное сообщество Rust славится стремлением учитывать в процессе принятия решений все самые ценные идеи. Этот дух всеобщей причастности витает в сообществе повсеместно. Открытые сообщения всецело приветствуются. Весь обмен информацией внутри Rust-сообщества регулируется принятыми в нем этиче скими нормами. Даже сообщения об ошибках, выдаваемые компилятором Rust, на удивление содержательны.
Rust
"',::
Большинство языков действуют в пределах этого диапазона. Rust предоставляет и безопасность. и возможность управления. Возможность управления
Рис. 1.1. Rust обеспечивает как безопасность, так и возможность управления. Другим языкам приходится балансировать между этих двух показателей.
До конца 2018 года на главной странице Rust посетителей встречало сообщение: «Rust - язык системного программирования, работающий поразительно быстро, не допускающий ошибок сегментации и гарантирующий безопасность потоков)). Затем сообщество изменило эту формулировку, сосредоточившись на интересах своих действующих и потенциальных пользователей (см. табл. 1.1 ). Таблица 1.1. Времена меняются, и слоган Rust меняется вместе с ними. По мере того, как укреплялись позиции языка Rust, в нем все активнее проступала такая идея: пусть этот язык способствует устремлениям тех программистов, которые ставят перед собой самые амбициозные цели До конца 2018 года
Затем
«Rust - язык системного программирования, работающий невероятно быстро, не допускающий ошибок сегментации и гарантирующий безопасность потокою).
«Язык, позволяющий каждому создавать надежное и эффективное программное обеспечение)).
Rust позиционируется как язык системного программирования, которое, как прави ло, рассматривается в качестве особой ветви программирования, являющейся уде лом узкого круга специалистов. Но многие Rust-программисты поняли, что его можно применять ко многим другим областям. Безопасность, производительность и
Глава 1
36
управляемость пригодятся во всех проектах разработки программного обеспечения. Более того, всеохватность, присущая Rust-сообществу, означает, что язык выигры вает от постоянного притока новых участников с различными интересами. Давайте конкретизируем три уже упомянутые цели создания языка: безопасность, продуктивность и управляемость. Что под ними понимается, и в чем их важность?
1.6.1. Цель создания Rust: безопасность В Rust-программах отсутствуют • Висячие указатели - прямые ссылки на данные, ставшие недействитель ными в ходе выполнения программы (см. листинг 1.3). • Состояния гонки - неспособность из-за изменения внешних факторов опре делить, как программа будет вести себя от запуска к запуску (см. листинг 1.4). • Переполнение буфера - попытка обращения к 12-му элементу массива, со стоящего из 6 элементов (см. листинг 1.5). • Недействительность итератора - проблема, вызываемая проходом како го-то объекта-итератора, претерпевшего изменение уже в ходе итерации (см. листинг 1.6). Когда программа компилируется в режиме отладки, Rust также обеспечивает защи ту от целочисленного переполнения. В чем его суть? Дело в том, что целые числа могут представлять только конечный набор чисел, поскольку имеют в памяти фик сированную ширину. Целочисленное переполнение происходит, когда целочислен ные значения упираются в свой предел и снова переходят к началу своего ряда. В следующем примере показан висячий указатель. Исходный код листинга нахо дится в файле ch1/ch1-cereals/src/main.rs.
1 #[derive(Debug)] 2 enurn Cereal { 3 Barley, Millet, Rice, 4 Rye, Spelt, Wheat,
(1) (2)
5
6
7 fn 8 9 10
11 12
main() let mut grains: Vec = vec! []; grains.push(Cereal::Rye); drop(grains); println! ("{ :?}", grains};
(3) (4) (5)
(6)
(1) Разрешение макросу println! вывести перечисление Cereal (2) enurn (перечисление) - тип с фиксированным количеством допустимых вариантов
37
Введение в Rust
(3) (4) (5) (6)
Инициализация пустого вектора Cereal Добавление элемента к вектору grains Удаление grains и его содержимого Попытка обращения к значению, которое уже удалено
В листинге 1.3 в g rains имеется указатель, созданный в строке 8. Вектор реализован с внутренним указателем на основной массив. Но код лис тинга не проходит компиляцию. При ее попытке выдается сообщение об ошибке с жалобой на попытку «позаимствовать» «перемещенное» значение. Интерпретация этого сообщения об ошибке и способ ее исправления будут рассмотрены далее. А сейчас посмотрим на то, что было выведено на экран при попытке скомпилиро вать код листинга 1.3:
vec
$ cargo run
Compiling chl-cereals v0.1.0 (/rust-in-action/code/chl/chl-cereals) (1) error[E0382]: borrow of moved value: grains --> src/main.rs:12:22 В
let mut grains: Vec = vec! []; ------- move occurs because 'grains' has type 'std::vec::Vec', which does not implement the 'Сору' trait 9 1 grains.push(Cereal::Rye); 101 drop(grains); ------ value moved here 1
111
(2)
(3)
121 println ! (" {:?) ", grains); лллллл value borrowed here after move (4) 1 error: aborting due to previous error (5) For more information about this error, try 'rustc --explain Е0382'. error: could not compile 'chl-cereals'. (6) (1) ошибка[Е0382]: заимствование перемещенного значения: grains (2) перемещение произошло, потому что у grains тип 'std::vec::Vec', в котором не реализован типаж 'Сору' (3) значение перемещено сюда (4) значение заимствовано здесь после перемещения (5) ошибка: прервано из-за предьщущей ошибки (6) Дополнительную информацию об ошибке можно получить, запустив команду 'rustc - explain Е0382' ошибка: скомпилировать 'chl-cereals' невозможно.
В листинге 1.4 показан пример состояния гонки. Если помните, это состояние воз никает, когда из-за изменений внешних факторов невозможно определить характер поведения программы от запуска к запуску. Исходный код листинга находится в файле ch1/ch1-race/src/main.rs.
Глава 1
38
1 use std::thread; 2 fn main() { 3 let mut data
4
5 6 7 8
(1) =
100;
thread:: spawn ( 11 { data thread::spawn(I I { data println ! (" {)", data);
=
500; }); 1000; });
(2) (2)
( 1) Сведение многопоточности к локальной области видимости (2) thread::spawn () принимает в качестве аргумента замыкание
Если термин поток (thread) вам неизвестен, то суть в том, что данный код не явля ется детерминированным. Узнать, какое значение будет у data при выходе из функции maiп (), невозможно. В строках 6 и 7 при вызове метода thread: : spawn () создаются два потока. Каждый вызов получает в качестве аргумента замыкание, обозначенное вертикальными линиями и фигурными скобками (например, 1 1 { ... ) ). Поток, порожденный в строке 5, пытается установить для переменной dat a значе ние 5 о о, а поток, порожденный в строке 6, пытается установить для переменной data значение 1 000. Поскольку диспетчеризация потоков определяется не про граммой, а операционной системой, невозможно узнать, будет ли первым запущен тот поток, который был первым определен. Попытка компиляции кода из листинга 1.4 приводит к целому ряду тревожных со общений об ошибках. Rust не позволяет иметь допуск по записи к данным сразу из нескольких мест приложения. Код пытается позволить себе это в трех местах: когда в основном потоке запускается main ( J, и по одному разу в каждом дочернем пото ке, порожденном вызовом thread: : s p awn (). Сообщения, выданные компилятором, выглядят следующим образом: $ cargo run Compiling chl-race v0.1.0 (rust-in-action/code/chl/chl-race) error[E0373]: closure may outlive the current function, but it borrows 'data', which is owned Ьу the current function --> src/main.rs:6:19 6
thread::spawn(I I { data = 500; )); ---- 'data' is borrowed here
(1)
(2)
тау outlive borrowed value 'data' note: function requires argument type to outlive 'static' --> src/main.rs:6:5
(3)
39
Введение в Rust
6 1 thread::spawn {11 { data = 500; } ) ; I
ллллллллллллллллллллллллллллллллл
help: to force the closure to take ownership of 'data' {and any other referenced variaЬles), use the 'move' keyword 6
(4)
thread: :spawn {move 11 { data = 500; ) ) ; (5)
(6) error: aЬorting due to 4 previous errors (7) Some errors have detailed explanations: Е0373, Е0499, Е0502, For more information aЬout ал error, try 'rustc --explain Е0373'. error: could not compile 'chl-race'.
(1) Компиляция chl-race v0.1.0 {rust-in-action/code/chl/chl-race) ошибка [Е0373]: замыкание может прожить дольше текущей функции, но оно заимствует переменную 'data', принадлежащую текущей функции (2) 'data', заимствованная здесь, может пережить заимствованное значение 'data' (3) Примечание: чтобы пережить 'static', функции нужен тип аргумента (4) Подсказка: чтобы заставить замыкание завладеть 'data' {и любой другой с=очной переменной), воспользуйтесь ключевым словом 'move { 5) Еше три ошибки опущены (6) Ошибка: прервано из-за четырех предьmущих ошибок (7) У некоторых ошибок имеются подробные объяснения: Е0373, Е0499, Е0502. Дополнительную информацию об ошибке можно получить, запустив команду 'rustc explain Е0373' ошибка: скомпилировать 'chl-race' невозможно
В листинге 1.5 показан пример переполнения буфера. Им описывается ситуация, при которой совершается попытка обращения к несуществующему или некоррект ному элементу памяти. В данном случае предпринимается попытка обращения к f r uit [ 4 J, приводящая к сбою программы, поскольку в переменной f rui t содер жится только три фрукта. Исходный код листинга находится в файле ch1/ch1fruit/src/main.rs.
1 fn main {) { let fruit = vec! [' 1 , 1 1 1 ']; 2 3 let buffer overflow = fruit[4]; 4 5 assert_eq! {buffer_overflow, 1 ') 6
(1) (2)
(1) В Rust вместо того, чтобы переменной было присвоено неправильное место в памяти, произойдет сбой компиляции (2) assert_eq! {) проверяет равенство аргументов.
Глава 1
40
При компиляции кода листинга 1.5 будет получено следующее сообщение об ошибке: $ cargo run Compiling chl-fruit v0.1.0 (/rust-in-action/code/chl/chl-fruit) Finished dev [unoptimized + debuginfo] target(s) in 0.31s Running 'target/debug/chl-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=l' environment variaЬle to display а backtrace (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'
(1)
В следующем листинге показан пример недействительности итератора, получив шейся при проходе какого-то объекта-итератора, претерпевшего изменение уже в ходе итерации. Исходный код листинга находится в файле ch1/ch1-letteгs/src/main.rs.
1 fn main() { 2 let mut letters = vec! [ "а", "Ь",
3
4 5 6 7 8 9
11
с"
(1)
]; for letter in letters ( println ! (" (} ", letter); letters.push(letter.clone());
(2)
10 (1) Создание изменяемого вектора букв (2) Копирование каждой буквы и добавление ее к концу вектора letters
Код листинга 1.6 не проходит компиляцию, поскольку Rust не позволяет перемен ной l e tters изменяться в блоке итерации. Сообщение об ошибке выглядит так: $ carqo run
Compiling chl-letters v0.1.0 (/rust-in-action/code/chl/chl-letters) (1) error[E0382]: borrow of moved value: 'letters' --> src/main.rs:8:7
Введение в Rust
2
let rnut letters = vec! [ ----------- rnove occurs because 'letters' has type 'std::vec::Vec', which does not (2) irnplernent the 'Сору' trait
6
for letter in letters
41
'letters' rnoved due to this implicit call to ',into_iter()' help: consider borrowing to avoid rnoving (3) into the for loop: '&letters' 7 println ! ( 11 { } 11, letter) ; 8 letters.push(letter.clone()); ллллллл value borrowed here after rnove er or: aЬorting due to previous error For rnore inforrnation about this error, try 'rustc --explain Е0382'. error: could not cornpile 'chl-letters'. (4) То learn rnore, run the cornrnand again with --verbose. (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 {) let а = 10; 2 3 if а = 10 { 4 5 println! ("а equals ten"); 6 7
В Rust предыдущий код не пройдет компиляцию. Компилятор Rust выдаст сле дующее сообщение: error[E0308]: mismatched types --> src/main.rs:4:8 4
if а
=
(1)
10
expected 'bool', found '{)' (2) help: try comparing for equality: а == 10' error: aborting due to previous error For more information aЬout this error, try 'rustc --explain ЕОЗОВ'. error: could not compile 'playground'. То learn more, run the command again with --verbose. (3) ошибка[ЕОЗОВ]: несоответствующие типы (1) ожидался 'bool', встретился '()'. Подсказка: попробуйте проверить на равенство: 'а == 10' (2) ошибка: прервано из-за предыдущей ошибки. Дополнительную информацию об ошибке можно получить, запустив команду 'rustc --explain Е0308'. Ошибка: скомпипировать 'playground' невозможно.
Поначалу сообщение об ошибке «несоответствующие ТИПЫ)) может показаться странным. Ведь можно же проверить переменные на равенство целым числам. Но если вдуматься, можно понять, почему тест i f получает неверный тип. Условие i f не получает целое число. Оно получает результат присваивания. В Rust это пус той тип: () (произносится как юнит [unit]) 14 •
Название «uпit)) раскрывает часть наследства Rust, являющегося потомком семейства языков программирования ML, включающего OCaml и F#. Это понятие имеет математические корни. Теоретически единственное значение имеется только у типа unit. Сравните его с булевым типом, у которого два значения, true или false, или же со строковым типом, у которого бесконечное количество допустимых значений. 14
43
Введение в Rust
Когда нет никакого другого значимого возвращаемого значения, выражение воз вращает () . Как показано в следующем фрагменте кода, в результате добавления в строке 4 второго знака равенства получается работоспособная программа, которая выводит на экран а equals ten: l fn main () 2 let а
3 4
=
10;
if а == 10 { println! ("а equals ten");
5 6 7
(1)
Использование допустимого оператора равенства (==) позволяет программе пройти компиляцию. В языке 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
Даже если эти понятия вам не известны, продолжайте чтение. Объяснения последуют дальше. Это особенности языка, которые могут не встречаться в других языках программирования.
Глава 1
44
бывает полезно создать свой собственный тип указателя. Rust открывает широкий простор для проектирования и предоставляет инструменты, позволяющие реализо вать наиболее предпочтительное решение. ПРИМЕЧАНИЕ
Если понятия стека, кучи и подсчета ссылок вам еще не известны, не бросайте кни гу! Далее в ней будет выделено достаточно места для объяснения сути и порядка их совместного применения.
Код листинга 1.7 выводит строку а: 10, Ь: 20, с: 30, d: Mutex { data: 40 ). Во всех представлениях используются разные способы хранения целочисленных значений. По мере изучения следующих нескольких глав станут понятны все плю сы и минусы каждого способа. А сейчас важно запомнить, что перечень способов весьма обширен. Вы можете свободно выбирать именно тот тип, который больше всего подходит для вашего конкретного случая. В листинге 1.7 также показаны несколько способов создания целочисленных значе ний. Каждой формой предоставляется различная семантика и характеристики вре мени выполнения. Но программисты могут полностью сохранять контроль над всеми избранными компромиссами.
1 use std::rc::Rc; 2 use std::sync::{Arc, Mutex); 3 4 fn main () { let а = 10; 5 (1) 6 let Ь = Box::new(20); (2) 7 let с = Rc::new(Box::new(З0)); (3) let d = Arc::new(Mutex::new(40)); 8 (4) println! ("а: {:?), Ь: {:?), с: {:?), d: {:?)", 9 10 ( 1) (2) (3) (4)
а, Ь, с,
d);
Целое число в стеке Целое число в куче, также именуемое «упакованное целое число» Упакованное целое число, завернутое в счетчик ссыпок Целое число, завернутое в атомарный счетчик ссыпок и защищенное блокировкой взаимного исключения
Чтобы понять, почему Rust что-то делает так, а не иначе, будет, наверное, полезно обратиться к следующим трем принципиальным положениям: • Главный приоритет языка - безопасность. •
По умолчанию данные в Rust являются неизменяемыми.
•
Настоятельно рекомендуется проводить проверки в ходе компиляции. Безо пасность должна быть «абстракцией с нулевыми затратами)).
Введение в Rust
45
1. 7. Особые возможности Rust Мы считаем, что результат работы предопределяется используемыми инструмен тами. Rust позволяет создавать программные продукты, которые вы хотели бы, но боялись создавать. Так к каким же инструментам относится Rust? Из трех принци пиальных положений, рассмотренных в предыдущем разделе, можно вывести три наиболее значимые возможности языка: • Достижение высокой производительности. •
Выполнение одновременных (параллельных) вычислений.
•
Достижение эффективной работы с памятью.
1.7.1. Достижение высокой производительности Rust позволяет воспользоваться всей доступной производительностью вашего ком пьютера. Он знаменит тем, что не использует для обеспечения безопасности памяти сборщик мусора. К сожалению, обещание более быстрых программ упирается в фиксированную ско рость вашего центрального процессора. Поэтому, чтобы программы выполнялись быстрее, нужно уменьшить объем их работы. А между тем много места во всех смыслах занимает сам язык. Чтобы разрешить данное противоречие, Rust всецело полагается на компилятор. Сообщество Rust отдает предпочтение более объемному языку с компилятором, выполняющим больший объем работы, а не простому языку, где компилятор вы полняет меньший объем работы. Компилятор Rust тщательно оптимизирует как размер, так и скорость работы вашей программы. А еще в Rust применяется ряд менее заметных приемов: • Предоставление по умолчанию удобных для кэширования структур данных. Массивы обычно содержат данные в Rust-программах, а не в древовидных структурах с глубоким вложением, созданных с помощью указателей. Это называется программированием, ориентированным на данные. • Доступность современного диспетчера пакетов (cargo), упрощающего использование десятков тысяч пакетов с открытым исходным кодом. В этом смысле согласованность у С и С++ оставляет желать лучшего, и соз дание крупных проектов с массой зависимостей на этих языках обычно за труднено. •
Неизменная статическая диспетчеризация методов, пока не будет явного запроса на динамическую диспетчеризацию. Это позволяет компилятору проводить сильную оптимизацию кода, иногда вплоть до полного устране ния издержек на вызов функции.
Глава 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[OuS; 1024]; read_secrets(&userl, 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, uint8_t *signature, 5 Uintl6 signatureLen) 6 7{ 8 OSStatus err; (1) 9
10 11 if ((err = SSLHashSНAl. update( &hashCtx, &serverRandom)) != О) 12 (2) goto fail; 13 14 15 if ((err = SSLHashSНAl.update(&hashCtx, &signedParams)) != О) 16 goto fail; goto fail; 17 (3) if ((err = SSLHashSНAl.final(&hashCtx, &hashOut)) != О) 18 19 goto fail; 20 err = sslRawVerify(ctx, 21 22 ctx->peerPubKey, 23 dataToSign, /* plaintext \*/ 24 dataToSignLen, /* plaintext length \*/ 25 signature, 26 signatureLen); if (err) ( 27 28 sslErrorLog("SSLDecodeSignedServerKeyExchange: sslRawVerify " 29 "returned %d\n", (int)err); goto fail; 30 31 32 33 fail: 34 SSLFreeBuffer(&signedHashes); SSLFreeBuffer(&hashCtx); 35 return err; 36 (4) 37 (1) Инициализация OSStatus проходным значением (например, О) (2) Серия защитных программных проверок 18
Оригинал доступен по адресу http://mng.bz/RК.Gj.
(3) Безусловный переход пропускает SSLHashSНAl.final() и (важный:) вызов sslRawVerify(). (4) Возврат проходного значения О даже для входных данных, которые бы провалили проверочный тест
Проблема в коде примера находится между строк 15 и 17. В языке С логические тесты не требуют фигурных скобок. Компиляторы С интерпретируют эти три стро ки следующим образом: if ((err = SSLHashSНAl.update(&hashCtx, &signedParams)) != О) { 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.
Глава 1
52
чать, что в сети окажутся многие миллиарды потенциально незащищенных вещей. Любой входной код синтаксического анализа будет регулярно проверяться на на личие слабых мест. Учитывая, насколько редко происходят обновления прошивки для этих устройств, очень важно, чтобы они были как можно более безопасными с самого начала. 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
55
♦ Язык Rust пришелся по душе разработчикам программного обеспечения. В оп росе разработчиков в Stack Overflow он неоднократно удостаивался титула «Са мый любимый язык программирования». ♦ Rust позволяет проводить эксперименты, не опасаясь негативных последствий. Он предоставляет гарантии корректности, на что без дополнительных затрат среды выполнения не могут пойти другие инструменты. ♦ Работая с Rust, нужно освоить всего лишь три инструментальных средства командной строки: ♦ Команда cargo, управляющая всем контейнером ♦ Команда rustup, управляющая установками Rust ♦ Команда rustc, управляющая компиляцией исходного кода на Rust ♦ В проекты на Rust могут вкрадываться ошибки, как и в любые другие. ♦ Код на языке Rust отличается стабильностью, быстротой выполнения и нетребо вательностью к ресурсам среды выполнения.
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
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/.
Часть 1 Особенности языка Rust Первая часть книги представляет собой краткое введение в язык программирования Rust. Изучив ее главы, можно будет получить неплохое представление о синтаксисе Rust и понять, что побуждает специалистов останавливать свой выбор на Rust. Также можно будет разобраться в сути некоторых фундаментальных отличий Rust от сопоставимых с ним языков.
2 Основы языка ЕЕ
J!IIIIIIIWRll§!Y
: са
,r
&JIМ!
'lliill!.::.:CZZ IJMkРТ
стrт
В этой главе рассматриваются следующие вопросы: ♦ Освоение синтаксиса 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
Основы языка
1 fn main () { 2 println! ("ОК") 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
ок
(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 выдает по умолчанию гораздо больший объем информации: $ carqo run Finished dev [unoptimized + debuginfo] target(s) in 0.03s Running 'target/debug/ok'
ок Если заинтересуетесь закулисными делами cargo по управлению rustc, добавьте в свою команду ключ вывода подробностей ( -v) $ rm -rf tarqet/ (1) $ carqo 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 Ьin --emit=dep-info,link -С emЬed-Ьitcode=no -С debuginfo=2 -С metadata=55485250d3e77978 -С extra-filename=-55485250d3e77978 --out-dir /tmp/ok/target/debug/deps -С incremental=/tmp/target/debug/incremental -1 dependency=/tmp/ok/target/debug/deps -С link-arg=-fuse-ld=lld' Finished dev [unoptimized + debuginfo] target(s) in 0.31s Running 'target/debug/ok'
ок
(1) Добавлено, чтобы спровоцировать cargo на компиляцию проекта с нуля
2.2. Взгляд на синтаксис Rust Rust банален и предсказуем везде, где только возможно. В нем есть переменные, числа, функции и другие известные составляющие, встречающиеся в других язы ках. Например, блоки в нем ограничиваются фигурными скобками ( { и } ), в каче стве оператора присваивания используется одинарный знак равенства ( = ), и этому языку совершенно безразличны пробелы.
Основы языка
63
2.2.1. Определение переменных и вызов функций Взглянем на другой короткий листинг и познакомимся с рядом основ: определени ем переменных с аннотациями типов и вызовом функций. Код листинга 2.2 выво дит на консоль а + ь = зо. В строках 2-5 этого листинга показано несколько син таксических вариантов для аннотации целочисленных типов данных. На практике нужно использовать тот из них, который больше всего подходит к конкретной си туации. Исходный код этого листинга находится в файле ch2/ch2-first-steps.rs.
fn main() let let let let let 6
2 3 4 5
3
9
{ а= 10; Ь: i32 = 20; с= 30i32; d = 30 i32; е = add(add(a, Ь), add(c, d));
:1 fn add(i: i32, j: i32) -> i32 { :3
(2)
(3) (4) (5)
println ! ( 11 ( а + Ь ) + ( с + d ) = { } 11, е);
:о
:2
(1)
i + j
(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 числовых типов и порядок прове дения с ними различных операций. Технически это не совсем корректно, но на данном этапе вполне допустимо. Опытные Rust проrраммисты, знающие, что main() по умолчанию возвращает ) ( (unit-тип), а также может возвращать Result,могут оrраничиться беглым просмотром данной главы. 1
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 �000000000000 �2
fn rnain() let twenty = 20; (1) 3 let twenty_one: i32 = 21; let twenty_two = 22i32;
2
(2) (3)
J
let addition = twenty + twenty_one + twenty_two; println! ("{} + {} + {} = {}", twenty, twenty_one, twenty_two, addition); j
�о �l
�2
�з
�4
let one rnillion: i64 = 1 ООО-000; println ! ("{}", one_rnillion.pow(2));
(4)
let forty_twos = [ 42.0, 42f32,
(6 )
(5)
(7) (8)
66
15 16 17 18 19
Глава 2
];
42.0_f32,
println ! (" {:02} ", forty_twos [О]);
(9)
(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 base base base
10: 3 30 300 2: 11 11110 100101100 8: 3 36 454 16: 3 le 12с
1 fn main () (1) 2 let three = ОЫl; (2) 3 let thirty = Оо36; (3) 4 let three hundred = Ох12С; 5 6 println!("base 10: {} {1 {}", three, thirty, three_hundred); 7 println!("base 2: { :Ь} {:Ь} {:Ь}", three, thirty, three_hundred); 8 println! ("base 8: {:о} {:о} {:о}", three, thirty, three_hundred); 9 println!("base 16: {:х} {:х} (:х}", 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. Одно и то же число может быть представлено несколькими комбинациями битов 1
Число
Тип
Битовая комбинация в памяти
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 а: i32 = 10; let Ь: ulб 100; ifa 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 else {
//
==
132 {
//
В Rust отсутствует концепция «правдивых» или «ложных>> типов. В других языках допускается, чтобы особые значения, например о или пустая строка, означали false, а другие значения означали true, но в Rust это не практикуется. Единствен ным значением, которое может быть true , является true, а за false может прини маться только f а 1 sе. Rust - язык, основанный на выражениях Так уж исторически сложилось в языках программирования, что все выражения возвращают значения, и почти все в них - выражения. Это наследие проявляется в некоторых конструкциях, недопустимых в других языках. В следующем фрагменте кода показано, что для Rust характерно обходиться в функциях без ключевого сло ва return:
fn is_even(n: i32) -> bool { n % 2 == О
И еще пример: Rust-программисты присваивают переменным значения из условных выражений: fn main() { let n = 123456; let description ''even"
=
if is_ even(n)
Глава 2
82
e lse { 11 odd 11
}; println ! ( 11 {} is {} 11, n, descript ion); // На экран выводится текст 11 123456 is even 11
Этот прием может распространяться и на другие блоки, включая match: fn main() { let n = 654321; let description = match is_even(n) true => 11 even 11 , false => 11 odd 11, }; println! ("{} is {} 11 , n, description); // На экран выводится текст 11 654321 is odd 11
Еще неожиданнее может показаться, что ключевым словом break также возвраща ется значение. Этим можно воспользоваться, чтобы позволить возвращать значения «бесконечным» циклам: fn main() { let n = loop break 123; }; println! ( 11 {} 11 , n);
// На экран выводится текст
11
123"
Можно спросить: а какие части Rust не являются выражениями и, следовательно, не возвращают значений? Выражениями не являются инструкции. В Rust они появ ляются в трех местах: • В выражениях, разделенных точкой с запятой(;). • При привязке имени к значению с помощью оператора присваивания (= ). • При объявлениях типов, куда включаются функции (fn) и типы данных, созданные с помощью ключевых слов struct и enum.
Официально первая форма называется инструкцией-выражением. А две остальные формы называются инструкциями-объявлениями. Отсутствие значения в Rust пред ставлено как () (unit-тип).
2.4.7. Match: соответствие образцу с учетом типов В Rust можно, конечно, воспользоваться блоками if-else, но match предоставляет этим блокам безопасную альтернативу. Если подходящий вариант не рассматри вался, match выдает предупреждение.
83
Основы языка
Кроме того, этот блок отличается наглядностью и лаконичностью: match item
о
10 40
20 80 => {},
..= 1
=> {}, => {},
(1)
{},
(4)
=>
(2) (3)
(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!
1 fn main () { (1) 2 let needle = 42; 3 let haystack = (1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862]; 4
5 6
for item in &haystack let result = match item
(2)
Глава 2
84 42
7
в 9
10 11 12 13 14
};
1
=>
1 32 => "hit!", "miss",
(3) (4)
i f result = = "hit!" { println! ("{}: {}", item, result);
15
(1) Теперь переменная needle уже ЛИlllliЯЯ. (2) Это выражение match возвращает значение, которое может быть привязано к переменной. (3) Успех! 42 1 132 соответствует как 42, так и 132. (4) Джокер, соответствукщий чему угодно
Ключевое слово match играет в языке Rust весьма важную роль. С применением выполнены внутренние определения многих управляющих структур (в част ности, циклов). А в сочетании с типом Option, который более подробно рассматри вается в следующей главе, match открывается нам во всей своей красе.
match
Как следует разобравшись с определением чисел и с приемами работы с некото рыми имеющимися в Rust механизмами управления ходом выполнения программы, давайте перейдем к наращиванию структуры программ с помощью функций.
2.5. Определение функций Вернемся к началу главы, где фрагмент кода из листинга 2.2 содержал небольшую функцию по имени add(). Она получает два значения типа i 32 и возвращает их сумму. Повторение кода функции показано в следующем листинге.
10 fn add(i: i32, j: i32) -> i32 {
11
12
i + j
(1)
(1) Функция add() получает два целочисленных параметра и возвращает целое число. Два аргумента привязываются к локальным переменным i и j.
Давайте пока сфокусируем внимание на синтаксисе каждого элемента листинга 2.9. На рис. 2.2 представлена визуализация каждой из частей. Разобраться в схеме смо жет любой специалист, имеющий опыт программирования на языках со строгой типизацией.
85
Основы языка
Rust-функции требуют указания типов параметров и типа возвращаемого функцией значения. Это те самые фундаментальные сведения, которые необходимы для большей части нашей работы с языком Rust. Давайте применим их к нашей первой более-менее серьезной программе. Иде�а�ор [ Па�аме�
t� 1
�
fn add (i: i32, j: i32) -> i32
"�,:;:Фl,,Т....
т
(
1
Эта с релка показывает возврат 1 т Начало блока кода
Рис. 2.2. Синтаксис определения функций, применяемый в Rust
2.6. Использование указателей Если в предыдущей деятельности вам приходилось иметь дело только с динамиче скими языками программирования, синтаксис и семантика указателей могут вос приниматься с трудом. Возможно, будет непросто сложить мысленную картину происходящего. Из-за этого будет сложно понять, какие символы и куда нужно по �1ещать. К счастью, компилятор Rust - хороший тренер. Указатель - это значение, заменяющее другое значение. Представим, к примеру, что переменной является большой массив, дублирование которого влечет за собой солидные издержки. В некотором смысле указатель r - дешевая копия а. Но вме сто создания дубликата программа сохраняет адрес а в памяти. Когда требуются .Jанные из а, указатель r можно разыменовать и открыть доступ к а. Соответст вующий код показан в следующем листинге.
::n main () let а = 42; let r = &а; let Ь = а + *r; println! ("а + а
(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.
1 fn main () { 2 let needle = 00204; 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. На экране терминала должно появиться множе ство Мандельброта:
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ·••*•**• . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . -·••***••· . . . . . . . . . . . . . . .
.. .. ... ... .... .. . . .. ..... ..... ... .•••·**+%+•·*•................ . . .. ..... ..... . . . .. . ........ .. .······••*$%\%%%••·····. ............ .
................. ............••**+*••******t\%*****+••·••*•····· .....
. ...... . ... . .. . .. .... . . . . . .. . •••••*%%+•%%%%%%t%%%t%%%%x•+•+*•• ........... .... ............ ........ ....••····•++%tt%%%%tt%%%%%t%%%%t%t••·····......... . ·······························••+tt%%%tt%tt%t%%t%t%%t%%%%tt••·············· ...............•••••••••••••••• ••+%%\%t\'lr%t%t%'1r%%tttttt%%%tttt%+•............ .. .... ..........•••••+t•t#xxt••••xt%t%%%%%%%%%%%t%%%%%%%%%%%%%%••• ............ . .... ... ... ·······•·++%%%%%%%%%t+•t%%%%%%%%%%%ttt%t%tt%%%%%%%%%%*•··· .......... . ...... ·········••+••tt%%%%%%%%%%+t%%%%%%%%%%tt%t%%%%%%%%%%%ttt•••.............. %%%%%%%tt%%%%%%%%%%%%%%%%%%%%t%%%%%%%%%%t%%%%%%%%%%%%%%%%%%tt••······ . ......... . .......••········+••tt%%%%%%%%%%+%%%%%%%%%%%%%%%t%%%t%%%%%%%%%•••.............. ...... . ... ·······•·++%%%%%%%%%%+•%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%*•··· ........... . ..... . ... ... ...•••••+%*%#xxt••••xt%%%tt%%%%%%%%%%%%%%%%%%%%%%t•••.............. ············· ··················••+%%%%%%%%%%%%%\%%%%%%%%%%%%%%%+•............ ......... .... ...••··············••+%%%%%%%%%%%%%%%%%%%%%%%%%%*••··· ......... . ........ ............... ....········++%%%%%%%%%%%%%%%%%%%%%%•••• ............ ....... ..... ..... ...... ......••••••tt+*%%%%%%%%%%%%%%%x•+•+••• ........... . . .. . . .... .... . ... . . . ........····+·•·····••ttt••···+••· . ...... . . . . . . . . .... . . . ... . . . ...·······••$%%%%%••·····............. .
••* •. . . . . . . . . .
. . . . . .. . . . . . . . .... . .. . . .... ... . .. . ·••***+%+***•............... .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . •••***••· · . . . . . . . . . . . . . .
1 use nшn::complex: :Complex; 2 3 fn calculate_mandelbrot(
4
5
6 7
8 9
10 11 12
max iters: usize, х min: fб4, (4 ) х max: fб4, y_min: fб4, y_max: f64, width: usize, height: usize, ) -> Vec
(1) (2) (3)
(4) (4) (4) (5) (5)
Глава 2
88 13
14 15 16 17 18 19 20 21 22 23 24 25 26
let mut rows: Vec = Vec::with_capacity(width); (6) for img_y in O ..height {
(7)
let mut row: Vec = Vec::with_capacity(height); for img_x in O ..width { let x_percent = (img_x as f64 / width as f64); let y_percent = (img_y as f64 / height as f64); let сх = х min + (x_max - x_min) * x_percent; (8) let су= y_min + (y_max - y_min) * y_percent; (8) let escaped_at = mandelbrot_at_point(cx, су, max_iters); row.push(escaped_at);
27
28 all_rows.push(row); 29 30 rows 31 32 33 fn mandelbrot_at_point( 34 сх: f64, 35 су: f64, 36 max iters: usize, 37 ) -> usize 38 let mut z = Complex { re: О.О, im: О.О); 39 let с = Complex::new(cx, су); 40 41 for i in O..=max iters if z.norm() > 2.0 { 42 43 return i; 44 45 z = z * z + с; 46 47 max iters 48 49 50 fn render_mandelbrot(escape_vals: Vec) { 51 for row in escape_vals { let mut line = String::with_capacity(row.len()); 52 53 for column in row 54 let val = match column { о .. =2=> 1 1' 55 2 ..=5=> 1 • 1 ' 56 5 ..=10=> '•', 57 58 11..=30 => '*'
(9)
(10) (11) (12) (13)
(14)
Основы языка
89
59 30..=100 => '+', 60 100 ..=200 => 'х'' 61 200..=400 => 1 $ 1 f 62 400..=700 => l#I, => 1!1,о 1 , 63 64 }; 65 66 line.push(val); 67 println! ("{}", line); 68 69 70 71 72 fn main() { 73 let mandelbrot calculate_mandelbrot(lOOO, 2.0, 1.0, -1.0, 1.0, 100, 24); 74 75
76 77
render_mandelbrot(mandelbrot);
(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 с одним из таких примеров.
1 fn add with lifetimes(i: &'а i32, j: &'Ь i32) -> i32 { 2 *i + *j 3
На первых порах разобраться в том, что происходит в незнакомом синтаксисе, бы вает довольно сложно. Но со временем все проясняется. Начнем с объяснения про исходящего, а потом разберемся в его причинах. Следующий перечень состоит из разбора всех составляющих кода первой строки: •
-> i32 - этот фрагмент уже должен быть знаком. Из него можно сделать вывод, что add_with_lifetimes () - функ ция, возвращающая значение типа i32.
fn add_with_lifeti mes (...)
• < ' а, ' Ь> - объявление двух переменных времени жизни, ' а и 'ь, в области видимости функции add_with_li fetimes (). Обычно о них говорят как о вре мени жизни а и времени жизни Ь. • i: & 'а i32 - привязка переменной времени жизни 'а к времени жизни i. Этот синтаксис читается как «параметр i является указателем на i32 с време нем жизни а>>. j : & 'ь i з 2 - привязка переменной времени жизни ' ь к времени жизни j . Этот синтаксис читается как «параметр j является указателем на i32 с време нем жизни ь». Зачем привязывать переменную времени жизни к значению, пока что, наверное, непонятно. Основа проводимых в Rust проверок безопасности - система времени жизни, позволяющая убедиться, что все попытки обращения к данным являются допустимыми. Аннотации времени жизни позволяют программистам заявить о сво их намерениях. Все значения, привязанные к данному времени жизни, должны су ществовать вплоть до последнего доступа к любому значению, привязанному к этому же времени жизни. •
Основы языка
91
Обычно система времени жизни работает без посторонней помощи. Хотя время жизни есть почти у каждого параметра, проверки в основном проходят скрытно, поскольку компилятор может определить время жизни самостоятельно8• Но в сложных случаях компилятору нужна помощь. Нередко попадаются функции, по лучающие в качестве аргументов сразу несколько указателей или возвращающие указатель, и тогда компилятор запросит помощь, выдав сообщение об ошибке. При вызове функции аннотации времени жизни не требуются. В примере, приве денном в полном объеме (см. следующий листинг), аннотации времени жизни можно увидеть в определении функции (строка 1 ), но не при ее использовании (строка 8). Исходный код листинга находится в файле ch2-add-with-lifetimes.rs.
1 fn add_with_lifetimes(i: &'а i32, j: &'Ь i32) -> i32 { 2 *i + *j
(1)
4 5 fn main() 6 let а = 10; 7 let Ь = 20; let res = add with_lifetimes(&a, &Ь); В
(2)
3
9
10 println! ("{}", res); 11 (1) Сложение значений, на которые указывают i и j, а не сложение непосредственно самих указателей (2) &10 и &20 означают указатели соответственно на 10 и 20. При вызове функции обозначать время жизни не нужно.
В строке 2 - * i + * j - складываются значения, на которые ссылаются указатели, хранящиеся в переменных i и j . Обычно параметры времени жизни можно увидеть при использовании указателей. В иных случаях Rust может самостоятельно вывес ти времена жизни, но указатели требуют, чтобы программист обозначил свои наме рения. Использование двух параметров времени жизни (а и ь) показывает, что вре мена жизни i и j не связаны друг с другом. ПРИМЕЧАНИЕ Параметры времени жизни - это способ предоставить программисту возможность управления складывающейся ситуацией при сохранении кода, присущего высокоуров невым языкам.
8 Формально факт отказа от использования аннотации времени жизни называется пропуском времени жизни.
Глава 2
92
2.8.2. Обобщенные функции Еще один особый вид синтаксиса функций применяется при написании программи стами Rust-функций, предназначенных для обработки множества возможных типов вводимых данных. Пока нами рассматривались только функции, принимающие 32-разрядные целые числа (iз2). В следующем листинге показана сигнатура функ ции, которую можно вызывать со многими типами вводимых данных, при условии, что все они будут одного и того же типа.
fn add(i: Т, j: Т) -> Т { i + j
(1)
(1) Переменная типа Т вводится в угловых скобках (). Эта функция принимает два аргумента одного и того же типа и возвращает значение такого же типа.
Заглавные буквы вместо типа указывают на обобщенный тип. В соответствии с действующим соглашением в качестве заместителей используются произвольно выбираемые переменные т, u и v. А переменная Е часто применяется для обозначе ния типа ошибки. Более подробно обработка ошибок будет рассмотрена в главе 3. Обобщения позволяют использовать код многократно и могут существенно повы сить удобство работы со строго типизированным языком. К сожалению, код лис тинга 2.15 не пройдет компиляцию. Компилятор Rust пожалуется, что он не может сложить два значения любого типа т. Вот что он выдаст при попытке компиляции кода листинга 2.15: error[E0369]: cannot add 'Т' to 'Т' --> add.rs:2:5
(1)
i + j
2
- т
т help: consider restricting type pararneter 'Т' 1
1
(2)
fn add(i: Т, j: Т) -> Т {
error: aborting due to previous error
(3)
For more information aЬout this error, try 'rustc --explain Е0369' .
( 4)
(1) ошибка[Е0369]: невозможно прибавить 'Т' к 'Т' (2) подсказка: попробуйте ограничить параметр типа 'Т'
Основы языка
93
(3) ошибка: прервано из-за пре.ш,щущей ошибки (4) Дополнительную информацию об ошибке можно получить, запустив команду 'rustc - explain Е0369'
Дело в том, что т означает вообще любой тип, куда входят даже те типы, сложение в которых не поддерживается. На рис. 2.3 предоставляется визуальное отображение проблемы. В коде листинга 2.15 предпринимается попытка сослаться на внешнее кольцо, а сложение поддерживается только типами, находящимися во внутреннем кольце.
Все типы Типы, поддерживающие сложение путем реализации
std: :ops: :Add
Рис. 2.3. Операторы реализации имеются только у подмножества типов. При создании обобщенных функций, включающих такой оператор, типаж этого оператора должен быть включен в качестве типажного ограничения
А как указать, что в типе т должно быть реализовано сложение? Ответ на этот во прос требует введения новой терминологии. Все Rust-oпepaтopы, включая сложение, определены в типажах. Чтобы выставить требование, что тип т должен поддерживать сложение, в определение функции на ряду с переменной типа включается типажное ограничение. Пример такого синтак сиса показан в следующем листинге.
fn add(i: Т, j: Т) -> Т { i + j
Фрагмент предписывает, что в т должна быть реализация операции std: : ops: : Add. Использование одной и той же переменной типа т с типажным ограничением гарантирует, что аргументы i и j, а также воз вращаемое значение будут одного и того же типа и их типы поддерживают сложение. Что такое типаж? Это свойство языка, аналогичное интерфейсу, протоколу или контракту. Имеющие опыт объектно-ориентированного программирования счита ют типаж чем-то вроде абстрактного базового класса. Если же есть опыт функцио нального программирования, то имеющиеся в Rust типажи можно рассматривать
Глава 2
94
как некое подобие классов типов в языке Haskell. Но пока достаточно будет ска зать, что типажи позволяют типам заявить, что они используют стандартное пове дение. Все Rust-операции определяются с помощью типажей. Например, оператор сложе ния ( +) определен как типаж std: : ops: : Add. Типажи в достаточной мере представ лены в главе 3 и постепенно раскрываются все более и более подробно в процессе изучения книги. Повторюсь: все Rust-oпepaтopы являются удобным синтаксическим приемом для вызова методов типажа. Таким образом Rust поддерживает перегрузку оператора. В ходе компиляции выполняется преобразование выражения а + ь в а. add (Ь). В листинге 2.17 представлен полный пример, показывающий, что обобщенные функции могут вызываться множеством типов. Код листинга выводит на консоль следующие три строки: 4.6 30
15s
1 use std::ops::{Add); 2 use std::time::{Durationf; 3 4 fn add(i: Т, j: Т) -> Т { 5 i + j 6 7 8 fn main() 9 let floats = add(l.2, 3.4); 10 let ints = add(l0, 20); 11 let durations = add( 12 Duration::new(5, О), 13 Duration::new(l0, О) 14 ); 15 16 println! ("{1", floats); 17 println! ("{)", ints); 18 println! ("{:? 1", durations); 19 20 (1) (2) (3) (4) (5)
(1) (2) (3)
(4) (5) (6) (6) (6)
(7)
Перенос типажа Add из std::ops в локальную область видимости Перенос типа Duration из std::time в локальную область видимости Аргументы функции add() могут принимать любой тип, реализукщий std::ops::Add. Вызов add() со значениями чисел с плавающей точкой Вызов add() с целыми числами
Основы языка
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?
1 fn main{) { 2 let search term = "picture"; 3 let quote = "\ 4 Every face, every shop, bedroom window, puЬlic-house, and 5 dark square is а picture feverishly turned--in search of what? 6 It is the same with books. 7 What do we seek through millions of pages?"; (1) 8 9 for line in quote.lines() { (2) 10 if line.contains{search_term) 11 println! {"{)", line); 12 13 } 14 }
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?
1 fn main() { let search_term = "picture"; 3 let quote = "\ 4 Every face, every shop, bedroom window, puЫic-house, and 5 dark square is а 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; 8 for line in quote.lines() 9 10 if line.contains(search_term) 11 println! ("{}: {}", line_num, line); 12 13 line num += 1; 14 15
2
(1)
(2)
(3) (4)
(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.
1 fn main() { 2 let search term = "picture"; let quote = "\ 3
99
Основы языка
4 Every face, every shop, bedroom window, puЫic-house, and 5 dark square is а picture feverishly turned--in search of what? 6 It is the same with books. What do we seek through millions of pages?";
7
8 9 10 11 12 13 14
for (i, line) in quote.lines().enumerate() if line.contains(search_term) { let line num = i + 1; println! ("(): {}", line_num, line);
(1) (2)
(1) Поскольку метод lines() возвращает итератор, к нему в цепочку можно пристроить метод enumerate(). (2) Вьmолнение дополнения в виде вычисления номера строки, позволяющее избежать вычислений при каждом проходе цикла.
Еще одна весьма полезная функция утилиты grep - вывод содержимого до и после той строки, в которой найдено соответствие. В GNU-реализации grep для этого ис пользуется ключ -с NUM. Чтобы добавить поддержку этой функции в grep-lite, нуж но освоить создание списков.
2.1 О. Создание списков с использованием массивов, слайсов и векторов Списки получили весьма широкое распространение. Чаще всего вам придется рабо тать с массивами и векторами. Массивы характеризуются фиксированной шириной и чрезвычайной скромностью в потреблении ресурсов. Векторы можно наращи вать, но им свойственны издержки времени выполнения из-за ведения дополни тельного учета. Чтобы понять механизмы, положенные в Rust в основу текстовых данных, полезно иметь хотя бы поверхностное представление о происходящем. Цель раздела - реализация поддержки вывода на экран n строк контекста, окру жающего найденное совпадение. Чтобы добиться желаемого, нужно сделать не большое отступление и подробнее разобраться с массивами, срезами и векторами. Самый полезный тип для этого упражнения - вектор. Но чтобы разобраться с век тором, нужно сперва понять, что собой представляют два его более простых собра та: массив и срез.
2.10.1. Массивы Массив, по крайней мере в Rust, представляет собой плотно упакованный набор однородных элементов. В массиве допускается замена элементов, но его размер изменяться не может. Поскольку типы переменной длины, вроде String, усложня ют рассмотрение, вернемся ненадолго к числам.
Глава 2
100
Для создания массивов применяются две формы. Мы можем предоставить список в квадратных скобках с запятыми в качестве разделителей (например, [ 1, 2, 3 J) или выражение повторения, где указываются два значения, разделенные точкой с запя той (например, [О; l00J). Значение слева (о) повторяется то количество раз, что указано справа (100). Все варианты показаны в листинге 2.21 в строках 2-5. Исход ный код этого листинга находится в файле сh2-Заггауs.гs. Программа выводит на консоль следующие четыре строки: [1, [1, [О, [О,
2, 2, о, о,
3): 3): О]: О]:
1 1 о о
+ + + +
10 = 11 2 + 10 11 2 + 10 10 о + 10 10 о +
10 10 10 10
= = = =
12 12 10 10
3 3 о О
+ + + +
10 10 10 10
= = = =
13 13 10 10
(□[1, О::[1, О::[о, (I:[0,
2, 2, О, о,
3) 3) О] О]
= = = =
6) 6) О) О)
1 fn main() { 2 let one = (1, 2, 3); let two: [u8; 3) = (1, 2, 3); 3 4 let Ьlankl = [О; 3); 5 let Ыank2: [u8; 3) = [О; 3); 6 7 let arrays = [one, two, Ыankl, Ыank2]; 8 for а in &arrays { 9 print!("{:?): ", а); 10 11 for n in a.iter() { 12 print!("\t{) + 10 = {)", n, n+l0); 13 14 15 let mut sum = О; for i in О..а. len() 16 sum += a[i]; 17 18 19 println!("\t(L{:?) { )) ", а, 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 wi ndow, puЫic-house, and dark square is а picture feverishly turned--in search of what? dark square is а picture feverishly turned--in search of what? It is the same with books. What do we seek through millions of pages?
1 fn main () { let ctx lines = 2; 2 let needle = "оо"; 3 let haystack = "\ 4 5 Every face, every shop, 6 bedroom window, puЫic-house, and 7 dark square is а picture В feverishly turned--in search of what? 9 It is the same with books. 10 What do we seek
Основы языка
11 through millions of pages?"; 12 13 let mut tags: Vec = vec! []; 14 let mut ctx: Vec = vec! []; 16 17 for (i, line) in haystack.lines() .enumerate() if line.contains(needle) { 18 19 tags.push(i); 20 let v = Vec::with_capacity(2*ctx_lines + 1); 21 ctx.push(v); 22 23 24 25 26 if tags.is_empty() 27 return; 28 29 30 for (i, line) in haystack.lines() .enumerate() 31 for (j, tag) in tags.iter().enumerate() let lower bound = 32 tag.saturating_suЬ(ctx_lines); 33 34 let upper_bound = tag + ctx_lines; 35 36 37 if (i >= lower_bound) && (i bool { 6 true 7
8 fn close(f: &mut File) -> bool { true 9 10
(1) (2)
(3)
(3)
11
12 #[allow(dead_code)] 13 fn read(f: &mut File, 14 save_to: &mut Vec) -> unimplemented! () 15
(4) (5)
(6)
16 17
18 fn main () let mut f1 = File::from{"fl.txt"); 19 20 open(&mut fl); 21 //read(fl, vec! []); 22 close(&mut fl); 23
(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: Т) { println! ("{:?}", i tem);
(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
Глава З
Тип unit нередко появляется и в сообщениях об ошибках. Многие часто забывают, что последнее выражение в функции не должно заканчиваться точкой с запятой. Восклицательный знак, ! , известен как тип «Never» (никогда). Never показывает, что функция никогда ничего не возвращает, особенно при гарантированном сбое. Возьмем, к примеру, следующий код: fn dead_end() -> ! { panic! ("you have reached а dead end"); (1) (1) Макрос panic! вызывает сбой программы. То есть функция гарантированно никогда не вернет управление вызвавшему ее коду.
В следующем примере создается бесконечный цикл, не позволяющий функции вернуть управление: fn forever() -> ! { loop { // . . .
(1)
};
(1) Если в цикле отсутствует инструкция break, он никогда не завершится. Это не позволяет функции вернуть управление.
как и unit, иногда появляется в сообщениях об ошибках. Если указать, что функция возвращает тип, отличный от Never, и забыть добавить break в блок loop, компилятор Rust пожалуется на несоответствие типов. Never,
3.2. Моделирование файлов с помощью struct Создаваемая модель нуждается в каком-либо представлении. struct позволяет соз давать составной тип, образованный из других типов. В зависимости от опыта про граммирования вам могут быть больше знакомы такие понятия, как объект или за пись. Сначала давайте потребуем, чтобы у наших файлов были имена и от нуля и более байтов данных. Код листинга 3.2 выводит на консоль следующие две строки: File { name: "fl.txt", data: [] } fl.txt is О bytes long
Для представления данных в коде листинга 3.2 используется тип vec, пред ставляющий собой расширяемый список ив (однобайтовых) значений. В основной части функции main () показан порядок применения (например, доступ к полю). Код этого листинга находится в файле chЗ/chЗ-mock-file.rs. Листинг 3.2. Оо 1 2
*[derive(Debug)] struct File {
(1)
Составные типы данных
3 4 5
narne: String, data: Vec,
6 7 fn main () let fl = File 8 9 narne: String::from("fl.txt"), 10 data: Vec::new(), 11
12 13 14 15 16 17 18
};
let fl_narne = &fl.narne; let fl_length = &fl.data.len();
121
(2)
(3) ( 4) (5) (5)
println!("{:?}", fl); println!("{} is {} bytes long", fl_narne, fl_length};
(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.1. На рисунке два его поля (name и data) сами созда ны в виде структур. Если незнакомо понятие указатель (ptr), считайте, что указате ли - то же самое, что и ссылки. Указатели - это переменные, которые ссылаются на некоторые области памяти. Подробности рассматриваются в главе 6. Имя файла
Структура файла
Представление в памяти
data
narne
Тип данных файпа ptr
String
size
capacity
[u8; name.size]
ptr
Vec
size
capacity
[u8; data.size]
Рис. 3.1. Взгляд на внутреннее устройство структуры File
Отложим вопросы взаимодействия с жестким диском или другим постоянным хра нилищем до конца этой главы. А пока переделаем, как и обещали, код листинга 3.1 и добавим тип File. Шаблон newtype Иногда можно просто обойтись ключевым словом t ype. Но как заставить компиля тор считать ваш новый «тиш> полноценным, обособленным типом, а не просто псевдонимом? Воспользуйтесь шаблоном newtype, представляющим собой упаков ку предопределенного базового типа в структуру s t ruct с одним полем (или, воз можно, в кортеж tuple). В следующем коде показано, как отличить имена сетевых узлов от обычных строк. Этот код находится в файле chЗ/chЗ-new-type-pattern.rs: struct Hostnarne(String); fn connect(host: Hostnarne) println! ("connected to {}", host.0); fn main() let ordinary_string = String::from("localhost"); let host = Hostna.�e ( ordinary_string.clone() ); connect(ordinary_string);
А вот как выглядит информация, выведенная компилятором rustc: $ rustc chЗ-newtype-pattern.rs error[E0308]: mismatched types --> chЗ-newtype-pattern.rs:11:13 1
11
1
connect(ordinary_string);
Составные типы данных
123
expected struct 'Hostname', found struct 'String' error: aborting due to previous error For more information aЬout this error, try 'rustc --explain Е0308'
(1)
(2) (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) Демонстрация этой строки испортила бы все удовольствие от результата!
1 #! [allow(unused_variaЬles)] 2 3 # [derive(Debug)] 4 struct File { 5 name: String, 6 data: Vec,
(1) (2)
7
8 9
10 11
fn open(f: &mut File) -> bool { true
12 13 fn close(f: &mut File) -> bool { 14 true
(3)
(3)
124
15 16 17 fn read( 18 f: &File, 19 save to: &mut Vec, 20 -> usize { 21 let mut tmp = f.data.clone(); 22 let read_length = tmp.len(); 23 24 save_to.reserve(read_length); 25 save_to.append(&mut tmp); 26 read_length 27 28 29 fn main() 30 let mut f2 = File { name: String::from("2.txt"), 31 data: vec! [114, 117, 115, 116, 33], 32 33 ); 34 35 let mut buffer: Vec vec! []; 36 37 open (&mut f2); 38 let f2_length = read(&f2, &mut buffer); 39 close(&mut f2); 40 41 let text = String::from_utf8_lossy(&buffer); 42 43 println!("{:?I", f2); 44 println! (" 11 is { 1 bytes long", &f2.name, f2_length); 45 println! ("{}", text) 46 47
Глава З
(4) (5) (6) (7)
(8) (8) (8) (9)
(10)
(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 {
1 Данные 1 1 Методы 1
Struct и enum в Rust stcuct File {
lданныеj
irnpl File {
1 Методы 1
Рис. 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.
1 2 3
#[derive(Debug)] struct File { narne: String,
127
Составные типы данных
4
5
data: Vec,
6 7 impl File { 8 fn new(name: &str) -> File { File { 9 10 name: String::from(name), 11 data: Vec::new(), 12 13 14 15 16 fn main() 17 let f3 = File::new("f3.txt"); 18 19 let f3 name = &f3.name; 20 let f3_length = f3.data.len(); 21 22 println!("{:?}", f3); 23 println!("{) is {) bytes long", fЗ_name, f3_length); 24
(1) (2) (2) (2)
(3)
(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 #! [allow(unused_variaЫes)] 2 3 #[derive(Debug)] 4 struct File { 5 name: String, data: Vec, 6 7
8
(1)
Глава З
128 9 impl File { fn new(name: &str) -> File { 10 File { 11 12 name: String::from(name), data: Vec::new(), 13 14 15 16 17 fn new_with_data( name: &str, 18 data: &Vec, 19 -> File 20 21 let mut f = File::new(name); 22 f.data = data.clone(); f 23 24 25 26 fn read( 27 self: &File, 28 save_to: &mut Vec, 29 -> usize { let mut tmp = self.data.clone(); 30 let read_length = tmp.len(); 31 32 save_to.reserve(read_length); save_to.append(&mut tmp); 33 read_length 34 35 36
(1)
(2)
37
38 fn open(f: &mut File) -> bool { 39 true 40 41 42 fn close(f: &mut File) -> bool { 43 true 44 45 46 fn main() 47 let f3 data: Vec = vec! [ 48 114, 117, 115, 116, 33 49 ]; 50 let mut f3 = File::new with_data("2.txt", &f3_data); 51 52 let mut buffer: Vec = vec! []; 53 54 open(&mut f3);
(3)
(3)
Составные типы данных 55 56
let f3_length = f3.read(&mut buffer); close(&mut f3);
58 59 60 61 62 63
let text = String: :from_utfB_lossy(&buffer);
57
129 (4)
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. В раздел
Глава 3
130
стандарта 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. Код в стиле Ru�t, проверяющий коды ошибок, извлекаемые из глобальн?й, переменной static mut ERROR: i32 = О;
//
(1)
...
f n main() let mut f = File::new("something.txt"); read(f, buffer); unsafe { if ERROR != О ( panic! ("An error has occurred while reading the file ")
(2) (3)
close(f); unsafe { if ERROR 1 = О { panic! ("An error h as occurred while closing the f ile ")
(2) (3)
(1) Глобальная переменная static mut (изменяемая статическая) со статическим временем жизни, действительным в течение всего времени существования программы. (2) Для доступа к переменным static mut и внесения в них изменений требуется использование небезопасного блока un safe. Таким образом Rust снимает с себя всякую ответственность. (3) Проверка значения ERROR. Проверка на наличие ошибок основана на соглашении о том, что О означает отсутствие ошибок.
Составные типы данных
131
Установка ERROR в значение Ок («все в порядке>>) main() Инициализация буфера
в виде объекта Vec
для Filе-объекта f
read() Чтение с диска, загрузка в буфер
Нет
Возвращение количества байтов, сохраненных в буфере
Установка ERROR в not Оk(«не все в порядке►>)
:--------п,,,L,-------• ошибка не закодирована ! ______ в результате_______
Паника
Проблема: i ! ! проверка на ошибки ! i ____ не выполняется____ i
i i
Проблема: i обработка ошибок ! ' 1 к с о т х _ _ _ � !1_ _т_?�_ и_ �---: : ---����
(Остальная часть программы)
Рис. 3.3. Визуализация кода листинга 3.7 с объяснением проблем при использовании глобальных кодов ошибок
Глава З
132
В коде представленного далее листинга 3.7 вводится новый синтаксис. Самое важ ным в нем, наверное, - ключевое слово unsafe, значение которого будет рассмот рено чуть позже. А пока считайте unsafe предупреждением, а не индикатором не законных действий. Небезопасность означает «тот же уровень безопасности, кото рый всегда обеспечивается языком С». Есть также несколько других небольших дополнений к языку Rust, о которых уже известно: • Изменяемые глобальные переменные обозначаются с помощью static m ut.
• По соглашеmпо в именах глобальных перемеш1ых ВСЕ БУКВЫ ЗАГЛАВНЫЕ. • Ключевое слово const включается для тех значений, которые никогда не из меняются. На рис. 3.3 представлен визуальный обзор ошибок управления ходом выполнения программы и обработка ошибок в коде листинга 3. 7.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
use rand:: { random}; static mut ERROR: isize
(1)
О;
struct File; #[333allow(unused_variaЫes)] fn read(f: &File, save_to: &mut Vec) -> usize { if random() && random() && random() { unsafe { ERROR = 1; О
15 16 17 #[allow(unused_mut)] 18 fn main() { 19 let mut f = File; 20 let mut buffer = vec! (]; 21 22 read(&f, &mut buffer); 23 unsafe { if ERROR != О { 24 25 panic! ("An error has occurred! ") 26 27 28
(2) (3)
(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/glob alerror'
134
Глава 3
В большинстве случаев программа не делает абсолютно ничего. Иногда, если у книги много читателей с достаточной мотивацией, она напечатает более громкое сообщение: $ cargo run
thread 'main' panicked at 'An error has occurred! ', src/main.rs:27:13 note: run with 'RUST BACKTRACE=l' envirorunent variaЫe to display а backtrace
(1)
(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, могут указывать на одни и те же данные. Ссылки для чтения-записи (изме няемые заимствования) гарантированно никогда не станут псевдонимами данных.
Составные типы данных
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, нужно из окна терминала выполнить следующие команды: $ $
qit clone --depth=l https:/ /qithuЬ.com/rust-in-action/code rust-in-action cd rust-in-action/chЗ/fileresult
$ carqo run
136
Глава З
То же самое можно сделать вручную, придерживаясь следующих рекомендуемых действий: 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 приведет к выдаче отладочной информации, но не покажет ничего от самого исполняемого файла: $ carqo run
Compiling fileresult v0.1.0 (file:/ / /path/to/fileresult) Finished dev [ unoptimized + debuginfo] target(s) in 1.04 secs Running 'target/debug/fileresult'
1 use rand::prelude: :*; 2 3 fn one_in(denominator: u32) -> bool { thread_rng() .gen_ratio(l, denominator) 4 5 б
7 #[derive(Debug)] В struct File { name: String, 9 10 data: Vec, 11 12 13 impl File ( fn new(name: &str) -> File { 14 File { 15
(1)
(2) (3)
137
Составные типы данных
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
пате: String::from(name), data: Vec::new()
(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)
37
38 39 fn open(f: File) -> Result { 40 if one_in(l0_000) { 41 let err_msg = String::from( 11 Permission denied 11 42 return Err(err_msg); 43 44 Ok(f) 45 46 47 fn close(f: File) -> Result { 48 if one_in(100_000) { let err_ msg = String::from( 11 Interrupted Ьу signal ! 1 49 return Err(err_msg); 50 51 52 Ok(f) 53 54 55 fn main() 56 let f4 data: Vec = vec! [114, 117, 115, 116, 33]; 57 let mut f4 = File::new_with_data( 11 4.txt 11 &f4_data); 58 59 let mut buffer: Vec = vec! []; 60 61 f4 = open(f4) .unwrap(); 62 let f4_length = f4.read(&mut buffer).unwrap();
(7)
);
(8) 1) ;
,
(9) (9)
138
63 67 65 66 67 68 69 70
Глава З
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
Составные типы данных
enum Suit Clubs, Spades, Diamonds, Hearts,
Если программировать на языке, использующем перечисления, еще не приходи лось, то для осознания их ценности придется немного потрудиться. Но практика программирования с их использованием даст, наверное, только лишь первичное представление о них. Рассмотрим задачу создания кода, анализирующего журналы регистрации событий. У каждого события есть имя, например UPDATE или DELETE. Вместо хранения в ва шем приложении этих значений в виде строк, что позже, когда сравнение строк станет слишком громоздким, может привести к досадным ошибкам, перечисления позволяют предоставить компилятору сведения о кодах событий. Позже будет по лучено предупреждение вроде: «Привет, я вижу, что рассмотрен запрос на UPDATE, но, похоже, забыт запрос на DELETE. Нужно исправить программу)). В листинге 3.10 показано начало приложения, анализирующего текст и выдающего структурированные данные. При запуске программа выводит на экран следующий результат. Код листинга находится в файле chЗ/chЗ-parse-log.rs: (Unknown, "BEGIN Transaction ХК342") (Update, "234:LS/32231 {\"price\": 31.00) -> {\"price\": 40.00)") (Delete, "342:10/22111")
1 #[derive(Debug)] 2 enum Event { 3 Update, 4 Delete, 5 Unknown,
(1)
(2) (2)
(2)
6
7
8
9
type Message
=
String;
10 fn parse_log(line: &str) -> (Event, Message) 11 let parts: Vec< > line 12 .splitn(2, ' ') 13 .collect(); 14 if parts.len() == 1 { return (Event::Unknown, String::frorn(line)) 15 16 17
(3) (4)
(5) (6) (7)
140
18 19 20 21 22 23 24 25
Глава З
let event = parts[0]; let rest = String::from(parts[l]); match event { "UPDATE" 1 "update" => (Event::Update, rest), "DELETE" 1 "delete" => (Event::Delete, rest}, => (Event::Unknown, String::from(line)),
(8)
(8)
(9)
(9)
(10)
26
27 28 fn main() 29 let log "BEGIN Transaction ХК342 30 UPDATE 234:LS/32231 {\"price\": 31.00} -> {\"price\": 40.00} 31 DELETE 342:10/22111"; 32 33 for line in log.lines() let parse_result = parse_log(line); 34 println! ("(:?}", parse_result); 35 36 37
(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,
141
Составные типы данных
Spades, Diamonds, Hearts, enurn Card King(Suit), Queen(Suit), Jack(Suit), Асе(Suit), Pip(Suit, usize),
(1)
(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 1 5.txt is О bytes long
1 f[derive(Debug,PartialEq)] 2 enurn FileState { Open, 3 Closed, 4 5
6 7 f [derive(Debug)] 8 struct File { 9 name: String, 10 data: Vec, 11 state: FileState, 12 13 14 impl File { 15 fn new(name: &str) -> File {
142
File ( 16 narne: String::from(narne), 17 18 data: Vec::new(), 19 state: FileState::Closed, 20 21 22 23 fn read( 24 self: &File, 25 save to: &mut Vec, 26 -> Result 27 if self.state != FileState::Open return Err(String::from("File must Ье open for reading")); 28 29 let mut tmp = self.data.clone(); 30 let read_length = tmp.len(); 31 32 save_to.reserve(read_length); 33 save_to.append(&mut tmp); 34 Ok(read_length) 35 36 37 38 fn open(mut f: File) -> Result { 39 f.state FileState::Open; 40 Ok(f) 41 42 43 fn close(mut f: File) -> Result { 44 f.state = FileState::Closed; 45 Ok(f) 46 47 48 fn main() 49 let mut f5 = File::new("5.txt"); 50 51 let mut buffer: Vec = vec! [i; 52 53 if f5.read(&mut buffer).is_err() { 54 println! ("Error checking is working"); 55 56 57 f5 = open(f5) .unwrap(); 58 let f5_length = f5.read(&mut buffer).unwrap(); 59 f5 = close(f5).unwrap(); 60 61 let text = String::from utf8_lossy(&buffer); 62
Глава 3
Составные типы данных 63 64 65 66
143
println! ("{ :?)", f5); println! ("{) is {) bytes long", &f5.name, f5_length); println! ("{)", text);
Перечисления могут стать эффективным помощником в решении задач по созда нию надежных, пользующихся доверием программ. Обратитесь к их использова нию в своем коде, если придется вводить «строго типизированные» данные, напри мер коды сообщений.
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
1 2 3 4 5 6 7 8 9
#! [allow(unused_variaЫes)]
(1)
#[derive(Debug)] struct File;
(2)
trait Read { fn read( self: &Self, save to: &mut Vec, 10 -> Result; 11
12 13 impl Read for File { 14 fn read(self: &File, save to: &mut Vec) -> Result { 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
(3)
(4)
(5)
(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, повто ряемое в следующем листинге.
#[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"); //
...
pri ntln!
("{:?}",
f5);
(1) (2)
146
Глава 3
//
(1)
(1) Пропущенные строки оригинала (2) Отладка основана на использовании синтаксиса двоеточия и вопросительного знака.
Можно, конечно, положиться на автоматическую реализацию типажа Debug, но что делать, если нужно предоставить собственный текст? Display требует, чтобы в ти пах был реализован метод fmt, возвращающий fmt: : Resul t. Эта реализация пока зана в следующем листинге. .,. ,..,.... .. .... ....,.Щi/''· · ·.'''
····,:.•,•·.,:•••>: fmt::Result match *self { FileState: :Open => write! (f, "OPEN"), FileState: :Closed => write! (f, "CLOSED"),
(4) (4)
impl Display for File { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result [ write! (f, "", self.name, self.state)
(5)
33
34 35 impl File { 36 fn new(name: &str) -> File { File { 37 38 name: String::from(name), 39, data: Vec: :new(), 40 state: FileState::Closed, 41 42 43 44
45 fn main() 46 let f6 = File::new("f6.txt"); 47 //... 48 println! ("{:?}", f6); 49 println! ("{}", f6); 50
(6) (7)
Глава 3
148
(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 общедоступности полей 11ame и state 1 #[derive(Debug,PartialEq)] 2 pub enum FileState {
(1)
149
Составные типы данных 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
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.
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 а time. /// Represents а "file", /// which probaЬly lives on а file system. #[derive(Debug)] рuЬ struct File { name: String, data: Vec,
(1)
(2)
impl File { /// New files are assumed to Ье empty, but а name is required. рuЬ 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.
рuЬ fn name(&self) -> String
self.name.clone()
fn main () let fl = File::new("fl.txt"); let fl_name = fl.name(); let fl_length = fl.len(); println! ("{:?}", fl); println! ("{} is [} bytes long", fl_name, fl_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 f--cargo.toml Lsrc L....rnaiп.rs
152
Глава 3
5. Теперь сохраните исходный код из листинга 3.17 в файле filebasics/src/ m ain. rs, переписав уже находящийся там шаблонный код "Hello World! ".
Чтобы пропустить сразу несколько действий, клонируйте репозиторий. Запустите из окна терминала следующие команды: $ qit clone https:/ /qithuЬ.com/rust-in-action/code rust-in-action $ cd rust-in-action/chЗ/fileЬasics
Для создания НТМ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 /С
@ fitebasics · Rust lito:///tmp/ru,t-in action/ch3/fiteba�:sft•r�•tfdoc/lil•boslcsjind•x.htmt_=··= • ✓ · АП crntes v
,... __ --�- ····-···-··-·· _ �--·== =='--�
Click or press 'S" to search, '?' for morfl optior.s...
Crate fitebasics Crate filebasics Version 0.1.0
See alt filebasics's items Struct.s Funct!o.ns
crates
f- J Simulating files one step at а time. Structs File Represents а 'file'\ which рrоЬаЫу lives on а file system.
Functions maiп
fiLeЬasics
Рис. 3.4. Визуализация вывода команды cargo doc
[-][src]
Составные типы данных
153
Если добавить к команде ключ --open, ваш веб-браузер запустится автоматически. Отображаемая в нем документация показана на рис. 3.4. СОВЕТ Если в контейнере много зависимостей, процесс сборки может затянуться. В таком случае пригодится команда сагgо doc с ключом --no-deps. Добавление --no-deps может существенно сократить объем работы, выполняемой rustdoc.
Инструмент rustdoc поддерживает визуализацию форматированного текста, напи санного на Markdown. Это позволяет добавлять в документацию заголовки, списки и ссылки. Фрагменты кода, заключенные в тройные обратные кавычки (' , , ), полу чают выделение синтаксиса.
1 2
//! Simulating files one step at а time.
3
4 impl File { /// Creates а пеw, empty 'File'. 5 /// 6 7 /// # Examples 8 /// 9 /// 10 /// let f = File::пew("fl.txt"); 11 /// 12 рuЬ fп пеw(паmе: &str) -> File File { 13 14 паше: String::from(name), 15 data: Vec::пew(), 16 17 18
Резюме ♦ Структура s t ruct - базовый составной тип данных. В сочетании с типажами структуры наиболее близки к объектам из других областей. ♦ Перечисление eпum эффективнее простого списка. Преимущество enum заключа ется в его способности работать с компилятором для рассмотрения всех крайних случаев. ♦ Методы добавляются к типам через блоки impl.
♦ В Rust можно использовать глобальные коды ошибок, но это может быть слиш ком обременительно и обычно не приветствуется.
154
Глава З
♦ Тип результата
Resul t выступает в качестве механизма, которому сообщество Rust отдает предпочтение для сообщения о возможной ошибке.
♦ Типажи в Rust-программах обеспечивают общее поведение.
♦ Данные и методы остаются закрытыми до тех пор, пока они не будут объявлены общедоступными с помощью ключевого слова pub.
♦ Cargo можно использовать для создания документации по контейнеру и всем его зависимостям.
4 Время жизни, владение NTJl!IP 1J
и заимствование н
М:Тоr Dai.АtUMJtdiiiЫI
fj 11
111!1
11
В этой главе рассматриваются следующие вопросы: ♦ Что означает понятие «время жизни» в программировании на Rust. ♦ Работа с контролером заимствований, а не против него. ♦ Несколько тактических приемов решения возникающих проблем. ♦ Осознание обязанностей владельца. ♦ Освоение приемов заимствований значений, принадлежащих коду, находящемуся в другом месте программы. В этой главе объясняется суть одной из концепций, на которой обычно спотыкают ся новички, приступающие к освоению языка Rust - имеющегося в нем контроле ра заимствований. Контролер заимствований (Ьоттоw checker) проверяет закон ность любого доступа к данным, что позволяет Rust избежать проблем с безопасно стью. Изучение порядка его работы, как минимум, сократит время разработки, помогая избежать ссор с компилятором. Но еще важнее то, что освоение работы с контролером заимствований позволяет с уверенностью создавать более крупные программные системы. Именно он положен в основу понятия «бесстрашный парал .1елизм». В этой главе будет объяснено, как работает эта система, и вы узнаете, как ее со блюдать при обнаружении ошибки. Для объяснения компромиссов, связанных с различными способами предоставления совместного доступа к данным, здесь ис пользуется отчасти возвышенный пример моделирования спутниковой группиров ки. В этой главе дается подробный разбор контроля заимствований. Но читателям, желающим быстро вникнуть в суть, может быть полезен ряд моментов. Проверка заимствований основана на трех взаимосвязанных понятиях: времени жизни, вла .Jении и заимствовании: • Владение - это распространенная метафора. Оно не имеет ничего общего с правами собственности. Владение в Rust связано с избавлением от значений, в которых больше нет надобности. Например, функция возвращает управле ние, необходимо освободить память, содержащую ее локальные переменные. Владельцы не могут запретить другим частям программы получать доступ к их значениям или же сообщать о хищении данных в какой-либо действую щий во всей программе механизм защиты данных. • Время жизни значения - это период, в течение которого доступ к этому зна чению - допустимое поведение. Локальные переменные функции живут до
156
Глава 4
тех пор, пока функция не вернет управление, а глобальные переменные могут жить в течение всего времени жизни программы. • Позаимствовать значение означает получить к нему доступ. Эта терминоло гия может показаться странной из-за отсутствия обязательств возвращения значения владельцу. Ее суть призвана подчеркнуть возможность общего дос тупа к значениям из многих частей программы при наличии у них одного владельца.
4.1. Реализация имитации наземной станции CubeSat Стратегическая задача главы - воспользоваться компилируемым примером. Затем будет внесено небольшое изменение, вызывающее ошибку, которая, как представ ляется, возникает без каких-либо изменений в ходе выполнения программы. Работа по устранению проблем должна дать более полное представление о всей концепции. Примером для изучения этой главы станет группировка микроспутников CubeSat. Если сведения о ней вам еще не попадались, ознакомьтесь со следующими опреде лениями: • CubeSat - миниатюрный, по сравнению с обычными, искусственный спутник, благодаря которому расширяется доступность космических исследований. • Наземная станция (Ground station)- посредник между операторами и сами ми спутниками. Она отслеживает радиосигналы, проверяет состояние каждо го спутника, входящего в группировку, и осуществляет двусторонний обмен сообщениями. После ввода в действие нашего кода она работает как шлюз между пользователем и спутниками. • Группировка (Constellation )- собирательное существительное для спутни ков на орбите.
fl
--,_\\' 1
Наземная станция
Рис. 4.1. CubeSats на орбите
На рисунке 4.1 показаны три спутника CubeSat. Чтобы смоделировать ситуацию, создадим переменную для каждого из них. На данный момент эта модель может
157
Время жизни, владение и заимствование
успешно реализовывать целые числа. Моделировать наземную станцию явным образом не нужно, поскольку отправка сообщений по группировке еще не осуще ствляется. Пока эта функция модели опускается. А переменные имеют следую щий вид:
let sat а = О; let sat_b = 1; let sat_c = 2;
Для проверки состояния каждого из наших спутников будет использоваться функ ция-заглушка и перечисление, представляющее возможные сообщения о состоянии: #[derive(Debug)] enum StatusMessage Ok,
(1)
fn check_status(sat_id: u64) -> StatusMessage { StatusMessage::Ok
(1)
(1) На данный момент со всеми нашими спутниками все в порядке.
В реально работающей системе функция check_status () была бы слишком слож ной. Но для наших целей вполне достаточно возвращать всякий раз одно и то же значение. Вставив эти два фрагмента в целую программу, которая дважды «прове ряет>> наши спутники, мы получим примерно следующий листинг. Код листинга находится в файле ch4/ch4-check-sats-1.rs.
1 #! [allow(unused_variaЫes)] 2 3 #[derive(Debug)] 4 enum StatusMessage 5 Ok, 6 7
8 fn check_status(sat_id: u64) -> StatusMessage { 9 StatusMessage: :Ok 10 11
12 fn main () { 13 let sat а = О; let sat Ь = 1; 14 15 let sat_c = 2; 16 let а status check_status(sat_a); 17 18 let Ь status check_status(sat_b);
(1) (1) (1)
158 19 20 21 22 23 24 25 26 27
Глава 4 let c_status = check_status(sat_c); println! ("а: {:?}, Ь: (:?}, с: {:?}", a_status, b_status, c_status); / / "ожидание" ... let а status check_status(sat_a); let Ь status = check_status(sat_b); let с status = check_status(sat_c); println! ("а: {:?}, Ь: {:?}, с: {:?}", a_status, b_status, c_status);
(1) Переменная каждого спутника представлена целым числом.
Выполнение кода листинга 4.1 должно проходить без осложнений. Код, пусть не охотно, но все же компилируется. На выходе получается следующий результат: а: Ok, Ь: Ok, с: Ok а: Ok, Ь: Ok, с: Ok
4.1.1. Выявление первой проблемы, связанной со временем жизни Давайте приблизимся к особенностям Rust и введем безопасность типов. Создадим вместо целых чисел тип, предназначенный для моделирования наших спутников. В жизни реализация типа CubeSat, наверное, будет включать массу информации о его местоположении, диапазоне радиочастот и многом другом. В следующем лис тинге наш выбор остановлен только на записи идентификатора. Листинг 4.2. Мо
ование CuЬeSat в �
#[derive(Debug)] struct CubeSat { id: u64,
Получив определение структуры, внедрим его в наш код. Следующий листинг не пройдет (пока) компиляцию. Подробный разбор причин этого и есть цель основной части этой главы. Код листинга находится в файле ch4/ch4-check-sats-2.rs.
1 #[derive(Debug)] 2 struct CubeSat { id: u64, 3 4 5
(1)
159
Время жизни, владение и заимствование
6 7 8 9 10 11 12 13 14 :s 16 17 :в �9 20 21 22 23 24 25
#[derive(Debug)] enum StatusMessage Ok, fn check_status( sat id: CubeSat -> StatusMessage StatusMessage::Ok fn main(){ let sat а let sat Ь let sat с
= = =
CuЬeSat CubeSat CuЬeSat
(2)
id: о }; id: 1 }; id: 2 };
(3) (3) (3)
let а status check_status(sat_a); let Ь status check_status(sat_b); let с status check_status(sat_c); println!("a: {:?), Ь:{:?}, с:{:?}", a_status, b_status, c_status);
26 27 28 29 30 31
// "waiting" let а status check_status(sat_a); let Ь status = check_status(sat_b); let с status = check_status(sat_c); println!("a: {:?}, Ь:{:?}, с:{:?}", a_status, b_status, c_status);
32 1 1)
Модификация 1: добавление определения . Модификация 2: использование нового типа в check_status(). (3) Модификация 3: создание трех новых экземпляров. 12)
При попытке скомпилировать код листинга 4.3 выдается примерно следующее ( специально укороченное) сообщение: �rror[E0382]: use of moved value: 'sat_a' --> code/ch4-check-sats-2.rs:26:31 20
let а status
check_status(sat_a); ----- value moved here
26
let а status
check_status(sat_a); value used here after move
=
note: move occurs because 'sat_a' has type 'CubeSat', which does not implement the 'Сору' trait
(1)
(2)
(3) (4) (4)
160
Глава 4 (5)
error: aЬorting du e 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().
fn main () { let sat а // . . .
=
CuЬeSat { id: О);
let а status // . . .
check_status(sat_a);
(1) (2) (3) (2)
1 Помните фразу «абстракции с нулевыми затратами»? Один из способов ее проявления - отказ от добавления дополнительных данных в отношении значений внутри структур.
161
Время жизни, владение и заимствование
// "waiting" let а status // . . . (1) (2) (3) (4)
check status(sat_a);
(4) (2)
Владение изначально возникает эдесь при создании объекта 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 особое поведе ние. В них реализован типаж сору.
Глава 4
162 Ход выполнения программы листинга 4.4 main() l let sat
а
Время жизни sat_a Создано и находится во владении main {)
= CubeSat ( )
Владение переходит к check_status ()
1 heck_status (sat_a) c �------..' '' ' drop ( sat ) _а __ _ _ _______________ !
!_
---------------------------
'
return StatusMessage: :Ok
Подразумеваемое удаление находящихся ,, ,, во владении значений по завершении , .,1 ,,,______ . -·. -------• • •- . .+-.---------- . . 1 1 I существования области видимости владельца . _ •..•� Повторное обращение к sа t а теперь недействительно; Этого удаления нет в исходном коде этот код не пройдет компиляцию ''' ,,,, ,, '' ,, c he �:������-��:-��--:�-------- __ i' : ' ' ' _ ! ' sat o j d � p( _а) _ ____ ______________ 1 ', �
.
i i
i
v·•---·--·---- . ---------�
1
.,1!/
1
�---------�
i i: !----�- ------------------------- ______________________ i :
:'
return StatusMessage:: Ok
:
'
/
j
Рис. 4.2. Визуальное объяснение перехода владения в Rust
Типы, в которых реализован типаж сору, иногда копируются, что в иных обстоя тельствах бьmо бы недопустимо. Это, конечно, предоставляет ряд насущных удобств, но все же не обходится без ловушки для новичков. Вырастая из игрушеч ных программ, использующих целые числа, новичок сталкивается с тем, что его код внезапно ломается. Формально элементарные типы обладают семантикой копирования, а все другие типы имеют семантику перемещения. К сожалению, для изучающих Rust этот осо бый случай выглядит как поведение по умолчанию, поскольку новички обычно на чинают работать с элементарными типами. Различие двух концепций показано в кодах листингов 4.5 и 4.6. Код первого листинга компилируется и запускается, а код второго - нет. Единственное, чем отличаются коды листингов, - это исполь зование разных типов. В следующем листинге показаны не только элементарные типы, но и типы, реализующие сору.
1
2
3
fn use_value(_val: i32) {
(1)
163
Время жизни, владение и заимствование 4 fn main() { let а = 123 5 use_value(a); 6 7 println! ("{ )", а); 8 9 10
(2)
(1) use_value() получает во владение аргумент _val. Функция usе_vаluе()универсальна, поскольку она используется в следующем примере. (2) Получение доступа к а после возвращения из вызова use_value()впoлнe законно.
В следующем листинге основное внимание уделяется тем типам, в которых не реа лизован типаж Сору. При использовании значений в качестве аргумента той функ ции, которая становится их владельцем, получить к этим значениям новый доступ из внешней области видимости уже невозможно.
1 2
fn use_value(_val: Demo) {
4
struct Demo а: i32,
(1)
3
s
6 7
fn main() { let demo = Demo { а: 123 ); 10 use_value(demo);
3
э
ll
12 :3
println! ("{ )", demo.a);
(2)
(1) use_value() становится владельцем _val. (2) Доступ к demo.a невозможен даже после возвращения из вызова use_value().
4.2. Справочник по рисункам, используемым в этой главе Чтобы проиллюстрировать три взаимосвязанных концепции - область видимости, время жизни и владение - на рисунках, используемых в этой главе, применяются специальные обозначения. Эти обозначения показаны на рис. 4.3.
164
Глава 4
Символы
"fj sat-а в,..) sat_b
(j'
sat_c
�base
ma i n ( ) в1,1з1,1вается
Пример
base, sat_a, sat Ь и sat_ с создаются
[iigJ StatusMessage: :Ok
11 Консоль ff
sat _с - единственн�.,й аргумент для .._____.� check_status ()
Действия Создание значения � Символ появился �
Сообщение выводится на консол1,
Удаление значения � Символ зачеркнут �
StatusMessage: :ОК создается, а затем удаляется
,,,------, Аргумент�.,
Вызов функции
sat_с возвращается
base, sat_a, sat_b
и sat_c удаляются
�•••Ф'""""
..._______ Возвращаем�.,е значения
Вывод на консоль
··--► 111
Рис. 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 rnain() { let sat_a // . . .
=
CuЬeSat { id: О);
Затем объект CubeSat передается в качестве аргумента в функцию check _ s tatus (). В результате этого владение переходит к локальной переменной s a t_id: fn rnain() { let sat_a = CubeSat { id: О); // . . . let a_status = check_status(sat_a); //
...
Еще одна возможность заключается в том, что s а t_а уступает владение другой пе ременной в функции rnain () . Это может иметь следующий вид: fn rnain() { let sat а //
...
//
...
=
CubeSat { id: О);
let new sat а
=
sat а;
И наконец, при наличии изменения в сигнатуре функции 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);
sat id
(2)
(1) Использование синтаксиса DеЬug-форматирования, поскольку наши типы имеют пометку # [derive(Debug)] (2) Возвращение значения с опусканием точки с запятой в конце последней строки.
С помощью скорректированной функции check_ s tatus (), используемой вместе с новой функцией main () , можно передать владение объектами cuьesa t обратно их исходным переменным. Соответствующий код показан в следующем листинге. Его источник находится в файле ch4/ch4-check-sats-3.rs.
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_variaЬles)] #[derive(Debug)] struct CubeSat { id: u64,
#[derive(Debug)] enum StatusMessage Ok,
fn check_status(sat_id: CuЬeSat) -> CuЬeSat { println ! (" { : ? } : { : ? } ", sat_id, StatusMessage::Ok); sat id
fn main () { let sat а CuЬeSat let sat Ь = CubeSat let sat с CubeSat let sat а let sat Ь let sat с
id: о }; id: 1 }; id: 2 1;
check_status(sat_a); check_status(sat_b); check_status(sat_c);
// "waiting" let sat а let sat Ь
check_status(sat_a); check_status(sat_b);
(1)
167
Время жизни, владение и заимствование
31 32
let sat с
check_status(sat_c);
(1) Теперь, когда возвращаемое значение check_status() является исходным sat_a, новая привязка let сбрасывается.
Результат работы новой функции щим образом:
из листинга 4.7 теперь выглядит следую
main ()
CuЬeSat { id: О }: Ok CuЬeSat id: 1 }: Ok CubeSat id: 2 }: Ok CuЬeSat id: О }: Ok CuЬeSat id: 1 }: Ok CuЬeSat id: 2 ): Ok
На рис. 4.4 показано визуальное представление перехода владения в коде листинга 4.7.
-
Ход выполнения программы
Переходы владения
sat а
sat Ь
sat с
В ходе инициализации создаются три экземпляра CubeSat
.----,
sat id sat а
@.;--....._
""'
"'e?��?,q,,,.
При каждом вызове check_status () владение одним из экземпляров переходит локальной переменной функции s а t i d, а затем возвращается в rna i n ( )
sat id
,-.-- ul6 { 11 let р = self.position_in_memory; 12 let op_bytel self.memory[p] as ulб; 13 let op_byte2 self.memory[p + 1] as ul6; 14 15 op_bytel > 12) as u8;
» 8) as u8;
>> 4) as u8; >> О) as u8;
let nnn = opcode & 0x0FFF; // let kk = (opcode & 0x00FF) as u8; match (с, х, у, d) { => ( о, О, О, О) ( о, о, ОхЕ, ОхЕ) => => (Ох2, ) (Ох8, ' Ох4) => =>
{ return; }, self.ret(), self. call(nnn), self.add_xy(x, у)' todo!("opcode {:04х}", opcode),
38
39 Н .;2 .;3 .;4 45 .;6 .;7
.;9 �9 50 51 52 53 54 55 56 57 58 59 50 51 52 53 54 65 66 67
fn call(&mut self, addr: ul6) { 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 == О { 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, х: u8, у: u8) { let argl self.registers[x as usize]; let arg2 = self.registers[y as usize];
232
Глава 5
68 let (val, overflow_detected) = argl.overflowing_add(arg2); self.registers[x as usize] = val; 69 70 71 if overflow_detected { 72 self.registers[OxF] 1; 73 else { 74 self.registers[OxF] О; 75 76 77 78 79 fn rnain() 80 let rnut cpu = CPU { registers: [О; 16], 81 rnernory: [О; 4096], 82 83 position_in_rnernory: О, 84 stack: [О; 16], 85 stack_pointer: О, 86 }; 87 88 cpu.registers [О] 5; 89 cpu.registers[l] 10; 90 91 let rnern = &rnut cpu.rnernory; 92 rnern[ОхООО] Ох21; rnern[OxOOl] ОхОО; Ох21; rnern[Ox003] ОхОО; 93 rnern[Ox002] 94 rnern[Ox004] ОхОО; ОхОО; rnern[Ox005] 95 96 rnern[OxlOO] Ох80; rnern[OxlOl] Ох14; 97 rnern[Ox102] Ох80; rnern[Ox103] Ох14; 98 rnern[Ox104] ОхОО; rnern[Ox105] ОхЕЕ; 99 100 cpu. run(); 101 102 assert_eq! (cpu.registers[O], 45); 103 println! ("5 + (10 * 2) + (10 * 2) {}", cpu.registers[О]); 104 (1) Установка для кода операции (2) Установка для кода операции (3) Установка для кода операции имеет, поскольку cpu.rnernory (4) Установка для кода операции к значению регистра О. (5) Установка для кода операции к значению регистра О. (6) Установка для кода операции
значения Ох2100: значения Ох2100: значения ОхОООО: инициализирована значения Ох8014:
(1)
(2)
(3) (4) (5) (6)
вызов (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. Фрагменты памяти объемом в байт или даже в от дельный бит обозначаются другими фигурами. • Прямоугольником с закругленными углами под надписью «Значение» обо значаются три смежных блока памяти.
Указатель
qJ .[
Значение
___,___,'------'
Указатели обычно обозначаются стрелками. Внутри компьютера они кодируются в виде целого числа (эквивалентного usi ze), являющегося адресом памяти объекта ссылки (данных, на которые ссылается указатель).
Рис. 6.1. Условные обозначения, используемые на рисунках главы для иллюстрации указателя. Чаще всего указатели в Rust встречаются в виде &Т и &mut Т, где Т-это тип значения.
Новички относятся к указателям настороженно. Их надлежащее применение требу ет достоверных сведений о месте программы в памяти. Представьте, что в оглавле нии началом главы 4 указана страница 97, но фактически глава начинается со стра ницы 107. Досадно, но вполне преодолимо. Компьютер не испытывает разочарований. Ему также не свойственна интуиция, подсказывающая, что его направили не туда. Он просто продолжает работу, полагая, что ему указано верное место в памяти. Боязнь указателей связана с воз можностью возникновения ошибки, не поддающейся отладке. Данные, хранящиеся в памяти программы, можно представить разбросанными где то на просторах физической оперативной памяти компьютера. Чтобы воспользо ваться оперативной памятью, должна быть некая система извлечения данных. Та кой системой является адресное пространство. Указатели кодируются в виде адресов памяти, представленных целыми числами типа usize. Адрес указывает на место в адресном пространстве. Пока что адресное пространство нам будет достаточно представить в виде всей оперативной памяти, вытянувшейся в одну большую строку. А зачем адреса памяти кодируются типом usize? Ведь 64-разрядных компьютеров с оперативной памятью размером 2 64 байт просто не бывает. Диапазон адресного пространства - это видимость, обеспечиваемая операционной системой и цен тральным процессором. Программам известна только упорядоченная последова тельность байтов, не зависящая от объема оперативной памяти, фактически дос-
Память
237
тупной в системе. Как это работает, станет ясно чуть позже, когда в этой главе дой .1ет очередь до раздела, повествующего о виртуальной памяти. ПРИМЕЧАНИЕ Еще один интересный пример- тип Option. Чтобы обеспечить для Option в скомпилированном двоичном файле нулевой расход памяти, в Rust используется оп тимизация нулевого указателя. Нулевым указателем представлен вариант None (ука затель на неверное место памяти), позволяющий варианту Some (Т) не иметь допол нительных косвенных ссылок.
В чем разница между ссылками, указателями и адресами памяти? В схожести ссылок, указателей и адресов памяти нетрудно запутаться: • Адрес памяти, часто сокращаемый просто до адреса, - это число, относя щееся к одному байту в памяти. Адреса памяти - абстракции, предоставляе мые �зыками Ассемблера. • Указатель, иногда называемый в развернутом виде обычным указателем, представляет собой адрес памяти, указывающий на значение какого-либо ти па. Указатели, по сути, - абстракции, предоставляемые языками более высо кого уровня. • Ссылка представляет собой указатель или, в случае с типами с динамически ми размерами, указатель и целое число с дополнительными гарантиями. Ссылки - абстракции, предоставляемые языком Rust. Компиляторы умеют определять диапазоны допустимых байтов для многих типов. Например, когда компилятор создает указатель на i32, он может проверить нали чие четырех байтов, в которых закодировано целое число. Это полезнее простого наличия адреса памяти, на который может быть (а может и не быть) указатель на какой-либо допустимый тип данных. К сожалению, ответственность за гарантию допустимости типов, размер которых неизвестен в ходе компиляции, несет про граммист. У Rust-ccылoк имеются существенные преимущества перед указателями: • Ссылки всегда указывают на решzыю существующие данные. Rust-ссылки могут использоваться только при наличии разрешенного доступа к данным, на которые они ссылаются. Полагаю, вам уже известен этот основной прин цип Rust! • Ссылки корректно выравнены по кратным usize. По техническим причинам центральные процессоры крайне негативно реагируют на требование извлечь данные без выравнивания памяти. Их работа резко замедляется. Для устране ния проблемы в типы Rust включаются байты заполнения, чтобы создание ссьшок на них не замедляло работу программы. • Ссылки гарантируют производительную работу с типами, имеющими дина мически изменяемый размер. Для типов, не имеющих фиксированную шири ну размещения в памяти, Rust гарантирует, что их размер будет сохраняться
Глава 6
238
рядом с внутренним указателем. Таким образом, Rust способен гарантиро вать, что программа никогда не переполнит пространство, выделяемое типу в памяти компьютера. ПРИМЕЧАНИЕ Адреса памяти отличаются от двух абстракций более высокого уровня тем, что у по следних имеется информация о типе данных, на которые они ссылаются.
6.2. Исследование типов ссылок и указателей, имеющихся в Rust В этом разделе рассматривается работа в Rust с несколькими типами указателей. При этом в книге «Rust в действии» превалирует стремление придерживаться сле дующих правил: • Ссылки - свидетельства о том, что Rust-компилятор предоставит свои гаран тии безопасности. • Указатели - более примитивный механизм. Тут также нужно понимать, что вся ответственность за обеспечение безопасности ложится на нас. (Считается, что это небезопасно.) • Обычные указатели - используются для типов, на небезопасную природу которых нужно указать явным образом. В этом разделе будет подробно рассмотрен общий фрагмент кода, представленный в листинге 6.1. Его код находится в файле ch6/ch6-pointer-intro.rs. В коде листинга имеются две глобальные переменные, в и с, на которые указывают ссылки. В этих ссылках содержатся, соответственно, адреса в и с. Сразу за кодом следует иллюст рация происходящего, представленная на рис. 6.2 и 6.3.
static В: [u8; 10] static С: [u8; 11]
[99, 97, 114, 114, 121, 116, 111, 119, 101, 108]; [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, О];
fn main () let а = 42; let Ь = &В; let с = &С; println! ("а: {}, Ь:{:р}, с: { :р}", а, Ь, с);
(1) (2) (3)
(1) С целью упрощения в примере используется один и тот же ссылочный тип. В следующих примерах используются не обычные, а интеллектуальные указатели, для которых нужны другие типы. (2) Синтаксис{: р} требует от Rust отформатировать переменную в виде указателя и вывести на экран адрес памяти, на который указывает ее значение.
Память
239 Переменные с и Ь являются ссылками. Ссылки имеют ширину 4 байта на 32-раэрядных процессорах и 8 байтов на 64-раэрядных процессорах.
�
� -··-·--
ci
1
Исходя из предположения, что у а тип i З 2, под эту переменную отводится 4 байта памяти
i
;
Jt
1
V
/
i
ь1
/
в
99
97
116 104
97
11 О 107
\
а
i
114
/
! 42
:
114
119 101
121
116
111
115 102
105
115 104
108
о
,
······ ·····--· ..........•.•.•.
l
:
:
1
i
Неполное представление адресного пространства программы
Рис. 6.2. Абстрактное представление совместной работы двух указателей со стандартным целым числом. Здесь важно понять, что программист заранее может не знать о расположении данных, на которые нацелен указатель.
В коде листинга 6.1 внутри функции rnain () имеются три переменные. Переменная а не представляет собой ничего особенного, это просто целое число. А вот две дру гие переменные намного интереснее. Переменные ь и с - ссылки. Они ссьmаются на два неявных массива данных: в и с. Давайте пока будем рассматривать Rust ссылки в качестве эквивалента указателей. Данные, выводимые на экран при одно кратном выполнении программы на 64-разрядном компьютере, имеют следующий вид: а: 42, Ь: 0x556fd40eb480, с: 0x556fd40eb48a (1) (3) При запуске кода на вьmолнение конкретные адреса памяти на вашем компьютере будут другими.
На рис. 6.3 тот же пример представлен в воображаемом адресном пространстве размером 49 байт. Ширина указателя в нем составляет два байта (16 бит). Нетрудно заметить, что переменные ь и с выглядят в памяти по-разному, несмотря на то что они того же типа, что и в листинге 6.1. Дело в том, что листинг вас обманывает. Вскоре будут предоставлены подробные сведения и пример кода, более точно от ражающие схему, показанную на рис. 6.3. Судя по рис. 6.2, изображение указателей на разобщенные массивы в виде стрелок не обошлось без проблем. Такие указатели, как правило, приуменьшают важность непрерывности адресного пространства и его совместного использования всеми переменными.
240
Глава 6
Поле длины
u16 (16
Схема памяти
== Ох10}
их преобразования
1
С Ох15
Охlб
Oxl 7
OxD
ОхЕ
OxF
OxlO
Oxll
Ох4
Ох5
Охб
Ох?
Ох8
115
ОхА
102
105
Знание способов
в Rust-тиnы пригодится при работе с внешним кодом через интерфейс внешних функций.
i16
Ох2А
внутренним
строк на языке С.
Целое число
Поле адреса
u16 (32 == Ох20} i16 �----�
являющийся
представлением
а
Обычный указатель Интеллектуальный указатель
Абстрактный тип данных Конкретное представление
Буфер с кодом завершения в виде нуля,
ь
с
Переменная
Oxl
Ох2
в совокупности
ОхЗ
115
116
NULL-бaйт - мертвая зона программы. Если указатель нацелен на это место, а затем он разыменовывается, то это обычно
104
закаживается аварийным завершением программы.
в Буфер фиксированной ширины размером в 10 байтов, содержащий байты без кода завершения. Буфер, используемый после типа указателя, часто называют вспомогательным массивом.
в совокупности ь и в близки к созданию Rust-тиna String, в котором также содержится параметр емкости.
в системе типов Rusl, с и с представляют собой тип cstr.
Рис. 6.3. Наглядное представление адресного пространства программы, показанной в листинге 6.1. Здесь иллюстрируется взаимосвязанность адресов (которые обычно записываются в виде шестнадцатеричных чисел) и целых чисел (которые обычно записываются в десятичном формате). Светлыми клетками представлена неиспользуемая память.
Чтобы детальнее разобраться в происходящем во внутренней «кухне», у вывода на экран, осуществляемого кодом листинга 6.2, - расширенное информационное на полнение. Для демонстрации внутренних различий и более точного соотнесения друг с другом всего представленного на рис. 6.3 вместо ссылок в нем используются более сложные типы. При запуске кода листинга 6.2 на выполнение получается следующий результат: а (an unsigned integer}: location: Ox7ffe8f7ddfd0 size: 8 bytes 42 value: Ь (а reference to В}: location: Ox7ffe8f7ddfd8 size: 8 bytes points to: Ох55876090с830
Память
241
с (а "Ьох" for С): location: 0x7ffe8f7ddfe0 size: 16 bytes points to: Ох558762130а40 в (an array of 10 bytes): location: Ох55876090с830 10 bytes size: [99, 97, 114, 114, 121, 116, 111, 119, 101, 108] value: � (an array of 11 bytes): location: Ох55876090с83а 11 bytes size: [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, О value:
1 use std::mem::size_of; 2 [99, 97, 114, 114, 121, 116, 111, 119, 101, 3 static В: [u8; 10] 108]; 4 static С: [u8; 11] [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, О]; 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
fn main () { let а: usize
=
42;
(1)
let Ь: &[u8; 10]
=
&В;
(2)
let с: Box
=
Box::new(C);
(3)
println!("a (an unsigned integer):"); println!(" location: {:р)", &а); println!(" size: { :?) bytes", size_of::()); println!(" value: {:?)", а); println!(); println!("Ь (а reference to В):"); println!(" location: {:р)", &Ь); println!(" size: {:?) bytes", size_of::()); println!("points to: {:р)", Ь); println!(); println!("с (а "Ьох" for С):"); println! (" location: {:р}", &с); println!(" size: {:?} bytes", size_of:: и «адрес памяти» используются как синонимы. Это целые числа, представляющие собой место в виртуальной памяти. Но с позиции компилятора имеется одно важное отличие. Типы Rust-указателей *const т и *mut т всегда нацелены на начальный байт т, и им также известна ширина типа т в байтах. А адрес памяти может относиться к любому месту в памяти. Тип i64 имеет ширину 8 байт (64 бита при 8 битах на байт). Следовательно, если i64 хранится по адресу Ox7fffd, то для воссоздания целочисленного значения из оперативной памяти должен быть извлечен каждый из байтов диапазона Ox7ffd ..Ох8004. Процесс выборки данных из оперативной памяти называется разыменованием указателя. В следующем листинге адрес значения идентифици руется путем приведения ссылки на него в обычный указатель посредством std: :mem::transmute.
Память
245
fn main() let а: i64 = 42; let a_ptr = &а as *const i64; let a_addr: usize = unsafe { std::mem::transmute(a_ptr) }; println! ("а:
{} ({:р} ... Ох{:х})", а,
(1)
a_ptr,
а
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! ("{:р} -> {:р}", ptr, new_addr);
(1) Безопасное создание указателей возможно из любого целочисленного значения. Тип это не Vec, но здесь Rust просто игнорирует это обстоятельство.
i32
Глава 6
246
Повторим: обычные указатели небезопасны. Им присущи некоторые свойства, оп ределяющие крайнюю нежелательность их повседневного использования в Rust кoдe: • Обычные указатели не владельцы своих значений. При обращении к ним ком пилятор Rust не проверяет доступность данных, на которые они указывают. • Допускается использование нескольких обычных указателей на одни и те же данные. Каждый обычный указатель может иметь доступ к записи или к чте нию и записи данных. Это означает, что Rust не может гарантировать дейст вительность совместно используемых данных. Но несмотря на все эти предостережения, для использования обычных указателей есть ряд веских причин: • Без них просто не обойтись. Возможно, обычный указатель потребуется для какого-нибудь вызова операционной системы или стороннего кода. Обычные указатели получили широкое распространение в коде С, предоставляющем внешний интерфейс. • Нужно также учесть важность совместного доступа и первостепенную роль производительности в ходе выполнения программы. Возможно, в вашем приложении равный доступ к какой-либо переменной, вычисляемой с боль шими затратами, требуется сразу нескольким компонентам. Если вы соглас ны пойти на риск и допустить, что один из этих компонентов сможет нару шить работу какого-либо другого компонента некой допущенной в нем глу пой ошибкой, тогда в качестве крайнего варианта можно выбрать обычные указатели.
6.2.2. Экосистема указателей Rust Обычные указатели небезопасны, а есть ли более безопасный выбор? Альтернати вой может послужцть использование интеллектуальных указателей. Под ними в сообществе Rust подразумевается тип, обладающий помимо способности опреде лять адрес памяти несколькими дополнительными свойствами. Также по этой теме может встретиться термин тип-оболочка. Как правило, типы интеллектуальных указателей Rust служат оболочкой для обычных указателей и наделяют их допол нительной семантикой. В С-сообществах бытует более узкое определение интеллектуального указателя. Его авторы обычно подразумевают под этим понятием имеющиеся в С эквиваленты Rust-типов core: : ptr: : Unique, core: : ptr: : Shared и std: : rc: : Weak, знакомство с которыми состоится чуть позже. ПРИМЕЧАНИЕ Есть такое понятие, как «толстый указатель», которое относится к размещению в па мяти. Тонкие указатели, вроде обычных, имеют одинарную usizе-ширину. Толстые указатели обычно шире в два и более раз.
Память
247
·Обычный указатель
ДвоюJюдные братья +mut '!' и "const Т-сsободt!Ь�ерадикалы мира указатепей. Невероятно быстрые , но крайне небезоnасные. Сильные стороны • Co,pocn, • Возм ожность вэаимодейспюеать с внешним миром
Слабые сторонь1 • НебеЭО11аС1ЮСТЬ
Вох
Rc
Агс
Все храюп в ynal(oeкe. Подходит д ля дпительюrо хране141я f1)ЭКТИЧеСКИ пюбоrо типа. Рабочая лошадка новой эры безоnэсноrо nроrраммирования.
УказатеtЬ с rюдсчеrом ссыrкж. Rc-делЬliЬlй, но ску пой бухrа nтер Rust. ет Он зна , к то, что и коrда ооэаимстеовал.
Arc-� ryeдcra6ИТesьRust Сrособен обесnечить соемесnюе VО'Юt'IЬЗОIЭсНtе ЗН3ЧеК1Й р аэнь�ми потоками, гаранmруя, чtо ож не б удуr мешаrь/JP'fТ /JP'ffY.
Сильные стороны • Хранит значение в цеmралыюм ,q,анилище е местеrюд названием скуча »
Слабые стороны • Увеличею1е размера
Ск.nьнь1е стороны Слабые стороны • С овместный достул • Увеличение размера к значениям • Издержки времени ВЫfЮЛtiеНИЯ • Отtутстане nоnжобеэоnас№Сти
Cell
RefCell
Cow
Будучи мастером по nреsращениям, Cell. позволяет 11зменять неизменяемь1е значения.
ПоЗ80ЛЯе1 вносить изменения в неизменяемые ссылки с гюмощью RefCel. Ero nоразитепьные сnосо6ности i. e Qбходятся без определенных иэдерЖеt bool { password.into() .len() > 5 Но эта стратегия неявного преобразования сопряжена с серьезными рисками. При необходимости неоднократного создания в конвейере строковой версии перемен ной пароля было бы гораздо эффективнее потребовать явного преобразования, вы полняемого в вызывающем приложении. Тогда значение типа Strin g будет создано единожды, а использовано многократно.
6.3.2. Куча В этом разделе рассматривается куча. Это область программной памяти для тех ти пов, размер которых в ходе компиляции еще не известен. Почему в ходе компиляции может быть неизвестен размер? В Rust на это есть две причины. Некоторые типы по мере надобности меняются в размере в обе стороны. Очевидные примеры - string и Vec. Есть и другие типы, неспособные сооб щить Rust-компилятору, сколько памяти под них выделять, несмотря на то что их размер в ходе выполнения программы не меняется. Их называют типами с динами чески определяемым размером. Зачастую в качестве примера приводятся слайсы ( [т J ). У слайсов на момент компиляции отсутствует длина. Слайс по сути - указа тель на какую-то часть массива. Но фактически слайсы представляют некоторое количество элементов этого массива.
Память
253
Еще одним примером может послужить типажный объект, который пока не рас сматривался в книге. Типажные объекты позволяют Rust-программистам имитиро вать некоторые особенности динамических языков, допуская помещение несколь ких типов в один и тот же контейнер.
Что такое куча? Полное представление о куче сложится после проработки следующего раздела, по священного виртуальной памяти. А пока выясним, что не является кучей. Как толь ко будет усвоен этот пункт, мы вернемся к выявлению истины. Слово «куча)) подразумевает дезорганизацию. Ближайшей аналогией могут послу жить складские помещения в каком-нибудь среднем бизнесе. По мере внешних по ступлений (создания переменных) склад предоставляет место. В ходе выполнения предприятием своей работы поступившие материалы расходуются и складские по \fещения становятся доступными для новых поступлений. В них бывают пустые \fеста и, возможно, заметен легкий беспорядок. Но в целом чувствуется достаточ ная упорядоченность. Проясним еще одно ошибочное мнение: куча не имеет никакого отношения к структуре данных, также называемой кучей. Эта структура весьма часто использу ется для создания очередей с приоритетом. По сути это весьма продуманный инст румент, но сейчас речь совершенно не о нем. Дело в том, что куча - не структура данных. Это область памяти. Итак, обозначив эти два различия, перейдем к объяснению. С позиции пользователя главной отличительной чертой кучи является то, что обращение к находящимся в ней переменным должно осуществляться через указатель, чего не требуется пере ченным, доступным в стеке. Возьмем простой пример и рассмотрим две переменные: а и ь. Обе они представ :тяют собой, соответственно, целые числа 40 и 60. Но одно из целых чисел находит ся в куче: �et а: i32 = 40; �et Ь: Box = Вох: :new(60); Давайте посмотрим, в чем здесь принципиальная разница. Следующий код не пройдет компиляцию: �et result =а+ Ь; Упакованное значение, присвоенное ь, доступно только через указатель. Чтобы по .1учить доступ к этому значению, нам нужно его разыменовать. Унарным операто ром разыменования служит символ *, помещаемый перед именем переменной: �et result =а+ *Ь; Поначалу такой синтаксис может вызвать недоумение, поскольку он же использу ется для умножения. Но со временем к нему привыкают. В следующем листинге показан полный пример, где создание переменных в куче подразумевает использо вание для этой цели такого типа указателя, как вох.
254
Глава 6
fn main() { let а: i32 40; let Ь: Box = Box::new(60);
(1) (2)
println! ("{) + {)={)", а, Ь, а + *Ь);
(3)
(1) 40 находится в стеке. (2) 60 находится в куче. (3) Для доступа к 60 требуется разыменование.
Чтобы понять, что такое ку'iа и что происходит в памяти в ходе выполнения про граммы, нужно рассмотреть небольшой пример. В нем все ограничивается создани ем нескольких чисел в куче, над которыми затем проводится операция сложения. В ходе своего выполнения программа из листинга 6.7 выдает простой результат в виде двух троек. Но фактически здесь важны не результаты, выданные програм мой, а то, что находится внутри ее памяти. Код следующего листинга находится в файле ch6/ch6-heap-via-box/src/main.rs. За ко дом (на рис. 6.5) следует изображение памяти программы в ходе ее выполнения. Посмотрим сначала на результат выполнения программы: 3 3
Листинг 6.7. Выделение и освобождение памяти в куче пр}! помощи Вох 3/%-'3/{..,.·.·.····... .
1 use std: :mem::drop; 2 3 fn main() 4 let а = Вох::new( 1); let Ь Вох::new( 1); 5 let с = Box::new(l); 6 7 8 9
10 11 12 13 14 15
,. ....,·.•
,.,.,... · ,
(1) (2) (2) (2)
let resultl = *а + *Ь + *с;
(3)
drop(а); let d = Box::new(l); let result2 = *Ь + *с + *d;
(4)
println!("{) {)", resultl, result2);
(1) Помещение функции drop(), используемой в принудительном порядке, в локальную область видимости. (2) Размещение значения в куче. (3) Унарный оператор разыменования * возвращает значение из упаковки, после чего в resultl содержится значение 3. (4) Вызов функции drор()высвобождает память для ее использования в иных целях.
Память
255 Ход выполнения программы
let а
Вох: :new(l) let Ь = Вох: :new (1) let с = Вох: :new( 1)
*а + *Ь + *с;
let resultl
drop(a) let d
Вох: : new( 1)
Ох118 OxllOl·-·--c·""''�--- --·-'--·i Ох108 OxlOO
каждого значения помещается в стек (при этом целочисленные значения находятся в упакованном виде).
складываются, эаня ое а, но распределитель памяти пометил поет рно и их сумма помещается в стек. это место как свободное для его испо ьэуется d. повторного использования.
Порядок интерпретации диаграммы
·-:.1 =,.;:,,";:.";;'.
Сверхспособности Rust
Стек, в отличие от стопки,
о,rн • • - \о, Куча представлена �;;�; �!�- нижним блокрм.
К этому моменту срок жизни переменной а истек
��п:��=��=т вниз,
::;:;
::.-:_ �- __,
::::�·. L·· •.· ·-..• ; ·•!._ .
Доступ к этому адресу памяти теперь недействителен. Данные о нем еще будут присутствовать в стеке, но гюлучить к нему доступ в безопасн ой экосистеме Rust невозможно.
Стек представлен верхним блоком.
·1т-.-__
1__: •
адресное пространство, составляющее 4096 байт. о,ш •· •-·· п . ох100 ·······--•···· В более реалистичной ситуации, · i например с 64-раэрядным процессором, адресное пространство составит 2"' байта.
е
ало
��и�= �а:��
;;,7оо;" '""�
Пространство в диапазоне смещения зарезервировано дпя исполняемых инструкций и переменных программы, сохраняемых в течение всего периода ее существования.
от О до
Рис. 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
Память
,,--..._
Под контролем проrраммы Запрос на память
Программа
i
Расnредеnитель
'-.__/
!
�: :
,,--..._
Вне контроля проrраммы
Операционная
система
Аппаратура
'-.__/
Интеллектуальный учет, проводимый распределителем и позволяющий избежать лишней работы операционной системы и оборудования компьютера.
Рис. 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.
[package] name = "chб-parti cles" version = "0.1.0" authors = ["TS McNamara "] edition = "2018" [dependencie s] piston_window = " 0.117"
(1)
Память
piston2d-graphics rand
=
259
"0.39"
"0.8"
(2) (3)
(1) Предоставление оболочки для основных функций игрового движка piston, позволяющей без особого труда вырисовывать объекты на экране практически без какой-либо зависимости от среды выполнения программы (2) Предоставление векторной математики, играющей важную роль в имитации движения. (3) Предоставление генераторов случайных чисел и связанных с ними функций. 1
Листинг 6.9. Графическое приложение для создания и уничтожения объектов в куче
1 2 3 4 5 6 7 8
use graphics::rnath:: {Vec2d, add, rnul scalar};
(1)
use piston_window::*;
(2)
use rand::prelude::*;
(3)
use std::alloc:: {GlobalAlloc, Systern, Layout);
(4)
9 use std::tirne::Instant; 10
(5)
11
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
f[global_allocator] static ALLOCATOR: ReportingAllocator
(6)
ReportingAllocator;
struct ReportingAllocator;
(7)
unsafe irnpl GlobalAlloc for ReportingAllocator { unsafe fn alloc(&self, layout: Layout) -> *rnut uB { let start = Instant::now(); let ptr = Systern.alloc(layout); let end = Instant::now(); let tirne_taken = end - start; let bytes_requested = layout.size();
(8)
eprintln! (" {} \t{}", bytes_requested, tirne_taken.as_nanos()); ptr unsafe fn dealloc(&self, ptr: *rnut u8, layout: Layout) { Systern.dealloc(ptr, layout);
33
34 struct World { 35 current turn: u64,
(9) (9)
260
36 particles: Vec, 37 height: f64, 38 width: f64, 39 rng: ThreadRng, 40 41 42 struct Particle 43 height: f64, 44 width: f64, 45 position: Vec2d, 46 velocity: Vec2d, 47 acceleration: Vec2d, 48 color: [f32; 4), 49 50 51 impl Particle { 52 fn new(world : &World) -> Particle { let mut rng = thread_rng(); 53 54 let х = rng.gen_range(O.O.. =world.width); 55 let у= world.height; let x_velocity = О.О; 56 let y_velocity = rng.gen_range(-2.0..0.0); 57 let х acceleration = О.О; 58 59 let y_acceleration = rng.gen_range(0.0..0.15); 60 61 Particle { height: 4.0, 62 width: 4.0, 63 64 position: [х, y].into(), velocity: [x_velocity, y_velocity] .into(), 65 66 acceleration: [x_acceleration, y_acceleration] .into(), 67 color: [1.0, 1.0, 1.0, 0.99), 68 69 70 71 72 fn update(&mut self) { self.velocity = add(self.velocity, 73 74 self.acceleration); self.position = add(self.position, 75 76 self.velocity); self.acceleration = mul_scalar( 77 self.acceleration, 78 0.7 79 80 ); 81 self.color[3] *= 0.995; 82
Глава 6 (9) (9) (9) (9)
(10) (10) (10) (10) (10) (10) (10)
(11) (11) (12) (12) (13) (13)
(14) (14) (14) (15)
(16) (16) (17) (17) (17) (17) (18)
261
Память
83 84 85 impl World 86 fn new(width: f64, height: f64) -> World { World { 87 current_turn: О, 88 particles: Vec::::new(), 89 height: height, 90 width: width, 91 rng: thread_rng(), 92 93 94 95 96 fn add_shapes(&mut self, n: i32) { for in О •• n.аЬs() { 97 let particle = Particle::new(&self); 98 99 let boxed_particle = Box::new(particle); self.particles.push(boxed_particle); 100 101 102 103 104 fn remove_shapes(&mut self, n: i32) { 105 for in О••n.aЬs() { 106 let mut to_delete = None; 107 let particle_iter = self.particles 108 109 .iter() 110 .enumerate(); 111 112 for (i, particle) in particle_iter 113 if particle.color[3] < 0.02 { to delete = Some(i); 114 115 116 break; 117 118 119 if let Some(i) = to_delete [ self.particles.remove(i); 120 } else { 121 self.particles.remove(0); 122 123 }; 124 125 126 127 fn update(&mut self) { let n = self.rng.gen_range(-3 .. =3); 128 129
(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 > О { self.add_shapes{n); 131 132 else { 133 self.remove_shapes(n); 134 135 136 self.particles.shrink_to_fit(); for shape in &mut self.particles 137 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 а window. "); 152 153 let mut world = World::new{width, height); 154 world.add_shapes{lOOO); 155 156 while let Some{event) window.next() { world.update(); 157 158 window.draw_2d{&event, lctx, renderer, _devicel 159 160 clear{[0.15, 0.17, 0.17, 0.9], renderer); 161 162 for s in &mut world.particles { 163 let size = [s.position[O], s.position[l], s.width, s.height]; rectangle(s.color, size, ctx.transform, renderer); 164 165 166 1); 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 в файл. $ cd ch6-particles $ carqo run -q 2> alloc.tsv $ head 4 5 48 9 9 19 15 16 14 16
alloc.tsv 219 83 87 78 93 69 960 40 70 53
(1) (2)
(1) Запуск ch6-particles на вьmолнение в режиме отключенного вывода. (2) Просмотр первых 10 строк вывода.
В этом небольшом фрагменте интересно то, что скорость выделения памяти плохо коррелируется с ее размером. Если составить график, охватывающий каждое выде ление памяти в куче, картина становится еще яснее (см. рис. 6.8). Для создания собственной версии графика, изображенного на рис. 6.8, можно вос пользоваться сценарием gnuplot, настраиваемым по вашему усмотрению; его код показан в следующем листинге. Этот же код можно найти в файле ch6/alloc.plot.
set set set set
key off rrnargin 5 grid ytics noxtics nocbtics back border 3 back lw 2 lc rgbcolor "#222222"
set xlabel "Allocation size (bytes)" set logscale х 2
265
Память
set xtics nomirror out set xrange [О to 100000] set set set set
ylaЬel "Allocation duration (ns)" logscale у yrange (10 to 10000] ytics nomirror out
plot "alloc.tsv" with points \ pointtype 6 \ pointsize 1.25 \ linecolor rgbcolor "#22dd3131" 10000
о о
:с
�
о
8
о
о 0 о о
� :с
с:
о о
о
о
1000
..
i
о
о
о о
:с
"
о
о
о
100
о
о
о
о о о
$
io::Result let saved checksum = f.read_u32::()?; let key_len = f.read_u32::()?; let val len = f.read_u32::()?; let data len key_len + val_len; let mut data
(2) (2) (2) (2) (2) (2)
ByteString::with_capacity(data_len as usize);
f .by_ref () .take(data_len as u64) .read_to_end(&mut data)?;
(3)
debug_assert_eq! (data.len(), data_len as usize); (4) let checksum = crc32::checksum_ieee(&data); if checksum != saved_checksum { panic! ( "data corruption encountered ({:08х) != {:08х))", checksum, saved checksum ); let value = data.split_off(key_len as usize); let key = data;
(5)
(6)
Ok( KeyValuePair { key, value ) )
(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::{ReadВytesExt, WriteBytesExt}; byteorder::LittleEndian и его собратья BigEndian и NativeEndi an (неиспользуе мые в коде листинга 7.14) - типы, указывающие на способ записи многобайтных данных на диск и способ считывания их с него. И byteorder::ReadBytesExt, и byteorder::Wr iteBytesExt - это типажи. В некотором смысле их присутствие в коде незаметно. Эти методы без лишних церемоний расширяют такие элементарные типы, как f32 и ilб. Их внесение в область видимости с помощью инструкции use сразу же повы шает эффективность типов, реализованных в исходном коде byteorder (на практи ке имеются в виду элементарные типы). Rust, являясь статически типизированным языком, выполняет эти преобразования в ходе компиляции. С позиции программы, запущенной на выполнение, целочисленные типы всегда способны записываться на диск в предопределенном порядке. В ходе выполнения код листинга 7 .14 выводит на экран комбинации байтов, соз данные в процессе записи 1 u32, 2 i B и з. O_f32 в прямом порядке. Получается следующая картина: (1, О, О, О] (1, о, о, о, 2]
[1, О, О, О, 2, О, О, О, О, О, О, 8, 64]
В следующем листинге показаны метаданные для проекта, код которого есть в лис тинге 7.14. Его исходный код находится в файле ch7/ch7-write123/Cargo.toml. А исход ный код листинга 7.14- в файле ch7/ch7-write123/src/main.rs.
305
Файлы и хранилища
[package] name = "write123" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] byteorder = "1.2"
1 2 3 4 5 6 7
use std::io::Cursor; use byteorder::{LittleEndian}; use byteorder::{Reac:IВytesExt, WriteBytesExt};
(1) (2) (3)
fn write_numЬers_to_file() -> (u32, i8, f64) { let mut w = vec! [];
(4)
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) ; 14 15 w.write_i8(two).unwrap(); 16 println! ("{:?}", &w); 17 18 w.write_f64::(three).unwrap(); 19 println! ("{:?}", &w); 20 21 (one, two, three) 22 23 24 fn read_numЬers_from_file() -> (u32, i8, f64) 25 let mut r = Cursor::new(vec! [1, О, О, О, 2, О, О, О, О, О, О, 8, 64]); 26 let one = r.read_u32::().unwrap(); 27 let two_ = r.read_i8().unwrap(); 28 let three = r.read_f64::() .unwrap(); 29 30 (one_, two_, three_) 31 32 33 fn main() 34 let (one, two, three) = write_numЬers_to_file();
(5) (6)
(5)
Глава 7
306
35 36 37 38 39 40
let (one_, two_, three_)
read_numЬers_from_file();
assert_eq! (one, one_); assert_eq! (two, two_); assert_eq! (three, three );
(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, показывающее, каким был результат под счета входящих битов: четным или нечетным.
Глава 7
308
При выполнении код листинга 7 .15 выдает следующую информацию: input: [97, 98, 99] 97 (ОЬ01100001) has 3 one bits 98 (ОЬ01100010) has 3 one bits 99 (ОЬ01100011) has 4 one Ьits output: 00000001
(1)
input: [97, 98, 99, 100] 97 (ОЬ01100001) has 3 one Ьits 98 (ОЬ01100010) has 3 one Ьits 99 (ОЬ01100011) has 4 one Ьits 100 (ОЬ01100100) has 3 one Ьits result: 00000000
(2)
(1) Внутри Rust-компилятора input: [97, 98, 99] представлен как Ь"аЬс". (2) input: [97, 98, 99, 100] представлен как b"abcd".
ПРИМЕЧАНИЕ Исходный код следующего листинга находится в файле ch7/ch7-parityblt/src/main.rs.
1 fn parity_Ьit(bytes: &[u8]) -> u8 { 2 let mut n ones: u32 = О;
(1)
3
4 5 6 7 8
for byte in bytes { let ones = byte.count_ones(); (2) n_ones += ones; println! ("{} (ОЬ{:08Ь}) has {} one Ьits", byte, byte, ones);
9 (n_ones % 2 == О) as u8 10 11 12 fn main() 13 let аЬс = Ь"аЬс"; println!("input: {:?}", аЬс); 14 15 println! ("output: { :08х}", parity_Ьit(abc)); 16 println!(); 17 let abcd = b"abcd"; 18 println!("input: {:?}", аЬсd); 19 println!("result: {:08х}", parity_Ьit(abcd)) 20
(3)
(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(()) 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); 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 tmp.push(*byte); 188 189
(1)
(2)
(3) (3) (3)
Глава 7
310
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205
for byte in value { tmp.push(*byte);
(3) (3) (3)
let checksum = crc32::checksum_ieee(&tmp); let next_byte = SeekFrom::End(0); let current_position = f.seek(SeekFrom::Current(0))?; f.seek(next_byte)?; f.write_u32::(checksum)?; f.write_u32::(key_len as u32)?; f.write_u32::(val_len as u32)?; f.write_all(&mut tmp)?; Ok(current_position)
(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, представлен весь код проекта. 1 2 3 4 5 6 7
8 9 10 11 12 13
use use use use use use
std::collections::HashМap; std::fs::{File, OpenOptions}; std::io; std::io::prelude::*; std::io: : {BufReader, BufWriter, SeekFrom}; std::path::Path;
use byteorder::{LittleEndian, ReadВytesExt, WriteBytesExt}; use crc::crc32; use serde_derive::{Deserialize, Serialize}; type ByteString = Vec; type ByteStr = [u8];
311
Файлы и хранилища 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 59 60 61
#[derive(Debug, Serialize, Deserialize)] рuЬ struct KeyValuePair { рuЬ key: ByteString, рuЬ value: ByteString, # [derive(Debug)] рuЬ struct ActionКV f: File, рuЬ index: HashМap, impl ActionКV { рuЬ fn open( path: &Path -> io::Result let f = OpenOptions: :new() . read(true) .write (true) .create(true) .append(true) .open(path)?; let index = HashМap::new(); Ok(ActionКV { f, index }) fn process_record( f: &mut R -> io::Result let saved checksum = f.read_u32::()?; let key_len = f.read_u32::()?; let val len = f.read_u32::()?; let data len = key_len + val_len; let mut data
=
ByteString::with_capacity(data_len as usize);
f .by_ref() .take(data_len as u64) .read_to_end(&mut data)?; debug_assert_eq! (data.len(), data_len as usize); let checksum
=
(1)
crc32::checksum_ieee{&data);
(2)
312
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
Глава 7
if checksurn != saved_checksurn { panic! ( "data corruption encountered ({:08х} != {:08х})", checksurn, saved checksurn ); let value = data.split_off(key_len as usize); let key = data; Ok(KeyValuePair { key, value }) pub fn seek_to_end(&mut self) -> io::Result { self.f.seek(SeekFrom::End(0)) рuЬ fn load(&mut self) -> io::Result { let mut f = BufReader::new(&mut self.f); loop { let current__position = f.seek(SeekFrom::Current(0))?; let maybe_kv = ActionКV::process_record(&mut f); let kv = match maybe_kv Ok(kv) => kv, Err(err) => { match err. kind() io::ErrorKind::UnexpectedEof => { break;
(3)
=> return Err(err), }; self.index.insert(kv.key, current__position); Ok(()) } рuЬ fn get( &mut self, key: &ByteStr ) -> io::Result let position = match self.index.get(key)
(4)
313
Файлы и хранилища
109 None => return Ok(None), 110 Some(position) => *position, 111 }; 112 113 let kv = self.get_at(position)?; 114 115 Ok(Some(kv.value)) 116 117 118 рuЬ fn get_at( 119 &mut self, position: u64 120 121 -> io::Result 122 let mut f = BufReader::new(&mut self.f); 123 f.seek(SeekFrom::Start(position))?; let kv = ActionКV::process_record(&mut f)?; 124 125 126 Ok(kv) 127 128 129 рuЬ fn find( &mut self, 130 target: &ByteStr 131 132 -> io::Result 133 let mut f = BufReader::new(&mut self.f); 134 let mut found: Option = None; 135 136 137 loop { let position = f.seek(SeekFrom::Current(0))?; 138 139 let maybe_kv = ActionКV::process_record(&mut f); 140 141 let kv = match maybe_kv 142 Ok(kv) => kv, Err(err) => { 143 144 match err.kind() io::ErrorKind::UnexpectedEof => 145 146 break; 147 148 => return Err(err), 149 150 } 151 ; } 152 153 if kv.key == target { 154 found = Some((position, kv.value)); 155
(5)
314 156 // в том случае, если ключ был переписан, 157 // важно не прерывать цикл до конца файла 158 159 160 Ok(fouпd) 161 162 163 164 pub fn insert( &mut self, 165 key: &ByteStr, 166 value: &ByteStr 167 -> io::Result 168 169 let position = self.insert_but_ignore_index(key, value)?; 170 171 self.index.insert(key.to_vec(), position); Ok(()) 172 173 174 175 pub fn insert_but_ignore_index( 176 &mut self, 177 key: &ByteStr, value: &ByteStr 178 -> io::Result 179 let mut f = BufWriter::new(&mut self.f); 180 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 { tmp.push(*byte); 187 188 189 190 for byte in value { tmp.push(*byte); 191 192 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)?; f.write_u32::(checksum)?; 199 200 f.write_u32::(key_len as u32)?; 201 f.write_u32::(val_len as u32)?; 202 f.write_all(&tmp)?;
Глава 7
Файлы и хранилища
315
203 204 Ok(current_position) 205 206 207 lt[inline] 208 рuЬ fn update( 209 &mut self, key: &ByteStr, 210 211 value: &ByteStr -> io::Result 212 self.insert(key, value) 213 214 215 216 lt[inline] 217 рuЬ fn delete( &mut self, 218 219 key: &ByteStr -> io::Result 220 221 self.insert(key, Ь'"') 222 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 все это носит название «хэшю).
Глава 7
316
• В 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::t rans mute: : (first) (4)
(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, и называется хранилищем кол лизий. При возникновении коллизии выполняется обращение к хранилищу колли зий и происходит его сквозное сканирование. По мере увеличения хранилища это линейное сканирование занимает все больше и больше времени. Злоумышленники могут воспользоваться этой особенностью для перезагрузки компьютера, выпол няющего хэш-функцию. Получается, что во избежание атак более быстрые хэш-функции выполняют мень ший объем работы. Также они будут лучше работать, когда их входные параметры находятся в рамках определенного диапазона. Для полного понимания внутреннего устройства реализации хэш-таблиц пришлось бы изложить слишком много подробностей, не вписывающихся в рамки этой врез ки. Но для программистов, желающих добиться оптимальной производительности и задействованности памяти для своих программ, это весьма увлекательная тема.
Глава 7
318
7.7.8. Создание HashMap и ее заполнение значениями В следующем листинге показана коллекция пар «ключ-значение», закодированная в формате 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
1 use std::collections::HashМap; 2 3 fn main() (1) 4 let mut capitals = HashМap::new(); 5 6 capitals.insert("Cook Islands", "Avarua"); 7 capitals.insert("Fiji", "Suva"); В capitals.insert("Kiribati", "South Tarawa"); 9 capitals.insert("Niue", "Alofi"); 10 capitals.insert("Tonga", "Nuku'alofa"); 11 capitals.insert("Tuvalu", "Funafuti"); 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 на консоль также выво дится одна строка: Capital of Tonga is: "Nuku'alofa"
(1)
(1) Использование двойных кавычек обусловлено тем, что макрос json! возвращает String в оболочке, а это ее представление по умолчанию.
В следующем листинге для включения JSОN-литералов в исходный Rust-кoд ис пользуется контейнер serde-json. Исходный код листинга находится в файле ch7/ch7pacific-json/src/main.rs.
1 it[macro_use]
2
3
(1) (1)
extern crate serde_json;
4 fn main () { 5 let capitals = json! ( { "Cook Islands": "Avarua", 6 7 "Fiji": "Suva", 8 "Kiribati": "South Tarawa", 9 "Niue": "Alofi", 10 "Tonga": "Nuku'alofa", 11 "Tuvalu": "Funafuti" 12 )) ; 13
14 15
(2)
println! ("Capital of Tonga is: {}", capitals["Tonga"])
(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)
Глава 7
320
В результате возвращается ссылка на значение, предназначенная только для чтения и дезориентирующая при работе с примерами, содержащими строковые литералы, поскольку их статус в качестве ссылок не столь очевиден. В синтаксисе, использо ванном в 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_КEY) (1) 51 .unwrap() (2) 52 .unwrap(); (2) 53 54 let index_decoded = Ьincode::deserialize(&index_as_bytes); 55 56 let index: HashМap = index_decoded.unwrap(); 57 match index.get(key) { 58 None => eprintln! ("{:?} not found", key), 59 60 Saoe(&i) => { let kv = a.get_at(i) .unwrap(); 61 62 println! (" { :?}", kv.value) 63 64 65
(3) (3) (3)
(3) (3) (3)
Глава 7
324
(1) INDEX_КEY - внутреннее скрытое имя индекса в базе данных. (2) Необходимость в двух вызовах unwrap() обусловливается тем, что a.index относится к структуре HashМap, возвращающей тип Option, а сами значения хранятся внутри Option, чтобы упростить возможные будущие удаления. (3) Теперь извлечение значения не обходится без предварительной выборки индекса и последующего определения верного места на диске.
В следующем листинге показано хранилище «ключ-значение)), сохраняющее дан ные индекса между запусками. Его исходный код находится в файле ch7/ch7actionkv2/sгc/akv_disk.гs
.24. �охранение индекснi.1/(да�ны�,; 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
use libactionkv::ActionКV; use std::collections::HashМap; il[cfg(target_os = "windows")] const USAGE: &str Usage: akv disk.exe FILE get КЕУ akv disk.exe FILE delete КЕУ akv disk.exe FILE insert КЕУ VALUE akv disk.exe FILE update КЕУ VALUE "; il[cfg(not(target_os "windows"))] const USAGE: &str = " Usage: akv disk FILE get КЕУ akv disk FILE delete КЕУ akv disk FILE insert КЕУ VALUE akv disk FILE update КЕУ VALUE
"·
type ByteStr = [u8]; type ByteString = Vec; fn store_index_on_disk(a: &mut ActionКV, index_key: &ByteStr) a.index.remove(index_key); let index_as_bytes = bincode::serialize(&a.index).unwrap(); a.index = std::collections::HashМap::new(); a.insert(index_key, &index_as_bytes) .unwrap(); fn main() const INDEX_КEY: &ByteStr
=
b"+index";
let args: Vec = std::env::args() .collect(); let fname = args.get(l).expect(&USAGE);
325
Файлы и хранилища 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
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 а = ActionКV::open(path).expect("unaЫe to open file"); a.load() .expect("unaЬle to load data"); match action { "get" => { let index_as_bytes
a.get(&INDEX_КEY) . unwrap() .unwrap();
let index decoded = bincode::deserialize(&index_as_bytes); let index: HashМap = index_decoded.unwrap(); match index.get(key) { None => eprintln! ("{:?} not found", key), Some(&i) => { let kv = a.get_at(i).unwrap(); println ! ("{:?} ", kv.value)
(1)
"delete" => a.delete(key) .unwrap(), "insert" => let value = maybe_value.expect(&USAGE).as_ref(); a.insert(key, value).unwrap(); store_index_on_disk(&mut а, INDEX_КEY); "update" => { let value = maybe_value.expect(&USAGE) .as_ref(); a.update(key, value).unwrap(); store_index_on_disk(&mut а, INDEX_КEY);
(2)
(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 конечных автоматов.
РАЗДЕЛ
Сетевые протоколы НТТР НТТР GET с reqwest Типажные объекты Микро RPG ТСР НТТР GET с std::net::TcpStream DNS DNS-преобразователь Расширенная обработка ошибок Обработчик ошибок парсинга МАС address
Генератор МАС-адреса Конечный автомат в Rust НТТР GET с обычным ТСР
Рис. 8.1. Схема материалов главы, посвященной работе в сети. Здесь выдерживается разумный баланс теории и практических примеров.
Здесь неоднократно будут рассматриваться способы создания НТТР-запросов, и при этом всякий раз будет вскрываться тот или иной уровень абстракции. Снача ла будет использоваться простая в усвоении библиотека, а затем начнется избавле-
328
Глава В
ние от всяческих надстроек, пока не состоится переход к работе с простыми ТСР-пакетами. К концу путешествия вы научитесь отличать 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
Способ общения компьютеров ОПИСАНИЕ Вид сетевого стека. Каждый уровень зависит от уровней, расположенных под ним. Иногда случаются взаимопроникновения уровней. Например, НТМL-файлы могут включать директивы, перезаписывающие те директивы, что предоставляются протоколом НТТР. Чтобы сообщение было получено, каждый уровень должен быть пройден снизу вверх. А для оmравки сообщения надо проделать обратный путь.
СПОСОБ ПРОЧТЕНИЯ Вертикальное расположение обычно указывает на то, что в этом месте взаимодействуют два уровня. К исключениям относится шифрование, предоставпяемое TLS Сетевая адресация обеспечивается 1Pv4 или 1Pv6, а виртуальные уровни в большей степени ИЛiОрируют физические каналы. (Физические свойства все же проявляются в верхних уровнях в виде задержек и степени безотказности.)
1
1 1
\1
Пустоты означают, что с верхнего уровня можно сразу перейпи на более низкий уровень. Например, для работы НТТР не требуется доменное имя или ТLS-безопасность.
il . ..
}
///
ЛЕГЕНДА
//
Протокол, рассматриваемый в этой главе
Протокол, используемый на этом уровне Представление сотен других протоколов, имеющихся на этом уровне.
,//
,,
//
.
..
..• .•
.
.·
Этот протокол доступен, но не может быть развернут.
Рис. 8.2. Ряд уровней сетевых протоколов, задействованных в отправке содержимого через Интернет. На рисунке сравниваются некоторые общепринятые модели, включая семиуровневую модель OSI и четырехуровневую модель TCP/IP.
330
Глава В
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 имеет следующий вид: chB-simple/ f- src 1 L- main.rs L_ Cargo.toml
Работа в сети
331
В следующем листинге представлены метаданные для кода листинга 8.2. Исходный код листинга находится в файле ch8/ch8-simple/Cargo.toml.
[package] пате = "ch8-simple" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] reqwest = "0.9"
Порядок создания НТТР-запроса с помощью библиотеки reqwest показан в сле дующем листинге. Его исходный код находится в файле ch8/ch8-simple/src/main.rs.
1 2 3 4 5 6 7 8 9 10
11
use std::error::Error; use reqwest; fn main() -> Result { let url = "http:/ /www.rustinaction.com/"; let mut response = reqwest::get(url)?;
(1)
let content = response.text()?; print ! (" {} ", content);
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
Глава В
ляются посредниками конкретных типов. Синтаксис 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 59 60 61 62
1
let d let е let h
= = =
Dwarf {); Elf {); Human {);
let party: Vec = vec! [&d, &h, &е];
(1)
В старом Rust-кoдe встречаются формы &Trai t и Box. Этот синтаксис допускается, но официально считается устаревшим. Добавление ключевого слова dyn входит в число настоятельных рекомендаций. 2 Названия даются непросто.
Глава В
334
(1) Хотя d, е и h - разные типы, использование аннотации типа &dyn Enchanter предписывает компилятору считать каждое значение типажным объектом. Теперь все они ОДНОГО типа.
Для произнесения заклинания нужно выбрать заклинателя (spellcaster). Для этого мы воспользуемся контейнером rand: 58 let spellcaster = party.choose(&rnut rand::thread_rng()) .unwrap(); 59 spellcaster.enchant(&rnut it)
Метод choose () берется из типажа r a n d: : seq: : SliceRandorn, помещаемого в об ласть видимости в листинге 8.4. Затем случайным образом выбирается один из уча стников. После чего участник пытается наложить заклинание на объект i t. Компи ляция и запуск кода листинга 8.4 приводят примерно к следующему: $ cargo
rш1
Cornpiling rpg v0.1.0 (/rust-in-action/code/ch8/ch8-rpg) Finished dev [unoptirnized + debuginfo] target(s) in 2.13s Running 'target/debug/rpg' Hurnan rnutters incoherently. The Sword glows brightly. (1) $ target/deЬug/rpg
Elf rnutters incoherently. The Sword fizzes, then turns into а worthless trinket.
(2) (3)
(1) Человек что-то бормочет. Меч начинает ярко светиться. (2) Повторная выдача команды без перекомпиляции. (3) Эльф что-то бормочет. Меч шипит и превращается в безделушку.
Метаданные для нашей фэнтезийной ролевой игры показаны в следующем листин ге. Исходный код rрg-проекта находится в файле ch8/ch8-rpg/Cargo.toml.
[package] narne = "rpg" version "0.1.0" ["Tirn McNarnara "] authors edition "2018" [dependencies] rand = "0.7"
В листинге 8.4 представлен пример использования типажного объекта, позволяю щего контейнеру содержать несколько типов. Его исходный код находится в файле ch8/ch8-rpg/src/main.rs.
335
Работа в сети
1 2 3 4 5 6 7 8 9 10
use rand; use rand::seq::SliceRandom; use rand::Rng; #[derive(Debug)] struct Dwarf { ) #[derive(Debug)] struct Elf {}
11 #[derive(Debug)] 12 struct Human {} 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
#[derive(Debug)] enum Thing { Sword, Trinket, trait Enchanter: std::fmt::Debug fn competency(&self) -> f64; fn enchant(&self, thing: &mut Thing) { let probaЬility_of_success = self.competency(); let spell_is_successful = rand::thread_rng() .gen_bool(probaЬility_of_success);
(1)
print! ("{:?} mutters incoherently. ", self); if spell_is_successful { println ! ("The {: ? } glows brightly.", thing); else { println!("The {:?} fizzes, \ then turns into а worthless trinket.", thing); *thing = Thing::Trinket {};
impl Enchanter for Dwarf { fn competency(&self) -> f64 0.5 (2)
42 43 44 impl Enchanter for Elf 45 fn competency(&self) -> f64 { 46 0.95
(3)
336 47 48 49 impl Enchanter for Human { 50 fn competency(&self) -> f64 0.8 51 52 53 54 55 fn main() 56 let mut it = Thing::Sword; 57 58 let d = Dwarf {1; 59 let е = Elf {1; 60 let h = Human {1; 61 62 let party: Vec = vec! [&d, &h, &е]; 63 let spellcaster = party.choose(&mut rand::thread_rng()).unwrap(); 64 65 spellcaster.enchant(&mut it); 66
Глава 8
(4)
(5)
(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ЕТ-запрос, имеет следующий вид: chB-stdlib f-- sr c 1 L main.rs L Cargo.toml
В следующем листинге показаны метаданные для кода листинга 8.6. Его исходный код находится в файле ch8/ch8-stdliЬ/Cargo.toml. [package] name = "chB-stdlib" version = "0.1.0" authors = ["Tim McNamar a "] ed ition = "2018" [depend encies]
Глава 8
338
В следующем листинге показан порядок использования стандартной библиотеки Rust при создании НТТР GЕТ-запроса с помощью std: :net: :Tcpstream. Его ис ходный код находится в файле ch8/ch8-stdliЬ/src/main.rs. Лис
_',, · std: :io::Result 5 let host = "www.rustinaction.com:80"; 6 7 let mut conn = TcpStream::connect(host)?; 8 9 10 conn.write_all(b"GET / НТТР/1.0")?; 11 conn.write_all(b"\r\n")?; 12
(1)
(2)
13 14
conn.write_all(b"Host: www.rustinaction.com")?; conn.write_all(b"\r\n\r\n")?;
(3)
16 17 18 19
std::io::copy( &mut conn, &mut std::io::stdout()
(4) (4) (4)
15
20
21 22
• ) ?. ,
(4) Ok(())
(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.1. Термины, использованные в документе RFC 1035, контейнере trust_dns и листинге 8.9, и их взаимосвязанность
Представление в коде
Термин
Определение
Доменное имя, Domainname
Определено в Доменное имя - это почти то, trust dns::domain::Name о чем вы, вероятно, думаете, когда используете термин pub struct Name { «доменное имя» в повседневной is fqdn: bool, (1) речи. labels: Vec, Техническое определение вклю чает ряд особенностей, таких как ) корневой домен, который коди(1) fqdn означает полное доменное имя. руется в виде точки, и имена доменов, которые должны быть нечувствительны к регистру символов.
Сообщение, Message
Сообщение - контейнер Определено в trust dns::domain::Name для запросов к DNS-cepвepaм (называемых запросами) и отве struct Message { тов клиентам (называемых отве header: Header, тами). queries: Vec, В сообщении должен быть заго answers: Vec, ловок, а другие поля носят не обязательный характер. Его name servers: Vec, представлением является струк additionals: Vec, тура Message, включающая не сколько полей Vec. Их не sig0: Vec, (1) нужно заключать в Option для edns: Option, (2) представления отсутствующих значений, поскольку они могут (1) sigO является записью иметь нулевую длину. с криптографической подписью для проверки целостности сообщения. Определение дано в документе RFC 2535. (2) edns показывает, включает ли сообщение расширенную версию DNS.
Тип сообщения, Тип сообщения определяет со общение как запрос или как от Message type вет. Запросы также могут быть обновлениями, игнорируемыми нашим кодом.
Определен в trust_dns::op::MessageType pub enum MessageType { Query, Response,
Работа в сети
341
Таблица 8.1 (окончание) Термин
Определение
Идентификатор сообщения, Message ID
Номер,используемый отправителями для связывания запросов и ответов.
Тип записи ре сурса,Resource record type
Тип записи ресурса относится к кодам DNS,с которыми вы, вероятно,сталкивались,если когда-либо занимались конфигурированием доменного имени. Примечательно то, как в trust_d.ns обрабатываются недо пустимые коды. В перечислении RecordType содержится вариант Unknown (ulб),который может использоваться для непонятных ему кодов.
Представление в коде ul 6
Определен в trust dns::rr::record_type:: RecordType рuЬ enurn RecordType { А, АААА, А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 в каком-то Определен в trust_dns:: ор::OpCode смысле является подтипом MessageType. Это механизм pub enurn OpCode { расширяемости,обеспечиваю Query, щий будущую функциональ Status, ность. Например,в документе RFC 1035 определены коды опе Notify, раций Query и Status,но ос Update, тальные коды получили свое определение в более поздних документах.Коды операций Notify и Update определены соответственно в документах RFC 1996 и RFC 2136.
342
Глава 8
К сожалению, использование протокола влечет за собой использование множества вариантов, типов и подтипов, что, как мне представляется, является следствием отображения реальной обстановки. В листинге 8.7, который является частью лис тинга 8.9, показывается процесс создания сообщения, обращающегося со следую щей просьбой: «Дорогой DNS-cepвep, каков будет 1Рv4-адрес для domain_name?». В коде листинга создается DNS-сообщение, а контейнер trust-dns запрашивает 1Рv4адрес для domain _name.
35 let mut msg = Message::new(); 36 msg 37 .set_id(rand::random::()) 38 .set_message_type(MessageType::Query) 39 .add_query( 40 41 42 43
Query::query(domain_name, RecordType::A) . set_ор_code(OpCode::Query) .set_recursion_desired(true);
(3) (4) (5) (6) (7)
(1) (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:
$ carqo run -q -- www.rustinaction.com 35.185.44.232
(1)
(1) Отправка аргументов, указанных справа от сдвоенного дефиса, (--) исполняемому файлу, получаемому в результате компиляции. Ключ -q подавляет вывод на консоль всей промежуточной информации.
Чтобы скомпилировать приложение resolve из официального хранилища исходного кода, нужно выполнить на консоли следующие команды: $ qit clone https:/ /qithuЬ.com/rust-in-action/code rust-in-action Cloning into 'rust-in-action' ...
$ cd rust-in-action/chB/chB-resolve $ carqo
run -q -- www.rustinaction.can
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
Глава В
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.tornl L src L rnain.rs
(1) (2)
(1) См. листинг 8.8 (2) См. листинг 8. 9 ЛисrиАr 8.8. Контей
[package] narne = "resolve" version "0.1.0" authors = ["Tirn McNarnara "] edition = "2018" [dependencies] rand = "0.6" clap = "2.33" trust-dns = { version "0.16", default-features false }
1 use std::net:: {SocketAddr, UdpSocket} ; 2 use std::tirne::Duration; 3 4 use clap:: {Арр, Arg}; 5 use rand; 6 use trust_dns::ор:: {Message, MessageType, OpCode, Query}; 7 use trust dns::rr::dornain::Narne; 8 use trust_dns::rr::record_type::RecordType; 9 use trust_dns::serialize::binary::*; 10 11 fn rnain() { 12 let арр = Арр::new("resolve") .about("А sirnple to use DNS resolver") 13 .arg(Arg::with_narne("dns14 server").short("s").default_value("1.1.1.1 ")) .arg(Arg::with_narne("dornain-narne").required(true)) 15
345
Работа в сети
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
.get_matches(); let domain name raw = арр .value_of{ 11 domain-name 11 .unwrap{); let domain name = Name::from_ascii{&domain_name_raw).unwrap();
(1)
let dns_server_raw = арр .value_of{ 11 dns-server") . unwrap{); let dns server: SocketAddr = format! ( 1 :53 11 dns_server_raw) .parse () .expect{ 11 invalid address 11
(2)
let mut request_as_bytes: Vec Vec::with_capacity{512); let mut response_as_bytes: Vec vec! [О; 512];
(3)
)
1 {}
(1)
(1)
(2) (2)
,
(2) (2) (2)
);
(3) =
(3) (3)
let mut msg = Message::new(); msg .set_id{rand::random::()) .set_message_type{MessageType::Query) .add_query{Query::query{domain_name, RecordType: :А)) .set_op_code{OpCode::Query) .set_recursion_desired{true); let mut encoder = BinEncoder::new{&mut request_as_bytes); msg.emit{&mut encoder).unwrap(); let localhost = UdpSocket::Ьind{ 11 0.0.0.0:0") .expect{ 1 cannot Ьind to local socket 1 let timeout = Duration::from_secs{3); localhost.set_read_timeout{Some{timeout)).unwrap(); localhost.set_nonЬlocking{false).unwrap(); 1);
1
let amt = localhost .send to{&request_as_bytes, dns_server) .expect{"socket misconfigured 11 );
let {_amt, _remote) = localhost .recv_from{&mut response_as_bytes) .expect{ 11 timeout reached 11 );
let dns_message = Message::from vec{&response_as_bytes) .expect{ 1 unaЬle to parse response 1 1
(1)
1 );
(4)
(5)
(6)
(7)
Глава В
346 63 64 65 66 67 68 69 70 71 72 73
for answer in dns_message.answers() { if answer.record_type() == RecordType::A let resource = answer.rdata(); let ip = resource .to_ip_addr() .expect("invalid IP address received"); println!("{}", ip.to_string());
(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 31 32 33
let mut request_as_bytes: Vec = Vec::with_capacity(512); let mut response_as_bytes: Vec = vec! [О; 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Р-клиент
Подведем итоги. Нашей главной задачей в этом разделе было создание НТТJ запросов. НТТР построен на ТСР. Поскольку мы располагаем только лишь до�н ным именем (www.rustinaction.com), при выполнении запроса возникает необiоди1 мость в использовании DNS. А данные в DNS в основном поставляются чер1 UDP, поэтому нам нужно было сделать небольшое отступление и узнать, что так9е 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.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.
1 use std::fs::File; 2 3 fn main() -> Result { 4 let _f = File::open("invisiЫe.txt")?; 5 6
7
Ok ( ())
А теперь введем в функцию mai n ( > еще один тип ошибки. Код следующего лис тинга приводит к ошибке компиляции, но для получения компилируемого кода мы просмотрим несколько вариантов. Этот код находится в файле ch8/misc/mt.iltierror.rs.
1 use std::fs::File; 2 use std::net::IpvбAddr; 3 4 fn main() -> Result { 5 let _f = File::open("invisiЫe.txt")?; 6 7 let localhost = "::1" В • parse::()?; 9
10
(1) (2) (2)
Ok(())
11 (1) File::open() возвращает Result. (2) "" . parse::() возвращает Result.
Чтобы скомпилировать код листинга 8.11, войдите в каталог ch8/misc и воспользуй тесь командой rustc. В результате будет выдано весьма суровое, но все же полез ное сообщение об ошибке: $ rustc шultierror.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 а conversion on the error value using the 'From' trait help: the following implementations were found:
= note: required Ьу 'from' error: aborting due to previous error For more information about this error, try 'rustc --explain Е0277'.
Если не знать, чем занимается оператор в виде вопросительного знака (? ), понять суть ошибки будет непросто. Почему здесь несколько сообщений, касающихся 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, Result::Err(err) => { let converted = convert::From::from(err); return Result::Err(converted);
(1) (2)
(3)
)) ;
(1) Когда выражение соответствует Result::Ok(val), используется val. (2) Когда выражение соответствует Result::Err(err), вьmолняется преобразование err в тип error, принадлежащий внешней функции, после чего управление сразу же возвращается этой функции. (3) Воз�rращение осуществляется не в сам макрос try!, а в вызывавшую функцию.
350
Гла5в
Result { let f = File::open( 11 invisiЫe.txt11 )?; 5 (1) 6 7 8 9
10
let localhost = ::111 .parse::()?;
(2)
11
(2)
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.
1 use std::fs::File; 2 use std::error::Error; 3 use std::net::IpvбAddr;
Работа в сети
4
5 fn ma in() -> Result { 6 7 let _f = File::open("invisiЫe.txt")?; 8 9 let localhost = "::1" 10 .parse::()? 11 12
351 (1) (2)
(3)
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
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::IpvбAddr; fn rnain() -> Result { let _f = File::open("invisiЫe.txt")?; let localhost = "::1" .parse::()?; Ok( ())
Причина сбоя в том, что '"'.parse: :() не возвращает std: :io: :Error. В конечном итоге желательно получить код, похожий на код следующего листинга.
1 2 3 4 5 6 7 В 9
10
use use use use
std::fs::File; std::io::Error; std::net::AddrParseError; std::net:: IpvбAddr;
enurn UpstrearnError{ IO(std::io::Error), Parsing(AddrParseError),
11 fn rnain() -> Result { let _f = File::open("invisiЫe.txt")? 12 .rnaybe_convert_to(UpstrearnError); 13 14
(1) (1)
Работа в сети 15 16 17
let localhost = "::1" . parse:: () ? .maybe_convert_to(UpstrearnError);
19
Ok(())
18
353
20
(1) Помещение вЬШiестоящих ошибок в локальную область видимости
Определение перечисления, включающего вышестоящие ошибки в качестве вариантов Первым делом нужно возвратить тип, способный содержать типы вышестоящих ошибок. В Rust с этой задачей вполне справляется перечисление. Код листинга 8.13 не проходит компиляцию, но выполняет именно это действие. При этом мы немно го подправим импорт: use std::io; use std::net; enum UpstrearnError{ IO(io::Error), Parsing(net::AddrParseError),
Снабжение перечисления аннотацией # [derive (DeЬug)] Внесение следующего изменения не составит особого труда. Хорошо, когда все действие ограничивается одной строкой кода. Чтобы снабдить перечисление анно тацией, добавим к коду# [de r i v e (Debug)]: use std::io; use std::net;
# [ derive (DeЬug) ] enum UpstrearnError{ IO(io::Error), Parsing(net::AddrParseError),
Реализация std: :fmt: :Display Давайте немного схитрим и реализуем Display путем простого использования oe Ьug. Поскольку, как известно, ошибки без реализации Debug не обходятся, доступ к нему открыт. Обновленный код приобретает следующий вид: use std::fmt; use std::io; use std::net;
Глава В
354
# [derive(Debug)] enum UpstrearnError{ IO(io::Error), Parsing(net::AddrParseError), impl fmt: :Display for UpstreamError { fn fmt(&self, f: &mut fmt::Fomatter) -> fmt::Result [ write! (f, {:?} 11 self) 11
,
impl error::Error for UpstrearnError { }
(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("invisiЫe.txt")? .maybe_convert_to(UpstreamError); let localhost = "::1" .parse::()? .maybe_convert_to(UpstreamError); Ok (()) Предложить такое, конечно, невозможно, но можно дать вот это: fn main() -> Result let _f = File::open("invisiЫe.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.
use use 3 use 4 use 5 use 1 2
std::io; std::fmt; std::net; std: :fs::File; std::net::IpvбAddr;
6 7 # [derive(Debug)]
356
Глава 8
8 enum UpstrearnError{ 9 IO(io::Error), 10 Parsing(net::AddrParseError), 11 12 13 irnpl frnt::Display for UpstrearnError { 14 fn frnt(&self, f: &rnut frnt::Foпnatter) -> fmt::Result { write! (f, 11 {:?} 11 , self) l((CO20-1})
impl error::Error for UpstreamError { } impl From for UpstreamError fn from(error: io::Error) -> Self { UpstreamError::IO(error)
impl From for UpstreamError fn from(error: net::AddrParseError) -> Self { UpstreamError::Parsing(error)
358
Глава 8
30 31 32 33 fn main() -> Result { let _f = File::open("invisiЫe.txt")?; 34 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.
::-=':_.,
J W __�-�-�--�l-� lr-l�l li""'i!....,.,_
Первый переданный байт------,_
1
По схеме Rust-синтаксиса МАС-адрес указывается как [u8; 6]
Флаг локальности --3 или универсальности
Универсальные адреса Локальные адреса Общие поля
с '--····-----•'-----� Роль конкретных битов изменяет я Устройство Организация в соответствии с установкой флага локальности '--···--- - йст - во - -----� или универсальности -У с- тро ·· Флаги
Рис. 8.3. Схема размещения в памяти МАС-адресов
У МАС-адресов две формы: • Универсалыю администрируемые (wiu универсальные) адреса устанавлива ются при изготовлении устройств. Производители используют префикс, на значенный органом регистрации IEEE, и выбираемую ими схему для остав шихся битов. • Локально администрируемые (wiu локальные) адреса позволяют устройст вам создавать свои собственные МА С-адреса без регистрации. Когда МАС адрес устройства устанавливается в программе самостоятельно, нужно убе диться, что адрес установлен в локальной форме. МАС-адреса имеют два режима: одиночный и групповой. Поведение передачи для этих форм идентично. Различие делается при принятии устройством решения о приеме кадра или отказе от его приема. Кадр - термин, используемый протоколом Ethemet для байтового слайса на данном уровне. Аналогами кадра могут послужить такие понятия, как пакет, упаковка и конверт. Разница показана на рис. 8.4. Одиночные адреса предназначены для переноса информации между двумя точка ми, находящимися в непосредственном контакте (скажем, между ноутбуком и ро утером). Точки беспроводного доступа несколько усложняют ситуацию, но сути дела не меняют. Групповые адреса могут быть приняты несколькими получателя ми, а у одиночных только один получатель. Сам термин «одиночный)) не вполне корректен. В отправке Ethemet-пaкeтa участвует более двух устройств. Использо вание одиночного адреса меняет только положение вещей, складывающееся при получении пакетов, но не то, какие данные передаются по проводным линиям (или по радиоволнам).
360
Глава 8
с у н в Сравн ение оди очных и гр ппов ых МАС-адр е о н в Поведение при отправке согпасова о обоих режимах.
,�... . -�. �г�
Отп вит ль ра е с в ючает М С адре А кл -
l�l
'""""" ' ' -· f
Г ■iil
.
]
изатор пер едает �...-- U r, Маршрутрес н ад сАеС ус йсназнач, е ия М � � /,,----.., \,, в м тро твам с t::r::=:::J от лежива ющим порт, w
\о.
Хотя от точки беспроводного доступа отходят три стрелки, на самом деле по радио ведется только одна передача.
с у Поведение при пол чении зави ит от режима О и ный ре им д ноч ж
у й Гр ппово р ежим
м кадраус,ройс Приеоч твом один ным у ус йс Др гиегн троу тва кадр и орир ют
адр может быть К принят ус йстнескол.ькими тро вами
Что определяет режим? В адрес ч й одиноусны ае с йахрежи МАг Сул и и р ппово м танав лив т я ре й г в наим енее знач1им омj jразряд е пер вого пе данно о ба та jj jj
\___!lри установке в 1 Мае-адрес находится в групповом режиме.
Рис. 8.4. Разница между одиночными и групповыми МАС-адресами
8.6.1. Создание МАС-адресов Когда в разделе 8.8 зайдет речь о чистом ТСР-протоколе, в листинге 8.22 будет создан код виртуального аппаратного устройства. Чтобы кого-то убедить, что с на ми можно вести диалог, нужно научиться назначать нашему виртуальному устрой ству МАС-адрес. Такой адрес для нас создается в проекте macgen, код которого по казан в листинге 8.17. А в следующем листинге показаны метаданные для этого проекта. Его код находится в файле ch8/ch8-mac/Cargo.toml.
[package] name = "chB-rnacgen" version = "0.1.0" authors = ["Tirn McNamara "] edition = "2018" [dependencies] rand = "0.7"
Код проекта macgen, являющегося нашим генератором МАС-адресов, показан в следующем листинге. Его можно найти в файле ch8/ch8-mac/src/main.rs.
361
Работа в сети
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
extern crate rand; use rand::RngCore; use std::fmt; use std::fmt::Display; #[derive(Debug)] struct мacAddress( [u8; 6]);
(1)
impl Display for МacAddress fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let octet = self.0; write! ( f, "{:02х):{:02х):{:02х):{:02х):{:02х):{:02х)",
octet[0], octet[l], octet[2], octet[3], octet[4], octet[5]
impl МacAddress рuЬ fn new() -> MacAddress let mut octets: [u8; 6] = [О; 6]; rand::thread_rng().fill_bytes(&mut octets); octets[0] i= 0b_0000_00l0; octets[0] &= 0b_llll_lll0; МacAddress { О: octets}
(1)
(2)
(3)
impl Into for МacAddress fn into(self) -> wire::EthernetAddress wire::EthernetAddress { О: self.0}
(1) Генерация случайного числа. (2) Обеспечение установки бита локального адреса в 1. (3) Обеспечение установки бита одиночного режима в О.
Порядок взаимодействия с сервером для выполнения НТТР-запроса показан в сле дующем листинге. Его код находится в файле ch8/ch8-mget/src/http.rs.
370
2 3 4 5 6 7 В 9 10 11
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
Глава 8
1 use std::collections::BTreeMap; use std::fmt; use std::net::IpAddr; use std::os::unix::io::AsRawFd; use smoltcp::iface::{EthernetinterfaceBuilder, NeighЬorCache, Routes}; use smoltcp::phy::{wait as phy_wait, Tapinterface}; use smoltcp::socket::{SocketSet, TcpSocket, TcpSocketBuffer}; use smoltcp::time::Instant; use smoltcp::wire::{EthernetAddress, IpAddress, IpCidr, Ipv4Address}; use url::Url; it [derive (Debug}] enum HttpState { Connect, Request, Response, it[derive(Debug)] pub enum UpstreamError Network(smoltcp::Error), InvalidUrl, Content(std::str::UtfBError), impl fmt::Display for UpstreamError { fn fmt(&self, f: &mut fmt::Formatter {} Err (smoltcp: :Error::Unrecognized) => {} Err(e) => { eprintln!("error: {:?}", е);
let mut socket = sockets.get::(tcp_handle); state = match state { HttpState::Connect if !socket.is_active() => { eprintln!("connecting"); socket.connect((addr, ВО), random_port())?; HttpState::Request HttpState::Request if socket.may_send() => { eprintln!("sending request"); socket.send_slice(http_header.as_ref())?; HttpState::Response HttpState::Response if socket.can_recv() => { socket.recv(lraw_datal { let output = String::from_utfB_lossy(raw_data); println!("{}", output); (raw_data.len(), ()) } )?; HttpState::Response HttpState::Response if !socket.may_recv() => eprintln !("recei ved complete response") ; break 'http; => state,
phy_wait(fd, iface.poll_delay(&sockets, timestamp)) .expect("wait error"); Ok(())
Работа в сети
373
И наконец, в следующем листинге показан код, выполняющий DNS-разрешение. Его можно найти в файле ch8/ch8-mget/src/dns.rs. 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
use std::error::Error; use std::net::{SocketAddr, UdpSocket}; use std::time::Duration; use use use use use
trust_dns: :ор::{Message, MessageType, OpCode, Query}; trust_dns::proto::error::ProtoError; trust_dns::rr::domain::Name; trust_dns::rr::record_type::RecordType; trust_dns::serialize::binary::*;
fn message_id() -> u16 { let candidate = rand::random(); if candidate == О { return message_id(); candidate # [derive(Debug)] рuЬ enum DnsError ParseDomainName(ProtoError), ParseDnsServerAddress(std::net::AddrParseError), Encoding(ProtoError), Decoding(ProtoError), Network(std::io::Error), Sending(std::io::Error), Receving(std::io::Error), impl std::fmt::Display for DnsError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write! (f, "{:#?}", self)
impl std::error::Error for DnsError {} рuЬ fn resolve( dns server address: &str, domain_name: &str, -> Result { let domain name
(1)
374 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 83 84 85 86 87 88 89
Глава В
Narne::from_ascii(domain_narne) .map_err(DnsError::ParseDomainNarne)?; let dns server address = format! ("{}:53", dns_server_address); let dns-server: SocketAddr = dns server-address .parse() .map_err(DnsError::ParseDnsServerAddress}?; let mut request_buffer: Vec = Vec::with_capacity(64); let mut response_buffer: Vec = vec![0; 512);
(2)
(3) (3) (4) (4)
let mut request = Message::new(); request.add_query( Query::query(domain_name, RecordType::A) );
(5) (5)
request .set_id(message_id()) .set_message_type(MessageType::Query) .set_op_code(OpCode::Query) .set_recursion_desired(true);
(6)
(5)
let localhost = UdpSocket::Ьind("О.О.О.О: 0").map_err(DnsError::Network)?; let timeout = Duration::from_secs(5); localhost .set_read_timeout(Some(timeout)) .map_err(DnsError::Network)?;
(7)
localhost .set_nonЬlocking(false) .map_err(DnsError::Network)?; let mut encoder = BinEncoder::new(&mut request_buffer); request.emit(&mut encoder).map_err(DnsError::Encoding)?; let _n_bytes_sent = localhost .send_to(&request_buffer, dns_server) .map_err(DnsError::Sending)?; loop { let (_b_bytes_recv, remote_port) = localhost .recv_from(&mut response_buffer)
(8)
Работа в сети 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
375
.map_err(DnsError::Receving)?; if remote _port break;
dns server
let response = Message::from_vec(&response_buffer) .map_err(DnsError::Decoding)?; for answer in response.answers() { if answer.record_type() == RecordType::A let resource = answer.rdata(); let server_ip = resource.to_ip_addr() .expect("invalid IP address received"); return Ok(Some(server_ip));
Ok(None)
(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
Глава В
Резюме ♦ Работа в сети отличается особой сложностью. Точность таких стандартных мо делей, как 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 показано на следующем рисунке.
Тепловизионная камера
Компьютер RaspЬeny 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 •
Странностей у chronos сравнительно мало, но одна из них - скрытый переход секунд в поле наносекунд. 3
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 вкратце показано, как это делается в приложении.
Приложение
Библиотека libc
Операционная система
Оборудование
Рис. 9.1. Приложение получает информацию о времени из операционной системы, пользуясь, как правило, функциями, предоставляемыми реализацией системной библиотеки libc.
Может показаться, что для полноценного примера код листинга 9.2, считывающий системное время в локальном часовом поясе, слишком невелик. Но его запуск на выполнение приводит к получению текущей метки времени, отформатированной в соответствии со стандартом ISO 8601. Конфигурация для него представлена в сле дующем листинге, код которого можно найти в файле ch9/ch9-clock0/Cargo.toml.
[package] name = "clock" version = "0.1.0"
Глава 9
384
authors = ["Tim McNamara "] edition = "2018" [dependencies] chrono = "0.4"
Код следующего листинга считывает системное время и выводит его на консоль. Его можно найти в файле ch9/ch9-clock0/src/main.rs.
1 use chrono::Local; 2 3 fn main () { 4 let now = Local::now(); 5 println ! (" { } ", now); 6
(1)
(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-clockl
$ cargo run -- --use-stanc:lard rfc2822 warning: associated function is never used: 'set'
385
Время и хронометраж
--> src/main.rs:12:B 12
fn set() -> ! {
= note: '#[warn(dead_code)]' on Ьу 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.
2 3 4 5 6 7 В 9 10
use chrono::{DateTimel; use chrono::{Local}; struct Clock; impl Clock { fn get() -> DateTime Local::now()
(1)
11
12 13 14
fn set() -> ! { unimplemented! ()
15 (1) DateTime - это DateTime с информацией о локальном часовом поясе.
А что на самом деле представляет собой тип, возвращаемый функцией s e t () ? Вос клицательный знак ( ! ) показывает компилятору, что функция никогда ничего не возвращает (возвращаемого значения у нее быть не может). Это называется типом «Никогда)). Если в ходе выполнения программы попадется макрос u n implemented ! () (или его более компактный собрат todo ! () ), программа запаникует.
Глава 9
386
На данном этапе clock JJ;fЙствует исключительно как пространство имен. Здесь до бавление структуры предоставляет возможность последующего расширения про граммы. По мере доработки приложения Clock может пригодиться для содержания какого-либо состояния между вызовами или для реализации какого-либо типажа, поддерживающего новые функциональные возможности. ПРИМЕЧАНИЕ Структура без полей известна как тип с нулевым размером (zero-sized type), или ZST. В получающемся в результате компиляции приложении она вообще не занимает ника кой памяти и является исключительно конструкцией времени компиляции.
9.6.2. Форматирование времени В данном разделе форматирование времени рассматривается как приведение к мет ке времени UNIX или к отформатированной строке, соответствующей соглашениям ISO 8601, RFC 2822 и RFC 3339. В следующем листинге, являющемся частью лис тинга 9.7, показывается, как создаются метки времени с использованием функцио нальности, предоставляемой chrono. Затем метки времени отправляются на стан дартный вывод.
48 49 50 51 52 53 54
let now = Clock::get(); match std { 1 timestarnp 11 => println ! (" {} ", now. timestarnp()), 11 => println! ( 11 {} , now.to_rfc2822()), rfc2822" 1 f 3339 11 r c => println ! ( 11 {} 11, now. to_rfc3339 ()), => unreachaЫe ! (), 1
11
1
Наше сlосk-приложение (благодаря chrono) поддерживает три формата времени: метку времени (timestamp), rfc2822 и rfc3339: • Метка времени (нmestamp) - является форматом, показывающим количест во секунд, прошедших с начала эпохи, известным также как метка времени 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
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
Глава 9
let арр = Арр::new("clock") .version("0.1") .aЬout("Gets and (aspirationally) sets the time.") .arg( Arg::with_name("action") .takes_value(true) .possiЫe_values(&["get", "set"]) .default_value("get"), .arg( Arg::with_name("std") .short("s") .long("standard") .takes_value(true) .possiЫe_values(&[ "rfc2822", "rfc3339", "timestamp", ]) .default_value("rfc3339"), .arg(Arg::with_name("datetime").help( "When is 'set', apply . \ Otherwise, ignore.", ));
(1)
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:/ /githuЬ.com/rust-in-action/code rust-in-action $ cd rust-in-action/ch9/ch9-clockl $ cargo Ьuild Compiling clock v0.1.1 (rust-in-action/ch9/ch9-clockl)
389
Время и хронометраж
warning: associated function is never used: 'set' --> src/main.rs:12:6 12
(1)
fn set() -> ! { note: '#[warn(dead_code)]' on Ьу default
warning: 1 warning emitted $ carqo run -- --help
(2)
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] [possiЬle values: rfc2822, rfc3339, timestamp] ARGS: [default: get] [possiЫe values: get, set] When is 'set', apply . Otherwise, ignore. $ urqet/deЬuq/cloclt 2021-04-03Т15:48:23.984946724+13:ОО
(3)
(1) Это предупреждение устраняется в clock v0.1.2. (2) Аргументы, указанные справа от двойного дефиса (--), отправляются получаюцемуся в результате компиляции исполняемому коду. (3) Непосредственное выполнение target/debug/clock.
Для поэтапного создания проекта придется приложить немного больше усилий. Поскольку clock v0.1.1 является проектом, управляемым cargo, он придерживается стандартной структуры:
r-
clock Cargo.toml L src L 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.
[package] narne = "clock" version = "0.1.1" authors = ["Tim McNarnara "] edition = "2018" [dependencies] chrono = "0.4" clap = "2"
1 2 3 4 5 6 7 8 9 10
use chrono::DateTime; use chrono::Local; use clap::{App, Arg};
12 13 14 15
fn set() -> ! { unimplemented! ()
11
struct Clock; impl Clock { .fn get() -> DateTime Local::now()
391
Время и хронометраж
16 17 fn main() { 18 let арр = Арр::new("clock") 19 .version("0.1") 20 .aЬout ("Gets and · (aspirationally) sets the time.") .arg( 21 22 Arg: :with_name("action") 23 .takes_value(true) 24 .possiЫe_values(&["get", "set"]) .default_value("get"), 25 26 .arg( 27 Arg::with_name("std") 28 29 .short("s") .long("use-standard") 30 .takes_value(true) 31 32 .possiЫe_values(& [ "rfc2822", 33 "rfc3339", 34 "timestamp", 35 ]) 36 37 .default_value("rfc3339"), 38 .arg (Arg: :with_name("datetime").help( 39 "When is 'set', apply . \ 40 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(); 48 49 if action == "set" 50 unimplemented! () 51 52 53 let now = Clock::get(); 54 match std { 55 "timestamp" => println! ("{)", now.timestamp()), "rfc2822" => println! ("{)", now.to_rfc2822()), 56 "rfc3339" => println! ("{)", now.to_rfc3339()), 57 => unreachaЬle! (), 58 59 60
(1) (1)
(2)
Глава 9
392
(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; рuЬ struct timeval рuЬ tv_sec: time_t, рuЬ 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] libc = "0.2"
(1)
(1) Эти две строки можно добавить к концу файла.
В следующем листинге, являющимся частью листинга 9.11, показано, как устано вить время с применением стандартной библиотеки языка С libc. В листинге преду сматривается использование операционных систем Linux и BSD, или других им подобных систем.
62 #[cfg(not(windows))] 63 fn set(t: DateTime) -> () 64 use libc::{timeval, time_t, suseconds_t); 65 use libc::{settimeofday, timezone 66 67 let t = t.with_timezone(&Local); 68 let mut и: timeval = unsafe { zeroed() }; 69 70 u.tv sec = t.timestamp() as time t; 71 u.t v usec = t.timestamp_suЬsec_micros() as suseconds_t; 72 73 74 unsafe { let mock tz: *const timezone = std::ptr::null(); (1) 75 settimeofday(&u as *const timeval, mock_tz); 76 77 78
(1) (2)
(2)
(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-разрядных платформах его использование без сложного приведения типов может вызвать досадные ошибки переполнения, поэтому здесь он не используется.
Глава 9
396
Код clock для Windows Из-за многочисленности полей в структуре SYSTEMTIME ее создание занимает не много больше времени. Конструкция этой структуры показана в следующем лис тинге.
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
#[cfg(windows)] fn set(t: DateTime) -> () { use chrono::Weekday; use kernel32::SetSystemTime; use winapi::{SYSTEМTIМE, WORDI; let t = t.with_timezone(&Local); let mut systime: SУSТЕМТIМЕ = unsafe { zeroed() 1; let dow = match t.weekday() Weekday::Mon => 1, Weekday: :Tue => 2, Weekday: :Wed => 3, Weekday::Thu => 4, Weekday: :Fri => 5, Weekday::Sat => 6, Weekday:: Sun => о,
(11 (11 (1) (1) (1) (1) (1) (1)
1;
let mut ns = t.nanosecond(); let mut leap = О; let is_leap_second = ns > 1 000_000_000;
(2)
if is_leap_second { ns -= 1_000_000_000; leap += 1;
(2)
systime.wYear = t.year() as WORD; systime.wMonth = t.month() as WORD; systime.wDayOfWeek = dow as WORD; systime.wDay = t.day() as WORD; systime.wHour = t.hour() as WORD; systime.wMinute = t.minute() as WORD; systime.wSecond = (leap + t.second()) as WORD; systime.wMilliseconds = (ns / 1 000_000) as WORD; let systime_ptr = &systime as *const SYSTEMTIМE;
(2) (2)
(2) (2) (2)
397
Время и хронометраж
58 59 60 61 62
unsafe { SetSystemTime(systime_ptr);
(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 L. src L. main.rs
(1) (2)
(1) См. листинг 9.10 (2) См. листинг 9 .11
Полный исходный код проекта представлен в листингах 9 .1О и 9 .11. Его можно за грузить соответственно из файлов ch9/ch9-clock0/Cargo.toml и ch9/ch9-clock0/src/main.rs.
[package] name = "clock" version "0.1.2" ["Tim McNamara "] authors 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"
Глава 9
398
1 2 3 4 5 6 7
# [ cfg(Windows)] use kernel32; #[cfg(not(windows))] use libc; #[cfg(windows)] use winapi;
8 use chrono::{DateTime, Local, TimeZone}; 9 use clap:: {Арр, Arg); 10 use std::mem::zeroed;
11
12 struct Clock; 13
14 impl Clock { 15 fn get() -> DateTime 16 Local ::now() 17 18 19 #[cfg(windows)] 20 fn set(t: DateTime) -> () { 21 use chrono::Weekday; 22 use kernel32::SetSystemTime; 23 use winapi::{SУSТЕМТIМЕ, WORD}; 24 25 let t = t.with_timezone(&Local); 26 let mut systime: SУSТЕМТIМЕ = unsafe { zeroed() }; 27 28 let dow = match t.weekday() { 29 Weekday::Mon => 1, 30 31 Weekday::Tue => 2, Weekday::Wed => 3, 32 Weekday::Thu => 4, 33 34 Weekday::Fri => 5, 35 Weekday::Sat => 6, Weekday::Sun => о, 36 }; 37 38
39 40 41 42 43 44 45
let mut ns = t.nanosecond(); let is_leap_second = ns > 1_000_000_000; if is_leap_second { ns -= 1_000_000_000;
399
Время и хронометраж
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
systime.wYear = t.year() as WORD; systime.wMonth = t.month{) as WORD; systime.wDayOfWeek = dow as WORD; systime.wDay = t.day() as WORD; systime.wHour = t.hour() as WORD; systime.wMinute = t.minute() as WORD; systime.wSecond = t.second() as WORD; systime.wMilliseconds = (ns / 1_000_000) as WORD; let systime_ptr
=
&systime as *const SУSТЕМГIМЕ;
unsafe { SetSystemTime(systime_ptr);
f[cfg(not(windows))] fn set(t: DateTime) -> () use libc::(timeval, time_t, suseconds_t}; use libc::(settimeofday, timezone}; let t = t.with_timezone(&Local); let mut u: timeval = unsafe ( zeroed() }; u.tv sec = t.timestamp() as time_t; u.tv usec = t.timestamp_suЬsec micros() as suseconds_t; unsafe ( let mock tz: *const timezone = std::ptr::null(); settimeofday(&u as *const timeval, mock_tz);
78 79 80 81 fn main() ( 82 let арр = Арр: :new("clock") 83 .version("0.1.2") 84 .aЬout("Gets and (aspirationally) sets the time.") 85 . after_help( 86 "Note: UNIX timestamps are parsed as whole \ seconds since 1st January 1970 0:00:00 UTC. \ 87 88 For more accuracy, use another format.", 89 .arg( 90 91 Arg::with_name("action") 92 .takes_value(true)
400
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 11 О 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
Глава 9
.possiЫe_values(&[ 11 get 11 .default_value( 11 get 11
,
11
),
.arg( Arg::with_narne( 11 std 1 .short( 11 s 11 .long( 11 use-standard 11 .takes_value(true) .possiЫe_values(&[ 11 rfc2822 11 11 rfc3339 11 11 timestarnp 11 ]) .default_value( 11 rfc3339 11
set 11
] )
1)
)
)
, ,
,
),
.arg(Arg::with_narne( 11 datetime 11 .help( 11 When is 'set', apply . \ Otherwise, ignore. 11 )); )
,
let args = app.get_matches(); let action = args.value_of( 11 action 11 .unwrap(); let std = args.value_of( std 11 unwrap(); )
11
) •
if action == 11 set 11 let t = args.value_of( 11 datetime 11 .unwrap(); {
)
let parser = match std { 11 rfc2822 11 => DateTime::parse_from_rfc2822, 1 rfc3339 1 => DateTime::parse_from_rfc3339, => unimplemented! (), ); 1
1
let err_msg = format! ( 11 UnaЬle to parse 11 according to {} 11 t_, std ); let t = parser(t_} .expect(&err_msg);
,
Clock::set(t) let now = Clock::get(); match std ( 11 timestarnp 11 => println! ( 11
{}
11 ,
now.timestarnp()),
401
Время и хронометраж
140 141 142 143 144
"rfc2822" => println! ("{}", now.to_rfc2822()), "rfc3339" => println! ("{}", now.to_rfc3339()), => unreachaЬle! (),
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(); match os_error_code { Some(0) => (), Some(_) => eprintln ! ("UnaЬle to set the time: {: ? }", maybe_error), None => (),
(1) (1) (2)
(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. Т, - запись времени локального компьютера на момент оmравки первого сообщения.
Т, - запись времени локального компьютера на момент получения второго сообщения.
Заголовок, показывающий, что сообщение является запросом времени�
не имеет абсо лютно\ никакого ) значения \
. 11 о ящ Исх д
)
т
, ------------------, указыв ается, f Заголовок, показывающий, тно на практике \
l Локальный к:�пьютер
ее сообщ ение
\,
,// что сообщение является ответом ,, ,
'
, ,, , ,
\
сервером отправляются
метки временит" Т2 и Т3
)
11 ••• Входящеес ообщ ение
2 3 l�У_ д_ ал_ е_·нн_ ь_1й_с _ ер_ в_ е_р ___т_ ___т _ ------�] Т2 и Т3 записываются удаленным сервером при фиксации времени получения первого и отправки второго сообщения.
Время-----------------------
Рис. 9.2. Метки времени, определенные в стандарте NTP
Чтобы понять, что все это означает в программном коде, обратимся к следующему листингу. Код в строках 2-12 занят установкой подключения. В строках 14-21 вы дается значение Т 1 - Т4.
404
Глава 9
1 fn ntp_roundtrip( 2 host: &str, 3 port: u16, 4 -> Result { 5 let destination = foпnat ! (" { } : { } ", host, port); 6 let timeout = Duration::from_secs(l); 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
let request = NTPMessage::client(); let mut response = NTPMessage::new(); let message
=
request.data;
let udp = UdpSocket::bind(LOCAL_ADDR)?; udp.connect(&destination).expect("unaЬle to connect"); let tl
=
Utc::now();
(1)
udp.send(&message)?; udp.set_read_timeout(Some(timeout))?; udp.recv_from(&mut response.data)?; let t4
=
(2)
(3)
Utc::now();
let t2: DateTime response .rx_time() . unwrap() .into();
=
let t3: DateTime = response .tx_time() .unwrap() .into();
(4)
(4) (4) (4) (4) (5) (5) (5)
(5) (5)
Ok(NTPResult tl: tl, t2: t2, t3: t3, t4: t4, })
(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, показан процесс обра щения к нескольким тайм-серверам.
175 fn check_time() -> Result { 176 const NTP_PORT: ul6 = 123; 177 178 let servers = [ "time.nist.gov", 179 180 "time.apple.com", 181 "time.euro. apple.com", 182 "time.google.com", 183 184 185 186 187 188 189 190 191 192 193
(1)
];
"time2.google.com", //"time.windows.com",
let mut times = Vec::with_capacity(servers.len()); for &server in servers.iter() print! ("{) =>", server); let calc = ntp_roundtrip(&server, NTP_PORT);
(1) (2)
407
Время и хронометраж 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211
212 213 214 215 216 217 218 219 220 221 222
match calc { Ok(time) => println ! (" { /ms away from local system time", time.offset()); times.push(time); Err(_) => { println! (" ? [response took too long] ") 1; let mut offsets = Vec::with_capacity(servers.len()); let mut offset_weights = Vec::with_capacity(servers.len()); for time in × { let offset = time.offset() as f64; let delay = time.delay() as f64; let weight = 1_000_000.0 / (delay * delay); if weight.is_finite() { offsets.push(offset); offset_weights.push(weight);
(3)
let avg_offset = weighted_mean{&offsets, &offset_weights); Ok(avg_offset)
(1) В тайм-серверах Google дополнительная секунда реализуется не ее добавлением, а увеличением длины секунды. То есть в какой-то из дней примерно каждЬiе 18 месяцев этот сервер вьщает время, отличное от времени других серверов. (2) На момент написания книги тайм-сервер Microsoft показывал время, на 15 секунд опережавшее время своих собратьев. (3) Подавление результатов от медленных серверов за счет СУШественного снижения их относительного рейтинга.
9.9.3. Преобразования между представлениями о времени, использующими различные степени точности и эпохи Контейнер chrono представляет дробную часть секунды с точностью до наносе кунд, а в NTP моменты времени могут быть представлены с разницей примерно в 250 пикосекунд. Подсчет ведется чуть ли не в четыре раза точнее! Использование различных внутренних представлений подразумевает возможную потерю точности при преобразованиях.
Глава 9
408
Механизм, сообщающий языку Rust о возможности преобразования одного типа в другой, находится в типаже From. В нем предоставляется метод from (), встреча с которым происходит на ранних стадиях освоения Rust (например, в виде кода String::from("Hello, world!")).
В следующем листинге показаны три фрагмента из кода листинга 9.15, в которых представлена реализация типажа std::conver t ::From. Этот код позволяет осуще ствлять вызовы . into (), показанные в строках 28 и 34 листинга 9.12.
19 const NTP ТО UNIX SECONDS: i64 = 2_208_988_800;
(1)
22 #[derive(Default,Debug,Copy,Clone)] 23 struct NTPTimestamp 24 seconds: u32, 25 fraction: u32, 26 (2) 52 impl From for DateTime { 53 fn from(ntp: NTPTimestamp) -> Self { 54 let secs = ntp.seconds as i64 - NTP_TO_UNIX_SECONDS; let mut nanos = ntp.fraction as f64; 55 56 nanos *= le9; 57 nanos /= 2_f64.powi(32); 58 59 Utc.timestamp(secs, nanos as u32) 60
(2) (2) (2)
(3) (3)
61
62 63 impl From for NTPTimestamp { 64 fn from(utc: DateTime) -> Self { 65 let secs =·utc.timestamp() + NTP_TO_UNIX_SECONDS; let mut fraction = utc.nanosecond() as f64; 66 67 fraction *= 2_f64.powi(32); 68 fraction /= le9; 69 NTPTimestamp { 70 71 seconds: secs as u32, fraction: fraction as u32, 72 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.
1 #[cfg(windows)] 2 use kerne132; 3 #[cfg(not(windows))] 4 use libc; 5 #[cfg(windows)] 6 use winapi; 7 8 use byteorder::{BigEndian, ReadВytesExt}; 9 use chrono::{ 10 DateTirne, Duration as ChronoDuration, TirneZone, Tirnelike, 11 1; 12 use chrono:: { Local, Utc}; 13 use clap:: {Арр, Arg}; 14 use std::mern::zeroed; 15 use std::net::UdpSocket; 16 use std::tirne::Duration; 17 18 const NTP_МESSAGE_LENGTH: usize = 48; 19 const NTP ТО UNIX SECONDS: i64 = 2 208 988_800; 20 const LOCAL ADDR: &'static str = "0.0.0.0:12300"; 21 22 #[derive(Default, Debug, Сору, Clone)] 23 struct NTPTirnestarnp 24 seconds: u32, 25 fraction: u32, 26
(1) (2)
Глава 9
410
27 28 struct NTPMessage { 29 data: [u8; NTP_МESSAGE_LENGTH], 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
# [derive(Debug)] struct NTPResult tl: DateTirne, t2: DateTirne, t3: DateTirne, t4: DateTirne,
irnpl NTPResult { fn offset(&self) -> i64 { let duration = (self.t2 - self.tl) + (self.t4 - self.t3); duration.nurn_rnilliseconds() / 2 fn delay(&self) -> i64 { let duration = (self.t4 - self.tl) - (self.t3 - self.t2); duration.nurn_rnilliseconds()
irnpl Frorn for DateTirne { fn frorn(ntp: NTPTirnestarnp) -> Self { let secs = ntp.seconds as i64 - NTP_TO_UNIX_SECONDS; let rnut nanos = ntp.fraction as f64; nanos *= le9; nanos /= 2_f64.powi(32); Utc.tirnestarnp(secs, nanos as u32)
irnpl Frorn for NTPTirnestarnp { fn frorn(utc: DateTirne) -> Self { let secs = utc.tirnestarnp() + NTP_TO_UNIX_SECONDS; let rnut fraction = utc.nanosecond() as f64; fraction *= 2_f64.powi(32); fraction /= le9; NTPTirnestarnp { seconds: secs as u32, fraction: fraction as u32,
411
Время и хронометраж 74 75 76 77 impl NTPMessage 78 fn new() -> Self NTPMessage { 79 80 data: [О; NTP_МESSAGE_LENGTH], 81 82 83 84 fn client() -> Self { 85 const VERSION: u8 = 0b00_0ll_000; 86 const MODE: u8 = 0b00_OO0_0ll; 87 let mut msg = NTPMessage::new(); 88 89 90 msg.data[0] 1= VERSION; 91 msg.data[0] 1= MODE; msg 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
(3) (3)
(4) (4)
(5)
fn parse_timestamp( &self, i: usize, -> Result { let mut reader = &self.data[i..i + 8]; let seconds = reader.read_u32::(}?; let fraction = reader.read_u32::(}?;
(6)
Ok(NTPTimestamp { seconds: seconds, fraction: fraction, }} fn rx_time( &self -> Result self.parse_timestamp(32} fn tx_time( &self -> Result self.parse_timestamp(40}
(7)
(8)
412
Глава 9
120 121 122 fn weighted_mean(values: &[f64], weights: &[f64]) -> f64 { 123 let mut result = О.О; 124 let mut sum_of_weights = О.О; 125 126 for (v, w) in values.iter().zip(weights) 127 result += v * w; 128 sum_of_weights += w; 129 130 131 result / sum_of_weights 132 133 134 fn ntp_roundtrip( 135 host: &str, 136 port: u16, 137 -> Result { 1 138 let destination = format ! ( host, port); 139 let timeout = Duration::from_secs(l); 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( 11 unaЫe to connect 11 148 149 let tl = 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 let t2: DateTime = response 157 .rx_time() 158 .unwrap() 159 160 .into(); 161 let t3: DateTime 162 response 163 .tx_time() 164 .unwrap() 165 .into(); 166 1, 11 {}: {}
);
Время и хронометраж
167 Ok(NTPResult tl: tl, 168 t2: t2, 169 t3: t3, 170 171 t4: t4, 172 11 173 174 175 fn check_tirne() -> Result { 176 const NTP_PORT: ul6 = 123; 177 178 let servers = [ 179 "tirne.nist.gov", "tirne.apple.corn", 180 "tirne.euro.apple.corn", 181 182 "tirne.google.corn", "tirne2.google.corn", 183 //"tirne.windows.corn", 184 185 ]; 186 187 let rnut tirnes = Vec::with_capacity(servers.len()); 188 189 for &server in servers.iter() 190 print! ("{1 =>", server); 191 192 let calc = ntp_roundtrip(&server, NTP_PORT); 193 194 rnatch calc Ok(tirne) => 195 196 println! (" { }rns away frorn local systern tirne", tirne.offset()); 197 tirnes.push(tirne); 198 Err(_) => { 199 200 println! (" ? [response took too long]") 201 202 1; 203 204 205 let rnut offsets = Vec::with_capacity(servers.len()); 206 let rnut offset_weights = Vec::with_capacity(servers.len()); 207 208 for tirne in &tirnes { 209 let offset = tirne.offset() as f64; 210 let delay = tirne.delay() as f64; 211
212
let weight = 1 ООО ООО.О/ (delay * delay);
413
414
if weight.is_finite() { 213 offsets.push(offset); 214 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 { fn get() -> DateTime 227 228 Local::now() 229 230 231 #[cfg(windows)] 232 fn set(t: DateTime) -> () { 233 use chrono::Weekday; 234 use kernel32::SetSystemTime; use winapi::(SYSTEМГIМE, WORD); 235 236 237 let t = t.with_timezone(&Local); 238 239 let mut systime: SУSТЕМГIМЕ = 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, Weekday::Fri => 5, 246 247 Weekday::Sat => 6, Weekday::Sun => о, 248 249 ); 250 251 let mut ns = t.nanosecond(); 252 let is_leap_second = ns > 1_000_000_000; 253 254 if is_leap_second [ 255 ns -= 1_000_000_000; 256 257 258 systime.wYear = t.year() as WORD; 259 systime.wMonth = t.month() as WORD;
Глава 9
Время и хронометраж
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 SУSТЕМТIМЕ; 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_suЬsec_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::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 .arg( 300 301 Arg: :with_name("action") 302 .takes_value(true) 303 .possiЫe_ values (& ["get", "set", "check-ntp"]) 304 .default_value("get"), 305 .arg( 306
415
Глава 9
416 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342
Arg::with_narne ("std") .short("s") .long("use-standard") .takes_value(true) .possiЫe_values(& [ "rfc2822", "rfc3339", "timestarnp"]) .default_value("rfc3339"), .arg(Arg: :with_narne("datetime") .help( "When is 'set', apply . Otherwise, ignore.", )); let args
app.get_matches();
let action args.value_of("action") .unwrap(); let std = args.value_of("std") .unwrap(); if action == "set" { let t_ = args.value_of("datetime") .unwrap(); let parser = match std { "rfc2822" => DateTime::parse_from rfc2822, "rfc3339" => DateTime::parse_from_rfc3339, => unimplemented ! (), 1; let err_msg = format ! ("UnaЬle to parse { 1 according to { 1 ", t_, std); let t = parser(t_) .expect(&err_msg); Clock::set(t); else if action == "check-ntp" { let offset = check_time() .unwrap() as isize; let adjust_ms_ = offset.signum() * offset.abs() .min(200) / 5; let adjust_ms ChronoDuration::milliseconds(adjust_ms_ as i64);
343 344 let now: DateTime = Utc::now() + adjust_ms; 345 Clock::set(now); 346 347 348 349 let maybe_error = std::io::Error::last_os_error(); 350 351 let os error code
Время и хронометраж 352 353 354 355 356
417
&rnaybe_error.raw_os_error(); match os_error_code { Some(0) => (), Some(_) => eprintln! ("UnaЫe to set the time: {:?}", maуЬе_error), None => (},
357 358 359 360 let now = Clock::get(}; 361 362 match std { 363 "timestarnp" => println! ("{}", now. timestarnp(}), "rfc2822" => println! ("{}", now.to_rfc2822(}), 364 "rfc3339" => println! ("{}", now.to_rfc3339(}), 365 => unreachaЫe! (}, 366 367 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, Ь: i32) -> i32 { а + Ь
Примерный эквивалент в форме лямбда-функции имеет следующий вид: let add = 1 а, Ы { а + Ь } ; Лямбда-функции обозначаются парой вертикальных линий ( 1 ... 1 ), за которыми следуют фигурные скобки ( { ... J ). Пара вертикальных линий позволяет определять аргументы. Лямбда-функции в Rust могут читать значения переменных из своей области видимости. Это замыкания. В отличие от обычных функций, лямбда-функции не могут определяться в гло бальной области видимости. В следующем листинге показано, как обойти это пра вило, определив такую функцию внутри main (). Здесь даются определения двух функций: обычной функции и лямбда-функции - а затем проверяется, приводит ли это к одному и тому же результату.
fn add(a: i32, Ь: i32) -> i32 { а + Ь
421
Процессы, потоки и контейнеры fn main() { let lamЬda_add = 1 а,Ь 1 { а + Ь } ; assert_eq! (add(4,5), lamЬda_add(4,5) );
При запуске кода листинга 10.1 на выполнение все проходит удачно (и без види мых признаков). А теперь посмотрим, как применить эти функциональные возмож ности в реальной работе.
10.2. Порождение потоков Потоки являются первичным механизмом, который операционная система предос тавляет для выполнения конкурентных вычислений. Современные операционные системы гарантируют каждому потоку равноправный доступ к центральному про цессору. Умение создавать потоки (зачастую называемое порождением потоков) и понимание оказываемого ими влияния относятся к фундаментальным навыкам про граммистов, желающих использовать многоядерные процессоры.
10.2.1. Введение в замыкания В Rust для порождения потока безымянная функция передается в функцию s td: : thr e a d: : spawn () . Согласно описанию в разделе 10.1 безымянные функции определяются с помощью двух вертикальных линий, применяемых для передачи аргументов, и последующих фигурных скобок, охватывающих тело функции. По скольку spawn () никаких аргументов не принимает, зачастую будет встречаться следующий синтаксис: thread: : spawn ( 1 1 // . . . }) ;
Когда порожденному потоку требуется доступ к переменным, определенным в ро дительской области видимости, называемой захватом (capture), Rust зачастую на стаивает на перемещении захватов в замыкание. Чтобы обозначить намерение пе редачи владения, в безымянной функции применяется ключевое слово move: thread: : spawn(move 1 1 {
//
( 1)
}) ;
(1) Ключевое слово move позволяет безымянной функции получать доступ к переменным из более широкой области видимости.
А зачем нужно ключевое слово move? Замыкания, порожденные в подпотоках, по тенциально могут пережить область видимости своего вызова. Поскольку Rust все гда гарантирует действительность доступа к данным, ему нужно, чтобы владение перешло к самому замыканию. Пока не сложится окончательное представление о
422
Глава 10
том, как все это работает, нужно следовать нескольким рекомендациям по исполь зованию захватов: • Во избежание конфликтов в ходе компиляции следует реализовать типаж Сору.
• Значениям, происходящим во внешних областях видимости, может понадо биться статическое время жизни. • Порожденные подпотоки могут пережить своих родителей. А значит, владе ние должно переходить к подпотоку с применением ключевого слова rnove.
10.2.2. Порождение потока Находясь в режиме ожидания, простая задача оставляет центральный процессор в спящем режиме на 300 мс (миллисекунд). Если процессор имеет тактовую частоту 3 ГГц, ему придется отдыхать примерно 1 миллиард циклов. Электроны будут пре бывать в полном бездействии. При выполнении код листинга 10.2 выведет на кон соль общую продолжительность выполнения обоих потоков (в показаниях «настен ных часою>): 300.218594rns
1 use std::{thread, tirne}; 2 3 fn main () { 4 let start = time::Instant::now(); 5 6 let handler = thread::spawn(I 1 { 7 let pause = time::Duration::frorn_millis(З00); 8 thread::sleep(pause.clone()); 9
10 11 12 13 14 15 16
}) ;
handler.join() .unwrap(); let finish = tirne::Instant::now(); println ! ( 11 {: 02?) 11, finish. duration _since(start));
Опыт программирования в многопоточной среде предполагает знакомство с функ цией сращивания 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 мс. Ко нечно, назвать это надежным эталонным тестом нельзя, и тем не менее, здесь пока зано, что создание потока не сильно снижает производительность.
1 use std::{thread, time); 2 3 fn main() { 4 let start = time::Instant::now(); 5 6 let handler_l = thread::spawn(move 11 { 7 let pause = time::Duration::from_millis(300); thread::sleep(pause.clone()); 8 9 ) ); 10 11 let handler_2 = thread::spawn(move 11 { 12 let pause = time::Duration::from_millis(300); 13 thread::sleep(pause.clone()); 14 )); 15 16 handler l.join() .unwrap(); 17 handler_2.join().unwrap(); 18
Глава 10
424 19 20 21 22
let finish = time::Instant::now(); println! ("{:?)", finish.duration_since(start));
Тем, кто уже был в теме, возможно, приходилось слышать, что потоки «не масшта бируются». Что это значит? Каждому потоку нужна своя собственная память, следовательно, память системы в конечном счете может быть исчерпана. Но даже не доходя до предела, создание потока начинает вызывать замедление в других областях. По мере увеличения чис ла потоков растет объем работы по их диспетчеризации со стороны операционной системы. Когда приходится регулировать работу множества потоков, решение о том, какой поток запускать следующим, занимает больше времени.
10.2.4. Эффект от порождения множества потоков Порождение потоков не обходится без издержек. Оно потребляет память и время центрального процессора. Переключение между потоками также обесценивает дан ные, хранящиеся в кэш-памяти. На рис. 10.1 показаны данные, сгенерированные при удачном запуске на выполне ние кода листинга 10.4. До серии из 400 потоков картина отклонений сохраняется практически в неизменном виде. Далее уже никто не знает, сколько времени уйдет на переход в 20-миллисекундный спящий режим.
u :r :r
о
,.:s: а. "'u
..,,. о; :s;
"'
:r а,
:s:
120 110 100 90 80
30 20
+
+
+++
+
+
:t: +
++
о
100
200
300
400
500
*
+
+ +i
++ +
. t!
+\"t-+
•
+
:+
+
+
•
++ +
f-
�;++ + + .. +.t+#.ф \
+
50
а,
"'
+
60 40
С')
+
70
"' :r
+
600
700
800
Количество потоков, порожденных в серии
Рис. 10.1. Продолжительность ожидания перехода потоков
в 20-миллисекундный спящий режим
900
1000
425
Процессы, потоки и контейнеры
! :::Е
�"'
500 +
7
:::Е
� ::r 400
+
:о '8
+
о ,:: s
1§:. 300
� � "' s
+
:,:
� 200
"'а. .,... о .,s :,:
О) :::Е О) а.
.,
+
100
f)1
о +-----,-----,--------,------,-----т----,-----т----,-------г----, 1000 900 800 700 600 500 400 300 200 100 о
� �
Количество потоков, порожденных в серии
Рис. 10.2. Сравнение времени, затрачиваемого на ожидание в течение 20 мс с использованием стратегии спящего режима (обозначено кружками) и с использованием стратегии пустого цикла (обозначено плюсами). На этой диаграмме показаны различия, возникающие при конкуренции сотен потоков.
ж :::Е
"'�7
50
:::Е
:о :,: 40 7 :о
+ +
+
\О
о о ,:: s :,;
а.
�.., s "'s
+ +
30
:,: ф
о- о-
3" 20
"' а. .,.., о .,
:,; :,:
ф
:::Е
ф
а.
•
8- !1-
g.
..
+ !j
+
•••
+ + о
о
е
+
+
о
8
е е
+
+ +
+
&
i
•е•
+ о
о
il �
е
�
о
+ + +
+
о о о о о
+
о 8
+
е
8 11
8
10
а,
� � �
7
о
о
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 мс.
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(); for _m in 0..n { 8 9 let handle = thread::spawn(I 1 { let pause = time::Duration::from_millis(20); 10 thread::sleep(pause); 11 1
Можно также применять и то, и другое: основную часть времени использовать спящий режим, а ближе к концу - пустой цикл.
427
Процессы, потоки и контейнеры
12 13 14 15
16 17 18 19 20 21 22 23
11; handlers.push(handle); while let Some(handle) handle.join();
=
handlers.pop() {
let finish = time::Instant::now(); println!("{l\t{:02?1", n, finish.duration_since(start));
1 use std::{thread, timel; 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(); for _m in O ..n { 8 9 let handle = thread::spawn(I 1 { 10 let start = time::Instant::now(); 11 let pause = time::Duration::from_millis(20); 12 while start.elapsed() < pause thread::yield_now(); 13 14 15
16 17 18 19 20 21 22 23 24
25
1);
handlers.push(handle); while let Some(handle) handle.join();
=
handlers.pop() {
let finish = time::Instant::now(); println! ("{l\t{:02?1", n, finish.duration_since(start));
26
Механизм управления ходом выполнения программы, выбранный нами в строках 19-21, выглядит немного странно. Вместо последовательного перебора вектора h a ndlers выполняется вызов метода рор (), после чего происходит слияние подпо-
428
Глава;:
тока с основным потоком. В следующих двух фрагментах кода происходит сравне ние более привычного цикла for (листинг 10.6) с фактически используемым меха низмом управления (листинг 10.7).
19 for handle in &handlers 20 handle.join(); 21
19 while let Some(handle) 20 handle.join(); 21 }
=
handlers.pop() {
Зачем был использован более сложный механизм управления? Полезно, наверное, будет вспомнить, что как только подпоток сливается с основным потоком, он пере стает существовать. А Rust не позволит нам сохранить ссылку на то, чего не суще ствует. Следовательно, чтобы вызвать j oin () в отношении описателя потока внут ри вектора описателей handlers, описатель потока должен быть удален из han dlers. Возникает проблема. Цикл for не позволяет изменять уже пройденные данные. А вот цикл while при вызове handle r s .рор () позволяет многократно по лучать изменяемый доступ. В коде листинга 10.8 показана нерабочая реализация стратегии пустого цикла. Ее несостоятельность обусловлена использованием более привычного механизма управления с использованием цикла for, который был отвергнут в коде листинга 10.5. Исходный код находится в файле c10/ch10-busythreads-broken/sгc/main.rs. Полу чаемый вывод на консоль показан сразу после листинга.
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 { let handle = thread::spawn(I 1 { 9 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 17 18 19 20 21 22 23 24 25 26
429
} ); handlers.push(handle); for handle in &handlers handle.join(); let finish = tirne::Instant::now(); println ! ( 11 { } \ t { :02?} 11, n, finish.duration_since(start) );
А вот как выглядит информация, выведенная на консоль при попытке компиляции кода листинга 10.8: $ cargo run -q error[E0507]: cannot rnove out of '*handle' which is behind а shared reference --> src/rnain.rs:20:13 20
handle.join(); лллллл rnove occurs because '*handle' has type 'std::thread::JoinHandle', which does not irnplernent the 'Сору' trait
error: aborting due to previous error For rnore inforrnation aЬout this error, try 'rustc --explain Е0507'. error: Could not cornpile 'chl0-busythreads-broken'. То learn rnore, run the cornrnand again with --verbose.
В сообщении об ошибке говорится, что ссылку здесь использовать нельзя. Дело в том, что несколько других потоков могут также воспользоваться своими собствен ными ссылками на базовые потоки. А эти ссылки должны быть действительными. Прозорливые читатели знают, что вообще-то есть более простой способ обойти данную проблему, чем тот, который использовался в коде листинга 10.5. Как пока зано в следующем листинге, нужно просто убрать амперсанд.
19 for handle in handlers 20 handle.join(); 21
430
Глава 10
Здесь мы столкнулись с одним из редких случаев, где использование ссылки на объект вызывает больше проблем, чем использование объекта напрямую. Непо средственный последовательный перебор описателей сохраняет право владения. Тем самым уходят в сторону любые опасения по поводу совместного доступа и можно выполнить задуманное.
Уступка управления с помощью thread::yield_now() Стоит напомнить, что пустой цикл в коде листинга 10.5 включает в себя незнако мый код, повторяющийся в коде следующего листинга. Посмотрим, для чего он нужен.
�· . истинг ;� . ;�он- ... .
14 while start.elapsed() < pause { 15 t hread::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.
9 let handle = t hread: :spawn ( 1 1 10 let start = time::Instant::now(); 11 let pause = time::Duratio n::from_millis(20); 12 while start.elapsed() < pause {
(1)
431
Процессы, потоки и контейнеры
13 14 15 } );
thread::yield_now();
(1) Создавать эту переменную в каждом потоке нет никакого смысла.
Хотелось бы написать какой-нибудь код вроде того, что показан в следующем лис тинге; его код находится в файле ch10/ch10-sharedpause-broken/src/main.rs.
1 use std::{thread,tirne};
2
3 fn rnain() { let pause = tirne::Duration::frorn_rnillis(20); 4 5 let handlel = thread::spawn( 1 1 6 thread::sleep(pause); 7
});
8 9 10
let handle2 = thread::spawn(I 1 thread::sleep(pause); } );
12 13 14
handlel.join(); handle2.join();
11
Если запустить код листинга 10.12 на выполнение, будет получено развернутое и на удивление весьма полезное сообщение об ошибке: $ cargo run -q
error[E0373]: closure тау outlive the current function, but it borrows 'pause', which is owned Ьу the current function --> src/rnain.rs:5:33 5 6
let handlel
=
thread::spawn(I
1
rnay outlive borrowed value pause thread::sleep(pause); ----- 'pause is borrowed here
note: function requires argurnent type to outlive ''static' --> src/rnain.rs:5:19 5
6 7
let handlel = thread::spawn(I } );
thread::sleep(pause);
1
Глава 10
432
help: to force the closure to take ownership of pause (and any other references variaЬles), use the 'move' keyword let handlel = thread::spawn(move 11
5
error[E0373]: closure may outlive the current function, but it borrows 'pause', which is owned Ьу the current function --> src/main,rs:8:33 8
let handle2
=
thread::spawn( 1 1
may outlive borrowed value pause thread::sleep(pause); ----- 'pause' is borrowed here
9
note: function requires argument type to outlive ''static' --> src/main,rs:8:19 let handle2
8 9 101
1);
=
thread::spawn(I 1
thread::sleep(pause);
1
help: to force the closure to take ownership of pause (and any other referenced variaЫes), use the 'move' keyword 8 1
let handle2
=
thread::spawn(move 11
error: aЬorting due to 2 previous errors For more information about this error, try 'rustc --explain Е0373'. error: Could not compile 'chlO-sharedpause-broken'. То learn more, run the command again with --verbose.
Исправить ситуацию можно, следуя указаниям, изложенным в разделе 10.2.1, в ко торых предписывалось добавление ключевого слова mov e в код создания замыка ния. Это слово, переключающее замыкание на использование mоvе-семантики, до бавлено в код следующего листинга. А это, в свою очередь, зависит от реализации типажа сору.
1 use std::(thread,timel; 2 3 fn main() (
433
Процессы, потоки и контейнеры
4 5 6 7
let pause = ti me::Duration::from_millis(20); let handlel = thread::spawn(move 11 { thread::sleep(pause); }) ;
В 9 10
let handle2 = thread::spawn(move 11 { thread::sleep(pause); } );
12 13 14
handlel.join(); handle2.join();
11
Разобраться в том, почему это работает, будет весьма интересно. Чтобы узнать все тонкости этого приема, обязательно прочтите следующий раздел.
10.3. Отличие замыканий от функций Замыкания ( 1 1 {)) отличаются от функций (fn). Поэтому они не взаимозаменяемы, что может усложнить обучение языку. Замыкания и функции имеют разные внутренние представления. Замыкания, по сути, являются безымянными структурами, реализующими типаж std: : ops: : FnOnce, а также, возможно, типажи std: : ops: : Fn и std: : ops: : FnMut. В исходном коде эти структуры не видны, но в них содержатся любые переменные из окружения замы кания, использующиеся внутри него. А вот функции реализованы как указатели на функции, указывающие на код, а не на данные. Код в этом смысле, по сути, является памятью компьютера, помеченной как исполняемый фрагмент. Ситуация усугубляется еще и тем, что замыкания, не содержащие никаких переменных из своего окружения, также являются указателя ми на функции. Принуждение компилятора к раскрытию типа замыкания В исходном коде конкретный тип Rust-замыкания увидеть невозможно. Он созда ется компилятором. Чтобы получить этот тип, нужно вызвать ошибку компилятора, воспользовавшись примерно следующим кодом: 1 fn main() { 2 let а = 20; 3 4 let add to а = lbl {а+ Ь }; 5 add to а == (); 6
(1)
(2)
(1) Замыкания - это значения, и их можно присваивать переменной. (2) Экспресс-метод проверки типа значения, при котором предпринимается попытка вьmолнения с ним недопустимой операции. Компилятор тут же реагирует выдачей сообщения об ошибке.
434
Глава 10
Помимо всех других компилятор при попытке скомпилировать фрагмент как / t mp/a-plus- b. rs выдает и эту ошибку: $ rustc /t:mp/a-plus-b.rs
error[E0369]: binary operation '==' cannot Ье 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 Е0369'.
10.4. Аватары, процедурно генерируемые из многопоточного парсера и генератора кода В этом разделе создается приложение с применением синтаксиса, рассмотренного в разделе 10.2. Предположим, что нужно, чтобы у пользователей нашего приложения по умолчанию были уникальные графические аватары. В качестве одного из под ходов для этого можно взять имена, под которыми они регистрировались, и полу чить дайджест от хэш-функции, а затем воспользоваться этими данными в качестве входных параметров для некоторой процедурной логики создания. Используя дан ный подход, можно получить для всех визуально похожие, но совершенно разные исходные аватары. В нашем приложении будут создаваться параллельные линии. Делаться это будет путем использования символов, задействованных в обозначении шестнадцатерич ных цифр, в качестве кодов операций для LОGО-подобного языка.
10.4.1. Как запустить проект render-hex, и как выrлядит его предполагаемый вывод В этом разделе будут созданы три варианта. Как показано в следующем листинге, все они будут вызываться одинаково. В этом же листинге показан результат вызова нашего проекта render-hex (см. листинг 10.18): $ qit clone https:/ /qithuЬ.can/rust-in-action/code rust-in-action $ cd rust-in-action/chlO/chlO-render-hex
$ carqo run -- $( > echo 'Rust in Action' > shalsшn 1 >
cut -fl -d' '
(1) (1) (1)
435
Процессы, потоки и контейнеры > ) $ 1s
(2)
5deaed72594aaal0edda990c5a5eed868ba8915e.svg Cargo.toml target Cargo.lock src $ cat Sdeaed72594aaa10edda990c5a5eed868Ьa891Se.svg
(3)
(1)
(2) (3)
Глава 11
472
11 intrinsics::abort(); 12 13 14 #[no_mangle] 15 pub extern "С" fn _start() -> ! { 16 let framebuffer = ОхЬВООО as *mut uB; 17 18 unsafe [ 19 framebuffer 20 .offset(l) 21 .write_volatile(Ox30); 22 23 24 loop {} 25 (1) (2) (3) (4) (5) (6)
(4)
(5) (6)
Подготовка программы к работе без операционной системы. Разблокирование внутренних функций компилятора 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, который предос-
Ядро операционной системы
c'Ц?s"ci�i ;;.-XS,JJ��' 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_Ыinking: ul, (1) background_color: u3, (1) is_bright: ul, (1) character color: u3, (1) character: uB, (2) (1) Эти четыре поля занимают в памяти один байт. (2) Доступные символы взяты из кодировки 437 кодовой страницы, которая (в некотором приближении) является расширением ASCII. Текстовый режим VGA имеет 16-цветную палитру, где 3 бита составляют основные 8 цветов. Цвета переднего плана также имеют дополнительный яркий вариант, по казанный ниже: il [repr (uB)] enum Color ( Black = О, White = 8, Blue = 1, BrightBlue = 9, Green = 2, BrightGreen = 10, Cyan = 3, BrightCyan = 11, Red = 4, BrightRed = 12, Magenta = 5, BrightMagenta = 13, Brown = 6, Yellow = 14, Gray = 7, DarkGray = 15,
476
Глава 11
Эта инициализация во время загрузки позволяет легко отображать информацию на экране. Каждая из точек в сетке 8Oх25 сопоставлена с ячейками памяти. Эта об ласть памяти называется буфером кадра. Наш загрузчик обозначает охьвооо началом буфера кадра размером 4000 байт. Для фактической установки значения наш код использует два новых метода: offset () и write_volatile () - которые раньше нам еще не встречались. Следующий лис тинг, являющийся частью листинга 11.4, показывает порядок их использования.
18 19 20 21 22 23
let mut framebuffer = ОхЬВООО as *mut uB; unsafe { framebuffer .offset(l) . write_volatile (ОхЗО);
Кратко эти два новых метода можно объяснить так: • Перемещение по адресному пространству с помощью offset (). Метод offset () , относящийся к типу указателя, выполняет перемещение по адресному пространству с шагом, совпадающим с размером указателя. Например, вызов . offset ( 1) для *mut ив(изменяемый указатель на ив) добавляет к его адресу единицу. Когда тот же вызов выполняется к *mut u32 (изменяемый указатель на uз2), адрес указателя перемещается на 4 байта. • Принудительная запись значения в память с помощью write_volatile(). Указатели предоставляют метод write_volatile (), который выполняет «не уловимую» запись. Неуловимость не позволяет оптимизатору компилятора оптимизировать инструкцию записи. Умный компилятор может просто за метить, что мы везде используем множество констант, и инициализировать программу так, чтобы память просто была установлена на желаемое нами значение. В следующем листинге показан еще один способ записи framebuffer.offset (1) .write volatile (ОхЗО). Здесь используются оператор разыменования(*) и само стоятельная установка памяти на охзо.
18 19 20 21
let mut framebuffer = ОхЬВООО as *mut uB; unsafe { *(framebuffer + 1) = ОхЗО;
(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 рuЬ extern "С" fn _start() -> ! 19 let mut framebuffer = ОхЬ8000 as *mut u8; 20 unsafe { framebuffer 21 22 .offset(l) .write_volatile(OxЗO); 23 24 loop 25 hlt(); 26 (1) 27 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.1О. Исходный код проекта для fledgeos-1 1 2 3 4 5 6
#! [no_std] #! [no_main] #! [feature(core_intrinsics)] use core::intriпsics; use core::panic::Paпicinfo;
Ядро операционной системы
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
479
use x86_64::instructions::{hlt); #[panic_handler] #[no_mangle] рuЬ fn panic( info: &Panicinfo) -> unsafe { intrinsics::abort();
#[no_mangle] pub extern "С" fn _start () -> ! let mut framebuffer = ОхЬ8000 as *mut u8; unsafe { framebuffer .offset(l) .write_volatile(Ох30) ; loop { hlt();
Контейнер х86_64 дал возможность вставлять в наш код инструкции ассемблера. Стоит также изучить еще один подход: использование внутреннего ассемблера. Этот подход вкратце будет продемонстрирован в разделе 12.3.
11.4. fledgeos-2: самостоятельная обработка исключений В следующей итерации FledgeOS улучшены возможности обработки ошибок. FledgeOS по-прежнему дает сбой при возникновении ошибки, но теперь у нас есть структура для создания чего-то более сложного.
11.4.1. Почти что правильная обработка исключений FledgeOS не может управлять какими-либо исключениями, выдаваемыми цен тральным процессором при обнаружении ненормальной работы. Для обработки исключений наша программа должна определить свою собственную специализиро ванную функцию. Специализированные функции вызываются в каждом кадре стека, когда он выпол няет откат после выдачи исключения. Это означает, что стек вызовов просматрива ется, вызывая специализированную функцию на каждом этапе. Роль специализиро ванной функции состоит в том, чтобы определить, может ли текущий кадр стека обработать исключение. Обработка исключений также известна как перехват ис ключения.
480
Глава 11
ПРИМЕЧАНИЕ
Что такое откат стека? Когда функции вызываются, кадры стека накапливаются. Об ратное движение по стеку называется откатом. В конечном итоге откат стека приведет к _start ( ). Поскольку строгая обработка исключений для FledgeOS не требуется, мы реализу ем только самый минимум. В листинге 11.11, являющемся частью листинга 11.12, предоставляется фрагмент кода с минимальным обработчиком. Вставьте его в main.rs. Пустая функция означает, что любое исключение фатально, поскольку ни что здесь не будет помечено как обработчик. При возникновении исключения нуж но просто бездействовать.
4 # ! [feature (lang_items)] 18 #[lang = "eh_personality"] 19 #[no_mangle] 20 рuЬ extern "С" 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.
1 2 3 4 5 6 7 В 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]
Ядро операционной системы li:!
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
Wi""":11$���..,:�;,:;,:;��:;;1:;,:A,�й'cJ'%«:".1;·:%f�,i>c'!Мi'Z«;;�,,;_:·ж·,�:ч:;�: ! { unsafe { intrinsics::abort();
#[lang = "eh_personality"] #[no_mangle] pub extern "С" fn eh_personali ty() { ) #[no_mangle] pub extern "С" fn _start() -> ! { let framebuffer = ОхЬ8000 as *mut u8; unsafe { framebuffer .offset(l) .write_volatile(0x30); loop hlt();
11.5. fledgeos-3: текстовый вывод Давайте выведем текст на экран. Тогда, если действительно возникнет паника, мы сможем сообщить о ней должным образом.
Рис. 11.2. Вывод текста на экран, произведенный fledgeos-3
482
Глава 11
В этом разделе процесс отправки текста в буфер кадра будет рассмотрен более под робно. Результат работы fledgeos-3 показан на рис. 11.2.
11.5.1. Вывод на экран цветного текста Для начала создадим тип числовых констант цвета, которые будут использоваться позже в листинге 11.16. Использование перечисления вместо определения серии константных значений обеспечивает повышенную безопасность типов. В некото ром смысле оно добавляет семантическую связь меЖду значениями. Все они рас сматриваются как члены одной группы. В следующем листинге определяется перечисление, представляющее цветовую па литру VGА-совместимого текстового режима. Сопоставление битовых шаблонов и цветов определяется стандартом VGA, и наш код должен ему соответствовать. Листинг 1◄.. 13. ПредставлениJ' -.. связанных �ис �·
9 #[allow(unused) ] 10 #[derive(Clone,Copy)] 11 # [repr(uB)] 12 enum Color { 13 Black = ОхО, White = 0xF, Blue = 0xl, BrightBlue = Ох9, 14 15 Green = Ох2, BrightGreen = ОхА, 16 Cyan = Ох3, BrightCyan = ОхВ, 17 Red = Ох4, BrightRed = ОхС, 18 Magenta = Ох5, BrightMagenta = 0xD, 19 Brown = Охб, Yellow = ОхЕ, 20 Gray = Ох7, DarkGray = ОхВ 21
(1) (2) (3)
(1) Все варианты цвета в нашем коде использоваться не будут, поэтому предупреждения можно отключить. (2) Используем семантику копирования. (3) Указание компилятору использовать для представления значений один байт.
11.5.2. Управление представлением перечислений в памяти Мы довольствовались тем, что способ представления перечисления отдавали на откуп компилятору. Но бывают моменты, когда нужно брать все в свои руки. Внешние системы часто требуют, чтобы наши данные соответствовали их требова ниям. В коде листинга 11.13 представлен пример подгонки перечисления цветов из VGА совместимой палитры текстового режима под единый формат u8. Он лишает ком пилятора возможности выбирать, какой битовый шаблон (формально называемый
483
Ядро операционной системы
дискриминантом) связывать с конкретными вариантами. Чтобы прописать пред ставление, добавьте атрибут repr. Затем можно будет указать любой целочислен ный тип (i32, u8, ilб, ulб, ... ), а также некоторые особые варианты.
Использование предписанного представления имеет ряд недостатков. В частности, оно снижает вашу гибкость и при этом также не дает компилятору оптимизировать использование пространства памяти. Некоторые перечисления с одним вариантом не требуют представления. Они появляются в исходном коде, но в запущенной на выполнение программе места не занимают.
11.5.3. Зачем использовать перечисления? Цвета можно моделировать по-разному. Например, можно создавать числовые кон станты, которые в памяти выглядят одинаково. Ниже показана одна из таких воз можностей: const BLACK: uB = ОхО; const BLUE: uB = Oxl; // . . .
Использование перечисления приносит дополнительную защиту. Использовать не допустимое значение в нашем коде по сравнению с непосредственным использова нием значения 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 fn color(&self) -> u8 { 32 33 let fg = self.foreground as u8; let bg = (self.background as u8) ! 69 let text = b"Rust in Action"; 70 71 let rnut cursor = Cursor { 72 position: О, 73 foreground: Color::BrightCyan, background: Color::Black, 74 75 1; 76 cursor.priпt(text}; 77 78 loop { hlt(}; 79 80 81
Ядро операционной системы
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.
1 2 3 4 5
6 7 8 9 10 11 12 13
#! #! #! #!
[feature(core_intrinsics)] [feature(lang_items)] [no_std] [no_main]
use core::intrinsics; use core::panic::Panicinfo; use x86_64::instructions::{hlt); #[allow(unused)] #[derive(Clone,Copy)] #[repr(u8)] enum Color { Black = ОхО, White = 0xF, Blue = 0xl, BrightBlue = Ох9, Green = Ох2, BrightGreen = ОхА, Cyan = Ох3, BrightCyan = ОхВ, Red = Ох4, BrightRed = Охс, Magenta = Ох5, BrightMagenta = 0xD, Brown = Ох6, Yellow = ОхЕ, Gray = Ох7, DarkGray = Ох8
14 15 16 17 18 19 20 21 22 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; let bg = (self.background as u8) unsafe { intrinsics::abort();
#[lang = "eh_personality"] #[no_mangle] рuЬ extern "С" fn eh_personality() { ) #[no_mangle] pub extern "С" fn _start() -> ! let text = b"Rust in Action"; let mut cursor = Cursor { position: О, foreground: Color::BrightCyan, background: Color::Black, ); cursor.print(text); loop { hlt();
11.6. fledgeos-4: специализированная обработка паники Наш обработчик паники, повторенный в следующем фрагменте кода, вызывает функцию core::intrinsics::abort(). Это приводит к немедленному выключению компьютера без предоставления каких-либо дополнительных данных: #[panic_handler] #[no_mangle] рuЬ 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 () очищает экран.
,шicked ,,t 'J,elp!', sгc/Mdiн.гs:04:3
QEMU
Рис. 11.3. Вывод сообщения при возникновении паники
о
488
Глава 11
А на втором этапе задействован макрос core: :wri t e ! . Этот макрос в качестве пер вого аргумента (cursor) принимает объект назначения, реализующий свойство core:: fmt: :Write. Обработчик паники, сообщающий, что при использовании те кущего процесса произошла ошибка, показан в следующем листинге, являющемся частью листинга 11.19. Ли�нr 11. f7. Очистка экрана и вывод 61 рuЬ fn p anic(info: &Panicinfo) -> ! { let mut cursor = Cursor { 62 position: О, 63 foreground: Color::White, 64 65 background: Color::Red, 66 ); 67 for in О •• (80*25) { 68 cursor.print(b" "); 69 70 cursor.p osition = О; write! (cursor, "{ )", info) .unwrap(); 71 72 loop {) 73 74 (1) (2) (3) (4)
(1) (1) (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.
54 impl fmt::Write for Cursor { f n write_str(&mut self, s: &str) - > fmt::Result { 55 56 self.print(s.a s_bytes{)); 57 Ok({)) 58 59
Ядро операционной системы
489
11.6.4. Исходный код fledge-4 В следующем листинге показан более удобный для пользователя код обработки паники для FledgeOS. Код листинга находится в файле ch11/ch11-fledgeos-4/src/main.rs. Как и в случае с более ранними версиями, для компиляции проекта повторите дей ствия, указанные в инструкции в разделе 11.2.1, но замените ссьmки на fledgeos-0 на fledgeos-4 . . J'lистинг 11.19. ПолШ:;йri;тинr кода Fledge()S с 1 2 3 4 5
#! [feature(core_intrinsics)] #! [feature(lang_items)] #! [no_std] #! [no_main]
6 use core::fmt; 7 use core::panic::Paniclnfo; 8 use core::fmt::Write; 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
use x86_64::instructions::{hlt); #[allow(unused)] #[derive(Copy, Clone)] #[repr (u8)] enum Color { Black = ОхО, White = 0xF, Blue = 0xl, BrightBlue = Ох9, Green = Ох2, BrightGreen = ОхА, Cyan = Ох3, BrightCyan = ОхВ, Red = Ох4, BrightRed = ОхС, Magenta = Ох5, BrightMagenta = 0xD, Brown = Ох6, Yellow = ОхЕ, Gray = Ох7, DarkGray = Ох8 struct Cursor { position: isize, foreground: Color, background: Color, impl Cursor { fn color(&self) -> u8 { let fg = self.foreground as u8; let bg = (self.background as u8) fmt::Result { self.print(s.as_bytes(}}; Ok((}}
#[panic_handler] #[no_mangle] рuЬ fn panic(info: &Panicinfo} -> let mut cursor = Cursor { position: О, foreground: Color::White, background: Color::Red, 1; for in О..(80*25} { cursor.print(b" "}; cursor.position = О; write!(cursor, "{1", info}.unwrap(}; loop { unsafe ( hlt(}; 11 #[lang = "eh_personality"] #[no_mangle] pub extern "С" fn eh_personality(} { 1 #[no_mangle] pub extern "С" fn _start(} -> panic! ("help!"}; 1
491
Ядро операционной системы ---------------------------•�=-------����
Резюме ♦ Написание программы, предназначенной для работы без операционной системы, может ощущаться как программирование в бесплодной пустыне. Функциональ ные возможности, наличие которых считалось вами само собой разумеющимся, например динамическая память или многопоточность, в данном случае будут недоступны. ♦ В средах вроде встроенных систем, не имеющих динамического управления па мятью, стандартную библиотеку Rust следует отключать, применяя для этого аннотацию#! [no std]. ♦ При взаимодействии с внешними компонентами символы наименования стано вятся значимыми. Чтобы отказаться от возможностей Rust по изменению имен, воспользуйтесь атрибутом#! [no_mangle]. ♦ Внутренними представлениями Rust можно управлять с помощью аннотаций. Например, аннотирование перечисления с помощью # ! [repr ( [uBJ) заставляет значения упаковываться в один байт. Если это не сработает, Rust откажется компилировать программу. ♦ Вам доступны манипуляции с обычными указателями, но есть и типобезопасные альтернативы. Там, где это целесообразно, воспользуйтесь для правильного под счета количества байтов, которые нужно пройти по адресному пространству, методом offset (). ♦ Внутренние компоненты компилятора всегда доступны вам по цене востребова ния nightlу-компилятора. Обращайтесь к встроенным функциям компилятора, таким как intrinsics:: abort (), для обеспечения той функциональности про граммы, которая обычно недоступна. ♦ cargo следует рассматривать как расширяемый инструмент. Это средство всегда находится в центре рабочего процесса Rust-программиста, но его стандартное поведение при необходимости может быть изменено. ♦ Для доступа к обычным машинным инструкциям, таким как HTL, можно вос пользоваться вспомогательными контейнерами, например х86_64, или же пола гаться на запуск встроенного ассемблера. ♦ Не бойтесь экспериментировать. С современными инструментами, такими как QEMU, худшее, что может случиться, - это сбой вашей крошечной операцион ной системы с необходимостью заняться ее немедленным перезапуском.
12 Сигналы, прерывания и исключения
¾
ш М±
7Иf%I. wr PWйФNt�
В этой главе рассматриваются следующие вопросы: ♦ Что такое прерывания, исключения, ловушки и сбои. ♦ Как драйверы устройств информируют приложения о готовности данных. ♦ Как передавать сигналы между приложениями, запущенными на выполнение. В этой главе описывается процесс, посредством которого внешний мир взаимодей ствует с вашей операционной системой. Сеть постоянно прерывает выполнение программы, когда байты готовы к отправке. Это означает, что после подключения к базе данных (или в любое другое время) операционная система может потребовать, чтобы ваше приложение обработало сообщение. Здесь рассматривается этот процесс и способы подготовки к нему ваших программ. В главе 9 говорилось, что цифровые часы периодически уведомляют операционную систему о ходе времени. А здесь объясняется, что происходит с этими уведомле ниями. Через концепцию сигналов в главе вводится понятие одновременного за пуска нескольких приложений. Сигналы возникли как часть традиций операцион ной системы UNIX. Их можно использовать для отправки сообщений между раз ными запущенными программами. Мы совместно рассмотрим обе концепции: и сигналов, и прерываний - поскольку модели программирования у них схожи. Но проще начать с сигналов. Несмотря на то, что в этой главе основное внимание уделяется операционной системе Linux, ра ботающей на процессорах семейства х86, это не означает, что пользователи других операционных систем не смогут руководствоваться ее указаниями.
12.1. Глоссарий Изучить порядок взаимодействия процессоров, драйверов устройств, приложений и операционных систем весьма непросто. Здесь бытует множество специальных тер минов. Хуже того, все термины похожи, и, конечно, разобраться в них мешает еще и то, что они часто подменяют друг друга. Рассмотрим несколько терминов, ис пользуемых в этой главе. Их взаимосвязанность показана на рис. 12.1: • Аварийный сбой (Abort) - невосстанавливаемое исключение. Если приложе ние выдает аварийный сбой, оно завершает работу. • Сбой (Fault) - восстанавливаемое исключение, ожидаемое при выполнении обычных операций, таких как ошибка страницы, случающаяся, когда адрес
494
Глава 12
памяти недоступен, и данные должны быть извлечены из микросхем основ ной памяти. Этот процесс известен как работа с виртуалыюй па.мятью и рас смотрен в разделе 4 главы 6. • Исключение (Exception) - обобщающее понятие, включающее в себя аварий ные завершения, сбои и ловушки. Исключения, формально называемые син хронными прерываниями, иногда рассматриваются в качестве формы преры вания. • Аппаратное прерывание (Hardware interrupt) - прерывание, выдаваемое та кими устройствами, как клавиатура или контроллер жесткого диска. Обычно используется устройствами для уведомления центрального процессора о том, что данные доступны для чтения с устройства. • Прерывание (Jnterrupt) - понятие аппаратного уровня, используемое в двух смыслах. Оно может относиться только к синхронным прерываниям, в числе которых аппаратные и программные прерывания. В него, в зависимости от контекста, также входят исключения. Обычно прерывания обрабатываются операционной системой. • Сигнал (Signal) - понятие уровня операционной системы, используемое для обозначения прерывания потока управления приложением. Сигналы обраба тываются приложениями. Прерывания
----
Зачастую один термин используется для обозначения обеих концепций.
,,,,,//
, , ,
// /, ,-,-,- -
Исключения
lntel определяет три формы классов исключений.
Прерывания
,
'
\ \ \ \ \ \
\\� , \, ,',,,,
о
',,',,,',,,,,_
...
... ... ____ __
......... ........ ..... ___
Рис. 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. Влияние прерываний на приложения Рассмотрим этот вопрос на небольшом примере кода. В следующем листинге пока зано простое вычисление суммы двух целых чисел.
1 fn add(a: i32, b:i32) -> i32 { 2 а + Ь 3
497
Сигналы, прерывания и исключения
4 5 fn main () 6 let а 5; 7 let Ь 6; add(a,b); let с 8 9
Значение с вычисляется всегда, независимо от количества аппаратных прерываний. Но время, затраченное программой на это вычисление и засекаемое по настенным часам, становится неопределенным, поскольку центральный процессор каждый раз выполняет разные задачи. Когда происходит прерывание, центральный процессор тут же останавливает вы полнение программы и переходит к обработчику прерывания. В следующем лис тинге (проиллюстрированном на рис. 12.2) дается подробное описание всего про исходящего, когда при выполнении кода листинга 12.1, показанного между строка ми 7 и 8, возникает прерывание. Нормальное выполнение программы. Поток управления в обычной обстановке представляет собой линейную последовательность инструкций. Вызовы функций и инструкции возврата действительно заставляют центральный процессор выполнять переходы по разным местам памяти, но порядок событий может быть предопределен. main ()
Пунктиром отмечен путь работы процессора при выполнении программы.
,,.
(
1.
let а = 5;
(�':,4-.l let Ь
\��1 - ...
(
= 6;
1 let с =
i
1 \\
',,
add(a: i32, Ь: i32) -> i32 а +
1
add (а, Ь)
ь
RETORN
/
1 /,/ 1/
Прерванное выполнение программы. Напрямую аппаратное прерывание на программу не влияет, хотя может немного повлиять на производительность, поскольку операционная система вынуждена отработать с оборудованием.
main()
,-• �----� j 1et а = 5; ( let Ь = 6;
',,...._l 1 '' -----,,,? ,/1 · ... Программе неизвестно, l· iet с = . . . \1 чем занят процессор. 1 add(a,Ь)I"'
/'
\
,
',"
1
add(a: 132, Ь: 132) -> i32 J
а + ь RETORN
1 // 1,,/
1
• _____ _,/ (
После завершения других задач выполнение продолжается в обычном режиме.
В Rust наличие инструкции возврата просто подразумевается.
Рис. 12.2. Использование сложения для демонстрации потока управления при обработке сигналов
498
1 #[allow(unused)] 2 fn interrupt_handler() 3 / / .. 4 5 6 fn add(a: i32, b:i32) -> i32 { 7 а + Ь
Глава 12
(1)
8
9 10 fn main() 11 let а = 5; 12 let Ь = 6; 13 14 // На клавиатуре нажата клавиша! 15 interrupt_handler() 16 17 let с = 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)
При запуске скомпилированного исполняемого файла операционная система выда ет следующую ошибку: $ rustc +niqhtly asm.rs $ ./asm
Segrnentation fault (core dumped) Начиная с версии Rust 1.50, макрос asm ! считается нестабильным и требует запуска имеющегося в Rust nightlу-компилятора, для установки которого нужно воспользо ваться командой rustup: $ rustup install niqhtly
12.4. Аппаратные прерывания У аппаратных прерываний есть особый поток управления. Для уведомления цен трального процессора устройства взаимодействуют со специализированной микро схемой, известной как программируемый контроллер прерываний (ProgrammaЫe Interrupt Controller, PIC). Порядок передачи прерывания от аппаратных устройств к приложению показан на рис. 12.3. Клавиша нажата! Сообщение получено! Микрочип внутри 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 1 L main.rs L Cargo.toml
(1) (2)
(1) См. листинг 12.4 (2) См. листинг 12.3
В следующем листинге показан контейнер исходных метаданных для проекта sixty. Его исходный код находится в каталоге ch12/ch12-sixty/. [package] name = "sixty" version = "0.1.0" authors = ["Tim McNamara "] [dependencies]
В следующем листинге представлен код для создания базового приложения со сро ком жизни 60 секунд, выводящего на консоль ход своего выполнения. Его исход ный код находится в файле ch12/ch12-sixty/src/main.rs. Листинг 12.4. Базовое приложение, по�ающее сигналы SIGSTOP и SIGCONT. 1 2 3 4 5 6 7 В 9 10 11 12 13 14 15
use std::time; use std::process; use std::thread::{sleep}; fn main() { let delay = time::Duration::from_secs(l); let pid = process::id(); println! ("{}", pid); for i in 1.. =60 { sleep(delay}; println! (". {}", i);
После сохранения кода из листинга 12.4 на диск открываются две консоли. В пер вой нужно выполнить команду cargo run. Появится 3-5-значное число, за которым следует счетчик, увеличивающийся каждую секунду. Число в первой строке -
Глава 12
502
идентификатор процесса (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
5 6 7 8
$ kill -SIGCONT 23221
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 -1 1) SIGHUP 6) SIGAВRT 11) SIGSEGV 16) SIGURG 21) SIGTTIN 26) SIGVТALRМ 31) SIGUSR2
2) 7) 12) 17) 22) 27) 32)
// -1 означает list. SIGINT 3) SIGQUIT 4) SIGILL SIGEMT 8) SIGfPE 9) SIGKILL SIGSYS 13) SIGPIPE 14) SIGALRМ SIGSTOP 18) SIGTSTP 19) SIGCONT SIGTTOU 23) SIGIO 24) SIGXCPU SIGPROf 28) SIGWINCH 29) SIGPWR SIGRTМAX
5) 10) 15) 20) 25) 30)
SIGTRAP SIGBUS SIGTERМ SIGCHLD SIGXfSZ SIGUSRl
Как же их много в системе 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 rnut SHUT DOWN: bool = false;
506
Глава 12
ПРИМЕЧАНИЕ
static mut, несмотря на грамматическую несуразицу, читается как «изменяемый статический». Глобальные переменные доставляют Rust-программистам определенную проблему. Доступ к ним (даже просто для чтения) небезопасен. А код, заключенный в небезо пасные блоки, может сильно загромождать программу. Эта неприглядная картина служит сигналом для предусмотрительных программистов: от глобальных состоя ний по возможности лучше отказываться. В листинге 12.6 представлен пример static mut переменной, чтение которой пока зано в строке 12, а запись - в строках 7-9. Вызов функции rand:: random () в стро ке 8 выдает булевы значения. В результате получается серия точек. Примерно в 50% случаев получается вывод, который похож на показанный в следующем сеансе консоли 1 : $ git clone https://githuЬ.com/rust-in-action/code rust-in-action $ cd rust-in-action/chl2/ch2-toy-gloЬal $ cargo run -q
В следующем листинге представлены метаданные для кода листинга 12.6. Исход ный код метаданных находится в файле ch12/ch12-toy-global/Cargo.toml . . 5. Контейнеl) метаданных
для котt лис
[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.
1 use rand; 2 3 static mut SHUT DOWN: bool 4 5 fn main () {
false;
1 Вывод предполагает наличие хорошего генератора случайных чисел, используемого Rust по умолчанию. Это предположение сбудется, если довериться генератору случайных чисел вашей операционной системы.
6 7 8 9 10
loop { unsafe SHUT DOWN = rand::random();
(1) (2)
print!(".");
11
12 13 14 15 16 17
if unsa fe break );
SHUT OOWN) [
println ! ()
(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://githuЬ.com/rust-in-action/code rust-in-action $ cd rust-in-action/chl2/chl2-Ьasic-handler $ cargo run -q 1 SIGUSRl 2 SIGUSRl
Глава 12
508
3 SIGTERМ 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.
[package] name = "ch12-handler" version = "0.1.0" authors = ["Tim McNamara "] edition = "2018" [dependencies] libc = "0.2"
При выполнении код следующего листинга использует обработчик сигнала для из менения глобальной переменной. Его можно найти в файле ch12/ch12-basic handler/src/main.rs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#! [cfg(not(windows))]
(1)
use std::time::{Duration); use std::thread:: {sleep}; use libc::{SIGTERМ, SIGUSRl}; static mut SHUT OOWN: bool = false; fn main() { register_signal_handlers(); let delay
=
(2)
Duration::from_secs(l);
for i in 1 usize .. { println! ("{)", i); unsafe { if SHUT DOWN
(3)
509
Сигналы, прерывания и исключения
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
println! ("*"); return;
sleep(delay); let signal = if i > 2 { SIGTERМ else ( SIGUSRl ); unsafe libc::raise(signal);
(4)
unreachaЫe! (); fn register_signal_handlers() { unsafe { libc::signal(SIGTERМ, handle sigterrn as usize); libc::signal(SIGUSRl, handle_sigusrl as usize);
#[allow(dead_code)] fn handle_sigterrn(_signal: i32) register_signal_handlers();
(4)
(5) (6)
println! ("SIGTERМ"); unsafe { SHUT DOWN
true;
#[allow(dead_code)] fn handle_sigusrl(_signal: i32) register_signal_handlers();
(7)
(5) (6)
println! ("SIGUSRl");
(1) Указание на то, что этот код не работает под Windows. (2) Этот вызов ДO.JDl(eH состояться как можно раньше, иначе сигнаnы будут обрабатываться некорректно.
Глава 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 chl2/fn-ptr-demo-l.rs && ./fn-ptr-demo-1
noop as usize: Ox5620bb4af530
ПРИМЕЧАНИЕ Получившееся на выходе число Ox5620bb4af530 является адресом памяти (в шест надцатеричной системе счисления), с которого начинается функция noop (). На ва шем компьютере это число будет другим.
Порядок преобразования функции в usize показан в следующем листинге, код ко торого находится в файле ch12/noop.rs. В нем можно увидеть, как значение типа usize используется в качестве указателя на функцию.
fn noop() {) fn main() let fn_ptr = noop as usize; println! ("noop as usize: Ох{ :х)", 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
ПРИМЕЧАНИЕ
На вашем компьютере эти два числа будут другими, но одинаковыми.
fn noop() {} fn rnain() let fn_ptr = noop as usize; let typed_fn_ptr = noop as *const fn() -> (); println! ("noop as usize: Ох {: х} ", fn_ptr); println! ("noop as *const Т: { :р}", 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 chl2/chl2-ignore $ carqo 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). [package] name = "igпore" version "0 .1. 0" authors ["Tim McNamara "] edition "2018" [dependencies] libc = "0.2"
1 use libc::{signal,raise); 2 use libc::{SIG_DFL, SIG_IGN, SIGTERМ); 3 4 fn main() { 5 unsafe { 6 signal(SIGTERМ, SIG_IGN); 7 raise(SIGTERМ); 8
(1) (2) (3)
9
10 11 12 13 14 15 16 17 18
println! ("ok"); unsafe { signal(SIGTERМ, SIG_DFL); raise(SIGTERМ);
(4) (5)
println ! ("not ok");
(6)
(1) Здесь нужен небезопасный: блок, поскольку Rust не контролирует то, что происходит за пределами функций. (2) Игнорирование сигнала SIGTERМ. (3) libc::raise() позволяет коду вьщавать сигнал; в данном случае - самому себе. (4) Переключение SIGTERМ на действие по умолчанию. (5) Завершение программы. (6) Вьmолнение до этого кода никогда не доходит, поэтому эта строка никогда не выводится на консоль.
Глава 12
514
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.130Демон 1 fn print_depth(depth:usize) 2 for in 0..depth { print! ("#"); 3 4 5 println! (""); 6 7
8 fn dive(depth: usize, max_depth: usize) { 9 print_depth(depth); 10 if depth >= max_depth 11 return; 12 13 else { 14 dive(depth+l, max_depth); 15 16 print_depth(depth); 17 18
19 fn main() 20 dive(0, 5); 21
516
Глава 12
Чтобы добиться этого, нужно приложить массу усилий. В самом языке Rust нет ин струментов, позволяющих выполнить подобные трюки с потоком управления. Тут нужен доступ к ряду инструментов, предоставляемых Rust-компилятором. Он предлагает специальные функции прикладных программ, известные как встроен ные. Использование встроенной функции совместно с кодом Rust требует особых настроек, после которых она работает точно так же, как и стандартная функция.
12.9.1. Представление проекта sjlj Проект sjlj демонстрирует способы, позволяющие переиначить обычный поток управления функцией. При некотором содействии операционной системы и компи лятора можно фактически создать ситуацию, при которой функция может переме щаться в любое место программы. Эта функциональная возможность используется в коде листинга 12.17 для обхода нескольких уровней стека вызовов, позволяя вы вести на консоль данные, показанные в правой части таблицы 12.3. Поток управле ния для проекта sjlj показан на рис. 12.5.
main(} reg ister_signal_handler(} ptr_tojmp_buf(} unsafe { setjmp() }.
sе t j mp () действует как точка входа � и как точка выхода. После вызова функции longjmp () функция setjmp () возвращает управnение во второй раз.
dive(} handle_signals() retum_early(} unsafe
{ longjmp() }
В эти разделы программы можно попасть только путем выдачи сигнала SIGUSR1. В нашей программе это делается своими силами путем применения функции libc:: signal (), но, в принципе, ничто и ни на каком из этапов не препятствует выдаче точно такого же сигнала со стороны внешнего процесса.
println!('early retum!") println!('finishing')
Рис. 12.5. Поток управления проекта sjlj. Поток управления программой может быть перехвачен с помощью сигнала, а затем запущен с места, указанного в функции setjmp ()
517
Сигналы, прерывания и исключения
12.9.2. Настройка встроенных функций для их использования в программе Листинг 12.17 использует две встроенные функции: setjmp () и longjmp (). Чтобы включить их в наших программах, ящик должен быть помечен указанным атрибу том. Соответствующий код показан в следующем листинге. ��и�нr 12.,14. Атрибут уровн� контейнера; во�бованный � mai�.ts 1 , #! [feature(link_llvm_intrinsics)] Но тут же возникают два вопроса, ответы на которые вскоре последуют: • Что такое встроенная функция? • Что такое LLVM? Кроме того, Rust должен узнать от нас о функциях, предоставляемых LLVМ. По скольку ему о них ничего не известно, кроме сигнатур их типов, следовательно, любое их использование должно происходить в unsafe-блoкe. Порядок сообщения Rust о функциях LLVM показан в следующем листинге, исходный код которого находится в файле ch12/ch12-sjlj/src/main.rs. • Листинг 12.15. Объявление внутренних фу�кций LLVM, используемое в коде листинг.j:12.17 extern "С" { # [link_name = "llvm.eh.sjlj .setjmp"] (1) pub fn setjmp(_: *mut i8) -> i32; (2) # [link_name = "llvm.eh.sjlj. longjmp"] pub fn longjmp(_: *mut i8);
(1)
(1) Предоставление компоновщику конкретных инструкций о том, где искать определения функций. (2) Поскольку имя аргумента не используется, здесь, чтобы оно было явно указанным, применяется символ подчеркивания (_).
Этот фрагмент кода хоть и небольшой, но все же довольно сложный: • extern "С" означает следующее: «Этот блок кода должен подчиняться со глашениям языка С, а не языка Rust». • Атрибут link_nam e сообщает компоновщику, где найти две объявляемые на ми функции. • eh в llvm.eh.sjlj .setjmp означает обработку исключений, а sjlj означает setjmp/longjmp. • *mut i8 - указатель на байт со знаком. Имеющие опыт программирования на языке С могут узнать здесь указатель на начало строки (например, тип *char).
Глава 12
518
Что такое встроенная функция? Встроенные функции (intrinsics) не часть языка и доступны только через компиля тор. Сам Rust в значительной степени независим от цели, а вот компилятор имеет доступ к целевой среде. Этот доступ может поспособствовать получению дополни тельных функций. Например, компилятор разбирается в характеристиках централь ного процессора, на котором будет выполняться компилируемая программа. Ком пилятор может через встроенные функции предоставить программе доступ к инст рукциям этого процессора. К числу встроенных функций относятся: • Атомарные операции. Многие процессоры предоставляют специальные ин струкции, предназначенные для оптимизации определенных рабочих нагру зок. Например, центральный процессор может гарантировать, что обновление целого числа будет атомарной операцией. Под атомарностью здесь подразу мевается неделимость. При работе с кодом конкурентных вычислений это может сыграть весьма важную роль. • Обработка исключений. Возможности центральных процессоров по управле нию исключениями различаются. Соответствующие средства могут исполь зоваться разработчиками языков программирования для создания настраи ваемого потока управления. В их числе встроенные функции setjmp и longjmp, представленные далее в этой главе. Что такое LLVM? С позиции Rust-программистов LLVM можно рассматривать как подкомпонент Rust-компилятора rustc. LLVM - это внешний инструмент, связанный с rustc. Rust программисты могут использовать предоставляемые им инструменты. Один из на боров инструментов, предоставляемых LLVM, - встроенные функции. LLVM сам по себе является компилятором. Его роль показана на рис. 12.6. Целевой центральный процессор
Среда окружения
Текстовый Инструмент редактор
cargo
Артефакт
Исходный код
Внешние контейнеры
Неофициально упоминается как rustc
.,,,;
'
,'
rustc эызыв s;;,1зь1srt linker ! llvm -· ►т :: rustc . - . - ·•--•-•".асr .. ---er ·► Вызывае 1 ·---··'.. ---------------------------------------------------------------------__,,'
LLVM IR
Сборка
Исполняемый файл
/'
В отношении библиотечных контейнеров это двоичные файлы, которые позже можно будет скомпоновать с другими контейнерами.
Рис. 12.6. Ряд основных шагов, необходимых для создания исполняемого
файла из исходного кода Rust. LLVM является важной, но не ориентированной на пользователя частью процесса.
519
Сигналы, ,........,, прерывания и исключения ____ ____ --· -----·-----�--���j�Ci=-=--==R!�';,�,g;J;}.WЩI-
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 "С" { #[link_name = "llvm.eh.sjlj.setjmp"] pub fn setjmp(_: *mut i8) -> i32;
#[link_name = "llvm.eh.sjlj.longjmp"] рuЬ fn longjmp(_: *mut i8);
Необходимость использования в качестве входного аргумента значения типа *mut i8 создает проблему, поскольку в нашем Rust-кoдe есть ссылка только на буфер перехода (например, &jmp_buf)2• Процесс разрешения этого конфликта рассматри вается в следующих нескольких абзацах. Тип jmp_buf определяется следующим образом: const JМР BUF WIDTH: usize = mem::size_of: :() * 8; type jmp_buf = [i8; JМP_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 = [О; JМP_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:/ /githuЬ.com/rust-in-action/code rust-in-action $ cd rust-in-action/chl2/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. Появле ние этой ошибки и избавление от нее показано в следующем выводе на консоль: $ carqo run -q error[E0554]: f! [feature] rnay not Ье used on the staЫe release channel --> src/rnain.rs:1:1
1
f! [feature(link_llvrn_intrinsics)]
error: aborting due to previous error For rnore inforrnation about this error, try 'rustc --explain Е0554'. $ rustup toolchain install niqhtly $ carqo +niqhtly run -q *
н
Ht
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. [package] narne = "sjlj" version = "0.1.0" authors = ["Tirn McNarnara "] edition = "2018" [dependencies] libc = "0.2"
1 f! [feature(link_llvrn_intrinsics)] 2 f! [allow(non_carnel_case_types)] 3 f! [cfg(not(windows))]
(1)
Глава 12
522 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 libc:: { SIGALRМ, SIGHUP, SIGQUIT, SIGTERМ, SIGUSRl, }; use std::mem; const JМР BUF WIDTH: usize mem::size_of::() * 8; type jmp_buf = [i8; JМP_BUF_WIDTH]; static mut SHUT_DOWN: bool = false; static mut RETURN_HERE: jmp_buf = [О; JМP_BUF_WIDTH]; const MOCK_SIGNAL_AT: usize = 3;
(2)
(3)
extern "С" { it[link_name = "llvm.eh.sjlj.setjmp"] pub fn setjmp(_: *mut i8) -> i32; # [link_name = "llvm.eh.sjlj .longjmp"] рuЬ fn longjmp(_: *mut i8); # [inline] fn ptr_to_jmp_buf() -> *mut i8 { unsafe { &RETURN HERE as *const i8 as *mut i8 }
(4)
#[inline] fn return_early() let franken_pointer = ptr_to_jmp_buf(); unsafe { longjmp(franken_pointer) };
(4)
fn register_signal_handler() { unsafe { libc::signal (SIGUSRl, handle_signals as usize); (6)
#[allow(dead_code)] fn handle_signals(sig: i32) { register_signal_handler(); let should shut down = match sig SIGHUP => false, SIGALRМ => false, SIGTERМ => true,
(5)
Сигналы, прерывания и исключения
51 SIGQUIT => true, 52 SIGUSRl => true, => false, 53 54 }; 55 56 unsafe SHUT DOWN = should_shut_down; 57 58 59 60 return_early(); 61 62 63 fn print_depth(depth: usize) { 64 for in O ..depth { print! ("it"); 65 66 67 println!() ; 68 69 70 fn dive(depth: usize, max_depth: usize) { 71 unsafe { if SHUT_DOWN { 72 println!("!"); 73 return; 74 75 76 77 print_depth(depth); 78 79 if depth >= max_depth return; 80 else if depth == MOCK_SIGNAL_AT 81 82 unsafe { libc::raise(SIGUSRl); 83 84 85 else { dive(depth + 1, max_depth); 86 87 88 print_depth(depth); 89 90 91 fn main() 92 const JUМP SET: i32 = О; 93 94 register_signal_handler(); 95 96 let return_point = ptr_to_jmp_buf(); 97 let rc = unsafe { setjmp(return_point) };
523
524
98 99 100 101 102 103 104 105
Глава 12
if rc == JUМP SET dive(0, 10); else { println! ("early return!"); println! ("finishing!")
(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 не смогли бы эффективно взаи модействовать с операционной системой и с другими сторонними компонентами.
Прохоренок Н.
Язык С. Самое необходимое
www.bhv.ru
Отдел оптовых поставок:
e-mail: [email protected]
Прикоснись к легенде • Базовый синтаксис современного языка С • Типы данных, операторы, условия и циклы • Работа с числами, массивами, строками и указателями • Создание пользовательских функций • Работа с файлами и каталогами • Многопоточные приложения • Создание статических и динамических библиотек • Библиотека MinGW-W64 • Редактор Eclipse
Описан базовый синтаксис современного языка С: типы данных, операторы, усло вия, циклы, работа с числами, строками, массивами и указателями, создание поль зовательских функций, модулей, статических и динамических библиотек. Рассмот рены основные функции стандартной библиотеки языка С, а также функции, применяемые только в операционной системе Windows. Для написания, компиля ции и запуска программ используется редактор Eclipse, а для создания исполняемо го файла - компилятор gcc.exe версии 8.2, входящий в состав популярной библио теки MinGW-W64. Книга содержит большое количество практических примеров, помогающих начать программировать на языке С самостоятельно. Весь материал тщательно подобран, хорошо структурирован и компактно изложен, что позволяет использовать книгу как удобный справочник. Электронный архив с примерами на ходится на сайте издательства. Прохоренок Николай Анатольевич, профессиональный программист, имеющий большой практический опыт создания и продвижения сайтов, анализа и обработки данных (работает с компьютерами с 1990 года). Автор книг «HTML, JavaScript, РНР и MySQL. Джентльменский набор WеЬ-мастера», «Python 3. Самое необходимое», «Python 3 и PyQt 5. Разработка прило жений», «Основы Java», «OpenCV и Java. Обработка изображений и компьютерное зрение», «Разработка WеЬ-сайтов с помощью Perl и MySQL» и др., многие из которых выдержали не сколько переизданий и стали бестселлерами.
КетовД. www.bhv.ru
Внутреннее устройство Linux, 2-е изд.
Отдел оптовых поставок:
e-mail: [email protected]
ВНУТРЕННЕЕ УСТРОЙСТВО
Дмитрий Кетов
• Пользовательское окружение и интерфейс командной строки CLI • Файлы, каталоги и файловые системы • Дискреционное, мандатное разграничение доступа и привилегии • Процессы и нити • Виртуальная память и отображаемые файлы • Каналы, сокеты и разделяемая память • Сетевая подсистема и служба SSH • Графический интерфейс GUI: оконные системы Х Window и Wayland • Программирование на языке командного интерпретатора • Контейнеры и виртуализация • Linux своими руками
Книга, которую вы держите в руках, адресована студентам, начинающим пользова телям, программистам и системным администраторам операционной системы Linux. Она представляет собой введение во внутреннее устройство Linux - от ядра до сетевых служб и от утилит командной строки до графического интерфейса. Все части операционной системы рассматриваются в контексте типичных задач, решаемых на практике, и поясняются при помощи соответствующего инструмента рия пользователя, администратора и разработчика. Все положения наглядно проиллюстрированы примерами, разработанными и про веренными автором с целью привить читателю навыки самостоятельного исследо вания постоянно эволюционирующей операционной системы Linux. Кетов Дмитрий Владимирович, инженер в Санкт-Петербургском исследовательском центре LG Russia R&D Lab. Профессионально занимается теорией построения и практикой разработ ки операционных систем и системного программного обеспечения. Имеет многолетний опыт преподавания в Санкт-Петербургском политехническом университете (СПбПУ) в области операционных систем и сетевых технологий.
BHV.RU
ИНТЕРНЕТ-МАГАЗИН
КНИГИ,РОБОТЫ, ЭЛЕКТРОНИКА
Интернет-магазин издательства «БХВ• • Более 25 лет на российском рынке • Книги и наборы по электронике и робототехнике по издательс. и компакт-дисков·. ,J):rВ:�;bl � -: .
..
, • 4-",
;i,Y