/
Tags: языки программирования трансляторы
ISBN: 978-5-93700-458-1
Text
Махмуд Хармуш
Rust для системного
программирования:
полное руководство
Ultimate Rust
for Systems Programming
Master Core Programming for Architecting Secure
and Reliable Software Systems
with Rust and WebAssembly
Mahmoud Harmouch
www.orangeava.com
Rust для системного
программирования:
полное руководство
Основы программирования
в области архитектурной разработки
защищенных и надежных программных систем
с использованием языка Rust
и стандарта WebAssembly
Махмуд Хармуш
Москва, 2026
УДК 004.438Rust:004.45
ББК 32.973.2
Х21
Х21
Махмуд Хармуш
Rust для системного программирования: полное руководство / пер. с англ.
А. В. Логунова. – М.: ДМК Пресс, 2026. – 654 с.: ил.
ISBN 978-5-93700-458-1
В динамичной среде системного программирования, где высокие требования
встречаются с инновациями, критически важно владеть языком, сочетающим
в себе надежность и эффективность. Эта книга представляет собой всеобъемлющее руководство, которое выходит за рамки основ и раскрывает сложности языка,
лежащего в основе разработки стабильных, защищенных и высокопроизводительных систем. Уникальные функциональные средства языка Rust позволят вам писать чистый и точный исходный код.
Издание будет полезно не только системным программистам, заинтересованным в повышении профессионального уровня, но и всем изучающим программирование на языке Rust.
УДК 004.438Rust:004.45
ББК 32.973.2
Originally published in English as “Ultimate Rust for Systems Programming” © 2025. Original Publisher – Orange Education Pvt Ltd. Reproduced and translated under license. All rights
reserved by Orange Education Private Limited.”
Все права защищены. Любая часть этой книги не может быть воспроизведена в какой
бы то ни было форме и какими бы то ни было средствами без письменного разрешения
владельцев авторских прав.
ISBN (анг.) 978-8-19699-473-0
ISBN (рус.) 978-5-93700-458-1
Copyright © 2024, Orange Education Pvt Ltd, AVA®
© Оформление, издание, перевод, ДМК Пресс, 2026
Эта книга посвящается
моей любимой маме
Аише Хармуш.
Моей опоре и поддержке
Оглавление
Об авторе................................................................................................................20
О техническом рецензенте...................................................................................21
Признательности...................................................................................................22
Предисловие...........................................................................................................23
Скачивание архивов с исходным кодом и цветными изображениями.............24
От издательства.....................................................................................................25
Глава 1. Системное программирование на языке Rust...................26
Введение.............................................................................................................26
Содержание главы.............................................................................................26
Защищенность и производительность............................................................27
Защита памяти..................................................................................................28
Обращение к памяти по пустому указателю...............................................29
Переполнение буфера...................................................................................34
Сборщик мусора............................................................................................36
Многопоточность и параллелизм.................................................................39
Сопоставление с шаблоном..........................................................................44
Времена жизни..............................................................................................45
Абстракции с нулевой стоимостью .............................................................46
Интерфейс с внешними функциями (FFI)...................................................48
Обработка ошибок.........................................................................................49
Контролируемые незащищенные операции...............................................50
Инструментарий Rust .......................................................................................50
Практические применения...............................................................................51
Будущее вместе с Rust ......................................................................................51
Отзывчивое сообщество . .................................................................................52
За пределами основ...........................................................................................52
Заметные проекты на Rust................................................................................52
Установка Rust...................................................................................................54
Установка языка в Windows..........................................................................54
Установка в Linux...........................................................................................56
Установка Rust в MacOS................................................................................57
Все готово к работе!.......................................................................................57
Интегрированная среда разработки и инструменты......................................57
Выбор интегрированной среды разработки................................................58
Visual Studio Code (VS Code)..........................................................................58
Основные инструменты Rust........................................................................59
Менеджер пакетов.........................................................................................59
Анализатор исходного кода..........................................................................59
Форматирование исходного кода.................................................................59
Оглавление
7
Написание первой программы на Rust............................................................60
Начало работы...............................................................................................60
Написание исходного кода...........................................................................60
Компиляция и запуск....................................................................................60
Cargo: менеджер пакетов исходного кода на Rust..........................................62
Знакомство с менеджером Cargo..................................................................62
Создание нового проекта..............................................................................62
Навигация по структуре проекта.................................................................62
Сборка и запуск.............................................................................................63
Управление зависимостями.........................................................................63
Тестирование и документация.....................................................................64
Обследование других функциональных возможностей
менеджера Cargo............................................................................................64
Заключение........................................................................................................64
Дополнительные ресурсы.................................................................................65
Вопросы для самопроверки..............................................................................65
Ответы............................................................................................................68
Ключевые термины...........................................................................................68
Глава 2. Основы Rust......................................................................................71
Введение.............................................................................................................71
Содержание главы.............................................................................................71
Переменные и типы данных.............................................................................72
Введение в переменные................................................................................72
Мутируемость................................................................................................74
Затенение ......................................................................................................74
Константы......................................................................................................76
Числовые примитивы.......................................................................................77
Фиксированная ширина................................................................................78
Целочисленные типы....................................................................................78
Символы и числовые типы...........................................................................79
Строки и символы.........................................................................................81
Итерация по символам и операции
со строковыми значениями..........................................................................81
Использование числовых конвертаций...........................................................82
Арифметические операции..........................................................................83
Сложение........................................................................................................84
Вычитание......................................................................................................84
Умножение.....................................................................................................84
Деление..........................................................................................................85
Взятие остатка от деления............................................................................85
Числа с плавающей точкой...............................................................................86
f32 для эффективности.................................................................................86
f64 для глубины точности.............................................................................87
Литералы чисел с плавающей точкой..........................................................88
Математические операции...........................................................................88
Ориентация в сложностях.............................................................................89
8
Оглавление
Приближенное равенство.............................................................................89
Освоение чисел с плавающей точкой..........................................................90
Булевы значения................................................................................................90
Сложные типы данных......................................................................................94
Строковые значения.....................................................................................94
Подстроки......................................................................................................95
Комбинирование строковых значений........................................................95
Массивы.........................................................................................................96
Преимущества фиксированного размера................................................97
Синтаксис и объявление...........................................................................97
Надежность в сложных условиях..............................................................97
Кортежи..........................................................................................................98
Деструктуризация кортежей.....................................................................98
Управляющие конструкции..............................................................................99
Условные инструкции.................................................................................100
Циклы while......................................................................................................100
Циклы for..........................................................................................................100
Конструкция loop.............................................................................................101
Выражения match............................................................................................101
Функции...........................................................................................................102
Явные сигнатуры ........................................................................................102
Вложенные функции для модульности......................................................102
Замыкания ......................................................................................................103
Владение, заимствование и времена жизни.................................................103
Владение......................................................................................................103
Контролер заимствования .........................................................................104
Повышение производительности..............................................................105
Заключение......................................................................................................105
Вопросы для самопроверки ...........................................................................105
Ответы..........................................................................................................108
Ключевые термины.........................................................................................109
Глава 3. Общие черты и обобщения......................................................110
Введение...........................................................................................................110
Содержание главы...........................................................................................110
Общие черты....................................................................................................111
Реализация общих черт...................................................................................111
Общая черта с дефолтным поведением.........................................................113
Границы общих черт.......................................................................................114
Расширение с помощью обобщений..............................................................115
Обобщения ......................................................................................................115
Адаптивные структуры...................................................................................116
Ассоциированные функции............................................................................117
Ассоциированные типы..................................................................................118
Объекты динамической диспетчеризации....................................................120
Практические применения.............................................................................121
Границы общих черт в стандартной библиотеке..........................................123
Оглавление
9
Продвинутые шаблоны общих черт...............................................................124
Ассоциированные константы.........................................................................124
Перегрузка операторов...................................................................................124
Маркерные общие черты................................................................................125
Комбинирование общих черт.........................................................................126
Избегание конфликтов общих черт...............................................................127
Всеохватывающие реализации.......................................................................128
Суперчерты......................................................................................................129
Шаблон-обертка...............................................................................................130
Типы с динамически изменяемым размером...............................................132
Условное соблюдение .....................................................................................134
Программирование на уровне типов ............................................................135
Соображения производительности................................................................137
Заключение......................................................................................................137
Вопросы для самопроверки............................................................................138
Ответы..........................................................................................................141
Глава 4. Встроенные в Rust структуры данных. ...............................142
Введение...........................................................................................................142
Содержание главы...........................................................................................143
Массивы в Rust.................................................................................................143
Создание массивов......................................................................................144
Доступ к элементам массива......................................................................144
Изменение элементов массива..................................................................146
Выполнение итераций по элементам массива в цикле............................147
Взятие срезов из массивов..........................................................................147
Многомерные массивы...............................................................................149
Работа с методами массивов......................................................................150
Инициализация массивов и дефолтные значения...................................151
Инициализация дефолтными значениями...........................................151
Генерирование шаблонов с помощью итераторов...............................151
Инициализация вычисляемыми значениями.......................................151
Длина массива и проверка границ.............................................................152
Получение длины....................................................................................152
Проверка границ . ...................................................................................153
Копирование и клонирование массивов...................................................153
Копирование массивов...........................................................................153
Клонирование массивов.........................................................................153
Векторы............................................................................................................154
Создание векторов......................................................................................155
Доступ к элементам и их изменение.........................................................155
Изменение векторов...................................................................................156
Добавление элементов............................................................................156
Обновление элементов...........................................................................157
Удаление элементов................................................................................158
Выполнение итераций по элементам в цикле..........................................159
Использование цикла for........................................................................159
10
Оглавление
Использование метода iter_mut для мутируемой итерации................160
Использование метода enumerate для получения
индекса и значения.................................................................................161
Кортежи............................................................................................................161
Создание и инициализация кортежей.......................................................162
Доступ к элементам кортежа......................................................................162
Деструктуризация кортежей.......................................................................163
Кортежные шаблоны и продвинутое использование...............................163
Варианты использования из реальной практики.....................................164
Координатные вычисления....................................................................164
Обработка ошибок с помощью кортежей Result...................................164
Ограничения кортежей...............................................................................165
Кортежи в операции сопоставления с шаблоном.....................................165
Сопоставление кортежа с шаблонами...................................................165
Игнорирование элементов кортежа.......................................................166
Вложенные кортежи и шаблоны.............................................................166
Опровержимость кортежных шаблонов ...............................................167
Владение и заимствование в кортежах......................................................167
Владение кортежами...............................................................................168
Заимствование кортежей........................................................................168
Взятие срезов кортежей и оператор развертывания................................169
Создание срезов элементов кортежей...................................................169
Доступ к срезам кортежей . ....................................................................170
Кортежи как аргументы функций и возвращаемые из них значения.....170
Использование кортежей в качестве аргументов функций.................170
Возврат кортежей из функций................................................................171
Кортежные варианты перечислений.........................................................172
Создание вариантов перечислений с использованием кортежей.......172
Срезы................................................................................................................173
Понимание срезов ......................................................................................174
Создание срезов..........................................................................................175
Доступ к элементам среза...........................................................................175
Изменение элементов среза.......................................................................176
Практические применения.........................................................................177
Манипуляция строковыми литералами................................................177
Обработка данных...................................................................................178
Токенизация текста.................................................................................179
Работа с двоичными данными...............................................................179
Отображение в память ...........................................................................180
Хеш-множества в Rust.....................................................................................181
Создание множества...................................................................................181
Обновление множества...............................................................................182
Добавление элементов............................................................................182
Удаление элементов................................................................................182
Продвинутые операции на множествах....................................................182
Симметрическая разность......................................................................183
Проверка на подмножество и надмножество........................................183
Оглавление
11
Практические применения.........................................................................184
Дедупликация..........................................................................................184
Проверка на вхождение в множество ...................................................185
Кеширование...........................................................................................185
Алгоритмы на графах..............................................................................186
Операции на множествах.......................................................................188
Тегирование и фильтрация . ..................................................................189
Отслеживание уникальных значений ...................................................190
Вопросы производительности....................................................................190
Вопросы конкурентного исполнения........................................................191
Умные указатели Mutex..........................................................................191
Атомарные типы . ...................................................................................192
Сериализация и десериализация...............................................................194
Нагрузочное тестирование и оптимизация..............................................195
Хеш-словари....................................................................................................196
Создание хеш-словаря................................................................................196
Обновление хеш-словаря............................................................................197
Добавление элементов............................................................................197
Удаление элементов................................................................................198
Обновление элементов...........................................................................198
Доступ к значениям................................................................................199
Выполнение итераций по элементам хеш-словаря в цикле....................199
Продвинутые операции на хеш-словарях.................................................201
Проверка существования ключа............................................................201
API внесения записей..............................................................................201
Очистка хеш-словаря..............................................................................202
Емкость хеш-словаря..............................................................................202
Практические применения.........................................................................203
Подсчет вхождений.................................................................................203
Мемоизация.............................................................................................204
Кеширование...........................................................................................204
Управление конфигурацией...................................................................205
Преобразование данных.............................................................................206
Группировка данных...............................................................................206
Графовые алгоритмы..............................................................................207
Индексация баз данных..........................................................................207
Заключение......................................................................................................208
Дополнительные ресурсы...............................................................................209
Вопросы для самопроверки............................................................................211
Ответы..........................................................................................................215
Глава 5. Обработка ошибок и восстановление после отказов......217
Введение...........................................................................................................217
Содержание главы...........................................................................................218
Обработка ошибок с использованием типов Result и Option.......................218
Понимание типов Result и Option..................................................................218
Обработка ошибок с помощью типа Result...............................................219
Обработка ошибок с помощью типа Option..............................................220
12
Оглавление
Передача ошибок по стеку вызовов...............................................................221
Оператор ?....................................................................................................221
Обработка нескольких ошибок с помощью типа Result...........................223
Передача ошибок с использованием типов Result и Option.....................224
Создание конкретно-прикладных типов ошибок.........................................225
Продвинутая обработка ошибок................................................................226
Библиотека anyhow......................................................................................226
Конкретно-прикладные типы ошибок с пакетом thiserror......................227
Асинхронная обработка ошибок................................................................229
Асинхронные функции и экземпляры типа Result...................................229
Конкретно-прикладные типы ошибок для асинхронного
исходного кода.............................................................................................230
Обработка ошибок конкурентности..........................................................231
Обработка ошибок в веб-приложениях.....................................................232
Собираем все воедино.................................................................................233
Разбор файлов и обработка ошибок...........................................................233
Обработка ошибок в консольных приложениях ......................................234
Использование библиотеки clap............................................................235
Обработка сигналов и прерываний............................................................236
Обработка ошибок при файловом вводе-выводе.....................................237
Чтение и запись файлов..........................................................................238
Работа с директориями...........................................................................239
Обработка ошибок в сетевом программировании...................................239
Выполнение HTTP-запросов..................................................................240
Построение сетевых сервисов................................................................240
Обработка ошибок в многопоточном исходном коде..............................242
Межпотоковый обмен данными................................................................244
Тестирование и обработка ошибок............................................................245
Написание тестов для обработки ошибок.............................................245
Тестирование на основе свойств............................................................246
Заключение......................................................................................................247
Справочные и дополнительные материалы..................................................248
Вопросы для самопроверки............................................................................248
Ответы..........................................................................................................252
Глава 6. Управление памятью и указатели.........................................253
Введение...........................................................................................................253
Содержание главы...........................................................................................254
Роль управления памятью..............................................................................254
Трудности ручного управления памятью......................................................255
Владение и заимствование.........................................................................255
Висячие ссылки............................................................................................257
Незащищенный исходный код ..................................................................258
Гонки за доступом к данным......................................................................259
Управление ресурсами................................................................................260
Стек и куча.......................................................................................................260
Роль стека в Rust..........................................................................................262
Оглавление
13
Роль кучи в Rust...........................................................................................262
Модель владения ........................................................................................263
Заимствование в Rust..................................................................................264
Времена жизни в Rust ................................................................................264
Сырые и умные указатели..............................................................................266
Умный указатель Box...................................................................................266
Умный указатель Rc.....................................................................................268
Умный указатель Arc...................................................................................269
Умный указатель RefCell.............................................................................271
Умный указатель Mutex..............................................................................272
Умный указатель RwLock............................................................................275
Атомарные операции..................................................................................278
Незащищенный Rust.......................................................................................280
Незащищенные функции............................................................................280
Незащищенные пакеты...............................................................................280
Конкретно-прикладные незащищенные абстракции..............................281
Интерфейс с внешними функциями..........................................................282
Рекомендации по написанию незащищенного исходного кода..............283
Лучшие образцы практики управления памятью.........................................283
Приоритет выделения памяти в стеке.......................................................284
Активное задействование ссылок .............................................................284
Разумное использование умных указателей.............................................284
Использование сопоставления с шаблоном..............................................285
Аннотирование времен жизни...................................................................285
Надлежащее управление ресурсами..........................................................286
Многопоточность и конкурентность . .......................................................286
Осторожное использование незащищенного исходного кода.................286
Профилирование и оптимизация..............................................................287
Продвинутое управление памятью................................................................288
Конкретно-прикладные выделители памяти............................................288
Отображение файлов в память...................................................................290
Операционная совместимость с другими языками..................................291
Лучшие образцы практики управления памятью.........................................292
Заключение......................................................................................................293
Глава 7. Управление конкурентностью.................................................294
Введение...........................................................................................................294
Содержание главы...........................................................................................294
Понимание конкурентного программирования...........................................294
Параллелизм против конкурентности...........................................................295
Порождение потоков исполнения и управление ими..................................296
Коллективное использование данных между потоками исполнения.........297
Конкурентные структуры данных..................................................................297
Базовое использование контейнера-синхронизатора Mutex..................298
Коллективное использование данных между потоками..........................300
Предотвращение взаимных блокировок...................................................301
Владение сторожами мьютекса..................................................................303
14
Оглавление
Обработка скомпрометированных мьютексов.........................................304
Продвинутое использование мьютекса.....................................................305
Условное запирание на замок....................................................................305
Реализация защищенной мьютексом очереди . .......................................307
Реализация защищенной мьютексом очереди с приоритетом...............308
Обработка взаимных блокировок с помощью мьютекса.........................310
Мьютекс в многопоточном сценарии производитель–потребитель......311
Обследование контейнера-синхронизатора RwLock................................313
Управление коллективными данными с помощью
примитива синхронизации RwLock...........................................................313
Продвинутые варианты использования
контейнера-синхронизатора RwLock в Rust..............................................315
Динамическое количество читателей....................................................315
Запирание на замок по времени............................................................316
Предотвращение взаимных блокировок...................................................318
Реализация объединения ресурсов в пул...................................................319
Обмен данными между потоками исполнения и передача сообщений.....321
Использование каналов для обмена данными..........................................321
Передача сообщений с использованием структур....................................322
Атомарные операции..................................................................................323
Продвинутый обмен данными между потоками исполнения.....................324
Координация потоков исполнения с помощью барьеров........................325
Локальное для потока хранение данных...................................................326
Библиотека crossbeam.................................................................................328
Асинхронное программирование..................................................................329
Лучшие образцы практики программирования конкурентности...............330
Заключение......................................................................................................331
Дополнительные ресурсы...............................................................................332
Вопросы для самопроверки............................................................................333
Ответы..........................................................................................................336
Ключевые термины.........................................................................................336
Глава 8. Консольные программы............................................................339
Введение...........................................................................................................339
Содержание главы...........................................................................................339
Введение в разбор аргументов.......................................................................339
Преимущества использования библиотеки clap...........................................340
Начало работы с библиотекой clap.................................................................341
Обработка нескольких аргументов................................................................343
Обработка аргументов-флагов.......................................................................345
Обработка продвинутых аргументов.............................................................346
Реализация логики поиска и замены текста.................................................349
Обзор функциональности утилиты поиска и замены текста...................349
Чтение входного файла...............................................................................350
Логика поиска и замены.............................................................................354
Запись измененного содержимого.............................................................357
Поиск без учета регистра символов (опционально).................................360
Поддержка нескольких входных файлов...................................................364
Оглавление
15
Заключение......................................................................................................367
Дополнительные ресурсы...............................................................................368
Вопросы для самопроверки............................................................................369
Ответы..........................................................................................................371
Ключевые термины.........................................................................................371
Глава 9. Работа с вводом-выводом с устройств на Rust...............373
Введение...........................................................................................................373
Содержание главы...........................................................................................374
Чтение из файлов и запись в файлы..............................................................374
Открытие файла..............................................................................................374
Чтение из файла..............................................................................................376
Запись в файл...................................................................................................377
Закрытие файла...............................................................................................380
Выполнение распространенных операций с файловой системой...............381
Создание директорий.................................................................................381
Переименование и перемещение файлов.................................................382
Проверка метаданных из файлов...............................................................383
Удаление файлов и директорий.....................................................................385
Обход директорий.......................................................................................386
Работа с путями к файлам..........................................................................388
Чтение и запись двоичных данных............................................................390
Обработка ошибок и типы Result...................................................................391
Работа с аппаратными устройствами............................................................393
Продвинутые файловые операции . ..............................................................395
Символические и жесткие ссылки.............................................................395
Запирание файлов на замок.......................................................................397
Отображаемые файлы.................................................................................401
Лучшие образцы практики файлового ввода-вывода .............................402
Драйверы устройств и модули ядра...............................................................405
Сетевое программирование и ввод-вывод с устройств................................406
Сокеты и протоколы....................................................................................407
Асинхронное сетевое взаимодействие и ввод-вывод с устройств..........410
Библиотеки для сетевого взаимодействия и ввод-вывод с устройств....414
Вопросы защищенности и кибербезопасности.............................................415
Параллелизм, конкурентность и ввод-вывод с устройств............................418
Реально-временные системы и ввод-вывод с устройств.............................420
Заключение......................................................................................................423
Дополнительные ресурсы...............................................................................424
Вопросы для самопроверки............................................................................425
Ответы..........................................................................................................427
Ключевые термины.........................................................................................428
Глава 10. Итераторы и замыкания.........................................................430
Введение...........................................................................................................430
Содержание главы...........................................................................................430
Итераторы........................................................................................................430
Анатомия итератора...................................................................................431
16
Оглавление
Ключевые общие черты: Iterator и IntoIterator.............................................433
Создание итераторов с помощью функций from_fn и successors.................435
Метод drain.......................................................................................................438
Другие источники итераций...........................................................................439
Адаптерные методы итераторов....................................................................442
Построение конкретно-прикладных итераторов.........................................444
Ленивые вычисления..................................................................................446
Продвинутые методы: flat_map, take, skip и peekable...................................447
Адаптерный метод flat_map........................................................................447
Адаптерные методы take и skip..................................................................448
Адаптерный метод peekable.......................................................................449
Адаптерные методы filter_map, fuse и flatten................................................451
Адаптерный метод filter_map.....................................................................451
Адаптерный метод fuse...............................................................................452
Адаптерный метод flatten...........................................................................454
Волшебство замыканий..................................................................................455
Синтаксис замыканий.................................................................................456
Захват переменных в замыканиях.................................................................456
Заимствование переменных.......................................................................457
Перемещение переменных.........................................................................458
Замыкания и общие черты Fn, FnMut, FnOnce..............................................459
Замыкания Fn..............................................................................................459
Замыкания FnMut........................................................................................460
Замыкания FnOnce......................................................................................461
Итераторы для эффективной обработки данных.........................................462
Накопление данных с помощью метода fold.............................................463
Выстраивание итераторов в цепочку с помощью метода chain..............464
Применение итераторов и замыканий в практических примерах.............465
Вариант использования 1: анализ текста..................................................465
Вариант использования 2: обработка изображений.................................467
Вариант использования 3: фильтрация и преобразование данных........468
Вариант использования 4: обработка сенсорных данных........................470
Вариант использования 5: финансовые расчеты......................................471
Вариант использования 6: сортировка......................................................473
Заключение......................................................................................................474
Дополнительные ресурсы...............................................................................475
Вопросы для самопроверки............................................................................476
Ответы..........................................................................................................479
Ключевые термины.........................................................................................479
Глава 11. Модульное тестирование на Rust.......................................481
Введение...........................................................................................................481
Содержание главы...........................................................................................481
Модульное тестирование................................................................................482
Написание тестовых функций и модулей......................................................482
Написание простейшей тестовой функции...............................................483
Тестовые модули..........................................................................................484
Тесты и результаты тестирования..................................................................485
Оглавление
17
Выполнение тестов..........................................................................................485
Интерпретация результатов тестирования...................................................487
Сверки..............................................................................................................489
Конкретно-прикладные сообщения об ошибках..........................................491
Выполнение тестов с опциями.......................................................................491
Фикстуры и исходный код подготовки..........................................................492
Условные тесты................................................................................................498
Асинхронное тестирование............................................................................500
Создание заглушек для зависимостей...........................................................502
Тестирование приватных функций................................................................503
Замеры производительности.........................................................................505
Покрытие исходного кода ..............................................................................506
Непрерывная интеграция...............................................................................506
Заключение......................................................................................................507
Дополнительные ресурсы...............................................................................508
Вопросы для самоконтроля............................................................................509
Ответы..........................................................................................................512
Ключевые термины.........................................................................................512
Глава 12. Сетевое программирование. ................................................515
Введение...........................................................................................................515
Содержание главы...........................................................................................516
Сетевое программирование...........................................................................516
Разработка сетевых приложений...............................................................516
Протоколы связи: TCP и UDP..........................................................................520
Протокол управления передачей (TCP).....................................................521
Протокол пользовательских датаграмм (UDP)..........................................524
Асинхронные сетевые операции....................................................................526
Асинхронное программирование..............................................................527
Асинхронный TCP-сервер...........................................................................530
Варианты использования из реальной практики:
обработка HTTP-запросов...............................................................................531
Приложение для обмена сообщениями по сети........................................533
Сервер передачи файлов.............................................................................536
Сервер дистанционного исполнения команд...........................................538
Одноранговый обмен файлами .................................................................540
Реально-временное коллаборативное редактирование...........................542
Заключение......................................................................................................546
Дополнительные ресурсы...............................................................................547
Вопросы для самопроверки............................................................................548
Ответы..........................................................................................................550
Ключевые термины.........................................................................................551
Глава 13. Незащищенное программирование на Rust..................552
Введение...........................................................................................................552
Содержание главы...........................................................................................552
18
Оглавление
Незащищенный исходный код.......................................................................553
Сценарии использования блоков unsafe....................................................554
Вопросы производительности и защищенности......................................556
Примеры из реальной практики....................................................................557
Пример 1: взаимодействие с базой данных через FFI..............................557
Пример 2: продвинутая обработка изображений.....................................563
Пример 3: конкретно-прикладное управление памятью........................566
Лучшие образцы практики использования незащищенного
исходного кода.................................................................................................568
Нарушения защищенности памяти...............................................................570
Практические примеры..................................................................................572
Практический пример 1: уязвимость Heartbleed......................................572
Практический пример 2:
авария первого полета ракеты Ариан-5....................................................575
Риски использования незащищенного исходного кода...............................580
Риск 1: обращение к памяти по пустому указателю.................................580
Риск 2: переполнение буфера.....................................................................581
Риск 3: ошибки использования памяти после ее высвобождения..........582
Заключение......................................................................................................583
Дополнительные ресурсы...............................................................................584
Вопросы для самопроверки............................................................................585
Ответы..........................................................................................................587
Ключевые термины.........................................................................................588
Глава 14. Асинхронное программирование.......................................590
Введение...........................................................................................................590
Содержание главы...........................................................................................590
Основы синтаксиса async/await Rust..............................................................591
Обследование динамики синтаксиса async/await.........................................593
Использование библиотеки tokio...................................................................595
Использование ключевого слова async для повышения отзывчивости......595
Обработка ошибок в асинхронном исходном коде.......................................597
Времена жизни конкурентных заданий........................................................599
Продвинутые шаблоны асинхронного программирования.........................601
Асинхронные потоки данных.....................................................................602
Управление ресурсами с помощью асинхронного
завершения жизненного цикла..................................................................606
Разветвление и сведение в асинхронных потоках данных......................608
Обработка отмен заданий и реализации тайм-аутов...............................611
Управление динамическими заданиями...................................................612
Интеграция асинхронного и синхронного исходных кодов........................614
Заключение......................................................................................................616
Дополнительные ресурсы...............................................................................617
Вопросы для самопроверки............................................................................618
Ответы..........................................................................................................620
Ключевые термины.........................................................................................620
Оглавление
19
Глава 15. Web Assembly на Rust. .............................................................622
Введение...........................................................................................................622
Содержание главы...........................................................................................622
Преимущества стандарта WebAssembly.........................................................623
Ограничения стандарта WebAssembly...........................................................623
Ограниченная поддержка веб-браузерами...............................................624
Косвенная манипуляция объектной моделью документа........................624
Сложности управления памятью...............................................................625
Rust для разработки в сопряжении со стандартом WebAssembly ...............626
Установка инструментария Rust и стандарта WebAssembly ....................626
Настройка нового проекта на Rust в связке
с двоичным форматом инструкций Wasm.................................................626
Сборка и тестирование модулей WebAssembly.........................................627
Написание функций на Rust с привязкой к Wasm....................................628
Тестирование модуля WebAssembly...........................................................629
Примеры применения стандарта WebAssembly из реальной практики.......630
Вариант использования 1: обработка изображений.................................631
Вариант использования 2: криптографические операции......................635
Вариант использования 3: обработка сетевых запросов..........................638
Заключение......................................................................................................641
Дополнительные ресурсы...............................................................................641
Вопросы для самопроверки............................................................................642
Ответы..........................................................................................................644
Предметный указатель...............................................................................645
Об авторе
Махмуд Хармуш – ориентированный на результат инженер-программист
с впечатляющим набором навыков. Будучи универсальным разработчиком
и исследователем данных, он обладает подтвержденным опытом создания
программных продуктов и сервисов высочайшего уровня. Страсть Махмуда
к программированию зажглась еще в студенческие годы и не угасает по сей
день. Написание чистого исходного кода и решение сложных алгоритмических задач приносят ему огромное удовольствие и чувство реализации.
Компетенция Махмуда охватывает различные языки программирования
и фреймворки, включая Python, C#, Java, Rust и C/C++. Он выделяется глубоким
знанием фреймворков FastAPI, Django, NodeJS, Yew и ReactJS. Его навыки технического письма не имеют себе равных: он умеет делать сложную информацию
доступной благодаря четкому и лаконичному документированию. Квалификация, эквивалентная степени магистра в области электротехники (Ливанский
университет), делает его профессиональный профиль еще более весомым.
Путь Махмуда как программиста начался в колледже, и его энтузиазм продолжает пылать и по сей день. Он эффективно работает как в коллективе сотрудников, так и в качестве самостоятельного специалиста, всегда находясь
в поиске новых вызовов для оттачивания мастерства. Махмуд не только быстро
обучается, но и активно применяет лучшие образцы практики, такие как разработка через тестирование и методы DevOps (непрерывная разработка), следуя
принципам SOLID и DRY.
Помимо технических навыков, Махмуд обладает талантом представлять
сложную информацию в простом и понятном виде. В сочетании со стремлением к выстраиванию долгосрочных отношений с клиентами это позволяет ему
создавать отказоустойчивые программные продукты, которые эффективно
решают бизнес-задачи.
Вне профессиональной деятельности Махмуд любит проводить время
с близкими. Будь то активный отдых на природе – езда на велосипеде и походы в горах рядом с домом – или погружение в хорошую книгу, он находит
радость в каждом моменте жизни.
О техническом рецензенте
Арман Риази имеет степень магистра наук и предоставляет услуги и решения
в сфере Web3, основанные на концепции децентрализованной науки. Будучи
практиком и консультантом в области Web3, Арман обладает глубоким пониманием и экспертными знаниями в этой области. Его вдохновляет возможность
реализации ваших целей, а его подход строится вокруг разработки решений
на языке Rust. Благодаря обширному опыту создания и вывода продуктов на исполнительный уровень Арман накопил ценный багаж знаний в этой индустрии.
Арман призывает начинающих специалистов обследовать смежные области
и читать профильную литературу, что способствует формированию широкого
кругозора и получению ценных сведений. Такая деятельность дает человеку
способность к непрерывному обучению и стойкость перед лицом трудностей
и серьезных неудач.
Арман выражает надежду, что программистские сообщества пересмотрят
свой подход и откажутся от предоставления бесплатного исходного кода индустрии, подчеркивая необходимость признания ценности времени и энергии. Он верит, что такой ответственный подход оздоровит отрасль для обеих
сторон: создание ценности ведет к росту спроса на экспертизу, которую перестанут воспринимать как бесплатное временное предложение. За нее должны платить так же, как и в других высокодоходных профессиональных сферах. Изменение формата конкуренции внутри сообщества и переход в иную
плоскость сделает индустрию более прибыльной для всех участников. Арман
отмечает, что мы создали достаточно языков и фреймворков, но пока не выстроили качественных «коробочных» отношений. В конечном итоге это позволит разработчикам чувствовать себя счастливее и проявлять более творческий
подход при создании высококлассных продуктов.
Как увлеченный мастер находить решения, он активно ищет возможности
для расширения знаний и обучения новичков в сфере технологий. Его страсть –
это программирование, умные контракты и блокчейн. Арман стремится к глубокому погружению в языки Rust и TypeScript в контексте таких направлений,
как децентрализованные/регенеративные финансы, метавселенные, децент
рализованная автономная организация, интернет вещей, облачные технологии, конструирование платформ и разработка + операции (DevOps).
Арман искренне рад возможности сотрудничать с автором книги, Махмудом Хармушем, и глубоко изучать язык Rust вместе с ним. Он выражает признательность за ваше время и внимание.
Признательности
Написание данной книги стало для меня захватывающим приключением,
и я хочу выразить искреннюю признательность всем замечательным людям,
которые помогли этому случиться. Книга не стала бы такой, какая она есть, без
поддержки, наставлений и профессионализма многих потрясающих личностей.
Огромное спасибо сообществу Rust и создателям языка за разработку такого
крутого инструмента. Документация Rust стала моим верным путеводителем,
гарантирующим точность и пользу информации в этой книге. Отдельная благодарность тем, кто внимательно проверял технические аспекты и делился отзывами – ваши идеи имели огромное значение.
Моей семье – спасибо, что прикрывали меня с тыла. Мама, ты – мой лучший
болельщик, и я очень ценю твою поддержку. Мой брат, твое подбадривание
много значило для меня на протяжении всего этого писательского пути.
Большое спасибо коллективу издательства; ваша слаженная работа и преданность делу сделали эту книгу лучше.
И вам, дорогие читатели, огромное спасибо за то, что выбрали эту книгу.
Надеюсь, она поможет вам стать профи в Rust и сделает ваше погружение в системное программирование по-настоящему крутым.
Предисловие
В динамичной среде системного программирования, где высокая точность
встречается с инновациями, критически важно владеть языком, сочетающим
в себе надежность и эффективность. Предлагаем погрузиться в программирование на языке Rust с помощью этого всеобъемлющего руководства, которое
выходит за рамки основ и раскрывает сложности языка, ставшего краеугольным камнем в разработке стабильных, защищенных и высокопроизводительных систем. Уникальные функциональные средства языка Rust – от гарантий
защищенности памяти до продвинутых концепций, таких как общие черты
и обобщения, – позволят вам писать исходный код с предельной точностью.
Книга состоит из 15 глав, которые шаг за шагом проведут вас по языку Rust:
главы 1 и 2 помогут вам познакомиться с Rust, настроить рабочее пространство и изучить основы, включая механизмы управления памятью
и проверку заимствований;
главы 3 и 4 погружают в более продвинутые темы: общие черты, обобщения и работу с различными типами коллекций;
глава 5 посвящена эффективной обработке ошибок и созданию своих
собственных типов ошибок;
главы 6 и 7 разбирают тему управления памятью, использования умных
указателей и концепции конкурентности;
глава 8 предлагает практический опыт по созданию утилиты командного интерфейса;
глава 9 фокусируется на операциях ввода-вывода и использовании Rust
для работы с аппаратными устройствами;
главы 10 и 11 посвящены итераторам, замыканиям и важности модульного тестирования;
глава 12 вводит в сетевое программирование с упором на обмен данными по протоколам TCP и UDP;
глава 13 углубляется в концепцию «незащищенного» исходного кода
на языке Rust;
глава 14 акцентирует внимание на асинхронном программировании
с использованием синтаксиса async/await и библиотеки tokio;
глава 15 поможет понять основы и преимущества стандарта WebAssembly,
а также настроить Rust для разработки в связке со средой исполнения
Wasm.
К концу этого пути вы получите глубокое понимание Rust и способов его
применения в системном программировании, что позволит вам успешно решать задачи в проектах из реальной практики.
Скачивание архивов с исходным
кодом и цветными изображениями
Пожалуйста, перейдите по ссылкам или отсканируйте QR-коды, чтобы скачать
архивы с исходным кодом и изображения из книги:
https://github.com/OrangeAVA/Ultimate-Rust-for-Systems-Programming
Архивы с кодом и изображения из книги также размещены по адресу: https://
rebrand.ly/1b5df0.
В случае внесения изменений в программный код обновления будут опуб
ликованы в существующем репозитории на GitHub.
От издательства
Отзывы и пожелания
Мы всегда рады отзывам наших читателей. Расскажите нам, что вы думаете
об этой книге – что понравилось или, может быть, не понравилось. Отзывы важны для нас, чтобы выпускать книги, которые будут для вас максимально полезны.
Вы можете написать отзыв на нашем сайте www.dmkpress.com, зайдя на страницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также
можно послать письмо главному редактору по адресу dmkpress@gmail.com;
при этом укажите название книги в теме письма.
Если вы являетесь экспертом в какой-либо области и заинтересованы в написании новой книги, заполните форму на нашем веб-сайте по адресу http://
dmkpress.com/authors/publish_book/ или напишите в издательство по адресу
dmkpress@gmail.com.
Список опечаток
Хотя мы приняли все возможные меры для того, чтобы обеспечить высокое
качество наших текстов, ошибки все равно случаются. Если вы найдете ошибку
в одной из наших книг, мы будем очень благодарны, если вы сообщите о ней
главному редактору по адресу dmkpress@gmail.com. Сделав это, вы избавите
других читателей от недопонимания и поможете нам улучшить последующие
издания этой книги.
Нарушение авторских прав
Пиратство в интернете по-прежнему остается насущной проблемой. Издательство «ДМК Пресс» очень серьезно относится к вопросам защиты авторских
прав и лицензирования. Если вы столкнетесь в интернете с незаконной публикацией какой-либо из наших книг, пожалуйста, пришлите нам ссылку на интернет-ресурс, чтобы мы могли применить санкции.
Ссылку на подозрительные материалы можно прислать по адресу электронной почты dmkpress@gmail.com.
Мы высоко ценим любую помощь по защите наших авторов, благодаря которой мы можем предоставлять вам качественные материалы.
Глава 1
Системное программирование
на языке Rust
Введение
Мир системного программирования всегда был «палкой о двух концах»: разработчикам приходится балансировать между производительностью, контролем и защищенностью. Эта задача становится еще более сложной при использовании низкоуровневых языков, таких как C и C++. Однако в последние годы
Rust заявил о себе как об инновационном решении данной дилеммы. Сочетая
мощь традиционных низкоуровневых языков с гарантиями защищенности
памяти, которые обычно встречаются только в высокоуровневых языках, Rust
предлагает лучшее из обоих миров1.
В этой главе мы подробно рассмотрим аспекты, которые делают Rust таким
уникальным, – те выдающиеся функциональные средства, благодаря которым
он идеально подходит для тех, кто ищет оптимальную гармонию между быст
родействием и защищенностью. Уникальное сочетание мощи и надежности
делает его идеальным для разработки высокопроизводительных систем; неудивительно, что он быстро становится приоритетным языком для каждого
системного программиста2.
В последующих разделах мы углубимся в синтаксис и способности языка,
а также разберем, как этот меняющий правила игры язык программирования
работает с конкурентностью и параллелизмом. К концу главы вы будете обладать всесторонними знаниями, необходимыми для внедрения языка Rust в свои
собственные проекты, что обеспечит успех при любом масштабе или сложности!
Содержание главы
В этой главе будут рассмотрены следующие темы:
1
2
Защищенное системное программирование на языке Rust, Safe systems programming
in rust, Dreyer, R. J. J. J. R. K. D. (2021, April 1). April 2021 | Communications of the ACM.
https://cacm.acm.org/magazines/2021/4/251364-safe-systems-programming-in-rust/fulltext.
Почему именно эта книга? – Высокая степень защиты с Rust: разработка защищенного и надежного программного обеспечения, Why this book? – High Assurance Rust:
Developing Secure and Robust Software (дата не указана). https://highassurance.rs/chp1/
why_this_book.html.
Защищенность и производительность 27
уникальные функциональные средства Rust;
создание надежного фундамента для исходного кода;
практическое начало работы;
ваша первая программа на Rust – «Привет, мир!».
Защищенность и производительность
Сочетание защищенности и производительности в Rust представляет собой
выдающееся достижение в конструкции языков программирования. Стержневая философия языка Rust сосредоточена вокруг обеспечения как защищенности памяти, так и высокой производительности, что позволяет отвечать
на серьезные вызовы, с которыми сталкиваются разработчики при создании
современных программных решений.
Вы
сок
Производительность
ая
Ни
зка
я
Защищенность
Вы
сок
ая
Рисунок 1.1. Сравнение языка Rust с другими языками программирования по критериям защищенности и производительности
Недовольство Грейдона Хоара (Graydon Hoare) частыми аварийными завершениями работы программного обеспечения дало жизнь языку Rust, который
гарантирует защищенность памяти без ущерба для производительности1. Выдающееся функциональное средство Rust – контролёр заимствований2 – анализирует времена жизни ссылок и выявляет потенциальные ошибки работы
с памятью еще на этапе компиляции. Благодаря такому подходу разработчики
получают возможность отлавливать дефекты на ранних стадиях, сокращая количество ошибок на этапе исполнения и предотвращая аварийное завершение
1
2
Узнать больше об истории и эволюции языка Rust можно по адресу: https://en.wikipedia.
org/wiki/Rust_(programming_language).
Познакомиться с мощью механизма проверки заимствований можно по адресу:
https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html.
28
Системное программирование на языке Rust
работы систем. Компания Mozilla признала потенциал Rust и начала поддерживать его еще в 2009 году, что придало мощный толчок к его развитию1.
Сегодня Rust стал крайне востребованным благодаря исключительным защитным мерам, беспрецедентной производительности и непоколебимой надежности. История создания языка Rust служит вдохновляющим примером
того, что инновации нередко рождаются из неудовлетворенности текущим положением дел, а при должном упорстве можно достигать прорывных технологических продвижений на благо человечества. Пройденный языком Rust путь
демонстрирует, как четкое видение в сочетании с решимостью и стремлением
решать практические задачи может приводить к великим результатам. Продолжая стремиться к технологическому прогрессу, не стоит забывать, что приоритетность защищенности и производительности – это равнозначные аспекты, которых можно достигать только при наличии правильных инструментов
и соответствующего мышления.
Защита памяти
Начатая языком Rust революция в области защищенности памяти в корне изменила правила игры для разработчиков. С Rust больше не нужно беспокоиться об уязвимостях в исходном коде, связанных с управлением памятью. Уникальные «абстракции с нулевой стоимостью» позволяют писать одновременно
высокопроизводительный и защищенный исходный код без дополнительных
накладных расходов на этапе исполнения. Это привело к тому, что в программировании такие ловушки работы с памятью, как обращение к памяти по пус
тым указателям и переполнение буфера, сведены к минимуму, что снижает
возможную вероятность катастрофических отказов.
Встроенные в Rust модели владения и заимствования обеспечивают наличие у каждого элемента данных единственного «владельца», определяющего
время жизни элемента. Такой строгий контроль предотвращает гонки за доступом к данным и недопустимый доступ к памяти, обеспечивая Rust репутацию языка с защищенной памятью. Теперь разработчики могут писать эффективный и защищенный исходный код, не опасаясь уязвимостей управления памятью. Rust сделал возможным написание программного обеспечения,
которое является не только высокопроизводительным, но и защищенным,
что стало важной вехой в компьютерном программировании. Эта революция
в защите памяти дала возможность легко строить надежное и защищенное
программное обеспечение.
В Rust ошибки проявляются в различных формах. Исправимые ошибки подобны неровностям на дороге; к ним относятся такие сценарии, как
ненайденный файл или путаница в числах. Rust предоставляет удобные
инструменты – типы Option и Result, – чтобы щадяще обрабатывать такие предсказуемые ошибки2. Напротив, неисправимые ошибки нарушают
1
2
Составители Wikipedia (2023). Rust (язык программирования). Wikipedia. https://
en.wikipedia.org/wiki/Rust_(programming_language).
Исправимые ошибки с помощью перечисления Result – Язык программирования Rust
(дата не указана). https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html.
Защита памяти 29
правила программы, например при выходе за границы массива. На такого
рода ошибки компилятор Rust реагирует радикальным механизмом, именуемым паникой1.
Теперь давайте обследуем важнейшую концепцию – RAII (от англ. Resource
Acquisition Is Initialization – Приобретение ресурса есть инициализация). Она
похожа на управление ресурсами в цифровой сфере. При использовании RAII
ресурсы не просто выделяются, но и инициализируются строго в диапазоне
жизненного цикла объекта, создавая бесшовный и контролируемый переток2.
Этот метод гарантирует бережное обращение с ресурсами, подобно умелому
дирижеру, управляющему развитием и затиханием музыкальных мотивов.
Инструменты обработки ошибок и метод RAII можно задействовать в Rust для
того, чтобы уверенно ориентироваться в сложных ситуациях и обеспечивать
бесшовное исполнение программ.
ЛАДНО, ЧЕЛОВЕК.
ПРЕЖДЕ ЧЕМ
НАЖМЕШЬ
«СКОМПИЛИРОВАТЬ»,
ПОСЛУШАЙ.
А?
ТЕБЕ ЗНАКОМО
ЧУВСТВО, КОГДА
ЗАСЫПАЕШЬ
И ТЕБЕ КАЖЕТСЯ,
ЧТО ТЫ ИДЕШЬ
ИЛИ ТИПА ТОГО
И ВДРУГ ТЫ
ОСТУПАЕШЬСЯ,
СПОТЫКАЕШЬСЯ
И РЕЗКО
ПРОСЫПАЕШЬСЯ?
ДА-А!
ТАК ВОТ, ЭТО ТО ЖЕ САМОЕ,
ЧТО ЧУВСТВУЕТ ПРОГРАММА
ПРИ ОШИБКЕ СЕГМЕНТАЦИИ.
ПЕРЕПРОВЕРЬ УЖЕ СВОИ
ЧЕРТОВЫ УКАЗАТЕЛИ,
ЛАДНО?
Рисунок 1.2. Жалоба компилятора – xkcd.com/37
В следующих далее разделах мы рассмотрим эти концепции на примерах,
написанных на языках программирования C, C++ и Rust.
Обращение к памяти по пустому указателю
Обращение к памяти по пустому указателю (null pointer dereference)3 – частая
причина аварийных завершений программ и ошибок во многих языках программирования – в Rust практически исключено благодаря его строгой системе типов и модели владения.
В языках программирования часто возникают ошибки, связанные с пустыми указателями. Это переменные-указатели, которым присвоено специальное
значение NULL или 0, а попытка обратиться к памяти по пустому указателю
может приводить к проблемам на этапе исполнения программы. В языке про1
2
3
Неисправимые ошибки с помощью паники! – Язык программирования Rust (дата
не указана). https://doc.rust-lang.org/book/ch09-01-unrecoverable-errors-with-panic.html.
RAII – Язык Rust на примерах (год издания не указан). https://doc.rust-lang.org/rust-byexample/scope/raii.html?highlight=raii#raii.
Dereference – это именно обращение к памяти по пустому указателю, т. е. указателю
с адресом NULL. В русском языке термин dereference невозможно выразить одним
словом, а термин «разыменование» вводит пустую сущность и запутывает, т. к. речь
идет не об имени. Обратная операция reference вполне логично переводится как
присваивание указателю адреса значения. – Прим. перев.
30
Системное программирование на языке Rust
граммирования C попытка обращения к памяти по пустому указателю приводит к неопределенному поведению. Подобного рода непредсказуемость мешает программистам понимать, как именно программа будет себя вести в аналогичных ситуациях1.
Пустой указатель
None
Рисунок 1.3. Простая иллюстрация пустого указателя
Гонки за доступом к данным действуют как скрытные дефекты, способные
нарушать работу исходного кода и приводить к непредсказуемым последствиям. Попытка отладки таких проблем во время работы программы напоминает
погоню за ускользающими тенями. Встроенный в Rust защитный механизм
выстроен вокруг гарантии того, что указатели будут держаться подальше от сомнительных участков недопустимой памяти2. Проще говоря, защищенность
в Rust означает, что указатели должны оставаться на надежном пути, избегая
любых ловушек, которые могут спровоцировать неопределенное поведение
на протяжении всего времени работы программы.
Неопределенное поведение возникает в ситуациях, когда программа переходит в необычное состояние из-за того, что компилятор не предусмотрел
те или иные сценарии развития событий. Это сравнимо с тем, как если бы ваша
программа оказалась на неизведанной территории без надежного проводника. Главная задача Rust заключается в том, чтобы справляться с этими сложностями: обеспечивать безопасную навигацию по исходному коду, гарантируя
надежность указателей и избегая хаоса, который может порождать неопределенное поведение3.
Rust работает с пустыми указателями иначе: при попытке обратиться к памяти
по пустому указателю вместо неопределенного поведения поднимается паника.
Паника эквивалентна исключению; она приводит к неисправимой ошибке, которая немедленно завершает работу программы4.
Вместе с тем ненадлежащее использование незащищенных (unsafe) блоков
кода в Rust может приводить к переполнению буфера во время исполнения.
1
2
3
4
Документация IBM (дата не указана). https://www.ibm.com/docs/en/i/7.3?topic=pointers-null.
Понимание концепции владения – Язык программирования Rust (дата не указана).
https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html?highlight=safety%20
features#understanding-ownership.
Ссылки и заимствование – Язык программирования Rust (год не указан). https://
doc.rust-lang.org/book/ch04-02-references-and-borrowing.html?highlight=undefined%20
behavior#mutable-references.
EXP34-C. Не следует брать значения по указателям с нулевым адресом – Стандарт
программирования SEI CERT C – Confluence (дата не указана). https://wiki.sei.cmu.edu/
confluence/display/c/EXP34-C.+Do+not+dereference+null+pointers.
Защита памяти 31
И если предварительно не предусмотреть их обработку, то могут быть получены непредсказуемые результаты1.
По умолчанию язык С не проверяет обращения к памяти по пустым указателям, что приводит к аварийному завершению программы при попытке
доступа через них. Следующий фрагмент исходного кода иллюстрирует такой
случай на языке С:
Листинг 1.1. Пример работы с пустым указателем на языке С
#include <stdio.h>
int main() {
int *ptr = NULL; // ①
*ptr = 5;
// ②
return 0;
}
// Скомпилировать и исполнить:
// $ gcc null_pointer.c -o null_pointer && ./null_pointer
// Вывод:
// Segmentation fault (core dumped)
В этой программе С мы сначала импортируем стандартную библиотеку ввода-вывода. Внутри функции main мы первым делом объявляем указатель ptr
на целое число, инициализируя указатель пустым значением (шаг ①). Затем,
на шаге ②, пытаемся назначить значение «5» по адресу, на который ссылается
пустой указатель, обратившись через него. Это обращение приводит к неопределенному поведению, результатом которого может стать повреждение данных или такие последствия, как аварийное завершение программы и другие
серьезные проблемы.
Теперь поговорим о куче. Она представляет собой своего рода особый уголок в вашей программе, где данные могут находиться в течение длительного
времени. В указанном выделенном пространстве программа хранит важную
информацию, чтобы воспользоваться ею позже. Но здесь есть важный нюанс:
с этим пространством нужно обращаться осторожно. Если вы забудете «прибраться» за собой, то это будет похоже на оставленный в углу мусор, что сулит
неприятности. Киллер нехватки памяти – это своего рода страж, который способен принудительно останавливать программу, если она накапливает слишком много памяти без надлежащей приборки2.
1
2
Трансляция с языка С на язык Rust под управлением механизма владения, Ownership
Guided C to Rust Translation, Zhang, H., David, C., Yu, Y., Wang, M. (2023). In: Enea, C.,
Lal, A. (eds) Computer Aided Verification. CAV 2023. Lecture Notes in Computer Science,
vol 13966. Springer, Cham. https://doi.org/10.1007/978-3-031-37709-9_22.
Управление ситуациями нехватки памяти (n.d.). https://www.kernel.org/doc/gorman/
html/understand/understand016.html.
32
Системное программирование на языке Rust
процесс 2
ОС
процесс 3
процесс 1
запрос памяти
процесс 5
ядро
Убийца ситуаций
нехватки памяти
Да
Задействовать
Убить
Нехватка
памяти?
Выделить
Нет
Виртуальная
память
процесс 7
Рисунок 1.4. Ядро вызывает киллера нехватки памяти, чтобы завершить процесс с высоким
баллом нехватки
Когда ваша программа работает, могут случаться ошибки – и это нормальная часть пути программиста! Например, вы можете забыть дать программе команду высвободить часть занятой памяти или попытаться использовать память недопустимым способом. При возникновении таких
ошибок программа получает сигнал от компьютера. Этот сигнал подобен
сообщению об ошибке (и, возможно, вы уже с ним знакомы), которое гласит: «segmentation fault» (ошибка сегментации). По сути, компьютер говорит
программе: «Исправь это, или мне придется тебя остановить». Поэтому нам,
программистам, нужно крайне внимательно относиться к тому, как мы используем это пространство памяти. Мы должны вовремя «прибирать» за собой или, что еще лучше, использовать языки программирования, которые
автоматически помогают управлять памятью, – и, как вы уже догадались,
таким языком является Rust!
Крайне важно всегда избегать любых попыток доступа к ячейкам памяти
без их предварительной инициализации или выделения, так как это может
привести к катастрофическим последствиям для стабильности и надежности
вашего исходного кода! Рассмотрим пример:
Защита памяти 33
Листинг 1.2. Пример защищенного выделения памяти на языке С
#include <stdio.h>
#include <stdlib.h>
int main() {
int *ptr = malloc(sizeof(int));
if (ptr == NULL) { // ①
printf("Ошибка: не удалось выделить память");
} else {
*ptr = 5;
}
return 0;
}
// $ gcc null_pointer_safe.c -o null_pointer_safe && ./null_pointer_safe
// Вывод: None
① Если выделить память не удастся, то при попытке доступа программа
столкнется с обращением к памяти по пустому указателю.
Попытки обращения к памяти по пустому указателю в Rust проходят
тщательную проверку еще на этапе компиляции. Это означает, что если
в исходном коде заложена попытка обращения к памяти по пустому указателю, то программа попросту не скомпилируется. Для того чтобы увидеть,
насколько эффективно Rust справляется со сценариями ошибочного доступа по пустым указателям, взгляните на следующий ниже фрагмент исходного кода:
Листинг 1.3. Пример защищенного обращения по пустому указателю в Rust
let mut vec = vec![1, 2, 3];
let item = vec.pop(); // ①
match item {
Some(val) => println!("Вытолкнутое значение: {}", val),
None => println!("Вектор пуст"),
}
// $ rustc null_pointer.rs
// Вывод:
// Вытолкнутое значение: 3
Rust предлагает решение проблемы обращения к памяти по пустому указателю посредством типа Option, который явно обозначает, существует значение
или нет. При использовании метода pop на шаге ① возвращается значение
типа Option, которое можно проверить и точно выяснить, было ли вообще получено какое-то значение.
Давайте рассмотрим еще один пример:
34
Системное программирование на языке Rust
Листинг 1.4. Пример небезопасного обращения по пустому указателю в Rust
fn main() {
let ptr: *const i32 = std::ptr::null(); // ①
let value = unsafe { *ptr };
// ②
println!("Значение: {}", value);
}
// $ rustc null_pointer.rs && ./null_pointer
// Вывод:
// Segmentation fault (core dumped)
Ключевое слово unsafe в Rust используется для обозначения прямого обращения к памяти, и программист берет на себя ответственность за обеспечение ее защищенности. Вдобавок благодаря системе владения и заимствования
само понятие пустых указателей в Rust практически отсутствует. Тем не менее
Rust позволяет использовать так называемые сырые указатели внутри блоков unsafe. В приведенной выше программе на Rust:
① мы объявляем немутируемый сырой указатель с именем ptr и инициализируем его пустым значением с помощью std::ptr::null();
② используем блок unsafe для обращения к памяти по пустому указателю ptr. Это незащищенная операция, которая может привести к неопределенному поведению.
Крайне важно предотвращать ошибки, связанные с обращением к памяти
по пустым указателям, выполняя проверку на наличие пустого указателя перед попыткой обратиться к памяти. Rust предлагает тип Option, который представляет возможно пустые значения и используется как превентивная мера.
В языке программирования С для проверки на наличие пустого указателя используются инструкции if.
При написании исходного кода на обоих языках крайне важно помнить
об этих потенциальных проблемах; принятие необходимых мер предосторожности против таких ошибок всегда должно быть приоритетом.
Переполнение буфера
Переполнение буфера – критическая уязвимость в программном обеспечении – в Rust предотвращается благодаря встроенным функциональным средствам обеспечения защищенности памяти и строгой проверке границ массивов. Вследствие этого он становится приоритетным языком для разработчиков, уделяющих особое внимание защите данных.
Возникновение переполнения буфера – это одна из ключевых проблем кибербезопасности. Она возникает в ситуациях, когда программа пытается сохранить в буфере объем данных, превышающий его расчетную емкость1. Такая уязвимость несет в себе серьезные риски и может проявляться как в С, так и в Rust.
В языке С проблема часто возникает из-за неправильного использования таких
1
Переполнение буфера. Безопасность, Buffer overflow. Security, Cobb, M. (2021). https://
www.techtarget.com/searchsecurity/definition/buffer-overflow.
Защита памяти 35
функций, как strcpy или sprintf, которые не проверяют лимиты размера перед
копированием данных. С другой стороны, в Rust к переполнению буфера могут приводить незащищенные (unsafe) блоки кода или ошибки при ручном выделении памяти. Разработчикам на обоих языках крайне важно обеспечивать
надлежащие проверки, чтобы не допускать нарушений защиты, вызываемых
переполнением буфера во время процессов исполнения программы.
Буфер
(5 байт)
Переполнение
(1 байт)
Рисунок 1.5. Иллюстрация примера переполнения буфера
Например, следующий ниже фрагмент исходного кода на С может привести
к переполнению буфера:
Листинг 1.5. Простейший пример переполнения буфера на языке С
#include <stdio.h>
#include <string.h>
int main() {
char buffer[5];
// ①
strcpy(buffer, "Избыточное содержимое!"); // ②
return 0;
}
// $ gcc -Wstringop-overflow=0 -fno-stack-protector buffer_overflow.c -o buffer_overflow &&
./buffer_overflow
// Вывод:
// Избыточное содержимое!
// Segmentation fault (core dumped)
В приведенной выше программе:
Мы вставляем стандартные библиотеки ввода-вывода и манипулирования
строковыми данными.
① В функции main объявлен массив с именем buffer, под который выделено
всего 5 байт пространства.
② С помощью функции strcpy мы пытаемся скопировать строковый литерал "Overflowing Content!" в этот массив. Однако поскольку строковый
литерал превышает лимит размера, возникает переполнение буфера: записывается больше символов, чем может вместить выделенная область
памяти, что неизбежно приводит к перезаписи соседних участков памяти.
Переполнение буфера в Rust предотвращается самой системой типов и моделью владения. Ниже представлен пример:
36
Системное программирование на языке Rust
Листинг 1.6. Пример переполнения буфера на Rust
fn main() {
let mut buffer: [u8; 5] = [0; 5];
let data = b"Избыточное содержимое!";
buffer[..data.len()].copy_from_slice(data);
println!("Содержимое буфера: {:?}", buffer);
}
//
//
//
//
①
②
③
④
// $ rustc buffer_overflow.rs && ./buffer_overflow
// Вывод:
// thread 'main' panicked at 'range end index 20 out of range
// for slice of length 5', buffer_overflow.rs:4:5
Функциональные средства защиты памяти делают переполнение буфера
в Rust маловероятным, так как он по умолчанию выполняет проверку границ
массивов. В представленной выше программе на Rust:
① внутри функции main мы объявляем мутируемый массив с именем buffer размером 5 байт, инициализированный нулями;
② мы объявляем байтовый массив data фиксированной длины, содержащий байты текста "Overflowing Content!";
③ мы используем срезы, чтобы скопировать из переменной data в массив buffer только необходимый объем байтов. Для копирования данных
в массив buffer применяется метод copy_from_slice, что позволяет избегать переполнения;
④ в завершение мы выводим содержимое массива buffer на экран.
Понимание механизмов обращения к памяти по пустому указателю и переполнения буфера имеет решающее значение для написания надежного и защищенного исходного кода. Языки программирования С и Rust предлагают
разные подходы к решению этих вопросов. Язык С, будучи менее строгим,
требует от программиста тщательного управления указателями и выделением памяти. С другой стороны, встроенные в Rust система владения и средства
защиты памяти способствуют предотвращению подобных ошибок, благодаря
чему этот язык является более защищенным вариантом выбора для современного программирования. Освоив эти концепции и правильно применяя их на
практике, вы сможете создавать программное обеспечение, которое будет одновременно надежным и отказоустойчивым.
Сборщик мусора
В сфере языков, основанных на виртуальных машинах, сборка мусора выступает в роли умного решения, защищающего исходный код от проблем с памятью1.
Это своего рода «бригада по уборке», которая работает совместно с системой памяти компьютера, следя за тем, чтобы неиспользуемое пространство вовремя
высвобождалось. Это избавляет от необходимости управлять подобного рода
1
Составители Wikipedia (27 ноября 2023). Сборка мусора (информатика). Wikipedia.
https://en.wikipedia.org/wiki/Garbage_collection_%28computer_science%29.
Защита памяти 37
задачами вручную. Различные стратегии, такие как алгоритм пометки-очистки и поколенческой сборки мусора, помогают отыскивать правильный баланс
между очисткой памяти и плавностью работы программы1. Для нас сочетание
встроенной в Rust системы владения и сборки мусора повышает защищенность
и эффективность, показывая язык Rust во всей красе2.
объект
Куча
Обращение к памяти
по указателю
Куча
объект удален
Рисунок 1.6. Иллюстрация процесса сборки мусора
Инновационность языка Rust распространяется и на управление памятью,
обеспечивая ее защищенность без использования сборщика мусора. Эта функциональная особенность сводит к минимуму накладные расходы на этапе исполнения и исключает риск возникновения пауз, вызываемых сборкой мусора,
которые могли бы повлиять на производительность и безопасность системы.
Рассмотрим следующий ниже исходный код на Rust:
Листинг 1.7. Простейший пример автоматического
управления памятью на Rust
struct Resource {
data: Vec<u8>,
}
// ①
fn main() {
let resource = Resource {
// ②
data: vec![1, 2, 3, 4, 5], // ③
};
// ④
}
Ниже приведен подробный разбор.
① Мы определяем структуру под названием Resource, которая содержит
поле data типа Vec<u8>.
1
2
Составители Wikipedia (24 ноября 2023). Трассировка сборки мусора. Wikipedia.
https://en.wikipedia.org/wiki/Tracing_garbage_collection#Basic_algorithm.
Владение – Rustonomicon (дата не указана).
https://doc.rust-lang.org/nomicon/ownership.html.
38
Системное программирование на языке Rust
② Создаем экземпляр структуры Resource, инициализируя ее поле data вектором, содержащим целые числа от 1 до 5.
③ Определяем данные внутри поля data.
④ Экземпляр resource выходит за пределы диапазона доступности, и встроенная в Rust автоматическая система управления памятью высвобождает память, занятую полем data.
В отличие от языков C/C++, где широко распространено ручное управление
памятью с использованием функций malloc и free, встроенная в Rust система
владения гарантирует автоматическую очистку памяти, снижая риски, связанные с утечками памяти и «висячими» указателями.
При работе со ссылками в Rust необходимо четко определять времена жизни
и продолжительность времени, в течение которого ссылки остаются действительными. Вся суть состоит в том, чтобы не давать ссылкам портиться и начинать указывать на данные, на которые они не должны указывать. Представьте,
что вы проверяете, ведут ли ваши пути (ссылки) к нужному пункту назначения
(данным). Поэтому аннотирование времени жизни в Rust – это не просто прихоть, а расстановка дорожных указателей, чтобы программа шла по верному
пути и ссылки не терялись и не создавали проблем. Мы выступаем защитниками процесса исполнения нашего исходного кода, обеспечивая, чтобы каждая
ссылка вела себя так, как положено1.
Для сравнения давайте рассмотрим эквивалентный фрагмент исходного
кода на языке C++, иллюстрирующий ручное управление памятью:
Листинг 1.8. Пример ручного управления памятью на языке C++
#include <iostream>
#include <vector>
struct Resource {
std::vector<uint8_t> data;
};
// ①
int main() {
Resource resource;
// ②
resource.data = {1, 2, 3, 4, 5}; // ③
// Перед выходом из диапазона доступности необходимо
// вручную очистить память, выделенную под data
return 0;
}
// $ g++ gc.cpp -o gc && ./gc
Ниже приводится подробный разбор.
① Мы определяем структуру Resource, содержащую поле std::vector типа
uint8_t.
② Объявляем экземпляр структуры Resource.
③ Присваиваем данные полю data вручную, используя инициализацию
списком.
1
Проверка правильности ссылок с помощью времен жизни – язык программирования
Rust (дата не указана). https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html.
Защита памяти 39
В языке C++ для управления высвобождением памяти крайне важно использовать ручное управление посредством функций new и delete или же умные
указатели. В противовес этому принятый в Rust подход, показанный в приведенном выше примере, упрощает работу с памятью, автоматизируя процесс
ее очистки.
Многопоточность и параллелизм
Эра многопоточности и параллелизма знаменует собой трансформационный
сдвиг в компьютерном программировании: современные приложения все
чаще опираются на конкурентное исполнение заданий с целью достижения
максимальной эффективности и производительности. Rust, как язык системного программирования, предлагает мощные инструменты и функциональные средства для раскрытия потенциала многопоточности и параллелизма,
поддерживая при этом строгий акцент на защищенности.
Конкурентные потоки
исполнения
Параллельные
задания
Рисунок 1.7. Потоки исполнения против параллельных заданий
В современном мире мы сильно зависим от многоядерных процессоров, способных справляться с постоянно растущими требованиями сложных программных приложений. Использование мощи параллелизма больше не роскошь, а необходимость. Продвинутая модель конкурентности в Rust – это идеальный ответ
на данный вызов. Продвигая концепцию «бесстрашной конкурентности», Rust
дает нам возможность писать параллельный исходный код, который одновременно эффективен и надежен, не подвергая нас риску возникновения гонок
за доступом к данным или ошибок работы с памятью.
Исключительный успех Rust в области конкурентного программирования
обусловлен его методикой, основанной на владении. Предотвращение коллективных (совместных) мутируемых состояний и продвижение механизма
передачи сообщений гарантируют, что конкурентные программы будут надежными и предсказуемыми, что выгодно отличает Rust от других языков программирования. Этот инновационный подход вывел Rust в лидеры в области
конкурентного программирования.
Встроенная в Rust надежная модель конкурентности позволяет без усилий
задействовать весь потенциал современного оборудования и писать эффективный исходный код, который легко масштабируется. Независимо от того,
40
Системное программирование на языке Rust
чем вы занимаетесь – разрабатываете динамичное веб-приложение или сложный алгоритм машинного обучения, – принятый в Rust конкурентный подход
обеспечит именно те результаты, которые вам необходимы.
Многопоточность играет ключевую роль в раскрытии потенциала современных процессоров при разработке систем. Хотя и C, и C++ поддерживают
многопоточность, они печально известны тем, что порождают такие сложные
проблемы, как гонки за доступом к данным и взаимные блокировки. Rust стоит особняком, превентивно решая эти проблемы за счет проверок на этапе
компиляции, которые радикально снижают вероятность возникновения гонок
за доступом к данным.
Рассмотрим фрагмент исходного кода, демонстрирующий функциональные
средства многопоточности в Rust.
Листинг 1.9. Простейший пример многопоточности на Rust
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5]; // ①
let mut handles = vec![];
// ②
for &item in &data {
// ③
handles.push(thread::spawn(move || {
// ④
println!("Обработано: {}", item * 2); // ⑤
}));
}
for handle in handles {
// ⑥
handle.join().unwrap(); // ⑦
}
}
// $ rustc thread.rs && ./thread
//
//
//
//
//
//
Вывод:
Обработано:
Обработано:
Обработано:
Обработано:
Обработано:
2
6
4
8
10
В приведенном выше наглядном примере:
① Мы инициализируем вектор data, содержащий целые числа от 1 до 5.
② Создаем мутируемый вектор handles для хранения дескрипторов потоков исполнения.
③ Выполняем итерации по элементам вектора data, используя ссылку.
④ Порождаем новый поток исполнения с помощью функции thread::spawn,
обеспечивая, чтобы каждый поток брал во владение захваченную переменную item.
⑤ Внутри потока исполнения выводим обработанный результат (удвоенное
значение переменной item).
⑥ Выполняем итерации по дескрипторам потоков исполнения в цикле.
⑦ Задействуем метод join, чтобы обеспечить синхронизацию, ожидая завершения каждого потока исполнения.
Защита памяти 41
В этом фрагменте ключевую роль играет концепция перемещения, так как потоки исполнения порождаются под конкурентную обработку элементов вектора. Использование ключевого слова move внутри замыкания thread::spawn знаменует передачу владения переменной item на каждой итерации. Этот элегантный механизм гарантирует, что каждый поток исполнения эксклюзивно
владеет и оперирует своей копией данных, исключая риск конфликтов. Иными
словами, move в Rust дирижирует «балетом» передачи владения, обеспечивая
бесшовное и защищенное параллельное исполнение, где каждый поток исполнения удерживает свой отдельный фрагмент среды, внося вклад в совокупную
производительность без ущерба для целостности данных.
Этот исходный код демонстрирует более защищенный подход к многопоточности в Rust по сравнению с языками C/C++, где необходима ручная синхронизация и явное использование замков либо умных указателей Mutex.
Для прямого сравнения рассмотрим аналогичный исходный код на языке
C++:
Листинг 1.10. Простейший пример многопоточности на языке C++
#include <iostream>
#include <thread>
#include <vector>
void process_item(int item) {
std::cout << "Обработано: " << item * 2 << std::endl; // ①
}
int main() {
std::vector<int> data = {1, 2, 3, 4, 5}; // ②
std::vector<std::thread> threads;
// ③
for (const auto &item : data) {
// ④
threads.push_back(std::thread(process_item, item)); // ⑤
}
for (auto &thread : threads) { // ⑥
thread.join();
// ⑦
}
return 0;
}
// Вывод:
// Обработано: Обработано: 26
// Обработано: 4
// Обработано: 8
// Обработано: 10
Давайте подробно разберем аналог на языке C++.
① Мы определяем функцию process_item, которая удваивает число.
② Инициализируем вектор data.
③ Создаем вектор объектов std::thread для управления потоками исполнения.
④ Выполняем итерации по элементам вектора data, используя константную ссылку.
42
Системное программирование на языке Rust
⑤ Создаем новые потоки исполнения, передавая функцию process_item
и элемент item в качестве аргументов.
⑥ Выполняем итерации по дескрипторам потоков исполнения в цикле.
⑦ Обеспечиваем синхронизацию, вызывая метод join перед завершением
программы.
Хотя язык C++ и обладает способностями по многопоточности, он требует
ручной синхронизации. Принятый в Rust подход на основе владения обеспечивает внутреннюю защищенность памяти и избавляет от необходимости явной синхронизации (в простых случаях), повышая надежность и удобство технической поддержки исходного кода.
В сложном конкурентном программировании концепция «кражи заданий
при ожидании» (или крадущая операция join) выделяется как особая стратегия, привносящая определенное изящество в дирижирование потоками исполнения1. Эта идея строится вокруг эффективной координации потоков исполнения таким образом, который дополняет встроенную в Rust систему владения. В контексте приведенного выше примера исходного кода, где несколько потоков исполнения конкурентно обрабатывают элементы вектора data,
стратегия «кражи заданий при ожидании» обеспечивает синхронизированное
и эффективное закрепление за ними индивидуальных заданий. Этот подход
согласуется с принятой в Rust философией передачи владения, позволяя потокам щадяще завершать свои операции перед присоединением к главному
потоку исполнения.
Поток
исполнения 1
Задание
Push
Pop
Взять
Очередь кражи
работы
Коллективная очередь
Взять
Исполнить
Поток
исполнения 2
Push
Pop
Очередь кражи
работы
Steal
Взять
Поток
исполнения 3
Push
Pop
Очередь кражи
работы
Рисунок 1.8. Механизм кражи заданий при ожидании (крадущей операции join)
1
Составители Wikipedia (25 июля 2023). Кража работы. Wikipedia. https://en.wikipedia.
org/wiki/Work_stealing.
Защита памяти 43
Более того, в более широком контексте конкурентности ключевым аспектом
становится концепция коллективного состояния. Rust, в рамках своей приверженности защищенному и конкурентному программированию, подчеркивает значимость эффективного управления коллективным состоянием1. Этот
акцент на управлении коллективным состоянием подкрепляет уникальный
стиль языка Rust в конкурентности, демонстрируя баланс между производительностью и защищенностью, который выделяет его в сфере параллельного
исполнения.
Конкурентность является краеугольным камнем современных программных систем, и модель Rust гарантирует защищенность без ущерба для производительности. Общие черты (типажи) Send и Sync обеспечивают защищенную
передачу данных между потоками исполнения.
Листинг 1.11. Простейший пример многопоточности
с умным указателем Mutex
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(0)); // Создаем защищенные данные
let handles: Vec<_> = (0..10)
.map(|_| {
let data = data.clone(); // Клонируем умный указатель
thread::spawn(move || {
let mut data = data.lock().unwrap(); // Приобретаем замок
*data += 1;
})
})
.collect();
}
for handle in handles {
handle.join().unwrap();
}
println!("Окончательное значение данных: {:?}", *data.lock().unwrap());
// Вывод:
// Окончательное значение данных: 10
Общая черта Send позволяет передавать данные между потоками исполнения, обеспечивая правильное управление владением, тогда как общая черта Sync гарантирует, что данные могут использоваться коллективно между
потоками исполнения без риска возникновения гонок за доступом к данным.
Эта модель способствует параллелизму, одновременно устраняя типичные
проблемы многопоточности.
По мере углубления в подробные примеры исходного кода и тщательные
сравнения становится предельно ясно, что язык Rust превосходит традиционные языки C и C++. Инновационные функциональные средства Rust в со1
Конкурентность на основе коллективного состояния – Язык программирования Rust
(дата не указана). https://doc.rust-lang.org/book/ch16-03-shared-state.html.
44
Системное программирование на языке Rust
четании с уникальными конструктивными решениями создают защищенную,
надежную и оптимизированную среду для разработки систем.
Сопоставление с шаблоном
Сопоставление структур с шаблоном – это важнейшее функциональное средство Rust, которое упрощает сложную условную логику и повышает удобочитаемость исходного кода. Оно позволяет выражать сложные шаблоны сопоставления элегантно и лаконично. Указанное функциональное средство оказывается бесценным в таких сценариях, как разбор структур данных, обработка
ошибок и даже синхронизация в многопоточности.
Сопоставление с шаблоном работает путем сопоставления структур значений с шаблоном и исполняет соответствующие блоки кода. Оно гораздо мощнее простых инструкций switch в других языках, так как позволяет деструктурировать и сопоставлять сложные структуры данных. Давайте рассмотрим более детальный пример, демонстрирующий сопоставление с шаблоном с привлечением деструктуризации:
Листинг 1.12. Простейший пример сопоставления с шаблоном на Rust
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle(f64, f64, f64),
}
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle(width, height) => width * height,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
fn main() {
println!("{:?}", area(Shape::Circle(32.0)));
}
// $ rustc pattern.rs && /pattern
// Вывод:
// 3216.990877275948
В приведенном выше примере перечисление Shape представляет разные
геометрические фигуры. Инструкция match элегантно извлекает значения
из каждого варианта и соответствующим образом вычисляет площадь. Благодаря такому подходу исходный код становится более удобочитаемым и менее
подверженным ошибкам, чем вложенные инструкции if или другие механизмы ветвления.
Защита памяти 45
Времена жизни
Система времен жизни в Rust – это выдающееся функциональное средство,
которое обеспечивает защищенность памяти без необходимости в сборщике
мусора. Времена жизни отслеживают продолжительность, в течение которой
ссылки на данные остаются действительными, предотвращая появление «висячих» указателей и утечек памяти. Эта система позволяет писать исходный
код, который является одновременно эффективным и надежным.
Рассмотрим более развернутый пример, включающий несколько времен
жизни и функцию, которая находит самый длинный общий префикс двух
строковых данных:
Листинг 1.13. Простейший пример применения времен жизни в Rust
fn longest_common_prefix<'a>(x: &'a str, y: &'a str) -> &'a str {
let min_length = std::cmp::min(x.len(), y.len());
let bytes_x = x.as_bytes();
let bytes_y = y.as_bytes();
for i in 0..min_length {
if bytes_x[i] != bytes_y[i] {
return &x[..i];
}
}
&x[..min_length]
}
fn main() {
let string1 = "abc";
let result;
{
let string2 = "abdef";
result = longest_common_prefix(string1, string2);
}
println!("Самый длинный общий префикс равен {}", result);
}
// $ rustc lifetimes.rs && ./lifetimes
// Вывод:
// Самый длинный общий префикс равен ab
В этой функции единое время жизни 'a обеспечивает, чтобы возвращаемая
ссылка была действительна в течение времени жизни самой короткой из входных ссылок. За счет этого обеспечивается защищенность и предотвращается
появление потенциально «висячих» ссылок.
Примечание. В примере есть тонкий момент: string2 живет только внутри
блока { }. Если бы функция longest_common_prefix вернула ссылку на данные,
которые исчезают вместе со string2, то компилятор Rust выдал бы ошибку. Система времен жизни – это как раз те «дорожные знаки», о которых говорилось
ранее, не дающие программе «сбиться с пути».
Аннотации времен жизни в Rust поначалу, возможно, покажутся сложными,
но они позволяют компилятору отлавливать типичные ошибки работы с памятью еще на этапе компиляции, делая исходный код более надежным.
46 Системное программирование на языке Rust
Абстракции с нулевой стоимостью
Rust дает возможность создавать высокоуровневые абстракции, не жертвуя
при этом производительностью. Это достигается благодаря принципу «абстракций с нулевой стоимостью». Встроенная в Rust система владения и заимствования в сочетании с продвинутыми оптимизациями компилятора позволяет писать исходный код в естественном и выразительном стиле, который
при этом компилируется в эффективный машинный код.
Давайте рассмотрим пример, иллюстрирующий абстракцию с ненулевой
стоимостью в Rust, и сравним его с вариантом, в котором этой абстракции
не хватает. Мы возьмем простую задачу: фильтрацию четных чисел из вектора.
Листинг 1.14. Простейший пример без абстракции с ненулевой стоимостью
fn filter_even_numbers_old(numbers: Vec<i32>) -> Vec<i32> {
let mut result = Vec::new();
for num in numbers {
if num % 2 == 0 {
result.push(num);
}
}
result
}
При таком традиционном подходе мы в явной форме выполняем итерации по элементам вектора в цикле, проверяем каждый элемент на четность
и вручную строим новый вектор. Хотя этот исходный код понятен, он выставляет напоказ низкоуровневые детали итерации и проверки условий –
в нем не хватает абстракции. Теперь рассмотрим вариант с абстракцией
с нулевой стоимостью:
Листинг 1.15. Простейший пример абстракции с нулевой стоимостью
fn filter_even_numbers_new(numbers: Vec<i32>) -> Vec<i32> {
numbers.into_iter().filter(|&num| num % 2 == 0).collect()
}
Напротив, использование абстракций с нулевой стоимостью в Rust позволяет получать более выразительное и лаконичное решение. Здесь мы используем метод into_iter(), чтобы создать итератор, применить метод filter с замыканием, задающим условие, а затем собрать результаты в новый вектор.
Этот подход скрывает детали низкоуровневой итерации, обеспечивая более
чистую и удобочитаемую реализацию. Следует отметить, что в главе 10 «Итераторы и замыкания» мы углубимся в итераторы глубже, предложив всестороннее обследование их многогранности и продемонстрировав предоставляемую ими возможность писать более элегантный и производительный исходный код.
Второй пример – абстракция с нулевой стоимостью – предлагает несколько преимуществ. В нем заключена логика фильтрации в более декларативном
стиле, делая намерение программиста яснее и снижая шансы на ошибку при
ручном выполнении итераций. Сравнивая оба примера, можно заметить, как
Защита памяти 47
абстракция с нулевой стоимостью не только улучшает эстетику исходного
кода, но и способствует построению более надежных и понятных решений.
Рассмотрим еще один пример, демонстрирующий встроенную в Rust систему владения и то, как она обеспечивает защищенное и производительное конкурентное программирование:
Листинг 1.16. Простейший пример абстракции с нулевой стоимостью
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
let shared_data = std::sync::Arc::new(data); // Создаем коллективные данные
let handles: Vec<_> = (0..5).map(|i| {
let shared_data = shared_data.clone();
thread::spawn(move || {
let local_sum: i32 = shared_data.iter().sum();
println!("Поток исполнения {} Сумма: {}", i, local_sum);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
//
//
//
//
//
//
Вывод:
Поток исполнения
Поток исполнения
Поток исполнения
Поток исполнения
Поток исполнения
0
2
4
1
3
Сумма:
Сумма:
Сумма:
Сумма:
Сумма:
15
15
15
15
15
В приведенном выше примере система владения обеспечивает, чтобы каждый поток исполнения имел доступ к коллективным данным защищенным
и производительным способом, без гонок за доступом к данным. Тип Arc (атомарный подсчет ссылок) позволяет нескольким потокам исполнения коллективно владеть данными, а система типов Rust гарантирует потокозащищенность без необходимости в явных механизмах установки замков (блокировок).
Подробнее об «умных указателях» – в последующих главах. В частности, глава 6 «Управление памятью и указатели» позволит глубже погрузиться в тему
управления памятью и умных указателей, предложив всестороннее обследование их многогранности.
Важно отметить, что использование типа std::sync::Arc и потоков исполнения в данном случае является примером абстракции с нулевой стоимостью. В этом исходном коде высокоуровневые концепции используются для
того, чтобы достичь конкурентного исполнения с коллективными данными
и параллельным суммированием по всем потокам исполнения. Несмотря
на высокий уровень абстракций, компилятор языка Rust гарантирует, что
итоговый код будет эффективным, что и является воплощением принципа
нулевой стоимости.
48
Системное программирование на языке Rust
Интерфейс с внешними функциями (FFI)
Интерфейс с внешними функциями (FFI, от англ. Foreign Function Interface)
в Rust обеспечивает бесшовную интеграцию с существующим исходным
кодом на языке C и C++. Это функциональное средство играет критически
важную роль для разработки систем, так как оно позволяет исходному коду
на Rust взаимодействовать с библиотеками, написанными на других языках.
Интерфейс Rust с внешними функциями гарантирует защищенность, предотвращая такие проблемы, как обращение к памяти по пустым указателям, которые часто возникают при масштабном взаимодействии с исходным кодом
на языках C/C++.
Статические
библиотеки (.a)
Исходный
код C
Исходный
код Rust
Компилятор C
Компилятор Rust
Объектный файл (.o)
Объектный файл (.o)
Статический
файл (.a)
Компоновщик
Двоичный
исполняемый файл
Рисунок 1.9. Интерфейс с внешними функциями
Рассмотрим сценарий, в котором вы хотите вызывать функцию на языке C
из Rust и обрабатывать сложные типы данных:
Защита памяти 49
Листинг 1.17. Простейший пример интерфейса
с внешними функциями (FFI) в Rust
extern "C" {
fn process_data(data: *mut u8, length: usize);
}
fn main() {
let mut data: Vec<u8> = vec![1, 2, 3, 4, 5];
unsafe {
// Вызываем внешнюю функцию, передавая указатель на данные
process_data(data.as_mut_ptr(), data.len());
}
}
//
//
//
//
$
$
$
$
gcc -c external_lib.c -o external_lib.o
ar rcs libexternal_lib.a external_lib.o
rustc -L . -o main main.rs -l external_lib
./main
// Вывод: 1 2 3 4 5
Возможности Rust по работе с интерфейсом внешних функций открывают
двери для модернизации и улучшения унаследованных кодовых баз с помощью
защитных функциональных средств Rust. Это позволяет задействовать строгую
систему типов и предлагаемые Rust гарантии защищенности даже при взаимодействии с исходным кодом, написанным на других языках.
Обработка ошибок
Обработка ошибок – критически важный аспект надежности программного
обеспечения. Типы Result и Option в Rust предоставляют мощный механизм
управления ошибками и возможно пустыми значениями.
Рассмотрим простейший пример обработки ошибок при работе с файловым
вводом-выводом:
Листинг 1.18. Простейший пример обработки ошибок в языке Rust
use std::fs::File;
use std::io::{Read, Error};
fn read_file_contents(filename: &str) -> Result<String, Error> {
let mut file = File::open(filename)?; // '?' лаконично обрабатывает ошибку
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
//
//
//
//
//
//
Вывод:
Содержимое файла:
Строка 1
Строка 2
Строка 3
50
Системное программирование на языке Rust
Тип Result обрабатывает потенциальные ошибки, побуждая разработчиков
явно описывать сценарии успеха и неуспеха. Тип Option аналогичным образом
обеспечивает явную обработку возможно пустых значений, эффективно искореняя пугающие проблемы с обращением к памяти по пустым указателям.
Такой подход побуждает разработчиков писать более надежный и удобный
в технической поддержке исходный код. Подробнее эта тема будет раскрыта
в главе 5 «Обработка ошибок и восстановление после отказов».
Контролируемые незащищенные операции
Хотя в Rust и делается упор на защищенность, в нем признается необходимость в низкоуровневых операциях. Ключевое слово unsafe позволяет обходить некоторые принятые в Rust проверки защищенности, когда это действительно необходимо.
Рассмотрим пример, включающий прямую манипуляцию с сырым указателем:
Листинг 1.19. Простейший пример исполнения
незащищенного блока кода на Rust
fn main() {
let mut data = [1, 2, 3, 4, 5];
let data_ptr = data.as_mut_ptr(); // Получаем простой указатель на данные
unsafe {
// Напрямую меняем значение в памяти по смещению
*data_ptr.offset(2) = 10;
}
println!("Измененные данные: {:?}", data);
}
// Вывод:
// Измененные данные: [1, 2, 10, 4, 5]
Такой контролируемый обход системы безопасности позволяет выполнять
операции, критически важные для производительности, и взаимодействовать с низкоуровневыми системными API, сохраняя при этом четкую границу
между защищенным и незащищенным исходным кодом. Сообщество языка
Rust активно продвигает культуру минимизации незащищенного исходного
кода и обеспечения его правильности посредством тщательного пересмотра
и тестирования.
Инструментарий Rust
Rust славится своей исключительной защищенностью и функциональными
средствами конкурентного программирования. Однако по-настоящему его
выделяет многогранный инструментарий, позволяющий создавать элегантный и производительный исходный код. Данный инструментарий содержит
выразительный синтаксис, абстракции с нулевой стоимостью, сопоставление
с шаблоном, перечисления и семантику владения – все это выводит способности Rust на новый уровень.
Как упоминалось ранее, одной из выдающихся черт инструментария языка
Rust является его система владения. Эта система задает времена жизни данных,
Будущее вместе с Rust
51
обеспечивая предельно точное управление ресурсами1. Подход на основе владения повышает защищенность памяти и одновременно способствует эффективному использованию ресурсов – редкое сочетание в компьютерном программировании. Используя встроенную в Rust систему владения, можно создавать высокоуровневые абстракции, которые одновременно защищены и эффективны2.
Инструментарий Rust дирижирует настоящей симфонией творчества, предоставляя холст, который позволяет достигать продвинутых абстракций, сохраняя при этом низкоуровневый контроль. Эффективный синтаксис и абстракции с нулевой стоимостью обеспечивают защищенный фундамент,
на котором разработчик строит свои творения. С языком Rust он способен
создавать программы, которые не только работают надежно и быстро, но и обладают изяществом и красноречием. Предлагаемый языком Rust инструментарий бесспорно свидетельствует о силе инноваций и той творческой искре,
которую он зажигает в людях.
Практические применения
Невозможно отрицать адаптивность Rust в реальных приложениях, особенно когда речь идет о разработке инструментов командного интерфейса. Благодаря его эргономичному синтаксису и эффективности работы с памятью
он идеально подходит для разработки консольных приложений, требующих
как продуктивности, так и удобства для пользователя. Разработчики могут
полагаться на способность Rust управлять памятью без ущерба для производительности, предоставляя им доступ к профессиональным инструментам
разработки3.
Более того, влияние Rust выходит далеко за рамки инструментов командного интерфейса, поскольку его преимущества распространяются и на вебсервисы. Впечатляющие способности языка по использованию памяти и быст
родействию делают этот язык привлекательным вариантом для создания надежных бэкендовых сервисов. Используя высокоуровневые абстракции и поддерживая при этом низкоуровневый контроль над процессами, разработчики
могут строить веб-решения, способные обрабатывать огромные объемы пользовательских запросов без потери скорости или качества.
Будущее вместе с Rust
Rust – это не просто мимолетная мода, это влиятельная сила, формирующая компьютерное программирование. Крупные компании, такие как Meta4,
Dropbox и Mozilla, уже признали способности Rust и внедрили их в свои
1
2
3
4
Ссылки и заимствование – Язык программирования Rust (дата не указана). https://
doc.rust-lang.org/book/ch04-02-references-and-borrowing.html.
Rust: язык программирования для защищенности и производительности, Bugden, W.
(2022, June 11). arXiv.org. https://arxiv.org/abs/2206.05503.
Консольные приложения (дата не указана). https://www.rust-lang.org/what/cli.
Признана в России террористической организацией, деятельность на территории РФ
запрещена. – Прим. перев.
52
Системное программирование на языке Rust
проекты1. Однако его влияние шире простой популярности: предложения
по интеграции исходного кода на Rust напрямую в ядро Linux демонстрируют его способность с легкостью справляться даже с самыми требовательными системами2. Этот этап стал важным достижением для языка Rust, доказав
его пригодность для критически важных приложений, требующих высокой
производительности.
По мере того как интерес к этому гибриду языка и инструменту продолжает
расти, становится ясно одно: принять Rust – значит раздвинуть границы и обследовать новые возможности в программировании, каких не видели прежде!
Благодаря заложенной в его основу адаптивности, мощи и ориентированности на будущее создание следующего проекта на Rust гарантирует готовность
не только к сегодняшним, но и к завтрашним вызовам.
Отзывчивое сообщество
Помимо технических особенностей, Rust обладает живым и инклюзивным сообществом. От опытных инженеров до новичков – сообщество Rust приветливо и всегда готово помочь.
Такая атмосфера сотрудничества проявляется в обширной документации,
обучающих материалах и дискуссиях в сети. Движимый сообществом процесс
разработки обеспечивает эволюцию языка в соответствии с потребностями
разработчиков, поддерживая фокус на безопасности, производительности
и удобстве.
За пределами основ
Функциональные средства, которые мы здесь затронули, – это лишь вершина
айсберга способностей Rust. Его система владения, потокозащищенность, выразительная система макрофункций и мощный менеджер пакетов (Cargo) являются дополнительными доказательствами инновационности языка. Живое
сообщество и подробная документация подвигают нас на обследование, учебу
и созидание. По мере погружения в эту книгу вы откроете для себя исключительную способность Rust совершать революцию в разработке систем, гарантируя при этом защищенность, производительность и удобство технической
поддержки исходного кода.
Заметные проекты на Rust
Заметные проекты на Rust подчеркивают растущую значимость языка в различных областях разработки, демонстрируя его многогранность и влияние
на современные образцы практики программирования.
1
2
Краткая история Rust в Facebook. Инженерное дело в Meta. Wiltz, C. (2021). https://
engineering.fb.com/2021/04/29/developer-tools/rust/.
Обновления безопасности по вторникам (дата не указана). https://lwn.net/
Articles/842382/.
Заметные проекты на Rust 53
Рисунок 1.10. Компании, использующие Rust в производственной разработке
Помимо браузерного движка Servo, инструментов инфраструктуры Habitat.sh
и внутреннего использования в Dropbox, существует целый ряд других примечательных проектов. Одним из таких примеров является компания Microsoft,
которая активно использует Rust для разработки подсистемы Windows для
Linux (WSL). Разработчики облачной платформы Amazon Web Services (AWS)
создали Firecracker – сервис бессерверных вычислений на базе Rust, который
обеспечивает повышенную защищенность и эффективность использования
ресурсов. Веб-фреймворк Actix – еще одно популярное приложение, используемое для создания высокопроизводительных веб-сервисов, способных обрабатывать миллионы запросов в секунду.
Кроме того, Rust также используется в игровом движке Amethyst; на нем
сегодня работают несколько независимых (инди) игр. Поскольку к сообщест
ву ежедневно присоединяются разработчики из самых разных областей, неудивительно, что этот мощный язык быстро набирает популярность благодаря уникальному сочетанию производительности, защищенности и надежности.
Это лишь несколько примеров захватывающих проектов, разрабатываемых на Rust. Более полный список можно найти на официальном веб-сайте
языка.
54
Системное программирование на языке Rust
Установка Rust
Если вы хотите погрузиться в компьютерное программирование на языке
Rust, то первым важным шагом станет его установка на ваш компьютер. Rust
отлично поддерживает все основные операционные системы – от Windows
и Linux до MacOS. В этом разделе мы представим подробное руководство
по установке на каждую платформу, чтобы вы могли начать работу без сучка
и задоринки!
Установка языка в Windows
Процесс установки Rust в операционной системе Windows предельно прост.
Для этого нужно выполнить несколько несложных действий.
① Сначала открыть предпочитаемый браузер и посетить официальный
веб-сайт языка Rust: https://www.rust-lang.org/tools/install.
② Затем кликнуть по ссылке Other ways to install rustup (Другие способы
установки rustup), чтобы скачать файл установщика с расширением .exe.
Рисунок 1.11. Официальное руководство по установке Rust
③ После успешного скачивания необходимо запустить этот исполняемый
файл двойным щелчком мыши. Внимательно следуйте всем подсказкам
во время установки: они помогут настроить менеджер пакетов Cargo,
компилятор языка Rust rustc и остальные компоненты.
Установка Rust 55
Рисунок 1.12. Процесс установки Rust в терминале
Процесс установки компонентов языка Rust:
Рисунок 1.13. Установка компонентов языка Rust в терминале
56
Системное программирование на языке Rust
④ Как только все будет установлено, следует открыть новое окно командного интерфейса или терминала и ввести команду rustc --version. На экране должен появиться номер версии вашего только что установленного
программного обеспечения языка Rust.
Рисунок 1.14. Проверка установки компонентов языка Rust
Как видите, установить Rust довольно просто, последовательно выполнив
всего четыре понятные инструкции, в которых разберется каждый.
Установка в Linux
Процесс установки Rust в Linux предельно прост. Для этого нужно выполнить
следующие ниже действия.
① Открыть терминал, нажав сочетание клавиш Ctrl+Alt+T.
② В терминале ввести и исполнить следующую ниже команду:
Листинг 1.20. Команда установки Rust в Linux
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Эта команда скачает и запустит скрипт установщика Rustup.
③ Завершить установку, следуя подсказкам на экране. Вам будет предложено выбрать стандартные настройки и компоненты, которые вы захотите
установить.
④ По окончании установки следует обновить переменные среды. Для
этого необходимо либо перезапустить терминал, либо исполнить команду:
Интегрированная среда разработки и инструменты 57
Листинг 1.21. Команда обновления переменных среды
$ source $HOME/.cargo/env
⑤ Для подтверждения успешной установки необходимо набрать в терминале и исполнить команду rustc --version. Если все прошло гладко,
то вы увидите информацию о версии Rust, которая только что была скачана!
Установка Rust в MacOS
Установить Rust в MacOS – проще пареной репы. Для этого надо:
① Открыть терминал, найдя его через встроенную систему глобального поиска Spotlight или перейдя в папку Applications → Utilities →
Terminal (Программы → Утилиты → Терминал).
② Исполнить в терминале следующую ниже команду:
Листинг 1.22. Команда установки Rust в MacOS
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Эта команда скачает и запустит необходимый вам скрипт установщика
Rustup.
③ Следовать подсказкам в терминале с целью завершения установки; принять стандартные настройки и нужные компоненты.
④ После выполнения этих шагов закройте и снова откройте окно терминала ИЛИ введите следующую ниже команду, чтобы обновить переменные
среды:
Листинг 1.23. Команда обновления переменных среды
$ source $HOME/.cargo/env
⑤ Убедитесь, что Rust установлен, введя и исполнив команду rustc
--version в терминале. Вы должны увидеть номер установленной версии.
Все готово к работе!
Теперь, когда Rust успешно установлен, все готово к обследованию этого мощного языка. В вашем распоряжении подробная документация, активное сообщество и потрясающий инструментарий. И не важно, чем вы занимаетесь –
создаете молниеносные приложения либо углубляетесь в системное программирование, – уникальные функциональные средства и преимущества Rust
ждут ваших творческих идей.
Интегрированная среда разработки и инструменты
Прежде чем вы с головой окунетесь в написание исходного кода, давайте
настроим вашу среду разработки. Этот раздел поможет вам установить интегрированные среды разработки (IDE) и основные инструменты, которые
58
Системное программирование на языке Rust
сделают ваш путь в программировании более плавным, чем работа хорошо
отлаженного двигателя.
Выбор интегрированной среды разработки
Правильный выбор интегрированной среды разработки (IDE) может превратить разработку на Rust в сущее удовольствие. Ниже представлен самый популярный вариант:
Visual Studio Code (VS Code)
Visual Studio Code – это облегченная и мощная среда разработки, широко используемая в сообществе языка Rust. Вот как ее настроить:
① Скачать и установить среду разработки Visual Studio Code.
Рисунок 1.15. Официальный веб-сайт Visual Studio Code
② Открыть VS Code и перейти в Extensions Marketplace (Магазин расширений), кликнув по иконке Extensions (Расширения) в боковой панели или
нажав сочетание клавиш Ctrl+Shift+X.
③ Выполнить поиск по термину Rust и установить официальное расширение,
предоставляемое разработчиками языка (рекомендуется Rust Analyzer).
Интегрированная среда разработки и инструменты 59
Рисунок 1.16. Расширение Rust Analyzer для VS Code
④ Перезапустить среду разработки VS Code, чтобы активировать расширение.
Поздравляем! Теперь вы готовы писать исходный код на Rust в среде разработки VS Code с такими функциональными средствами, как автодополнение,
подсветка ошибок и многое другое.
Основные инструменты Rust
Помимо интегрированной среды разработки, вашими верными спутниками
при освоении Rust станут еще несколько инструментов.
Менеджер пакетов
Менеджер пакетов исходного кода на Rust, Cargo, упрощает управление зависимостями и сборку проектов. Для того чтобы удостовериться в его наличии,
надо:
① открыть терминал или командный интерфейс (оболочку);
② ввести команду cargo --version и нажать Enter. Если вы увидите номер
версии – все в порядке. Если нет, то установите Rust, используя официальное руководство.
Анализатор исходного кода
Clippy – это потрясающий инструмент – линтер, который помогает писать
идиоматичный исходный код без ошибок. Для его установки необходимо исполнить команду:
Листинг 1.24. Команда установки анализатора исходного кода Clippy
$ rustup component add clippy
Форматирование исходного кода
Поддерживать исходный код в чистоте и порядке проще простого с помощью официального инструмента автоматического форматирования rustfmt.
Для его установки необходимо исполнить команду:
60
Системное программирование на языке Rust
Листинг 1.25. Команда установки инструмента rustfmt
$ rustup component add rustfmt
Вы успешно настроили среду разработки с выбранной интегрированной
средой разработки (IDE) и важными инструментами. Теперь вы оснащены
всем необходимым для написания элегантного, производительного и защищенного исходного кода.
Написание первой программы на Rust
Теперь, когда Rust установлен, пришло время сделать решительный шаг и создать свою первую программу. Мы представим миру простое, но значимое приветствие: «Привет, мир!»
Начало работы
Перед началом убедитесь, что Rust установлен. Если нет – вернитесь к предыдущему разделу.
Написание исходного кода
① Откройте ваш любимый текстовый редактор или среду IDE.
② Создайте новый файл и сохраните его с расширением .rs. Например, назовите его hello.rs.
③ Внутри файла наберите следующий ниже исходный код:
Листинг 1.26. Простейшая программа на Rust
fn main() { // ①
println!("Привет, мир!"); // ②
}
Вот что происходит в приведенном выше исходном коде:
① fn main(): это точка входа в программу на Rust. Именно отсюда начинается исполнение исходного кода;
② println!("Привет, мир!");: эта строка вызывает макрофункцию println!,
чтобы вывести на экран сообщение. Знак ! указывает на то, что println! –
это именно макрофункция, а не обыкновенная функция.
Компиляция и запуск
Теперь давайте создадим нашу первую программу.
Написание первой программы на Rust 61
Рисунок 1.17. Компиляция и запуск простой программы Rust
① Открыть терминал или командный интерфейс.
② Перейти в директорию, где вы сохранили файл hello.rs.
③ Исполнить следующую ниже команду, чтобы скомпилировать Rust’овскую
программу:
Листинг 1.27. Команда компиляции программы Rust
$ rustc hello.rs
После успешной компиляции в той же директории появится исполняемый
файл с именем hello (или hello.exe в Windows).
④ Запустить программу, введя ее имя в терминале:
Листинг 1.28. Команда запуска программы
$ ./hello
Или в Windows:
Листинг 1.29. Команда запуска программы в Windows
$ hello.exe
Отлично сработано! Вы успешно создали и исполнили свою первую программу на Rust. Это начало пути, который позволит вам конструировать долговечное и производительное программное обеспечение. Впереди – масса возможностей для углубления в мощный потенциал языка Rust, его выразительный синтаксис и приверженность защитным мерам.
62
Системное программирование на языке Rust
Cargo: менеджер пакетов исходного кода на Rust
Cargo – это важнейший ресурс, который будет полезен как опытным разработчикам, так и новичкам. Его выдающийся механизм управления зависимостями, автоматизированная настройка сборки и удобный командный интерфейс
сделали его стандартом среди программистов на Rust.
По завершении этого раздела вы получите исчерпывающие знания о том,
как эффективно использовать менеджер Cargo в своих проектах. Вы сможете
без усилий управлять зависимостями и уверенно собирать свой исходный код,
зная, что все технические нюансы профессионально улажены за вас.
Знакомство с менеджером Cargo
Cargo – это не просто инструмент сборки, это ваш партнер в бесшовной разработке на Rust. Представьте, что у вас есть надежный помощник, который берет
на себя такие задачи, как управление зависимостями, компиляция исходного
кода, выполнение тестов, генерация документации и даже публикация пакетов. Для того чтобы начать обследовать возможности менеджера Cargo, необходимо проверить, чтобы в вашей системе был установлен Rust, следуя официальному руководству по установке.
Создание нового проекта
Для создания нового проекта необходимо:
① Открыть терминал или командный интерфейс.
② Перейти в директорию, где вы хотите создать свой проект Rust.
③ Инициировать новый проект с помощью следующей ниже команды:
Листинг 1.30. Команда создания нового проекта Rust
$ cargo new my_project
④ Заменить имя my_project на предпочитаемое имя проекта. Менеджер
Cargo создаст новую директорию под этим именем, настроив для вас базовую структуру проекта.
Навигация по структуре проекта
Понимание структуры проекта Rust играет фундаментальную роль в эффективном управлении разработкой программного обеспечения. В этом разделе
мы разберем основные компоненты и организацию стандартного проекта,
чтобы вы могли свободно ориентироваться в его внутреннем устройстве.
Cargo: менеджер пакетов исходного кода на Rust 63
Рисунок 1.18. Структура простого проекта Cargo
Зайдя в директорию вашего нового проекта, вы обнаружите:
① директорию src: это область вашего исходного кода, где будут находиться все исходные файлы Rust;
② файл Cargo.toml: этот конфигурационный файл – сердце вашего проекта;
в нем хранятся метаданные и информация о зависимостях.
Сборка и запуск
① Находясь в директории проекта, введите и исполните следующую ниже команду, чтобы собрать свой шедевр:
Листинг 1.31. Команда сборки проекта Rust
$ cargo build
② Cargo скомпилирует ваш исходный код и создаст исполняемый файл
внутри директории target/debug.
③ Для того чтобы увидеть свое творение в деле, исполните команду:
Листинг 1.32. Команда запуска проекта Rust
$ cargo run
Управление зависимостями
Настоящая жемчужина возможностей менеджера Cargo – это фокус на управлении зависимостями. Вы можете без усилий устанавливать в свой проект
внешние библиотеки, просто добавляя их в файл Cargo.toml. Например, для
того чтобы импортировать пакет serde для сериализации, надо указать в нем
следующее:
Листинг 1.33. Содержимое файла Cargo.toml
[dependencies]
serde = "1.0"
Затем необходимо исполнить команду cargo build, и Cargo сам найдет,
соберет и подготовит пакет serde вместе со всеми его зависимостями.
64
Системное программирование на языке Rust
Тестирование и документация
Менеджер Cargo не ограничивается сборкой и управлением – вы также можете
использовать его для тестирования исходного кода и создания удобной документации. Добавьте в свой исходный код тестовые функции, а затем запустите
весь комплект тестов командой:
Листинг 1.34. Команда Cargo для запуска тестов
$ cargo test
Более того, Cargo умеет создавать документацию, подобно искусному
писцу. Используйте встроенные в Rust документационные комментарии
и исполните:
Листинг 1.35. Команда Cargo для сборки документации
$ cargo doc
Обследование других функциональных
возможностей менеджера Cargo
Менеджер Cargo на этом не останавливается. Оцените его обширные функцио
нальные возможности, такие как:
публикация авторских пакетов на crates.io – великолепном реестре пакетов Rust;
создание нагрузочных тестов (тестов производительности) для тонкой
настройки и анализа быстродействия исходного кода;
дирижирование целой «симфонией» проектов внутри одного рабочего
пространства с целью гармонизации процесса разработки.
С менеджером Cargo в качестве компаса вы полностью готовы приступить
к освоению языка Rust – с особым стилем, легкостью и безграничными возможностями!
Заключение
Эта вводная глава заложила фундамент вашего погружения в программирование на Rust. Ниже представлен краткий итог того, что мы охватили.
Мощь Rust: вы познакомились с Rust – языком, который подобен супергерою в сфере разработки надежного программного обеспечения. Его
уникальные функциональные средства придают программам стабильность и защищенность.
Прочный фундамент: Rust помогает строить программы так же основательно, как возводят неприступные замки. Он предотвращает ошибки
памяти и гарантирует плавную и безотказную работу исходного кода.
Особые функциональные средства: у языка Rust припасено несколько
козырей в рукаве, выделяющих его на фоне других языков. Он настоящий профи в управлении памятью и поддержании высокой производительности.
Вопросы для самопроверки 65
Переход к делу: вы научились устанавливать Rust на свой компьютер
(под управлением Windows, Linux или MacOS), а также познакомились
с инструментами и средами для написания исходного кода.
Первая приветственная программа: вы написали свою самую первую
программу – лаконичное приветствие «Привет, мир!». Это стало началом
вашего практического опыта.
Ваш помощник – Cargo: мы представили вам менеджер Cargo, верный
спутник Rust. Он берет на себя всю скучную рутину, позволяя вам сосредоточиваться на творчестве.
Вооружившись базовым пониманием Rust, вы готовы окунуться в захватывающую сферу компьютерного программирования, решения задач и инноваций. Эта глава – лишь начало, впереди целая вселенная возможностей. Приготовьтесь раскрыть свой потенциал в последующих главах!
Дополнительные ресурсы
В целях более глубокого ознакомления с настройкой среды и инструментами
рекомендуется заглянуть в официальную документацию:
Официальный веб-сайт Rust: https://www.rust-lang.org.
Руководство по установке Rust: https://www.rust-lang.org/tools/install.
Документация по официальному установщику Rustup: https://rustup.rs.
Книга по языку программирования Rust: https://doc.rust-lang.org/book.
VS Code – расширение Rust: https://marketplace.visualstudio.com/
items?itemName=rust-lang.rust.
IntelliJ IDEA – плагин Rust: https://plugins.jetbrains.com/plugin/8182-rust.
Cargo – менеджер пакетов на Rust: https://doc.rust-lang.org/cargo.
Clippy – анализатор исходного кода Rust: https://github.com/rust-lang/
rust-clippy.
rustfmt – форматирование исходного кода: https://github.com/rustlang/rustfmt.
Документация по менеджеру Cargo: https://doc.rust-lang.org/cargo.
Реестр пакетов Crates.io: https://crates.io.
Книга по языку программирования Rust – глава о Cargo: https://doc.
rust-lang.org/book/ch01-03-hello-cargo.html.
Книга по языку программирования Rust: https://doc.rust-lang.org/book.
Rust на примерах: https://doc.rust-lang.org/stable/rust-by-example.
Сообщество и форумы по Rust: https://users.rust-lang.org.
Вопросы для самопроверки
Вопрос 1. Что из перечисленного лучше всего описывает основной фокус
языка Rust?
a) динамическая типизация
b) защищенность памяти и производительность
c) обфускация исходного кода
d) интерпретируемое исполнение
66 Системное программирование на языке Rust
Вопрос 2. Какова первостепенная цель системы владения в Rust?
a) упрощение сигнатур функций
b) обеспечение работы сборщика мусора на этапе исполнения
c) обеспечение защищенности памяти
d) повышение удобочитаемости исходного кода
Вопрос 3. Какое функциональное средство Rust предотвращает гонки
за доступом к данным в конкурентном программировании?
a) немутируемые переменные
b) потоки исполнения
c) умные указатели Mutex
d) модель владения и общие черты Send/Syn
Вопрос 4. Какое ключевое слово используется в Rust для объявления незащищенных блоков кода?
a) risk
b) uncertain
c) unsafe
d) dangerous
Вопрос 5. Что представляет тип Option в Rust?
a) требуемое значение
b) возможно пустое значение
c) булево значение
d) целочисленное значение
Вопрос 6. Какая конструкция Rust позволяет элегантно обрабатывать
разные случаи или состояния?
a) функции
b) структуры
c) перечисления
d) макрофункции
Вопрос 7. В чем заключается цель сопоставления с шаблоном в Rust?
a) обеспечение правильности исходного кода
b) оптимизация производительности на этапе исполнения
c) сокращение многословности исходного кода
d) обработка сложной условной логики
Вопрос 8. Что такое абстракции с нулевой стоимостью в Rust?
a) высокоуровневые конструкции исходного кода, не влияющие на производительность
b) конструкции, снижающие защищенность ради эффективности
c) конструкции, требующие сложных проверок на этапе исполнения
d) абстракции, создающие накладные расходы на этапе исполнения
Вопросы для самопроверки 67
Вопрос 9. Какие крупные компании внедрили Rust в свои программные
проекты?
a) Amazon
b) Apple
c) Google
d) все вышеперечисленные
Вопрос 10. Что такое FFI и как Rust его поддерживает?
a) это функциональное взаимодействие между функциями, в Rust не поддерживается
b) это интеграция федеративных функций, позволяет языку Rust взаимодействовать с языком Python
c) это интерфейс с внешними функциями; Rust поддерживает его посредством ключевого слова extern, чтобы взаимодействовать с другими языками, такими как C
d) это быстрый вызов функций, который является эксклюзивным для Rust
Вопрос 11. Какое функциональное средство Rust позволяет нескольким час
тям исходного кода получать доступ к данным без взятия их во владение?
a) система владения
b) времена жизни
c) заимствование
d) сопоставление с шаблоном
Вопрос 12. Какие возможности появляются у разработчиков при использовании ключевого слова extern в Rust?
a) объявлять внешние переменные
b) импортировать внешние библиотеки
c) определять внешние функции для интерфейса FFI
d) создавать внешние модули
Вопрос 13. Что из перечисленного лучше всего описывает принятый
в Rust подход к обработке ошибок?
a) блоки try-catch
b) типы Result и Option
c) исключения
d) инструкции goto
Вопрос 14. В чем основное преимущество использования перечислений
в Rust?
a) снижение сложности исходного кода
b) создание бесконечных типов данных
c) представление конечного множества значений
d) устранение необходимости в сопоставлении с шаблоном
68 Системное программирование на языке Rust
Ответы
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
b) защищенность памяти и производительность
c) обеспечение защищенности памяти
d) модель владения и общие черты Send/Sync
c) unsafe
b) пустое значение
c) перечисления
d) обработка сложной условной логики
а) высокоуровневые конструкции исходного кода, не влияющие на производительность
d) все вышеперечисленное
c) это интерфейс с внешними функциями; Rust поддерживает его посредством ключевого слова extern для взаимодействия с другими языками, такими как C
c) заимствование
c) определение внешних функций для интерфейса с внешними функция
ми (FFI)
b) типы Result и Option
c) представление конечного множества значений
Ключевые термины
Владение (ownership): в Rust это система, в которой у каждого значения
есть единственная переменная-владелец. Помогает эффективно управлять памятью и предотвращать ошибки наподобие гонок за доступом
к данным, отслеживая способы использования данных и время, когда
память под них должна быть высвобождена.
Заимствование (borrowing): позволяет частям исходного кода на Rust обращаться к данным без передачи владения. Заимствование может быть
мутируемым или немутируемым, и оно гарантирует, что данные остаются
защищенными и целостными на протяжении всего своего времени жизни.
Время жизни (lifetime): в Rust это способ конкретизировать область доступности или длительность периода, в течение которого ссылки на данные остаются действительными. Оно помогает предотвращать ситуации,
когда ссылки «переживают» данные, на которые они указывают, обеспечивая тем самым защищенность памяти.
Сопоставление с шаблоном (pattern matching): в Rust это функциональное средство, которое позволяет разработчикам сопоставлять значения или структуры с заранее заданными шаблонами, упрощая обработку сложных данных и управляющих конструкций.
Интерфейс с внешними функциями (foreign function interface): это
механизм Rust, который позволяет исходному коду на Rust взаимодействовать с исходным кодом на других языках программирования, таких
как C. Благодаря ему программа на Rust получает возможность задействовать уже существующие библиотеки и системы.
Абстракции с нулевой стоимостью (zero-cost abstractions): это высокоуровневые конструкции исходного кода на Rust, которые не влекут
Ключевые термины 69
за собой никаких потерь производительности на этапе исполнения программы. Компилятор языка Rust оптимизирует их на этапе компиляции,
что позволяет разработчикам использовать высокоуровневые абстракции, не жертвуя при этом эффективностью.
Незащищенный код (unsafe code): в Rust unsafe – это ключевое слово,
используемое для обозначения блоков кода или функций, в которых
гарантии языка Rust по защищенности временно ослаблены. Разработчикам следует использовать unsafe экономно и осмотрительно, поскольку оно позволяет обходить проверки защищенности исходного
кода Rust.
Option и Result<T, E>: это встроенные в Rust типы, используемые для
обработки ошибок и управления пустыми значениями. Тип Option<T>
представляет опциональное/необязательное значение, которое может
быть либо чем-то конкретным (Some(T)), либо ничем (None). В свою очередь, тип Result<T, E> представляет либо успешный результат типа T,
либо ошибку типа E.
Перечисления (enums): в перечислениях Rust задаются конкретно-прикладной тип данных, который может иметь конечное множество значений, причем каждое из них может нести связанные с ним данные. Они
обычно используются для представления различных состояний или вариантов выбора внутри программы.
Конкурентность (concurrency): это способность исполнять несколько
заданий или потоков одновременно. Для защищенной работы с конкурентным программированием в Rust предоставлены такие инструменты, как потоки исполнения и механизмы передачи сообщений.
Гонка за доступом к данным (data race): это ошибка конкурентности,
которая возникает, когда несколько потоков исполнения одновременно
обращаются к коллективным данным и как минимум один из них эти
данные изменяет. Встроенные в Rust система владения и общие черты
Send и Sync предотвращают появление гонок за доступом к данным.
Сборщик мусора (garbage collector): это механизм, используемый в некоторых языках программирования для автоматического истребования
памяти, занятой объектами, которые больше не используются. В Rust
сборщик мусора не применяется.
Бесстрашная конкурентность (fearless concurrency): этот термин отражает особенность Rust, позволяющую разработчикам писать конкурентный исходный код с полной уверенностью в его защищенности
и правильности. Она становится возможной благодаря гарантиям защищенности памяти и мощным встроенным механизмам поддержки конкурентного программирования.
Передача сообщений (message passing): это модель конкурентности,
в которой потоки или процессы исполнения обмениваются друг с другом данными путем отправки и получения сообщений. Rust предоставляет каналы для безопасной передачи сообщений между потоками исполнения.
Переполнение буфера (buffer overflow): это распространенная уязвимость в системе безопасности, при которой данные выходят за границы
70
Системное программирование на языке Rust
выделенного буфера, что потенциально может приводить к повреждению памяти и нарушениям безопасности. Встроенная в Rust система
владения предотвращает переполнение буфера.
Утечка памяти (memory leak): утечка памяти возникает в ситуации,
когда программа не высвобождает память, которая ей больше не нужна,
что приводит к постепенному росту потребления ресурсов. Встроенная
в Rust система владения помогает предотвращать утечки памяти.
Глава 2
Основы Rust
Введение
В стремительно меняющемся ландшафте языков программирования Rust утвердился как мощный игрок, уникальным образом сочетающий в себе производительность и защищенность. Эта глава служит всесторонним введением в фундаментальные концепции, на которых строится программирование
на языке Rust. Мы приступим к обследованию переменных, типов данных,
управляющих конструкций, функций и замыканий, а также подробно рассмот
рим интереснейшие темы управления памятью, владения, заимствования,
времен жизни и знаменитого «контролёра заимствований». К концу этой главы вы не только освоите базовый синтаксис Rust, но и по достоинству оцените
философию языка, ставящую защищенность во главу угла.
Как мы узнали из главы 1 «Системное программирование на языке Rust»,
эволюция языка была продиктована потребностью в более защищенном системном программировании, где проблемы при работе с памятью являются
частым источником ошибок и уязвимостей. Конструктивные решения языка
дают разработчикам возможность писать производительный исходный код,
не жертвуя защищенностью. Одним из ключевых принципов является система
«владения», которая вносит ясность в управление памятью и гарантирует, что
только одна часть исходного кода может изменять данные в конкретный момент времени. Владение – это центральная концепция, которая выделяет Rust
среди других языков, и понимание ее нюансов играет критически важную роль
для написания надежного исходного кода.
Содержание главы
В этой главе будут рассмотрены следующие темы:
введение в переменные и типы данных Rust;
управляющие конструкции с использованием условных инструкций,
циклов и выражения match;
определение функций и работа с замыканиями;
понимание системы владения, заимствования и времен жизни в Rust;
обзор функциональных средств Rust по защите памяти;
написание защищенного исходного кода для работы с памятью с помощью контролёра заимствований.
72
Основы Rust
Переменные и типы данных
В компьютерном программировании на Rust глубокое понимание переменных и типов данных закладывает фундамент для надежного исходного кода.
Вложенная в Rust статическая система типизации, навязывающая строгие
правила, служит щитом против многих распространенных ошибок программирования. Эта система гарантирует, что каждая переменная привязана к конкретному типу данных, что позволяет обнаруживать несоответствия на ранних этапах и сокращать количество аварийных отказов на этапе исполнения
программы. В ходе этого обследования мы подробно рассмотрим спектр типов
данных, начиная с элементарных примитивов, таких как целые числа, числа
с плавающей точкой1 и булевы значения, и перейдя к более сложным типам,
включая строковые данные, массивы и кортежи.
Типы данных
Примитивные
Булев
Непримитивные
Числовой
Символьный
Структура
Массив
Прочие
Интегральный
Целочисленный
Вещественный
Рисунок 2.1. Типы данных в Rust
Введение в переменные
Переменные в Rust выступают в роли фундаментальных строительных блоков,
представляя собой именованные контейнеры, в которых хранятся различные
значения данных и осуществляется управление ими. Их значимость трудно переоценить, так как именно они позволяют писать надежный и выразительный
исходный код. Работа с переменными начинается с ключевого слова let, кото1
В русском языке в качестве математического разделителя принято использовать
запятую (3,14), но в программировании (и в частности, в Rust) всегда используется
точка. В переводе оставлена точка, чтобы не возникло путаницы с исходным кодом. –
Прим. перев.
Переменные и типы данных 73
рое служит «входными воротами» для их создания. На первый план выходит
важнейшее трио: наряду с именем переменной еще тип данных и присваивание начального значения. Приверженность Rust явной типизации не только облегчает понимание исходного кода разработчиком, но и наделяет компилятор
способностью тщательно проверять правильность типов.
Объявление переменной
Имя переменной
Тип данных
Значение
Стек
Рисунок 2.2. Переменные в Rust
Давайте углубимся в эту концепцию на серии наглядных примеров:
Листинг 2.1. Объявление переменных в Rust
let age: u32 = 30;
// ①
let pi: f64 = 3.14159;
// ②
let is_happy: bool = true; // ③
В приведенном выше исходном коде:
① переменная age дебютирует как 32-битное целое число без знака;
② мы представляем pi как 64-битное число с плавающей точкой, захватывающее приближенное значение математической константы;
③ переменная is_happy появляется на сцене как булев тип, принимающий
значения «истина» либо «ложь».
Истинная мощь аннотаций, сопровождающих эти объявления, заключается
в их способности передавать компилятору Rust четкую информацию о типе
данных. Это превентивное действие играет жизненно важную роль в защите
процесса разработки от потенциальных ловушек и ошибок. Однако на этом
путь не заканчивается. Сам акт объявления переменных с помощью ключевого слова let служит иллюстрацией стержневой философии Rust: немутируемости по умолчанию. Этот принцип подталкивает нас к парадигме программирования, которая по своей сути минимизирует использование мутируемого
состояния и связанных с ним рисков.
Понимание переменных и их симбиотической связи с типами данных открывает дверь к созданию исходного кода, который не только отличается
кристальной ясностью, но и устойчив к потенциальным ошибкам. В широком смысле переменные – это важнейший инструмент, пронизывающий саму
ткань логики и вычислений.
74
Основы Rust
Мутируемость
Как упоминалось ранее, переменные в Rust по умолчанию не мутируют. Такой
конструктивный выбор согласуется с главной целью языка Rust – обеспечением защищенного и конкурентного программирования. Немутируемость помогает писать исходный код, менее подверженный ошибкам, и упрощает анализ логики программы. Однако бывают ситуации, когда мутируемость данных
не просто желательна, но и необходима для достижения конкретных целей.
Рассмотрим сценарий, в котором нужно обновить значение переменной
после первоначального присваивания ей значения. На Rust это реализуется
посредством концепции мутируемости. Используя ключевое слово mut, можно
указать, что значение переменной может меняться с течением времени. Это
простое, но мощное функциональное средство одновременно передает намерение изменить переменную и понимание компилятором того, что такие изменения допустимы в данном диапазоне доступности.
Давайте подробно рассмотрим практический пример, чтобы лучше проиллюстрировать эту концепцию:
Листинг 2.2. Мутируемость переменной в Rust
fn main() {
let mut count = 0; // ①
println!("Первоначальный счетчик: {}", count);
count += 1;
// ②
println!("Обновленный счетчик: {}", count);
}
В приведенном выше исходном коде:
① переменная count объявляется как мутируемая с помощью ключевого
слова mut;
② значение переменной count увеличивается на 1, демонстрируя мутируемость в действии.
В этом фрагменте исходного кода переменной count изначально присваивается значение 0. Поскольку count объявлена мутируемой, исходный код может
увеличить ее значение на 1. Такая возможность модифицировать переменные
после их создания демонстрирует гибкий, но контролируемый подход к мутируемости в Rust.
Поддерживая мутируемость через явное объявление, Rust дает нам возможность принимать взвешенные решения о том, когда и где переменные могут изменяться. Этот подход соблюдает баланс между гибкостью и предотвращением
непредвиденных побочных эффектов, что в конечном итоге способствует созданию более надежного и удобного в технической поддержке исходного кода.
Затенение
В процессе написания программ на Rust вы часто будете сталкиваться со сценариями, когда имя переменной нужно использовать повторно для других
целей в том же диапазоне доступности. Здесь в игру вступает концепция затенения. Затенение позволяет объявлять новую переменную с тем же име-
Переменные и типы данных 75
нем, что и у существующей, фактически «заслоняя» изначальную переменную внутри ограниченного диапазона. Такая практика дает несколько преимуществ, включая повышение ясности исходного кода и предотвращение
конфликтов имен.
Внешний диапазон
Внутренний диапазон
Рисунок 2.3. Затенение переменных в разных диапазонах доступности
Рассмотрим следующий ниже пример, чтобы лучше понять суть затенения.
Листинг 2.3. Затенение переменных в Rust
fn main() {
let x = 5;
// ①
println!("Изначальное значение переменной x: {}", x);
{
}
}
//
//
//
//
let x = x + 1; // ②
println!("Затененное значение переменной x: {}", x);
let x = x * 2;
// ③
println!("Затененное и измененное значение переменной x: {}", x);
Вывод:
Изначальное значение переменной x: 5
Затененное значение переменной x: 6
Затененное и измененное значение переменной x: 10
В приведенном выше исходном коде:
76
Основы Rust
① переменной x изначально присваивается значение 5;
② путем затенения переменной x вычисляется новое значение, которое
присваивается тому же имени в другом (вложенном) диапазоне доступности;
③ процесс повторяется, но уже в главном диапазоне доступности, демонстрируя способность механизма затенения изменять значение, связанное с именем.
В этом фрагменте исходного кода переменная x затеняется в разных диапазонах доступности. Каждый раз на основе предыдущего значения переменной x вычисляется новое, и имя x используется повторно для хранения этого
обновленного результата. При этом изначальная переменная x остается нетронутой, что сохраняет немутируемость переменной.
Затенение отличается от мутируемости тем, что оно позволяет изменять
не только значение, но и тип переменной, используя то же имя. Это избавляет
от необходимости избегать потенциальных ловушек, связанных с переназначением типа мутируемой переменной1. Такая практика улучшает удобочитаемость исходного кода и снижает риск появления дефектов, вызванных неожиданными мутациями данных.
По мере углубления в парадигмы программирования на Rust следует помнить, что затенение – это инструмент в вашем арсенале для написания более
чистого и организованного исходного кода. Стратегически реиспользуя имена
переменных в ограниченных диапазонах доступности, вы сможете разрабатывать программы, которые одновременно выразительны и надежны.
Константы
Помимо переменных, Rust предлагает еще одну конструкцию для привязки
значений к именам: константы. Хотя константы похожи на немутируемые
переменные тем, что их нельзя изменять, они обладают рядом отличительных особенностей. Понимание этих различий имеет критическую важность
для принятия взвешенного решения о том, какую конструкцию использовать
в конкретном сценарии.
Прежде всего константы всегда немутируемы, и к ним нельзя применять
ключевое слово mut. Эта немутируемая природа соответствует роли констант
как значений, которые остаются постоянными на протяжении всего времени исполнения программы. Они объявляются с помощью ключевого слова const вместо let, при этом обязательным требованием является явное указание типа значения.
Листинг 2.4. Константа на Rust
const MAX: u32 = 1_000_000; // ①
В этом примере:
1
① объявляется константа с именем MAX и значением 1 000 000.
То есть обходить ограничение, при котором нельзя менять тип уже существующей
мутируемой переменной. – Прим. перев.
Числовые примитивы 77
Существенным отличием констант является то, что они привязаны к константным выражениям. Это означает, что их значения должны быть вычислены
еще на этапе компиляции. Данный принцип гарантирует стабильное и неизменное присутствие констант на протяжении всего «существования» программы. В противовес этому переменные могут получать вычисленные значения
на этапе исполнения, тогда как константы привязаны к заранее определенным
значениям еще до начала исполнения исходного кода.
Константы размещаются в глобальном диапазоне доступности, что делает
их доступными в самых разных частях исходного кода. Это языковое средство полезно, когда нескольким компонентам нужен доступ к одному и тому
же значению. Более того, константы служат инструментом, объясняющим
смысл чисел, «жестко прописанных» в исходный код, другим разработчикам.
Общепринятые правила именования, такие как использование заглавных букв
с подчеркиванием, помогают легко опознавать константы в исходном коде.
Понимание нюансов работы с переменными и константами дает возможность выбирать подходящий механизм привязки в зависимости от нужд программы. Переменные предлагают мутируемость и динамические значения,
а константы обеспечивают стабильность и ясность.
Концепции затенения и констант обогащают инструментарий, позволяя
писать элегантный исходный код, который четко транслирует намерения автора и минимизирует потенциальные ловушки. С помощью затенения можно изящно реиспользовать имена переменных, повышая удобочитаемость
и модульность. Константы же предлагают неизменные значения, которыми
можно делиться между частями программы, помогая создавать цельную и понятную кодовую базу.
По мере углубления в Rust следует всегда помнить: мастерство в управлении
переменными и типами данных – это лишь начало пути к созданию программного обеспечения, которое не только функционально, но и элегантно по своей
конструкции. Объединив эти базовые концепции с нюансами синтаксиса языка Rust, вы откроете массу возможностей, ограниченных только вашим воображением и творчеством. Примите эти вызовы, празднуйте победы, и пусть
Rust ведет вас к мастерству!
Числовые примитивы
Стремление Rust к точности и гибкости проявляется не только в широком
спектре целочисленных типов, но и в поддержке множества базовых математических операций. Сложение, вычитание, умножение, деление и получение
остатка от деления составляют основу математических вычислений и манипуляций с данными в Rust. Погружаясь в тему работы с числами на Rust, давайте
обследуем механизмы и нюансы этих операций, которые позволяют в полной
мере использовать потенциал числовых данных.
Прежде чем переходить к операциям, необходимо четко понимать, какие
числовые типы доступны в языке. Rust предлагает набор числовых типов
фиксированной ширины, которые соответствуют аппаратным реализациям
и обеспечивают различные диапазоны и уровни прецизионности – глубины
точности. Давайте посмотрим на числовые типы и их характеристики.
78
Основы Rust
Фиксированная ширина
Фундамент системы типов в Rust составляют числовые типы фиксированной
ширины, тщательно отобранные, чтобы соответствовать типам, реализованным в большинстве современных процессоров непосредственно на аппаратном уровне. Хотя в определенных случаях в таких типах может возникать переполнение или потеря глубины точности (т. е. знаков после точки), они отлично
подходят для большинства приложений и работают значительно быстрее, чем
такие представления, как целые числа произвольной глубины точности или
точные рациональные числа. Для тех, кому требуются специализированные
числовые представления, предусмотрена поддержка в библиотеке num.
Имена числовых типов в Rust следуют единообразному шаблону, который
включает их ширину в битах (разрядность) и способ представления, как показано в следующей ниже таблице.
Таблица 2.1. Числовые примитивы фиксированной ширины
Размер (бит)
Беззнаковое целое
Знаковое целое
Число с плавающей
точкой
8
u8
i8
–
16
u16
i16
–
32
u32
i32
f32
64
u64
i64
f64
128
u128
i128
–
В рамках этого каркаса под машинным словом понимается величина, соответствующая размеру адреса на устройстве, где выполняются инструкции.
Обычно размер машинного слова составляет 32 либо 64 бита.
Целочисленные типы
Беззнаковые целочисленные типы в Rust охватывают диапазон положительных значений и нуль, как показано в табл. 2.2.
Таблица 2.2. Беззнаковые целочисленные типы
Тип
Диапазон (формула)
Диапазон (значения)
u8
0 до 2^8 – 1
0 до 255
u16
0 до 2^16 – 1
0 до 65 535
u32
0 до 2^32 – 1
0 до 4 294 967 295
u64
0 до 2^64 – 1
0 до 18 446 744 073 709 551 615
u128
0 до 2^128 – 1
0 до ≈ 3.4 × 10^38
usize
Зависит от архитектуры
0 до 2^32 – 1 (32-бит) или 2^64 – 1 (64-бит)
Для знаковых целочисленных типов в Rust используется представление
в виде дополнительного кода с теми же битовыми шаблонами, что и у соответствующих им беззнаковых типов. За счет этого ими можно охватывать как
положительные, так и отрицательные значения (см. табл. 2.3).
Числовые примитивы 79
Таблица 2.3. Знаковые целочисленные типы
Тип
Формула диапазона
Диапазон (значения)
i8
от –2^7 до 2^7 – 1
от –128 до 127
i16
от –2^15 до 2^15 – 1
от –32 768 до 32 767
i32
от –2^31 до 2^31 – 1
от –2 147 483 648 до 2 147 483 647
i64
от –2^63 до 2^63 – 1
от –9 223 372 036 854 775 808
до 9 223 372 036 854 775 807
i128
от –2^127 до 2^127 – 1
примерно от –1.7 × 10^38 до +1.7 × 10^38
isize
Зависит от архитектуры
Диапазон i32 (на 32-бит) или i64 (на 64-бит)
Тип u8 используется в Rust для представления байтовых значений, что делает его незаменимым при работе с двоичными файлами или сетевыми сокетами.
Символы и числовые типы
В отличие от таких языков, как C и C++, Rust отделяет символы от числовых
типов. Тип char не является эквивалентом u8 или u32, даже несмотря на то, что
он занимает 32 бита в памяти. Более подробно тип char рассматривается в разделе «Строки и символы».
Типы usize и isize в языке Rust выполняют задачи, аналогичные типам size_t
и ptrdiff_t в языках C и C++. Их глубина точности соответствует размеру адресного пространства целевой машины: 32 бита на 32-битных архитектурах и 64 бита
на 64-битных архитектурах. Язык Rust требует, чтобы индексы массивов всегда
имели тип usize. Кроме того, размеры массивов, векторов или количество элементов в структурах данных также обычно представляются типом usize.
Целочисленные литералы в языке Rust могут иметь суффикс, указывающий
на их тип. Например, 42u8 – это значение типа u8, а 1729isize – значение типа isize.
Если у литерала отсутствует суффикс, компилятор Rust выводит его тип логически
на основе контекста. Если контексту соответствует несколько типов, компилятор
Rust по умолчанию выбирает i32 (если он входит в число возможных вариантов);
в противном случае компилятор сообщает об ошибке неопределенности.
Для улучшения удобочитаемости в шестнадцатеричных, восьмеричных
и двоичных литералах можно использовать символы подчеркивания. Примеры целочисленных литералов приведены в табл. 2.4.
Таблица 2.4. Числовые литералы
Литерал
Тип
Десятичное значение
Описание
116i8
i8
116
Десятичный литерал с суффиксом
51966u16
u16
51 966
Десятичный литерал с суффиксом
0xcafeu32
u32
51 966
Шестнадцатеричный литерал (0x)
0b0010_1010
Выводится
логически
42
Двоичный литерал (0b)
с разделителем
0o106
Выводится
логически
70
Восьмеричный литерал (0o)
80
Основы Rust
Rust предоставляет байтовые литералы, которые записываются как b'X' и
представляют ASCII-коды символов в виде значений типа u8. Например, и b'A',
и 65u8 представляют ASCII-код символа «A». В байтовых литералах допустимы
только символы ASCII.
Некоторые символы нельзя ставить сразу после одинарной кавычки из-за
неоднозначности или проблем с удобочитаемостью. В таких случаях в Rust
используется подстановочная нотация, начинающаяся с обратной наклонной
черты. Примеры таких символов и их обозначений приведены в табл. 2.5.
Таблица 2.5. Типы символов, их байтовые литералы и числовые эквиваленты
Символ
Байтовый литерал
Числовой эквивалент
Одинарная кавычка (')
b'\''
39u8
Обратная наклонная черта (\)
b'\\'
92u8
Новая строка
b'\n'
10u8
Возврат каретки
b'\r'
13u8
Табуляция
b'\t'
9u8
Используя байтовые литералы формата b'\xHH', где HH – любое двузначное
шестнадцатеричное число, можно представлять байты, значения которых соответствуют этим цифрам. Данный метод оказывается особенно полезным
при работе с управляющими символами ASCII и их представлением в текстовых приложениях.
Оператор as служит средством преобразования между различными целочисленными типами. В следующих ниже разделах приведены примеры, демонстрирующие эти преобразования в действии.
Для обработки символов, требующих особого обращения, используются
экранирующие последовательности с обратной наклонной чертой. Примерами могут служить символьные литералы '\\' и '\n', которые обозначают соответственно обратную наклонную черту и новую строку. Кроме того, вы можете указывать символы, используя Юникод-экранирование как в шестнадцатеричном формате, так и в формате кодовых точек. Например, '\u{CA0}'
соответствует символу «ಠ», а '\u{2A}' – символу «*».
Таблица 2.6. Примеры разных представлений символов Юникода
Символ
'你'
Литерал
Описание
Значение/перевод
'你'
Китайский иероглиф
«Ты»
Японское приветствие
«Конничива» (Добрый день)
'こんにちは'
'こんにちは'
''ابحرم
''ابحرم
Арабское приветствие
«Мархаба» (Привет)
'नमस्त'े
'नमस्त'े
Приветствие на хинди
«Намасте»
'안녕하세요'
'안녕하세요'
Корейское приветствие
«Аннёнасэё» (Здравствуйте)
'Привет'
'Привет'
Русское приветствие
«Привет»
'❄'
'❄'
Эмодзи
Снежинка
'\t'
'\t'
Экранирование (Escape)
Символ табуляции
Числовые примитивы 81
Окончание табл. 2.6
Описание
Значение/перевод
'\u{1F601}'
Символ
‘😁’
Литерал
Юникод-экранирование
😁 (Сияющее лицо
с улыбающимися глазами)
'\u{00A9}'
‘©’
Юникод-экранирование
© (Символ авторского
права)
'\u{2600}
'☀'
Эмодзи
Солнце
Тип char в Rust предлагает большое количество методов идентификации
и преобразования символов. Эти методы позволяют определять, является
ли данный символ буквой, цифрой или даже пробельным символом. Кроме того,
используя функции to_ascii_lowercase и to_ascii_uppercase, можно с легкостью
преобразовывать символы соответственно в их нижний или верхний регистр.
Строки и символы
Тип char в Rust бесшовно интегрируется со строковыми значениями, или строками, позволяя выполнять сложные конвертации и манипуляции. Рассмот
рим сценарий, в котором необходимо перевести все символы в предложении
в верхний регистр:
Листинг 2.5. Пример работы со строковыми данными на Rust
let sentence = "Rust – это круто!";
// sentence имеет тип &str (ссылка на статический литерал в памяти программы)
// transformed_sentence имеет тип String (в динамической куче)
let transformed_sentence: String = sentence.chars()
.flat_map(|s| s.to_uppercase()) // ①
.collect();
println!("Результат: {}", transformed_sentence);
// Вывод:
// Результат: RUST – ЭТО КРУТО!
В этом примере:
① конвертация символов переменной типа String в верхний регистр.
В приведенном выше примере компилятор переводит все символы предложения в верхний регистр, демонстрируя возможности типа char при использовании совместно с функциями манипулирования строковыми данными.
Итерация по символам и операции
со строковыми значениями
Различие между типами String и &str в Rust играет ключевую роль в управлении памятью и семантике владения. Передача типа &str внутри программы
невероятно эффективна, так как она требует минимальных затрат на выделение памяти и позволяет избегать копирования данных в памяти. Главное отличие заключается во владении: &str – это заимствованный тип, по сути, представляющий данные только для чтения, а String – это владеемый тип, предо-
82
Основы Rust
ставляющий динамическую способность чтения и записи. Если рассматривать
практическую сторону, то тип String состоит из трех компонентов: указателя
на область памяти с содержимым строковых данных, длины и емкости. Важно отметить, что выделение памяти под String происходит в куче, что обеспечивает гибкость размера. С другой стороны, ссылки на эти данные, коими
и являются &str, располагаются в секции данных двоичного файла или в стеке. Это различие подчеркивает компромисс между владением и эффективностью: тип &str служит облегченной ссылкой с правом только чтения, а тип
String обеспечивает мутируемое хранилище в куче со своим набором атрибутов. Понимание этой двойственности играет ключевую роль для ориентации
в вопросах управления памятью и владения в программах.
Помимо преобразований, тип char также упрощает итерацию внутри строковых данных. Символы внутри строковых данных можно прокручивать
в цикле с помощью метода-итератора chars(), что позволяет выполнять мелкозернистые операции на каждом отдельном символе.
Более того, тип char позволяет нарезать строковые данные, указывая не байтовые смещения, а индексы символов, благодаря чему символы остаются целостными даже в многобайтовых кодировках, таких как UTF-8.
Начиная свой путь в обработке текста, следует помнить, что тип char в Rust –
ваш верный спутник. Этот помощник позволяет лепить, преобразовывать и рисовать, используя всю палитру символов Юникода. От простых задач, таких как
извлечение первого символа, до сложных преобразований больших текстов –
тип char всегда готов помочь в ваших лингвистических и творческих изысканиях.
Для получения более полной информации о числовых типах фиксированной ширины, целочисленных литералах и смежных темах обратитесь к официальной документации языка Rust. Она содержит подробные сведения о каждом типе и связанных с ними методах, что поможет вам в освоении числовых
примитивов Rust.
Использование числовых конвертаций
Хотя система типов языка Rust разработана для предотвращения случайных
ошибок типизации, возникают ситуации, когда необходимо выполнять конвертацию между числовыми типами в явной форме. Понимание того, как точно выполнять эти конвертации, имеет решающее значение. Давайте углубимся в механизмы числовых конвертаций и рассмотрим практические примеры.
Оператор as, предлагаемый Rust для числовых конвертаций, позволяет конвертировать различные целочисленные типы и типы с плавающей точкой.
Ниже приведено несколько примеров.
Листинг 2.6. Приведение целочисленного типа
к типу чисел с плавающей точкой
fn main() {
let integer_number: i32 = 32;
let float_number: f64 = integer_number as f64; // ①
println!("Преобразованное число с плавающей точкой: {}", float_number);
}
Использование числовых конвертаций 83
В приведенном выше исходном коде:
① мы конвертируем целочисленное значение (32) в значение с плавающей
точкой, используя оператор as. Переменная float_number будет содержать
конвертированное значение.
Крайне важно осознавать, что конвертация чисел может приводить к неточным результатам или проблемам с переполнением. Система типов языка Rust
была создана с учетом защитных мер для таких конвертаций, однако понимание этих ограничений остается жизненно важным. Кроме того, Rust позволяет использовать явные аннотации типов, чтобы прояснять ваши намерения
во время конвертаций. Например:
Листинг 2.7. Приведение типа чисел с плавающей
точкой к целочисленному типу
fn main() {
let float_number: f64 = 3.14;
let truncated_integer: i32 = float_number as i32; // ①
println!("Усеченное целое число: {}", truncated_integer);
}
В приведенном выше исходном коде:
① число с плавающей точкой 3.14 конвертируется в целое число, что приводит к отбрасыванию дробной части и получению результата 3.
Понимая нюансы числовых конвертаций, вы сможете обеспечивать точную
и эффективную работу исходного кода на Rust с разными типами данных.
Арифметические операции
Поддерживаемые в Rust арифметические операции являются неотъемлемой
частью повседневных задач программирования. Эти операции доступны для
всех числовых типов, включая целые числа и числа с плавающей точкой. Они
позволяют выполнять вычисления, формулировать алгоритмы и решать задачи в самых разных областях.
Сложение
Деление
Арифметические
операции
Вычитание
Рисунок 2.4. Арифметические операции
Умножение
84
Основы Rust
Сложение
Сложение – это арифметическая операция, представляющая собой объединение двух значений для получения их суммы. В Rust оператор сложения используется следующим образом:
Листинг 2.8. Сложение на Rust
fn main() {
let sum = 3 + 7;
// sum теперь содержит значение 10
}
В приведенном выше фрагменте исходного кода значения 3 и 7 складываются с помощью оператора +. Результирующая сумма (10) присваивается переменной sum.
Таблица 2.7. Сложение для разных типов данных
Пример
Выражение
Результат
Целочисленное сложение
3 + 7
10
Сложение чисел с плавающей точкой
3.14 + 1.1
4.24
Смешанное сложение
4 + 1.5
5.5
Вычитание
Вычитание – это важная арифметическая операция, которая определяет разность между двумя числовыми величинами. Рассмотрим пример этого процесса:
Листинг 2.9. Вычитание на Rust
fn main() {
let difference = 92.5 - 1.3;
// difference теперь содержит значение 91.2
}
В данном фрагменте исходного кода значение 1.3 вычитается из 92.5 с помощью оператора –. Результирующая разность (91.2) присваивается переменной difference.
Умножение
Умножение предусматривает объединение значений для получения произведения. Вот как использовать оператор умножения на Rust:
Листинг 2.10. Умножение на Rust
fn main() {
let product = 3 * 30;
// product теперь содержит значение 90
}
Использование числовых конвертаций 85
Символ * представляет операцию умножения. При умножении значений 3
и 30 получается результат 90, который затем присваивается переменной с именем product.
Таблица 2.8. Умножение разных типов данных
Пример
Выражение
Результат
Целочисленное умножение
5*8
40
Умножение чисел с плавающей точкой
2.5 * 3.0
7.5
Смешанное умножение
4 * 1.5
6.0
Примечание: как и в случае со сложением, в реальном исходном коде Rust
для смешанного умножения потребуется явное приведение типов (например, 4 as f64 * 1.5).
Деление
Деление разбивает значение на равные части. Ниже представлена иллюстрация деления на Rust:
Листинг 2.11. Деление на Rust
fn main() {
let quotient = 56.7 / 32.2;
// quotient теперь содержит приближенное значение 1.7608695652173911
}
Оператор / представляет операцию деления. В данном фрагменте исходного
кода значение 56.7 делится на 32.2, в результате чего получается частное, приближенно равное 1.7608695652173911, которое присваивается переменной quotient.
Взятие остатка от деления
Операция взятия остатка возвращает значение, оставшееся после деления нацело. Ниже представлен пример использования оператора остатка на Rust:
Листинг 2.12. Вычисление остатка на Rust
fn main() {
let remainder = 43 % 5;
// remainder теперь содержит значение 3
}
Оператор % вычисляет остаток от операции деления. В приведенном выше
примере 43 делится на 5, что дает остаток 3, который присваивается переменной remainder.
Внедрение этих базовых арифметических операций в исходный код на Rust
открывает огромный спектр возможностей. От реализации алгоритмов до анализа данных – эти операции служат строительными блоками для создания
сложных вычислительных решений. Понимая и осваивая эти операции, вы вооружаетесь инструментами, необходимыми для навигации в числовой среде
Rust и воплощения ваших программных идей в жизнь.
86
Основы Rust
Кроме того, библиотека rug в Rust является мощным подспорьем для выполнения сложных числовых вычислений. Специально разработанная для
арифметических операций произвольной глубины точности, rug предоставляет универсальный набор инструментов для работы с числами, выходящими за пределы стандартных типов. Ее наличие в экосистеме Rust подчеркивает стремление языка к решению сложных вычислительных задач, предлагая
обширную функциональность для манипулирования, вычисления и анализа
чисел с большой глубиной точности. Будь то сложные математические алгоритмы, криптография или научные вычисления, библиотека rug остается надежным инструментом, позволяющим уверенно и эффективно справляться
со сложными численными сценариями. Ее обширный комплект функциональных средств делает ее ценным ресурсом для тех, кто работает в области высокопрецизионных вычислений на Rust.
Числа с плавающей точкой
В сфере вычислений числа с плавающей точкой представляют собой идеальный баланс между глубиной точности (количеством знаков после запятой)
и аппроксимацией (приближением). Типы f32 и f64 в Rust занимают центральное место, предлагая различные уровни точности для ваших вычислительных
процедур.
В Rust предоставлено два главных типа с плавающей точкой: f32 и f64. Эти
типы соответствуют стандарту IEEE-754, который принят в современных вычислительных системах для представления вещественных чисел1.
f32 для эффективности
Тип f32, также известный как «одинарная глубина точности по стандарту
IEEE», занимает 32 бита в памяти. Он предлагает компромисс между глубиной
точности и использованием памяти, благодаря чему подходит для сценариев, где критически важна эффективность вычислений. Несмотря на меньшую
глубину точности по сравнению с f64, тип f32 способен точно представлять
широкий диапазон значений.
32 бита
Знак
Экспонента
1 бит
8 бит
Мантисса
23 бита
Стандарт чисел с плавающей точкой IEEE-754
одинарной глубины точности
Рисунок 2.5. Стандарт чисел с плавающей точкой IEEE-754 одинарной глубины точности
1
Составители Wikipedia (1 декабря 2023). IEEE 754. Wikipedia. https://en.wikipedia.org/
wiki/IEEE_754.
Числа с плавающей точкой 87
Ниже представлен пример использования типа f32 в Rust:
Листинг 2.13. Литералы типа f32 в Rust
let distance_f32: f32 = 123.456;
let velocity: f32 = 12.34;
let temperature: f32 = -5.67;
Тип f32 проявляет себя наилучшим образом в таких сценариях, как обработка графики или симуляции в реальном времени, где скорость вычислений
имеет первостепенное значение. Его упрощенное представление способствует
быстроте вычислений, что делает его ценным ресурсом в областях, критичных
к производительности.
f64 для глубины точности
В то время как f32 обеспечивает эффективность, тип f64 (64-битный тип с плавающей точкой) выступает в роли настоящего «маэстро глубины точности».
В этом типе используется 64-битное представление, что позволяет ему захватывать сложнейшие детали разных математических операций. Будь то научные симуляции, раскрывающие тайны космоса, или сложное финансовое моделирование – глубина точности f64 имеет решающее значение1.
64 бита
Знак
Экспонента
1 бит
11 бит
Мантисса
23 бита
Стандарт чисел с плавающей точкой IEEE-754
двойной глубины точности
Рисунок 2.6. Стандарт чисел с плавающей точкой IEEE-754 двойной глубины точности
Давайте рассмотрим способности типа f64 на примере следующего ниже
фрагмента исходного кода.
Листинг 2.14. Литералы типа f64 в Rust
let pi_approximation: f64 = 3.141592653589793;
let gravitational_constant: f64 = 6.67430e-11;
Тип f64 служит эталоном точности воспроизведения в приложениях, где
критически важна глубина точности, таких как научные исследования, инженерные симуляции и финансовые расчеты.
1
Составители Wikipedia (16 ноября 2023). Формат чисел с плавающей запятой двойной
точности. Wikipedia. https://en.wikipedia.org/wiki/Double-precision_floating-point_format.
88
Основы Rust
Литералы чисел с плавающей точкой
В Rust можно гибко выражать литералы чисел с плавающей точкой как в стандартной десятичной, так и в научной экспоненциальной нотации1. В научной
нотации для обозначения порядка (степени десятки) используется буква e.
Ниже представлен краткий пример:
1.23e4 означает 12300.0;
2.5e-3 означает 0.0025.
В математике дробные числа обычно записываются с использованием десятичной точки. Этот символ служит разделителем дробной части числа, упрощая как понимание, так и процесс вычислений. Рассмотрим примеры:
3.14 представляет число π (пи);
–0.5 представляет отрицательную половину.
Ниже приведены примеры литералов с плавающей точкой и соответствующие им способы записи на Rust.
Таблица 2.9. Литералы в Rust и их эквивалентные представления
Литерал
Десятичная
нотация
Научная
нотация
Присваивание значения
типа f32
Присваивание значения
типа f64
3.14
3.14
–
let a_f32: f32 = 3.14;
let a_f64: f64 = 3.14;
2.0
2.0
–
let b_f32: f32 = 2.0;
let b_f64: f64 = 2.0;
6.
6.0
–
let c_f32: f32 = 6.;
let c_f64: f64 = 6.;
1.5e3
1500.0
1.5e3
let d_f32: f32 = 1.5e3;
let d_f64: f64 = 1.5e3;
1e-2
0.01
1e-2
let e_f32: f32 = 1e-2;
let e_f64: f64 = 1e-2;
В представленной выше таблице продемонстрированы разные литералы
чисел с плавающей точкой и соответствующие им формы записи. Каждый литерал приведен вместе с исходным кодом на Rust, который присваивает это
значение переменной, имеющей тип чисел с плавающей точкой. Данная таб
лица служит удобным справочником представлений величин разного порядка
и глубины точности.
Математические операции
Типы данных f32 и f64 предлагают широкий спектр арифметических и математических функций, включая сложение, вычитание, умножение, деление,
а также вычисление квадратного корня.
Эти операции легко исполняются с использованием стандартных арифметических операторов (+, -, *, /) в сочетании с обширными библиотечными
функциями Rust.
Ниже приведен пример выполнения вычислений с использованием переменных типов f32 и f64.
1
Литеральные выражения – Справочник по языку Rust (дата не указана). https://doc.
rust-lang.org/reference/expressions/literal-expr.html.
Числа с плавающей точкой 89
Листинг 2.15. Математические операции
с переменными типов f32 и f64 в Rust
let radius_f32: f32 = 10.0;
let area_f32 = 3.14 * radius_f32 * radius_f32;
let radius_f64: f64 = 10.0;
let area_f64 = std::f64::consts::PI * radius_f64 * radius_f64;
В данном примере мы определяем площадь окружности, задействуя варианты типов f32 и f64. Точное значение числа π (пи) представлено с максимально возможной достоверностью благодаря привлечению константы std::f64::consts::PI.
Ориентация в сложностях
За внешней простотой глубины точности чисел с плавающей точкой скрываются определенные сложности. Несмотря на свою универсальность и мощь,
такие числа иногда могут давать неожиданные результаты из-за особенностей
их двоичного представления.
Рассмотрим вот этот пример:
Листинг 2.16. Проблема глубины точности двоичного
представления чисел с плавающей точкой
let tricky_number: f64 = 0.1 + 0.1 + 0.1; // Вывод: 0.30000000000000004
let expected_number: f64 = 0.3;
if tricky_number == expected_number {
println!("Они равны, верно?");
} else {
println!("Не совсем равны из-за крошечных расхождений.");
}
В приведенном выше фрагменте исходного кода простая на первый взгляд
работа по сравнению двух чисел выявляет любопытную особенность. Несмот
ря на ожидаемое равенство двух чисел, погрешности двоичной аппроксимации могут вносить едва заметные расхождения. При работе с числами с плавающей точкой эти нюансы важно учитывать, чтобы предотвращать неожиданное поведение вычислений.
Приближенное равенство
Может показаться, что сравнивать числа с плавающей точкой довольно просто,
но на практике это напоминает попытку поймать светлячков летней ночью.
Из-за погрешностей двоичных аппроксимаций определение равенства превращается в задачу со множеством нюансов.
Тем не менее Rust предоставляет инструменты для навигации в этой области. Ниже представлен пример конкретно-прикладной функции проверки
приближенного равенства:
90 Основы Rust
Листинг 2.17. Решение проблемы глубины точности
двоичного представления через аппроксимацию
fn approx_equal(a: f64, b: f64, tolerance: f64) -> bool {
(a - b).abs() < tolerance
}
fn main() {
let value_a: f64 = 0.1 + 0.1 + 0.1;
let value_b: f64 = 0.3;
let tolerance: f64 = 1e-10;
if approx_equal(value_a, value_b, tolerance) {
println!("Приближенно равны в пределах допустимой погрешности.");
} else {
println!("Не равны даже с учетом погрешности.");
}
}
// Вывод:
// Приближенно равны в пределах допустимой погрешности.
Вложив значение погрешности, вы щадяще обходите проблему расхождений в числах с плавающей точкой и обеспечиваете достоверность результатов
своих сравнений.
Освоение чисел с плавающей точкой
Искусство работы с числами с плавающей точкой подобно хождению по канату, балансируя между глубиной точности и ограничениями двоичного представления. Хотя эти числа обладают огромной вычислительной мощью, они
также создают уникальные вызовы.
Следует всегда помнить: для того чтобы стать «маэстро» в сфере чисел с плавающей точкой, требуется сочетание практики и глубокого понимания их нюансов. И не важно, что вы используете – быстрый тип f32 ради эффективности
или мощный тип f64 для захватывания мельчайших деталей, – Rust дает все
необходимое, чтобы уверенно выполнять свои вычисления.
Числа с плавающей точкой – это «мастера» численных расчетов, позволяющие обследовать огромный диапазон значений с изяществом и достоверностью. Освоив нюансы типов f32 и f64, можно достичь высокой глубины точности в научных вычислениях и симуляциях, что позволяет моделировать сложные явления реального мира с поразительной детализацией и надежностью.
Булевы значения
В сложном компьютерном программировании булевы значения играют важнейшую роль, выступая в качестве «хореографа» логики. Именно они отвечают за упорядоченное управление процессом принятия решений. Булевы значения элегантно представлены в языке Rust типом bool; он воплощает в себе
суть двоичного выбора, ограничивая возможные значения лишь вариантами
«истина» либо «ложь». Несмотря на кажущуюся простоту, эти маркеры име-
Булевы значения 91
ют огромное значение, когда дело доходит до виртуозного управления такими
конструкциями, как условные инструкции и циклы, в рамках сложной логики
программы. Давайте подробнее разберем, как именно это происходит!
Переменная
is_authorized
истинна?
Да
Предоставить
доступ
Нет
Отказать
в доступе
Рисунок 2.7. Булевы значения и управление логикой программы
Булевы значения подобны диспетчерам процесса принятия решений в цифровой сфере. Они воплощают саму суть логического выбора. В Rust тип bool
служит примером этой сути – это универсальный «дирижер», направляющий
переток логики. Представьте, что булевы значения отвечают на такие вопросы, как «Должен ли выполняться этот блок кода?» или «Соблюдено ли это условие?». Рассмотрим сценарий, в котором работа программы наталкивается
на авторизацию пользователя:
Листинг 2.18. Булевы значения в Rust
let is_authorized: bool = check_authorization(username, password);
if is_authorized {
// Предоставить доступ
} else {
// Отказать в доступе
}
В данном случае булева переменная is_authorized является ключом к доступу пользователя. Если она истинна (true), то предоставить доступ; если ложна
(false) – отказать в доступе. Такая взаимная игра булевых значений демонстрирует их ключевую роль в программной логике.
Одной из примечательных особенностей Rust является его статическая типизация. Булевы значения в Rust идеально воплощают эту концепцию. После
объявления переменной как bool Rust гарантирует, что она сможет принимать
только значения true либо false, не оставляя места для двусмысленности или
путаницы. Эта целостная и строгая система типизации выступает в качестве
защиты от логических ошибок, обеспечивая стабильность логики вашей программы.
92 Основы Rust
Булевы значения в своем двоичном изяществе выступают в роли «маэстро»
логики, дирижируя симфонией принятия решений в исходном коде. Их сила
заключается в простоте, а влияние – в универсальности. Они образуют фундамент, позволяющий создавать сложные программы, которые динамично реагируют на меняющиеся условия.
Логические операторы, такие как && (И) и || (ИЛИ), позволяют комбинировать булевы значения, чтобы формировать сложные условия. Ниже приведен
пример, иллюстрирующий, как логические операторы могут создавать разветвленные деревья решений:
Листинг 2.19. Логические операторы в Rust
let is_raining: bool = check_rain_status();
let is_cold: bool = check_temperature();
if is_raining && is_cold {
take_umbrella_and_coat(); // Взять зонт и пальто
} else if is_raining || is_cold {
prepare_for_weather();
// Подготовиться к погоде
} else {
enjoy_sunny_day();
// Наслаждаться солнечным днем
}
В этом сценарии булевы переменные is_raining (идет дождь) и is_cold (холодно) помогают решить, как подготовиться к погоде. Использование операторов && и || позволяет выстраивать ветвящуюся логику на основе нескольких
условий. Здесь булевы значения координируют сложный процесс принятия
решений.
Булевы значения также можно комбинировать с помощью оператора логического НЕ (!), чтобы инвертировать их значения. Это бывает полезно в сценариях, когда нужно проверить отсутствие определенного условия:
Листинг 2.20. Логическое отрицание в Rust
let has_no_errors: bool = !check_errors();
if has_no_errors {
execute_task(); // Исполнить задание
} else {
handle_errors(); // Обработать ошибки
}
Инвертируя результат функции check_errors(), мы получаем булево значение has_no_errors. Оператор отрицания, обозначаемый символом !, позволяет
быстро и легко определять отсутствие ошибок.
Булевы значения в Rust также используются для создания циклов, что еще
больше усиливает их роль в управлении потоком исполнения программы. Рассмотрим пример цикла, который продолжает исполняться, пока соблюдается
определенное условие:
Булевы значения 93
Листинг 2.21. Булевы значения в циклах
let mut is_running: bool = true;
while is_running {
perform_iteration(); // Выполнить итерацию
is_running = check_continue_condition(); // Проверить условие продолжения
}
Здесь переменная is_running определяет, будет цикл продолжать исполнение или нет. Цикл продолжает итерации, пока значение переменной is_
running остается истинным (true). Булевы значения обеспечивают динамический способ управления поведением цикла.
Помимо использования в условных инструкциях и циклах, булевы значения незаменимы при создании конечных автоматов, где различные состояния
обусловливают поведение программы. Рассмотрим следующий ниже пример
симуляции торгового автомата:
Листинг 2.22. Использование булевых значений для выбора состояний в Rust
enum VendingMachineState {
Idle,
// Ожидание
SelectingItem, // Выбор товара
DispensingItem, // Выдача товара
RefundingCoins, // Возврат монет
}
fn main() {
// Начальное состояние торгового автомата
let mut current_state = VendingMachineState::Idle;
loop {
match current_state {
VendingMachineState::Idle => {
// Ожидание внесения монет или выбора
}
VendingMachineState::SelectingItem => {
// Обработка выбора товара пользователем
}
VendingMachineState::DispensingItem => {
// Процесс выдачи выбранного товара
}
VendingMachineState::RefundingCoins => {
// Возврат средств при отмене или ошибке
}
}
// Обновление состояния на основе условий
current_state = update_state(current_state);
// Здесь должно быть условие выхода из loop
}
}
94
Основы Rust
В приведенном выше примере перечисление VendingMachineState содержит
разные состояния торгового автомата. Поведение программы зависит от текущего состояния, которое управляется с помощью булевых значений и условной логики. Тип bool играет жизненно важную роль в определении переходов
внутри этого конечного автомата.
Следует помнить: овладевая силой булева типа, вы осваиваете искусство
управления логикой, создавая сложные шаблоны, которые обусловливают поведение исходного кода. Будь то простые условия, сложные конечные автоматы, разветвленные деревья решений, динамические циклы или состояния
программ – булевы значения остаются верными спутниками в компьютерном
программировании.
Сложные типы данных
По мере перехода от строительства простого фундамента к более сложным
каркасам вы откроете для себя набор мощных ресурсов, именуемых сложными
типами данных. Эти инструменты наделяют вас способностью конструировать
программы, которые не только изящны, но и достаточно универсальны в решении самых разнообразных задач.
Строковые значения
Строковые значения, или строки, подобны контейнерам для слов и предложений; они необходимы для обмена данными во многих приложениях. Тип String
в Rust выводит идею таких контейнеров на новый уровень, позволяя им изменять свой размер и содержимое. Представьте себе сумку, которая может растягиваться и сжиматься, когда вы кладете в нее вещи или вынимаете их. Это
динамическое качество особенно полезно при работе с сообщениями, текстом
от пользователей или в любой ситуации, где длина текста заранее неизвестна.
Одной из замечательных особенностей типа String является его способность расти и уменьшаться по мере необходимости. В отличие от коробок фиксированного размера, контейнеры значений типа String могут менять свои
габариты. Взгляните на приведенный ниже пример в действии:
Листинг 2.23. Строковые значения в Rust
let mut user_greeting = String::from("Привет"); // ①
user_greeting.push_str(", добро пожаловать в Rust!"); // ②
В приведенном выше исходном коде:
① переменная user_greeting начинается с дружелюбного приветствия
«Привет»;
② используя функцию push_str, к исходному сообщению можно добавить
дополнительный текст. В результате итоговое сообщение выглядит так:
"Привет, добро пожаловать в Rust!".
Этот пример наглядно показывает, как Rust справляется с динамическими
изменениями размера текстового содержимого.
Сложные типы данных 95
Стандартная библиотека Rust предлагает целый арсенал инструментов,
упрощающих работу со строковыми данными. От поиска конкретных слов
до изменения регистра текста – эти функции берут на себя рутинные аспекты, позволяя сосредоточиваться на более масштабных целях программы. Используя такие утилиты, вы экономите время и достигаете более эффективных
результатов. Например, давайте узнаем, как подсчитать частоту появления
конкретного слова в предложении:
Листинг 2.24. Работа со строковыми значениями в Rust
let sentence = "Rust – это язык программирования. Rust – мощный язык.";
let target_word = "Rust";
let count = sentence.matches(target_word).count();
println!("Слово '{}' встречается {} раза.", target_word, count);
// Вывод:
// Слово 'Rust' встречается 2 раза.
Функция matches в приведенном выше исходном коде задействуется для
локализации каждого вхождения искомого термина в предложении, а операция count подсчитывает общее количество таких вхождений. Эта функцио
нальность служит впечатляющей демонстрацией способностей Rust манипулировать строковыми данными и упрощать сложные процессы.
Подстроки
Нарезка строковых значений в Rust позволяет легко извлекать их части. Это
похоже на нарезку торта на маленькие кусочки. Рассмотрим следующий ниже
пример:
Листинг 2.25. Подстроки в Rust
let sentence = "Rust – это одно удовольствие!";
let first_word = &sentence[0..4]; // Берем первые 4 символа
println!("Первое слово: {}", first_word);
При работе со строковыми данными бывает полезно извлекать определенные
части текста. Именно здесь на помощь приходит конструкция &sentence[0..4] –
создав срез значения строковой переменной sentence начиная с индекса 0
вплоть до индекса 3 (первые четыре символа), на выходе получаем "Rust". Это
мощный инструмент манипулирования и анализа значений внутри крупных
строковых данных.
Комбинирование строковых значений
В Rust предоставлены разные способы комбинирования строковых данных
в зависимости от конкретных нужд. Можно использовать конкатенацию,
чтобы объединять несколько строковых литералов, либо интерполяцию,
чтобы вставлять значения внутрь строковых литералов. Давайте рассмот
рим оба способа:
96
Основы Rust
Листинг 2.26. Комбинирование строковых значений в Rust
let first_name = "Махмуд";
let last_name = "Хармуш";
// Способ 1: конкатенация посредством оператора +
let full_name = first_name.to_string() + " " + last_name;
println!("Полное имя: {}", full_name);
let age = 25;
// Способ 2: интерполяция посредством макрофункции format!
let message = format!("{}у {} лет.", first_name, age);
println!("{}", message);
// Вывод:
// Полное имя: Махмуд Хармуш
// Махмуду 25 лет.
В приведенном выше исходном коде сначала задействуется конкатенация,
чтобы сгенерировать экземпляр типа String с полным именем, а затем используется макрофункция format!, чтобы выполнить интерполяцию значений
внутри сообщения. Используя эти методы, можно с легкостью и гибкостью
конструировать сложные строковые данные.
В компьютерном программировании на Rust погружение в сложные типы
данных, такие как строковые значения и векторы, подобно освоению продвинутых инструментов в вашем арсенале. Оно предусматривает умение работать
с адаптивными контейнерами – мощными инструментами, которые берут
на себя выполнение сложной работы, – и методиками обработки разнообразных наборов данных. По мере совершенствования этих умений у вас появятся
новые подходы к решению запутанных задач и разработке более отказоустойчивых приложений. Имея твердое понимание этих продвинутых принципов,
вы будете готовы двигаться дальше в своем обследовании компьютерного программирования на Rust, глядя на вещи под новым углом зрения.
Массивы
Массивы – это фундамент коллекционных структур данных. Они строятся
на принципе однородности, объединяя в себе элементы строго одинаковых
типов. Эти конструкции имеют фиксированный размер и особенно эффективны, когда количество элементов остается неизменным, что гарантирует
целостность памяти. Массивы в Rust являются образцом лаконичности и производительности.
Представьте оркестр, исполняющий симфонию: каждый инструмент вносит
свой уникальный звук, и при этом все они гармонично сливаются воедино.
Точно так же и массив в программировании – он подобен набору элементов,
имеющих один и тот же тип данных. Эта однородность гарантирует предсказуемое поведение каждого элемента, упрощая работу с ними.
Массивы в Rust воплощают эту концепцию, строго следя за тем, чтобы каждый элемент внутри них соответствовал заданному типу данных. Такое структурное единообразие способствует эффективности массивов с точки зрения
как использования памяти, так и скорости доступа.
Сложные типы данных 97
Преимущества фиксированного размера
Массивы незаменимы в ситуациях, когда количество элементов фиксировано и известно заранее. Их неизменный размер дает преимущество в эффективном управлении памятью. Представьте массивы как ряд отсеков, каждый
из которых предназначен для хранения определенного предмета. Стремление
языка Rust к защищенности и производительности распространяется и на
массивы, гарантируя предотвращение утечек памяти и предоставляя четкую
схему ее распределения. Такая предсказуемость особенно полезна при разработке приложений, требующих точного распределения ресурсов.
let temperatures: [f64; 7] = [23.5, 25.1, 21.8, 20.7, 22.3, 24.9, 26.6];
В приведенном выше примере мы определяем массив с именем temperatures типа [f64; 7], представляющий семь значений с плавающей
точкой. Каждое значение соответствует показателю температуры, наглядно
иллюстрируя предсказуемость использования памяти массивами.
Синтаксис и объявление
Прежде чем погружаться в увлекательную тему массивов Rust, давайте
рассмотрим простое объявление:
let fibonacci: [u32; 5] = [0, 1, 1, 2, 3];
В этом фрагменте исходного кода создается переменная fibonacci, содержащая массив 32-битных целых чисел без знака, определенный как [u32; 5]. Точка с запятой в конце каждой строки указывает на завершение инструкции –
это базовый синтаксис Rust. Такой краткий, но выразительный стиль повышает ясность при определении массивов.
Разберем синтаксис подробнее:
let fibonacci: инициирует объявление переменной-массива;
[u32; 5] указывает, что это массив, содержащий 32-битные целые числа
без знака, длиной 5 элементов;
= [0, 1, 1, 2, 3]; инициализирует массив предоставленными значениями.
Строгая система типов Rust гарантирует, что в этот массив могут быть вставлены только значения указанного типа. Такой защитный механизм обеспечивает единообразие типов и способствует стабильности и предсказуемости исходного кода.
Надежность в сложных условиях
Стремление Rust к защищенности памяти и производительности отчетливо
проявляется в работе с массивами. В средах с ограниченными ресурсами, таких как встроенные системы или высокопроизводительные приложения, массивы Rust показывают себя с лучшей стороны. Они обеспечивают надежность
распределения памяти фиксированного размера, защищая при этом от уязвимостей при работе с памятью. Благодаря задействованию массивов программа
остается отказоустойчивой и эффективной даже при решении сложных вычислительных задач.
98 Основы Rust
let stock_prices: [f32; 10] = [
50.2, 52.6, 51.0, 53.2, 49.8,
48.5, 51.7, 53.9, 50.0, 49.2
];
На этот раз мы объявляем массив с именем stock_prices, представленный
как [f32; 10]. Каждый элемент здесь отражает цену акции за определенный
период, демонстрируя универсальность массивов в различных областях.
Массивы – это не просто хранилище; они наделяют разработчиков шаблонами эффективного решения задач. При использовании массивов прокручивание их элементов в цикле или применение пакетных операций становится естественным. Массивы в Rust предоставляют холст для вычислительного
творчества, где ограничения лишь стимулируют поиск инновационных решений. Предсказуемость фиксированных размеров гарантирует, что ваши творения останутся хорошо структурированными и управляемыми.
Массивы – это универсальные инструменты в арсенале программиста, предлагающие двойную выгоду: эффективность использования памяти и предсказуемость. И не важно, над чем вы работаете: над компактными встроенными системами или высокопроизводительными алгоритмами, массивы Rust
обеспечивают прочный фундамент для разработки эффективного и надежного исходного кода. Так что примите массивы как своих надежных спутников
на пути к вершинам мастерства программирования!
Кортежи
Кортежи – проводники универсальности – взаимоувязывают значения разных
типов в гармоничные ансамбли. В отличие от массивов, которые обеспечивают
единообразие, кортежи поощряют разнообразие, принимая элементы разных
типов данных. Кортеж формируется путем его определения внутри круглых
скобок, разделяя элементы запятыми.
let person_info: (String, u32, bool) = ("Алиса".to_string(), 30, true); // ①
① Объявление кортежа, содержащего значение типа String, значение типа
u32 (32-битное целое число без знака) и значение типа bool.
Представьте, что вы создаете приложение, которому нужно хранить информацию о человеке. В традиционном программировании вы могли бы создать отдельные переменные для имени, возраста и флага, указывающего, зарегистрирован пользователь или нет. Однако кортежи предлагают элегантное решение, позволяя объединять эти разнородные фрагменты информации в единую сущность.
В Rust кортежи позволяют бесшовно комбинировать значения разных типов данных. Приведенный выше блок кода демонстрирует создание кортежа
с именем person_info. Он содержит в себе имя человека (String), возраст (u32)
и статус регистрации (bool). Каждое значение внутри кортежа отделяется запятой, а вся конструкция заключается в круглые скобки.
Деструктуризация кортежей
Извлекать значения из кортежа так же просто, как и его создавать. В Rust есть
эффективное функциональное средство, именуемое сопоставление с шаблоном,
или деструктуризация кортежа, которая позволяет без усилий извлекать конкретные элементы из кортежа. Давайте рассмотрим этот механизм в действии.
Управляющие конструкции 99
Листинг 2.27. Деструктуризация кортежа
let (name, age, is_registered) = person_info;
println!("Имя: {}, Возраст: {}, Зарегистрирован: {}", name, age, is_registered);
В приведенном выше исходном коде мы используем сопоставление с шаб
лоном, чтобы извлечь значения из кортежа person_info и присвоить их переменным: name, age и is_registered. Вслед за этим мы распечатываем эти значения на экране. Сопоставление с шаблоном упрощает работу с кортежами
и повышает удобочитаемость исходного кода.
Кортежи проявляют себя во всей красе не только при хранении данных,
но и в качестве параметров функций и возвращаемых ими значений. Рассмот
рим сценарий, в котором нужно вычислить площадь и периметр прямоугольника. Вместо того чтобы создавать отдельные функции для каждого вычисления, можно вернуть кортеж, содержащий в себе оба значения:
Листинг 2.28. Кортежи как тип значения, возвращаемого из функции
fn calculate_area_and_perimeter(length: f64, width: f64) -> (f64, f64) {
let area = length * width;
let perimeter = 2.0 * (length + width);
(area, perimeter) // Возвращение кортежа
}
Функция calculate_area_and_perimeter получает длину и ширину прямо
угольника, вычисляет площадь и периметр, а затем возвращает кортеж, содержащий эти два значения. Такой подход позволяет аккуратно группировать
соотнесенные данные и делает назначение функции предельно ясным.
Кортежи демонстрируют приверженность Rust гибкости и выразительности
исходного кода. Позволяя разным типам данных сосуществовать внутри одной конструкции, они дают возможность представлять сложные взаимосвязи,
не жертвуя при этом прозрачностью исходного кода. Будь то комбинирование
данных о конкретном объекте, сопоставление с шаблоном или расширение
значений, возвращаемых из функций, – кортежи являются бесценным инструментом в вашем арсенале программирования на Rust.
Освоение умений объявлять переменные и использовать разнообразные типы
данных на Rust открывает путь к разработке надежного и высокопроизводительного исходного кода. Тщательно работая как с примитивными, так и со сложными типами данных, вы сможете использовать характерную для Rust статическую
типизацию, чтобы строить сложные, но надежные приложения, в которых соблюден баланс между эффективностью и защищенностью.
Управляющие конструкции
Управляющие конструкции – это фундаментальные инструменты программирования, которые направляют поток исполнения программы посредством
принятия решений и итераций. В Rust эти конструкции имеют решающее значение для построения отзывчивых и динамичных программ. В этом разделе
мы углубимся в область управляющих конструкций, обследовав условные инструкции, циклы и мощное выражение match.
100
Основы Rust
Условные инструкции
Условные инструкции позволяют делать выбор на основе определенных условий. Инструкция if – это простая, но мощная конструкция, которая запускает блок кода только в том случае, если заданное условие является истинным.
Кроме того, опциональный блок else предоставляет альтернативный путь для
исполнения другого блока кода, если условие не соблюдается. Ради большей
наглядности давайте рассмотрим пример:
Листинг 2.29. Условные инструкции в Rust
let num = 7;
if num % 2 == 0 { // ①
println!("Число четное."); // Исполнится, если num четное
} else { // ②
println!("Число нечетное."); // Исполнится, если num нечетное
}
В приведенном выше примере:
① инструкция if выполняет проверку переменной num на четность;
② блок else исполняется в ситуациях, когда условие является ложным, что
указывает на нечетное число.
Циклы while
Циклы необходимы для многократного исполнения блока кода. Цикл while –
это базовая циклическая конструкция в Rust, которая продолжает исполнять
блок кода, пока заданное условие остается истинным. Ниже представлена иллюстрация:
Листинг 2.30. Цикл while в Rust
let mut count = 0;
while count < 5 { // ①
println!("Текущий счет: {}", count);
count += 1;
}
В приведенном выше исходном коде:
① цикл while многократно выводит значение count на экран, пока оно
меньше 5.
Циклы for
Цикл for – это универсальная итеративная конструкция, которая упрощает
выполнение итераций по элементам диапазона, коллекции или последовательности в цикле. Давайте рассмотрим простейший пример:
Выражения match 101
Листинг 2.31. Цикл for в Rust
for i in 1..=3 { // ①
println!("Текущее значение: {}", i);
}
В приведенном выше исходном коде:
① цикл for выполняет итерации по диапазону значений от 1 до 3 (включительно). На каждой итерации значение параметра i цикла меняется,
и выводится соответствующее сообщение.
Конструкция loop
В Rust ключевое слово loop служит мощной конструкцией, предлагающей более тонкий контроль, чем его аналоги – циклы for и while1. По сути, конструкция loop исполняет блок кода бесконечно. Исполнение продолжается до тех
пор, пока внутри цикла не встретится ключевое слово break, которое служит
«выходом», позволяющим программе вырваться из вечного цикла. Эта отличительная черта конструкции loop делает ее предпочтительной при реализации
долгоживущих серверов или процессов, где критически важны непрерывное
исполнение и адаптивность к меняющимся условиям. Конструкция loop обес
печивает отказоустойчивость, необходимую в сценариях, требующих постоянной работы, предлагая надежный и гибкий инструмент для навигации в сложностях серверных реализаций.
Выражения match
Выражение match – это мощная функция в Rust, которая обеспечивает элегантное и исчерпывающее ветвление на основе разных шаблонов. Оно особенно
полезно для обработки сложных сценариев, где необходимо рассматривать
большое количество вариантов. Рассмотрим следующий ниже пример:
Листинг 2.32. Выражение match
let day = "Wednesday";
match day { // ①
"Monday" => println!("Это начало недели!"),
"Friday" => println!("Выходные близко!"),
_ => println!("Это просто обычный день."),
}
В приведенном выше примере:
① выражение match сравнивает значение переменной day с разными шаб
лонами. Сообщения печатаются в зависимости от того, какой шаблон совпал.
1
Управляющие конструкции – Язык программирования Rust (дата не указана). https://
doc.rust-lang.org/book/ch03-05-control-flow.html#loop-labels-to-disambiguate-betweenmultiple-loops.
102
Основы Rust
Символ _ в приведенном выше примере – это «всеохватывающий» шаблон,
который гарантирует, что match обработает абсолютно все возможные варианты (этого требует компилятор для обеспечения защищенности).
Функции
В процессе разработки сложных программ становится очевидной необходимость в хорошо структурированном и повторно используемом исходном
коде. В Rust функции являются основными строительными блоками, используемыми для создания абстракций, которые способствуют организованности и модульности исходного кода. В этом разделе мы углубимся в нюансы синтаксиса функций, передачи параметров и получения результатов
их исполнения.
Явные сигнатуры
Листинг 2.33. Явная сигнатура функции
fn add(a: i32, b: i32) -> i32 { // Определение функции с параметрами типа i32 и
// типом возвращаемого значения i32
a + b
// Возвращение суммы a и b
}
let result = add(5, 3);
println!("Сумма: {}", result);
// Вызов функции add с аргументами 5 и 3
// Печать результата
В приведенном выше примере функция add принимает два параметра
типа i32 и возвращает значение типа i32. Система типов Rust гарантирует использование только совместимых типов, что снижает риск ошибок на этапе
исполнения.
Вложенные функции для модульности
Листинг 2.34. Вложенные функции
fn main() {
let x = 5;
let y = 7;
fn inner_function(a: i32, b: i32) -> i32 { // Определение вложенной функции
«inner_function»
a * b // Возвращаемый результат: произведение a и b
}
let result = inner_function(x, y); // Вызов вложенной функции
println!("Произведение: {}", result);
}
В приведенном выше примере inner_function вложена в функцию main. Такое вкладывание помогает поддерживать чистоту исходного кода и изолирует
логику внутри конкретного контекста.
Владение, заимствование и времена жизни 103
Обратите внимание на важную деталь: в примере a + b в конце строки нет
точки с запятой. В Rust это называется выражением – результат исполнения
последней строки в блоке без точки с запятой автоматически возвращается.
Если поставить там ;, то это превратит строку в инструкцию, и функция вернет
пустой тип (), что в данном случае приведет к ошибке компиляции.
Замыкания
Наряду с традиционными функциями в Rust представлены замыкания – мощное средство для определения кратких анонимных функций. Замыкания отличаются универсальностью и позволяют создавать переносимые функции
прямо «на лету».
Замыкания захватывают переменные из окружающего их диапазона доступности, что позволяет гибко настраивать поведение программы. Такая
адаптивность неоценима в сценариях, в которых нужно передавать поведение
в качестве аргумента, например в итераторах или обработчиках событий.
Листинг 2.35. Простой пример замыкания
let multiply = |x: i32, y: i32| x * y; // Создание замыкания multiply
let product = multiply(4, 7);
// Вызов замыкания с аргументами 4 и 7
println!("Произведение: {}", product); // Печать результата
В приведенном выше примере замыкание multiply перемножает два целых
числа, предлагая лаконичный способ описания действия без необходимости
объявления отдельной функции.
Владение, заимствование и времена жизни
Исключительные возможности управления памятью выделяют Rust среди
других языков программирования. В этом разделе мы проведем обследование фундаментальных принципов владения, заимствования и времен жизни, которые лежат в основе обязательства Rust по обеспечению защищенности памяти.
Владение
Владение в Rust – это ключевая концепция, играющая решающую роль в эффективном управлении памятью и предотвращении таких распространенных
ошибок, как утечки памяти и «висячие» указатели. Центральная идея заключается в том, что у каждого значения в Rust есть единственный «владелец»,
который несет ответственность за высвобождение памяти, когда значение
больше не нужно. Такой подход гарантирует контролируемое высвобождение
памяти и позволяет избегать ловушек ручного управления, характерных для
таких языков, как C и C++.
Когда переменная выходит из диапазона доступности, встроенная в Rust система владения автоматически вызывает функцию drop, высвобождая память.
Это избавляет от необходимости явного высвобождения памяти и значительно снижает риск утечек.
104 Основы Rust
Листинг 2.36. Простой пример владения
let s = String::from("привет");
// ... делаем что-то с переменной s
// Переменная s выходит из диапазона доступности, и память высвобождается
Как показано в примере, строковый литерал "привет" закреплен за переменной s. Rust гарантирует: как только переменная s выйдет за пределы диапазона доступности, выделенная под строковый литерал память будет высвобождена без необходимости вызывать функцию высвобождения памяти вручную.
Контролер заимствования
Хотя владение закладывает прочный фундамент для защищенности памяти, оно бывает ограничивающим фактором в ситуациях, когда нужно использовать коллективные данные между разными частями исходного кода.
Здесь в игру вступает концепция заимствования. Заимствование позволяет
нескольким частям исходного кода обращаться к данным, не забирая право
владения ими.
Контролер заимствования в Rust – это «страж», который следит за соблюдением правил заимствования, гарантируя, что ссылки на данные используются
защищенно и не приводят к гонкам за доступом к данным или ошибкам памяти. Он анализирует взаимосвязи между переменными, ссылками и владением еще на этапе компиляции. Это означает, что компилятор Rust отлавливает
ошибки до исполнения программы.
Рассмотрим пример, демонстрирующий заимствование:
Листинг 2.37. Пример заимствования
fn main() {
let s1 = String::from("привет");
let len = calculate_length(&s1);
println!("Длина '{}': {}", s1, len);
}
// ①
fn calculate_length(s: &String) -> usize { // ②
s.len()
}
В приведенном выше исходном коде:
① мы передаем &s1 – ссылку на владеемое значение типа String – в функцию calculate_length, разрешая функции «позаимствовать» значение, не беря
его во владение;
② определяем функцию calculate_length, которая принимает ссылку
на значение типа String и возвращает его длину.
Функция calculate_length использует ссылку на значение типа String в качестве входного параметра. Данная ссылка, обозначаемая символом &s, предоставляет доступ к информации в строковом значении, не принимая на себя
владение им. Этот метод заимствования позволяет передавать данные между
функциями, соблюдая меры защиты памяти.
Вопросы для самопроверки
105
Повышение производительности
Использование ссылок для заимствования не только гарантирует защищенность памяти, но и повышает производительность за счет сокращения избыточного копирования данных. Когда вместо фактического значения в функцию передается ссылка на него, компилятор Rust избегает создания лишней
копии информации. Такая практика оказывается особенно выгодной при работе с крупными и сложными структурами данных, дублирование которых потребовало бы значительных затрат времени и места в памяти.
Благодаря тщательному управлению владением и умелому использованию
заимствования Rust достигает баланса между защищенностью и производительностью. Способность контролера заимствования соблюдать строгие правила, допуская при этом эффективный коллективный доступ к данным, делает
Rust языком, который превосходит другие как по надежности, так и по эффективности.
Заключение
В этой главе мы провели обследование основ программирования на языке
Rust. Мы заложили прочный фундамент: от понимания переменных и типов
данных до освоения управляющих конструкций и создания абстракций с помощью функций и замыканий. Кроме того, теперь вы обладаете знаниями,
позволяющими «укрощать зверя», коим является память, с помощью владения, заимствования и времен жизни – и все это под бдительным присмотром
встроенных в Rust функциональных средств защиты памяти и проверки заимствования. Двигаясь дальше, следует помнить, что элегантность Rust заключается не только в его синтаксисе, но и в стремлении к созданию защищенного,
производительного и надежного исходного кода.
В следующей главе мы подробно рассмотрим продвинутые функциональные средства Rust, сосредоточив основное внимание на важнейшей концепции общих черт и их ключевой роли в реиспользовании исходного кода. Вы научитесь эффективно реализовывать общие черты для определяемых пользователем структур данных, использовать мощь обобщенных функций и структур,
а также ориентироваться в области ограничений общих черт и ассоциированных типов с целью повышения универсальности исходного кода. Вооружившись этими знаниями, вы сможете разрабатывать не только более гибкий,
но и высокоэффективный исходный код, подняв свои умения программировать на Rust на новый уровень.
Вопросы для самопроверки
Вопрос 1. Что дает система владения программированию на Rust?
a) организованность исходного кода
b) ясность в управлении памятью
c) управление конкурентностью
d) обработку ошибок
106 Основы Rust
Вопрос 2. Как сделать переменную в Rust мутируемой?
a) с помощью ключевого слова const
b) с помощью ключевого слова let
c) с помощью ключевого слова mut
d) в Rust переменные всегда мутируемы
Вопрос 3. Какова цель затенения в Rust?
a) предотвращение изменения переменной
b) избегание объявлений переменных
c) реиспользование имен переменных в разных диапазонах доступности
d) обеспечение немутируемости
Вопрос 4. Чем константы отличаются от переменных в Rust?
a) константы по умолчанию мутируемы
b) константы всегда немутируемы
c) константы имеют динамические типы
d) константы нельзя использовать в функциях
Вопрос 5. В каком случае вы выберете тип f32 в Rust?
a) когда важна предельная глубина точности
b) когда важна вычислительная эффективность
c) при работе с целыми числами
d) при реализации криптографических алгоритмов
Вопрос 6. Как представляются литералы чисел с плавающей запятой
в Rust?
a) используя шестнадцатеричную нотацию
b) используя двоичную нотацию
c) используя научную (экспоненциальную) нотацию
d) используя восьмеричную нотацию
Вопрос 7. Какие математические операции поддерживаются типами чисел с плавающей точкой в Rust?
a) только сложение, вычитание и умножение
b) только умножение и деление
c) сложение, вычитание, умножение, деление и взятие остатка от деления
d) только взятие квадратного корня и возведение в степень
Вопрос 8. Какова значимость функции main в программе на Rust?
a) вывод на экран документации программы
b) определение конкретно-прикладных типов данных
c) служит точкой входа в программу
d) обработка вводимых пользователем данных
Вопросы для самопроверки
107
Вопрос 9. Как компилятор Rust обрабатывает целочисленное переполнение в режиме релиза?
a) вызывает панику и завершает программу
b) циклически переносит значение, используя дополнительный код
c) выдает исключение
d) автоматически изменяет размер целого числа
Вопрос 10. Для чего обычно используются кортежи в Rust?
a) для хранения одиночных значений
b) для возвращения нескольких значений из функции
c) для создания массивов фиксированного размера
d) для определения конкретно-прикладных типов данных
Вопрос 11. Как в Rust обеспечивается защищенность памяти?
a) посредством автоматического сборщика мусора
b) путем разрешения неограниченного доступа к памяти
c) посредством системы владения и контролера заимствования
d) путем ограничения использования указателей
Вопрос 12. В чем преимущество использования типа String перед строковыми литералами в Rust?
a) тип String имеет фиксированный размер на этапе компиляции
b) строковые литералы можно изменять на этапе исполнения
c) тип String позволяет создавать динамически расширяемые строковые
значения
d) строковые литералы более эффективны с точки зрения использования
памяти
Вопрос 13. Какова цель ключевого слова mut при работе со ссылками
в Rust?
a) оно указывает на мутируемую ссылку, позволяя вносить изменения через нее
b) оно обеспечивает немутируемость, предотвращая любые изменения
данных
c) оно означает ссылку на константное значение
d) оно задает ссылку на немутируемый тип данных
Вопрос 14. Для чего в Rust можно использовать срез?
a) для определения конкретно-прикладных типов данных
b) для создания массивов фиксированного размера
c) для дублирования коллекционных данных
d) для работы с частью коллекции без взятия ее во владение
108
Основы Rust
Вопрос 15. Как замыкание в Rust захватывает переменные из окружающего диапазона доступности?
a) путем копирования всех переменных
b) путем взятия всех переменных во владение
c) путем захвата по ссылке либо по значению, в зависимости от того, что
было указано
d) путем создания новых переменных с теми же именами
Вопрос 16. Какова роль контролера заимствования в предотвращении
дефектов, связанных с конкурентностью?
a) он разрешает неограниченный конкурентный доступ к данным
b) он анализирует исходный код с целью обеспечения защищенного использования ссылок и предотвращает гонки за доступом к данным
c) он автоматически решает проблемы гонок за доступом к данным на этапе исполнения
d) он вводит замки (блокировки) и умные указатели Mutex с целью синхронизации доступа к данным
Ответы
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
b) ясность в управлении памятью
c) использование ключевого слова mut
c) реиспользование имен переменных в разных диапазонах доступности
b) константы всегда немутируемы
b) когда важна вычислительная эффективность
c) использование научной (экспоненциальной) нотации
c) сложение, вычитание, умножение, деление и взятие остатка от деления
c) служит точкой входа в программу
b) циклический перенос значения, используя дополнительный код
b) возвращение нескольких значений из функции
c) посредством системы владения и контролера заимствования
c) тип String позволяет создавать динамически расширяемые строковые
значения
a) он указывает на мутируемую ссылку, позволяя вносить изменения через нее
d) для работы с частью коллекции без взятия ее во владение
c) путем захвата по ссылке либо по значению, в зависимости от того, что
было указано
b) он анализирует исходный код с целью обеспечения защищенного использования ссылок и предотвращает гонки за доступом к данным
Ключевые термины 109
Ключевые термины
Владение (ownership): центральная концепция Rust, которая определяет способ управления памятью и обеспечивает защищенность данных,
позволяя только одной части исходного кода изменять данные в конкретный момент времени.
Мутируемый (mutable): свойство переменной в Rust, которое позволяет менять ее значение после объявления при использовании ключевого
слова mut.
Затенение (shadowing): функциональное средство Rust, позволяющее
объявлять новую переменную с тем же именем, что и у существующей,
в ограниченном диапазоне доступности, фактически «заслоняя» изначальную переменную.
Константы (constants): немутируемые значения в Rust, которые объявляются с помощью ключевого слова const; для них обязательна явная
аннотация типа.
f32 и f64: типы чисел с плавающей точкой, представляющие соответственно 32-битные и 64-битные числа согласно стандарту IEEE-754.
Литералы чисел с плавающей точкой: представление чисел с плавающей точкой в Rust в стандартной десятичной либо в научной нотации.
Математические операции: операции, поддерживаемые типами чисел
с плавающей точкой, включая сложение, вычитание, умножение, деление и взятие остатка от деления, а также математические функции, такие как извлечение квадратного корня.
Глава 3
Общие черты и обобщения
Введение
Общие черты (типажи, трейты) и обобщения являются двумя наиболее мощными инструментами в Rust. Они позволяют писать гибкий, адаптивный и эффективный исходный код, который бесшовно укладывается в самые разные
ситуации. В этой главе мы приступим к глубокому обследованию этих принципов и раскроем их революционное влияние на то, как мы задумываем и конструируем программное обеспечение.
Общие черты дают возможность определять набор вариантов поведения,
которые могут реализовываться различными типами. Это позволяет конструировать обобщенный исходный код, работающий с любым типом, который
соответствует «контракту» общей черты. Такой подход делает исходный код
более лаконичным, реиспользуемым и простым в техническом обслуживании,
так как можно абстрагироваться от деталей реализации и сосредоточиваться
на желаемом поведении.
В свою очередь, обобщения позволяют разрабатывать исходный код, который функционирует с самыми разными типами, избавляя разработчика от необходимости писать отдельные функции или методы для каждого из них. Данная методика делает исходный код невероятно универсальным и эффективным: одну и ту же логику можно реиспользовать для разных типов данных без
ущерба для производительности.
Общие черты и обобщения в сочетании наделяют разработчика способностью конструировать по-настоящему мощный и изысканный исходный код,
позволяя элегантно и с полной уверенностью решать сложные задачи. Итак,
давайте окунемся в данную тему и раскроем весь потенциал этих фундаментальных концепций языка Rust!
Содержание главы
В этой главе будут рассмотрены следующие темы:
концепция общих черт и их роль в реиспользовании исходного кода;
реализация общих черт для определяемых пользователем структур данных;
работа с обобщенными функциями и структурами данных на Rust;
обследование ассоциированных типов и границ общих черт с целью достижения максимальной гибкости.
Реализация общих черт 111
Общие черты
В сфере разработки программного обеспечения крайне важно устанавливать
единообразное и заданное поведение различных типов. Возьмем, к примеру,
концепцию Printable (Печатаемого), как показано на рисунке ниже.
ключевое слово
Имя
доступность
Функция
Рисунок 3.1. Простое объяснение концепции общей черты
Внедрив функциональность под названием Printable, можно обеспечить
единую процедуру распечатки значений разных типов. Такое единообразие
не только упрощает организацию исходного кода, но и улучшает его понимание. Создание подобной общей черты предоставляет четкий контракт для
любого типа, в котором он реализован. Благодаря этому можно опираться
на стандартный способ печати объектов, что способствует удобочитаемости
и удобству технического сопровождения исходного кода.
Листинг 3.1. Простой пример общей черты
trait Printable {
fn print(&self); // ①
}
В приведенном выше исходном коде:
① метод print, определенный в общей черте Printable, обеспечивает единообразное поведение распечатывания значений разных типов.
Теперь давайте углубимся в реализацию этой общей черты у конкретных
типов.
Реализация общих черт
В целях демонстрации практичности общих черт давайте начнем с базового
типа – Number. Этот тип представляет целое число.
112
Общие черты и обобщения
Листинг 3.2. Пример простой реализации общей черты
struct Number {
value: i32,
}
impl Printable for Number {
fn print(&self) { // ①
println!("Число: {}", self.value);
}
}
В приведенном выше примере:
① реализация метода print для типа Number гарантирует единообразное поведение печати числовых значений.
В данном случае общая черта Printable была применена к структуре Number.
Эта реализация устанавливает правила печати структуры Number, следуя нормам, заданным общей чертой. Теперь рассмотрим более сложный сценарий
с геометрическими фигурами.
Предположим, у нас есть две фигуры: Circle (Окружность) и Rectangle (Прямоугольник). Реализовав общую черту Printable у обоих типов, мы гарантируем единообразное поведение печати всех фигур.
Листинг 3.3. Пример реализации общих черт
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Printable for Circle {
fn print(&self) { // ①
println!("Окружность с радиусом: {}", self.radius);
}
}
impl Printable for Rectangle {
fn print(&self) { // ②
println!("Прямоугольник с размерами: {} x {}", self.width, self.
height);
}
}
В приведенном выше исходном коде:
① реализация метода print у типа Circle обеспечивает единообразное поведение печати;
② реализация метода print у типа Rectangle обеспечивает единообразное
поведение печати.
Общая черта с дефолтным поведением 113
Таким образом, мы определили унифицированный способ печати разных
геометрических фигур. Мощь общих черт становится очевидной: они позволяют создавать стандартизированный интерфейс для разных типов, делая исходный код организованным и понятным.
Общая черта с дефолтным поведением
Хотя общие черты позволяют определять общий контракт, иногда желательно
предоставлять дефолтное поведение внутри самой общей черты, позволяя типам переопределять его при необходимости.
Общая черта
с дефолтным
поведением
Структуры
Слабо состыкованные
Рисунок 3.2. Использование базовой общей черты как строительного блока для более сложных общих черт
Давайте возьмем в качестве примера общую черту Drawable, которая отвечает за отрисовку фигур. Мы можем предоставить дефолтный метод отрисовки,
который послужит отправной точкой для реализации конкретно-прикладной
логики отрисовки.
Листинг 3.4. Простое определение общей черты
trait Drawable {
fn draw(&self) {
println!("Отрисовка фигуры."); // ①
}
}
В приведенном выше примере:
① дефолтный метод draw внутри общей черты Drawable обеспечивает общее
поведение отрисовки.
Это дефолтное поведение служит фундаментом, на котором могут строить
свою логику типы, реализующие общую черту Drawable. Оно обеспечивает
единообразие, позволяя при необходимости выполнять индивидуальную настройку.
Давайте посмотрим, как эта общая черта работает на практике, реализовав
ее у типов Circle и Rectangle.
114
Общие черты и обобщения
Листинг 3.5. Реализация общей черты
impl Drawable for Circle {
fn draw(&self) { // ①
println!("Отрисовка окружности с радиусом: {}", self.radius);
}
}
impl Drawable for Rectangle {
fn draw(&self) { // ②
println!("Отрисовка прямоугольника с размерами: {} x {}",
self.width, self.height);
}
}
В приведенном выше исходном коде:
① дана конкретно-прикладная реализация метода draw у типа Circle;
② дана конкретно-прикладная реализация метода draw у типа Rectangle.
В данном примере оба типа, Circle и Rectangle, реализуют общую черту Drawable, демонстрируя свои индивидуальные методы отрисовки. Тем
не менее если какой-либо другой тип решит реализовать общую черту Drawable,
не настраивая свой собственный метод, он автоматически будет использовать
дефолтное поведение отрисовки.
Общие черты с дефолтными методами обеспечивают баланс между стандартным поведением и возможностью конкретно-прикладной настройки, гарантируя целостность и гибкость кодовой базы.
Границы общих черт
При конструировании программных систем крайне важно создавать функции,
которые могут работать с самыми разными типами данных, при условии что
они проявляют специфическое поведение. Именно здесь вступают в дело границы общих черт. Они позволяют указывать, что обобщенная функция должна принимать любой тип, реализующий конкретную общую черту. Благодаря
этому мы сосредоточиваемся не на конкретной идентичности типа, а на его
ожидаемом поведении. Этот подход повышает гибкость и возможность ре
использования исходного кода, так как функции приобретают качества адаптируемости к разным структурам данных, придерживающимся одного и того
же интерфейса.
Использование границ общих черт упрощает создание универсальных
функций. Представьте сценарий, в котором нужно вычислять площадь различных геометрических фигур, таких как окружности, прямоугольники или
треугольники. Вместо того чтобы писать отдельные функции для каждого
типа фигур, можно задействовать границы общей черты, создав единую функцию calculate_area, которая работает с любым типом, реализующим общую
черту Shape (Фигура). Это не только уменьшает дублирование исходного кода,
но и упрощает расширение кодовой базы новыми фигурами в будущем.
Обобщения
115
Листинг 3.6. Функциональный аргумент с типом, реализующим общую черту
fn calculate_area(shape: impl Shape) -> f64 {
shape.area()
}
В данном случае функция calculate_area принимает в качестве аргумента
любой тип, реализующий общую черту Shape. Нас интересует именно поведение типа, а не его точная идентичность. Как следствие можно без усилий
вычислять площадь окружностей, прямоугольников и других фигур.
Расширение с помощью обобщений
Развивая идею границ общих черт, обобщения предлагают невероятно мощный и гибкий подход к разработке функций, которые бесшовно обрабатывают
разнообразные типы данных. В случае функции calculate_area для достижения того же результата можно использовать обобщения. Вместо явного указания конкретного типа мы вводим обобщенный тип T, который должен придерживаться общей черты Shape. Следовательно, с функцией calculate_area
может быть использован любой тип T, реализующий предписанное поведение.
Листинг 3.7. Обобщенный тип функционального аргумента
fn calculate_area<T: Shape>(shape: T) -> f64 { // ①
shape.area()
}
В приведенном выше примере:
① обобщенная функция calculate_area принимает любой тип T, который
придерживается общей черты Shape.
В этой версии мы используем обобщенный тип T, от которого требуется придерживаться общей черты Shape. Такой подход повышает гибкость, давая возможность при необходимости интегрировать дополнительные правила.
Границы общих черт играют ключевую роль в создании функций и структур, которые работают с разнообразными типами, сводя к минимуму избыточность и обеспечивая защищенность исходного кода.
Обобщения
Обобщения являются фундаментальным средством Rust, позволяющим разрабатывать чрезвычайно универсальный и реиспользуемый исходный код. Они
дают возможность конструировать функции, структуры и общие черты, которые могут бесшовно работать с различными типами данных без ущерба для
типозащищенности. Такая гибкость оказывается исключительно ценной при
разработке библиотек или компонентов, предназначенных для максимально
широкого спектра вариантов использования.
116
Общие черты и обобщения
– это местозаполнитель любого типа (f32, i32, String...)
Тип параметра items должен совпадать
с типом
Рисунок 3.3. Простое объяснение концепции обобщений
Адаптивные структуры
Обобщения меняют правила игры во всем, что касается структур данных. Возьмем
в качестве примера стековую структуру данных. Вместо того чтобы ограничивать
ее конкретным типом данных, можно использовать обобщения, чтобы создать
тип Stack<T>, который может хранить элементы любого типа T. Такая гибкость позволяет легко строить стеки для целых чисел, строковых литералов, конкретноприкладных объектов или любых других типов данных. Используя обобщения,
улучшается организованность исходного кода, уменьшается избыточность и создаются структуры данных, адаптируемые к огромному количеству сценариев.
Листинг 3.8. Построение структур с использованием обобщений
struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
fn push(&mut self, item: T) {
// ①
self.items.push(item);
}
fn pop(&mut self) -> Option<T> { // ②
self.items.pop()
}
}
fn main() {
// Создать новый стек целых чисел
let mut stack = Stack::<i32> { items: Vec::new() };
// Затолкнуть элементы в стек
stack.push(1);
stack.push(2);
stack.push(3);
// Вытолкнуть элементы из стека и распечатать их
while let Some(item) = stack.pop() {
println!("Вытолкнул: {}", item);
}
}
Ассоциированные функции 117
//
//
//
//
Вывод:
Вытолкнул: 3
Вытолкнул: 2
Вытолкнул: 1
В приведенном выше исходном коде:
① дана реализация метода push для обобщенной структуры Stack.
② дана реализация метода pop для обобщенной структуры Stack.
В приведенном выше примере структура Stack выступает в роли инструментария, способного управлять элементами любого типа T. Методы push и pop функционируют единообразно, независимо от конкретного используемого типа. Такая универсальность позволяет создавать стеки, вмещающие числа, слова или любые другие типы данных.
Обобщения отлично подходят для разработки исходного кода, который
не ограничен одним типом, что способствует более качественной организации
программы и предотвращает дублирование.
Ассоциированные функции
Общие черты также могут содержать ассоциированные функции – функции,
связанные непосредственно с самой общей чертой, а не с конкретными его
экземплярами. Рассмотрим расширение общей черты Shape путем добавления
ассоциированной функции для создания фигуры с дефолтным размером (т. е.
со стандартным размером, если не указан иной):
Листинг 3.9. Общие черты с ассоциированными функциями
struct Circle {
radius: f64,
}
trait Shape {
fn area(&self) -> f64;
fn default_shape() -> Self; // ①
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
fn default_shape() -> Self { // ②
Circle { radius: 1.0 }
}
fn main() {
// Создать окружность и вычислить ее площадь
let circle = Circle { radius: 2.0 };
let area = circle.area();
println!("Площадь окружности: {}", area);
118
}
Общие черты и обобщения
// Создать дефолтную окружность и вычислить ее площадь
let default_circle = Circle::default_shape();
let default_area = default_circle.area();
println!("Площадь дефолтной окружности: {}", default_area);
// Вывод:
// Площадь окружности: 12.566370614359172
// Площадь дефолтной окружности: 3.141592653589793
① Ассоциированная функция default_shape, связанная с общей чертой Shape.
② Реализация ассоциированной функции default_shape у типа Circle.
Функция default_shape предоставляет средство порождения фигур со стандартными размерами. Каждая фигура получает возможность определять свой
собственный дефолтный механизм создания экземпляра.
Ассоциированные типы
Ассоциированные типы в Rust – это мощное функциональное средство, которое обеспечивает отказоустойчивость исходного кода к будущим изменениям,
абстрагируясь от деталей конкретных типов. Они позволяют определить типместозаполнитель внутри общей черты, оставляя выбор фактического типа
на усмотрение структуры или перечисления, которые реализуют эту общую
черту. Такой уровень абстракции обеспечивает гибкость и расширяемость, делая ассоциированные типы ценным инструментом при разработке интерфейсов для сложных систем.
Используя ассоциированные типы, мы, по сути, говорим: «У меня есть общая черта, которая задает определенное поведение, но я не хочу заранее указывать конкретные типы, так как они могут меняться в зависимости от ситуации». Этот подход особенно полезен при разработке общих черт для итераторов или подобных конструкций.
Представьте создание итераторной общей черты, не привязанной к конкретному типу элементов, которые она выдает. Вместо этого в ней определен
ассоциированный тип с именем Item, который остается абстрактным. Это позволяет любой структуре или перечислению, реализующему эту общую черту,
самостоятельно решать, какой тип данных они будут выдавать. Такая гибкость
жизненно важна при работе с разнообразными коллекциями или в ситуациях,
когда вы планируете развивать свой исходный код с целью поддержки новых
структур данных.
Листинг 3.10. Общие черты с ассоциированными типами
trait Iterator {
type Item;
// ①
fn next(&mut self) -> Option<Self::Item>; // ②
}
① Ассоциированный тип Item, указывающий на тип элементов, производимых итератором.
Ассоциированные типы 119
② Метод next, возвращающий экземпляр Option, который содержит элемент
ассоциированного типа.
Этот подход позволяет разрабатывать различные итераторы, каждый из которых скроен под определенный вид элементов:
Листинг 3.11. Конкретно-прикладной итератор с ассоциированными типами
struct Counter {
count: u32,
}
impl Iterator for Counter {
type Item = u32; // ①
fn next(&mut self) -> Option<Self::Item> {
if self.count < 10 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
fn main() {
let mut counter = Counter { count: 0 };
while let Some(number) = counter.next() {
println!("Счетчик: {}", number);
}
}
//
//
//
//
//
//
//
//
//
//
//
Вывод:
Счетчик:
Счетчик:
Счетчик:
Счетчик:
Счетчик:
Счетчик:
Счетчик:
Счетчик:
Счетчик:
Счетчик:
1
2
3
4
5
6
7
8
9
10
В приведенном выше исходном коде:
① ассоциированный тип Item итератора Counter задан как u32.
В приведенном выше примере структура Counter реализует общую черту Iterator, при этом ее ассоциированный тип Item имеет тип u32. Следовательно, можно создавать итераторы для самых разных типов элементов, придерживаясь одного и того же структурного шаблона. Ассоциированные типы обеспечивают повышенную гибкость, позволяя общим чертам бесшовно интегрироваться с ассоциированными типами без наложения жестких ограничений.
120
Общие черты и обобщения
Объекты динамической диспетчеризации
Объекты динамической диспетчеризации (динамические объекты или
объекты общей черты – trait object) в Rust предоставляют механизм внесения изменений на этапе исполнения, что бывает невероятно полезным
в различных сценариях. Задействуя динамические объекты, вы позволяете исходному коду бесшовно взаимодействовать с разными типами, имеющими общую черту, даже если вы заранее не знаете их конкретный тип.
Эта исключительная способность особенно выгодна при конструировании
библиотек или систем, требующих обработки широкого спектра входных
данных или плагинов.
Если коротко: это способ работать с разными типами так, будто это один и тот же тип,
при условии что во всех них реализована одна и та же общая черта. В исходном коде
динамический объект всегда записывается с ключевым словом dynю.
При использовании динамических объектов мы открываем дверь для динамической диспетчеризации – процесса, который на этапе исполнения определяет, какую именно реализацию метода следует вызывать, основываясь
на фактическом типе объекта. Это контрастирует со статической диспетчеризацией, в рамках которой вызовы методов определяются на этапе компиляции. Динамические объекты дают гибкость динамической диспетчеризации,
позволяя писать функции или компоненты, работающие с любыми типами,
придерживающимися общей черты.
Представьте сценарий, в котором нужно обрабатывать различные фигуры
с помощью одной функции, принимающей динамический объект &dyn Shape.
Используя динамические объекты, можно работать с окружностями, прямо
угольниками и другими фигурами, не зная их точных типов на этапе компиляции. Эта гибкость упрощает исходный код и позволяет создавать более обобщенные и реиспользуемые компоненты.
Листинг 3.12. Динамические объекты
trait Shape {
// ①
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
// ②
struct Rectangle { // ②
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
Практические применения 121
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn print_area(shape: &dyn Shape) { // ③
println!("Площадь: {}", shape.area());
}
fn main() {
// ④
let circle = Circle { radius: 2.0 };
let rectangle = Rectangle { width: 3.0, height: 4.0 };
}
print_area(&circle);
print_area(&rectangle);
// Вывод:
// Площадь: 12.566370614359172
// Площадь: 12
В приведенном выше исходном коде:
① мы определяем общую черту Shape с методом area;
② структуры Circle и Rectangle представляют фигуры и реализуют общую
черту Shape;
③ функция print_area принимает ссылку на динамический объект и вызывает метод area;
④ в функции main создаем экземпляры структур Circle и Rectangle и распечатываем их площади.
Используя динамические объекты, можно писать функции, работающие
с многочисленными типами, реализующими одну и ту же общую черту, не зная
конкретных типов на этапе компиляции. Эта гибкость достигается ценой небольших накладных расходов на производительность из-за динамической
диспетчеризации.
Динамические объекты являются незаменимым инструментом в тех случаях, когда необходимо реализовывать полиморфизм этапа исполнения. Они делают исходный код адаптивным к самым разным типам, придерживаясь при
этом общей черты. Хотя они привносят небольшие накладные расходы на производительность по сравнению со статической диспетчеризацией, их динамическая природа неоценима в сценариях, в которых требуется работать с разными типами.
Практические применения
Общие черты играют критически важную роль в применениях на практике,
обеспечивая такую функциональность, как сериализация и десериализация –
важнейшие процессы в современных программных системах. Используемая
в Rust система общих черт предлагает надежную основу для библиотек сериализации, гарантируя эффективное кодирование и декодирование данных.
122
Общие черты и обобщения
Рассмотрим простой пример использования популярного пакета serde. Сначала убедитесь, что зависимость serde добавлена в ваш файл Cargo.toml:
[dependencies]
serde = "1.0"
serde_json = "1.0"
Теперь давайте выполним сериализацию простой структуры в формат JSON:
Листинг 3.13. Использование внешних общих черт
use serde::{Serialize, Deserialize};
use serde_json;
// ①
#[derive(Serialize, Deserialize, Debug)] // ③
struct Person { // ②
name: String,
age: u32,
}
fn main() {
// ④
let person = Person {
name: String::from("Махмуд"),
age: 25,
};
let serialized = serde_json::to_string(&person).unwrap();
println!("Сериализовано: {}", serialized);
}
// ⑤
let deserialized: Person = serde_json::from_str(&serialized).unwrap(); // ⑥
println!("Десериализовано: {:?}", deserialized);
// Результат
// Сериализовано: {“name”:”Махмуд”,”age”:25}
// Десериализовано: Person { name: “Махмуд”, age: 25 }
В приведенном выше примере:
① мы импортируем необходимые общие черты и пакет serde_json для выполнения сериализации;
② структура Person представляет имя и возраст человека;
③ выделяем через derive общие черты Serialize и Deserialize, используя
атрибуты библиотеки serde;
④ в функции main создаем экземпляр структуры Person;
⑤ сериализуем этот экземпляр в формат JSON с помощью функции serde_
json::to_string;
⑥ десериализуем символьную последовательность JSON обратно в экземпляр структуры Person, используя функцию serde_json::from_str.
Задачи по сериализации и десериализации распространены во многих приложениях, особенно при работе с форматами обмена данными, такими как
JSON. Библиотека serde упрощает этот процесс, предоставляя надежный и гибкий механизм выполнения таких преобразований.
Границы общих черт в стандартной библиотеке 123
Границы общих черт в стандартной библиотеке
Обобщения и общие черты активно используются в стандартной библиотеке
Rust. Один из самых ярких примеров – общая черта Iterator, которая позволяет последовательно обрабатывать коллекции и последовательности.
Общая черта Iterator воплощает в себе всю мощь границ общих черт. Она
определяет такие методы, как next, map, filter, и многие другие, обеспечивая
лаконичную и выразительную итерацию по коллекциям. Давайте взглянем
на внутреннее устройство общей черты Iterator:
Листинг 3.14. Общая черта Iterator из стандартной библиотеки
pub trait Iterator {
// ①
type Item;
fn next(&mut self) -> Option<Self::Item>; // ②
// Дополнительные методы итератора...
}
В приведенном выше исходном коде:
① мы определяем ассоциированный тип Item внутри общей черты Iterator,
который обозначает тип элементов, выдаваемых итератором;
② метод next используется для получения следующего элемента из итератора.
Используя общую черту Iterator, можно выстраивать несколько итераторов
в цепочки, создавая сложные преобразования данных с минимальным объемом исходного кода:
Листинг 3.15. Использование общей черты Iterator
fn main() {
let numbers = vec![1, 2, 3, 4, 5]; // ①
let squares = numbers.iter().map(|&x| x * x).collect::<Vec<_>>(); // ②
for square in squares {
// ③
println!("{}", square);
}
}
В приведенном выше исходном коде:
① мы создаем вектор numbers и используем метод iter для создания итератора;
② метод map преобразовывает каждый элемент с помощью замыкания, а затем метод collect собирает преобразованные значения в новый вектор;
③ выполняем итерации по элементам вектора squares в цикле и распечатываем каждое значение.
Общая черта Iterator демонстрирует элегантность и эффективность, которые достигаются путем сочетания обобщений и общих черт, что делает
ее краеугольным камнем стандартной библиотеки Rust.
124
Общие черты и обобщения
Продвинутые шаблоны общих черт
По мере совершенствования ваших умений использовать обобщения и общие
черты вы непременно столкнетесь с продвинутыми шаблонами, которые обеспечивают еще бóльшую адаптивность и надежность. Эти шаблоны укрепляют исходный код и позволяют реализовывать более сложные архитектурные решения.
Ассоциированные константы
Общие черты могут включать в себя не только методы и ассоциированные
типы, но и ассоциированные константы. Такие константы позволяют задавать
значения, связанные с самой общей чертой:
Листинг 3.16. Общие черты и ассоциированные константы
trait Geometry {
const PI: f64;
}
// ①
struct Circle {
radius: f64,
}
impl Geometry for Circle { // ②
const PI: f64 = std::f64::consts::PI;
}
fn main() {
// ③
let circle = Circle { radius: 2.0 };
println!("Площадь окружности: {}",
Circle::PI * circle.radius * circle.radius);
}
// Вывод:
// Площадь окружности: 12.566370614359172
В приведенном выше примере:
① общая черта Geometry задает ассоциированную константу PI;
② тип Circle реализует общую черту Geometry и предоставляет значение
для этой константы;
③ мы вычисляем и печатаем площадь окружности, используя ассоциированную константу.
Ассоциированные константы особенно полезны в ситуациях, когда у общей
черты есть значения, которые остаются константными для всех реализаций.
Перегрузка операторов
Система общих черт в Rust также поддерживает перегрузку операторов, позволяя определять конкретно-прикладное поведение для таких операторов,
как +, -, *, и других. Это значительно повышает выразительность и удобочитае
мость исходного кода. Рассмотрим сценарий сложения экземпляров конкретно-прикладного типа Complex, представляющего комплексные числа:
Маркерные общие черты 125
Листинг 3.17. Общие черты для перегрузки операторов
use std::ops::Add;
struct Complex {
real: f64,
imaginary: f64,
}
// ①
// ②
impl Add for Complex {
// ③
type Output = Complex; // ④
fn add(self, other: Complex) -> Complex { // ⑤
Complex {
real: self.real + other.real,
imaginary: self.imaginary + other.imaginary,
}
}
}
fn main() {
let c1 = Complex { real: 1.0, imaginary: 2.0 };
let c2 = Complex { real: 3.0, imaginary: 4.0 };
let result = c1 + c2;
println!("Результат сложения: {} + {}i", result.real, result.imaginary);
}
// Вывод:
// Результат сложения: 4 + 6i
В приведенном выше исходном коде:
① мы импортируем общую черту Add из модуля std::ops, которая используется для перегрузки операторов;
② структура Complex представляет комплексные числа с действительной
и мнимой частями;
③ реализуем общую черту Add у структуры Complex, определив поведение
оператора +;
④ ассоциированный тип Output указывает тип результата сложения;
⑤ метод add выполняет поэлементное сложение и возвращает новый экземпляр структуры Complex.
Маркерные общие черты
В отличие от большинства общих черт, которые предоставляют методы, маркерные общие черты не требуют реализации каких-либо методов. Вместо этого они указывают на определенные свойства или поведение типов. Например,
общие черты Copy и Clone являются маркерными (технически Clone требует
наличия метода, но часто используется как маркер возможности дублирования). Они сигнализируют о том, что типы могут поверхностно копироваться
или клонироваться.
126
Общие черты и обобщения
Листинг 3.18. Маркерные общие черты
#[derive(Copy, Clone)]
struct MyStruct;
// ①
fn main() {
let original = MyStruct;
// ②
let copy = original;
// ③
let clone = original.clone(); // ④
}
В приведенном выше исходном коде:
① мы определяем пустую структуру MyStruct (т. е. без полей);
② в функции main создаем экземпляр original структуры MyStruct;
③ при присваивании экземпляра original переменной copy значение копируется поверхностно (из-за общей черты Copy);
④ для клонирования значения мы вызываем метод clone на экземпляре original.
Маркерные общие черты предоставляют способ обозначать общие черты,
не предусматривающие реализацию методов, а вместо этого символизирующие определенные характеристики типов. Такое разграничение помогает направлять поведение компилятора и реализовывать более индивидуализированные оптимизации.
Комбинирование общих черт
Ключевое слово where в Rust – это мощное предикатное условие, служащее для
добавления ограничений к реализациям общих черт. Оно особенно удобно
в ситуациях, когда желательно реализовать общую черту только для тех типов,
которые удовлетворяют сразу нескольким специфическим условиям.
Листинг 3.19. Композиция общих черт
trait Drawable { // ①
fn draw(&self);
}
trait Printable { // ①
fn print(&self);
}
#[derive(Debug)]
struct Complex { // ②
real: f64,
imaginary: f64,
}
impl<T> Drawable for T
where
T: Printable + std::fmt::Debug,
{
// ③
fn draw(&self) { // ④
Избегание конфликтов общих черт 127
println!("Начертание: {:?}", self);
}
}
impl Printable for Complex {
fn print(&self) {
println!("Распечатка: {} + {}i", self.real, self.imaginary);
}
}
fn main() {
let complex = Complex { real: 1.0, imaginary: 2.0 };
complex.draw();
}
// Вывод:
// Начертание: Complex { real: 1.0, imaginary: 2.0 }
В приведенном выше исходном коде:
① мы определяем две общие черты: Drawable и Printable;
② структура Complex представляет комплексные числа;
③ реализуем общую черту Drawable для любого типа, который уже реализует общие черты Printable и Debug, используя предикатное условие where;
④ метод draw использует общую черту Printable для вывода значения
на экран.
Встраивание предикатного условия where в реализации общих черт позволяет накладывать дополнительные ограничения. Этот прием дает возможность
разрабатывать сложные реализации, гарантируя, что они будут применимы
только к подходящим типам. Это мощный инструмент конструирования универсальных и защищенных абстракций на Rust.
Избегание конфликтов общих черт
Система общих черт в Rust тщательно сконструирована под обеспечение четких и однозначных реализаций. За счет этого появляется возможность избегать конфликтов, которые могли бы возникать, если бы несколько пакетов реа
лизовывали одну и ту же общую черту у одного и того же типа. Эта фундаментальная концепция называется согласованностью (когерентностью) общих
черт. Она имеет огромное значение для поддержания правильности исходного кода и предотвращения непреднамеренных столкновений между разными
реализациями коллективно используемой общей черты.
Представьте сценарий, в котором вы стремитесь предложить единый способ
печати типов из внешних пакетов. Определив общую черту, предоставляющую
метод печати, и реализовав ее у типов из внешних пакетов, вы сможете гарантировать, что исходный код будет оставаться свободным от конфликтов. Согласованность общих черт гарантирует отсутствие множества конфликтующих
реализаций одной и той же общей черты у конкретного типа, что способствует стабильности и предсказуемости исходного кода. Рассмотрим следующий
ниже фрагмент:
128 Общие черты и обобщения
Листинг 3.20. Согласованность в реализациях общих черт
use std::fmt::Debug; // ①
trait Printable {
// ②
fn print(&self) where Self: Debug { // Добавить границу общей черты
println!("{:?}", self);
// из-за форматного спецификатора {:?}
}
}
impl<T: Debug> Printable for T {} // ③
fn main() { // ④
let number = 12;
number.print();
}
// Вывод:
// 12
В приведенном выше исходном коде:
① мы импортируем общую черту Debug из пакета std::fmt;
② общая черта Printable задает дефолтный метод print, использующий общую черту Debug для печати значения;
③ реализация общей черты Printable у любого типа T, реализующего общую черту Debug, использует дефолтный метод print;
④ в функции main мы создаем экземпляр number и распечатываем его, используя метод print.
Приведенный выше исходный код иллюстрирует концепцию согласованности в реализациях общих черт. Ограничивая реализацию только теми типами, которые реализуют общую черту Debug, мы избегаем конфликтов, которые
могли бы возникать, если бы несколько пакетов предоставляли разные реализации одной и той же комбинации общей черты и типа.
Всеохватывающие реализации
В Rust всеохватывающие (так называемые «одеяльные») реализации обладают огромной силой, так как они позволяют без особых усилий применять
общую черту ко всем типам, соответствующим определенным критериям.
Этот подход оказывается чрезвычайно выгодным в ситуациях, когда нужно
установить общую функциональность для широкого круга типов, избавляя
от необходимости реализовывать общую черту для каждого типа по отдельности.
Ради иллюстрации давайте рассмотрим создание общей черты под названием Negate, целью которой является изменение знака значения переменной
на противоположный. Ниже приведен пример, который эффективно демонстрирует данную концепцию:
Суперчерты 129
Листинг 3.21. Всеохватывающие реализации
trait Negate { // ①
fn negate(&self) -> Self;
}
impl<T: Clone + std::ops::Neg<Output = T>> Negate for T { // ②
fn negate(&self) -> Self {
-self.clone()
}
}
fn main() {
// ③
let number = 12;
let negated = number.negate();
println!("Изменено на противоположный: {}", negated);
}
// Вывод:
// Изменено на противоположный: -12
В приведенном выше исходном коде:
① мы определяем общую черту Negate с методом negate;
② реализуем общую черту Negate для любого типа T, предоставляя всеохватывающую реализацию у всех типов, которые реализуют общую черту Neg;
③ в функции main мы меняем знак числа на противоположный, используя
метод negate.
Суперчерты
В языке программирования Rust концепция суперчерт позволяет создавать новые общие черты, которые дополняют или уточняют качества существующих.
Использование суперчерт дает прямой доступ к методам и ассоциированным
типам изначальной общей черты, что позволяет расширять или индивидуально настраивать их способности.
В качестве иллюстрации представьте общую черту с именем Displayable,
предназначенную для повышения эффективности общей черты Printable путем предоставления опций форматирования результата.
Листинг 3.22. Общие суперчерты
trait Printable {
fn print(&self);
}
// ①
trait Displayable: Printable { // ②
fn display(&self);
}
struct Person {
name: String,
}
// ③
130
Общие черты и обобщения
impl Printable for Person {
// ④
fn print(&self) {
println!("Name: {}", self.name);
}
}
impl Displayable for Person {
// ④
fn display(&self) {
println!("Выводится на экран: {}", self.name);
}
}
fn main() {
// ⑤
let person = Person {
name: String::from("Махмуд"),
};
person.print();
person.display();
}
// Вывод:
// Name: Махмцд
// Выводится на экран: Махмуд
В приведенном выше исходном коде:
① мы определяем общую черту Printable с методом print;
② общая черта Displayable расширяет общую черту Printable и добавляет
метод display;
③ структура Person представляет человека с именем;
④ реализуем общие черты Printable и Displayable для структуры Person,
предоставляя собственные реализации методов;
⑤ в функции main мы создаем экземпляр структуры Person и вызываем оба
метода: print и display.
Посредством спецификации суперчерты можно конструировать новую общую черту, уточняющую или расширяющую возможности уже существующих
общих черт. Это позволяет выстроить четко организованную иерархию общих
черт, которая отражает взаимосвязи между разными вариантами поведения.
Шаблон-обертка
Шаблон-обертка (дословно «новый тип») – это распространенный в Rust архитектурный шаблон, который заключается в создании нового типа, обертывающего уже существующий. Шаблон-обертка используется для создания разных
типов, которые имеют общее поведение с обертываемым типом, но при этом
типы воспринимаются компилятором как абсолютно разные. В реализации
этого архитектурного шаблона важную роль играют общие черты.
Представьте, что вы участвуете в разработке библиотеки для численных вычислений. Ваша цель – создать систему классифицирования, которая представляет результаты замеров вместе с соответствующими им единицами измерения.
Задача состоит в том, чтобы гарантировать способность выполнять математиче-
Шаблон-обертка 131
ские расчеты на величинах с совпадающими единицами измерения и одновременно с этим выявлять любые несоответствия единиц еще на этапе компиляции.
Для достижения этой цели служат архитектурный шаблон-обертка и общие
черты.
Листинг 3.23. Шаблон-обертка и общие черты
use std::ops::{Add, Sub};
trait Unit: Default + Copy {
fn unit() -> &'static str;
}
#[derive(Default, Copy, Clone, Debug)]
struct Meter;
// ①
impl Unit for Meter {
fn unit() -> &'static str {
"м"
}
}
#[derive(Default, Copy, Clone)]
struct Kilogram;
impl Unit for Kilogram {
fn unit() -> &'static str {
"кг"
}
}
#[derive(Debug)]
struct Quantity<U: Unit> {
value: f64,
unit: U,
}
// ②
// ③
// ④
// ⑤
impl<U: Unit> Add for Quantity<U> { // ⑥
type Output = Quantity<U>;
fn add(self, other: Quantity<U>) -> Quantity<U> {
Quantity {
value: self.value + other.value,
unit: U::default(),
}
}
}
impl<U: Unit> Sub for Quantity<U> { // ⑦
type Output = Quantity<U>;
fn sub(self, other: Quantity<U>) -> Quantity<U> {
Quantity {
value: self.value - other.value,
unit: U::default(),
}
}
}
132
Общие черты и обобщения
fn main() {
let distance1 = Quantity::<Meter> { value: 5.0, unit: Meter };
let distance2 = Quantity::<Meter> { value: 3.0, unit: Meter };
let total_distance = distance1 + distance2;
println!("Общее расстояние: {:?}", total_distance);
}
// Вывод:
// Общее расстояние: Quantity { value: 8.0, unit: Meter }
В приведенном выше исходном коде:
① мы определяем структуру Meter, являющуюся частью архитектурного
шаблона-обертки. Она представляет единицу измерения и используется
для обертывания единицы «метр»;
② реализуем общую черту Unit у структуры Meter, показывающую, что данная структура используется как «новый тип» для представления единицы
измерения;
③ определяем структуру Kilogram, также являющуюся частью архитектурного шаблона. Он представляет единицу измерения и используется для
обертывания единицы «килограмм»;
④ реализуем общую черту Unit у структуры Kilogram, демонстрируя тем самым, что структура Kilogram используется как «новый тип» для представления единицы измерения;
⑤ определяем обобщенную структуру Quantity, параметризованную типом, реализующим общую черту Unit, что служит примером использования шаблона-обертки. Он обертывает числовое значение с плавающей
точкой и единицу измерения;
⑥ реализуем общую черту Add у Quantity<U>, чтобы сделать возможной
операцию сложения для величин с одинаковыми единицами измерения;
⑦ реализуем общую черту Sub у Quantity<U>, чтобы сделать возможной операцию вычитания для величин с одинаковыми единицами измерения.
Архитектурный шаблон-обертка отчетливо виден в структурах Meter и
Kilogram, в которые вставлены единицы измерения для обеспечения типозащищенности и семантической ясности. В Rust архитектурный шаблон-обертка
является ценным приемом, способствующим созданию надежного и выразительного исходного кода, особенно в сложных предметных областях.
Типы с динамически изменяемым размером
Типы с динамически изменяемым размером (DST, от англ. dynamically sized
types) – это типы, размер которых неизвестен на этапе компиляции. Хотя компилятор языка Rust в первую очередь придерживается строгой типизации
и использования типов заранее известного размера, существуют сценарии,
в которых типы динамически изменяемого размера оказываются полезными. Один из распространенных вариантов использования – работа со срезами
данных динамического размера.
Общие черты приобретают огромное значение в ситуациях, когда дело доходит до работы с такими типами, особенно в сочетании с динамическими
Типы с динамически изменяемым размером 133
объектами. Теперь давайте рассмотрим простой пример, демонстрирующий
использование динамических объектов совместно с типами с динамически
изменяемым размером.
Листинг 3.24. Типы с динамически изменяемым размером
trait Shape {
fn area(&self) -> f64; // ① Определяем общую черту Shape с методом area
}
struct Circle {
radius: f64,
}
// ② Создаем структуру Circle с полем radius
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius // ③ Реализуем метод
}
}
fn main() {
let circle: &dyn Shape = &Circle { radius: 5.0 }; // ④ Динамический объект
println!("Площадь окружности: {}", circle.area()); // ⑤ Вызов метода
}
// Вывод:
// Площадь окружности: 78.53981633974483
В приведенном выше исходном коде:
① мы определяем общую черту Shape с методом area, который вычисляет
площадь фигуры;
② создаем структуру Circle с полем radius, чтобы представлять окружность;
③ блок impl реализует общую черту Shape для структуры Circle, предоставляя конкретную реализацию метода area для окружностей. Он позволяет вычислять площадь окружности по формуле std::f64::consts::PI *
self.radius * self.radius;
④ в функции main создаем динамический объект circle типа &dyn Shape,
который ссылается на экземпляр структуры Circle с радиусом 5.0. Это
пример типа с динамически изменяемым размером, так как конкретный
тип объекта circle определяется на этапе исполнения;
⑤ затем вызываем метод area на динамическом объекте circle, который
динамически перенаправляет вызов к реализации метода area в структуре Circle. Данный пример демонстрирует, как компилятор Rust обрабатывает динамическую диспетчеризацию методов общих черт, позволяя
работать с разными типами, реализующими одинаковую общую черту
посредством динамического объекта.
В этом разделе мы обследовали использование типов с динамически изменяемым размером (DST), которые позволяют работать с типами, чей размер
определяется не на этапе компиляции, а на этапе исполнения. Мы продемонстрировали эту концепцию на примере общей черты Shape и структуры Circle.
134
Общие черты и обобщения
Реализовав общую черту Shape у структуры Circle и использовав динамический объект (&dyn Shape), мы показали, как компилятор Rust управляет динамической диспетчеризацией методов общих черт, обеспечивая гибкость и полиморфизм в исходном коде. Типы с динамически изменяемыми размерами
являются мощным функциональным средством языка Rust, способствующим
гибкости на этапе исполнения и обеспечивающим абстракцию при работе
с разными типами, коллективно использующими общую черту.
Условное соблюдение
Условное соблюдение – это принятая в Rust концепция, которая заключается
в реализации общей черты у конкретного типа только при соблюдении определенных условий. Этот механизм обеспечивает мелкозернистый контроль
над тем, какие типы могут принимать общую черту, что дает гибкость и возможность специализации исходного кода.
В следующем ниже примере мы подробно разберем концепцию условного
соблюдения, определив общую черту Convertible, которая представляет типы,
способные конвертироваться в другие типы. В качестве иллюстрации нам послужат два типа единиц измерения температуры: Celsius и Fahrenheit. Общая
черта Convertible объявляет метод convert, и мы реализуем эту общую черту
как у структуры Celsius, так и у структуры Fahrenheit. Реализации позволяют
выполнять конвертации температуры между двумя этими единицами.
В функции main создаются экземпляры структур Celsius и Fahrenheit, и выполняются конвертации с помощью метода convert. Данный пример наглядно
показывает, как Rust поддерживает условное соблюдение, повышая выразительность и адаптивность исходного кода за счет реализации общих черт только при соблюдении специфических условий.
Листинг 3.25. Условное соответствие
trait Convertible<T> { // ①
fn convert(&self) -> T;
}
struct Celsius(f64);
// ②
struct Fahrenheit(f64); // ②
impl Convertible<Fahrenheit> for Celsius { // ③
fn convert(&self) -> Fahrenheit {
Fahrenheit(self.0 * 1.8 + 32.0)
}
}
impl Convertible<Celsius> for Fahrenheit { // ③
fn convert(&self) -> Celsius {
Celsius((self.0 - 32.0) / 1.8)
}
}
fn main() {
let celsius = Celsius(100.0);
// ④
let fahrenheit = Fahrenheit(212.0); // ④
Программирование на уровне типов
135
let converted_fahrenheit: Fahrenheit = celsius.convert();
let converted_celsius: Celsius = fahrenheit.convert();
}
println!("100°C по Фаренгейту: {:.2}°F", converted_fahrenheit.0); // ⑤
println!("212°F по Цельсию: {:.2}°C", converted_celsius.0);
// ⑤
// Вывод:
// 100 °C по Фаренгейту: 212.00 °F
// 212 °F по Цельсию: 100.00 °C
В приведенном выше исходном коде:
① мы определяем общую черту Convertible с методом convert;
② структуры Celsius и Fahrenheit представляют значения температуры;
③ реализуем общую черту Convertible у структур Celsius и Fahrenheit, чтобы разрешить конвертации;
④ в функции main создаем экземпляры структур Celsius и Fahrenheit;
⑤ используем метод convert для конвертации температур.
В приведенном выше примере условного соблюдения мы раскрыли мощный аспект Rust, который позволяет реализовывать общие черты только при
соблюдении определенных условий. Определив общую черту Convertible
и реализовав ее у структур Celsius и Fahrenheit, мы продемонстрировали, как
язык Rust обеспечивает мелкозернистый контроль над принятием общих черт.
Это функциональное средство Rust повышает выразительность и адаптивность исходного кода, позволяя специализировать реализации для разных
типов и ситуаций. Условное соблюдение – это ценный инструмент в арсенале Rust, позволяющий создавать более гибкий и эффективный исходный код,
особенно в сценариях, в которых определенные общие черты должны применяться только к конкретным типам или при особых обстоятельствах. Это еще
раз подчеркивает приверженность Rust принципам защищенности и правильности, предоставляя разработчикам инструменты, необходимые для четкого
и лаконичного выражения своих намерений в исходном коде.
Программирование на уровне типов
Программирование на уровне типов – это захватывающая концепция, в которой
эффективно используется мощь системы типов с целью воплощения сложной логики и взаимосвязей между типами. Учитывая сложность и обширность этой области, мы коснемся ее лишь вкратце, чтобы дать представление о ее потенциале.
Важнейшим применением программирования на уровне типов является
создание ограничений непосредственно на уровне типов, что позволяет определять сложные условия, которым типы должны соответствовать. В этом процессе общие черты играют жизненно важную роль, позволяя устанавливать
эти ограничения с помощью ассоциированных типов и границ общих черт.
Представьте, что вы разрабатываете игровую библиотеку и хотите гарантировать допустимость только определенных комбинаций классов персонажей
и оружия. Используя программирование на уровне типов, эти ограничения
можно обеспечить еще на этапе компиляции, гарантировав их соблюдение.
Ниже приведен упрощенный пример, демонстрирующий описанную идею:
136
Общие черты и обобщения
Листинг 3.26. Программирование на уровне типов
trait CharacterClass { // ①
type Weapon: Weapon;
// Метод для создания экземпляра ассоциированного типа
fn create_weapon() -> Self::Weapon;
}
trait Weapon { // ②
fn attack(&self);
}
struct Warrior; // ③
struct Mage;
// ③
struct Sword;
struct Staff;
// ④
// ④
impl Weapon for Sword {
fn attack(&self) {
println!("Удар мечом!");
}
}
impl Weapon for Staff {
fn attack(&self) {
println!("Волшебное заклинание посохом!");
}
}
impl CharacterClass for Warrior { // ⑤
type Weapon = Sword;
// Реализовать метод для создания Меча (Sword) для Воина (Warrior)
fn create_weapon() -> Self::Weapon {
Sword
}
}
impl CharacterClass for Mage { // ⑤
type Weapon = Staff;
// Реализовать метод для создания Посоха (Staff) для Мага (Mage)
fn create_weapon() -> Self::Weapon {
Staff
}
}
fn attack<C: CharacterClass>() { // ⑥
let weapon = C::create_weapon(); // Создать экземпляр ассоциированного типа
weapon.attack();
}
fn main() {
attack::<Warrior>();
attack::<Mage>();
}
// Вывод:
// Удар мечом!
// Волшебное заклинание посохом!
Заключение 137
В приведенном выше исходном коде:
① мы определяем общую черту CharacterClass с ассоциированным типом Weapon;
② общая черта Weapon задает метод attack;
③ структуры Warrior (Воин) и Mage (Маг) представляют классы-персонажи;
④ структуры Sword (Меч) и Staff (Посох) представляют оружие и реализуют
общую черту Weapon;
⑤ реализуем класс CharacterClass для Warrior и Mage, жестко связывая
их с конкретным оружием через ассоциированный тип;
⑥ в функции attack используется программирование на уровне типов, чтобы получить нужное оружие и вызвать метод атаки.
Приведенный выше пример наглядно показывает, как программирование
на уровне типов в Rust позволяет определять сложные взаимосвязи между типами и ограничения, гарантируя правильность исходного кода и соблюдение
специфических правил еще на этапе компиляции.
Это мощное функциональное средство бывает особенно полезно в тех областях, где правильность и защищенность имеют решающее значение, таких как
разработка игр или системное программирование.
Соображения производительности
Хотя обобщения и общие черты предоставляют мощные абстракции, важно
помнить о производительности при их использовании. В системе общих черт
в рамках Rust для динамических объектов по умолчанию используется динамическая диспетчеризация, которая может вносить небольшие накладные расходы на этапе исполнения по сравнению со статической диспетчеризацией.
Если вашей главной приоритетной задачей является максимальная производительность, то для статической диспетчеризации следует рассмотреть
использование обобщенных функций с мономорфизацией (т. е. статической
диспетчеризацией). Этот подход позволяет дополнительно оптимизировать
исходный код, хотя он может приводить к дублированию кода каждой конкретной специализации обобщенного типа.
Кроме того, общие черты, обладающие ассоциированными методами или
ассоциированными константами, могут демонстрировать менее предсказуемую производительность из-за своих динамических характеристик. Это представляет собой компромисс между гибкостью и производительностью.
Проводя профилирование и нагрузочное тестирование своего исходного
кода, можно точно определять узкие места в производительности и принимать
обоснованные решения относительно использования обобщений и общих черт.
Заключение
Обобщения и общие черты – это краеугольные камни принятого в Rust подхода к конструированию гибкого и эффективного исходного кода. Они позволяют создавать адаптивные, реиспользуемые и выразительные программные
решения, которые легко подстраиваются под различные сценарии. С помо-
138
Общие черты и обобщения
щью общих черт можно определять общее поведение, обеспечивающее единообразное взаимодействие между типами. Обобщения позволяют создавать
универсальный исходный код, который единообразно работает с разными типами данных, сохраняя при этом строгую типозащищенность.
В этой главе мы тщательно обследовали фундаментальные концепции обобщений и общих черт, углубившись в их синтаксис, реализацию и практическое
применение. Получив глубокое понимание приемов определения общих черт,
их эффективного применения к различным типам и использования огромного
потенциала обобщений, вы теперь готовы раскрыть всю мощь этих бесценных
инструментов.
Продолжая свой путь по языку Rust, следует помнить, что обобщения и общие черты – это не просто функциональные средства языка; они воплощают
философию конструктивного исполнения языка, в которой приоритетами являются ясность, возможность реиспользования и адаптивность. Принимая эти
концепции, вы присоединяетесь к сообществу разработчиков, которые ценят
элегантные и надежные решения.
Теперь настала ваша очередь применить на практике то, что вы узнали.
Используйте полученные знания в собственных проектах и наблюдайте, как
ваше программирование совершенствуется с точки зрения структуры, гибкости и эффективности. Обобщения и общие черты станут ценными инструментами в вашем стремлении создавать исключительное программное обеспечение, способное выдерживать испытание временем.
В следующей главе мы проведем обследование массивоподобных структур
данных Rust, таких как векторы, массивы, кортежи и срезы, а также коллекций
на основе хешей, включая хеш-словарь и хеш-множество. Мы узнаем об их характеристиках, вариантах использования и практическом применении, разберем типичные операции и манипуляции с коллекциями на Rust. Эти знания
позволят эффективно организовывать данные и уверенно решать широкий
спектр задач программирования на Rust из реальной практики.
Вопросы для самопроверки
Вопрос 1. Какова основная цель общих черт в Rust?
a) определять структуры данных
b) предоставлять дефолтные реализации функций
c) определять общее поведение, которое типы могут реализовывать
d) ограничивать доступ к данным
Вопрос 2. Какими возможностями наделяют разработчиков обобщения
в Rust?
a) определять дефолтные варианты поведения для типов
b) писать исходный код, работающий только с одним типом данных
c) создавать исходный код, который работает с разными типами данных
d) реализовывать проверки типов на этапе исполнения
Вопросы для самопроверки 139
Вопрос 3. Какова роль контролера заимствований в Rust?
a) разрешать неограниченный конкурентный доступ к данным
b) анализировать исходный код и гарантировать защищенное использование ссылок, предотвращая гонки за доступом к данным
c) автоматически исправлять ошибки гонок за доступом к данным на этапе
исполнения
d) внедрять замки (блокировки) и умные указатели Mutex с целью синхронизации доступа к данным
Вопрос 4. Что дает дефолтный метод в общей черте в Rust?
a) стандартный способ реализации общей черты
b) автоматическую реализацию для всех типов
c) способ обеспечивать строгое поведение для всех типов
d) механизм, предотвращающий индивидуальную настройку по типам
Вопрос 5. В чем ценность ассоциированных типов общих черт в Rust?
a) они избавляют реализующие типы от необходимости определять собственные методы
b) они обеспечивают жесткую иерархию типов в исходном коде
c) они позволяют реализующим структурам или перечислениям самим
определять фактический тип
d) они ограничивают использование обобщенных типов внутри общих черт
Вопрос 6. Какова основная выгода от использования обобщений в Rust?
a) улучшение производительности на этапе исполнения
b) создание исходного кода, специфичного для одного типа данных
c) повышение гибкости исходного кода и реиспользования
d) уменьшение многословности исходного кода
Вопрос 7. Какова основная роль контролера заимствования в предотвращении дефектов, связанных с конкурентностью?
a) он разрешает неограниченный конкурентный доступ к данным
b) он анализирует исходный код, чтобы обеспечивать защищенное использование ссылок и предотвращать гонки за доступом к данным
c) он автоматически урегулирует конфликты гонок за доступом к данным
на этапе исполнения
d) он внедряет замки (блокировки) и умные указатели Mutex с целью синхронизации
Вопрос 8. Как дефолтные методы в общих чертах уравновешивают стандартизацию и индивидуальную настройку?
a) они обеспечивают одинаковое поведение для всех типов, реализующих
эту общую черту
b) они предоставляют стандартную реализацию, которую типы могут при
необходимости переопределять
140
Общие черты и обобщения
c) они автоматически генерируют конкретно-прикладные методы для реа
лизующих типов
d) они ограничивают индивидуальную настройку заранее определенным
набором опций
Вопрос 9. В чем главное преимущество использования ассоциированных
типов у общих черт в Rust?
a) они избавляют от необходимости определять их собственные методы
b) они обеспечивают жесткую иерархию типов
c) они позволяют проводить более строгую проверку типов на этапе компиляции
d) они позволяют реализующим структурам или перечислениям определять фактический используемый тип
Вопрос 10. Чем обобщения отличаются от конкретных типов данных?
a) обобщения обеспечивают более высокую производительность на этапе
исполнения
b) обобщения работают только с одним фиксированным типом
c) обобщения повышают гибкость, позволяя работать с многочисленными
типами данных
d) обобщения требуют более многословного исходного кода, чем конкретные типы
Вопрос 11. Какое основное преимущество использования динамических
объектов?
a) они обеспечивают проверку типов на этапе компиляции
b) они позволяют использовать динамическую диспетчеризацию и адаптивность на этапе исполнения
c) они улучшают производительность исходного кода
d) они избавляют от необходимости использовать общие черты
Вопрос 12. Какова роль маркерных общих черт в Rust?
a) они определяют методы и ассоциированные значения для типов
b) они сигнализируют о специфических характеристиках или вариантах
поведения типов без реализации методов
c) они реализуют перегрузку операторов для типов
d) они задают ограничения типов для обобщенных функций
Вопрос 13. Что такое тип с динамически изменяемым размером в Rust?
a) тип, имеющий фиксированный размер, известный на этапе компиляции
b) тип, используемый для программирования на уровне типов
c) тип, размер которого неизвестен на этапе компиляции
d) тип, используемый для перегрузки операторов
Вопросы для самопроверки 141
Вопрос 14. В чем польза программирования на уровне типов в Rust?
a) оно упрощает создание макрофункций
b) оно позволяет использовать динамическую диспетчеризацию методов
c) оно позволяет определять сложные взаимосвязи типов и ограничения
d) оно избавляет от необходимости реализовывать общие черты
Ответы
1. c) определять общее поведение, которое типы могут реализовывать
2. c) создавать исходный код, который работает с разными типами данных
3. b) анализировать исходный код и гарантировать защищенное использование ссылок, предотвращая гонки за доступом к данным
4. а) стандартный способ реализации общей черты
5. c) они позволяют реализующим структурам или перечислениям самим
определять фактический тип
6. c) повышение гибкости исходного кода и реиспользования
7. b) он анализирует исходный код, чтобы обеспечивать защищенное использование ссылок и предотвращать гонки за доступом к данным
8. b) они предоставляют стандартную реализацию, которую типы могут
при необходимости переопределять
9. d) они позволяют реализующим структурам или перечислениям определять фактический используемый тип
10. c) обобщения повышают гибкость, позволяя работать с многочисленными типами данных
11. b) они позволяют использовать динамическую диспетчеризацию и адаптивность на этапе исполнения
12. b) они сигнализируют о специфических характеристиках или вариантах
поведения типов без реализации методов
13. c) тип, размер которого неизвестен на этапе компиляции
14. c) оно позволяет определять сложные взаимосвязи типов и ограничения
Глава 4
Встроенные в Rust
структуры данных
Введение
В этой обширной главе мы подробно рассмотрим основополагающие концепции и мощные инструменты, которые составляют костяк языка программирования Rust. Встроенные в Rust структуры данных обеспечивают надежный
фундамент для разработки эффективного и защищенного программного обес
печения. Представьте себе структуры данных как контейнеры, которые вы используете для организации вещей в своей комнате. В этой главе мы «откроем»
эти контейнеры и посмотрим, как они устроены.
Структуры
данных
Массив
Вектор
Кортеж
Срез
Хешмножество
Хешсловарь
Рисунок 4.1. Обзор встроенных в Rust структур данных
Прежде всего мы начнем с массивов, которые напоминают расставленные
коробки для хранения ваших вещей. Затем перейдем к векторам – универсальным контейнерам, способным вмещать многочисленные элементы. Кортежи, с другой стороны, работают как компактные посылки, в которых можно хранить коллекцию объектов разных типов. Срезы (диапазоны значений)
подобны окнам, позволяющим заглядывать внутрь контейнера, не открывая
его целиком. Множества служат уникальными коллекциями, гарантирующими отсутствие дубликатов, а хеш-словари напоминают справочники, позволяя
быстро извлекать нужную информацию.
На протяжении всей главы вы будете учиться использовать эти контейнеры
для простого и эффективного решения задач из реальной практики. К концу
главы вы овладеете умениями эффективно применять эти инструменты Rust
в собственных проектах. Это похоже на изучение приемов использования разных типов контейнеров, чтобы лучше организовывать свои вещи и быстрее
Массивы в Rust 143
справляться с делами. Итак, давайте приступим и узнаем, как структуры данных упрощают работу программиста на Rust!
Содержание главы
В этой главе будут рассмотрены следующие темы:
массивоподобные структуры данных в Rust: векторы, массивы, кортежи
и срезы;
коллекции Rust на основе хешей: хеш-словарь и хеш-множество;
демонстрация общих операций и манипуляций с коллекциями Rust
и многое другое.
Массивы в Rust
Массивы в Rust предоставляют мощный способ хранения коллекций значений
одного и того же типа. В отличие от векторов, размер которых может меняться
динамически, массивы имеют фиксированный размер и содержат элементы
идентичных типов данных. В этом разделе мы коснемся темы массивов Rust,
охватив все аспекты: от их создания и доступа к элементам до модификации
и итерации. Независимо от того, что вам нужно делать – выделять память
в стеке или работать с коллекциями постоянного размера, – массивы играют
жизненно важную роль в программировании на Rust.
Cтековый кадр
Массив
Вторник
Понедельник
Воскресенье
Рисунок 4.2. Упрощенная схема расположения массива в памяти
144
Встроенные в Rust структуры данных
Создание массивов
Создание массивов в Rust – это фундаментальная операция, которая позволяет эффективно хранить коллекции данных и манипулировать ими. Массивы
в Rust имеют фиксированный размер: это означает, что их длина предопределяется в момент их объявления, а все элементы внутри массива должны быть
одного типа. Для определения массива используются квадратные скобки [],
внутри которых значения перечисляются через запятую. Доступ к каждому
элементу осуществляется по его индексу, начиная с нуля.
Для того чтобы создать массив на Rust, используются квадратные скобки [] и затем внутри них перечисляются значения через запятую:
Листинг 4.1. Пример объявления простого массива с типом String
let days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday"];
В приведенном выше примере мы создали массив с именем days, который
содержит названия дней недели. Каждый элемент является строковым литералом, а размер массива определяется количеством элементов.
Кроме того, можно явно указывать как тип данных, так и количество элементов:
Листинг 4.2. Пример объявления простого массива с целочисленным типом
let a: [i32; 5] = [1, 2, 3, 4, 5];
Здесь мы объявляем массив a, состоящий из значений типа i32, с длиной,
фиксированной 5 элементами. Для того чтобы инициализировать все элементы
массива одинаковым значением, используется следующий ниже лаконичный
синтаксис:
Листинг 4.3. Пример объявления массива
с инициализацией повторяющимся значением
let zeros = [0; 5];
Этот исходный код создает массив длиной 5, все элементы которого инициа
лизированы нулем.
Умение конструировать массивы является важнейшей компетенцией в программировании на Rust, так как оно закладывает основу для грамотной и эффективной работы с коллекциями данных. Независимо от того, что вам нужно – простой набор значений или сложная структура данных, – синтаксис массивов в Rust предоставляет все необходимые средства для их создания и настройки под ваши задачи.
Доступ к элементам массива
Доступ к элементам массива – это фундаментальная и довольно прямолинейная операция в Rust.
Массивы в Rust 145
Cтековый кадр
Массив
Рисунок 4.3. Доступ к элементу внутри массива
Для получения конкретного элемента из массива просто используются
квадратные скобки [] с индексом нужного элемента.
Листинг 4.4. Пример доступа к элементам массива
let numbers = [1, 2, 3, 4, 5];
println!("{}", numbers[2]);
// Вывод:
// 3
В этом фрагменте исходного кода мы обращаемся к третьему элементу (индекс 2) массива numbers, который содержит значение 3.
Важно помнить, что индексация массивов в Rust начинается с 0, поэтому
самый первый элемент находится по индексу 0, и так далее для всех последующих элементов. Этот несложный синтаксис обеспечивает эффективный и точный способ доступа к данным, хранящимся в массивах, закрепляя за ним статус фундаментального функционального средства Rust по работе с массивами.
146
Встроенные в Rust структуры данных
Cтековый кадр
Массив
Рисунок 4.4. Изменение элемента массива
Изменение элементов массива
Поскольку массивы в Rust имеют фиксированный размер, отсутствует возможность динамически добавлять или удалять элементы, как в случае с векторами.
Тем не менее все же можно изменять отдельные элементы внутри массива,
объявив его мутируемым с помощью ключевого слова mut. Эта мутируемость
позволяет изменять значения элементов по конкретным индексам.
Листинг 4.5. Пример изменения элементов массива
let mut numbers = [1, 2, 3, 4, 5];
numbers[1] = 10;
// ①
println!("{:?}", numbers); // ②
// Вывод:
// [1, 10, 3, 4, 5]
В приведенном выше примере:
① мы меняем второй элемент (индекс 1) массива numbers на 10;
② выводим на экран измененный массив numbers, который теперь становится [1, 10, 3, 4, 5].
Хотя массивы в Rust имеют фиксированный размер, возможность их изменения позволяет обновлять существующие элементы, что делает их универсальными структурами данных для самых разных задач.
Массивы в Rust 147
Выполнение итераций по элементам массива в цикле
Для того чтобы получить каждый отдельный элемент массива, необходимо выполнить итерации по элементам массива, а не обращаться к каждому по отдельности через индексы. Существует несколько методов итерации по массиву, каждый из которых имеет свой синтаксис:
Листинг 4.6. Примеры выполнения итераций по массиву
let seasons = ["Зима", "Весна", "Лето", "Осень"];
// Использование цикла for-in
for season in seasons {
println!("{season}");
}
//
//
//
//
//
Вывод:
Зима
Весна
Лето
Осень
// Использование цикла for с индексом
for index in 0..seasons.len() {
println!("{}", seasons[index]);
}
//
//
//
//
//
Вывод:
Зима
Весна
Лето
Осень
// Использование цикла for с итератором
for season in seasons.iter() {
println!("{}", season);
}
//
//
//
//
//
Вывод:
Зима
Весна
Лето
Осень
Приведенные выше примеры демонстрируют разные способы выполнения итераций по массиву seasons с выводом названия каждого времени года
на экран. Вы можете выбрать тот метод итерации, который лучше всего соответствует вашим задачам и стилю программирования.
Взятие срезов из массивов
Возможность создания срезов в Rust – это мощное функциональное средство,
позволяющее формировать подмассив, содержащий только определенные
элементы из исходного массива, не изменяя самого источника.
148
Встроенные в Rust структуры данных
Cтековый кадр
Рисунок 4.5. Нарезка одномерного массива
Для выполнения этой операции необходимо определить диапазон индексов, используя синтаксис ...
Листинг 4.7. Пример взятия среза из массива
let numbers = [1, 2, 3, 4, 5];
let slice = &numbers[1..4]; // ①
println!("{:?}", slice);
// ②
// Вывод:
// [2, 3, 4]
В приведенном выше исходном коде:
① мы выполняем операцию взятия среза из массива numbers, чтобы создать новый массив, который включает в себя элементы, расположенные
по индексам 1, 2 и 3;
② результатом исполнения этого исходного кода будет подмассив [2, 3, 4].
Взятие срезов оказывается крайне полезным методом при работе с конкретной частью массива. Оно позволяет эффективно обрабатывать и извлекать специфические сегменты данных внутри массива без необходимости создавать новый массив с нуля. Этот инструмент экономит время и силы, обес
печивая легкость в манипулировании массивами в соответствии с вашими
потребностями.
Массивы в Rust 149
Многомерные массивы
Язык программирования Rust предоставляет возможность создания многомерных массивов путем вложения одного массива в другой. Подобная функциональная возможность позволяет с невероятной легкостью представлять
сложные структуры данных, такие как матрицы или таблицы.
Например, использование двумерного массива позволяет эффективно визуализировать игровое поле игры «крестики-нолики», состоящее из трех строк
и трех столбцов. Рассмотрим следующий ниже пример:
Листинг 4.8. Пример работы с многомерным массивом
fn main() {
// Определяем двумерный массив для поля «крестиков-ноликов»
let mut tic_tac_toe: [[char; 3]; 3] = [
[' ', 'X', 'O'],
['O', 'X', ' '],
['X', ' ', 'O']
];
// Выводим начальное состояние поля
println!("Первоначальное поле игры 'крестики-нолики':");
print_tic_tac_toe(&tic_tac_toe);
// Обновляем поле и выводим его снова
tic_tac_toe[1][2] = 'X';
tic_tac_toe[2][1] = 'O';
println!("Обновленное поле 'крестики-нолики':");
print_tic_tac_toe(&tic_tac_toe);
}
// Функция для печати игрового поля
fn print_tic_tac_toe(board: &[[char; 3]; 3]) {
for row in board.iter() {
for cell in row.iter() {
print!("{} ", cell);
}
println!();
}
}
//
//
//
//
//
//
//
//
//
Вывод:
Первоначальное поле игры 'крестики-нолики':
X O
O X
X O
Обновленное поле 'крестики-нолики':
X O
O X X
X O O
В приведенном выше фрагменте исходного кода мы объявляем переменную с именем tic_tac_toe как двумерный массив размерностью [3][3]. Каждый элемент массива представлен типом char, который может быть символом 'X', 'O' либо пробелом. Такая компоновка позволяет легко управлять со-
150
Встроенные в Rust структуры данных
стоянием игры. Сначала мы показываем исходную разметку поля, затем модифицируем ее и используем функцию print_tic_tac_toe для демонстрации
обновленной версии.
Функция print_tic_tac_toe отвечает за вывод поля в терминал, обеспечивая наглядное визуальное представление игрового процесса. Данный пример
демонстрирует базовую структуру управления игрой «крестики-нолики» с использованием многомерного массива в полноценной программе на Rust.
Работа с методами массивов
Стандартная библиотека Rust предлагает целый ряд методов для эффективной
работы с массивами. Эти инструменты включены в модуль std::array и позволяют упрощать стандартные операции. Одним из таких методов является iter,
используемый для выполнения итераций по массивам и исполнения функций
на каждом его элементе. Рассмотрим пример, демонстрирующий, как можно
удвоить каждое значение в массиве, используя метод iter:
Листинг 4.9. Пример использования методов массива
fn main() {
let mut numbers = [1, 2, 3, 4, 5];
for num in numbers.iter_mut() { // ①
*num *= 2;
}
println!("{:?}", numbers);
}
// Вывод:
// [2, 4, 6, 8, 10]
В приведенном выше примере:
① после применения операции удвоения к каждому элементу массива numbers результирующий массив примет вид [2, 4, 6, 8, 10].
Еще один полезный метод – sort, который позволяет сортировать элементы
массива прямо «на месте». Ниже представлен пример:
fn main() {
let mut unsorted = [4, 1, 7, 3, 9];
unsorted.sort();
println!("{:?}", unsorted); // ①
}
// Вывод:
// [1, 3, 4, 7, 9]
В приведенном выше примере:
① мы сортируем массив unsorted по возрастанию с использованием метода sort, получив результат [1, 3, 4, 7, 9].
Существует большое количество приемов работы с массивами, и упомянутые выше – лишь два из них. Стандартная библиотека Rust предлагает целый
ряд полезных функций для таких задач, как поиск, клонирование или нахождение максимального и минимального значений в массиве.
Массивы в Rust 151
Инициализация массивов и дефолтные значения
В процессе создания массивов могут возникать ситуации, когда крайне важно
задавать им предопределенные значения или создавать специфические шаблоны данных. Rust предоставляет целый ряд методов, которые можно оптимально
использовать для быстрого и эффективного достижения этих целей.
Инициализация дефолтными значениями
Для инициализации массива предопределенными, или дефолтными, значения
ми Rust предлагает синтаксис, в котором вы указываете размер. Ниже представлен пример: массив размером 10, каждый элемент которого получает дефолтное значение 0.
Листинг 4.10. Пример инициализации массива дефолтными значениями
let default_values = [0; 10];
Этот фрагмент исходного кода объявляет массив с именем default_values из
десяти элементов, каждый из которых равен 0.
Генерирование шаблонов с помощью итераторов
Rust предлагает мощные функции-итераторы, которые позволяют создавать
массивы со специфическими шаблонами или значениями. Показательным
методом в этом отношении является map, которая применяет функцию преобразования к каждому элементу. Ниже представлен пример генерации массива,
состоящего из чисел, возведенных в квадрат:
Листинг 4.11. Пример инициализации массива и использования итераторов
fn main() {
let original = [1, 2, 3, 4, 5];
let mut squared: [i32; 5] = [0; 5]; // Инициализируем массив нулями
let _ = original
.iter()
.enumerate()
.map(|(index, &value)| squared[index] = value * value)
.collect::<Vec<_>>();
println!("{:?}", squared);
}
// Вывод:
// [1, 4, 9, 16, 25]
В приведенном выше исходном коде мы используем метод iter, чтобы создать итератор по изначальному массиву. Затем мы применяем замыкание,
которое возводит каждый элемент в квадрат, и, наконец, собираем преобразованные элементы в новый массив squared.
Инициализация вычисляемыми значениями
Иногда может понадобиться инициализировать массив значениями, вычисляемыми на этапе исполнения программы. Rust позволяет использовать для
этого циклы. Ниже представлен пример генерации массива факториалов:
152
Встроенные в Rust структуры данных
Листинг 4.12. Пример инициализации массива и вычисления элементов
fn main() {
let mut factorials = [1; 10];
for i in 1..10 {
factorials[i] = factorials[i - 1] * (i as i32);
}
println!("{:?}", factorials);
}
// Вывод:
// [1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880]
В этом исходном коде мы начинаем с установки значения начального
элемента массива factorials равным 1. Затем используем цикл for для
вычисления и присваивания значений факториалов последующим элементам.
В результате будет получен массив, содержащий факториалы чисел от 0 до 9.
Длина массива и проверка границ
Понимание длины массива и обеспечение безопасности границ имеют решающее значение для написания надежного исходного кода на Rust. В этом разделе рассматриваются приемы получения длины массива и выполнения проверки границ при обращении к элементам.
Получение длины
Для того чтобы определить длину массива на Rust, удобно использовать метод len, который возвращает количество содержащихся в нем элементов. Эта
информация бывает критически важной в ситуациях, когда нужно выполнять
итерации по элементам массива или динамически манипулировать элементами. Рассмотрим следующий ниже пример:
Листинг 4.13. Пример получения длины массива
fn main() {
let numbers = [1, 2, 3, 4, 5];
let length = numbers.len(); // ①
println!("{:?}", length);
}
// Вывод:
// 5
В приведенном выше фрагменте исходного кода:
① мы используем метод len, чтобы получить длину массива numbers, которая равна 5.
Размер массива обозначает количество содержащихся в нем элементов
и служит надежным инструментом для обеспечения правильной работы исходного кода при обработке массивов разных размерностей. Функция len упрощает получение этой важной информации, делая процесс разработки на Rust
эффективнее.
Массивы в Rust 153
Проверка границ
Приоритетом Rust является защищенность, которая включает в себя тщательную проверку границ элементов массива. В случае попытки обращения к элементу по индексу, выходящему за пределы допустимого диапазона, ваша программа перейдет в состояние паники. Рассмотрим следующий ниже поясняющий пример:
Листинг 4.14. Пример проверки границ массива
let numbers = [1, 2, 3, 4, 5];
let index = 10;
let value = numbers[index];
В этом исходном коде мы пытаемся обратиться к элементу с индексом 10
в массиве numbers. Поскольку массив содержит только индексы от 0 до 4, это
приведет к панике на этапе исполнения программы.
Во избежание паники и обеспечения защищенного доступа следует всегда
выполнять проверку нахождения индекса в пределах границ массива, используя перед обращением к элементу инструкцию if или сопоставление
с шаблоном.
Копирование и клонирование массивов
При работе с массивами важно понимать, как компилятор Rust обрабатывает
их копирование и клонирование. В этом разделе рассматриваются различия
между копированием и клонированием массивов, а также ситуации, в которых
уместен каждый из этих подходов.
Копирование массивов
Массивы в Rust реализуют общую черту Copy, если типы их элементов также
реализуют Copy. Это означает, что у массивов с типами данных, поддерживающими общую черту Copy, копирование происходит неявно при присваивании
или вызове функций. Ниже представлен пример:
Листинг 4.15. Пример копирования массива
let original = [1, 2, 3];
let copied = original;
В этом исходном коде мы копируем изначальный массив original в массив copied посредством простого присваивания. Поскольку тип i32 реализует
общую черту Copy, эта операция разрешена.
Клонирование массивов
Если тип элементов массива не реализует общую черту Copy, то скопировать его
напрямую не получится. Вместо этого нужно использовать метод to_owned()
(или clone()), чтобы клонировать массив. Ниже представлен пример:
154
Встроенные в Rust структуры данных
Листинг 4.16. Пример клонирования массива
let original = ["one", "two", "three"];
let cloned = original.to_owned();
В этом фрагменте мы клонируем изначальный массив original в массив cloned с помощью метода to_owned(). Поскольку строковые литералы (в данном контексте экземпляры типа String) не поддерживают неявное копирование,
то есть общую черту Copy, этот подход необходим для создания глубокой копии.
Важно отметить, что клонирование массива предусматривает создание нового массива с идентичными элементами. Эта операция бывает затратной
с точки зрения памяти и производительности, поэтому использовать ее следует осмотрительно.
Массивы в Rust – это универсальные структуры данных, подходящие для
различных вариантов использования. Их природа (фиксированный размер)
делает их эффективными в ситуациях, когда размер данных известен заранее
и не меняется на этапе исполнения. Не важно, что вы делаете – работаете над
математическими вычислениями или управляете коллекциями константного
размера, – массивы являются ценным инструментом в вашем арсенале программиста на Rust. Благодаря знаниям, полученным в этом разделе, вы будете
готовы эффективно использовать массивы в своих проектах.
Векторы
Векторы, иногда именуемые динамическими массивами, – это универсальные
и фундаментальные структуры данных в Rust. Они позволяют хранить коллекцию значений одного типа и могут увеличиваться или уменьшаться в размере
по мере необходимости. Давайте обследуем приемы создания векторов и указания типов их элементов.
Cтек
Куча
Вектор
длина
емкость
адрес
"абрикос"
"банан"
"виноград"
Рисунок 4.6. Упрощенное представление расположения вектора в памяти
Векторы 155
Создание векторов
Процесс создания векторов в Rust отличается гибкостью и многообразием
подходов к управлению коллекциями данных, и для этого существует несколько методов, из которых два наиболее распространенных – это использование
конструктора Vec::new() и макрофункции vec!. Первый метод проиллюстрирован пунктом ① в приведенном ниже исходном коде:
Листинг 4.17. Пример инициализации вектора
let v: Vec<i32> = Vec::new(); // ①
Здесь мы создаем пустой вектор с именем v, явно указывая тип его элементов как i32. Аннотации типов в Rust гарантируют, что вектор будет иметь правильный тип, обеспечивая типозащищенность. Второй метод показан пунк
том ② в исходном коде ниже:
Листинг 4.18. Пример инициализации вектора
let numbers = vec![1, 2, 3, 4, 5]; // ②
В этом подходе используется макрофункция vec!, чтобы создать вектор
с именем numbers и начальными значениями. Встроенный в Rust механизм логического вывода типов автоматически определяет, что этот вектор содержит
элементы типа i32, что сокращает многословность исходного кода, поддерживая при этом строгую типозащищенность.
Выбор подхода зависит от конкретной ситуации. При использовании конструктора Vec::new() создастся пустой вектор, который можно заполнить элементами позже, тогда как макрофункция vec! позволяет сразу же инициализировать вектор предопределенными значениями. Эти два метода являются базовыми для управления динамическими коллекциями на Rust.
Доступ к элементам и их изменение
После создания вектора часто требуется доступ к его элементам или их изменение. Давайте разберем, как это можно делать безопасно и эффективно. Для
получения элементов из вектора можно использовать индексацию; однако
крайне важно помнить о возможности возникновения ошибок выхода индекса
за допустимые границы.
Листинг 4.19. Пример обращения к элементам вектора по индексу
fn main() {
let fruits = vec!["абрикос", "банан", "виноград", "дыня"]; // ①
}
let second = &fruits[1]; // ②
В приведенном выше примере:
① мы создаем вектор fruits, содержащий строковые литералы, представляющие различные фрукты;
156
Встроенные в Rust структуры данных
② здесь мы обращаемся ко второму элементу вектора fruits по индексу.
В Rust используется индексация с нуля, поэтому второй элемент имеет
индекс 1. Тем не менее индексация бывает рискованной, так как она
приводит к паникам, если пытаться обращаться к индексу за пределами вектора.
Для безопасного доступа к элементам рекомендуется использовать метод get:
Листинг 4.20. Безопасный доступ к элементам вектора с помощью метода get
fn main() {
let fruits = vec!["абрикос", "банан", "виноград", "дыня"];
let second = fruits.get(1); // ①
match second {
Some(fruit) => println!("Второй элемент: {}", fruit),
None => println!("Элемент не найден"),
}
}
// Вывод:
// Второй элемент: банан
В приведенном выше примере:
① метод get возвращает экземпляр перечисления Option<&T>, в котором Some содержит элемент (если он существует), а None означает отсутствие элемента. Этот подход обеспечивает защиту, возвращая вариант
перечисления вместо вызова паники.
Изменение векторов
Векторы в Rust по умолчанию могут мутировать1, что означает возможность
модификации их содержимого. Мутируемость позволяет адаптировать структуры данных на этапе исполнения программы, благодаря чему векторы универсально подходят для управления динамическими коллекциями. Независимо от того, что вы собираетесь делать, будь то добавление, обновление либо
удаление элементов, векторы обеспечивают гибкость, эффективно выполняя
эти преобразования.
Добавление элементов
Одной из самых распространенных операций при работе с векторами является добавление элементов, в частности присоединение новых данных в конец
вектора.
Эта операция особенно ценна при работе с растущими коллекциями. Rust
предоставляет простой способ ее реализации с помощью метода push. В приведенном ниже примере:
1
Технически после добавления ключевого слова mut в Rust мутировать может все. – Прим.
перев.
Векторы 157
Вталкивание
в вектор
Cтек
Куча
"абрикос"
Вектор
длина
емкость
"банан"
адрес
"облепиха"
"манго"
Рисунок 4.7. Добавление элемента в вектор
Листинг 4.21. Пример добавления элементов в вектор
fn main() {
let mut fruits = vec!["абрикос", "банан"]; // ①
// Добавление элемента "виноград" в конец вектора
fruits.push("виноград");
// ②
}
В приведенном выше примере:
① мы создаем мутируемый вектор fruits и инициализируем его двумя начальными элементами;
② используя метод push, добавляем строковый литерал "виноград" в конец
вектора. Вектор автоматически изменяет свой размер, чтобы вместить
новый элемент.
Динамическое добавление элементов в векторы – важнейшая способность
при работе с коллекциями данных, которые могут расти или меняться на этапе
исполнения программы. Эта функциональность в сочетании с ориентированностью Rust на защищенности и производительности делает векторы надежным
вариантом выбора для управления динамическими структурами данных.
Обновление элементов
Обновление элементов внутри вектора в Rust – это прямолинейная операция.
Ее можно выполнять, используя индексацию для доступа к конкретному элементу, который требуется изменить, а затем присваивая ему новое значение.
В приведенном ниже примере исходного кода:
158
Встроенные в Rust структуры данных
Листинг 4.22. Пример обновления элементов в векторе
fn main() {
let mut fruits = vec!["абрикос", "банан"]; // ①
// Обновление второго элемента на "груша"
fruits[1] = "груша";
// ②
}
В приведенном выше примере:
① мы создаем мутируемый вектор fruits с двумя начальными элементами;
② используя индексацию, заменяем второй элемент, "банан", на "груша".
Этот пример демонстрирует, как можно изменять элементы вектора прямо на месте.
Обновление элементов в векторе позволяет поддерживать и адаптировать
структуру данных, чтобы отражать реально-временные изменения, благодаря
чему векторы универсально подходят для управления эволюционирующими
коллекциями в Rust.
Удаление элементов
При работе с динамически меняющимися данными на Rust часто возникает
необходимость удаления элементов из вектора. Rust предоставляет несколько
методов решения этой задачи, позволяя настраивать подход индивидуально
в зависимости от конкретных потребностей вашей программы. Среди этих методов функция pop выделяется как высокоэффективный способ удаления и получения последнего элемента из вектора.
Cтек
Выталкивание
из вектора
Куча
"абрикос"
длина
емкость
адрес
"банан"
"облепиха"
"ма
н
го"
Рисунок 4.8. Удаление элемента из вектора
Векторы 159
Листинг 4.23. Удаление элементов из вектора с помощью метода pop
fn main() {
let mut fruits = vec!["абрикос", "банан", "виноград"]; // ①
// Удаление и сохранение последнего элемента
let removed_fruit = fruits.pop();
// ②
}
В приведенном выше примере:
① мы создаем мутируемый вектор fruits, инициализированный тремя
элементами;
② используя метод pop, удаляем последний элемент, "виноград", и сохраняем
его в переменной removed_fruit. Метод возвращает экземпляр Option<T>,
что гарантирует щадящую обработку возможных пустых векторов.
Для удаления элемента по конкретному индексу используется метод remove:
Листинг 4.24. Удаление элементов из вектора с помощью метода remove
fn main() {
let mut fruits = vec![ "абрикос", "банан", "виноград"]; // ①
// Удаление элемента по индексу 1 ("банан")
let removed_fruit = fruits.remove(1);
// ②
}
В приведенном выше примере:
① мы создаем мутируемый вектор fruits, инициализированный тремя
элементами;
② используя метод remove с индексом 1, удаляем элемент "банан". Метод
возвращает удаленный элемент.
Указанные методы удаления элементов обеспечивают гибкость и контроль
над данными, позволяя адаптировать коллекцию к меняющимся требованиям, сохраняя при этом гарантии Rust по защищенности.
Выполнение итераций по элементам в цикле
При работе с коллекциями в Rust крайне важно уметь выполнять фундаментальную операцию – выполнение итераций по элементам вектора в цикле. Этот
мощный метод позволяет последовательно обрабатывать каждый элемент, облегчая задачи по преобразованию, фильтрации и агрегации данных. Rust предоставляет целый ряд методов итерации, отвечающих самым разным требованиям программирования с максимальной гибкостью и адаптируемостью.
Использование цикла for
Для выполнения итераций по элементам вектора можно использовать цикл for:
Листинг 4.25. Выполнение итераций по элементам вектора с помощью цикла for
fn main() {
let numbers = vec![1, 2, 3, 4, 5]; // ①
160
Встроенные в Rust структуры данных
for number in &numbers {
println!("{}", number);
}
}
//
//
//
//
//
//
// ②
Вывод:
1
2
3
4
5
В приведенном выше примере:
① мы создаем вектор numbers с пятью целыми числами;
② с помощью цикла for выполняем итерации по элементам вектора,
выводя каждое число на экран. В данном случае мы используем ссылку
(&numbers), чтобы избежать передачи владения вектором.
Использование цикла for во время итерации предлагает простой и понятный
подход к последовательной обработке элементов вектора, что позволяет адаптировать его под самые разные задачи: от базового вывода на экран до сложных преобразований данных и операций фильтрации. В документации Rust
этот метод считается незаменимым для эффективной работы с коллекциями.
Использование метода iter_mut для мутируемой итерации
В ситуациях, требующих корректировки элементов вектора во время итерации, Rust предлагает эффективное решение в виде метода iter_mut. Этот метод
позволяет получать мутируемые ссылки на каждый элемент вектора и вносить
правки без создания новых экземпляров.
Листинг 4.26. Итерация по вектору с помощью метода iter_mut
fn main() {
let mut numbers = vec![10, 11, 12, 13, 14, 15]; // ①
for number in numbers.iter_mut() {
// ②
*number += 1; // Прибавить 1 к каждому элементу
}
println!("{:?}", numbers);
}
// Вывод:
// [11, 12, 13, 14, 15, 16]
В приведенном выше примере:
① мы создаем мутируемый вектор numbers с шестью целыми числами;
② используя метод iter_mut, получаем мутируемые ссылки на элементы
и увеличиваем каждый из них на 1 внутри цикла.
Этот подход позволяет эффективно изменять элементы вектора прямо на мес
те. Он особенно полезен в ситуациях, когда нужно корректировать данные внут
ри вектора, сохраняя при этом его структуру. Принятая в Rust система владения
и заимствования гарантирует защищенность и предотвращает гонки за досту-
Кортежи 161
пом к данным во время таких мутируемых итераций, что делает этот метод надежным выбором в том числе и для конкурентного программирования.
Использование метода enumerate для получения индекса и значения
Если во время итерации требуются и индекс, и значение элемента, то Rust предоставляет удобный метод enumerate.
Листинг 4.27. Итерация по вектору с помощью метода enumerate
fn main() {
let fruits = vec!["абрикос", "банан", "виноград"]; // ①
for (index, fruit) in fruits.iter().enumerate() { // ②
println!("Индекс {}: {}", index, fruit);
}
}
//
//
//
//
Вывод:
Индекс 0: абрикос
Индекс 1: банан
Индекс 2: виноград
В приведенном выше примере:
① мы создаем вектор fruits, содержащий строковые литералы;
② используя метод enumerate, выполняем итерации по элементам вектора в цикле, одновременно получая как индекс, так и значение каждого
элемента. Это бывает полезно в тех случаях, когда необходимо работать
одновременно и с позицией элемента, и с его содержимым.
Результат работы этого исходного кода демонстрирует, как метод enumerate
предоставляет лаконичный способ обращения одновременно к индексу и значению во время итераций по вектору, что бывает полезно в различных задачах
по обработке и манипулированию данными. Понимание и использование таких методов, как enumerate, расширяет ваши способности эффективно работать с векторами на Rust.
В этом разделе мы углубились в фундаментальные операции по работе
с векторами в Rust, предоставив прочную основу для эффективного использования этих динамических коллекций. В ходе данного обследования вы получили представление о ключевых операциях на векторах, включая их создание,
доступ к элементам, модификацию и выполнение итераций.
Векторы благодаря своей мутируемости и динамическому размеру служат
фундаментом для более продвинутых коллекций и задач по манипулированию данными на Rust. По мере того как вы будете продолжать осваивать программирование на Rust, глубокое понимание векторов окажется неоценимым
при работе с более сложными структурами данных и алгоритмами, позволяя
вам уверенно справляться с широким спектром задач.
Кортежи
Кортежи в Rust обладают поразительной гибкостью и адаптивностью, поскольку они позволяют хранить элементы разных типов в одной-единственной
162
Встроенные в Rust структуры данных
коллекции. В данном разделе мы более детально обследуем кортежи, раскроем их продвинутые функциональные возможности, варианты практического
применения и многое другое.
Создание и инициализация кортежей
Процедура создания кортежей на Rust довольно проста; их можно инициализировать различными способами в зависимости от конкретных требований
к вашим данным. Ниже представлено несколько наглядных примеров:
Листинг 4.28. Примеры инициализации кортежей
fn main() {
// Создание и инициализация кортежа
let person = ("Alice", 30, true); // ①
// Создание пустого кортежа
let empty: () = ();
// ②
// Аннотация типа для пустого кортежа
let another_empty: () = ();
// ③
}
В приведенном выше примере:
① мы создаем кортеж person с тремя элементами: строковый литерал, целое число и булево значение;
② пустой кортеж, который иногда называют единичным кортежем, создается с помощью пустых скобок. Он используется в ситуациях, когда нужно возвращать пустое значение или обозначать отсутствие значения;
③ здесь мы явно указываем аннотацию типа для еще одного пустого кортежа.
Доступ к элементам кортежа
Доступ к элементам внутри кортежа осуществляется напрямую по индексу, начиная с нуля.
Листинг 4.29. Примеры доступа к элементам кортежа
fn main() {
let person = ("Алиса", 30, true); //
// Доступ к первому элементу кортежа
let name = person.0;
//
// Доступ ко второму элементу
let age = person.1;
//
// Доступ к третьему элементу
let employed = person.2;
//
}
В приведенном выше примере:
①
②
③
④
① у нас есть кортеж person, содержащий имя, возраст и статус занятости;
② здесь мы обращаемся к первому элементу через точку и индекс .0.
В данном случае переменной name будет присвоен строковый литерал
"Алиса";
③ точно так же обращаемся ко второму элементу (возраст), используя .1;
④ наконец, обращаемся к третьему элементу (статус занятости), используя .2.
Кортежи 163
Деструктуризация кортежей
Деструктуризация кортежей позволяет извлекать отдельные элементы и присваивать их переменным, еще больше облегчая работу с кортежами:
Листинг 4.30. Пример деструктуризации кортежа
fn main() {
let person = ("Алиса", 30, true);
// ①
// Деструктуризация кортежа в переменные
let (name, age, employed) = person; // ②
}
В приведенном выше примере:
① мы определили кортеж person с тремя элементами;
② используя деструктуризацию, извлекаем элементы кортежа person и связываем их с переменными name, age и employed. Этот прием упрощает работу с кортежами и особенно полезен в ситуациях, когда нужно обрабатывать или манипулировать элементами по отдельности.
Кортежные шаблоны и продвинутое использование
Кортежные шаблоны в Rust позволяют выполнять более сложное сопоставление и деструктуризацию. Рассмотрим пример:
Листинг 4.31. Пример использования кортежей и сопоставления с шаблоном
fn main() {
let person = ("Алиса", 30, true); // ①
}
// Использование кортежного шаблона для сопоставления и деструктуризации
match person {
(name, age, true) => println!("{}: возраст {} лет, работает.",
name, age),
(name, age, false) => println!("{}: возраст {} лет, не работает.",
name, age),
_ => println!("Неизвестный статус занятости."), // ②
}
// Вывод:
// Алиса: возраст 30 лет, работает.
В приведенном выше примере:
① мы определили кортеж person с тремя элементами.
В приведенном выше примере мы задействуем кортежный шаблон в выражении match, чтобы классифицировать людей на основе их статуса занятости. На экран выводятся разные сообщения в зависимости от значений элементов в кортеже person;
② символ _ – это подстановочный шаблон, используемый для сопоставления
с любым значением, которое не было охвачено предыдущими шаблонами.
164
Встроенные в Rust структуры данных
Варианты использования из реальной практики
Кортежи, несмотря на их кажущуюся простоту, находят практическое применение в самых разных реальных сценариях. Давайте рассмотрим некоторые
продвинутые варианты использования кортежей.
Координатные вычисления
Кортежи отлично подходят для представления координат, благодаря чему они
идеально подходят для приложений, работающих с точками в пространстве.
Например, точку в двумерном пространстве можно элегантно определить как
кортеж из двух значений типа f64, представляющих координаты x и y:
Листинг 4.32. Использование кортежа в вычислениях координат
fn main() {
let point: (f64, f64) = (3.0, 4.0); // ①
// Вычисление расстояния от начала координат
let distance = (point.0.powi(2) + point.1.powi(2)).sqrt();
println!("Расстояние от начала координат: {}", distance);
}
// Вывод:
// Расстояние от начала координат: 5
В приведенном выше примере:
① мы создаем кортеж point, представляющий двумерную координату. Затем вычисляем расстояние от этой точки до начала координат, используя
теорему Пифагора.
Способность кортежей объединять различные типы данных в единую структурированную единицу делает их неоценимыми для организации и управления сложными данными в различных научных, инженерных и географических
приложениях. Такая универсальность распространяется и на другие области,
где кортежи оказываются полезными для легкого и наглядного представления
составных структур данных.
Обработка ошибок с помощью кортежей Result
Кортежи играют важную роль в парадигме обработки ошибок в Rust, упрощая
возврат нескольких значений из функций. Они позволяют создавать более информативные и структурированные сообщения об ошибках. Давайте рассмот
рим практический пример с обработкой ошибок:
Листинг 4.33. Использование кортежа в обработке ошибок
fn divide_with_remainder(dividend: i32, divisor: i32) -> Result<(i32, i32),
String> {
if divisor == 0 {
return Err("Деление на ноль".to_string());
}
let quotient = dividend / divisor;
let remainder = dividend % divisor;
Ok((quotient, remainder)) // Возврат кортежа с частным и остатком
}
Кортежи 165
В приведенном выше фрагменте исходного кода мы определяем функцию divide_with_remainder, которая принимает два целых числа: делимое (dividend) и делитель (divisor). Она возвращает экземпляр перечисления Result, содержащий кортеж из частного и остатка в случае успеха, либо
сообщение об ошибке в виде экземпляра типа String, если была предпринята
попытка деления на ноль.
Возвращая кортеж внутри экземпляра типа Result, мы обеспечиваем четкий
и структурированный способ обработки как успешных результатов, так и потенциальных ошибок. Такой подход повышает защищенность и надежность
исходного кода на Rust, гарантируя правильную обработку ошибок и предоставляя точную информацию об их природе. В данном контексте кортежи служат лаконичным и эффективным средством объединения нескольких значений, делая обработку ошибок на Rust отказоустойчивой и дружественной.
Ограничения кортежей
Несмотря на свою универсальность и полезность во многих сценариях, кортежи имеют ряд ограничений: в отличие от векторов или динамических коллекций, кортежи имеют фиксированный размер. Это означает, что после создания
кортежа невозможно динамически добавлять элементы в кортеж или удалять
элементы из них. Если вашей структуре данных требуется изменение размера,
то стоит рассмотреть использование других типов данных, к примеру векторов или коллекций, таких как Vec.
Вдобавок при работе с крупными и сложными структурами данных кортежи
могут становиться менее эргономичными, чем конкретно-прикладные структуры или перечисления с именованными полями.
Кортежи в операции сопоставления с шаблоном
Сопоставление с шаблоном в Rust – это мощное функциональное средство, которое позволяет деструктурировать кортежи и выполнять проверку их содержимого. Она особенно полезна в ситуациях, когда нужно извлекать значения
из кортежей на основе определенных условий. Рассмотрим несколько продвинутых приемов сопоставления кортежа с шаблоном.
Сопоставление кортежа с шаблонами
При использовании сопоставления с шаблоном можно создавать шаблоны,
совпадающие с конкретными кортежными структурами. Рассмотрим следующий ниже пример:
Листинг 4.34. Пример сопоставления кортежа с шаблоном
fn main() {
let coordinates = (3, 4);
match coordinates {
(0, 0) => println!("Точка
(x, 0) => println!("Точка
(0, y) => println!("Точка
(x, y) => println!("Точка
}
}
находится в начале координат."),
на оси х, где x = {}.", x),
на оси у, где y = {}.", y),
с координатами ({}, {}).", x, y),
166
Встроенные в Rust структуры данных
// Вывод:
// Точка с координатами (3, 4).
В приведенном выше фрагменте исходного кода мы создаем кортеж coordinates, представляющий двумерную точку. Затем используем сопоставление с шаблоном, чтобы классифицировать положение точки.
В выражении match есть четыре шаблона для разных случаев. Первый шаб
лон срабатывает, когда x и y равны 0. Последующие шаблоны проверяют, равен ли нулю один из компонентов, что указывает на нахождение точки на оси
х или у. Последний шаблон охватывает все остальные случаи.
Игнорирование элементов кортежа
В определенных ситуациях может понадобиться сопоставлять с кортежем,
но намеренно игнорировать один или несколько его элементов. Rust предоставляет для этого удобный механизм – символ подчеркивания (_).
Листинг 4.35. Пример игнорирования элементов кортежа
fn main() {
let student = ("Алиса", 25, "Информатика");
match student {
(name, _, major) => println!("{} – студент факультета '{}'.",
name, major),
}
}
// Вывод:
// Алиса – студент факультета 'Информатика'.
В приведенном выше исходном коде мы создаем кортеж student из трех
элементов: имя (name), возраст (age) и специализация (major). Однако в инструкции match нас интересуют только имя и специализация. Для того чтобы
эффективно игнорировать возраст, мы используем символ _. Это позволяет сосредоточиваться на извлечении только релевантных данных, повышая удобочитаемость и выразительность исходного кода.
Игнорирование элементов кортежа с помощью символа подчеркивания –
это полезное функциональное средство, применяемое в ситуациях, когда нужно работать только с определенными частями кортежа, не учитывая остальные. Благодаря ему исходный код на Rust становится лаконичнее и более соответствующим логике программы.
Вложенные кортежи и шаблоны
Кортежи могут быть вложены в другие кортежи, создавая сложные структуры
данных. Сопоставление с шаблоном можно использовать для навигации и деструктуризации таких вложенных структур.
Листинг 4.36. Пример вложенных кортежей и сопоставления с шаблоном
fn main() {
let person = (("Алиса", "Борисова"), 30);
match person {
Кортежи 167
((first_name, last_name), age) => {
println!("Имя: {} {}", first_name, last_name);
println!("Возраст: {}", age);
},
}
}
// Вывод:
// Имя: Алиса Борисова
// Возраст: 30
В приведенном выше исходном коде мы создаем кортеж person, который
содержит еще один кортеж, представляющий имя человека, и отдельное значение возраста. Вложенные шаблоны используются для того, чтобы извлекать
и выводить эти значения на экран.
Внешний шаблон ((first_name, last_name), age) в точности совпадает
со структурой кортежа person, позволяя получать доступ к вложенным значениям напрямую.
Опровержимость кортежных шаблонов
Понимание опровержимости кортежных шаблонов в Rust имеет решающее
значение для написания надежного и безошибочного исходного кода. Шаблон
считается опровержимым, если существуют такие входные значения, при
которых сопоставление не произойдет, что потенциально может приводить
к сбою операции сопоставления с шаблоном. С другой стороны, неопровержимый шаблон – это тот, который совпадет с любым входным значением, что
делает его всегда успешным.
Например, рассмотрим шаблоны (0, 0) и (x, 0). Шаблон (0, 0) является
опровержимым, так как он сработает только в том случае, если оба элемента
кортежа в точности равны 0. Если входной кортеж не отвечает этому точному
условию, то совпадение не произойдет. Напротив, шаблон (x, 0) является неопровержимым в контексте кортежа из двух элементов, где второй элемент
равен 0, так как он совпадет с любым таким кортежем независимо от значения
первого элемента.
В строгом понимании полностью неопровержимым шаблоном для кортежа из двух элементов будет (x, y), так как он примет абсолютно любые значения.
Понимание опровержимости шаблонов – это важный аспект написания стабильного исходного кода на Rust. Оно гарантирует, что ваша логика сопоставления является исчерпывающей и охватывает все возможные сценарии ввода
данных, повышая надежность и защищенность ваших программ.
Владение и заимствование в кортежах
Принятые в Rust правила владения и заимствования в равной степени применяются и к кортежам. Когда вы присваиваете кортеж другой переменной или
передаете его в функцию, компилятор Rust применяет эти правила точно так
же, как и для других типов данных. Давайте рассмотрим несколько сценариев
владения и заимствования на примере кортежей.
168
Встроенные в Rust структуры данных
Владение кортежами
Когда в Rust присваивают одну переменную другой и это присваивание содержит типы, реализующие общую черту Copy (например, целые числа или кортежи, имеющие типы с семантикой копирования Copy), значение копируется,
и по-прежнему можно использовать изначальную переменную. Однако когда
присваивают тип без семантики копирования (например, кортеж, содержащий
типы без общей черты Copy), происходит передача владения, и после этого
больше невозможно использовать изначальную переменную.
Листинг 4.37. Попытка использовать изначальный
кортеж приведет к ошибке компиляции
fn main() {
let original_tuple = (String::from("Hello"), String::from("World")); // ①
let new_tuple = original_tuple; // ②
// Раскомментирование этой строки кода приведет к ошибке
// println!("{:?}", original_tuple);
}
В приведенном выше примере:
① мы создаем кортеж original_tuple, содержащий два элемента;
② мы присваиваем кортежную переменную original_tuple новой переменной new_tuple. Эта операция перемещения передает владение переменной new_tuple, делая кортежную переменную original_tuple непригодной
для использования. Раскомментирование строки кода с попыткой использования переменной original_tuple приведет к ошибке компиляции.
Заимствование кортежей
В Rust есть возможность заимствовать ссылку на кортеж, не беря его во владение. Этот механизм заимствования позволяет получать доступ к элементам
кортежа без его перемещения, обеспечивая защищенный и эффективный коллективный доступ к данным. Рассмотрим пример:
Листинг 4.38. Пример кортежей и заимствования
fn print_coordinates(coordinates: &(i32, i32)) { // ①
println!("Координаты: {:?}", coordinates);
}
fn main() {
let point = (5, 7);
// ②
// Заимствование ссылки на кортеж point
print_coordinates(&point); // ③
// Кортеж point остается доступным для использования
println!("Координаты точки: {:?}", point); // ④
}
// Вывод:
// Координаты: (5, 7)
// Координаты точки: (5, 7)
Кортежи 169
В приведенном выше примере:
① мы определяем функцию print_coordinates, которая принимает ссылку
на кортеж в качестве аргумента;
② создаем кортеж point, представляющий координаты;
③ вызываем функцию print_coordinates, передавая ей ссылку на кортеж point. Это позволяет выводить на экран координаты, не беря кортеж
во владение;
④ кортеж point остается доступным после заимствования его ссылки, что
доказывает отсутствие передачи владения.
Приведенный выше пример демонстрирует способность Rust наделять различные функции и части программы способностью работать с данными конкурентно без передачи владения, обеспечивая защищенность и контроль над
состоянием программы.
Взятие срезов кортежей и оператор развертывания
Rust представляет мощное функциональное средство, именуемое взятием срезов кортежей, которое позволяет эффективно работать с подмножествами элементов кортежа. Это становится возможным благодаря использованию оператора развертывания (..). Давайте обследуем эту функциональность подробнее.
Создание срезов элементов кортежей
Взятие срезов кортежей дает возможность генерировать новый объект, содержащий выбранное подмножество элементов из существующего кортежа.
Вы указываете диапазон элементов, которые хотите включить в срез, используя оператор ... Ниже приведен наглядный пример:
Листинг 4.39. Пример взятия среза кортежа
fn main() {
let numbers = (1, 2, 3, 4, 5);
// ①
// Создание среза с элементами 2, 3 и 4
let slice = numbers.1..numbers.4; // ②
println!("{:?}", slice);
}
// Вывод:
// 2..5
В приведенном выше примере:
① мы определяем кортеж numbers, содержащий пять элементов;
② используя синтаксис среза, мы создаем новый срез, который включает
значения элементов под индексами 1, 2 и 3 (сами значения – 2, 3 и 4)
из кортежа numbers. Выражение numbers.1..numbers.4 задает диапазон
индексов для этого среза.
Взятие срезов кортежей с помощью оператора развертывания .. – это удобное функциональное средство, упрощающее процесс работы с частью элементов кортежа. Оно обеспечивает четкий и лаконичный способ создания новых
170
Встроенные в Rust структуры данных
кортежей, скроенных под ваши конкретные нужды, что повышает удобочитаемость и улучшает техническое сопровождение исходного кода.
Доступ к срезам кортежей
После создания среза кортежа можно получить доступ к его границам,
обратившись к свойствам start и end. С помощью указанных свойств
извлекаются значения внутри этого среза. Ниже приведен пример:
Листинг 4.40. Пример доступа к срезам кортежа
fn main() {
let numbers = (1, 2, 3, 4, 5); // ①
// Создание среза с элементами 2, 3 и 4
let slice = numbers.1..numbers.4;
// Доступ к элементам среза кортежа
let first_element = slice.start; // ②
let last_element = slice.end; // ③
println!("Первый элемент: {}", first_element);
println!("Последний элемент: {}", last_element);
}
// Вывод:
// Первый элемент: 2
// Последний элемент 5
В приведенном выше примере:
① мы определяем кортеж numbers, содержащий пять элементов;
② получаем доступ к первому значению среза, используя slice.start;
③ получаем доступ к последнему значению (верхней границе) среза, используя slice.end.
Используя операцию взятия срезов кортежей и доступ к элементам через
свойства start и end, можно добиваться точного управления определенным
диапазоном элементов. Этот прием оказывается крайне эффективным при
обработке обширных наборов данных или при концентрации на конкретных
подчастях внутри кортежей. Он позволяет без лишних хлопот манипулировать
данными и их обрабатывать.
Кортежи как аргументы функций
и возвращаемые из них значения
Кортежи часто используются в Rust в качестве аргументов функций и возвращаемых из них значений. Они обеспечивают удобный способ передачи нескольких значений в функции или возврата нескольких значений из них. Давайте обследуем эти сценарии.
Использование кортежей в качестве аргументов функций
В Rust можно определять функции, которые принимают кортежи в качестве
аргументов. Это удобный способ передачи нескольких значений как единого
целого, благодаря чему исходный код становится лаконичнее и более удобочитаемым. Ниже приведен пример:
Кортежи 171
Листинг 4.41. Использование кортежей в качестве аргументов функций
fn print_person_info(person: (&str, i32, &str)) { // ①
let (name, age, occupation) = person;
println!("Имя: {}", name);
println!("Возраст: {}", age);
println!("Род деятельности: {}", occupation);
}
fn main() {
let alice_info = ("Алиса", 30, "софтостроитель"); // ②
// Вызов функции с кортежем в качестве аргумента
print_person_info(alice_info); // ③
}
//
//
//
//
Вывод:
Имя: Алиса
Возврат: 30
Род деятельности: софтостроитель
В приведенном выше исходном коде:
① мы определяем функцию print_person_info, которая в качестве аргумента
принимает кортеж, представляющий информацию о человеке;
② создаем кортеж alice_info, содержащий имя, возраст и род деятельности
человека;
③ вызываем функцию print_person_info, передавая кортеж alice_info
в качестве аргумента, что позволяет удобно передать несколько значений сразу.
Использование кортежей в качестве аргументов функций упрощает спис
ки параметров, снижает необходимость в создании конкретно-прикладных
структур данных и повышает удобочитаемость исходного кода. Это особенно
полезно в ситуациях, когда функции должны получать и обрабатывать логически соотнесенные элементы данных.
Возврат кортежей из функций
В Rust функции могут возвращать кортежи в качестве результата, что позволяет передавать несколько значений как единое возвращаемое значение. Такой
подход обеспечивает изящный и структурированный способ возврата соотнесенных данных из функции. Ниже приведен пример:
Листинг 4.42. Пример возврата кортежей из функций
fn get_person_info(id: u32) -> (&'static str, i32, &'static str) { // ①
match id {
1 => ("Алиса", 30, "софтостроитель"),
2 => ("Боб", 28, "исследователь данных"),
_ => ("Неизвестное лицо", 0, "отсутствует"),
}
}
172
Встроенные в Rust структуры данных
fn main() {
let alice = get_person_info(1); // ②
println!("Имя: {}", alice.0);
println!("Возраст: {}", alice.1);
println!("Род деятельности: {}", alice.2);
}
//
//
//
//
Вывод:
Имя: Алиса
Возраст: 30
Род деятельности: софтостроитель
В приведенном выше исходном коде:
① мы определяем функцию get_person_info, которая принимает id в качестве аргумента и возвращает кортеж, представляющий информацию
о человеке;
② вызываем функцию get_person_info с определенным id и получаем результат в виде кортежа. Затем обращаемся к отдельным элементам кортежа и выводим их на экран.
Возврат кортежей из функций – это элегантный и лаконичный способ предоставлять вызывающей части исходного кода сразу несколько элементов информации. Это помогает поддерживать чистоту и удобочитаемость исходного кода,
группируя соотнесенные данные в рамках одного возвращаемого значения.
Кортежные варианты перечислений
В Rust кортежи также можно использовать внутри перечислений для определения вариантных структур данных. Варианты перечислений могут состоять
из кортежей, что позволяет представлять разные состояния или значения, связанные с каждым конкретным вариантом. Давайте обследуем эту концепцию.
Создание вариантов перечислений с использованием кортежей
В Rust можно определять варианты перечислений, содержащие кортежи для
представления разных состояний или данных. Такой подход позволяет создавать универсальные и выразительные перечисления. Ниже приводится пример:
Листинг 4.43. Пример создания вариантов перечислений с кортежами
enum Shape { // ①
Circle(f64),
Rectangle(f64, f64),
Triangle(f64, f64, f64),
}
fn main() {
let circle = Shape::Circle(5.0); // ②
let rectangle = Shape::Rectangle(4.0, 6.0); // ③
let triangle = Shape::Triangle(3.0, 4.0, 5.0); // ④
// Сопоставление с образцом для извлечения значений из вариантов перечисления
match circle {
Срезы 173
Shape::Circle(radius) => println!("Окружность с радиусом: {}", radius),
_ => (),
}
match rectangle {
Shape::Rectangle(length, width) => println!("Прямоугольник с длиной {}
и шириной {}", length, width),
_ => (),
}
match triangle {
Shape::Triangle(a, b, c) => println!("Треугольник со сторонами {}, {},
и {}", a, b, c),
_ => (),
}
}
//
//
//
//
Вывод:
Окружность с радиусом: 5
Прямоугольник с длиной 4 и шириной 6
Треугольник со сторонами 3, 4, и 5
В приведенном выше исходном коде:
① мы определяем перечисление Shape с тремя вариантами: Circle (Круг),
Rectangle (Прямоугольник) и Triangle (Треугольник). Каждый вариант
содержит кортеж с разным количеством элементов, чтобы представлять
разные фигуры;
② создаем экземпляр перечисления Shape с вариантом Circle, предоставив
значение радиуса в качестве элемента кортежа;
③ создаем экземпляр перечисления Shape с вариантом Rectangle, указав
длину и ширину;
④ мы создаем экземпляр перечисления Shape с вариантом Triangle, указав
длины сторон треугольника.
Используя механизм сопоставления с шаблоном, можно извлекать значения
из каждого варианта перечисления и управлять данными, связанными с каждой фигурой. Благодаря этому методу сложные состояния или структуры данных внутри перечисления представляются в простом и понятном виде.
Использование кортежей при создании вариантов перечислений повышает
чистоту исходного кода, а также дает возможность эффективно моделировать
различные сценарии данных. Это функциональное средство имеет невероятную ценность для определения конкретно-прикладных типов данных, которые охватывают разные возможности.
Выше были перечислены продвинутые темы, связанные с кортежами в Rust,
демонстрирующие их гибкость и полезность в различных сценариях. Понимание того, как эффективно использовать кортежи в разных контекстах, может
значительно улучшить ваши умения программирования на Rust.
Срезы
В следующем разделе мы займемся срезами – фундаментальной концепцией
Rust, которая играет решающую роль при работе с коллекциями и массивами.
Срезы предоставляют способ ссылаться на последовательность смежно рас-
174
Встроенные в Rust структуры данных
положенных элементов, позволяя выполнять операции на частях данных без
их копирования или передачи владения. Мы рассмотрим основы срезов, способы их создания и различные операции, которые можно на них выполнять.
Кроме того, мы углубимся в практическое применение и варианты использования срезов в Rust.
Понимание срезов
Срез в Rust – это ссылка на последовательность смежно расположенных элементов коллекции, такой как массив, вектор или строковый литерал. В отличие
от владеющих структур данных, срезы не обладают правом владения данными, на которые они ссылаются. Вместо этого они предоставляют заимствованный вид на нижележащие данные, что делает срезы мощным инструментом
эффективной работы с частями данных без затрат на копирование.
Срезы в Rust бывают двух основных видов: &[T] и &mut [T]. Нотация [T]
означает, что эти срезы могут содержать элементы типа T. Нотация &[T] представляет немутируемую ссылку на срез, позволяющую читать его элементы,
но запрещающую вносить изменения. Это полезно для обеспечения целостности данных и защищенного конкурентного доступа. С другой стороны,
&mut [T] – это мутируемая ссылка на срез, предоставляющая доступ как для
чтения, так и для записи. Такая мутируемость жизненно важна для модификаций прямо «на месте», благодаря чему она является мощным инструментом
для таких задач, как сортировка и преобразование данных. Эти разновидности
срезов воплощают стремление Rust к защищенности и контролю, предотвращая распространенные ошибки программирования и обеспечивая эффективность и надежность исходного кода.
Срезы в Rust состоят из следующих трех компонентов (нередко именуемых
«толстым указателем»):
указателя на первый элемент. Каждый срез в Rust включает в себя
указатель на свой первый элемент. Этот указатель указывает на начальную точку для операций внутри среза, обеспечивая эффективный доступ
к его данным. Он критически важен для локализации данных среза в памяти;
длины. Длина среза представляет собой количество содержащихся в нем
элементов. Эта информация необходима для итерации по срезу и гарантии невыхода за границы данных. Строгая проверка границ в Rust, упрощенная благодаря компоненту длины, помогает предотвращать такие
ошибки, как переполнение буфера;
емкости. Хотя срезы не владеют своими данными, они ограничены емкостью изначальной структуры данных, из которой они были получены (например, массива или вектора). Емкость указывает на максимальное количество
элементов, которое срез может удерживать без необходимости перераспределения памяти. Понимание емкости играет важную роль для оптимизации
использования памяти и предотвращения ненужных ее выделений.
В стандартном Rust сам объект среза &[T] обычно состоит только из указателя и длины. Понятие емкости принадлежит владеющей коллекции (например,
Vec<T>), а срез просто ограничен этой областью.
Срезы 175
Давайте взглянем на методы создания срезов и приемы работы с ними
на Rust.
Создание срезов
Создание срезов– это фундаментальная операция в Rust, которая позволяет
эффективно работать с конкретными частями данных. В зависимости от структуры данных, с которой вы имеете дело, существуют различные методы создания срезов. Рассмотрим приведенные ниже примеры:
Листинг 4.44. Примеры инициализации срезов
// Создание среза из массива
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let slice_arr: &[i32] = &arr[1..4]; // ①
// Создание среза из вектора
let vec: Vec<i32> = vec![1, 2, 3, 4, 5];
let slice_vec: &[i32] = &vec[1..4]; // ②
// Создание среза из строкового литерала
let text: &str = "Привет, Rust!";
let slice_text: &str = &text[0..5]; // ③
В приведенном выше исходном коде:
① мы создаем срез slice_arr из массива arr, включающий элементы по индексам с 1 по 3 (нижняя граница включается, верхняя – исключается);
② создаем срез slice_vec из вектора vec, используя ту же диапазонную нотацию;
③ создаем срез slice_text из строкового литерала text, включающий символы по индексам с 0 по 4.
Эти примеры демонстрируют универсальность срезов в Rust, которые обес
печивают облегченный и эффективный способ работы с подмножествами данных без затрат на копирование. Независимо от того, с какой структурой данных вы работаете, будь то массивы, векторы или строковые литералы, освоение умения создавать срезы необходимо для эффективного манипулирования
данными на Rust.
Доступ к элементам среза
После получения среза к его элементам можно обращаться так же, как и в случае с массивом или вектором. Срезы поддерживают индексацию и итерацию.
Листинг 4.45. Примеры доступа к элементам среза
fn main() {
let data: [i32; 5] = [10, 20, 30, 40, 50];
let slice: &[i32] = &data[1..4];
// Доступ к элементам по индексу
let second_element = slice[0]; // ①
176
Встроенные в Rust структуры данных
let third_element = slice[1];
let fourth_element = slice[2];
// Итерации по срезу
for element in slice.iter() { // ②
println!("Элемент: {}", element);
}
}
//
//
//
//
Вывод:
Элемент: 20
Элемент: 30
Элемент: 40
В приведенном выше примере:
① мы получаем доступ к элементам среза по индексу точно так же, как и в
случае с массивом;
② элементы среза можно также прокручивать в цикле, используя циклическую конструкцию for и метод iter.
Изменение элементов среза
Мутируемые срезы (&mut [T]) – это мощное функциональное средство Rust,
позволяющее модифицировать элементы, на которые они ссылаются. В отличие от немутируемых срезов, предоставляющих доступ только для чтения,
мутируемые срезы дают возможность изменять, добавлять или удалять элементы внутри среза. Эта способность особенно ценна в ситуациях, когда необходимо вносить изменения в данные прямо «на месте» в таких структурах,
как массивы или векторы.
Независимо от того, работаете ли вы с массивами, векторами или другими
структурами данных, мутируемые срезы позволяют вносить правки без создания
копий, что делает их критически важным инструментом для задач по манипулированию данными. Они позволяют эффективно обновлять данные без необходимости копирования, что не только повышает производительность, но и помогает
поддерживать защищенность работы с памятью, соблюдая принятые в Rust правила владения и заимствования. Давайте рассмотрим следующий ниже пример:
Листинг 4.46. Примеры изменения элементов среза
fn modify_data(data: &mut Vec<i32>) {
let slice = &mut data[1..4];
// Изменение элементов
slice[0] = 99;
// ①
// Симуляция добавления элементов
let new_elements = [60];
data.insert(4, new_elements[0]);
// ②
// Симуляция удаления элементов
let _removed_element = data.remove(4); // ③
}
fn main() {
let mut data: Vec<i32> = vec![10, 20, 30, 40, 50];
modify_data(&mut data);
Срезы 177
// Печать вектора данных
println!("Вектор данных:");
for element in &data {
println!("{}", element);
}
// Печать среза
let slice = &mut data[1..4];
println!("Срез:");
for element in slice.iter() {
println!("{}", element);
}
}
//
//
//
//
//
//
//
//
//
//
//
Вывод:
Вектор данных
10
99
30
40
50
Срез:
99
30
40
В приведенном выше исходном коде:
① мы изменяем первый элемент мутируемого среза;
② добавляем элемент в конец мутируемого среза, тем самым изменяя его
размер;
③ удаляем последний элемент из мутируемого среза и сохраняем его значение в переменной removed_element.
Независимо от того, что вы делаете, будь то сортировка элементов, обновление значений или динамическое изменение размера коллекции, мутируемые срезы позволяют эффективно и защищенно манипулировать данными.
Это возможно благодаря встроенной в Rust системе владения и заимствования, которая гарантирует отсутствие гонок за доступом к данным и проблем
с памятью при модификации данных. Понимание принципов работы с мутируемыми срезами – это ключ к написанию высокопроизводительного и надежного исходного кода на Rust, особенно в алгоритмах или задачах по обработке
данных, нуждающихся в динамических изменениях.
Практические применения
Срезы являются универсальным инструментом в Rust и находят широкое применение в самых разных областях. Ниже представлено несколько типичных
сценариев.
Манипуляция строковыми литералами
Задачи по манипулированию строковыми литералами нередко требуют работы с подстроками. Срезы неоценимы в подобных случаях, так как позволяют
ссылаться на части строковых литералов без копирования данных. Срезы часто
178
Встроенные в Rust структуры данных
используют для извлечения расширений файлов, разбора строк журналов событий или обработки сегментов URL-адресов. В приведенном ниже примере
исходного кода показано, как извлекать расширение файла из его имени.
Листинг 4.47. Пример манипуляции строковыми
литералами с использованием срезов
fn main() {
let filename = "example.txt";
let extension: &str = &filename[filename.len() - 3..]; // ①
println!("Расширение файла: {}", extension);
}
// Вывод:
// Расширение файла: txt
В приведенном выше фрагменте исходного кода:
① мы извлекаем расширение файла, создавая срез, который включает три
последних символа строки.
Мастерское владение срезами на Rust для работы со строковыми литералами играет критически важную роль для написания чистого и эффективного исходного кода. Срезы позволяют разработчикам выполнять сложные операции
на тексте с высокой точностью, используя преимущества системы владения
в эффективном управлении памятью и ресурсами.
Обработка данных
Срезы играют ключевую роль в задачах обработки данных, позволяя разработчикам эффективно манипулировать конкретными сегментами данных и их преобразовывать. В таких операциях, как фильтрация, отображение/проецирование
или агрегация элементов, срезы оказываются незаменимыми благодаря своей
способности предоставлять лаконичное представление подмножества данных без
необходимости их дублирования. Это повышает производительность и эффективность использования памяти, одновременно упрощая логику исходного кода.
Листинг 4.48. Пример обработки данных с использованием срезов
fn calculate_average(slice: &[f64]) -> f64 {
let sum: f64 = slice.iter().sum();
sum / slice.len() as f64
}
fn main() {
let data: [f64; 6] = [2.5, 3.0, 1.5, 4.0, 2.0, 3.5];
let slice: &[f64] = &data[1..5]; // Взять элементы с индексами 1, 2, 3 и 4
let average = calculate_average(slice);
println!("Среднее: {:.2}", average);
}
// Вывод:
// Среднее: 2.62
Срезы 179
Приведенный выше пример исходного кода иллюстрирует использование
среза в сценарии обработки данных с вычислением среднего значения подмножества чисел с плавающей точкой. Ссылаясь на часть изначального массива, мы устраняем накладные расходы на копирование ненужных элементов. Срезы позволяют оптимизировать вычисления: мы суммируем элементы
внутри среза и делим на его длину, что в итоге дает понятный и эффективный
алгоритм. Поддержка срезов в Rust значительно облегчает манипулирование
данными, благодаря чему этот язык идеально подходит для задач, связанных
с обработкой структурированной информации.
Токенизация текста
Токенизация текста – фундаментальная операция в обработке естественного
языка и текстовом анализе – значительно упрощается на Rust и становится эффективнее при использовании срезов. С помощью срезов можно удобно разделять текст на составляющие его единицы, такие как слова, предложения или
абзацы, основываясь на определенных разделителях или шаблонах.
Листинг 4.49. Пример токенизации текста с использованием срезов
fn tokenize_sentence(sentence: &str) -> Vec<&str> {
sentence.split_whitespace().collect()
}
fn main() {
let text: &str = "Rust является языком системного программирования.";
let words = tokenize_sentence(text);
println!("Слова: {:?}", words);
}
// Вывод:
// Words: ["Rust", "является", "языком", "системного", "программирования."]
В приведенном выше примере исходного кода мы демонстрируем разбивку предложения на отдельные слова. Используя возможности срезов в Rust,
мы применяем метод split_whitespace, чтобы разбить предложение на слова по пробельным символам. В результате получается вектор строковых срезов (&str), который представляет собой организованное и удобное для управления представление текстовых данных. Подобные методы играют ключевую
роль во многих задачах обработки текста, включая классифицирование текстов, анализ настроений и поиск информации, в которых понимание структуры текста имеет первостепенное значение.
Работа с двоичными данными
Работа с двоичными данными – еще одна область, где срезы оказываются бесценными. Разбор двоичных форматов, таких как заголовки файлов или сетевые пакеты, зачастую нуждается в детальном подходе для доступа к конкретным сегментам данных. В таких случаях срезы выходят на первый план как
механизм эффективного обращения к частям двоичных данных.
180
Встроенные в Rust структуры данных
Листинг 4.50. Пример обработки двоичных данных с использованием срезов
fn parse_header(header_data: &[u8]) {
// Здесь происходит разбор двоичных данных заголовка
}
fn main() {
let binary_data: [u8; 16] = [
0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x2C, 0x20, 0x52,
0x75, 0x73, 0x74, 0x21, 0x00, 0x00, 0x0A, 0x0D
];
let header_slice: &[u8] = &binary_data[0..8];
parse_header(header_slice);
}
В приведенном выше примере исходного кода иллюстрируется применение
срезов для разбора двоичного заголовка. Создавая срез байтов из более крупного
массива двоичных данных, можно эффективно сосредоточиваться на секции заголовка для дальнейшей обработки, например для извлечения метаданных или проверки целостности данных. Этот подход обеспечивает одновременно и производительность, и точность при работе с двоичными данными. Он незаменим в таких
приложениях, как разбор форматов файлов, сетевой обмен данными и криптография, где высокая точность и эффективность являются приоритетными задачами.
Отображение в память
Отображение в память – это эффективный подход к управлению файлами или
наборами данных большого объема. В нем используются срезы для бесшовной
интеграции частей крупных файлов в память. Такая интеграция обеспечивает
быстрый и эффективный произвольный доступ к конкретным сегментам файла, значительно расширяя возможности обработки данных.
Листинг 4.51. Пример отображения в память с использованием срезов
use std::fs::File;
use std::io::{self, Read};
fn main() -> io::Result<()> {
// file.txt содержит:
// Hello
// World
let mut file = File::open("/path/to/file.txt")?;
let mut buffer = [0; 1024];
// Прочитать часть файла в буфер
let bytes_read = file.read(&mut buffer)?;
// Обработать буфер как срез
let slice = &buffer[0..bytes_read];
println!("Прочитано {} байт: {:?}", bytes_read, slice);
Ok(())
}
// Вывод:
// Прочитано 11 байт: [72, 101, 108, 108, 111, 10, 87, 111, 114, 108, 100]
Хеш-множества в Rust 181
В приведенном выше фрагменте исходного кода мы извлекаем сегмент
объемного файла в буфер и работаем с ним как со срезом. Этот подход дает
возможность эффективно обращаться к конкретным сегментам файла, пока
он находится в памяти.
Срезы – это мощный и универсальный инструмент Rust, обеспечивающий
эффективное и защищенное манипулирование данными в различных областях. По мере вашего продвижения в изучении Rust понимание и освоение
срезов станет бесценным умением для написания высокопроизводительных
и надежных программ на Rust.
Хеш-множества в Rust
Хеш-множества – это мощный ресурс, доступный в модуле стандартной биб
лиотеки Rust std::collections::HashSet. Они служат эффективным средством
организации коллекций, состоящих из уникальных и неупорядоченных элементов. Хеш-множества сравнимы с множествами (set) в языке Python и будут
подробно рассмотрены в данном обширном разделе. Мы углубимся в процесс
создания, модификации и исполнения продвинутых функций с использованием хеш-множеств. Кроме того, мы проведем обследование нескольких областей их практического применения.
Создание множества
Процедура создания хеш-множества на Rust достаточно проста. Сначала необходимо импортировать тип HashSet из стандартной библиотеки. После этого сущест
вует несколько способов инициализировать хеш-множество под свои нужды.
Листинг 4.52. Импорт модуля HashSet
use std::collections::HashSet;
Затем можно создать новое пустое хеш-множество, используя метод new,
а в качестве альтернативы хеш-множество можно создать из вектора или массива элементов:
Листинг 4.53. Методы инициализации хеш-множества
let mut my_set: HashSet<i32> = HashSet::new(); // ①
// Хеш-множество из вектора элементов:
let my_vector = vec![1, 2, 3, 4];
let my_set: HashSet<i32> = my_vector.into_iter().collect(); // ②
// Хеш-множество из массива элементов:
let a = HashSet::from([1, 2, 3]);
// ③
В приведенном выше фрагменте исходного кода:
① мы создаем новое пустое хеш-множество типа i32;
② создаем хеш-множество из вектора путем сбора его элементов;
③ инициализируем множество напрямую из массива.
182
Встроенные в Rust структуры данных
Данный фрагмент исходного кода показывает, как начинать, инициируя
пустое хеш-множество функцией new, указав тип элементов, которые оно будет содержать. Еще один вариант – заполнить хеш-множество, конвертировав
вектор в итератор и затем собрав все его элементы, как показано во втором
примере. Этот подход удобен при работе с уже существующей коллекцией. Наконец, Rust позволяет выполнять прямую инициализацию множеств из массивов, что продемонстрировано в третьем примере.
Более подробная информация о создании хеш-множеств находится в официальной документации по адресу: https://doc.rust-lang.org/std/collections/struct.
HashSet.html#method.new.
Обновление множества
В Rust процесс обновления множества весьма прост, в особенности когда речь
идет о хеш-множествах. Обновление может предусматривать добавление элементов, их изменение или удаление в соответствии с вашими требованиями.
Ниже мы сперва сосредоточимся на первом аспекте – добавлении новых элементов в хеш-множество.
Добавление элементов
Добавить элементы в хеш-множество очень просто с помощью метода insert:
Листинг 4.54. Пример добавления элементов в хеш-множество
let mut my_set: HashSet<i32> = HashSet::new();
my_set.insert(1); // ①
my_set.insert(2);
my_set.insert(3);
① Вставка элементов в хеш-множество.
Удаление элементов
Удаление элементов из хеш-множества выполняется с помощью метода remove:
Листинг 4.55. Пример удаления элементов из хеш-множества
let mut my_set = HashSet::from([1, 2, 3, 4]);
my_set.remove(&2); // ① Удаляет элемент 2 из множества
① Удаление конкретного элемента из множества.
Для получения дополнительной информации об обновлении хеш-множеств
обратитесь к официальной документации по методам insert и remove.
Продвинутые операции на множествах
Хеш-множества в Rust поддерживают продвинутые операции, которые незаменимы при работе со сложными структурами данных. Одной из таких операций является симметрическая разность.
Хеш-множества в Rust 183
Симметрическая разность
Операция симметрической разности возвращает элементы, которые присутствуют в одном множестве, но отсутствуют в другом. Проиллюстрируем ее на
примере двух множеств, set_x и set_y:
Листинг 4.56. Пример симметрической разности хеш-множеств
use std::collections::HashSet;
let set_x = HashSet::from([1, 2, 3, 4]);
let set_y = HashSet::from([3, 4, 5, 6]);
let symmetric_difference = set_x.symmetric_difference(&set_y);
for element in symmetric_difference {
println!("{}", element);
}
//
//
//
//
//
Вывод:
1
2
5
6
Операция симметрической разности важна для сравнения и сопоставления
множеств, помогая эффективно определять различия между ними. Она бывает
особенно полезной в сценариях, в которых необходимо найти несовпадающие
элементы или области наложения коллекций друг на друга.
Обратите внимание, что метод remove(&2) принимает ссылку на значение. Это характерная особенность Rust: для поиска элемента в коллекции не обязательно передавать
право владения самим объектом.
Проверка на подмножество и надмножество
Для проверки того, является ли одно множество подмножеством либо надмножеством другого, соответственно используются методы is_subset и is_
superset:
Листинг 4.57. Пример проверки подмножества
и надмножества в хеш-множествах
use std::collections::HashSet;
fn main() {
let set_a = HashSet::from([1, 2, 3]);
let set_b = HashSet::from([1, 2, 3, 4, 5]);
let is_subset = set_a.is_subset(&set_b); // ①
let is_superset = set_b.is_superset(&set_a); // ②
println!("set_a является подмножеством set_b? {}", is_subset);
println!("set_b является надмножеством set_a? {}", is_superset);
}
184
Встроенные в Rust структуры данных
// Вывод:
// set_a является подмножеством set_b? true
// set_b является надмножеством set_a? true
В приведенном выше фрагменте исходного кода:
① мы проверяем, является ли множество set_a подмножеством множест
ва set_b;
② проверяем, является ли множество set_b надмножеством множест
ва set_a.
Для получения подробной информации о расширенных операциях на множествах обратитесь к официальной документации по адресу: https://doc.rustlang.org/std/collections/struct.HashSet.html#set-operations
Практические применения
Хеш-множества имеют широкий спектр практических применений благодаря
эффективной работе с уникальными элементами и операциями на множеств
ах, включая перечисленные ниже.
Дедупликация
Дедупликация – это удаление одинаковых записей из коллекции. Она критически важна в сценариях, в которых необходимы целостность и уникальность
данных, таких как ведение списка уникальных ИД пользователей, обработка
журнальных записей с целью выявления уникальных событий или обеспечение уникальности записей в наборе данных. Хеш-множества упрощают процесс удаления дубликатов, становясь незаменимым инструментом в различных прикладных задачах.
Листинг 4.58. Пример использования хеш-множества для дедупликации
use std::collections::HashSet;
fn main() {
let duplicates = vec![1, 2, 2, 3, 4, 4, 5];
let unique_elements: HashSet<i32> = duplicates.into_iter().collect();
println!("{:?}", unique_elements);
}
// Результат (порядок может отличаться):
// {5, 4, 3, 1, 2}
В приведенном выше фрагменте исходного кода мы показываем, как использовать хеш-множества для дедупликации на Rust. Мы начинаем с вектора duplicates, содержащего повторяющиеся значения. Превращая этот вектор
в итератор и собирая элементы в хеш-множество, мы эффективно избавляемся от дублей. Результирующее множество unique_elements содержит только
уникальные значения из изначального вектора, гарантируя, что каждый элемент встречается лишь один раз.
Дедупликация играет решающую роль в сценариях, где важны целостность
и уникальность данных, например при ведении списка уникальных идентифи-
Хеш-множества в Rust 185
каторов пользователей, обработке журналов событий с целью выявления отдельных событий или обеспечении неповторяемости записей в наборе данных.
Хеш-множества значительно упрощают процесс устранения дубликатов, становясь незаменимым инструментом в различных практических приложениях.
Проверка на вхождение в множество
Быстрая проверка наличия элемента в большом наборе данных с помощью
метода contains хеш-множества позволяет эффективно определять наличие
элемента в наборе данных.
Листинг 4.59. Пример использования хеш-множества для проверки членства
use std::collections::HashSet;
fn main() {
// Создать множество с числами от 1 до 10 000
let large_dataset: HashSet<i32> = (1..10001).collect();
let item_to_find = 42;
if large_dataset.contains(&item_to_find) {
println!("Элемент {} найден в наборе данных.", item_to_find);
} else {
println!("Элемент {} не найден в наборе данных.", item_to_find);
}
}
// Вывод:
// Элемент 42 найден в наборе данных.
В приведенном выше примере на Rust мы демонстрируем проверку вхождения элемента с помощью хеш-множества. Мы создаем большой набор данных,
содержащий числа от 1 до 10 000, используя метод collect. Затем указываем
искомый элемент (item_to_find), наличие которого хотим проверить в этом наборе данных.
Приведенный выше пример успешно подтверждает наличие элемента «42»
в наборе данных. Такая проверка членства имеет огромное значение в различных приложениях, таких как поиск значений в базе данных, проверка прав
доступа пользователей или подтверждение существования определенных элементов в массиве данных без необходимости долгого выполнения итераций.
Использование хеш-множеств убыстряет эти операции и делает практически
применимыми в реальных сценариях.
Кеширование
Хранение уникальных результатов во избежание избыточных вычислений.
Кеширование широко применяется в приложениях, оптимизация производительности которых играет критически важную роль: в веб-серверах, при
оптимизации запросов к базам данных или в любом сценарии, где повторных
вычислений можно избегать за счет сохранения результатов. Хеш-множества
предоставляют удобный и эффективный способ реализации кеширования
в таких приложениях.
186
Встроенные в Rust структуры данных
Листинг 4.60. Пример использования хеш-множества для кеширования
use std::collections::HashSet;
fn expensive_computation(input: i32) -> i32 {
// Симуляция ресурсоемких вычислений
input * 2
}
let mut cache: HashSet<i32> = HashSet::new();
fn get_or_compute(input: i32) -> i32 {
if cache.contains(&input) {
*cache.get(&input).unwrap()
} else {
let result = expensive_computation(input);
cache.insert(input, result);
result
}
}
В приведенном выше фрагменте исходного кода на Rust мы демонстрируем простой механизм кеширования с использованием хеш-множества.
Функция expensive_computation симулирует вычислительно сложное задание,
а функция get_or_compute проверяет, присутствует ли результат для переданных входных данных в кеше. Если да, то функция извлекает и возвращает
кешированный результат. В противном случае она производит вычисления,
вставляет входные данные в кеш и возвращает результат. Такой подход эффективно экономит время и ресурсы, избегая повторных вычислений.
Алгоритмы на графах
Эффективная реализация алгоритмов наподобие поиска сперва в ширину (BFS).
В алгоритмах на графах хеш-множества можно использовать для отслеживания посещенных узлов или эффективного управления множествами узлов.
Ниже приведен пример исходного кода с эффективной реализацией на Rust
алгоритма поиска сперва в ширину с использованием хеш-множеств:
Листинг 4.61. Пример использования хешмножества для представления графов
use std::collections::{HashSet, VecDeque};
// Определить простую графовую структуру в виде списка смежности
struct Graph { // ①
adjacency_list: Vec<Vec<usize>>,
}
impl Graph {
fn new(num_nodes: usize) -> Self {
Self {
adjacency_list: vec![vec![]; num_nodes],
}
}
Хеш-множества в Rust 187
fn add_edge(&mut self, u: usize, v: usize) {
self.adjacency_list[u].push(v);
self.adjacency_list[v].push(u);
}
fn bfs(&self, start_node: usize) -> HashSet<usize> { // ②
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
// Начать поиск сперва в ширину с заданного узла
queue.push_back(start_node);
visited.insert(start_node);
while let Some(node) = queue.pop_front() {
for &neighbor in &self.adjacency_list[node] {
if !visited.contains(&neighbor) {
visited.insert(neighbor);
queue.push_back(neighbor);
}
}
}
visited
}
}
fn main() { // ③
// Создать граф
let mut graph = Graph::new(6);
// Добавить ребра
graph.add_edge(0, 1);
graph.add_edge(0, 2);
graph.add_edge(1, 3);
graph.add_edge(1, 4);
graph.add_edge(2, 5);
// Выполнить алгоритм поиска сперва в ширину, начиная с узла 0
let start_node = 0;
let visited_nodes = graph.bfs(start_node);
println!("Узлы, посещенные при поиске, начиная с узла {}: {:?}",
start_node, visited_nodes);
}
// Вывод:
// Узлы, посещенные при поиске, начиная с узла 0: {0, 3, 5, 1, 2, 4}
В приведенном выше исходном коде:
① мы определяем структуру Graph для представления простого графа в виде
списка смежности. Функция new инициализирует граф, а функция add_
edge добавляет ребра между узлами;
② функция bfs выполняет поиск сперва в ширину, начиная с указанного
узла, и возвращает хеш-множество, содержащее все посещенные узлы;
③ в функции main мы создаем граф, добавляем ребра и запускаем алгоритм
поиска сперва в ширину. В качестве результата выводятся посещенные
узлы.
188
Встроенные в Rust структуры данных
Приведенный выше исходный код демонстрирует, как хеш-множества позволяют эффективно отслеживать посещенные узлы во время обхода графа, что
крайне полезно при реализации таких алгоритмов, как поиск сперва в ширину.
Операции на множествах
Хеш-множества незаменимы при выполнении таких операций, как объединение, пересечение и разность. Можно объединять два множества, находить общие элементы посредством пересечения или определять разность
между ними.
Объединение
Пересечение
Разность
Симметрическая разность
Рисунок 4.9. Операции над множествами
Листинг 4.49. Пример использования хешмножества и операций на множествах
use std::collections::HashSet;
fn main() {
let set_a: HashSet<i32> = HashSet::from([1, 2, 3, 4, 5]);
let set_b: HashSet<i32> = HashSet::from([3, 4, 5, 6, 7]);
// Объединение
let union = set_a.union(&set_b).collect::<HashSet<_>>();
// Пересечение
let intersection = set_a.intersection(&set_b).collect::<HashSet<_>>();
// Разность
let difference = set_a.difference(&set_b).collect::<HashSet<_>>();
println!("Множество A: {:?}", set_a);
println!("Множество B: {:?}", set_b);
println!("Объединение: {:?}", union);
println!("Пересечение: {:?}", intersection);
println!("Разность: {:?}", difference);
}
Хеш-множества в Rust 189
//
//
//
//
//
//
Вывод:
Множество A: {4,
Множество B: {5,
Объединение: {4,
Пересечение: {4,
Разность: {2, 1}
2,
3,
1,
3,
1, 3, 5}
6, 7, 4}
3, 6, 2, 7, 5}
5}
Тегирование и фильтрация
Хеш-множества удобны для тегирования и фильтрации элементов. Элементы
можно помечать определенными атрибутами или характеристиками, а затем
фильтровать их на основе этих атрибутов.
Листинг 4.63. Использование хеш-множества для тегирования и фильтрации
use std::collections::{hash_map::DefaultHasher, HashSet};
use std::hash::{Hash, Hasher};
#[derive(Clone, Eq, PartialEq, Debug)]
struct Item {
id: i32,
tags: HashSet<String>,
}
impl Hash for Item {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
fn main() {
let mut items = HashSet::new();
items.insert(Item {
id: 1,
tags: ["rust", "программирование"]
.iter()
.map(|s| s.to_string())
.collect(),
});
items.insert(Item {
id: 2,
tags: ["python", "программирование"]
.iter()
.map(|s| s.to_string())
.collect(),
});
let rust_items: HashSet<_> = items
.iter()
.filter(|item| item.tags.contains("rust"))
.cloned()
.collect();
}
println!("{:?}", rust_items); // Печать отфильтрованных элементов
190
Встроенные в Rust структуры данных
// Вывод:
// {Item { id: 1, tags: {"программирование", "rust"} }}
Отслеживание уникальных значений
Хеш-множества часто используются для отслеживания уникальных значений в потоках данных или журналах событий. Вставляя значения в хеш-множество и проверяя их на дубликаты, можно выявлять уникальные события или точки данных.
Листинг 4.64. Пример использования хеш-множества
для отслеживания уникальных значений
use std::collections::HashSet;
fn main() {
let mut unique_values = HashSet::new();
// Обработка потока значений
let values = vec![1, 2, 3, 4, 3, 2, 5, 6];
for value in values {
// Метод insert возвращает true, если элемента еще не было в множестве
if unique_values.insert(value) {
println!("Новое уникальное значение: {}", value);
}
}
}
//
//
//
//
//
//
//
Вывод:
Новое уникальное
Новое уникальное
Новое уникальное
Новое уникальное
Новое уникальное
Новое уникальное
значение:
значение:
значение:
значение:
значение:
значение:
1
2
3
4
5
6
Это лишь несколько примеров применения хеш-множеств в сценариях
из реальной практики. Универсальность хеш-множеств и эффективность операций на них делают их ценным инструментом во многих областях.
Вопросы производительности
Хотя хеш-множества обеспечивают большую гибкость и высокое быстродействие во многих сценариях, важно понимать их характеристики и то, как они
влияют на производительность.
Вставка и удаление: хеш-множества оптимизированы под быстрые
вставки и удаления элементов. Методы insert и remove обычно имеют
временнýю сложность в среднем O(1) . Однако в редких случаях коллизии
хешей могут приводить к некоторому ухудшению производительности.
Поиск: проверка существования элемента с помощью метода contains также имеет среднюю временную сложность O(1), что делает
хеш-множества эффективными для проверки членства.
Итерация: выполнение итераций по элементам хеш-множества имеет временную сложность, пропорциональную количеству элементов
в нем, – O(n). Если требуется выполнять операции на всех элементах,
то следует использовать цикл for.
Хеш-множества в Rust 191
Использование памяти: хеш-множества потребляют память для хранения самих элементов и для управления хеш-таблицами. Следует помнить об объеме потребляемой памяти, особенно при работе с большими
наборами данных.
Таблица 4.1. Анализ производительности операций на хеш-множествах
Операция
Временная сложность
Описание
Вставка
O(1) (в среднем)
Быстрая вставка элементов, изредка возможны коллизии
Удаление
O(1) (в среднем)
Быстрое удаление элементов, изредка возможны
коллизии
Поиск
O(1) (в среднем)
Эффективная проверка наличия элемента
Итерация
O(n)
Время растет пропорционально количеству элементов
Память
Зависит от объема
Потребление памяти зависит от количества элементов
Вопросы конкурентного исполнения
При работе с хеш-множествами в конкурентной или многопоточной среде необходимо учитывать вопросы потокозащищенности. Rust предоставляет механизмы обеспечения защищенного конкурентного доступа к структурам данных, включая хеш-множества.
Умные указатели Mutex
Хеш-множество можно обертывать в умный указатель Mutex (сокр. от mutual
exclusion – взаимное исключение), чтобы разрешать доступ к множеству только одному потоку исполнения в конкретный момент времени. За счет этого
гарантируется потокозащищенность, но данный прием может создать накладные расходы на производительность.
Листинг 4.65. Пример использования хешмножества с умным указателем Mutex
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Создать хеш-множество, обернутое в умный указатель Mutex
let my_set = Arc::new(Mutex::new(HashSet::<i32>::new()));
// Породить несколько потоков исполнения для вставки элементов в множество
let num_threads = 4;
let mut handles = vec![];
for i in 0..num_threads {
let set_clone = Arc::clone(&my_set);
let handle = thread::spawn(move || {
// Захватить умный указатель Mutex для доступа к множеству
let mut set = set_clone.lock().unwrap();
set.insert(i);
192
Встроенные в Rust структуры данных
// Умный указатель Mutex автоматически высвобождается здесь,
// когда set выходит из диапазона доступности
});
handles.push(handle);
}
// Ожидать завершения всех потоков исполнения
for handle in handles {
handle.join().unwrap();
}
// Захватить умный указатель Mutex, чтобы получить доступ
// и распечатать содержимое
let set = my_set.lock().unwrap();
println!("Содержимое множества: {:?}", *set);
}
// Вывод:
// Содержимое множества: {0, 2, 3, 1}
В приведенном выше исходном коде:
мы создаем хеш-множество my_set и оборачиваем его в умный указатель Mutex, гарантируя тем самым, что только один поток исполнения
может иметь к нему доступ одновременно;
порождаем несколько потоков исполнения, каждый из которых запирает
умный указатель Mutex для вставки элемента;
по завершении всех потоков исполнения мы снова запираем умный указатель Mutex, чтобы защищенно прочитать и вывести содержимое.
Использование умного указателя Mutex гарантирует синхронизацию конкурентного доступа, обеспечивая полную потокозащищенность.
Атомарные типы
Если требуется использовать хеш-множество коллективно в нескольких потоках исполнения без применения замков (умных указателей Mutex), то можно
рассмотреть возможность использования атомарных типов, предоставляемых
в модуле std::sync::atomic. Однако атомарные типы ограничены лишь несколькими специфическими операциями и подходят не для всех сценариев.
Листинг 4.66. Пример использования хеш-множества и атомарных типов
use std::collections::HashSet;
use std::sync::atomic::{AtomicPtr, Ordering};
fn main() {
// Создаем AtomicPtr для хранения указателя на HashSet.
let my_set = AtomicPtr::new(std::ptr::null_mut());
// Создать новое хеш-множество и
// переместить его в кучу (Box), получив простой указатель
let new_set = Box::into_raw(Box::new(HashSet::<i32>::new()));
// Атомарно сохранить указатель на новое множество
my_set.store(new_set, Ordering::Relaxed);
Хеш-множества в Rust 193
// Обратиться к хеш-множеству через умный указатель AtomicPtr
let set_ptr = my_set.load(Ordering::Relaxed);
if !set_ptr.is_null() {
// Защищенно конвертировать указатель в мутируемую ссылку (требует
unsafe)
let set_ref: &mut HashSet<i32> = unsafe { &mut *set_ptr };
// Выполнить операции на хеш-множестве
set_ref.insert(42);
set_ref.insert(100);
// Распечатать содержимое хеш-множества
for elem in set_ref.iter() {
println!("Элемент: {}", elem);
}
} else {
println!("Указатель на HashSet пуст (null)");
}
}
// Очистить хеш-множество
if !set_ptr.is_null() {
// Конвертировать указатель обратно в Box для
// корректного освобождения памяти
let _boxed_set: Box<HashSet<i32>> = unsafe { Box::from_raw(set_ptr) };
}
// Вывод:
// Элемент: 42
// Элемент: 100
В приведенном выше примере:
мы создаем атомарный простой указатель AtomicPtr с именем my_set для
хранения указателя на хеш-множество;
создаем новое хеш-множество, помещаем его в умный указатель (Box)
и сохраняем указатель в my_set, используя метод store с упорядочением
памяти Ordering::Relaxed;
затем обращаемся к хеш-множеству через атомарный указатель AtomicPtr и выполняем на нем операции;
в конце мы высвобождаем память, занимаемую хеш-множеством, конвертируя указатель обратно в Box.
Данный пример демонстрирует базовое применение атомарных типов для
управления коллективными данными без прямого участия нескольких потоков исполнения. В сценариях из реальной практики атомарные типы оказываются исключительно полезными, когда многочисленным потокам исполнения
требуется одновременный доступ и модификация данных.
Следует помнить, что использование атомарного простого указателя AtomicPtr вместе
с &mut HashSet в многопоточной среде без дополнительных проверок нарушает правила заимствования. Если два потока исполнения одновременно вызовут set_ref.
insert(), то это приведет к гонке за доступом к данным.
194
Встроенные в Rust структуры данных
Сериализация и десериализация
Хеш-множества в Rust можно легко сериализовывать и десериализовывать
с помощью таких библиотек, как serde. Сериализация позволяет конвертировать хеш-множество в формат, пригодный для хранения или передачи (например, JSON- или двоичный формат). Десериализация – это процесс восстановления хеш-множества из его сериализованной формы.
Ниже приведен простейший пример сериализации и десериализации хешмножества:
Листинг 4.67. Простейший пример сериализации и десериализации
use
use
use
use
serde::{Serialize, Deserialize};
std::collections::HashSet;
std::fs::File;
std::io::{Read, Write};
// Определить структуру для хранения хеш-множества
#[derive(Serialize, Deserialize)]
struct MyData {
my_set: HashSet<i32>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Создать хеш-множество
let mut my_set = HashSet::new();
my_set.insert(1);
my_set.insert(2);
my_set.insert(3);
// Сериализовать хеш-множество в JSON-файл
let data = MyData { my_set };
let serialized = serde_json::to_string(&data)?;
let mut file = File::create("my_set.json")?;
file.write_all(serialized.as_bytes())?;
// Десериализовать хеш-множество из JSON-файла
let mut file = File::open("my_set.json")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let deserialized: MyData = serde_json::from_str(&contents)?;
}
println!("{:?}", deserialized.my_set);
Ok(())
// Вывод:
// {1, 2, 3}
В приведенном выше примере мы определяем конкретно-прикладную
структуру MyData, содержащую хеш-множество. Используя библиотеку serde,
мы активируем возможность сериализации и десериализации этой структуры,
что упрощает сохранение и извлечение хеш-множества из JSON-файла.
Хеш-множества в Rust 195
Нагрузочное тестирование и оптимизация
Оптимизация исходного кода, использующего хеш-множества, зачастую
нуждается в проведении нагрузочного тестирования (замеров производительности) и профилирования. В экосистеме Rust для измерения и анализа
работы исходного кода доступны такие пакеты, как criterion и различные
профилировщики.
Для того чтобы начать работу с нагрузочными тестами посредством пакета
criterion, необходимо добавить его в файл Cargo.toml:
Листинг 4.68. Конфигурация файла cargo.toml для проекта с пакетом criterion
[dev-dependencies]
criterion = "0.5.1"
[[bench]]
name = "my_benchmark"
harness = false
Ниже приведен простой пример написания нагрузочного теста для операций на хеш-множестве:
Листинг 4.69. Пример нагрузочного теста с использованием пакета criterion
// Создать файл в корне своего проекта: ./benches/my_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use std::collections::HashSet;
fn insert_benchmark(c: &mut Criterion) {
c.bench_function("Вставка в хеш-множество", |b| {
let mut set = HashSet::new();
b.iter(|| {
// black_box предотвращает оптимизацию значения компилятором
set.insert(black_box(42));
});
});
}
criterion_group!(benches, insert_benchmark);
criterion_main!(benches);
//
//
//
//
//
//
//
Запуск в терминале:
$ cargo bench
Результат
Вставка в хеш-множество time: [25.812 ns 25.819 ns 25.826 ns]
Found 8 outliers among 100 measurements (8.00%)
4 (4.00%) high mild
4 (4.00%) high severe
В приведенном выше тесте мы измеряем производительность вставки
в хеш-множество. Указанный нагрузочный тест можно адаптировать под проверку других операций.
Для профилирования исходного кода на Rust можно использовать такие инструменты, как perf, flamegraph или valgrind. Профилирование помогает выявлять «узкие места» в производительности и области, требующие оптимизации.
196
Встроенные в Rust структуры данных
Хеш-множества в Rust – это надежная структура данных, которая позволяет
эффективно работать с коллекциями уникальных элементов. Гибкость и быст
родействие хеш-множеств окажутся неоценимыми независимо от вашей задачи – будь то удаление дубликатов, выполнение операций на множествах или
отслеживание уникальных значений.
По мере дальнейшего изучения Rust и его стандартной библиотеки вы убедитесь, что хеш-множества являются важным дополнением к вашему инструментарию. Обязательно обращайтесь к официальной документации для
изучения деталей и продвинутых способов использования.
Освоив хеш-множества и разобравшись в областях их применения, вы сможете писать более эффективный и элегантный исходный код на Rust для самого широкого спектра задач.
Хеш-словари
Хеш-словари – это тип коллекции, состоящий из пар «ключ–значение». Они
обеспечивают быстрый и эффективный доступ к данным, используя вместо
индексации ключи.
Хеш-словарь
Ключи
Значения
"абрикос"
"облепиха"
"банан"
Рисунок 4.10. Простое представление хеш-словаря
В Rust хеш-словари представлены модулем std::collections::HashMap. Это
неупорядоченная структура данных, обладающая впечатляющим быстродействием. Давайте рассмотрим приемы создания хеш-словарей, обновления, получения доступа и выполнения итераций по их элементам на Rust.
Создание хеш-словаря
Инициализировать хеш-словарь в Rust можно несколькими способами. Один
из распространенных – использование метода new структуры HashMap:
Листинг 4.70. Пример инициализации хеш-словаря
use std::collections::HashMap;
let mut scores = HashMap::new(); // ①
Хеш-словари 197
① Мы создаем пустой хеш-словарь с именем scores.
Еще один способ инициализации – использование метода HashMap::from:
Листинг 4.71. Пример инициализации хеш-словаря
use std::collections::HashMap;
let ages = HashMap::from([ // ①
("Алиса", 30),
("Борис", 28),
]);
① Мы создаем хеш-словарь ages со строковыми ключами и целочисленными значениями.
Помимо этих методов, Rust также предоставляет возможность создавать хешсловарь, используя метод collect на итераторе. Эта стратегия особенно полезна при работе с данными, находящимися в другой коллекции, или в ситуациях,
когда необходимо конвертировать существующие данные в хеш-словарь. Вызвав метод iter для среза, вектора или любой другой итерируемой коллекции,
можно создать итератор и затем трансформировать его в хеш-словарь с помощью вызова метода collect(). Ниже приводится практическая иллюстрация:
Листинг 4.72. Пример инициализации хеш-словаря
use std::collections::HashMap;
let data = vec![("Carol", 25), ("Дэвид", 32)];
// ①
let name_age_map: HashMap<_, _> = data.iter().cloned().collect(); // ②
В приведенном выше примере:
① сначала мы определяем вектор data, содержащий кортежи с именами
и возрастом;
② затем создаем хеш-словарь name_age_map, клонируя элементы итератора,
полученного из data.iter(). Метод collect берет на себя задачу преобразования итератора в хеш-словарь.
Этот метод инициализации бывает особенно полезен, когда нужно перенести
данные из одной структуры в хеш-словарь для обеспечения эффективного поиска и выполнения операций.
Обновление хеш-словаря
Для бесперебойной работы программы крайне важно понимать концепцию
обновления хеш-словарей. На них можно выполнять многочисленные операции, включая (но не ограничиваясь) добавление и удаление элементов, а также
изменение значений, связанных с уже существующими ключами. Такая гибкость делает хеш-словари универсальным решением по управлению данными
во многих приложениях на Rust.
Добавление элементов
Для добавления элементов (пар «ключ–значение») в хеш-словарь используется метод insert. Например:
198
Встроенные в Rust структуры данных
Листинг 4.73. Пример добавления элементов в хеш-словарь
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert("Алиса", 42); // ①
scores.insert("Борис", 73); // ②
В приведенном выше примере:
① мы вставляем пару «ключ–значение» со строковым литералом "Алиса"
и баллом 42 в хеш-словарь scores;
② вставляем еще одну пару со строковым литералом "Борис" и баллом 73.
Добавление элементов в хеш-словарь – это важнейшая операция при построении структур данных и управлении информацией на Rust. Независимо от того, заполняете ли вы словарь начальными данными или динамически обновляете его по мере работы программы, метод insert предоставляет
гибкий и эффективный способ работы с парами «ключ–значение» в хешсловарях на Rust.
Удаление элементов
Для удаления элемента (пары «ключ–значение») из хеш-словаря используется
метод remove:
Листинг 4.74. Пример удаления элементов из хеш-словаря
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Алиса", 42);
scores.insert("Борис", 73);
scores.remove("Алиса"); // ①
println!("Содержимое хеш-словаря: {:?}", scores);
}
// Вывод:
// Содержимое хеш-словаря: {"Борис": 73}
В приведенном выше примере:
① мы удаляем из хеш-словаря scores пару, соответствующую ключу "Алиса".
Удаление элементов из хеш-словаря имеет решающее значение для эффективного управления данными и поддержания точности информации в приложении. Возможность выборочного удаления записей по их ключам гарантирует, что хеш-словарь будет оставаться актуальным и соответствовать требованиям логики программы.
Обновление элементов
Обновить значение в хеш-словаре можно также с помощью метода insert:
Хеш-словари 199
Листинг 4.75. Пример обновления элементов внутри хеш-словаря
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Алиса", 42); // ①
scores.insert("Алиса", 55); // ②
println!("Содержимое хеш-словаря: {:?}", scores);
}
// Вывод:
// Содержимое хеш-словаря: {"Алиса": 55}
В приведенном выше примере:
① мы вставляем пару "Алиса" со значением 42;
② обновляем значение для ключа "Алиса" на 55. (Старое значение будет
перезаписано.)
Доступ к значениям
Для доступа к значению по ключу в Rust используется метод get:
Листинг 4.76. Пример доступа к элементам хеш-словаря
use std::collections::HashMap;
fn main() {
let scores = HashMap::from([ // ①
("Алиса", 42),
("Борис", 73),
]);
let alice_score = scores.get("Алиса").unwrap(); // ②
println!("Балл Алисы: {:?}", alice_score);
}
// Вывод:
// Балл Алисы: 42
В приведенном выше примере:
① мы создаем хеш-словарь scores со строковыми ключами и целочисленными значениями;
② используем метод get для извлечения значения, связанного с ключом "Алиса".
Метод get является стандартной операцией для извлечения данных хешсловаря и работы с конкретными значениями по ключу. Данный метод гарантирует защищенный доступ к данным, позволяя щадяще обрабатывать случаи,
когда искомое значение отсутствует в коллекции.
Выполнение итераций по элементам хеш-словаря в цикле
Задача выполнения итераций по всем парам ключ–значение встречается
очень часто. Хеш-словарь прокручивается в цикле с помощью циклической
конструкции for:
200
Встроенные в Rust структуры данных
Листинг 4.77. Выполнение итераций по элементам
хеш-словаря с помощью цикла for
use std::collections::HashMap;
fn main() {
let scores = HashMap::from([
("Алиса", 42),
("Борис", 73),
]);
for (name, score) in &scores { // ①
println!("{}: {}", name, score);
}
}
// Вывод:
// Алиса: 42
// Борис: 73
В приведенном выше исходном коде:
① мы выполняем итерации по парам ключ–значение в цикле и выводим
каждое имя и соответствующий балл на экран.
Кроме того, используя методы keys() и values(), можно прокручивать в цикле
отдельно ключи или значения:
Листинг 4.78. Выполнение итераций по ключам
и значениям хеш-словаря в цикле
use std::collections::HashMap;
fn main() {
let scores = HashMap::from([
("Алиса", 42),
("Борис", 73),
]);
for name in scores.keys() {
// ①
println!("Имя: {}", name);
}
for score in scores.values() { // ②
println!("Балл: {}", score);
}
}
//
//
//
//
//
Вывод:
Имя: Алиса
Имя: Борис
Балл: 42
Балл: 73
В приведенном выше исходном коде:
① мы выполняем итерации по ключам хеш-словаря scores и печатаем каждое имя на экране;
② выполняем итерации по значениям хеш-словаря scores и печатаем каждый балл на экране.
Хеш-словари 201
Продвинутые операции на хеш-словарях
Модуль std::collections::HashMap предоставляет обширный набор методов для
продвинутых вариантов использования. В следующем далее разделе мы проведем обследование некоторых из них и увидим, как они применяются на практике.
Проверка существования ключа
Выполнить проверку на существование ключа в хеш-словаре можно с помощью метода contains_key. Он широко используется в ситуациях, когда нужно определить наличие конкретного ключа перед обращением к связанному
с ним значению.
Листинг 4.79. Проверка существования ключа с помощью метода contains_key
use std::collections::HashMap;
fn main() {
let scores = HashMap::from([
("Алиса", 42),
("Борис", 73),
]);
let name = "Алиса";
if scores.contains_key(name) { // ①
println!("Балл ключа {}: {}", name, scores[name]);
} else {
println!("Ключ {} не найден.", name);
}
}
// Вывод:
// Балл ключа Алиса: 42
В приведенном выше исходном коде:
① мы используем метод contains_key, чтобы убедиться в существовании
ключа "Алиса" в хеш-словаре scores перед доступом к значению.
API внесения записей
API внесения записей в словарь предоставляет способ эффективно изменять
существующее значение или вставлять новую пару «ключ–значение», если
ключа еще нет. Это позволяет избегать двойного поиска в памяти при одновременной вставке и обновлении.
Листинг 4.80. Использование API внесения записей
для работы с ключами в хеш-словаре
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Алиса", 42);
let name = "Алиса";
let new_score = 10;
202
}
Встроенные в Rust структуры данных
scores
.entry(name)
.and_modify(|score| *score += new_score) // ①
.or_insert(new_score);
// ②
println!("Обновленный балл ключа {}: {}", name, scores[name]);
// Вывод:
// Обновленный балл ключа Алиса: 52
В приведенном выше исходном коде:
① мы используем API внесения записей для эффективного обновления балла ключа "Алиса". Если ключ существует, мы модифицируем значение;
② применяем метод or_insert, чтобы вставить новую пару, если ключ отсутствует.
Использование API внесения записей (в листинге 4.80) – это самый Rust’овский способ
работы со словарями, так как он за одно обращение к хеш-словарю позволяет и проверять наличие ключа, и изменять его.
Очистка хеш-словаря
Для того чтобы удалить все пары «ключ–значение» и полностью очистить содержимое словаря, используется метод clear:
Листинг 4.81. Пример очистки хеш-словаря
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Алиса", 42);
scores.insert("Борис", 73);
scores.clear();
// ①
if scores.is_empty() { // ②
println!("Хеш-словарь пуст.");
}
}
// Вывод:
// Хеш-словарь пуст.
В приведенном выше исходном коде:
① мы используем метод clear, чтобы удалить все записи, в результате чего
хеш-словарь становится пустым;
② проверяем, является ли хеш-словарь пустым, используя метод is_empty,
и выводим на экран соответствующее сообщение.
Емкость хеш-словаря
Хеш-словари в Rust имеют емкость, показывающую количество элементов,
которые они могут хранить до того, как потребуется изменение размера.
Хеш-словари 203
Текущую емкость и количество элементов можно узнать с помощью методов capacity и len.
Листинг 4.82. Пример проверки емкости хеш-словаря
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Алиса", 42);
scores.insert("Борис", 73);
let current_capacity = scores.capacity(); // ①
let number_of_entries = scores.len();
// ②
}
println!("Текущая емкость: {}", current_capacity);
println!("Количество записей: {}", number_of_entries);
// Вывод:
// Текущая емкость: 3
// Количество записей: 2
В приведенном выше исходном коде:
① мы используем метод capacity, чтобы получить текущую емкость хешсловаря scores;
② используем метод len, чтобы получить фактическое количество записей
в хеш-словаре scores.
Практические применения
Хеш-словари находят применение в самых разных сценариях из реальной
практики, включая:
Подсчет вхождений
Хеш-словари широко применяются для подсчета количества повторений элементов в коллекции. В частности, можно выполнять итерации по коллекции,
используя хеш-словарь для отслеживания счетчиков, и эффективно определять частоту каждого элемента.
Листинг 4.83. Пример использования хеш-словаря для подсчета вхождений
use std::collections::HashMap;
fn main() {
let mut word_counts = HashMap::new();
let text = "Lorem ipsum dolor sit amet consectetur ipsum";
for word in text.split_whitespace() {
let count = word_counts.entry(word).or_insert(0);
*count += 1;
}
println!("{:?}", word_counts);
}
204
Встроенные в Rust структуры данных
// Вывод:
// {"sit": 1, "ipsum": 2, "Lorem": 1, "amet": 1, "consectetur": 1, "dolor": 1}
В приведенном выше примере мы подсчитываем количество слов в тексте,
разбивая его на отдельные слова и используя хеш-словарь для хранения результатов. Метод entry в рамках API внесения записей используется для эффективного обновления счетчиков или добавления новых слов.
Мемоизация
В динамическом программировании хеш-словари можно использовать для
хранения и извлечения ранее вычисленных результатов. Это позволяет избегать избыточных вычислений и значительно повышать производительность
программы.
Листинг 4.84. Пример использования хеш-словаря для мемоизации
use std::collections::HashMap;
fn fibonacci_memoization(n: u64, memo: &mut HashMap<u64, u64>) -> u64 {
if let Some(&result) = memo.get(&n) {
return result;
}
let result = match n {
0 => 0,
1 => 1,
_ => fibonacci_memoization(n - 1, memo) + fibonacci_memoization(n - 2,
memo),
};
memo.insert(n, result);
result
}
fn main() {
let mut memo = HashMap::new();
let n = 20;
let result = fibonacci_memoization(n, &mut memo);
println!("фибоначчи({}) = {}", n, result);
}
// Вывод:
// фибоначчи(20) = 6765
В приведенном выше примере мы используем хеш-словарь (memo) для хранения уже вычисленных чисел Фибоначчи, чтобы избежать избыточных повторных вычислений.
Кеширование
Хеш-словари применяются в кешировании для эффективного хранения и извлечения значений. В хеш-словаре могут кешироваться часто используемые
или ресурсозатратные вычисления, чтобы не выполнять их заново.
Хеш-словари 205
Листинг 4.85. Пример использования хеш-таблицы для кеширования
use std::collections::HashMap;
fn expensive_calculation(input: u32) -> u32 {
// Симуляция длительного вычисления
input * 2
}
fn main() {
let mut cache = HashMap::new();
let input = 42;
// Вычислить результат только в том случае, если его нет в кеше
let result = cache.entry(input).or_insert_with(|| expensive_
calculation(input));
println!("Результат вычисления: {}", result);
}
// Вывод:
// Результат вычисления: 84
В приведенном выше примере хеш-словарь (cache) хранит результаты ресурсозатратной функции; само вычисление запускается только при отсутствии
готового значения в кеше.
Управление конфигурацией
Хеш-словари используются для управления настройками приложений. Пары
«ключ–значение» позволяют легко получать доступ к параметрам конфигурации и обновлять их.
Листинг 4.86. Пример использования хешсловаря для управления конфигурацией
use std::collections::HashMap;
fn main() {
let mut config = HashMap::new();
config.insert("api_key", "your_api_key");
config.insert("port", "8080");
config.insert("debug_mode", "true");
let api_key = config.get("api_key").map(|s| s.to_string()).unwrap_or_default();
let port = config.get("port").map(|s| s.to_string()).unwrap_or_default();
let debug_mode = config.get("debug_mode").map(|s| s.to_string()).unwrap_or_
default();
println!("API Key: {}", api_key);
println!("Port: {}", port);
println!("Debug Mode: {}", debug_mode);
}
206
//
//
//
//
Встроенные в Rust структуры данных
Вывод:
API Key: your_api_key
Port: 8080
Debug Mode: true
В приведенном выше примере мы используем хеш-словарь для управления
конфигурационными настройками приложения.
Преобразование данных
При конвертации данных из одного формата в другой хеш-словари незаменимы
в части хранения соотнесений между старыми и новыми значениями.
Листинг 4.87. Пример использования хешсловаря для преобразования данных
use std::collections::HashMap;
fn main() {
let mut conversion_table = HashMap::new();
conversion_table.insert("USD", "Доллар США");
conversion_table.insert("EUR", "Евро");
conversion_table.insert("JPY", "Японская иена");
let input_currency = "USD";
let output_currency = conversion_table.get(input_currency).unwrap_
or(&"Неизвестно");
}
println!("Конвертация валют: {} => {}", input_currency, output_currency);
// Вывод:
// Currency Conversion: USD => US Dollar
В приведенном выше примере хеш-словарь выступает в роли таблицы с соотнесением кодов валют с их полными названиями.
Группировка данных
Хеш-словари можно использовать для группировки данных по определенным
критериям. Например, можно группировать список людей по возрасту, используя хеш-словарь, ключом которого будет возраст, а значением – список имен.
Листинг 4.88. Пример использования хеш-словаря для группировки данных
use std::collections::HashMap;
fn main() {
let mut people_by_age = HashMap::new();
let people = vec![
("Алиса", 25),
("Борис", 30),
("Виктор", 25),
("Дэвид", 35),
];
Хеш-словари 207
for (name, age) in people {
people_by_age
.entry(age)
.or_insert_with(Vec::new) // Создать новый вектор, если ключа еще нет
.push(name);
}
}
println!("{:?}", people_by_age);
// Вывод:
// {25: ["Алиса", "Виктор"], 30: ["Борис"], 35: ["Дэвид"]}
В приведенном выше примере мы используем хеш-словарь для распределения людей по группам в соответствии с их возрастом.
Графовые алгоритмы
Хеш-словари являются важнейшим компонентом в алгоритмах на графах. Они
используются для эффективного представления графов, узлы которых выступают в качестве ключей, а связанная с ними информация (например, списки
смежности) – в качестве значений.
Листинг 4.89. Пример использования хеш-словаря в графовых алгоритмах
use std::collections::HashMap;
fn main() {
let mut graph = HashMap::new();
// Определяем ориентированный граф
graph.insert("A", vec!["B", "C"]);
graph.insert("B", vec!["C", "D"]);
graph.insert("C", vec!["D"]);
graph.insert("D", vec![]);
}
println!("{:?}", graph);
// Вывод:
// {"D": [], "C": ["D"], "B": ["C", "D"], "A": ["B", "C"]}
В приведенном выше примере мы используем хеш-словарь для представления ориентированного графа (в виде списка смежности).
Индексация баз данных
В системах управления базами данных хеш-словари применяются для индексации данных, что позволяет быстро находить записи по ключевым атрибутам.
Листинг 4.90. Пример использования хеш-словаря для индексации базы данных
use std::collections::HashMap;
fn main() {
let mut database_index = HashMap::new();
208
Встроенные в Rust структуры данных
// Индексация записей пользователей по их именам (username)
let users = vec![
("алиса", "Алиса Петрова"),
("борис", "Борис Иванов"),
("виктор", "Виктор Сидоров"),
];
for (username, user_info) in users {
database_index.insert(username, user_info);
}
}
let username_to_lookup = "борис";
if let Some(user_info) = database_index.get(username_to_lookup) {
println!("Сведения о пользователе {}: {}", username_to_lookup, user_info);
} else {
println!("Пользователь не найден: {}", username_to_lookup);
}
// Вывод:
// Сведения о пользователе борис: Борис Иванов
В приведенном выше примере хеш-словарь выступает в роли индекса, обес
печивающего мгновенный поиск информации о пользователях по их пользовательским именам (логинам) в сценарии упрощенной базы данных.
Хеш-словари в Rust – это надежная структура данных, которая способствует
эффективному управлению и упорядочиванию информации с помощью пар
«ключ–значение». Они обеспечивают быстрый доступ к данным и часто используются для таких задач, как подсчет вхождений, мемоизация, кеширование, управление конфигурациями, преобразование данных, группировка данных, графовые алгоритмы и индексация баз данных.
Благодаря встроенной в Rust реализации хеш-словарей и обширному набору методов процедура использования хеш-словарей становится простой и лишена сложностей. Вооружившись этими знаниями, вы сможете разрабатывать
исходный код на Rust, который будет производительным и высокоэффективным в достижении желаемых результатов.
По мере вашего дальнейшего пути в изучении Rust хеш-словари, без сомнения, станут ценным дополнением к основному инструментарию, позволяя решать широкий спектр задач из реальной практики.
Заключение
На протяжении этой всеобъемлющей главы вы ознакомились с многообразными коллекциями и структурами данных в Rust. Вы подробно обследовали тонкости работы с массивами, векторами, срезами, кортежами, хеш-множествами
и хеш-словарями, приобретя ценные знания о том, как умело их создавать,
эффективно ими манипулировать и использовать эти мощные инструменты.
Вы начали с обследования массивов и их фиксированных размеров, осознав
их сильные стороны в представлении данных с заранее известной и постоянной длиной. Вы открыли для себя мощь векторов – динамических массивов
языка Rust, которые обеспечивают гибкость и расширяемое хранилище, что
Дополнительные ресурсы 209
делает их незаменимыми для управления коллекциями переменного размера.
Кортежи, обладая способностью объединять разнородные типы данных в единую сущность, добавили уникальное измерение в ваш инструментарий представления данных.
Вы углубились в концепцию срезов в Rust – важнейшее понятие, которое позволяет манипулировать последовательностями смежно расположенных элементов внутри коллекции без владения ими. Вы научились создавать срезы
из массивов, векторов и строковых литералов, а также поняли, как получать
доступ к элементам среза, изменять их и выполнять итерации в цикле. Кроме
того, вы познакомились с практическими применениями срезов в различных
областях: от задач по манипулированию строковыми литералами и обработки
данных до токенизации текста, работы с двоичной информацией и внедрения
мер конкурентности при выполнении задач по отображению в память.
Двигаясь дальше, вы погрузились в сферу хеш-множеств и хеш-словарей,
освоив искусство хранения данных в формате «ключ–значение» и эффективного управления элементами. Вы научились вставлять, удалять и обновлять
элементы, а также получили представление о продвинутых операциях на множествах. Практические примеры применения этих структур продемонстрировали их значимость в самых разных сценариях: от подсчета частоты вхождений до алгоритмов на графах.
Продолжая свой путь в программировании на Rust, следует помнить, что глубокое понимание этих фундаментальных коллекций и структур данных имеет первостепенное значение. Они являются строительными блоками многих
алгоритмов и приложений, позволяя разрабатывать эффективный, надежный
и стабильный исходный код. Вооружившись этими знаниями, вы отлично подготовлены к решению сложных задач и созданию элегантных решений на Rust.
В следующей главе мы подробно разберем критически важную тему управления ошибками на Rust, в первую очередь посредством использования типов
Result и Option. Мы проведем обследование методов эффективной обработки
ошибок и их передачи по стеку вызовов в программах на Rust, что гарантирует
надежность исходного кода. Вдобавок в главе будет затронута тема создания
конкретно-прикладных типов ошибок, что позволит формировать более информативные и описательные сообщения, упрощая процессы отладки и поиска неисправностей. Освоив техники обработки и передачи ошибок, вы сможете значительно повысить отказоустойчивость своих приложений перед лицом
непредвиденных ситуаций.
Дополнительные ресурсы
В целях проведения дальнейшего обследования, ознакомления и углубления
знаний о хеш-множествах, хеш-словарях, векторах, массивах и кортежах в Rust
рекомендуется использовать следующие ниже ресурсы:
Полное руководство по Ndarray: освоение искусства научных вычислений на языке Rust – обзор встроенных в язык Rust разных
структур данных и глубокое погружение в библиотеку Ndarray: https://
towardsdatascience.com/the-ultimate-ndarray-handbook-mastering-the-art-ofscientific-computing-with-rust-ef5ab767212a;
210 Встроенные в Rust структуры данных
Документация стандартной библиотеки Rust – std::collections –
официальная документация по коллекциям стандартной библиотеки
Rust, включая хеш-множества и хеш-словари: https://doc.rust-lang.org/std/
collections/index.html;
Язык Rust на примерах – Типы из стандартной библиотеки – практическое руководство с примерами использования коллекций языка
Rust, включая векторы и хеш-словари: https://doc.rust-lang.org/rust-byexample/std.html;
Коллекции в языке Rust – Язык программирования Rust – глава
из книги «Язык программирования Rust», посвященная таким коллекциям, как векторы, массивы, хеш-множества и хеш-словари: https://doc.
rust-lang.org/book/ch08-00-common-collections.html;
Игровая площадка Rust – онлайновый редактор и изолированная среда языка Rust для экспериментов с фрагментами исходного кода: https://
play.rust-lang.org/;
Форум сообщества Rust – форум, где можно задавать вопросы и обсуждать темы, связанные с Rust: https://users.rust-lang.org/;
Официальное сообщество Rust – различные ресурсы сообщества, включая форумы, информационные бюллетени и рабочие группы: https://
www.rust-lang.org/community;
Книга «Язык программирования Rust» – официальная книга для
изучения языка Rust с главами о коллекциях и структурах данных: https://
doc.rust-lang.org/book/ch08-00-common-collections.html;
Rustlings – небольшие упражнения на Rust – набор небольших упражнений для практики концепций Rust, включая коллекции: https://github.
com/rust-lang/rustlings;
Восхитительный Rust – курируемый список исходного кода и ресурсов – избранный список библиотек, инструментов и учебных ресурсов по Rust: https://github.com/rust-unofficial/awesome-rust;
Архитектурные шаблоны в Rust – коллекция шаблонов разработки для
Rust, включая шаблоны структур данных: https://github.com/rust-unofficial/
patterns;
Рекомендации по API на Rust – официальные рекомендации по написанию API на Rust, включая работу со структурами данных: https://rustlang.github.io/api-guidelines;
Rust для системных программистов – ресурс для программистов
на языках C/C++, переходящих на Rust, охватывающий структуры данных: https://github.com/nrc/r4cppp;
Управление памятью в Rust – понимание принятой в Rust модели владения, которая применима ко всем структурам данных: https://doc.rustlang.org/book/ch04-00-understanding-ownership.html;
Serde – фреймворк для сериализации – широко используемый фреймворк для сериализации в Rust, применимый к структурам данных: https://
serde.rs;
Поиск по документации Rust – удобный поиск и доступ к документации модулей стандартной библиотеки: https://doc.rust-lang.org/std/
index.html;
Вопросы для самопроверки 211
Rust для разработки игр – библиотеки и ресурсы Rust для разработки
игр, включая структуры данных: https://arewegameyet.rs;
Discord-канал сообщества Rust – присоединяйтесь к сообществу Rust
в Discord для обсуждений и помощи в реальном времени: https://discord.
gg/rust-lang;
Rust на Reddit – ветка, посвященная Rust, для новостей, вопросов и дискуссий: https://www.reddit.com/r/rust/.
Приведенные выше ресурсы обеспечивают всестороннее понимание хешмножеств, хеш-словарей, векторов, массивов и кортежей в Rust, а также предлагают ценные практические примеры.
Вопросы для самопроверки
Вопрос 1. Каково основное предназначение общих черт (типажей) в Rust?
a) определять структуры данных
b) предоставлять дефолтные реализации функций
c) определять общее поведение, которое могут реализовывать типы
d) ограничивать доступ к данным
Вопрос 2. Какая из следующих структур данных в Rust имеет фиксированный размер и содержит элементы идентичных типов данных?
a) векторы
b) кортежи
c) срезы
d) массивы
Вопрос 3. Как в Rust обращаться к элементу внутри массива?
a) используя метод get
b) используя цикл for
c) используя индексацию с квадратными скобками []
d) используя метод pop
Вопрос 4. Какой метод позволяет добавлять элементы в конец вектора
в Rust?
a) push
b) get
c) remove
d) pop
Вопрос 5. В чем преимущество использования метода enumerate при выполнении итераций по вектору в Rust?
a) он позволяет получать одновременно и индекс, и значение каждого элемента
b) он автоматически сортирует вектор
212
Встроенные в Rust структуры данных
c) он удаляет последний элемент из вектора
d) он меняет порядок элементов в векторе на обратный
Вопрос 6. Какой метод позволяет удалять и возвращать последний элемент вектора в Rust?
a) push
b) get
c) remove
d) pop
Вопрос 7. Что произойдет, если попытаться обратиться к элементу массива Rust по индексу, который выходит за границы?
a) вернется None
b) рроизойдет паника на этапе исполнения
c) исходный код скомпилируется с предупреждением
d) вернется дефолтное значение
Вопрос 8. Какая структура данных в Rust похожа на «окно», позволяющее
видеть часть контейнера, не открывая его?
a) массивы
b) кортежи
c) срезы
d) векторы
Вопрос 9. Что позволяет делать метод iter_mut при выполнении итераций по вектору в Rust?
a) получать индекс и значение каждого элемента
b) добавлять элементы в вектор
c) получать мутируемые ссылки на элементы для их модификации прямо
на месте
d) удалять элементы из вектора
Вопрос 10. В чем преимущество использования массивов Rust перед векторами?
a) массивы могут динамически изменять размер
b) массивы позволяют хранить элементы разных типов данных
c) массивы имеют фиксированный размер и содержат элементы одного типа
d) массивы можно легко сортировать прямо на месте
Вопрос 11. Что такое кортеж в Rust?
a) коллекция элементов различных типов
b) структура данных, используемая для обработки ошибок
c) массив фиксированного размера
d) коллекция элементов одинакового типа
Вопросы для самопроверки 213
Вопрос 12. Какое из следующих утверждений о кортежах верно?
a) кортежи могут содержать элементы только одного типа
b) кортежи могут динамически изменять размер
c) кортежи имеют фиксированный размер и могут содержать элементы
разных типов
d) кортежи можно легко сортировать прямо на месте
Вопрос 13. Как обращаться к элементам внутри кортежа в Rust?
a) используя цикл for
b) путем прямого индексирования элементов через точку
c) путем конвертации кортежа в вектор
d) используя выражение match
Вопрос 14. Какова цель деструктуризации кортежа в Rust?
a) Создавать новый кортеж
b) Извлекать отдельные элементы и присваивать их переменным
c) Конвертировать кортеж в вектор
d) Проверять, пуст ли кортеж
Вопрос 15. Что из перечисленного является примером кортежного шаб
лона в Rust?
a) (1, "hello", true)
b) [1, 2, 3]
c) {name: "Alice", age: 30}
d) "Rust is great!"
Вопрос 16. Каково основное применение кортежей при представлении
координат?
a) хранение элементов различных типов
b) обеспечение динамического изменения размера данных
c) представление точек в пространстве
d) облегчение обработки ошибок
Вопрос 17. Какова роль кортежей в парадигме обработки ошибок в Rust?
a) кортежи позволяют динамически изменять размер данных
b) кортежи предоставляют краткий способ определения конкретно-прикладных типов данных
c) кортежи позволяют возвращать несколько значений из функций для
структурированных сообщений об ошибках
d) кортежи используются для сортировки элементов
Вопрос 18. Каковы ограничения кортежей в Rust?
a) кортежи могут динамически изменять размер
b) кортежи не подходят для представления составных структур данных
214
Встроенные в Rust структуры данных
c) кортежи имеют фиксированный размер, и после объявления его нельзя
изменять
d) кортежи не используются в конструкции сопоставления с шаблоном
Вопрос 19. Что такое неопровержимый шаблон в Rust?
a) шаблон, который всегда совпадает с любым входным значением
b) шаблон, который может не совпасть с определенными входными значениями
c) шаблон, используемый исключительно для сопоставления кортежей
d) шаблон, который сочетается только с пустыми кортежами
Вопрос 20. Каково одно из практических применений хеш-множеств в Rust?
a) сортировка элементов в массиве
b) извлечение подстрок из строкового литерала
c) кеширование результатов во избежание избыточных вычислений
d) токенизация предложений в обработке естественного языка
Вопрос 21. Какая операция на хеш-множествах помогает находить элементы, присутствующие в одном множестве, но отсутствующие в другом?
a) объединение
b) пересечение
c) разность
d) подмножество
Вопрос 22. Как в Rust эффективно проверять наличие элемента в большом наборе данных с помощью хеш-множества?
a) использовать цикл для выполнения итераций по всем элементам
b) использовать метод contains хеш-множества
c) конвертировать набор данных в вектор и выполнить поиск
d) применить функцию filter к набору данных
Вопрос 23. Что возвращает операция симметрической разности хешмножеств?
a) общие элементы двух множеств
b) элементы, присутствующие в обоих множествах
c) элементы, присутствующие в одном из множеств, но не в обоих сразу
d) объединение двух множеств
Вопрос 24. Какова основная цель использования хеш-множеств в задачах дедупликации?
a) создание дополнительных копий элементов
b) выполнение сложных математических операций
c) эффективное удаление дубликатов
d) сортировка элементов по возрастанию
Вопросы для самопроверки 215
Вопрос 25. Что из перечисленного НЕ является практическим применением хеш-множеств в Rust?
a) проверка членства в большом наборе данных
b) реализация графового алгоритма, такого как поиск сперва в ширину
c) сортировка элементов в массиве
d) выполнение операций на множествах (объединение/пересечение)
Вопрос 26. Какая структура данных в Rust обычно используется для отслеживания посещенных узлов при реализации поиска сперва в ширину?
a) хеш-таблицы
b) связные списки
c) массивы
d) хеш-множества
Вопрос 27. Какой метод хеш-множества можно использовать для вычисления объединения двух множеств?
a) add
b) subtract
c) union
d) intersect
Вопрос 28. В чем основное преимущество использования хеш-множеств
для проверки вхождения (членства) в Rust?
a) хеш-множества гарантируют определенный порядок элементов
b) проверка вхождения в хеш-множествах медленнее, чем в массивах
c) хеш-множества обеспечивают быструю и практичную оценку наличия
элемента
d) хеш-множества могут хранить только элементы одного типа
Вопрос 29. Что означает термин «дедупликация» в контексте хеш-множеств?
a) процесс добавления дубликатов в множество
b) процесс выполнения математических операций
c) процесс эффективного удаления повторяющихся элементов из коллекции
d) процесс сортировки элементов по убыванию
Ответы
1.
2.
3.
4.
5.
c) определять общее поведение, которое могут реализовывать типы
d) массивы
c) используя индексацию с квадратными скобками []
a) push
a) он позволяет получать одновременно и индекс, и значение каждого
элемента
6. d) pop
7. b) произойдет паника на этапе исполнения
216
Встроенные в Rust структуры данных
8. c) срезы
9. c) он позволяет получать мутируемые ссылки на элементы для их модификации прямо «на месте»
10. c) массивы Rust имеют фиксированный размер и содержат элементы одного типа
11. a) коллекция элементов различных типов
12. c) кортежи имеют фиксированный размер и могут содержать элементы
разных типов
13. b) путем прямого индексирования элементов через точку
14. b) извлекать отдельные элементы и присваивать их переменным
15. a) (1, "hello", true)
16. c) представление точек в пространстве
17. c) кортежи позволяют возвращать несколько значений из функций для
структурированных сообщений об ошибках
18. c) кортежи имеют фиксированный размер, и после объявления его нельзя
изменять
19. a) шаблон, который всегда совпадает с любым входным значением
20. c) кеширование результатов во избежание избыточных вычислений
21. c) разность
22. b) использовать метод contains хеш-множества
23. c) элементы, присутствующие в одном из множеств, но не в обоих сразу
24. c) для эффективного удаления дубликатов
25. c) сортировка элементов в массиве
26. d) хеш-множества
27. c) union
28. c) хеш-множества обеспечивают быструю и практичную оценку вхождения элемента
29. c) процесс эффективного удаления повторяющихся элементов из коллекции
Глава 5
Обработка ошибок
и восстановление
после отказов
Введение
Обработка ошибок – это важнейший аспект программирования, обеспечивающий надежность и отказоустойчивость программного обеспечения. В этой главе мы проведем обследование сложной темы обработки ошибок и восстановления после отказов, вооружив вас знаниями и методами, необходимыми профессиональному разработчику на Rust для работы с различными сценариями
возникновения ошибок.
Эта глава познакомит вас со способностями Rust по обработке ошибок, уделив основное внимание его мощным инструментам: типам Result и Option.
Вы научитесь использовать выразительность этих типов для эффективного
управления ошибками в своем исходном коде. Будь то работа с потенциальными отказами при операциях с файлами, сетевом обмене данными или любым
другим аспектом ваших приложений на Rust – понимание и освоение типов
Result и Option имеет решающее значение.
Но это еще не все; мы не остановимся на основах. В этой главе мы углубимся
в продвинутые стратегии обработки ошибок, научим вас щадяще передавать
ошибки по исходному коду и обеспечивать эффективную передачу информации об ошибках между функциями и модулями. Вы откроете для себя приемы
обработки сценариев возникновения нескольких ошибок, позволяющие вашим программам гибко адаптироваться к непредвиденным ситуациям.
Кроме того, мы проведем обследование приемов создания конкретно-прикладных типов ошибок. Они позволяют добавлять больше контекста в сообщения об ошибках, что упрощает диагностику и исправление проблем при
их возникновении. Вы увидите, как конструировать типы ошибок, которые
содержат в себе конкретные детали о природе и источнике отказа, повышая
ясность и полезность отчетов об ошибках.
Эта глава – всеобъемлющий путеводитель по обработке ошибок на Rust. Начинаете ли вы свой путь в Rust или хотите отточить навыки обработки ошибок,
вы найдете здесь ценные идеи и практические знания, которые помогут вам писать
218
Обработка ошибок и восстановление после отказов
более надежный и поддерживаемый исходный код. Итак, давайте окунемся в работу и вместе разберемся в тонкостях эффективной обработки ошибок на Rust.
Содержание главы
В этой главе будут рассмотрены следующие темы:
работа с ошибками на Rust с использованием типов Result и Option;
методы передачи по стеку вызовов и обработки нескольких ошибок;
создание конкретно-прикладных типов ошибок с целью улучшения информативности сообщений.
Обработка ошибок с использованием
типов Result и Option
Обработка ошибок с использованием типов Result и Option в Rust – это фундаментальный аспект программирования. Она нуждается во всестороннем понимании этих двух основных типов и их ключевой роли в формировании системы обработки ошибок на Rust. Типы Result и Option служат краеугольным
камнем принятого в Rust подхода к управлению ошибками, и глубокое понимание их тонкостей играет критически важную роль в написании надежного
и поддерживаемого исходного кода.
Исключение
генерируется
сверху вниз
функция n
функция 2
функция 1
Стек вызовов
растет снизу
вверх
main
Передача исключений
в Rust
Рисунок 5.1. Передача исключений в Rust
Понимание типов Result и Option
Управление ошибками в Rust осуществляется преимущественно посредством
двух типов-перечислений: Result и Option. Давайте начнем с четкого понимания этих типов и их роли.
Понимание типов Result и Option 219
Листинг 5.1. Простейший пример использования типов Result и Option
fn main() {
let result: Result<i32, &str> = Ok(42); // ①
let option: Option<i32> = Some(42);
// ②
}
В приведенном выше примере:
① мы создаем экземпляр типа Result с именем result, представляющий
успешный результат со значением 42;
② и аналогичным образом создаем экземпляр типа Option с именем option,
также содержащий значение 42.
Тип Result, как следует из названия, представляет собой результат операции, которая может быть либо успешной (вариант Ok), либо безуспешной
(вариант Err). Эта двойственность содержит в себе суть философии обработки ошибок на Rust: ошибки обрабатываются явно и защищенно, не оставляя
места для непроверенных исключений. Умение эффективно использовать
тип Result позволяет щадяще обрабатывать успех и реагировать на отказы,
что ведет к созданию более стабильных и отказоустойчивых приложений.
Тип Option, с другой стороны, вводит концепцию опциональных (необязательных) значений. Он нередко используется в ситуациях, когда функция
может вернуть значение, а может не вернуть ничего, что устраняет необходимость в пустых или неопределенных значениях (null или undefined), часто
встречающихся в других языках программирования (например, в JavaScript).
Понимание типа Option позволяет явно выражать возможность отсутствия
или наличия значения, снижая риск ошибок пустого указателя и делая исходный код более предсказуемым и защищенным, как было объяснено в главе 1
«Системное программирование на языке Rust».
Эти два типа – не просто синтаксические конструкции; они олицетворяют
стремление Rust к обеспечению защищенности памяти и предотвращению
ошибок. Они направляют вас к написанию исходного кода, свободного от распространенных ловушек, таких как обращение к памяти по пустому указателю
и непроверенные исключения, которые часто являются источником уязвимостей и нестабильности программного обеспечения. Таким образом, освоение
умений работы с типами Result и Option – это не просто вопрос синтаксиса,
а фундаментальный сдвиг в мышлении в сторону написания надежного и эффективного исходного кода.
Обработка ошибок с помощью типа Result
Обработка ошибок с помощью типа Result на Rust – центральный аспект написания надежного исходного кода. Тип Result применяется в первую очередь
тогда, когда операция потенциально может завершиться отказом и требуется
структурированный и щадящий способ передачи ошибок и управления ими
по всей кодовой базе.
В следующем ниже примере исходного кода мы иллюстрируем распространенный шаблон использования типа Result. Здесь определена функция divide для выполнения деления двух целых чисел, a и b. Внутри этой функции процесс обработки ошибок выстроен максимально точно:
220
Обработка ошибок и восстановление после отказов
Листинг 5.2. Обработка ошибок с помощью типа Result
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err(format!("Деление {} на ноль невозможно.", a)); // ①
}
Ok(a / b) // ②
}
В приведенном выше примере:
① когда делитель (b) равен нулю, функция незамедлительно возвращает вариант Err с описательным сообщением об ошибке, недвусмысленно указывая на то, что деление на ноль недопустимо. Для создания конкретноприкладного сообщения об ошибке используется макрофункция format!;
② если делитель не равен нулю, то функция возвращает вариант Ok, содержащий результат деления, упаковывая успешный исход операции.
Такой структурированный подход позволяет упаковывать ошибки осмысленным контекстом и эффективно передавать их вверх по стеку вызовов.
Использование типа Result далее демонстрируется в функции main:
Листинг 5.3. Базовый вызов метода с обработкой ошибки
fn main() {
let result = divide(10, 2);
// ①
match result {
Ok(value) => println!("Результат: {}", value),
Err(error) => eprintln!("Ошибка: {}", error), // ②
}
}
// Вывод:
// Результат: 5
В приведенном выше исходном коде:
① мы вызываем функцию divide с аргументами 10 и 2, что приводит
к успешному делению; вариант Ok сохраняется в переменной result;
② в дальнейшем мы применяем конструкцию match для сопоставления
типа Result с шаблоном, что позволяет соответствующим образом реагировать на исход. Если результат – Ok, мы выводим вычисленное значение;
если же он равен Err, то выводим сообщение об ошибке.
Этот шаблон демонстрирует не только структурированное использование
типа Result, но и важность предоставления понятных сообщений об ошибках
и их щадящей обработки, что повышает поддерживаемость и надежность программ на Rust.
Обработка ошибок с помощью типа Option
Когда речь заходит о необязательных параметрах конфигурации или проверке
наличия элемента в коллекции, часто используется тип Option. Этот тип предназначен для ситуаций, когда значение может отсутствовать вовсе.
Передача ошибок по стеку вызовов 221
Листинг 5.4. Базовая обработка ошибок с использованием типа Option
fn find_element(arr: &[i32], target: i32) -> Option<usize> {
for (i, &element) in arr.iter().enumerate() {
if element == target {
return Some(i); // ①
}
}
None
// ②
}
fn main() {
let numbers = [1, 2, 3, 4, 5];
let target = 3;
match find_element(&numbers, target) {
Some(index) => println!("Найден по индексу: {}", index),
None => println!("Элемент не найден."),
}
}
// Вывод:
// Найден по индексу: 2
В приведенном выше исходном коде:
① если целевой элемент найден, то мы возвращаем экземпляр типа Some,
содержащий индекс элемента;
② если целевой элемент не найден, то возвращаем None.
Использование типов Result и Option для обработки ошибок закладывает
прочный фундамент управления как отказами, так и необязательными значениями. Это позволяет разрабатывать исходный код, который будет более предсказуемым и надежным.
Однако в механизмах обработки ошибок в Rust есть еще много интересного.
В последующих разделах мы подробно обследуем эти концепции на дополнительных примерах исходного кода, а также разберем продвинутые приемы работы с исключительными ситуациями.
Передача ошибок по стеку вызовов
При работе над крупными проектами или сложными системами часто встречаются сценарии, в которых сразу несколько операций могут завершиться отказом. В таких случаях необходимо эффективно обрабатывать возникающие
ошибки. Rust предлагает механизмы передачи ошибок по стеку вызовов и щадящей обработки нескольких ошибок.
Оператор ?
Оператор ? в Rust, который часто называют оператором try, – это мощный
инструмент, значительно упрощающий передачу ошибок по стеку вызовов
и их обработку. Он предназначен для оптимизации процесса досрочного выхода из функции при возникновении ошибки и ее автоматической передачи
222
Обработка ошибок и восстановление после отказов
вверх по стеку вызовов. Этот оператор играет ключевую роль в повышении
удобочитаемости и поддерживаемости исходного кода, особенно при работе
с функциями, возвращающими типы Result или Option.
В следующем ниже примере исходного кода использование оператора ? иллюстрируется следующим образом:
Листинг 5.5. Пример файлового ввода-вывода
с обработкой ошибок посредством Result
use std::fs::File;
use std::io::Read;
fn read_file_contents(file_path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(file_path)?; // ①
let mut contents = String::new();
file.read_to_string(&mut contents)?;
// ②
Ok(contents)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let file_path = "example.txt";
let contents = read_file_contents(file_path)?; // ③
println!("Содержимое файла: {}", contents);
Ok(())
}
// Вывод, если файл не существует:
// Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
В приведенном выше исходном коде:
① оператор ? используется при попытке открыть файл по пути file_path с
помощью статической функции File::open. Если возникает ошибка (например, файл не найден), она автоматически возвращается, и функция read_file_contents досрочно завершается, передавая ошибку вызывающему элементу кода;
② оператор ? применяется аналогичным образом при чтении содержимого
файла в переменную contents посредством метода file.read_to_string.
Любые ошибки, возникшие во время операции чтения, также передаются вверх по стеку вызовов;
③ в функции main оператор ? используется при вызове функции read_
file_contents. Это позволяет немедленно возвращать из самой функции main любые ошибки, встреченные при открытии или чтении файла,
обеспечивая краткость и эффективность обработки.
Оператор ? можно использовать и с конкретно-прикладными типами ошибок. Реализовав общую черту From, можно конвертировать ошибки из одного
типа в другой. В данном примере в качестве типа ошибки в функции main используется Box<dyn std::error::Error>, что позволяет единообразно обрабатывать различные типы ошибок.
Таким образом, оператор ? служит механизмом автоматической и эффективной обработки ошибок, уменьшая необходимость в написании ручного исход-
Передача ошибок по стеку вызовов 223
ного кода обработки и делая кодовую базу понятнее. Он особенно ценен при работе с функциями, возвращающими экземпляр Result либо Option, так как элегантно справляется со сложностями передачи ошибок по стеку вызовов в Rust.
Обработка нескольких ошибок с помощью типа Result
Тип Result в Rust можно эффективно использовать для обработки нескольких
потенциальных ошибок. Можно применять такие комбинаторы, как map, and_
then и or_else, чтобы выстраивать цепочки операций и обрабатывать ошибки
на каждом шаге.
Листинг 5.6. Обработка нескольких ошибок с помощью Result
use std::num::ParseIntError;
#[derive(Debug)]
enum CustomError {
ParsingError(ParseIntError),
SquaringError,
}
fn parse_and_square(input: &str) -> Result<i32, CustomError> {
// Преобразуем ошибку разбора в конкретно-прикладной
// вариант CustomError и передаем ее через ?
let number = input.parse::<i32>().map_err(CustomError::ParsingError)?;
if number <= 0 {
return Err(CustomError::SquaringError);
}
let squared = number * number;
Ok(squared)
}
fn main() -> Result<(), CustomError> {
let input = "a";
let result = parse_and_square(input)
.and_then(|squared| {
if squared < 100 {
Ok(squared)
} else {
Err(CustomError::SquaringError)
}
})
.map_err(|err| {
eprintln!("Произошла ошибка: {:?}", err);
err
})?;
println!("Результат: {}", result);
Ok(())
}
// Вывод:
// Произошла ошибка: ParsingError(ParseIntError { kind: InvalidDigit })
// Error: ParsingError(ParseIntError { kind: InvalidDigit })
224
Обработка ошибок и восстановление после отказов
В приведенном выше примере мы выполняем разбор строкового литерала,
конвертируя его в целое число, а затем возводим число в квадрат и возвращаем
результат. Если любая из этих операций завершается отказом, то ошибка передается вверх по стеку вызовов. Показанные приемы позволяют щадяще обрабатывать цепочки ошибок, поддерживая исходный код чистым и удобочитаемым.
Передача ошибок с использованием типов Result и Option
Функциональные средства Rust по обработке ошибок чрезвычайно гибки, и их можно комбинировать многочисленными способами с целью эффективного управления сложными сценариями. Задействуя всю мощь типов Result и Option, можно создавать высокоуровневые и легко компонуемые
цепочки обработки ошибок.
Листинг 5.7. Проброс ошибок с использованием Result и Option
fn find_element(arr: &[i32], target: i32) -> Option<usize> {
arr.iter().position(|&x| x == target)
}
fn divide(dividend: i32, divisor: i32) -> Result<i32, String> {
if divisor == 0 {
Err("Деление на ноль".to_string())
} else {
Ok(dividend / divisor)
}
}
fn find_and_divide(arr: &[i32], target: i32, divisor: i32) -> Result<f64,
String> {
// ① Конвертировать тип Option в тип Result с помощью ok_or_else
let index = find_element(arr, target)
.ok_or_else(|| "Элемент не найден.".to_string())?;
let result = divide(arr[index], divisor)?;
Ok(result as f64)
}
fn main() {
let numbers = [1, 2, 3, 4, 5];
let target = 3;
let divisor = 2;
}
match find_and_divide(&numbers, target, divisor) {
Ok(result) => println!("Результат: {}", result),
Err(error) => eprintln!("Ошибка: {}", error),
}
// Вывод:
// Результат: 1
В приведенном выше исходном коде:
① мы используем метод ok_or_else, чтобы конвертировать тип Option
в тип Result с конкретно-прикладным сообщением об ошибке.
Создание конкретно-прикладных типов ошибок 225
Этот пример демонстрирует отыскание элемента в массиве, деление его
на число и возврат результата в виде значения с плавающей точкой. Если любой из этапов завершается отказом, то мы передаем ошибку вызывающему
элементу кода.
Если вы хотите повысить свои навыки программирования на Rust, крайне
важно овладеть искусством эффективной передачи ошибок по стеку вызовов
и управления ошибками. К счастью, в вашем распоряжении имеется целый ряд
проверенных методов достижения этой цели. От шаблонов обработки ошибок
до продвинутых стратегий работы с несколькими ошибками – Rust предоставляет обширный инструментарий, помогающий минимизировать количество
ошибок в исходном коде.
Создание конкретно-прикладных типов ошибок
Хотя Rust предоставляет встроенные типы ошибок, такие как std::io::Error
и std::num::ParseIntError, вам нередко может потребоваться создание конкретно-прикладных типов ошибок, чтобы добавлять исходному коду больше
контекста и делать сообщения об ошибках информативнее.
Листинг 5.8. Создание конкретно-прикладных типов ошибок
use std::fs::File;
use std::io::Read;
use std::io::Error as IoError;
#[derive(Debug)]
enum FileReadError {
FileNotFound,
IoError(IoError),
}
impl From<IoError> for FileReadError {
fn from(error: IoError) -> Self {
if error.kind() == std::io::ErrorKind::NotFound {
FileReadError::FileNotFound
} else {
FileReadError::IoError(error)
}
}
}
fn read_file_contents(file_path: &str) -> Result<String, FileReadError> {
// Использовать map_err для преобразования ошибки
// посредством реализации общей черты From
let mut file = File::open(file_path).map_err(FileReadError::from)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let file_path = "nonexistent.txt";
226
Обработка ошибок и восстановление после отказов
match read_file_contents(file_path) {
Ok(contents) => println!("Содержимое файла: {}", contents),
Err(FileReadError::FileNotFound) => eprintln!("Файл не найден."),
Err(FileReadError::IoError(e)) => eprintln!("Ошибка ввода-вывода: {}",
e),
}
Ok(())
}
// Вывод:
// File not found.
В приведенном выше примере мы определяем конкретно-прикладное перечисление FileReadError, которое содержит специфичные варианты ошибок.
Мы также используем map_err, чтобы соотносить std::io::Error с нашим конкретно-прикладным типом, добавив больше контекста.
Создание конкретно-прикладных типов ошибок позволяет предоставлять
более понятные сообщения и помогает пользователям вашего исходного кода
эффективнее понимать и обрабатывать возникающие ошибки.
К настоящему моменту вы изучили фундаментальные концепции обработки ошибок на Rust, такие как использование типов Result и Option, передача ошибок по стеку вызовов, работа с несколькими ошибками и создание
конкретно-прикладных типов ошибок. По мере продвижения в изучении Rust
вы научитесь мастерски управлять ошибками и производить надежное программное обеспечение.
Продвинутая обработка ошибок
Продвинутая обработка ошибок в Rust необходима при разработке сложных
реальных приложений, в которых сценарии возникновения ошибок могут быть
самыми разнообразными и запутанными. Rust предлагает несколько продвинутых приемов эффективного управления ошибками в подобных контекстах.
Библиотека anyhow
Одним из примечательных инструментов в арсенале обработки ошибок в Rust
является библиотека anyhow. Эта библиотека оптимизирует процесс обработки ошибок, предоставляя унифицированный тип ошибки, который упрощает
их создание и управление ими. Она становится особенно полезной в сценариях, в которых требуется лаконичная обработка ошибок без необходимости
определять конкретно-прикладные типы ошибок для каждого возможного условия возникновения ошибки.
Для того чтобы задействовать библиотеку anyhow в своем проекте на Rust,
сначала необходимо ее добавить в список зависимостей в файле Cargo.toml:
Листинг 5.9. Конфигурационный файл Cargo.toml
для добавления зависимости anyhow
[dependencies]
anyhow = "1"
Создание конкретно-прикладных типов ошибок 227
После добавления зависимости библиотеку anyhow можно использовать
в своем исходном коде для более эффективной обработки ошибок:
Листинг 5.10. Простейший пример использования библиотеки anyhow
use anyhow::{anyhow, Result};
fn divide(a: i32, b: i32) -> Result<i32> {
if b == 0 {
return Err(anyhow!("Деление на ноль невозможно.")); // ①
}
Ok(a / b)
}
fn main() -> Result<()> {
let result = divide(10, 0)?; // ②
println!("Результат: {}", result);
Ok(())
}
// Вывод:
// Error: Деление на ноль невозможно.
В приведенном выше исходном коде:
① макрофункция anyhow! используется для создания конкретно-прикладного сообщения об ошибке. Эта ошибка возвращается как вариант Err типа Result при возникновении деления на ноль;
② при вызове функции divide в функции main используется оператор ?, что
обеспечивает автоматическую передачу ошибки по стеку вызовов. Если
во время деления произойдет ошибка, она будет возвращена из функции main, обеспечивая лаконичную и понятную обработку.
Библиотека anyhow значительно упрощает управление ошибками, предлагая
унифицированный тип ошибок, который снижает многословность исходного
кода. Этот подход особенно выгоден в приложениях, где ошибки могут возникать из самых разных источников, а определять конкретно-прикладные
типы для каждой из них было бы слишком трудоемко. Благодаря библиотеке anyhow можно сосредоточиваться на эффективной обработке ошибок, поддерживая при этом чистоту и краткость исходного кода.
Конкретно-прикладные типы ошибок с пакетом thiserror
Конкретно-прикладные типы ошибок играют решающую роль в продвинутой обработке ошибок, и пакет thiserror значительно упрощает их создание. С его помощью можно определять перечисления с ассоциированными
данными ошибок, при этом общие черты Error и Display будут реализованы
автоматически. За счет этого обработка ошибок становится выразительнее
и эффективнее.
Для того чтобы включить пакет thiserror в свой проект, необходимо добавить его в конфигурационный файл Cargo.toml:
228
Обработка ошибок и восстановление после отказов
Листинг 5.11. Конфигурационный файл Cargo.
toml с добавленной зависимостью thiserror
[dependencies]
thiserror = "1"
После добавления данной зависимости можно с легкостью создавать конкретно-прикладные типы ошибок, как показано в приведенном ниже примере:
Листинг 5.12. Простейший пример использования пакета thiserror
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyError {
#[error("Файл не найден: {0}")]
FileNotFound(String),
#[error("Ошибка ввода-вывода: {0}")]
IoError(#[from] std::io::Error),
}
fn read_file_contents(file_path: &str) -> Result<String, MyError> {
let result = std::fs::read_to_string(file_path);
match result {
Ok(contents) => Ok(contents),
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
Err(MyError::FileNotFound(file_path.to_string()))
} else {
Err(MyError::IoError(e))
}
}
}
}
fn main() -> Result<(), MyError> {
let file_path = "example.txt";
let contents = read_file_contents(file_path)?;
println!("Содержимое файла: {}", contents);
Ok(())
}
// Вывод:
// Error: FileNotFound("example.txt")
В приведенном выше примере пакет thiserror используется для создания
перечисления MyError, представляющего конкретно-прикладные ошибки
с данными. Атрибут #[error] позволяет задавать конкретно-прикладное сообщение для каждого варианта, а атрибут #[from] упрощает конвертацию других
типов (например, std::io::Error) в ваш конкретно-прикладной тип ошибки.
Описанные выше продвинутые методы обработки ошибок обеспечивают
более высокую степень гибкости и ясности в управлении ошибками, что особенно важно в крупных и сложных проектах на Rust. Строгая система типов
и функциональные средства Rust по работе с ошибками позволяют создавать
Создание конкретно-прикладных типов ошибок 229
отказоустойчивое и надежное программное обеспечение, поддерживая при
этом удобочитаемость и удобство сопровождения исходного кода. Благодаря
использованию таких инструментов, как пакет thiserror, обработка ошибок
становится более эргономичной и выразительной частью процесса разработки приложений на Rust.
Асинхронная обработка ошибок
Асинхронное программирование бросает иные вызовы в вопросах обработки
ошибок, требуя иного подхода по сравнению с синхронным исходным кодом.
Появление синтаксиса async и await в Rust в сочетании с такими библиотеками, как tokio и async-std, дает специализированные инструменты для эффективного решения задач в асинхронных сценариях.
В данном контексте крайне важно понимать, что управление ошибками
в асинхронном программировании необходимо для обеспечения отказоустойчивости и надежности программного обеспечения. Поскольку задачи выполняются конкурентно, а ошибки могут возникать в разные моменты времени,
надежный механизм их обработки становится краеугольным камнем для щадящего разрешения непредвиденных ситуаций. Он также играет ключевую
роль в предотвращении аварийных завершений работы программ и выдачи
неверных результатов.
Асинхронные функции и экземпляры типа Result
При работе с асинхронным исходным кодом часто встречаются асинхронные
функции (с предваряющим ключевым словом async), возвращающие экземпляры типов Result или Option.
Листинг 5.13. Асинхронная обработка ошибок с помощью типа Result
use reqwest;
async fn fetch_data() -> Result<String, reqwest::Error> { // ①
let response = reqwest::get("https://www.google.com/").await?;
let body = response.text().await?;
Ok(body)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Здесь при необходимости можно запускать асинхронный код.
let result = fetch_data().await;
match result {
Ok(body) => {
println!("Доставленные данные:\n{}", body);
}
Err(err) => {
eprintln!("Ошибка доставки данных: {:?}", err);
}
}
Ok(())
}
230
Обработка ошибок и восстановление после отказов
// Вывод:
// Доставленные данные:
// <!doctype html><html ...
В приведенном выше примере:
① функция fetch_data представляет собой асинхронную функцию, возвращающую экземпляр типа Result. Ключевое слово await используется для приостановки исполнения функции. Оно позволяет писать асинхронные функции
так, будто они являются синхронными (исполняются последовательно).
При работе с асинхронными функциями крайне важно понимать механику передачи ошибок. Оператор ? оптимизирует управление ошибками, автоматически преобразуя их в соответствующий возвращаемый тип. Если ошибка
возникает в любом месте асинхронной цепочки исполнения, то она «выводится на поверхность» и возвращается из функции. Благодаря этому появляется
возможность обрабатывать ошибки на более высоких уровнях или передавать
их дальше по мере необходимости. Следовательно, такой подход способствует
созданию лаконичного и чистого асинхронного исходного кода, обеспечивая
при этом эффективное управление ошибками на всех этапах процесса.
Конкретно-прикладные типы ошибок
для асинхронного исходного кода
При работе с асинхронным исходным кодом одним из полезных практических
приемов является создание конкретно-прикладных типов ошибок, адаптированных под уникальные требования приложения или библиотеки. Такой подход позволяет предоставлять пользователям и коллегам-разработчикам более
информативные сообщения и контекст.
Создание конкретно-прикладных типов ошибок преследует несколько важных целей. Прежде всего оно позволяет абстрагироваться от низкоуровневых
деталей реализации и выдавать сообщения, более понятные и информативные для пользователя. Например, вместо того чтобы просто указывать на возникновение сетевой ошибки или ошибки ввода-вывода, можно уточнить природу проблемы, что упрощает ее отыскание и устранение.
Листинг 5.14. Конкретно-прикладные типы ошибок для асинхронного кода
use std::io;
#[derive(Debug)]
enum MyError {
Network(reqwest::Error),
Io(io::Error),
}
async fn fetch_data() -> Result<String, MyError> {
// ... логика функции
}
В приведенном выше примере MyError представляет собой конкретно-прикладное перечисление, которое объединяет два разных типа ошибок: reqwest::Error и io::Error. Таким образом, оно добавляет дополнитель-
Создание конкретно-прикладных типов ошибок 231
ный уровень контекста в отчеты об ошибках. Например, если в функции fetch_
data произойдет сетевая ошибка, то результирующая ошибка будет иметь
тип MyError и содержать информацию о природе проблемы – будь то неполадки в сети или операции ввода-вывода.
Использование конкретно-прикладных типов ошибок в асинхронном исходном коде повышает ясность процесса их обработки, облегчая диагностику
и исправление проблем, возникающих во время исполнения программы. Эта
практика соответствует глобальной цели написания надежного и удобного для
пользователя асинхронного исходного кода.
Обработка ошибок конкурентности
Асинхронное программирование часто предусматривает исполнение конкурентных операций – особенность, которая привносит свои вызовы, особенно
в части обработки ошибок. Грамотное управление ошибками конкурентности
необходимо для обеспечения отказоустойчивости и надежности асинхронных
приложений. В Rust синтаксис async/await и библиотеки наподобие tokio предоставляют специализированные инструменты, позволяющие легко ориентироваться в таких сложных сценариях.
Конкурентность привносит возможность одновременного выполнения
нескольких заданий, каждое из которых потенциально может столкнуться
с ошибками. А это требует комплексной стратегии обработки ошибок, учитывающей асинхронную и конкурентную природу программы.
Листинг 5.15. Пример обработки ошибок конкурентности
use tokio::sync::mpsc;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let (tx, mut rx) = mpsc::channel(32);
for i in 0..4 {
let tx = tx.clone();
tokio::spawn(async move { // ①
let result = do_work(i).await;
tx.send(result).await.expect("Send failed");
});
}
}
for _ in 0..4 {
if let Some(result) = rx.recv().await {
println!("Полученный результат: {:?}", result);
}
}
Ok(())
async fn do_work(task_id: i32) -> i32 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
task_id * 2
}
232
//
//
//
//
//
Обработка ошибок и восстановление после отказов
Вывод:
Полученный
Полученный
Полученный
Полученный
результат:
результат:
результат:
результат:
4
0
2
6
В приведенном выше примере:
① мы задействуем свободную функцию tokio::spawn для конкурентного
исполнения заданий и используем каналы (mpsc::channel) для сбора и обработки результатов. Такой шаблон обеспечивает эффективное конкурентное
исполнение и агрегацию данных, но также привносит возможность одновременной передачи ошибок из нескольких заданий сразу.
Формирование эффективных методов распознавания, обработки и передачи ошибок, возникающих в ходе разных операций, имеет первостепенное
значение для бесшовного управления конкурентным исходным кодом. Оно
требует синхронизации ошибок в условиях частичных отказов при сохранении общей согласованности в исполнении программы.
Мир асинхронного и конкурентного программирования в Rust создает уникальные препятствия для обработки ошибок, но с правильными инструментами и стратегиями можно эффективно их преодолевать и создавать надежные
приложения, способные щадяще справляться с ошибками конкурентности.
Более подробное объяснение асинхронного программирования приведено
в главе 10 «Итераторы и замыкания».
Обработка ошибок в веб-приложениях
Обработка ошибок в области веб-разработки играет ключевую роль в обеспечении надежности и удобства использования веб-приложений. Rust, уверенно набирающий популярность в этой области, зарекомендовал себя как привлекательный
инструмент, и на то есть веские причины. Его надежные способности по обработке ошибок в сочетании с мощью таких фреймворков, как warp и actix-web, предоставляют мощный инструментарий для управления ошибками в веб-сервисах.
Обработка ошибок в веб-приложениях охватывает массу сценариев. К ним
относятся: обработка запросов с отсутствующими или недопустимыми данными, работа с отказами баз данных или внешних сервисов, а также грамотное
управление непредвиденными исключениями с целью предотвращения аварийного завершения работы системы. Суть заключается в том, чтобы обеспечивать пользователям плавный и информативный опыт, сохраняя при этом
стабильность приложения.
Листинг 5.16. Пример обработки ошибок в веб-приложениях
use warp::Filter;
#[tokio::main]
async fn main() {
// Определяем маршрут: при переходе на "hello/world"
let hello = warp::path!("hello" / "world")
.map(|| {
warp::reply::html("Привет, мир!")
});
Создание конкретно-прикладных типов ошибок 233
// Добавляем журналирование к маршрутам
let routes = hello.with(warp::log("myapp::api"));
}
// Запускаем сервер на локальном адресе 127.0.0.1 и порту 3030
warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
// $ curl 127.0.0.1:3030/hello/world
// Вывод:
// Привет, мир!
В приведенном выше примере с использованием фреймворка warp определен простейший веб-сервис. В контексте веб-приложений под обработкой
ошибок часто подразумевается возврат соответствующих HTTP-кодов состояния и формирование информативных ответов, которые помогают пользователям понимать и устранять возникшие проблемы.
Способности Rust по обработке ошибок, включая создание конкретно-прикладных типов и использование синтаксиса async/await, делают его исключительно подходящим для разработки отказоустойчивых и надежных вебсервисов. Эти функциональные средства позволяют разработчикам писать чис
тый, поддерживаемый исходный код, который элегантно справляется с различными сценариями возникновения ошибок, что в конечном итоге способствует
улучшению пользовательского опыта и долгосрочному успеху веб-приложений.
Обработка ошибок – жизненно важный аспект разработки веб-приложений.
Rust благодаря своим современным функциональным средствам и фреймворкам
позволяет разработчикам достигать успеха в этой критической области, поставляя веб-сервисы, которые одновременно надежны и удобны для пользователей.
Собираем все воедино
В целях закрепления вашего понимания приемов обработки ошибок на Rust
давайте разберем комплексный пример. Мы создадим упрощенный обработчик файлов, который читает данные из CSV-файла, выполняет обработку данных и эффективно управляет ошибками на каждом этапе этого процесса.
Разбор файлов и обработка ошибок
В сфере обработки данных крайне важно не только точно извлекать информацию и манипулировать ею, но и щадяще справляться с потенциальными
ошибками. Наш пример решает эту задачу путем реализации обработчика CSVфайлов, оснащенного механизмами эффективного управления ошибками.
Листинг 5.17. Разбор файлов и обработка ошибок
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Debug)]
enum CsvError {
Io(io::Error),
ParseError(csv::Error),
}
234
Обработка ошибок и восстановление после отказов
impl From<io::Error> for CsvError { // ①
fn from(error: io::Error) -> Self {
CsvError::Io(error)
}
}
impl From<csv::Error> for CsvError { // ①
fn from(error: csv::Error) -> Self {
CsvError::ParseError(error)
}
}
fn parse_csv(file_path: &str) -> Result<(), CsvError> { // ②
let file = File::open(file_path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
let _record = csv::Reader::from_reader(line.as_bytes())
.deserialize::<(String, i32)>();
}
Ok(())
}
В приведенном выше исходном коде:
① создается новый тип ошибки – CsvError. Он объединяет два разных типа
ошибок: io::Error (ошибки ввода-вывода) и csv::Error (ошибки разбора CSV). В целях упрощения обработки этих ошибок мы используем
конвертации, привязанные к общей черте From. За счет этого появляется
возможность конвертировать проблемы низкого уровня в более общую
проблему, представленную абстрактным типом CsvError;
② функция parse_csv составляет ядро нашего примера. Она читает и обрабатывает CSV-файл, указанный в аргументе file_path, эффективно
используя средства обработки ошибок в Rust. Эта функция элегантно
справляется с потенциальными ошибками, которые могут возникать
при операциях с файлом, синтактико-структурном разборе данных или
на других этапах обработки.
Приведенный выше пример, объединяющий мощь механизмов Rust по обработке ошибок, демонстрирует, как создавать отказоустойчивые и надежные компоненты для обработки данных. Благодаря грамотному управлению
ошибками мы гарантируем, что наше приложение останется стабильным даже
при возникновении непредвиденных проблем, обеспечивая более надежный
и удобный пользовательский опыт.
Обработка ошибок в консольных приложениях
Консольные приложения (CLI) – обычное дело в системном программировании, и Rust отлично подходит для их создания. Правильная обработка ошибок
в таких приложениях крайне важна для предоставления понятной обратной
связи пользователям и щадящей обработки различных сценариев.
Создание конкретно-прикладных типов ошибок 235
Использование библиотеки clap
При разработке консольных приложений на Rust первостепенное значение имеют эффективный разбор аргументов и обработка ошибок. Библиотека clap является популярным и надежным инструментом: она упрощает процесс определения командных интерфейсов, предлагая при этом всеобъемлющие возможности по обработке ошибок.
Листинг 5.18. Простейший пример создания
командного интерфейса с помощью clap
use clap::{Parser, Subcommand, Args};
#[derive(Parser)]
#[clap(author, version, about="Мое консольное приложение Rust", long_about=None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Input(Input),
Output(Output),
}
#[derive(Args)]
struct Input {
file_name: Option<String>,
}
#[derive(Args)]
struct Output {
file_name: Option<String>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Cli::parse();
match args.command {
Some(Commands::Input(file)) => {
match file.file_name {
Some(ref file_name) => {
// Здесь исполняется логика приложения
}
None => {
println!("Введите имя входного файла");
}
}
}
Some(Commands::Output(file)) => {
match file.file_name {
Some(ref file_name) => {
// Здесь исполняется логика приложения
}
236
Обработка ошибок и восстановление после отказов
None => {
println!("Введите имя выходного файла");
}
}
}
None => todo!()
}
Ok(())
}
// $ cargo run -- input example.txt
// $ cargo run -- output example.txt
// $ cargo run – help
// Вывод:
// Мое консольное приложение Rust
Usage: file [COMMAND]
Commands:
input
output
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
В приведенном выше пояснительном фрагменте исходного кода мы используем библиотеку clap, чтобы оптимизировать процесс определения и управления аргументами командного интерфейса в нашем приложении. Библиотека
clap великолепно справляется с этой ролью, предоставляя удобный и выразительный API для задания имен аргументов, их описаний и многого другого.
Данный исходный код настраивает простейшее консольное приложение
под названием "Мое консольное приложение Rust" и определяет две подкоманды: input и output, каждая из которых снабжена соответствующим справочным текстом. Пользователи могут указывать эти аргументы при запуске программы, а clap автоматически выполняет их разбор и обрабатывает.
Библиотека clap также проявляет себя с лучшей стороны в вопросах обработки ошибок. Она помогает проверять отсутствие обязательных аргументов
или их недопустимость, гарантируя, что вводимые пользователем данные
соответствуют заданному формату. При возникновении ошибок библиотека clap генерирует четкие и информативные сообщения, помогая пользователям понимать и устранять проблемы.
Используя библиотеку clap, можно создавать дружелюбные командные
интерфейсы, которые не только упрощают взаимодействие с приложениями,
но и улучшают обработку ошибок, что в конечном итоге обеспечивает более
отточенный и профессиональный пользовательский опыт. Подробнее о консольных приложениях читайте в главе 8 «Консольные программы».
Обработка сигналов и прерываний
Обеспечение щадящей реакции консольных приложений на такие сигналы,
как сочетание клавиш Ctrl+C, является важным аспектом создания удобного
Создание конкретно-прикладных типов ошибок 237
и надежного программного обеспечения. Пакет Rust ctrlc упрощает настройку обработчиков сигналов, позволяя легко и эффективно управлять прерываниями работы программ.
Листинг 5.19. Простейший пример обработки сигналов
с использованием библиотеки ctrlc
use ctrlc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Создать атомарный флаг для контроля работы программы
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
// Установить обработчик сигнала Ctrl+C
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
println!("Только что прервали!");
})?;
// Здесь выполняется основная логика приложения
while running.load(Ordering::SeqCst) {
// Цикл продолжает работу, пока флаг 'running' истинен
}
Ok(())
}
//
//
//
//
Вывод:
[Бесконечный цикл]
[Нажатие Ctrl+C]
^CТолько что прервали!
В приведенном выше примере исходного кода мы используем пакет ctrlc
для щадящей обработки прерываний (таких как Ctrl+C) и обеспечения щадящего завершения работы приложения. Это крайне важно для предотвращения
утечек ресурсов и сохранения целостности данных и состояния программы.
Функция ctrlc::set_handler используется для установки обработчика сигналов, который реагирует на события нажатия комбинации клавиш Ctrl+C.
Когда пользователь инициирует такое прерывание, обработчик вызывает переданное замыкание, которое можно использовать для выполнения любых необходимых операций по очистке.
Такая обработка сигналов и прерываний улучшает пользовательский опыт
и помогает предотвращать неожиданное аварийное завершение приложения. Это
позволяет программам высвобождать ресурсы, сохранять текущее состояние или
выполнять другие важные задания перед выходом, что в конечном итоге повышает общую надежность и профессиональный уровень консольного инструмента.
Обработка ошибок при файловом вводе-выводе
Операции ввода-вывода имеют решающее значение для большого количества
приложений. Однако они также могут приводить к различным ошибкам, таким
238
Обработка ошибок и восстановление после отказов
как отсутствие файла, конфликты прав доступа или отказы в работе оборудования. Rust предлагает мощные ресурсы для эффективного управления такими
проблемами, что гарантирует надежную и стабильную работу при манипулировании файлами.
Чтение и запись файлов
При работе с файловым вводом-выводом в Rust важно предвидеть и щадяще
обрабатывать ошибки, которые могут возникать по ходу дела. Правильная обработка ошибок гарантирует, что приложение сможет адекватно реагировать
на непредвиденные ситуации, предотвращая аварийные завершения работы
программ и повреждение данных.
Листинг 5.20. Простейший пример чтения и записи
файлов с обработкой ошибок
use std::fs::File;
use std::io::{Read, Write};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let file_path = "example.txt";
// Попытка открыть файл для чтения
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
// Здесь можно выполнить обработку и внести изменения в содержимое файла
// Попытка создать файл для записи результата
let mut output_file = File::create("output.txt")?;
output_file.write_all(contents.as_bytes())?;
Ok(())
}
В приведенном выше фрагменте исходного кода на Rust мы демонстрируем, как
читать данные из файла, обрабатывать его содержимое и писать результаты в выходной файл. На протяжении всего этого процесса мы используем способности
Rust по обработке ошибок с целью управления потенциальными ошибками, такими как отсутствие файла, ошибки ввода-вывода или проблемы с правами доступа.
Благодаря оператору ? Rust упрощает передачу ошибок по стеку вызовов.
Если на любом этапе исполнения файловых операций происходит ошибка,
то она немедленно возвращается, и программа щадяще завершает исполнение функции. Благодаря этому предотвращается дальнейшее исполнение исходного кода и возникновение потенциальных осложнений.
Эффективная обработка ошибок при работе с файлами не только повышает отказоустойчивость приложения, но и улучшает пользовательский опыт
за счет предоставления понятных сообщений и предотвращения потери или
повреждения данных. Особое внимание Rust к обеспечению защищенности
и обработке ошибок делает его отличным инструментом создания надежных
компонентов обработки файлов в программном обеспечении.
Создание конкретно-прикладных типов ошибок 239
Работа с директориями
Эффективное управление директориями в Rust требует основательного подхода для щадящей обработки возможных ошибок. Операции с директориями
могут сталкиваться с такими проблемами, как «директория не найдена» или
«отказ в доступе». Систематическая обработка таких ситуаций необходима для
обеспечения надежности и целостности приложения.
Листинг 5.21. Простейший пример работы
с директориями и обработки ошибок
use std::fs;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir_path = "my_directory";
if !Path::new(dir_path).exists() {
fs::create_dir(dir_path)?;
}
// Здесь выполняются операции внутри директории
Ok(())
}
В приведенном выше примере исходного кода на Rust мы демонстрируем создание директорий и управление ими с использованием практических
приемов надежной обработки ошибок. Сначала исходный код выполняет проверку на существование директории, указанной в переменной dir_path. Если
она отсутствует, то программа приступает к ее созданию с помощью функции fs::create_dir.
Использование оператора ? в Rust значительно упрощает обработку ошибок. Если при создании директории возникнет какая-либо проблема, то ошибка будет передана выше по стеку вызовов, и исполнение функции завершится
в щадящем режиме. Такое поведение гарантирует, что ваше приложение будет
адекватно реагировать на различные ошибки при работе с файловой системой.
Подобная обработка ошибок, возникающих при работе с директориями,
критически важна для поддержания стабильности приложения. Упреждающее решение потенциальных проблем и предоставление осмысленных сообщений об ошибках делают программное обеспечение надежнее и удобнее
для пользователя.
Обработка ошибок в сетевом программировании
Когда речь идет о сетевом программировании на Rust, ключом к созданию стабильных и надежных приложений является высокая точность. Благодаря акценту на защитных мерах и контроле ошибок Rust зарекомендовал себя как
идеальный инструмент для изящного решения сетевых задач. Кроме того, этот
динамичный инструмент предоставляет ряд мощных ресурсов, позволяющих
крайне эффективно управлять специфическими сетевыми ошибками.
240 Обработка ошибок и восстановление после отказов
Выполнение HTTP-запросов
При разработке приложений, взаимодействующих с дистанционными серверами через HTTP-запросы, крайне важно предвидеть и щадяще обрабатывать
возможные ошибки, такие как проблемы с сетью, неверные URL-адреса или
отказы на стороне сервера. Грамотное управление ошибками гарантирует, что
приложение сможет разумно реагировать на непредвиденные обстоятельства,
предотвращая неожиданные аварийные завершения работы или неправильное поведение.
Листинг 5.22. HTTP-запросы и обработка ошибок
use reqwest;
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let response = reqwest::get("https://google.com").await?;
if response.status().is_success() {
// Обработка тела ответа
let body = response.text().await?;
println!("Тело ответа:\n{}", body);
} else {
eprintln!("HTTP-запрос не удался: {:?}", response.status());
}
Ok(())
}
В приведенном выше примере для отправки HTTP-запроса на указанный
URL используется библиотека reqwest. Обработка ошибок упрощена благодаря
оператору ?, который мгновенно передает выше любые потенциальные ошибки, возникающие во время запроса. В данном случае мы обрабатываем ошибку reqwest::Error, которая охватывает различные проблемы, связанные с сетью.
Исходный код анализирует статусный код HTTP-ответа, чтобы определить, был
ли запрос успешным. Если статус указывает на успех, то начинается соответствующая обработка тела ответа. В противном случае выводится сообщение об ошибке, чтобы пользователь мог получить четкий отклик о том, что пошло не так.
Эффективная обработка сетевых ошибок имеет решающее значение для поддержания целостности и надежности приложений, зависящих от внешних сервисов или ресурсов. Способности Rust по обработке ошибок в сочетании с такими библиотеками, как reqwest, позволяют разрабатывать сетевые программы,
которые гибко адаптируются под различные сценарии возникновения ошибок,
повышая общее качество и отказоустойчивость программного обеспечения.
Построение сетевых сервисов
Разработка сетевых сервисов предусматривает создание программного обес
печения, которое обменивается данными по сети, что неизбежно влечет за собой широкий спектр сетевых ошибок. В этом контексте обработка ошибок
становится критически важной для обеспечения отказоустойчивости и надежности сервисов. Библиотеки Rust и его механизмы обработки ошибок отлично
подходят для эффективного управления подобными сложными сценариями.
Создание конкретно-прикладных типов ошибок 241
Листинг 5.23. Создание сетевых сервисов с обработкой ошибок
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
use std::thread;
fn handle_client(mut stream: TcpStream) -> Result<(), Box<dyn
std::error::Error>> {
// Здесь реализуется логика работы с клиентом
println!("Клиент подключен: {:?}", stream.peer_addr()?);
let mut buffer = [0; 1024];
loop {
let bytes_read = stream.read(&mut buffer)?;
if bytes_read == 0 {
// Соединение закрыто
println!("Клиент отключен: {:?}", stream.peer_addr()?);
break;
}
let data = &buffer[..bytes_read];
println!("Получено от клиента {:?}: {:?}", stream.peer_addr()?,
String::from_utf8_lossy(data));
// Обработка данных, полученных от клиента
// Пример: отправка данных обратно клиенту (эхо)
stream.write_all(data)?;
}
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// При необходимости изменить адрес и порт
let listener = TcpListener::bind("127.0.0.1:8080")?;
println!("Сервер слушает на: {:?}", listener.local_addr()?);
for stream in listener.incoming() {
match stream {
Ok(stream) => {
println!("Принято соединение от: {:?}", stream.peer_addr()?);
thread::spawn(move || {
if let Err(err) = handle_client(stream) {
eprintln!("Ошибка при работе с клиентом: {:?}", err);
}
});
}
Err(e) => {
eprintln!("Не удалось принять соединение: {:?}", e);
}
}
}
Ok(())
}
// Вывод:
// $ cargo run
242
//
//
//
//
Обработка ошибок и восстановление после отказов
Сервер слушает на: 127.0.0.1:8080
Принято соединение от: 127.0.0.1:45842
Клиент подключен: 127.0.0.1:45842
Получено от клиента 127.0.0.1:45842: “Hello\r\n”
// $ telnet 127.0.0.1 8080
//
//
//
//
//
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is ‘^]’.
Hello
Hello
В приведенном выше фрагменте исходного кода на Rust демонстрируется настройка простого TCP-сервера для обработки клиентских соединений.
Функция handle_client, которая вызывается для каждого входящего соединения, служит местом для размещения логики сетевого сервиса.
В процессе обслуживания клиентов сетевые сервисы могут сталкиваться с различными сетевыми ошибками, такими как разрыв соединения или
проблемы при передаче данных. Способности Rust по обработке ошибок,
включая тип Result и оператор ?, позволяют щадяще управлять этими ситуациями. Возвращая вариант Err с информативным описанием ошибки,
можно передавать детали возникшей сетевой проблемы и предпринимать
необходимые действия: журналирование, очистку ресурсов или уведомление клиентов.
Создание надежных сетевых сервисов на Rust влечет за собой не только реа
лизацию желаемой функциональности, но и профессиональное предвидение
и обработку сетевых ошибок. Такой подход гарантирует, что ваши сервисы
останутся надежными и отзывчивыми даже в условиях нестабильной сети,
что способствует положительному пользовательскому опыту и долгосрочному
успеху сетевых приложений. Подробнее о сетевом программировании читайте
в главе 12 «Сетевое программирование».
Обработка ошибок в многопоточном исходном коде
Многопоточный и конкурентный исходный код создает уникальные сложности при обработке ошибок, особенно в том, что касается надлежащей синхронизации и передачи ошибок по стеку вызовов. В Rust, в котором защищенность и конкурентность поставлены во главу угла, обработка ошибок в многопоточной среде критически важна для создания стабильных и надежных конкурентных приложений.
Листинг 5.24. Конкурентность и многопоточность с обработкой ошибок
use std::sync::{Arc, Mutex};
use std::thread;
fn process_data(data: Arc<Mutex<Vec<u32>>>) -> Result<(), Box<dyn
std::error::Error>> {
// Запереть данные для эксклюзивного доступа внутри этого потока исполнения
let mut data_lock = data.lock().unwrap();
Создание конкретно-прикладных типов ошибок 243
// Конкурентная обработка данных
for i in 0..4 {
data_lock.push(i);
}
}
// Данные отпираются автоматически, когда data_lock
// выходит из диапазона доступности
Ok(())
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Создаем общую структуру данных (вектор типа u32),
// используя умные указатели Arc и Mutex
let data = Arc::new(Mutex::new(Vec::new()));
// Создаем вектор для хранения дескрипторов потоков исполнения
let mut handles = vec![];
// Породить несколько потоков исполнения
// для конкурентной обработки данных
for _ in 0..4 {
let data_clone = Arc::clone(&data); // Клонировать Arc
let handle = thread::spawn(move || {
if let Err(err) = process_data(data_clone) {
eprintln!("Ошибка при обработке данных: {:?}", err);
}
});
handles.push(handle);
}
// Ожидать завершения всех потоков исполнения
for handle in handles {
handle.join().unwrap();
}
// Доступ к коллективным данным по завершении обработки
let shared_data = {
let data_lock = data.lock().unwrap();
data_lock.clone()
};
// Вывод содержимого вектора
for item in &*shared_data {
println!("Обработанный элемент данных: {}", item);
}
Ok(())
}
//
//
//
//
//
//
//
//
Вывод:
Обработанный
Обработанный
Обработанный
Обработанный
Обработанный
Обработанный
Обработанный
элемент
элемент
элемент
элемент
элемент
элемент
элемент
данных:
данных:
данных:
данных:
данных:
данных:
данных:
0
1
2
3
0
1
2
244
//
//
//
//
//
//
//
//
//
Обработка ошибок и восстановление после отказов
Обработанный
Обработанный
Обработанный
Обработанный
Обработанный
Обработанный
Обработанный
Обработанный
Обработанный
элемент
элемент
элемент
элемент
элемент
элемент
элемент
элемент
элемент
данных:
данных:
данных:
данных:
данных:
данных:
данных:
данных:
данных:
3
0
1
2
3
0
1
2
3
В приведенном выше фрагменте исходного кода мы решаем задачу конкурентной обработки данных с учетом потокозащищенности и обработки
ошибок. Функция process_data представляет собой упрощенный пример, где
данные используются коллективно несколькими потоками исполнения с помощью умных указателей Arc (атомарный подсчет ссылок) и Mutex для взаимного исключения.
При конкурентной обработке могут возникать ошибки из-за гонок за доступом
к данным, паник в потоках исполнения или других проблем, связанных с конкурентностью. Важно обрабатывать такие ошибки щадяще и передавать их таким
образом, чтобы обеспечивать общую стабильность приложения. Механизмы Rust,
такие как тип Result и общая черта Box<dyn std::error::Error>, позволяют упаковывать ошибки и возвращать их защищенным и контролируемым способом.
Разработка многопоточных приложений на Rust требует не только управления конкурентностью, но и реализации отказоустойчивых стратегий обработки
ошибок. Следуя такому подходу, можно строить программы, которые эффективно используют потенциал параллелизма, поддерживая при этом защищенность
и надежность. Достижение этого баланса во многом зависит от надлежащих технических приемов синхронизации и передачи ошибок по стеку вызовов.
Межпотоковый обмен данными
При разработке эффективных и отзывчивых приложений с использованием
конкурентного исходного кода ключевым элементом является обмен данными между потоками исполнения. Тем не менее это привносит дополнительную
сложность в обработку ошибок, так как требует тщательной синхронизации
и обмена данными между разными потоками исполнения.
Листинг 5.25. Межпотоковый обмен данными
с использованием обработки ошибок
use std::thread;
use std::sync::mpsc;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Создать канал: tx (передатчик), rx (приемник)
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
// Логика рабочего потока исполнения
tx.send("Задача выполнена").unwrap();
});
Создание конкретно-прикладных типов ошибок 245
// Логика главного потока исполнения
let result = rx.recv()?;
println!("Получен результат: {}", result);
worker.join().unwrap();
Ok(())
}
// Вывод:
// Получен результат: Задача выполнена
Приведенный выше пример исходного кода на Rust иллюстрирует обмен
данными между главным и рабочим потоками исполнения с учетом потенциальных сценариев возникновения ошибок. Для обмена данными используется
канал типа mpsc (multiple-producer, single-consumer – «несколько производителей, один потребитель»), позволяющий передавать данные из рабочего потока
исполнения в главный.
Обработка ошибок при межпотоковом обмене данными требует внимательного отношения к синхронизации и передаче ошибок по стеку вызовов. В приведенном выше примере тип Result и общая черта Box<dyn
std::error::Error> используются для упаковки и передачи потенциальных
ошибок по стеку вызовов, гарантируя, что любые проблемы, возникающие при
обмене данными по каналу или во время исполнения потока, будут обработаны надлежащим образом.
При создании конкурентных приложений, требующих высокой отзывчивости и надежности, крайне важно обрабатывать ошибки обмена данными
между потоками. Rust предоставляет средства, позволяющие использовать
преимущества параллелизма, гарантируя при этом защищенность и стабильность исходного кода с помощью эффективных механизмов синхронизации
и стратегий передачи ошибок по стеку вызовов.
Тестирование и обработка ошибок
Тестирование – это жизненно важный аспект разработки программного обес
печения, который гарантирует надежность и точность исходного кода, играя
ключевую роль в проверке механизмов обработки ошибок. Встроенная в Rust
среда тестирования предлагает систематическую стратегию создания тестов,
ориентированных на нештатные ситуации. Она позволяет с высокой точностью оценивать прочность исходного кода к различным ошибочным условиям.
Написание тестов для обработки ошибок
Инструментарий Rust для тестирования предлагает простой подход к созданию тестов, проверяющих исходный код на предмет его способностей справляться с ошибками. Применяя атрибут #[test], можно определить тестовую
функцию и запустить ее проверку. Инструкции подтверждения истинности,
используемые внутри этих функций, помогают убеждаться, что логика обработки ошибок реализована верно и программа адекватно реагирует на нештатные обстоятельства.
246
Обработка ошибок и восстановление после отказов
Листинг 5.26. Написание тестов для обработки ошибок
fn divide(dividend: i32, divisor: i32) -> Result<i32, String> {
if divisor == 0 {
Err(format!("Невозможно разделить {} на ноль.", dividend))
} else {
Ok(dividend / divisor)
}
}
#[test]
fn test_divide_by_zero() {
let result = divide(10, 0);
assert!(result.is_err()); // Убедиться, что это ошибка
// Проверка сообщения об ошибке
assert_eq!(result.unwrap_err(), "Невозможно разделить 10 на ноль.");
}
// Вывод:
// $ cargo test
// running 1 test
// test test_divide_by_zero ... ok
// test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.00s
В приведенном выше примере представлен тест test_divide_by_zero, который оценивает механизм функции divide по обработке ошибок при делении
на ноль. Факт возникновения ошибки подтверждается с помощью макрофункции assert!, а соотнесение полученного сообщения с ожидаемым – с помощью макрофункции assert_eq!.
Написание тестов для ошибок позволяет систематически проверять, что исходный код обработки ошибок функционирует в соответствии с замыслом. Это
дает уверенность в способности программного обеспечения щадяще справляться с нештатными ситуациями. Таким образом гарантируется, что приложение будет разумно реагировать на ошибки, предотвращая непредвиденные
аварийные завершения работы и повышая общую надежность системы.
Тестирование на основе свойств
Тестирование на основе свойств, или генеративное тестирование, – это мощная
методика, которая позволяет систематически проверять нештатные условия и граничные случаи на широком диапазоне входных данных. Библиотеки для такого
тестирования в Rust, такие как proptest, предоставляют средства автоматической
генерации тестовых сценариев. Это позволяет выявлять потенциальные проблемы и всесторонне оценивать надежность исходного кода обработки ошибок.
Листинг 5.27. Пример тестирования на основе свойств
use proptest::prelude::*;
fn divide(dividend: i32, divisor: i32) -> Result<i32, String> {
if divisor == 0 {
Err(format!("Невозможно разделить {} на ноль.", dividend))
} else {
Заключение 247
Ok(dividend / divisor)
}
}
proptest! {
#[test]
fn test_divide_property_based(input in 1..100, divisor in 0..100) {
if divisor == 0 {
let result = divide(input, divisor);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), format!("Невозможно разделить {}
на ноль.", input));
} else {
let result = divide(input, divisor);
assert!(result.is_ok());
assert_eq!(result.unwrap(), input / divisor);
}
}
}
В приведенном выше примере мы используем библиотеку proptest, чтобы
реализовать тестирование на основе свойств для функции divide. Этот подход
генерирует случайные входные данные в заданных диапазонах, проверяя различные сценарии, включая деление на ноль и допустимые операции деления.
Тестирование на основе свойств отлично справляется с обследованием граничных случаев и нештатных условий, которые могут быть не очевидны в традиционных модульных тестах. Автоматически создавая разнообразный набор
тестовых сценариев, оно помогает обнаруживать непредвиденные проблемы,
гарантируя, что исходный код обработки ошибок эффективно справляется
с широким спектром входных значений.
Внедрение тестирования на основе свойств в рабочий процесс позволяет поднимать надежность исходного кода на новый уровень. За счет этого повышается
отказоустойчивость программного обеспечения к непредвиденным ситуациям.
Тестирование на основе свойств является ценным ресурсом для обнаружения
потенциальных уязвимостей и улучшения общего качества приложений на Rust.
Заключение
Эта глава позволила глубоко погрузиться в сложную тему обработки ошибок
на Rust, охватив широкий спектр сценариев и технических приемов. Мы обследовали все темы: от базового использования типов Result и Option до таких
сложных аспектов, как асинхронная обработка ошибок, создание конкретноприкладных типов ошибок и интеграция с библиотеками наподобие anyhow.
Обработка ошибок – это не просто отдельный аспект программирования
на Rust; это фундамент для создания надежного и отказоустойчивого программного обеспечения, независимо от сферы применения или специфики
приложения. Создаете ли вы командные интерфейсы, работаете с файловыми
операциями, сетевыми протоколами или дирижируете конкурентными заданиями – описанные здесь принципы неоценимы.
Тщательно применяя эти методики и придерживаясь лучших образцов
практики, вы сможете создавать приложения на Rust, которые не только ща-
248
Обработка ошибок и восстановление после отказов
дяще обрабатывают ошибки, но и предоставляют пользователям содержательный отклик, обеспечивая бесшовное восстановление после ошибок и грамотное управление ресурсами.
По мере того как вы продолжите свой путь по Rust, освоение этих технических приемов обработки ошибок, несомненно, позволит вам писать исходный код, который выдержит проверку временем и обеспечит живучесть
программного обеспечения – фундаментальный навык для каждого разработчика на Rust.
В следующей главе мы подробно рассмотрим важнейшую тему управления
памятью и указателей в Rust. В ней будет представлен подробный обзор уникального подхода, принятого в Rust к управлению памятью с акцентом на различиях между выделением памяти в стеке и куче. Кроме того, вы познакомитесь с концепцией умных указателей, таких как Box, Rc и Arc, которые играют
ключевую роль в эффективном управлении памятью. Также мы затронем основы незащищенного исходного кода на Rust и способы безопасной работы
с сырыми указателями, что расширит ваше понимание способностей Rust
и обеспечит защищенную и надежную работу с памятью в ваших программах.
Справочные и дополнительные материалы
1. Rust – Обработка ошибок – официальная документация Rust по обработке ошибок.
2. Обработка ошибок на Rust Error на практике – практические советы
и примеры обработки ошибок на Rust.
3. Как обрабатывать ошибки на Rust: всестороннее руководство – подробное руководство по техническим приемам обработки ошибок на Rust.
4. Обработка ошибок на Rust с различными подходами – обзор различных
подходов к обработке ошибок на Rust.
5. anyhow – документация пакета anyhow для удобной обработки ошибок
в приложениях.
6. thiserror – документация пакета thiserror для создания конкретно-прикладных типов ошибок.
7. proptest – документация пакета proptest для тестирования на основе
свойств в Rust.
8. tokio – документация асинхронной среды исполнения tokio для Rust.
9. async-std – документация асинхронной стандартной библиотеки asyncstd для Rust.
Вопросы для самопроверки
Вопрос 1. Какова основная цель общих черт в Rust?
a) определение структур данных
b) предоставление дефолтных реализаций функций
c) определение общего поведения, которое типы могут реализовывать
d) ограничение доступа к данным
Вопросы для самопроверки 249
Вопрос 2. Какой из следующих типов используется в Rust для представления необязательного значения, которое может либо присутствовать,
либо отсутствовать?
a) bool
b) Option
c) Result
d) Enum
Вопрос 3. Каково основное предназначение типа Result в Rust?
a) представление необязательного значения
b) обработка ошибок и их защищенная передача по стеку вызовов
c) хранение булевых значений
d) определение общего поведения типов
Вопрос 4. Какой оператор в Rust используется для лаконичной передачи
ошибок по стеку вызовов и часто называется оператором try?
a) ?
b) !
c) *
d) @
Вопрос 5. Каково предназначение библиотеки anyhow в Rust?
a) определение конкретно-прикладных типов ошибок
b) обработка асинхронных ошибок
c) обеспечение унифицированной обработки ошибок с упрощенным типом ошибки
d) создание необязательных значений
Вопрос 6. Какой пакет Rust упрощает создание конкретно-прикладных
типов ошибок и автоматически реализует для них такие общие черты,
как Error и Display?
a) thiserror
b) custom_error
c) error_handling
d) error_crate
Вопрос 7. Что делает ключевое слово await в асинхронном исходном коде
на Rust?
a) приостанавливает текущее задание до тех пор, пока ожидаемое заданиебудущее не будет готово
b) возобновляет текущее задание после ожидания в течение указанного
времени
c) конвертирует синхронный исходный код в асинхронный
d) выдает ошибку, если ожидаемое задание-будущее не готово
250
Обработка ошибок и восстановление после отказов
Вопрос 8. Какая библиотека Rust обычно используется для асинхронного
программирования и предоставляет инструменты для работы с асинхронными заданиями и вводом-выводом?
a) std::async
b) asyncio
c) tokio
d) async-std
Вопрос 9. Каково основное предназначение обработки ошибок в асинхронном исходном коде на Rust?
a) вызывать аварийное завершение работы программы в случае ошибки
b) игнорировать ошибки и продолжать исполнение
c) щадяще обрабатывать непредвиденные ситуации и предотвращать аварийные завершения работы программ
d) замедлять программу, чтобы было проще обрабатывать ошибки
Вопрос 10. Какой тип обычно используется в асинхронном исходном
коде на Rust для представления результата асинхронной операции, которая может завершиться успешно либо безуспешно?
a) bool
b) Result
c) Option
d) Future
Вопрос 11. Какова основная роль оператора ? в Rust при использовании
в обработке ошибок?
a) он создает конкретно-прикладные типы ошибок
b) он преобразовывает ошибки в надлежащий тип возвращаемого значения
c) он генерирует ошибку при возникновении любой проблемы
d) он выполняет асинхронные операции
Вопрос 12. Какое ключевое слово используется для синхронизации асинхронных операций при работе с асинхронным исходным кодом на Rust?
a) sync
b) await
c) async
d) asyncronize
Вопрос 13. Почему важно создавать конкретно-прикладные типы ошибок в асинхронном исходном коде на Rust?
a) для запутывания пользователей сложными сообщениями об ошибках
b) для добавления в исходный код ненужной сложности
c) для абстрагирования от низкоуровневых деталей и предоставления осмысленных сообщений об ошибках
d) ради полного избегания обработки ошибок
Вопросы для самопроверки 251
Вопрос 14. Какие два типа ошибок обертывает конкретно-прикладное
перечисление MyError?
a) reqwest::Error и io::Error
b) tokio::Error и csv::Error
c) async::Error и custom::Error
d) warp::Error и ctrlc::Error
Вопрос 15. Какова одна из проблем, привносимых асинхронным программированием, особенно в отношении обработки ошибок?
a) более медленное исполнение программы
b) синхронная передача ошибок по стеку вызовов
c) конкурентные ошибки из нескольких заданий
d) отсутствие встроенной обработки ошибок
Вопрос 16. Какая библиотека упрощает создание командных интерфейсов и предлагает обширные способности по обработке ошибок для приложений на Rust?
a) argparse
b) clap
c) getopts
d) cliargs
Вопрос 17. Каково основное преимущество использования пакета ctrlc
для обработки таких сигналов, как сочетание клавиш Ctrl+C, в приложениях на Rust?
a) он предотвращает прерывание работы приложения
b) он позволяет игнорировать сигналы
c) он упрощает настройку обработчиков сигналов и щадящее прерывание
работы
d) он обрабатывает сигналы асинхронно
Вопрос 18. Что позволяет делать функция-обработчик сигналов при использовании пакета ctrlc?
a) предотвращать попадание всех сигналов в приложение
b) исполнять произвольную логику при получении сигнала, например операции очистки ресурсов
c) откладывать обработку сигналов до выхода из приложения
d) обрабатывать только специфические сигналы, исключая все остальные
Вопрос 19. Каково основное преимущество использования оператора ?
при выполнении операций файлового ввода-вывода в Rust?
a) он немедленно завершает программу при возникновении любой ошибки
b) он преобразовывает ошибки в конкретно-прикладные типы
252
Обработка ошибок и восстановление после отказов
c) он упрощает передачу ошибок по стеку вызовов и прекращает дальнейшее исполнение в случае ошибки
d) он автоматически повторяет файловые операции при возникновении
ошибки
Вопрос 20. В чем важность надлежащей обработки ошибок при работе
с операциями ввода-вывода в Rust?
a) в том, чтобы скрывать ошибки от пользователей и продолжать исполнение программы
b) в том, чтобы делать исходный код более сложным и трудным для чтения
c) в том, чтобы адекватно реагировать на непредвиденные ситуации, предотвращать аварийные завершения работы программы и повреждение
данных
d) в том, чтобы увеличивать вероятность утечки ресурсов
Ответы
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
c) определение общего поведения, которое типы могут реализовывать
b) option
b) обработка ошибок и их защищенная передача по стеку вызовов
a) ?
c) обеспечение унифицированной обработки ошибок с упрощенным типом ошибки
a) thiserror
a) приостанавливает текущее задание до тех пор, пока ожидаемое задание-будущее не будет готово
c) tokio
c) щадяще обрабатывать непредвиденные ситуации и предотвращать
аварийные завершения работы программ
b) result
b) он преобразовывает ошибки в надлежащий тип возвращаемого значения
b) await
c) для абстрагирования от низкоуровневых деталей и предоставления
осмысленных сообщений об ошибках
a) reqwest::Error и io::Error
c) конкурентные ошибки из нескольких заданий
b) clap
c) он упрощает настройку обработчиков сигналов и щадящее прерывание работы
b) исполнять произвольную логику при получении сигнала, например
операции очистки ресурсов
c) он упрощает передачу ошибок по стеку вызовов и прекращает дальнейшее исполнение в случае ошибки
c) в том, чтобы адекватно реагировать на непредвиденные ситуации,
предотвращать аварийные завершения работы программы и повреждение данных
Глава 6
Управление памятью
и указатели
Введение
В постоянно эволюционирующем ландшафте языков программирования
управление памятью остается важнейшим вопросом, напрямую влияющим
на качество и производительность программных систем. Оно становится тем
самым фундаментом, на котором разработчики строят свое программное
обеспечение. Глава начинается с обследования отличительной парадигмы
управления памятью на Rust, обещая увлекательное содержание, которое прольет свет на нюансы выделения памяти в стеке и куче. Эта глава охватывает
не только фундаментальные методы управления памятью, но и углубляется
в продвинутые темы, такие как умные указатели и незащищенный Rust.
Выделение памяти – это ключевая тема в Rust, подкрепляющая основную
философию языка: сочетание защищенности и производительности. По своей
сути Rust стремится давать возможность писать исходный код, одновременно
надежный и невероятно быстрый. В целях достижения этого хрупкого баланса
Rust вводит строгий набор правил, регулирующих выделение и высвобождение памяти. К концу этой главы вы будете глубоко понимать эти правила и то,
как они способствуют уникальному обещанию Rust – обеспечивать защищенность памяти без ущерба для производительности.
По мере изложения мы пройдемся по умным указателям – мощной абстракции, предоставляющей мелкозернистый контроль над ресурсами памяти. Умные указатели позволяют писать более выразительный и гибкий исходный код,
что в конечном итоге ведет к более защищенному и эффективному программному обеспечению. Однако глава не ограничивается рамками защищенности;
она делает шаг в сторону незащищенного Rust. Здесь вы проведете обследование пограничных случаев и сценариев, в которых защитные гарантии Rust
могут временно приостанавливаться в пользу оптимизации производительности или взаимодействия с внешним исходным кодом. Таким образом, эта
глава оснастит вас знаниями, позволяющими использовать инструменты Rust
по управлению памятью с точностью и творческим подходом, гарантирующими, что ваше программное обеспечение будет не только работать эффективно,
но и оставаться стабильным и надежным.
254
Управление памятью и указатели
Содержание главы
В этой главе будут рассмотрены следующие темы:
введение в умные указатели: Box, Rc, Arc, Mutex и другие;
понимание процедуры выделения памяти в стеке и куче на Rust;
знакомство с незащищенным Rust и работа с сырыми указателями.
Роль управления памятью
В сложной сфере разработки программного обеспечения управление памятью
выступает в роли «тихого героя» – зачастую незаметного, но жизненно важного для производительности и стабильности программы. Оно играет роль невидимого дирижера, координирующего то, как программа выделяет, использует
и высвобождает свой самый ценный ресурс: память.
Грамотное управление памятью – это тот самый стержень, который позволяет программе работать бесшовно, обходя такие опасные ловушки, как
утечки памяти и аварийные завершения работы. По мере того как мы будем
углубляться в эту тему, мы увидим, что управление памятью – это непризнанный герой, стоящий за надежностью и эффективностью любого программного обеспечения.
Управление
вводом-выводом
Управление
памятью
Интерпретатор
команд
Операционная
система
Управление
файлами
Управление
ресурсами
Управление
безопасностью
Рисунок 6.1. Разные функции операционной системы
Трудности ручного управления памятью 255
Среди языков программирования Rust заявляет о себе как об особой силе,
которую часто называют «языком системного программирования». Он смело
стремится предоставлять разработчикам уникальное сочетание высокоуровневого контроля над памятью, твердо опираясь на два столпа: защищенность
и надежность. В ходе этого обследования темы управления памятью на Rust
вы узнаете, почему этот язык ставит работу с памятью в самый центр своей
концепции. Конструктивная философия Rust вращается вокруг предоставления разработчикам возможности использовать память с ювелирной точностью, избегая ловушек, которые были проклятием языков прошлого. Именно
сплав контроля и защищенности выделяет Rust и делает его привлекательным выбором для тех, кто стремится создавать отказоустойчивые и эффективные программные системы.
Навигация по сложностям принятого в Rust подхода к управлению памятью позволит вам оценить, как он воплощает саму суть философии языка.
Rust бросает вызов текущему положению дел, предлагая надежную систему управления памятью, которая, в отличие от некоторых других языков,
не требует жертвовать защищенностью ради производительности или наоборот. В Rust управление памятью – это не просто техническая деталь,
а свидетельство приверженности языка идее создания эффективного и защищенного программного обеспечения. Изложение в этой главе проложит
путь к использованию всего потенциала языка, который ставит работу с памятью во главу угла своей миссии по революционному преобразованию системного программирования.
Трудности ручного управления памятью
Модель управления памятью в Rust, будучи мощной и защищенной, возлагает
на программиста значительную ответственность. В отличие от языков, которые используют сборку мусора и автоматически высвобождают неиспользуемую память, Rust требует явного обращения с памятью.
Такой подход к ручному управлению памятью несет в себе ряд трудностей.
Разработчику на Rust крайне важно помнить о потенциальных препятствиях
и придерживаться оптимальных методов поддержания защищенности и эффективности исходного кода. Ниже приведены основные вызовы и факторы,
которые следует учитывать при работе с памятью на Rust.
Владение и заимствование
Модель управления памятью в Rust сосредоточена на принципах владения, заимствования и ссылок. Ссылка подобна указателю в том смысле, что она служит адресом, по которому можно перейти, чтобы обратиться к данным, которыми владеет какая-то другая переменная. Однако, в отличие от указателя,
ссылка гарантированно указывает на валидное значение определенного типа.
256
Управление памятью и указатели
Владение
Этот телефон
принадлежит
Красному
Этот телефон
принадлежит
Желтому
Передача
владения
Красный больше не владеет телефоном
и поэтому не может его использовать
Рисунок 6.2. Простая иллюстрация владения
Понимание того, когда использовать эти концепции, имеет критически
важное значение для эффективного управления памятью на Rust. Давайте рассмотрим пример:
Листинг 6.1. Простейший пример владения и заимствования
fn main() {
let s1 = String::from("привет");
let s2 = s1;
// ①
}
// println!("s1: {}", s1); // ②
println!("s2: {}", s2);
// ③
// Вывод:
// s2: привет
В приведенном выше примере:
① данные, которыми владеет переменная s1, передаются переменной s2 (происходит перемещение владения);
② эта строка привела бы к ошибке компиляции (так как s1 больше не владеет данными);
③ переменную s2 по-прежнему можно использовать.
В этом фрагменте исходного кода s1 и s2 – строковые переменные. Когда
переменной s2 присваивается значение переменной s1, владение опорными
данными передается от s1 к s2. Попытка использовать переменную s1 после
передачи приводит к ошибке компиляции, что демонстрирует строгие правила владения в Rust.
Понимание того, когда использовать владение, заимствование (ссылки
с &) и мутируемое заимствование (ссылки с &mut), играет крайне важную
роль для эффективного управления памятью и предотвращения таких проб
лем, как ошибки использования после высвобождения или ненужное копирование данных.
Трудности ручного управления памятью 257
Висячие ссылки
Висячие ссылки возникают в ситуациях, когда ссылка существует дольше, чем
данные, на которые она указывает. В некоторых ситуациях владение конкретным
ресурсом бесшовно передается из одного контекста в другой, не оставляя после
себя ссылок, которые могли бы приводить к появлению висячих указателей. Висячие указатели появляются тогда, когда ссылка указывает на область памяти, которая уже была высвобождена или более не является валидной, что потенциально
ведет к неопределенному поведению и проблемам с защищенностью памяти.
Указатель
Указатель
Указатель
Привет, мир
Рисунок 6.3. Указатели и соответствующие им значения в памяти
Используемая в Rust система времен жизни помогает предотвращать эту
проблему, но она требует внимательного подхода при написании исходного
кода. Данная концепция описывает ситуацию, когда владение эффективно
передается без возникновения каких-либо проблем или рисков, связанных
с висячими ссылками или высвобождением памяти. В Rust владение – это фундаментальный принцип управления памятью, гарантирующий защищенную
и эффективную работу с ресурсами. Рассмотрим следующий ниже пример:
Листинг 6.2. Пример переменных и диапазонов доступности
fn main() {
let r;
{
let x = 42;
r = &x;
// ①
}
// println!("r: {}", r); // ②
}
В приведенном выше примере:
① попытка использовать ссылку r за пределами ее диапазона доступности
приведет к ошибке компиляции;
② эта строка кода вызвала бы ошибку компиляции.
В приведенном выше исходном коде r является ссылкой на целочисленную
переменную x. Однако переменная x выходит из диапазона доступности в кон-
258
Управление памятью и указатели
це внутреннего блока, из-за чего r становится висячей ссылкой. Применяемая
в Rust система времен жизни обнаруживает эту проблему и запрещает использование ссылки r после того, как переменная x перестает быть валидной.
Во избежание появления висячих ссылок необходимо понимать принцип
работы времен жизни в Rust и следить за тем, чтобы продолжительность жизни ссылок не превышала продолжительность жизни тех данных, на которые
они указывают.
Незащищенный исходный код
Существуют специфические ситуации, когда использование рискованного Rust
становится необходимым для того, чтобы обходить выполняемые компилятором проверки защищенности. Однако в таких случаях крайне важно соблюдать
осторожность, так как это может приводить к появлению уязвимостей, связанных с защищенностью памяти. Рассмотрим следующий ниже пример:
Листинг 6.3. Пример незащищенного исходного кода
fn main() {
let mut numbers = vec![1, 2, 3, 4, 5];
let mut_ptr = numbers.as_mut_ptr();
let len = numbers.len();
// ①
unsafe {
// ②
for i in 0..len {
let current_value = *mut_ptr.add(i); // ③
*mut_ptr.add(i) = current_value * 2; // ④
}
}
println!("{:?}", numbers);
}
// Вывод:
// [2, 4, 6, 8, 10]
В приведенном выше примере:
① создать сырой указатель на первый элемент вектора;
② блок unsafe для ручного выполнения итераций по вектору с использованием сырых указателей;
③ обратиться к памяти по сырому указателю для доступа к элементам;
④ изменить элемент (умножить на 2).
В этом исходном коде мы используем незащищенный Rust с целью прямого
манипулирования элементами вектора посредством сырых указателей. Внутри
блока unsafe мы обращаемся к памяти по указателю и вносим изменения в элементы. Это демонстрирует, как незащищенный исходный код позволяет обходить определенные проверки защищенности, обеспечиваемые компилятором.
Вместе с тем необходимо подчеркнуть, что подобные незащищенные операции следует использовать осмотрительно и только в случае необходимости,
учитывая их потенциал порождать проблемы с защищенностью памяти. При
использовании такого подхода критически важную роль играет глубокое по-
Трудности ручного управления памятью 259
нимание защитных гарантий Rust и тщательное тестирование незащищенного исходного кода.
Гонки за доступом к данным
Строгие правила Rust в отношении мутируемых ссылок помогают предотвращать гонки за доступом к данным – тип ошибок конкурентности, при которых
несколько потоков исполнения конкурентно обращаются к коллективным данным без надлежащей синхронизации. Рассмотрим следующий ниже пример:
Листинг 6.4. Пример гонок за доступом к данным
use std::thread;
fn main() {
let mut data = vec![1, 2, 3];
let handle1 = thread::spawn(move || {
data.push(4);
});
handle1.join().unwrap();
// println!("{:?}", data); // ①
}
В приведенном выше примере:
① раскомментирование этой строки кода приведет к ошибке компиляции.
В приведенном выше исходном коде один поток исполнения (handle1) пытается добавить элементы в коллективный вектор data. Однако встроенная
в Rust система владения гарантирует, что изменять данные в конкретный момент времени может только один поток исполнения. Если раскомментировать
строку кода с макрофункцией println!, то это приведет к ошибке компиляции,
так как доступ к data осуществляется с возможностью мутирования без надлежащей синхронизации.
Поток
исполнения
Поток
исполнения
Поток
исполнения
Рисунок 6.4. Простая иллюстрация гонки за доступом к данным
260
Управление памятью и указатели
Хотя строгие правила Rust предотвращают гонки за доступом к данным, они
также требуют тщательной проработки многопоточного исходного кода во избежание взаимных блокировок и соревнования за ресурсы.
Управление ресурсами
Rust управляет не только памятью, но и другими ресурсами, например дескрипторами файлов. Своевременное высвобождение этих ресурсов играет
крайне важную роль в предотвращении их утечки.
Листинг 6.5. Пример управления ресурсами
use std::fs::File;
fn main() -> std::io::Result<()> {
let file = File::create("example.txt")?; // ①
// Файл будет автоматически закрыт, когда переменная
// выйдет из диапазона доступности
// Здесь может быть дополнительный исходный код для работы с файлом
Ok(())
// ②
}
В приведенном выше примере:
① создать файл с именем "example.txt";
② вернуть Ok, чтобы сообщить об успехе.
В приведенном выше фрагменте исходного кода дескриптор файла создается с помощью функции File::create(). Rust гарантирует, что как только переменная file выйдет из своего диапазона доступности, файл автоматически
закроется благодаря системе владения и времен жизни. Такая гарантия обес
печивает правильное высвобождение ресурсов и эффективно предотвращает
любые потенциальные утечки.
Способность Rust управлять ресурсами распространяется и на другие области, такие как сетевые сокеты и подключения к базам данных, что делает его
ценным инструментом для создания надежных и эффективных систем.
В этой главе мы продолжим обследовать подобные сложности и дадим рекомендации по их успешному преодолению. Хотя управление памятью на Rust
может показаться утомительным, оно дает возможность писать высокопроизводительный и защищенный исходный код.
В следующих далее разделах мы подробно рассмотрим детали каждого типа
указателей. Мы также рассмотрим умные указатели, такие как Box, Rc и Arc, которые предоставляют полезные функциональные средства управления памятью.
Кроме того, мы проведем обследование незащищенного Rust, в котором ответственность за поддержание защищенности ложится на плечи программиста.
Стек и куча
В основе философии управления памятью в Rust лежат два фундаментальных
столпа: стек и куча. Эти две области памяти служат основными площадками,
Стек и куча 261
в которых значения выделяются, управляются и в конечном итоге высвобождаются, и именно здесь указатели реализуют свое влияние на адресное пространство программы.
Cтек
Куча
Вектор
длина
емкость
"абрикос"
"банан"
адрес
"виноград"
Рисунок 6.5. Векторный объект и его представление в стеке и куче
Стек – область памяти, отличающаяся упорядоченностью и эффективностью, – работает по строгому принципу «последним пришел – первым вышел»
(LIFO). Он служит основным местом управления короткоживущими переменными, например теми, что находятся внутри диапазона доступности вызова
функции. При вызове функции ее локальные переменные размещаются в стеке, что гарантирует их автоматическое высвобождение при выходе из функции.
Эта простота и предсказуемость делают стек важным ресурсом компилятора,
позволяя ему оптимизировать выделение памяти под постоянные данные.
Напротив, куча характеризуется гибкостью и динамическим выделением
памяти. Значения, находящиеся в куче, могут выделяться и высвобождаться
в произвольные моменты исполнения программы. Это пространство предназначено для долгоживущих структур данных, продолжительность жизни которых превышает продолжительность жизни диапазона доступности, в котором
они были созданы. Способность компилятора Rust бесшовно переключаться
между стеком и кучей – эффективно выделяя значения в стеке и обращаясь
к куче лишь при необходимости – демонстрирует умение компилятора соблюдать баланс между производительностью и управлением памятью. По мере
погружения в эту главу вы получите исчерпывающее понимание механизма
использования стека и кучи в Rust с целью оптимизации выделения памяти, что позволит вам принимать обоснованные решения в своих собственных
проектах на Rust.
262
Управление памятью и указатели
Роль стека в Rust
В Rust стек берет на себя хранение локальных переменных и управление вызовами функций. Каждый вызов функции создает стековый кадр – выделенное
пространство для ее переменных и данных. Когда функция завершается, кадр
удаляется, высвобождая связанную с ним память.
Это правило работы со стеком соответствует акценту Rust на защищенности
и эффективности. Локальные переменные имеют четко определенное время
жизни, а их диапазон доступности ограничен текущим блоком или функцией.
В результате Rust гарантирует, что переменные доступны только тогда, когда
они находятся в валидном состоянии, снижая риск ошибок, вызванных обращением к памяти, которая больше не используется.
Проиллюстрируем эту концепцию на примере простой функции:
Листинг 6.6. Простая функция с выделением памяти в стеке
fn main() {
let x = 42; // ①
println!("Ответ: {}", x);
}
// ②
В приведенном выше примере:
① переменная x сохраняется в стеке;
② переменная x выходит из диапазона доступности и высвобождается.
В приведенном выше примере переменная x размещается в стеке внутри
функции main. Когда функция завершается, x выходит из диапазона доступности и автоматически высвобождается. Такое выделение пространства в стеке
обеспечивает эффективное управление памятью для x, избавляя программис
та от необходимости делать это вручную.
Роль кучи в Rust
В то время как стек идеально подходит для мгновенных, короткоживущих данных, Rust обращается к куче в ситуациях, когда дело касается данных переменного размера и динамического времени жизни. Куча позволяет компилятору
Rust выделять память, которая сохраняется за пределами диапазона доступности одной функции или блока, вмещая структуры данных, которые могут расти
или уменьшаться по мере необходимости.
Подход компилятора Rust к выделению памяти в куче опирается на принципы владения, заимствования и времен жизни. Прежде чем мы углубимся в эти
темы, давайте рассмотрим, как компилятор Rust размещает данные в куче.
Листинг 6.7. Простая функция с выделением памяти в куче
fn main() {
let s1 = String::from("Hello");
let s2 = s1;
println!("{}", s2);
}
В приведенном выше примере:
//
//
//
//
①
②
③
④
Стек и куча 263
① переменная s1 – это строковый литерал (типа String), размещенный
в куче;
② переменная s2 принимает владение данными в куче, переменная s1 больше не является валидной;
③ переменная s2 по-прежнему валидна и может использоваться;
④ s2 выходит из диапазона доступности, и память в куче автоматически
высвобождается.
В приведенном выше примере значение типа String представляет размещенный в куче строковый литерал динамического размера. Когда переменная s1 присваивается переменной s2, владение данными в куче передается
от s1 к s2. В результате переменная s1 перестает быть валидной, и компилятор
Rust автоматически высвобождает связанную с ней память в куче при выходе s2 из диапазона доступности.
Этот механизм, именуемый передачей владения, гарантирует, что в любой момент времени у данных в куче есть только один владелец. Подобный подход предотвращает такие проблемы, как двойное высвобождение памяти или повреждение данных. Строгий контроль Rust над выделением памяти в куче и владением
является фундаментальным аспектом его гарантий защищенности памяти.
Модель владения
В целях понимания принятого в Rust подхода к управлению памятью необходимо познакомиться с концепцией владения. В Rust у каждого значения есть единственный владелец, и именно владелец отвечает за высвобождение связанной
с ним памяти, когда она больше не нужна. Эта модель владения обеспечивается
строгими правилами Rust, которые предотвращают наличие нескольких владельцев или несанкционированный доступ к данным. Рассмотрим простой пример:
Листинг 6.8. Простая функция, иллюстрирующая данные и их владельца
fn main() {
let s = String::from("Rust"); // ①
println!("{}", s);
// ②
}
// ③
В приведенном выше примере:
① переменная s является владельцем строкового литерала, размещенного
в куче;
② переменная s валидна и здесь может использоваться;
③ переменная s выходит из диапазона доступности, и компилятор Rust автоматически высвобождает память в куче.
В этом фрагменте исходного кода переменная s является владельцем строкового литерала, память под который выделена в куче. Когда переменная s выходит из диапазона доступности в конце функции main, компилятор Rust автоматически высвобождает память, занимаемую строковым литералом. Такое автоматическое управление памятью лежит в основе защитных гарантий Rust, обес
печивая на практике отсутствие каких-либо утечек памяти и висячих ссылок.
264
Управление памятью и указатели
Заимствование в Rust
Принятая в Rust модель владения обеспечивает эффективное управление памятью, но она также создает трудности в ситуациях, когда нужно использовать
коллективные данные между частями исходного кода. Именно здесь вступают
в игру заимствование и ссылки.
Заимствование позволяет нескольким частям исходного кода получать доступ к данным, не принимая на себя владение ими. Rust предлагает два типа
заимствования: мутируемое и немутируемое. Немутируемые ссылки, обозначаемые как &T, разрешают доступ к данным только в режиме чтения, тогда
как мутируемые ссылки, обозначаемые как &mut T, разрешают как чтение, так
и запись. Рассмотрим следующий ниже пример:
Листинг 6.9. Простой пример немутируемого заимствования
fn main() {
let s = String::from("Rust");
let len = calculate_length(&s); // ①
println!("Длина '{}' составляет {}.", s, len);
}
fn calculate_length(s: &String) -> usize {
s.len() // ②
}
// Вывод:
// Длина 'Rust' составляет 4.
В приведенном выше примере:
① мы передаем немутируемую ссылку, указывающую на строковые данные
в куче;
② мы можем обратиться к переменной s, не беря ее во владение.
В этом фрагменте исходного кода функция calculate_length заимствует
ссылку на данные типа String в куче, не беря над ними контроль. Благодаря
этому можно получить длину строковых данных без их перемещения или высвобождения.
Разграничение между мутируемыми и немутируемыми ссылками имеет
огромное значение для защитных гарантий Rust. Придерживаясь принципа
Rust «либо много читателей, либо один писатель», мы гарантируем, что данные не могут конкурентно изменяться и читаться, предотвращая тем самым
возникновение гонок за доступом к данным или повреждение памяти.
Времена жизни в Rust
Обследуя управление памятью в Rust, мы сталкиваемся с понятием времен жизни. Времена жизни – это способ, которым Rust гарантирует, что ссылки будут
оставаться валидными на протяжении всего их использования. Они помогают
предотвращать появление висячих ссылок и обеспечивают, чтобы заимствованные данные «жили дольше» ссылок на них. Рассмотрим следующий ниже пример:
Стек и куча 265
Листинг 6.10. Демонстрация концепции времен жизни
с использованием разных диапазонов доступности
fn main() {
let result;
{
let s = String::from("Rust");
result = get_length(&s); // ①
}
// ②
println!("Длина: {}", result);
}
fn get_length(s: &String) -> usize {
s.len()
}
// Вывод:
// Длина: 4
В приведенном выше примере:
① мы передаем ссылку на переменную s;
② переменная s выходит из диапазона доступности, но результат вычисления по-прежнему доступен.
Эта программа требует, чтобы ссылка на s, переданная в функцию get_length,
имела время жизни, выходящее за пределы самого вызова функции. Принятая
в Rust система времен жизни обеспечивает соблюдение этого условия, гарантируя, что мы не будем пытаться обращаться к данным, которые уже были высвобождены из памяти.
В данном случае функция возвращает usize (копируемое значение), поэтому исходный
код работает. Если бы функция пыталась вернуть саму ссылку на s, то система времен
жизни предотвратила бы это.
Выходя за рамки базового понимания, давайте углубимся в чуть более сложный пример, чтобы показать мощь и необходимость принятой в Rust системы
времен жизни. Рассмотрим сценарий со структурами:
Листинг 6.11. Времена жизни в структурах
с разными диапазонами доступности
struct Container<'a> {
data: &'a str,
}
fn main() {
let result;
{
let s = String::from("Rust");
let container = Container { data: &s };
result = process_container(container);
}
println!("Обработанные данные: {}", result);
}
266
Управление памятью и указатели
fn process_container(container: Container) -> usize {
container.data.len()
}
// Вывод:
// Обработанные данные: 4
В приведенном выше примере мы вводим структуру Container, которая хранит ссылку на строковый срез с определенным временем жизни 'a.
Функция main создает значение типа String, а затем инициализирует структуру Container ссылкой на это строковое значение. Далее функция process_
container принимает структуру Container во владение, извлекая и возвращая
длину строкового значения, на которую та ссылается.
Ключевым моментом здесь является то, что время жизни ссылки внутри
структуры Container должно охватывать вызов функции process_container.
Компилятор Rust строго контролирует эти ограничения, гарантируя, что
мы сохраняем валидную ссылку на протяжении всего исполнения программы.
Понимая механизмы владения, заимствования и времен жизни, разработчик может писать исходный код, который не только эффективен, но и защищен от распространенных ошибок работы с памятью. Строгие проверки
со стороны компилятора и аннотации времен жизни помогают отлавливать
потенциальные проблемы на этапе компиляции, что приводит к созданию надежного и стабильного программного обеспечения.
Сырые и умные указатели
Понятие указателей в Rust становится важным, когда значениям необходимо
взаимодействовать, ссылаться друг на друга или размещаться в куче. В отличие
от языков с автоматическим управлением памятью, Rust передает всю власть
по управлению указателями непосредственно в руки программиста. Это позволяет осуществлять мелкозернистую настройку и обеспечивает обнаружение ошибок
работы с памятью именно на этапе компиляции, а не на этапе исполнения.
В последующих разделах вы познакомитесь с различными типами указателей, используемых в Rust. Ссылки, боксы и сырые указатели – каждый из них
играет свою роль, и понимание того, когда и как их использовать, является
ключевым. Ссылки предлагают защищенные и эффективные способы доступа
к данным. Умные указатели Box обеспечивают способ размещения значений
в куче, когда это необходимо, под строгим контролем компилятора Rust. Сырые указатели, хоть и открывают дверь в незащищенный исходный код, предлагают высокую степень контроля в особых случаях.
Умный указатель Box
Rust предоставляет мощный инструмент выделения памяти в куче под названием Box.
В отличие от значений в стеке, которые имеют фиксированный размер
и время жизни, умный указатель Box позволяет выделять память для значения
известного типа и помещать его в кучу. Это значение в куче затем принадлежит умному указателю Box (боксу), который автоматически высвобождает
память, когда сам выходит из диапазона доступности.
Сырые и умные указатели 267
Cтек
длина
Куча
адрес
Рисунок 6.6. Пример расположения умного указателя Box<[T]> в памяти
Листинг 6.12. Простой пример использования умного указателя Box
fn main() {
let complex_data = Box::new(vec![1, 2, 3, 4, 5]); // ①
let sum: i32 = complex_data.iter().sum();
// ②
println!("Сумма вектора: {}", sum);
// ③
}
// Вывод:
// Сумма вектора: 15.
В приведенном выше примере:
① создать сложную структуру данных в куче с помощью умного указателя Box;
② выполнить манипуляции с данными внутри умного указателя Box;
③ умный указатель Box выходит из диапазона доступности и высвобождает
память в куче.
В приведенном выше примере умный указатель Box используется для выделения памяти под вектор [1, 2, 3, 4, 5], который находится в куче. Это
наглядно показывает, что Box можно использовать для управления сложными
структурами данных в куче. По достижении конца своего диапазона доступности умный указатель Box уничтожается и высвобождает занимаемую им память, предотвращая любые потенциальные утечки.
Универсальность умного указателя Box делает его ценным инструментом
в сценариях, в которых нужно:
268
Управление памятью и указатели
управлять динамическими структурами данных с переменным размером;
гарантировать, что значение «переживет» свой диапазон доступности
(например, при передаче в другие функции);
передавать владение сложной структурой данных без дорогостоящих
операций копирования.
Умный указатель Rc
Умный указатель Rc (сокращение от reference counter – счетчик ссылок) привносит в Rust концепцию коллективного владения.
Указатель
Указатель
Указатель
Поток
исполнения 1
Рисунок 6.7. Вектор с несколькими читателями, использующий умный указатель Rc
В то время как принятая в Rust модель владения обычно предусматривает
только одного владельца данных, умный указатель Rc позволяет нескольким
владельцам коллективно использовать одни и те же данные. Он отслеживает
количество ссылок на данные и автоматически высвобождает память, когда
удаляется последняя ссылка.
Листинг 6.13. Простой пример использования умного указателя Rc
use std::rc::Rc;
fn main() {
let data = Rc::new(vec![1, 2, 3, 4, 5]); // ①
let clone1 = Rc::clone(&data);
// ②
let clone2 = Rc::clone(&data);
// ②
let sum: i32 = data.iter().sum();
println!("Данные: {:?}", data);
println!("Клон1: {:?}", data);
println!("Клон2: {:?}", data);
}
// ③
// ④
Сырые и умные указатели 269
//
//
//
//
Вывод:
Данные: [1, 2, 3, 4, 5]
Клоны1: [1, 2, 3, 4, 5]
Клоны2: [1, 2, 3, 4, 5]
В приведенном выше примере:
① создать коллективные данные с помощью умного указателя Rc;
② клонировать умные указатели Rc для коллективного владения. Клонируется только указатель, и увеличивается счетчик, сами данные вектора
не копируются;
③ выполнить манипуляции с коллективными данными;
④ переменные data, clone1 и clone2 выходят из диапазона доступности;
память высвобождается.
Умный указатель Rc предоставляет способ иметь несколько читателей одних
и тех же данных, что делает его полезным в сценариях, когда нужно коллективно использовать данные между разными частями программы. Однако важно
отметить, что Rc не обеспечивает потокозащищенность. Если требуется коллективное владение данными в нескольких потоках исполнения, то для этих
целей следует рассмотреть использование умного указателя Arc.
Умный указатель Arc
Умный указатель Arc (сокращение от atomic reference counting – атомарный
подсчет ссылок) – это «собрат» умного указателя Rc, который позволяет нескольким владельцам коллективно использовать одни и те же данные в многопоточной среде.
Указатель
Поток
исполнения 1
Указатель
Поток
исполнения 2
Указатель
Поток
исполнения 3
Рисунок 6.8. Вектор с многопоточными читателями, использующий Arc
Однако, в отличие от умного указателя Rc, умный указатель Arc специально
разработан под многопоточные сценарии, обеспечивая потокозащищенность
за счет атомарных операций.
270 Управление памятью и указатели
Для того чтобы использовать умный указатель Arc в многопоточном контексте, необходимо импортировать его из модуля std::sync.
Листинг 6.14. Простой пример использования умного указателя Arc
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]); // ①
let clone1 = Arc::clone(&data);
// ②
let clone2 = Arc::clone(&data);
// ②
let handle1 = thread::spawn(move || {
// ③
let sum: i32 = clone1.iter().sum();
println!("Поток 1 - Сумма данных: {}", sum);
});
let handle2 = thread::spawn(move || {
// ③
let product: i32 = clone2.iter().product();
println!("Поток 2 - Произведение данных: {}", product);
});
}
handle1.join().unwrap(); // ④
handle2.join().unwrap(); // ④
// ⑤
// Вывод:
// Поток исполнения 1 - Сумма данных: 15
// Поток исполнения 2 - Произведение данных: 120
В приведенном выше примере:
① создать потокозащищенные коллективные данные с помощью умного
указателя Arc;
② клонировать умные указатели Arc для коллективного владения между
потоками исполнения;
③ породить два потока исполнения для работы с коллективными данными;
④ ожидать завершения работы обоих потоков исполнения;
⑤ переменные data, clone1, clone2 и потоки исполнения выходят из диапазона доступности; память высвобождается.
В этом обновленном примере умный указатель Arc используется для защищенного коллективного владения вектором [1, 2, 3, 4, 5] между несколькими потоками исполнения. Порождаются два потока (Поток исполнения 1 и Поток исполнения 2) для выполнения операций на коллективных данных. Когда
все ссылки на данные (data, clone1 и clone2) и сами потоки выходят из диапазона доступности, память автоматически высвобождается.
Умный указатель Arc особенно ценен в конкурентном программировании,
так как гарантирует защищенный доступ к данным из нескольких потоков исполнения. Его механизм атомарного подсчета ссылок гарантирует, что ссылки и высвобождение памяти обрабатываются потокозащищенным образом,
благодаря чему он идеально подходит для работы с коллективными данными
в многопоточных средах.
Сырые и умные указатели 271
Умный указатель RefCell
Строгие правила Rust в отношении мутируемости обеспечивают защищенность и целостность исходного кода. Однако возникают ситуации, когда необходимо применять мутации к данным даже в тех контекстах, которые
в остальном считаются немутируемыми. Именно здесь на помощь приходит
умный указатель RefCell – ценный инструмент обеспечения динамической мутируемости, представляющий собой контейнер, внутри которого есть счетчик
активных заимствований; он владеет данными и сам решает, давать на них
ссылку сейчас или нет.
Cтек
длина
Куча
адрес
Изначальные данные не мутируют
Cтек
длина
Куча
адрес
Мутируемый заемщик
Рисунок 6.9. Заимствование мутируемой ссылки с использованием умного указателя RefCell
В отличие от традиционной концепции мутируемости, которая определяется на этапе компиляции, умный указатель RefCell предоставляет возможность
изменять данные динамически, сохраняя при этом немутируемость самой переменной. Указанное функциональное средство оказывается исключительно
выгодным при работе с данными, которые могут подвергаться правкам внутри
немутируемого контекста.
Давайте рассмотрим, как умный указатель RefCell обеспечивает динамическую мутируемость на наглядном примере:
Листинг 6.15. Простой пример использования умного указателя RefCell
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
let data_ref = data.borrow(); // ①
// data_ref.push(4); // ② – Ошибка компиляции, так как ссылка немутируема
drop(data_ref);
// ③
}
let mut data_ref_mut = data.borrow_mut();
// ④
data_ref_mut.push(4);
// ⑤
println!("Измененные данные: {:?}", *data_ref_mut); // ⑥
// Вывод:
// Измененные данные: [1, 2, 3, 4]
272
Управление памятью и указатели
В приведенном выше примере:
① заимствование немутируемой ссылки;
② попытка мутировать данные (приведет к ошибке компиляции или панике на этапе исполнения, если правила заимствования будут нарушены
динамически);
③ явное высвобождение немутируемой ссылки;
④ заимствование изменяемой ссылки;
⑤ подвергнуть данные мутации в защищенном режиме;
⑥ доступ к мутируемым данным.
В приведенном выше примере мы начинаем с умного указателя RefCell, содержащего вектор целых чисел. Сначала мы заимствуем немутируемую ссылку на данные, что предотвращает их прямые мутации.
После явного высвобождения немутируемой ссылки мы заимствуем мутируемую ссылку, чтобы защищенно модифицировать данные.
Умный указатель RefCell выполняет проверку заимствований на этапе исполнения, обеспечивая динамическую мутируемость при соблюдении принятых в Rust правил заимствования. Если вы нарушите эти правила, то ваша
программа поднимет панику. Такая проверка на этапе исполнения обеспечивает гибкость ценой снижения защищенности на этапе исполнения.
Умный указатель RefCell находит применение в различных сценариях,
включая следующие:
работа с замыканиями: он позволяет мутировать данные внутри замыканий, которые захватывают немутируемые ссылки;
внутренняя мутируемость: он удобен для реализации структур данных с внутренней мутируемостью, таких как алгоритмы на графах или
конкретно-прикладные умные указатели;
однопоточные контексты: умный указатель RefCell упрощает коллективное использование данных в ситуациях, когда правила заимствования слишком строги в рамках одного потока исполнения.
Вместе с тем следует помнить, что использовать умный указатель RefCell
следует осмотрительно, а к потенциальным паникам нужно относиться с осторожностью. При использовании динамической мутируемости в исходном коде
критически важны надлежащая синхронизация и обработка ошибок.
Умный указатель Mutex
При работе с многопоточными программами на Rust крайне важно защищать коллективные данные от конкурентного доступа, который может приводить к гонкам за доступом к данным и непредсказуемому поведению.
Для решения этой задачи в Rust предлагается мощный инструмент – умный
указатель Mutex (название происходит от англ. mutual exclusion – взаимное
исключение). Он обеспечивает исключительный синхронизированный доступ к коллективным данным, гарантируя потокозащищенность и целостность данных.
Сырые и умные указатели 273
Поток
исполнения 1
Запирание
Чтение
Поток
исполнения 2
Отпирание
Запирание
Чтение
Поток
исполнения 3
Отпирание
Запирание
Запись
Рисунок 6.10. Конкурентный доступ к коллективным данным с использованием механизма
запирания/отпирания в умном указателе Mutex
Давайте рассмотрим принцип работы умного указателя Mutex<T> на примере
симуляции банка, в рамках которой несколько счетов обновляются конкурентно:
Листинг 6.16. Простой пример использования умного указателя Mutex
use
use
use
use
std::sync::{Mutex, Arc};
std::thread;
std::time::Duration;
std::collections::HashMap;
struct Bank {
// Мьютекс защищает хеш-таблицу со счетами
accounts: Mutex<HashMap<String, f64>>,
}
impl Bank {
fn new() -> Self {
Bank {
accounts: Mutex::new(HashMap::new()),
}
}
fn deposit(&self, account: &str, amount: f64) {
// Блокируем мьютекс для получения доступа
let mut accounts = self.accounts.lock().unwrap();
let balance = accounts.entry(account.to_string()).or_insert(0.0);
*balance += amount;
}
fn withdraw(&self, account: &str, amount: f64) {
let mut accounts = self.accounts.lock().unwrap();
let balance = accounts.entry(account.to_string()).or_insert(0.0);
if *balance >= amount {
*balance -= amount;
} else {
println!("Недостаточно средств на счету: {}", account);
}
}
274
Управление памятью и указатели
fn check_balance(&self, account: &str) -> f64 {
let accounts = self.accounts.lock().unwrap();
*accounts.get(account).unwrap_or(&0.0)
}
}
fn main() {
let bank = Arc::new(Bank::new());
let mut handles = vec![];
for i in 0..5 {
let bank_clone = Arc::clone(&bank);
let handle = thread::spawn(move || {
let account = format!("Счет{}", i);
bank_clone.deposit(&account, 100.0);
// Симулировать другую работу
thread::sleep(Duration::from_millis(100));
bank_clone.withdraw(&account, 30.0);
let balance = bank_clone.check_balance(&account);
println!("{} - Баланс: {:.2}", account, balance);
});
handles.push(handle);
}
}
//
//
//
//
//
//
for handle in handles {
handle.join().unwrap();
}
Вывод:
Счет0 Счет2 Счет1 Счет4 Счет3 -
Баланс:
Баланс:
Баланс:
Баланс:
Баланс:
70.00
70.00
70.00
70.00
70.00
В приведенном выше примере мы создаем структуру Bank, которая содержит
хеш-словарь, обернутый в умный указатель, Mutex<HashMap<String, f64>>, для
хранения балансов счетов. Несколько потоков исполнения симулируют банковские транзакции, включая внесение средств на депозит, снятие средств с депозита и проверку баланса. Мьютекс гарантирует, что только один поток исполнения
может иметь доступ к банковским счетам в конкретный момент времени, предотвращая гонки за доступом к данным и обеспечивая их непротиворечивость.
Этот пример наглядно показывает, как умный указатель Mutex может использоваться для защиты коллективных данных в многопоточной среде, такой
как банковское приложение. Каждый поток исполнения работает с разными
счетами конкурентно, при этом гарантируется, что данные остаются точными
и непротиворечивыми.
Запирание и отпирание мьютекса имеют решающее значение для обеспечения эксклюзивного доступа. Когда поток исполнения запирает мьютекс на замок, он получает единоличный контроль над защищенными данными. Как
только поток исполнения завершает операцию и замок выходит из диапазона
Сырые и умные указатели 275
доступности, мьютекс автоматически отпирается, позволяя другим потокам
исполнения получать к нему доступ.
Умный указатель Mutex – это ценный инструмент для защиты коллективных данных в многопоточных программах на Rust, гарантирующий, что только один поток исполнения может изменять их в любой момент времени. Тем
не менее важно использовать умный указатель Mutex осмотрительно во избежание ненужной соревновательности за доступ, которая может снижать производительность. В сценариях, в которых преобладают операции чтения, стоит рассмотреть использование умного указателя RwLock<T>, чтобы добиваться
улучшенной конкурентности.
Умный указатель RwLock
Умный указатель RwLock (от англ. read-write lock – замок на чтение–запись)
играет важную роль в многопоточном программировании на Rust, обеспечивая сбалансированный подход к управлению коллективными данными.
Он позволяет нескольким потокам исполнения конкурентно читать данные,
гарантируя при этом исключительный доступ для операции записи. Эта универсальность делает его ценным инструментом для сценариев с различными
требованиями к операциям чтения и записи.
Поток
исполнения 1
Поток
исполнения 2
Поток
исполнения 3
Запирание
Чтение
Запись
Чтение
Чтение
Отпирание
Чтение
Чтение
Рисунок 6.11. Конкурентное чтение коллективных данных с использованием умного указателя RwLock
При выборе между умными указателями Mutex<T> и RwLock<T> следует учитывать природу коллективных данных:
Mutex: нужно выбирать Mutex<T>, если данные часто обновляются и поддержание их непротиворечивости через исключительный доступ имеет
решающее значение. Этот умный указатель следует использовать в ситуациях, когда сложность управления отдельными замками на чтение
и запись не оправдана приростом производительности;
276
Управление памятью и указатели
RwLock: если данные читаются чаще, чем записываются, и вас беспокоят
задержки из-за конфликтов при чтении, то RwLock<T> предложит более
эффективный подход. В таких сценариях разрешение на выполнение нескольких конкурентных операций чтения может значительно повышать
производительность.
Рекомендации по использованию:
Mutex<T> подходит для ситуаций, когда и чтение, и запись требуют исключительного доступа или когда обособленные замки добавляют сложности без явной выгоды в быстродействии;
RwLock<T> эффективен при нагрузках с преобладанием чтения, повышая
производительность за счет конкурентных читателей при обеспечении
защищенной операции записи.
Важно отметить, что характеристики производительности умных указателей Mutex<T> и RwLock<T> могут варьироваться в зависимости от платформы.
Поэтому для принятия обоснованного решения рекомендуется проводить нагрузочное тестирование (производительности) и профилирование применительно к конкретному варианту использования.
Ниже приведен предыдущий пример, переписанный с использованием умного указателя RwLock вместо умного указателя Mutex, за которым следует подробное объяснение различий:
Листинг 6.17. Простой пример использования умного указателя RwLock
use
use
use
use
std::sync::{Arc, RwLock};
std::thread;
std::time::Duration;
std::collections::HashMap;
struct Bank {
// Используем RwLock вместо Mutex
accounts: RwLock<HashMap<String, f64>>,
}
impl Bank {
fn new() -> Self {
Bank {
accounts: RwLock::new(HashMap::new()),
}
}
fn deposit(&self, account: &str, amount: f64) {
// Блокировка на запись для изменения данных
let mut accounts = self.accounts.write().unwrap();
let balance = accounts.entry(account.to_string()).or_insert(0.0);
*balance += amount;
}
fn withdraw(&self, account: &str, amount: f64) {
let mut accounts = self.accounts.write().unwrap();
let balance = accounts.entry(account.to_string()).or_insert(0.0);
if *balance >= amount {
Сырые и умные указатели 277
*balance -= amount;
} else {
println!("Недостаточно средств на счету: {}", account);
}
}
fn check_balance(&self, account: &str) -> f64 {
// Блокировка только на чтение
let accounts = self.accounts.read().unwrap();
*accounts.get(account).unwrap_or(&0.0)
}
}
fn main() {
let bank = Arc::new(Bank::new());
let mut handles = vec![];
for i in 0..5 {
let bank_clone = Arc::clone(&bank);
let handle = thread::spawn(move || {
let account = format!("Счет{}", i);
bank_clone.deposit(&account, 100.0);
// Симулировать другую работу
thread::sleep(Duration::from_millis(100));
bank_clone.withdraw(&account, 30.0);
let balance = bank_clone.check_balance(&account);
println!("{} - Баланс: {:.2}", account, balance);
});
handles.push(handle);
}
}
//
//
//
//
//
//
for handle in handles {
handle.join().unwrap();
}
Вывод:
Счет1 Счет2 Счет0 Счет3 Счет4 -
Баланс:
Баланс:
Баланс:
Баланс:
Баланс:
70.00
70.00
70.00
70.00
70.00
Использование умного указателя RwLock: в переписанном примере
мы заменили умный указатель Mutex на умный указатель RwLock, чтобы
позволить нескольким потокам исполнения читать данные конкурентно, обеспечивая исключительный доступ для операции записи. Это изменение улучшает параллелизм в сценариях с интенсивным чтением.
Использование методов write() и read(): мы используем метод self.
accounts.write().unwrap() для приобретения замка на запись и self.
accounts.read().unwrap() для приобретения замка на чтение. За счет
этого осуществляется управление доступом к хеш-словарю HashMap, в ре-
278
Управление памятью и указатели
зультате чего несколько потоков исполнения могут читать данные одновременно, но только один может писать в конкретный момент времени.
Методы умного указателя RwLock: RwLock предоставляет раздельные
методы для чтения и записи, что позволяет четко выражать намерения.
В методах deposit и withdraw мы используем метод write(), чтобы гарантировать исключительный доступ при обновлении балансов. В методе check_balance мы используем метод read(), разрешая конкурентные
операции чтения.
Конкурентность: это изменение в синхронизации не влияет на основную логику программы; она по-прежнему симулирует несколько
банковских транзакций. Однако оно оптимизирует программу в части
улучшения производительности в тех случаях, когда операции чтения
происходят чаще операций записи.
Вывод результата: результат остается прежним, отображая балансы
счетов по завершении симулированных транзакций.
Подводя итог, используя умный указатель RwLock, мы соблюдаем баланс
между доступом на чтение и запись, делая исходный код более эффективным в ситуациях, когда чтение доминирует в рабочей нагрузке. Это приводит
к улучшению конкурентности и более качественному использованию ресурсов
по сравнению с использованием умного указателя Mutex.
Атомарные операции
В конкурентном программировании, когда несколько потоков исполнения
одновременно обращаются к коллективным данным и изменяют их, крайне
важно обеспечивать атомарность этих операций. Атомарная операция означает, что с точки зрения других потоков исполнения она происходит мгновенно, без каких-либо промежуточных или частично завершенных этапов.
Rust предоставляет мощную поддержку атомарных операций через свою
стандартную библиотеку, позволяя писать защищенный и эффективный
конкурентный исходный код.
В многопоточной среде неатомарные операции на коллективных данных
могут приводить к гонкам за доступ к данным, которые влекут за собой неопределенное поведение и трудноотлаживаемые ошибки. Как вы уже знаете,
гонки за доступ к данным возникают в ситуациях, когда два или более потока
исполнения одновременно обращаются к одной и той же ячейке памяти и по
меньшей мере один из них выполняет операцию записи. Во избежание гонок
за доступ к данным и поддержания целостности коллективных данных в Rust
предлагаются атомарные операции.
Модуль std::sync::atomic предоставляет широкий выбор атомарных типов
и операций для разных типов данных (отсюда его иногда именуют Atomic*).
Эти атомарные типы гарантируют, что выполняемые над ними действия, такие как чтение, запись и модификация, являются атомарными. За счет этого
обеспечивается предотвращение гонок за доступ к данным в конкурентном
исходном коде.
Давайте разберем простейший пример, демонстрирующий использование
атомарных операций:
Сырые и умные указатели 279
Листинг 6.18. Простейший пример атомарной операции
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let atomic_flag = Arc::new(AtomicBool::new(false)); // ①
let atomic_flag_clone = Arc::clone(&atomic_flag); // ②
let thread_handle = thread::spawn(move || { // ③
thread::sleep(std::time::Duration::from_secs(1));
// Устанавливаем флаг в true атомарно
atomic_flag_clone.store(true, Ordering::Relaxed);
});
// Цикл ожидания, пока флаг не станет true
while !atomic_flag.load(Ordering::Relaxed) { // ④
// Выполнить какую-либо работу...
}
thread_handle.join().unwrap();
println!("Атомарный флаг получил значение true.");
}
// Вывод:
// Атомарный флаг получил значение true.
В приведенном выше примере:
① создать новое атомарное булево значение с начальным значением false;
② клонировать атомарный флаг для использования в нескольких потоках
исполнения;
③ породить поток исполнения, который переключает атомарный флаг после
задержки;
④ проверять значение атомарного флага в цикле до тех пор, пока оно
не станет true.
В приведенном выше примере мы создаем переменную атомарного булева
типа AtomicBool под названием atomic_flag, инициализированную значением false. Затем мы клонируем ее (используя умный указатель Arc), чтобы использовать в отдельном потоке исполнения. Порожденный поток «засыпает»
на одну секунду, а затем атомарно присваивает флагу значение true, используя метод store.
Тем временем главный поток исполнения непрерывно проверяет значение
флага в цикле с помощью метода load, выполняя некоторую работу до тех пор,
пока флаг не станет true. Этот пример демонстрирует атомарность и синхронизацию между потоками исполнения.
Перечисление Ordering, используемое в таких атомарных операциях, как
store и load, конкретизирует ограничения по упорядочению памяти, обеспечивая надлежащую синхронизацию между потоками исполнения.
Атомарные операции играют жизненно важную роль в построении защищенных и эффективных конкурентных структур данных и алгоритмов на Rust.
280
Управление памятью и указатели
Эти операции предоставляют необходимые способы координации и синхронизации потоков исполнения, гарантируя предотвращение гонок за доступ
к данным и неопределенного поведения.
Незащищенный Rust
Хотя защитные функциональные средства Rust весьма надежны, бывают моменты, когда необходимо задействовать «запретные» способности незащищенного программирования на Rust. Ключевое слово unsafe предоставляет
контроль над низкоуровневыми операциями с памятью, но при этом нуждается в предельной внимательности к вопросам защищенности памяти.
Одним из выделяющихся вариантов использования незащищенного (unsafe)
исходного кода на Rust является взаимодействие с исходным кодом на других
языках, таких как C или C++. Эти языки зачастую работают на более низком
уровне и выполняют операции, которые проверки со стороны компилятора
Rust на предмет защищенности просто не способны выразить.
Давайте рассмотрим некоторые сложные сценарии, в которых незащищенный исходный код на Rust становится необходимым.
Незащищенные функции
В Rust есть возможность объявлять функции как «незащищенные», если они
выполняют операции, защищенность которых не может быть проверена компилятором статически. Сюда входят такие действия, как обращение к памяти по сырым указателям, создание мутируемых псевдонимов или доступ
к регистрам, связанным с отображением в память.
Ниже представлен пример незащищенной функции, которая обращается
к памяти по сырому указателю.
Листинг 6.19. Простой пример незащищенной функции
fn main() {
let mut x = 42;
let raw_ptr: *const i32 = &x; // Создать сырой указатель
unsafe {
// Обратиться к памяти по указателю внутри блока unsafe
println!("Значение по адресу raw_ptr: {}", *raw_ptr);
}
}
В приведенном выше исходном коде блок unsafe используется для обращения к памяти по сырому указателю raw_ptr, который указывает на значение
переменной x. Блок unsafe служит индикатором того, что программист берет
на себя ответственность за обеспечение защищенности этой операции.
Незащищенные пакеты
В Rust также есть возможность реализовывать пакеты на типах, которые требуют наличия незащищенных операций. Такая практика часто встречается при
реализации конкретно-прикладных умных указателей или низкоуровневых
Незащищенный Rust 281
абстракций. Примерами незащищенных пакетов являются Send и Sync. Эти пакеты помечены как unsafe, поскольку их реализация подразумевает определенные семантические гарантии, связанные с потокозащищенностью, и нарушение этих гарантий может приводить к проблемам с защищенностью памяти.
Ниже приведен пример использования гипотетического незащищенного
пакета:
Листинг 6.20. Простой пример незащищенного пакета
unsafe trait UnsafeTrait {
fn unsafe_method(&self);
}
unsafe impl UnsafeTrait for i32 {
fn unsafe_method(&self) {
println!("Реализация незащищенного метода для i32: {}", self);
}
}
fn main() {
let num: i32 = 42;
let trait_ref: &dyn UnsafeTrait = #
println!("Обращение к незащищенному пакету само по себе безопасно");
trait_ref.unsafe_method();
println!("Вызван незащищенный метод");
}
В приведенном выше примере пакет UnsafeTrait помечен как unsafe, и его
реализация для типа i32 также помечена как unsafe. Сама реализация метода unsafe_method может включать операции, которые при неправильном использовании могут ставить под угрозу защищенность памяти.
Ключевой момент заключается в том, что использование незащищенных
пакетов требует скрупулезного анализа и соблюдения семантических гарантий, предусмотренных пакетом. Реализация таких пакетов должна выполняться с глубоким пониманием их последствий для защищенности памяти.
Конкретно-прикладные незащищенные абстракции
В Rust можно разрабатывать свои собственные незащищенные абстракции
при работе со специфическим оборудованием или низкоуровневыми операциями. Рассмотрим сценарий, в котором вы работаете с нестандартным аппаратным устройством, требующим точного управления памятью:
Листинг 6.21. Простой пример незащищенных абстракций
mod custom_device {
pub struct HardwareDevice {
// Поля, специфичные для аппаратного устройства
}
impl HardwareDevice {
pub fn new() -> Self {
282
Управление памятью и указатели
// Инициализация и настройка аппаратного устройства
Self {
// Инициализация полей
}
}
pub fn perform_unsafe_operation(&self) {
// Выполнение низкоуровневых операций,
// требующих использования unsafe в Rust
}
}
}
fn main() {
let device = custom_device::HardwareDevice::new();
// Защищенное взаимодействие с аппаратным устройством
device.perform_unsafe_operation();
}
В приведенном выше примере мы выносим функциональность аппаратного
устройства в модуль и определяем метод perform_unsafe_operation для низкоуровневого взаимодействия с устройством. Благодаря этому гарантируется,
что только необходимая часть исходного кода будет помечена как unsafe, позволяя локализовывать потенциальные риски.
Интерфейс с внешними функциями
Способности Rust взаимодействовать с внешними функциями посредством
соответствующего интерфейса FFI обеспечивают бесшовную интеграцию
с другими языками программирования. Вам придется сталкиваться с незащищенным (unsafe) исходным кодом на Rust, как правило, при вызове функций
из внешних библиотек. Давайте рассмотрим пример:
Листинг 6.22. Простой пример интерфейса с внешней функцией
extern "C" {
// Объявление внешней функции на языке C
fn external_function(arg: i32) -> i32;
}
fn main() {
let result: i32;
unsafe {
// Вызов внешней функции внутри блока unsafe
result = external_function(42);
}
println!("Результат из внешней функции: {}", result);
}
В приведенном выше исходном коде мы объявляем внешнюю функцию C external_function и вызываем ее внутри блока unsafe. Необходимость
блока unsafe связана с тем, что вызовы согласно FFI-интерфейсу по своей природе не защищены: компилятор Rust не может проверять защищенность исходного кода, написанного на других языках.
Лучшие образцы практики управления памятью 283
Эти примеры демонстрируют, как незащищенный Rust дает разработчику
возможность справляться со сложными сценариями, будь то взаимодействие
с нестандартным оборудованием, бесшовная интеграция с другими языками,
реализация незащищенных пакетов или определение незащищенных функций. Тем не менее огромная мощь влечет за собой огромную ответственность –
в незащищенном Rust всегда следует действовать осмотрительно.
Рекомендации по написанию незащищенного исходного кода
При работе с незащищенным исходным кодом на Rust крайне важно неукоснительно следовать правилам обеспечения защищенности. Ниже представлено несколько советов, которые помогут сделать незащищенный исходный код
максимально защищенным.
Применение unsafe в малых блоках: по возможности локализовывать
незащищенный исходный код в малых, хорошо изолированных блоках
или функциях. Это сужает область потенциальных проблем и облегчает
проведение аудиторских проверок защищенности.
Документирование допущений: четко описывать любые допущения
или инварианты, на которых основывается незащищенный исходный
код. Это поможет другим разработчикам разбираться в логике исходного кода и будет способствовать его более защищенному использованию.
Использование абстракций: везде, где это применимо, обертывать незащищенные операции в защищенные абстракции. Это защитит остальную часть кодовой базы от сложностей и тонкостей незащищенного исходного кода.
Тестирование: строго тестировать свой незащищенный исходный код,
чтобы выявлять потенциальные проблемы на ранних этапах. В этом контексте особенно полезны тестирование на основе свойств (генеративное
тестирование) и нечеткое тестирование (когда на вход подаются случайные или заведомо некорректные данные).
Перепроверка исходного кода: обращаться к опытным разработчикам на Rust за перепроверкой исходного кода, особенно если он содержит unsafe. Свежий взгляд поможет заметить скрытые нарушения правил обеспечения защищенности.
Подходя к незащищенному Rust с осторожностью и следуя лучшим образцам практики, вы сможете использовать его мощь, не ставя под угрозу общую
защищенность исходного кода. Подробнее о незащищенном Rust читайте
в главе 13 «Незащищенное программирование на Rust».
Лучшие образцы практики управления памятью
Эффективное управление памятью в Rust выходит за рамки простого понимания основ механизмов владения, заимствования и умных указателей. Для того
чтобы писать надежный и производительный исходный код, крайне важно
следовать лучшим образцам практик и использовать идиоматичные для Rust
шаблоны программирования. В данном разделе мы рассмотрим некоторые
из этих правил и рекомендаций.
284
Управление памятью и указатели
Приоритет выделения памяти в стеке
В качестве общего правила: везде, где это возможно, следует предпочитать выделение памяти в стеке, а не в куче. Стек работает быстро, а управление памятью в нем осуществляется максимально прозрачно. Локальные переменные,
данные вызовов функций и короткоживущие значения должны преимущест
венно располагаться в стеке.
Выделение памяти в стеке имеет ряд преимуществ.
Таблица 6.1. Выделение памяти в стеке и его преимущества
Преимущество
Описание
Быстродействие
Более быстрое выделение и высвобождение памяти по сравнению с кучей
Предсказуемость
Очередность «последним пришел – первым вышел» (LIFO) обеспечивает эффективное управление значениями
Защищенность
Строгие правила времен жизни снижают риск ошибок работы с памятью
Использовать выделение памяти в куче (например, с помощью умных указателей Box, Rc или Arc) следует только тогда, когда требуется динамическое
время жизни или когда размер данных неизвестен на этапе компиляции.
Активное задействование ссылок
Ссылки (&) – ваши лучшие друзья в ситуациях, когда дело касается эффективного доступа к данным и их коллективного использования. Их следует использовать для того, чтобы заимствовать значения, а не брать их во владение. Благодаря им минимизируется ненужное копирование и несколько частей исходного кода получают возможность читать данные одновременно.
Немутируемые ссылки (&) обеспечивают доступ к данным только для операции чтения и могут коллективно использоваться несколькими потоками
исполнения, гарантируя защищенность без ущерба для производительности.
Мутируемые ссылки (&mut) предоставляют исключительный доступ к данным
для операции записи в рамках ограниченного диапазона доступности.
При разработке функций и методов следует стараться по возможности принимать ссылки, а не поглощать значения. За счет этого обеспечиваются более
высокая гибкость и возможность реиспользования исходного кода.
Разумное использование умных указателей
Умные указатели, такие как Box, Rc и Arc, являются ценными инструментами,
но их следует применять осмотрительно. Рекомендуется оставлять их для сценариев, в которых действительно необходимы коллективное владение, подсчет ссылок или выделение памяти в куче.
Следует избегать ненужного вкладывания значений в умный указатель Box,
которые могут быть размещены в стеке. Хотя умный указатель Box предлагает
динамическое время жизни, его чрезмерное использование может приводить
к ненужному выделению памяти в куче и снижению производительности.
Умные указатели Rc и Arc отлично подходят в ситуациях, когда нескольким
частям исходного кода требуется коллективное владение данными. Однако следует остерегаться возможных циклических ссылок (ситуаций, когда объекты
Лучшие образцы практики управления памятью 285
ссылаются друг на друга, не давая высвобождать выделенную под них память).
В таких случаях рекомендуется рассмотреть возможность использования слабых ссылок вместе с умными указателями Rc или Arc, чтобы разрывать циклы.
Использование сопоставления с шаблоном
Сопоставление с шаблоном в Rust, реализуемое с помощью ключевого слова match, является мощным инструментом эффективного управления памятью.
Оно позволяет с легкостью деструктурировать и извлекать данные из сложных
структур. Шаблоны могут использоваться для сопоставления с перечислениями, структурами и даже кортежами. Ниже представлен простой пример сопоставления с шаблоном:
Листинг 6.23. Простой пример сопоставления с шаблоном
fn main() {
let option = Some(42);
match option {
Some(value) => println!("Значение: {}", value),
None => println!("Значения нет"),
}
}
// Вывод:
// Значение: 42
В приведенном выше исходном коде мы используем сопоставление с шаб
лоном для обработки перечисления Option, деструктурируя его на варианты Some и None.
Аннотирование времен жизни
Для управления памятью на Rust решающее значение имеет понимание времен жизни. Хотя компилятор способен логически выводить времена жизни самостоятельно, существуют сценарии, в которых необходимы явные аннотации
времен жизни, чтобы точно передавать намерения разработчика.
Аннотации времен жизни используются для указания взаимосвязей между ссылками, гарантируя, что заимствованные данные остаются валидными
на протяжении всего периода их использования. При разработке функций,
структур или реализаций пакетов стоит рассматривать возможность добавления явных аннотаций времен жизни, чтобы делать исходный код более удобочитаемым и прояснять логику его работы. Ниже представлен пример использования явных аннотаций времен жизни:
Листинг 6.24. Простой пример аннотаций времен жизни
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
286
Управление памятью и указатели
fn main() {
let s1 = "Привет,";
let s2 = "мир!";
let longest_string = longest(s1, s2);
}
println!("Самый длинный строковый литерал: {}", longest_string);
// Вывод:
// Самый длинный строковый литерал: Привет
В приведенном выше исходном коде функция longest имеет явную аннотацию времени жизни ('a), которая указывает на то, что время жизни возвращаемой ссылки связано с временами жизни входных ссылок s1 и s2.
Надлежащее управление ресурсами
Управление ресурсами в Rust распространяется не только на память, но и на
другие активы, такие как файлы, сетевые соединения и внешние библиотеки.
Правильное управление этими ресурсами необходимо для предотвращения
утечек ресурсов и обеспечения надежности исходного кода.
Система владения и механизм высвобождения в Rust естественным образом подходят для управления ресурсами. Когда значение выходит из диапазона доступности, связанные с ним ресурсы высвобождаются – будь то память
или другие ресурсы, такие как дескрипторы файлов.
В сценариях работы с внешними ресурсами следует стараться использовать
типы из стандартной библиотеки Rust, например std::fs::File для файлового ввода-вывода. Эти типы реализуют общую черту Drop, чтобы гарантировать
высвобождение ресурсов, когда они больше не нужны.
Многопоточность и конкурентность
Если приложение на Rust нуждается в многопоточности или конкурентности,
то следует уделять пристальное внимание синхронизации памяти. Используемая в Rust модель владения обеспечивает защитные гарантии для однопоточного исходного кода, однако многопоточные программы нуждаются в дополнительном рассмотрении.
Рекомендуется применять примитивы синхронизации, такие как Mutex,
RwLock и Atomic* для защиты коллективных данных. Указанные примитивы
гарантируют, что доступ к данным будет надлежащим образом синхронизирован между потоками исполнения, что предотвращает гонки за доступ к данным и повреждение памяти.
При использовании многопоточности следует учитывать компромиссы между
производительностью и потреблением памяти. Такие стратегии, как локальное
для потока хранение и тщательная проработка структуры потоков исполнения,
позволяют минимизировать конфликты и максимизировать эффективность.
Осторожное использование незащищенного исходного кода
Хотя незащищенный Rust предоставляет мощные инструменты для низко
уровневых операций с памятью, его следует использовать экономно и с осто-
Лучшие образцы практики управления памятью 287
рожностью. Прибегать к незащищенному исходному коду следует только тогда, когда вы исчерпали все защищенные альтернативы и способны гарантировать, что исходный код не поставит под угрозу совокупную защищенность.
При написании незащищенного исходного кода следует тщательно тестировать и проверять его на предмет выявления и устранения потенциальных
проблем. Рекомендуется четко документировать принятые допущения и инварианты, а сам незащищенный исходный код держать максимально изолированным, чтобы минимизировать его влияние на всю кодовую базу.
Следует помнить, что цель незащищенного Rust – не в том, чтобы обходить
принятые в языке проверки защищенности, а в том, чтобы сделать возможными защищенные абстракции и взаимодействие с внешним исходным кодом,
которые нельзя выразить средствами защищенного Rust. Подробнее о незащищенном исходном коде читайте в главе 13 «Незащищенное программирование на Rust».
Профилирование и оптимизация
Эффективное управление памятью зачастую идет рука об руку с профилированием и оптимизацией. Рекомендуется использовать инструменты профилирования, такие как cargo-prof и perf, чтобы выявлять узкие места производительности и шаблоны использования памяти в исходном коде.
Профилировать тесты
производительности
Профилировщик
Найти узкие
места
Тесты
Написать
тесты
Написать
Тесты
тесты
производитель- производиности
тельности
Выполнить
тесты
производительности
Оптимизировать
исходный
код
Выполнить
тесты
Рисунок 6.12. Рабочий процесс оптимизации исходного кода
Оптимизация на основе профилирования позволяет сосредоточивать усилия на тех участках исходного кода, которые оказывают наибольшее влияние на производительность и потребление памяти. Следует использовать как
встроенные в Rust способности профилирования, так и внешние профилировщики, чтобы получать представление о поведении исходного кода.
Такие методы оптимизации, как подбор структур данных, улучшение алгоритмов и кеширование, могут значительно влиять на использование памяти
и быстродействие. Следует всегда измерять результаты оптимизации с помощью нагрузочных тестов (производительности) и на основе данных профилирования, чтобы обеспечивать желаемый эффект от вносимых изменений.
Применяя эти рекомендованные методы и принципы управления памятью, вы получите необходимые инструменты для разработки эффективного,
надежного и защищенного исходного кода на Rust. Управление памятью –
288
Управление памятью и указатели
это стержневой элемент программирования, а уникальные функциональные
средства и защитные гарантии Rust делают его мощным инструментом для
эффективного проведения операций с памятью.
Продвинутое управление памятью
Теперь, когда мы рассмотрели основы управления памятью на Rust, пришло
время погрузиться в более сложные темы. В этом разделе мы проведем обследование конкретно-прикладных выделителей памяти, отображаемых в память
файлов, и способов взаимодействия Rust с другими языками программирования (как вы узнали из главы 1 «Системное программирование на языке Rust»).
Знакомство с этими темами расширит ваше понимание впечатляющих способностей Rust по управлению памятью.
Конкретно-прикладные выделители памяти
Стандартная библиотека Rust предоставляет дефолтный выделитель памяти,
но существуют сценарии, в которых требуется конкретно-прикладное управление памятью. Конкретно-прикладные выделители памяти позволяют конт
ролировать процессы выделения и высвобождения памяти, открывая возможности для специализированных вариантов использования.
Когда возникает потребность в конкретно-прикладном выделителе памяти?
Таблица 6.2. Варианты использования конкретно-прикладных выделителей памяти
Вариант использования
Описание
Системы реального
времени
Гарантирование предсказуемых времен выделения памяти
Ограниченные ресурсы
Оптимизация использования памяти для встраиваемых систем
Специализированные
типы данных
Эффективная реализация нестандартных структур данных
Создание конкретно-прикладного выделителя памяти предусматривает реализацию общей черты GlobalAlloc. В этой общей черте определены методы для
выделения, высвобождения и получения статистики памяти. Ниже перечислены основные шаги по созданию конкретно-прикладного выделителя памяти:
определить тип, реализующий общую черту GlobalAlloc;
реализовать необходимые методы, такие как alloc, dealloc и необязательные (например, realloc);
использовать атрибут #[global_allocator], чтобы назначить собственный выделитель памяти глобальным для всей программы.
Ниже представлен пример конкретно-прикладного выделителя памяти:
Листинг 6.25. Пример конкретно-прикладного выделителя памяти
use std::alloc::{GlobalAlloc, Layout};
use std::ptr;
struct MyAllocator;
Продвинутое управление памятью 289
unsafe impl GlobalAlloc for MyAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// Здесь реализуется логика выделения памяти
// Вернуть указатель на выделенную память.
let size = layout.size();
if size == 0 {
return ptr::null_mut();
}
// В демонстрационных целях используется простой глобальный буфер памяти.
// Это лишь пример, он не пригоден для производственного использования.
static mut MEMORY: [u8; 4096] = [0; 4096];
static mut NEXT: usize = 0;
let align = layout.align();
// Выравнивание указателя
let aligned_next = (NEXT + align - 1) & !(align - 1);
if aligned_next + size <= MEMORY.len() {
NEXT = aligned_next + size;
&mut MEMORY[aligned_next] as *mut u8
} else {
ptr::null_mut()
}
}
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// Здесь реализуется логика высвобождения памяти.
// В данном примере выделитель не поддерживает высвобождение памяти.
}
#[global_allocator]
static GLOBAL_ALLOCATOR: MyAllocator = MyAllocator;
fn main() {
// Выделить память через конкретно-прикладной выделитель
let size = 64;
let layout = Layout::from_size_align(size, 8).unwrap();
let ptr = unsafe { GLOBAL_ALLOCATOR.alloc(layout) };
if !ptr.is_null() {
println!("Память выделена по адресу: {:?}", ptr);
} else {
println!("Ошибка выделения памяти");
}
}
// Высвобождение памяти (в данном примере не поддерживается)
// Вывод:
// Память выделена по адресу: 0x557080d0a069
В Rust есть возможность настраивать управление памятью индивидуально в соответствии со своими специфическими требованиями. Это позволяет
оптимизировать приложения, требующие минимального запаздывания, снижать фрагментацию памяти или бесшовно интегрировать с внешними системами управления ресурсами.
290
Управление памятью и указатели
Отображение файлов в память
Отображение файлов в память предоставляет механизм, который позволяет отображать/проецировать содержимое файла напрямую в адресное пространство
процесса. Это дает возможность работать с огромными наборами данных или файлами, размер которых превышает объем доступной оперативной памяти (ОЗУ).
В стандартной библиотеке Rust поддержка таких файлов реализована через
тип std::fs::File и метод mmap, а само отображение обычно выполняется с помощью сторонних библиотек, таких как memmap2.
Cтек
Куча
SSD
Область отображения в память
Содержимое
файла
Адрес отображения
в память
Отображение
в память
адрес
Содержимое
файла
ОЗУ
Рисунок 6.13. Простая иллюстрация файлов, отображаемых в память
Для создания отображения файла в память на Rust необходимо выполнить
следующие шаги:
открыть файл, используя std::fs::File;
определить порцию файла, которую требуется отобразить, и нужный режим доступа (только для чтения или чтение–запись);
использовать пакет memmap (или актуальный memmap2) для создания отображенного в память вида/взгляда на файл.
Ниже представлен простой пример отображения файла в память для чтения:
Листинг 6.26. Пример использования отображаемых файлов
use std::fs::File;
use std::io::{self, Read};
use memmap::Mmap;
fn main() -> io::Result<()> {
// ① Открыть файл, содержащий слово “Hello”, для чтения
let mut file = File::open("data.txt")?;
// ② Получить длину файла
let file_length = file.metadata()?.len();
Продвинутое управление памятью 291
// ③ Проверяем, что длина файла не равна нулю.
if file_length == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Для отображения в память файл должен иметь ненулевую длину",
));
}
// ④ Отобразить файл в память
let mmap = unsafe { Mmap::map(&file)? };
// ⑤ Получить доступ к данным в памяти
println!("{:?}", &mmap);
Ok(())
}
// Вывод:
// Mmap { ptr: 0x7f0ba6513000, len: 5 }
Отображаемые файлы полезны в таких сценариях, как работа с огромными
журнальными файлами, хранилищами баз данных или эффективная реализация конкретно-прикладных форматов хранения данных. При использовании
этого механизма следует проявлять осмотрительность, так как он напрямую
влияет на системную память и файловый ввод-вывод.
Операционная совместимость с другими языками
Как вы увидели в главе 1 «Системное программирование на языке Rust», способности Rust по управлению памятью выходят за рамки самого языка. Rust может
операционно взаимодействовать с другими языками программирования, такими
как C и C++, позволяя использовать существующие библиотеки и кодовые базы.
Для привязки к исходному коду, написанному на других языках, в Rust предлагается ключевое слово extern и интерфейс с внешними функциями (foreign
function interface, аббр. FFI). Данный интерфейс позволяет компилятору Rust
вызывать функции, написанные на языке C или других языках, и наоборот.
При работе с интерфейсом с внешними функциями следует учитывать следующие факторы:
Таблица 6.3. Факторы, учитываемые при работе с интерфейсом с внешними функциями (FFI)
Фактор
Защищенность
Описание
Вызовы по FFI изначально не защищены и должны помечаться как unsafe
Совместимость прикладных Обеспечивать, чтобы Rust и целевой язык имели совместидвоичных интерфейсов (ABI) мые прикладные двоичные интерфейсы
Конвертации типов
Обрабатывать конвертации между типами Rust и внешними
типами
Обработка ошибок
Устанавливать механизмы обработки ошибок для межъязыковых вызовов
Ниже приведен пример вызова функции на языке C из Rust:
292
Управление памятью и указатели
Листинг 6.27. Пример FFI
extern "C" {
fn c_function(arg: i32) -> i32;
}
fn main() {
// Вызов функции из C внутри блока unsafe
let result = unsafe { c_function(42) };
println!("Результат из функции на языке C: {}", result);
}
При использовании интерфейса с внешними функциями следует скрупулезно управлять выделением и высвобождением памяти, так как принятая в Rust
система владения может отличаться от систем в других языках. Для защищенных конвертаций строковых литералов и типов в Rust и C рекомендуется использовать модуль std::ffi.
Операционная совместимость позволяет задействовать мощь Rust по управлению памятью в существующих проектах или применять проверенные временем сторонние библиотеки.
Лучшие образцы практики управления памятью
В завершение нашего обследования механизмов управления памятью в Rust
давайте подведем итог и перечислим основные рекомендации и правила,
о которых стоит помнить:
Лучший образец
практики
Описание
Приоритет выделения
памяти на стеке
Везде, где это возможно, использовать стек с целью повышения
эффективности и защищенности
Использование ссылок
Использовать ссылки и заимствование для доступа к данным
без передачи владения
Сопоставление
с шаблоном
Применять сопоставление с шаблоном и деструктуризацию для
сложных манипуляций данными
Стратегии
заимствования
Выбирать между мутируемыми и немутируемыми ссылками
в зависимости от потребностей доступа к данным
Аннотирование времен
жизни
Использовать явные аннотации времен жизни для обеспечения
ясности и защищенности исходного кода
Избегание висячих
ссылок
Обеспечивать, чтобы ссылки оставались валидными на протяжении всего времени их использования
Защита от гонок
за доступом к данным
Использовать примитивы синхронизации с целью предотвращения гонок за доступ к данным в многопоточном исходном коде
Документирование
незащищенного
исходного кода
Четко документировать принятые допущения и защитные меры
внутри блоков unsafe
Профилирование
и оптимизация
Использовать инструменты профилирования с целью выявления узких мест в производительности и оптимизации использования памяти
Заключение 293
Следуя этим образцам практики и постоянно углубляя свои знания в области управления памятью на Rust, вы будете оснащены всеми необходимыми
средствами для разработки высокопроизводительного, защищенного и надежного программного обеспечения.
Заключение
В этой главе мы обследовали тонкости управления памятью и работы с указателями на Rust. Мы рассмотрели роль управления памятью в разработке программного обеспечения и то, как философия Rust строится вокруг эффективного использования ресурсов, защищенности и надежности.
Мы углубились в механизмы выделения памяти в стеке и куче, разобравшись в том, как компилятор Rust оптимизирует использование памяти для
различных сценариев. Вы узнали о роли стека в управлении локальными переменными и вызовами функций, а также о роли кучи в обработке данных с динамическими временами жизни.
Были представлены концепции владения, заимствования и времен жизни,
подчеркивающие строгие правила Rust по обеспечению защищенности памяти.
Вы увидели, как в Rust гарантируется защищенность без необходимости сборки
мусора, устраняя такие проблемы, как утечки памяти и гонки за доступ к данным.
Центральное место заняли умные указатели: Box, Rc, Arc, Atomic*, RwLock,
Mutex и другие, предлагающие решения для коллективного владения и подсчета ссылок. Умный указатель Box позволяет выделять память в куче с автоматическим высвобождением, тогда как умные указатели Rc и Arc облегчают коллективное владение с помощью подсчета ссылок, обеспечивая защищенность
даже в многопоточных средах.
Мы также обследовали тему незащищенного Rust, в котором разработчик
получает больше контроля над операциями с памятью, но берет на себя ответственность за обеспечение ее защищенности. Вы обследовали использование
незащищенных (unsafe) функций и общих черт, а также узнали лучшие образцы практики написания незащищенного исходного кода.
Наконец, мы погрузились в продвинутые темы: конкретно-прикладные выделители памяти, отображаемые файлы и операционная совместимость с другими языками программирования. Каждая из этих тем расширяет ваше понимание способностей Rust и дает ценные инструменты для сложных сценариев
управления памятью.
Независимо от того, над чем вы работаете, будь то разработка системного
программного обеспечения, веб-приложений или встраиваемых устройств,
возможности управления памятью на Rust станут вашим надежным спутником. Ощутите мощь Rust и продолжайте расширять границы возможного.
Продолжая изучать Rust, вы обнаружите, что механизмы управления памятью и указателями позволяют писать высокопроизводительный, защищенный
и эффективный исходный код. В следующих главах мы погрузимся еще глубже,
обследовав продвинутые темы, шаблоны программирования и технические
приемы, которые поднимут ваши умения программировать на Rust на новую
высоту. Приготовьтесь к захватывающему изложению!
Глава 7
Управление конкурентностью
Введение
Конкурентность является краеугольным камнем современной разработки
программного обеспечения, позволяя программам выполнять несколько заданий конкурентно, что в конечном итоге повышает производительность
и отзывчивость программ. Эта глава проведет нас по сложностям управления
конкурентностью на Rust. Мы углубимся в использование потоков исполнения
и примитивов синхронизации, вооружив вас знаниями о том, как создавать
надежные и эффективные конкурентные приложения.
В предыдущих главах мы уже касались таких фундаментальных концепций, как
конкурентность и умные указатели, кратко обозначив их значимость. Однако эта
глава знаменует собой глубокое погружение в обширную тему конкурентного программирования на Rust. Наше обследование начнется с детального изучения поведения потоков исполнения и анализа методов, необходимых для их создания.
Прежде чем переходить к тонкостям конкурентного программирования, необходимо сформировать четкое понимание механизма работы потоков исполнения и приемов инициализации их экземпляров. Потоки исполнения – это
«строительные блоки» конкурентности, позволяющие нескольким заданиям
исполняться конкурентно в рамках одной программы. В последующих разделах мы проведем обследование основ работы с потоками исполнения на Rust,
включая их создание, управление и синхронизацию, что заложит фундамент
для более сложных концепций конкурентного программирования.
Содержание главы
В этой главе будут рассмотрены следующие темы:
понимание конкурентного программирования с использованием потоков исполнения и их синхронизации;
более глубокое знакомство с конкурентными структурами данных: примитивами Mutex и RwLock;
методы обмена данными и передачи сообщений между потоками исполнения на Rust.
Понимание конкурентного программирования
Конкурентность в разработке программного обеспечения означает способность программы управлять несколькими заданиями одновременно, позволяя
Параллелизм против конкурентности 295
им выполняться либо последовательно (путем переключения контекста), либо
параллельно. В Rust для этого используются потоки исполнения.
Работа потоков исполнения в Rust основана на идеях владения, заимствования и времен жизни. Представьте себе, что у каждого потока исполнения есть
свое небольшое пространство для хранения и инструменты, которые он может использовать. Компилятор Rust следит за тем, чтобы когда потоки делятся
чем-либо / коллективно используют что-то, они делали это защищенно и осторожно во избежание проблем.
Вот немного подробнее.
Владение: представьте владение как ответственность за что-либо. В Rust
каждый элемент данных (например, число или текст) в конкретный момент времени может принадлежать только одному потоку исполнения.
Это значит, что только один поток имеет над ним контроль.
Заимствование: заимствование похоже на просьбу воспользоваться
чем-то, что принадлежит другому. Потоки исполнения могут просить
временно использовать данные другого потока, но они должны следовать правилам, чтобы не испортить эти данные для остальных.
Времена жизни: времена жизни подобны таймерам, определяющим
продолжительность, в течение которой можно использовать ту или иную
вещь. Компилятор Rust отслеживает продолжительность, с которой потоки исполнения используют коллективные данные, чтобы предотвращать любые ошибки или конфликты.
Итак, в Rust потоки исполнения можно представить как маленьких работников. У каждого работника есть свои инструменты, и они могут просить
друг друга о помощи, но им нужно быть очень осторожными и соблюдать
важные правила, чтобы обеспечивать защищенность всего, чем они пользуются. Компилятор Rust выступает в роли начальника, который присмат
ривает за работниками, следя за тем, чтобы они соблюдали правила и не
создавали проблем.
Параллелизм против конкурентности
Прежде чем мы начнем обсуждать механизмы выполнения нескольких дел
в одно и то же время, важно понять две родственные, но разные идеи: параллелизм и конкурентность.
Рисунок 7.1. Конкурентные потоки исполнения против параллельных заданий
296 Управление конкурентностью
Параллелизм означает выполнение нескольких заданий в один и тот же момент времени с целью ускорения работы, особенно при использовании нескольких частей «мозга» компьютера (именуемых ядрами ЦП). Обычно это
предусматривает работу над заданиями, которые не зависят друг от друга.
С другой стороны, конкурентность – это более широкое понятие, которое
включает в себя управление несколькими заданиями, которые могут происходить как друг за другом, так и в одно и то же время. Это похоже на жонглирование разными делами сразу: например, вы отвечаете на электронные письма
и при этом слушаете музыку. В программировании конкурентность означает
работу с заданиями, которые не всегда происходят по порядку, и обеспечение
обмена информацией и ресурсами без возникновения проблем.
В этой главе мы проведем обследование как параллелизма, так и конкурентности. А теперь давайте начнем с понимания механизмов работы потоков исполнения в Rust.
Порождение потоков исполнения и управление ими
В Rust работа с потоками исполнения довольно проста. Потоки исполнения
можно представить как умелых помощников, которые могут выполнять задания независимо друг от друга. Для работы с ними существует удобная функция
под названием thread::spawn. Эта функция позволяет порождать (создавать)
нового помощника (поток исполнения) и давать ему указания о том, какую
именно работу нужно сделать.
Листинг 7.1. Пример создания потоков исполнения и управления ими
use std::thread;
fn main() {
let handle = thread::spawn(|| { // ①
// Здесь располагается исходный код нового потока исполнения
});
}
handle.join().unwrap();
// ②
В приведенном выше фрагменте исходного кода:
① новый поток исполнения порождается с помощью функции thread::spawn.
В замыкании внутри этой функции заключен исходный код, предназначенный для исполнения в этом только что созданном потоке. Указанное замыкание содержит конкурентное задание, которое мы хотим выполнить;
② после порождения нового потока мы используем метод handle.join(),
чтобы он гарантированно завершил свое исполнение, после чего главный поток исполнения продолжит работу дальше.
Понимание нюансов порождения и управления потоками исполнения имеет решающее значение для эффективного конкурентного программирования
на Rust. Овладение этими концепциями позволит раскрыть всю мощь конкурентности, обеспечивая при этом защищенное и устойчивое к ошибкам исполнение исходного кода.
Конкурентные структуры данных 297
Коллективное использование данных
между потоками исполнения
Одним из важнейших аспектов конкурентного программирования является
способность коллективно использовать данные между потоками в защищенном режиме. Rust предоставляет для этого мощный механизм, основанный
на концепциях владения и заимствования.
Рассмотрим пример коллективного использования данных между потоками
на основе встроенной в Rust модели владения:
Листинг 7.2. Пример коллективного использования
данных между потоками исполнения
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
// ①
let handle = thread::spawn(move || { // ②
// Здесь располагается исходный код для нового потока исполнения
println!("{:?}", data);
// ③
});
}
handle.join().unwrap();
// Вывод:
// [1, 2, 3, 4, 5]
В приведенном выше фрагменте исходного кода:
① мы создаем вектор data, содержащий несколько целых чисел;
② при порождении нового потока исполнения с помощью статической
функции thread::spawn мы перемещаем право владения переменной data в новый поток, используя ключевое слово move. Это означает, что
теперь новый поток имеет исключительный доступ к переменной data,
а главный поток исполнения больше не может к ней обращаться;
③ в замыкании внутри нового потока исполнения можно защищенно обращаться к содержимому переменной data и выводить его на экран.
Этот пример демонстрирует, как используемая в Rust система владения гарантирует, что доступ к данным, коллективно используемым потоками исполнения, осуществляется контролируемым и защищенным образом. Это предотвращает гонки за доступ к данным и другие ошибки работы с конкурентностью.
Конкурентные структуры данных
Хотя система владения и заимствования в Rust помогает предотвращать гонки
за доступ к данным, иногда возникает необходимость в явной координации
действий между потоками исполнения. Для этой цели в Rust предоставлены
различные примитивы синхронизации, такие как мьютексы и каналы.
298 Управление конкурентностью
Коллективный ресурс
Поток
исполнения 1
Ожидать
Ожидать
Данные
Высвободить
Поток
исполнения 2
Высвободить
Рисунок 7.2. Коллективный ресурс потоков исполнения посредством мьютекса
Мьютекс (сокращение от mutual exclusion – «взаимное исключение») – это
фундаментальный примитив синхронизации в Rust, который играет решающую роль в конкурентном программировании. Он позволяет нескольким потокам исполнения координировать свои действия и защищенно использовать
коллективные данные, гарантируя, что только один поток может иметь доступ
к защищенному ресурсу в конкретный момент времени. Благодаря этому предотвращаются гонки за доступ к данным и сохраняется целостность данных
в многопоточных приложениях.
В Rust мьютекс (Mutex) – это и конкурентная структура данных, и умный указатель,
и обертка, но с важными уточнениями. Его правильнее всего называть контейнеромсинхронизатором. С технической точки зрения std::sync::Mutex<T> – это структура,
которая обертывает данные типа T. Это «обертка», которая предоставляет внутреннюю
мутируемость. Сам мьютекс не является умным указателем в классическом понимании
(как Box или Rc), но при вызове метода lock() он возвращает MutexGuard, который является чистым умным указателем.
В последующих разделах мы всесторонне рассмотрим структуру данных Mutex
как примитив синхронизации в Rust, тщательно изучив ее сложную природу.
Мы начнем с базовых концепций и постепенно перейдем к более сложным сценариям, оснастив вас знаниями, необходимыми для эффективного использования
мьютекса при синхронизации потоков исполнения и управлении коллективными
данными. Кроме того, мы проведем обследование стратегий предотвращения взаимных блокировок и разберем приемы щадящей обработки исключительных ситуаций, которые могут возникать в процессе конкурентного программирования.
К концу этого обследования у вас будет четкое понимание причин, по которым
Mutex является ценным инструментом в вашем арсенале программиста на Rust.
Базовое использование контейнера-синхронизатора Mutex
Контейнер-синхронизатор Mutex (сокращение от mutual exclusion – «взаимное исключение») – это примитив синхронизации в Rust, который помогает
защищать коллективные данные при работе нескольких потоков исполнения.
Он гарантирует, что в конкретный момент времени только один поток исполнения может иметь доступ к защищенным данным, что предотвращает гонки
за доступ к данным.
Контейнер-синхронизатор Mutex является критически важным компонентом стандартной библиотеки Rust (находится в модуле std::sync) и играет
ключевую роль в разработке конкурентных программ. По своей сути Mutex
Конкурентные структуры данных 299
предоставляет две жизненно важные функции: lock (блокировать, заперев
на замок) и unlock (разблокировать, сняв замок).
Метод lock служит средством обеспечения исключительного доступа к данным,
которые охраняются мьютексом. Когда поток исполнения приобретает замок посредством метода lock, это гарантирует, что никакие другие потоки не смогут
конкурентно изменять охраняемые данные или обращаться к ним. Такой механизм синхронизации необходим для предотвращения гонок за доступ к данным
и обеспечения упорядоченного исполнения конкурентного исходного кода.
Кроме того, мьютекс защищает от потенциальных утечек ресурсов, автоматически снимая замок, когда сторож мьютекса (обычно представленный
переменной) выходит из диапазона доступности. Этот механизм автоматического освобождения упрощает управление замками и снижает шансы непреднамеренного возникновения взаимных блокировок или других проблем синхронизации в программах на Rust. Таким образом, мьютекс является ценным
инструментом для конструирования надежных и потокозащищенных конкурентных приложений.
Ниже приведен простой пример базового применения мьютекса:
Листинг 7.3. Пример применения мьютекса
use std::sync::Mutex;
fn main() {
let data = Mutex::new(42);
{
// ①
let mut guard = data.lock().unwrap(); // ②
*guard += 1;
// ③
} // «Сторож» выходит из диапазона доступности, снимая замок с мьютекса
}
println!("Данные после изменения: {:?}", *data.lock().unwrap()); // ④
Вывод:
// Данные после изменения: 43
В приведенном выше примере:
① мы создаем защищенную мьютексом структуру данных с начальным
значением 42;
② запираем мьютекс на замок с помощью вызова метода lock(), чтобы получить мутируемую ссылку на данные;
③ внутри запертой секции мы изменяем данные, увеличивая их значение
на единицу;
④ после того как сторож (объект guard типа MutexGuard) вышел из диапазона
доступности, мы снова запираем мьютекс на замок, чтобы получить доступ к измененным данным.
Мьютекс – это мощный инструмент управления коллективными данными
в конкурентных программах, и овладение приемами его использования играет крайне важную роль для написания защищенного и эффективного многопоточного исходного кода на Rust.
300
Управление конкурентностью
Коллективное использование данных между потоками
Одним из основных вариантов использования мьютекса в Rust является защищенное коллективное использование данных между несколькими потоками исполнения. В многопоточной среде крайне важно координировать доступ
к коллективным ресурсам во избежание гонок за доступ к данным и для того,
чтобы обеспечивать целостность данных.
Для достижения защищенного коллективного использования данных между потоками исполнения контейнер-синхронизатор Mutex можно комбинировать с умным указателем Arc (atomic reference counter – атомарный счетчик
ссылок), что позволяет нескольким потокам исполнения коллективно владеть
данными, охраняемыми мьютексом. Эта комбинация обеспечивает одновременно и потокозащищенность, и подсчет ссылок, гарантируя, что данные «переживут» потоки исполнения, использующие эти данные.
Давайте рассмотрим пример, в котором несколько потоков исполнения увеличивают счетчик с помощью мьютекса:
Листинг 7.4. Пример коллективного использования данных между потоками
use std::sync::{Mutex, Arc};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(0)); // ①
let mut handles = vec![];
// ②
for _ in 0..5 {
let data = Arc::clone(&data);
// ③
let handle = thread::spawn(move || {
let mut guard = data.lock().unwrap(); // ④
*guard += 1;
// ⑤
});
handles.push(handle);
// ⑥
}
for handle in handles {
handle.join().unwrap();
}
}
// ⑦
println!("Итоговое значение счетчика: {:?}", *data.lock().unwrap()); // ⑧
Вывод:
// Итоговое значение счетчика: 5
В приведенном выше примере:
① мы создаем охраняемый мьютексом счетчик, инициализированный нулем,
и оборачиваем его в умный указатель Arc для коллективного владения;
② инициализируем вектор для хранения дескрипторов потоков исполнения для последующей синхронизации;
③ внутри цикла потоков исполнения клонируем умный указатель Arc (и,
следовательно, Mutex), чтобы предоставить каждому потоку доступ к защищенному счетчику;
Конкурентные структуры данных 301
④ запираем мьютекс на замок, чтобы получить исключительный доступ
к счетчику;
⑤ внутри запертой секции каждый поток исполнения увеличивает значение счетчика на единицу;
⑥ дескрипторы потоков исполнения сохраняются в векторе handles для
дальнейшего использования;
⑦ ждем завершения исполнения всех потоков с помощью метода join;
⑧ в конце снова запираем мьютекс на замок, чтобы получить доступ к окончательному значению счетчика и вывести его на экран.
Коллективное использование данных между потоками с помощью контейнера-синхронизатора Mutex и умного указателя Arc – это мощный способ задействовать функциональные средства конкурентности в Rust, поддерживая
при этом защищенность и целостность данных.
Предотвращение взаимных блокировок
Взаимные блокировки возникают в ситуациях, когда потоки исполнения зависают в бесконечном ожидании, пока другой поток снимет замок с мьютекса,
что приводит к полной остановке программы. В конкурентном программировании крайне важно не допускать взаимных блокировок.
Поток
исполнения 1
Поток
исполнения 2
Желает
получить
Заперт
Ресурс 1
Заперт
Ресурс 2
Рисунок 7.3. Пример взаимной блокировки в Rust
Рассмотрим следующий ниже пример, демонстрирующий потенциальную
взаимную блокировку:
Листинг 7.5. Пример предотвращения взаимных блокировок в мьютексах
use std::sync::{Mutex, Arc};
use std::thread;
fn main() {
// переменные, которые удерживают умный указатель типа Arc,
// внутри которого находится мьютекс, защищающий данные
let data1 = Arc::new(Mutex::new(0)); // ①
let data2 = Arc::new(Mutex::new(0)); // ②
let data1_final = Arc::new(Mutex::new(0));
302
Управление конкурентностью
let data2_final = Arc::new(Mutex::new(0));
let handles = vec![
thread::spawn({
let data1_clone = Arc::clone(&data1); // ③
let data2_clone = Arc::clone(&data2); // ④
move || {
let mut guard1 = data1_clone.lock().unwrap();
*guard1 += 1;
let mut guard2 = data2_clone.lock().unwrap();
*guard2 += 1;
(1, 0)
}
}),
thread::spawn({
let data1_clone = Arc::clone(&data1);
move || {
let mut guard1 = data1_clone.lock().unwrap();
*guard1 += 1;
(0, 1)
}
}),
];
// ⑤
// ⑥
// ③
// ⑦
let results: Vec<(i32, i32)> = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect();
let final_value1: i32 = results.iter().map(|&(val1, _)| val1).sum();
let final_value2: i32 = results.iter().map(|&(_, val2)| val2).sum();
{
}
let mut guard1_final = data1_final.lock().unwrap(); // ⑧
let mut guard2_final = data2_final.lock().unwrap(); // ⑨
*guard1_final = *data1.lock().unwrap() + final_value1;
*guard2_final = *data2.lock().unwrap() + final_value2;
println!(
"Окончательные значения: data1 = {}, data2 = {}",
*data1_final.lock().unwrap(),
*data2_final.lock().unwrap()
);
}
// Вывод:
// Окончательные значения: data1 = 3, data2 = 2
В приведенном выше исходном коде:
① мы создаем первую структуру данных, защищенную мьютексом;
② создаем вторую структуру данных, защищенную мьютексом;
③ первый поток исполнения клонирует переменные data1 и data2 для доступа к их значениям;
④ второй поток исполнения клонирует переменную data1 для доступа к ее
значению;
Конкурентные структуры данных 303
⑤ внутри первого потока сторож guard1 запирает клонированные данные data1_clone на замок;
⑥ внутри первого потока сторож guard2 запирает клонированные данные data2_clone на замок;
⑦ внутри второго потока сторож guard1 запирает клонированные данные data1_clone на замок;
⑧ сторож guard1_final запирает клонированные данные data1_final на замок;
⑨ сторож guard2_final запирает клонированные данные data2_final на замок.
Во избежание взаимных блокировок крайне важно соблюдать последовательность запирания нескольких мьютексов.
Владение сторожами мьютекса
Сторожи мьютекса, получаемые посредством метода lock(), обеспечивают
соблюдение принятых в Rust правил владения, гарантируя, что в конкретный момент времени изменять данные может только один поток исполнения.
За счет этого обеспечивается потокозащищенность и предотвращаются гонки
за доступом к данным.
Рассмотрим владение сторожем мьютекса на практике:
Листинг 7.6. Пример владения сторожем мьютекса
use std::sync::Mutex;
fn main() {
let data = Mutex::new(vec![1, 2, 3]);
{
// ①
let mut guard = data.lock().unwrap(); // ②
guard.push(4);
// ③
} // Сторож выходит из диапазона доступности, снимая замок с мьютекса
// Попытка изменить данные вне диапазона доступности сторожа
// let mut guard = data.lock().unwrap(); // Эта строка не компилируется
}
println!("Данные: {:?}", *data.lock().unwrap()); // ④
// Вывод:
// Данные: [1, 2, 3, 4]
В приведенном выше примере:
① мы создаем вектор, защищенный мьютексом;
② запираем мьютекс на замок, чтобы получить исключительный доступ
к вектору;
③ внутри запертой секции изменяем вектор, добавляя в него элемент;
④ попытка изменить данные за пределами диапазона доступности сторожа привела бы к ошибке компиляции, так как компилятор Rust
строго соблюдает правила владения с целью обеспечения потокозащищенности.
304
Управление конкурентностью
Сторожи мьютекса гарантируют защищенность данных, соблюдая принцип
владения и не позволяя нескольким потокам исполнения изменять данные
одновременно.
Обработка скомпрометированных мьютексов
Мьютекс может переходить в скомпрометированное («отравленное») состоя
ние, если в потоке исполнения, который удерживал замок этого мьютекса,
произошла паника (panic!). Такое состояние сигнализирует о потенциальном
повреждении данных и нуждается в специальной обработке.
Рассмотрим следующий ниже пример:
Листинг 7.7. Пример обработки скомпрометированных мьютексов
use std::sync::{Mutex, Arc};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(0)); // ①
let mut handles = vec![];
for _ in 0..5 {
let data = Arc::clone(&data);
// ②
let handle = thread::spawn(move || {
let mut guard = data.lock().unwrap(); // ③
if *guard == 3 {
println!("Поток достиг значения 3, остановка.");
return; // Не паниковать, просто выйти из потока исполнения
}
*guard += 1;
});
handles.push(handle); // ④
}
for handle in handles {
handle.join().unwrap();
}
let result = data.lock();
match result {
// ⑤ Если все в порядке, то распечатать значение
Ok(guard) => println!("Окончательное значение счетчика: {:?}", *guard),
// ⑥ Если мьютекс скомпрометирован, то восстановить данные
Err(poisoned) => {
let guard = poisoned.into_inner();
println!("Мьютекс отравлен. Восстановленное значение: {:?}", *guard);
}
}
}
//
//
//
//
Вывод:
Поток достиг значения 3, остановка.
Поток достиг значения 3, остановка.
Окончательное значение счетчика: 3
Конкурентные структуры данных 305
В приведенном выше примере:
① мы создаем защищенную мьютексом структуру данных с помощью std::sync::Mutex, обернутую в умный указатель Arc, для коллективного владения;
② внутри каждого потока исполнения клонируем Arc и Mutex, чтобы обес
печить доступ к защищенным данным;
③ используем метод data.lock().unwrap(), чтобы запереть мьютекс на замок и получить исключительный доступ;
④ каждый поток исполнения увеличивает счетчик на единицу (в реальных
сценариях для обработки потенциальных паник можно использовать метод unwrap_or_else);
⑤ по завершении всех потоков исполнения пытаемся снова запереть мьютекс на замок и вывести окончательное значение на экран;
⑥ если мьютекс находится в скомпрометированном («отравленном») состоянии, то извлекаем данные с помощью метода into_inner() и обрабатываем ситуацию соответствующим образом.
Продвинутое использование мьютекса
Мьютексы – это фундаментальный примитив синхронизации в Rust, который
играет решающую роль в управлении коллективными данными и обеспечении
потокозащищенности в конкурентных программах. Хотя мы уже рассмотрели
основы использования мьютекса, данный раздел погрузит вас в более сложные сценарии. Мы разберем приемы использования мьютекса для реализации
комплексных конкурентных структур данных и решения продвинутых задач
синхронизации.
Условное запирание на замок
Структура std::sync::Condvar (сокращение от condition variable – условная переменная) – это продвинутый инструмент синхронизации, который позволяет
потокам исполнения ожидать соблюдения определенного условия, перед тем
как продолжить работу. Она часто используется в связке с мьютексом с целью
реализации сложных шаблонов синхронизации, таких как сценарии «производитель–потребитель».
Ниже приведен упрощенный пример реализации шаблона «производитель–потребитель» с использованием структуры Condvar:
Листинг 7.8. Пример условного запирания на замок
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
fn main() {
// Создать коллективную структуру данных, используя Mutex и Condvar,
// обернутые в умный указатель Arc для многопоточного доступа
let data = Arc::new((Mutex::new(Vec::new()), Condvar::new())); // ①
let mut handles = vec![];
// ②
306
Управление конкурентностью
for i in 0..5 {
let data_clone = Arc::clone(&data);
}
// ③
// ④
let handle = thread::spawn(move || {
// ⑤
let (lock, cvar) = &*data_clone;
// ⑥
let mut data = lock.lock().unwrap(); // ⑦
data.push(i);
// ⑧
cvar.notify_one(); // ⑨
});
handles.push(handle); // ⑩
let (lock, cvar) = &*data; // ⑪
let mut data = lock.lock().unwrap();
while data.len() < 5 {
// ⑫
// Ожидание сигнала от Condvar, снимаем замок с мьютекса на время ожидания
data = cvar.wait(data).unwrap(); // ⑫
}
}
println!("Данные: {:?}", *data);
// ⑭
// Вывод:
// Данные: [0, 1, 2, 4, 3]
В приведенном выше примере:
① мы создаем кортеж из контейнера-синхронизатора Mutex и структуры Condvar, что является стандартной практикой в Rust;
② инициализируем вектор для хранения дескрипторов потоков исполнения;
③ порождаем пять потоков исполнения в цикле;
④ клонируем коллективную структуру данных для каждого потока исполнения;
⑤ порождаем новый поток исполнения с замыканием, которое обращается
к коллективным данным;
⑥ деструктурируем кортеж коллективных данных для доступа к Mutex
и Condvar. Операция &* используется для получения доступа к содержимому умного указателя Arc перед деструктуризацией;
⑦ запираем мьютекс на замок для получения доступа к коллективному вектору;
⑧ изменяем коллективный вектор путем добавления значения i;
⑨ уведомляем один ожидающий поток исполнения через Condvar;
⑩ сохраняем дескриптор потока исполнения в векторе;
⑪ деструктурируем кортеж коллективных данных вне цикла;
⑫ ожидаем в цикле до тех пор, пока вектор данных не будет содержать
5 элементов. Важно использовать именно while, а не if, так как потоки
исполнения могут просыпаться ложно (пробуждение без сигнала);
⑬ ожидаем сигнал от Condvar с освобождением мьютекса на время этого
ожидания;
⑭ выводим окончательные данные на экран после того, как все потоки исполнения завершат свою работу. Метод wait принимает объект-сторож
Конкурентные структуры данных 307
типа MutexGuard, временно снимает замок с мьютекса, чтобы другие потоки
могли работать, и автоматически забирает его обратно при пробуждении.
В этом примере структура Condvar используется для уведомления главного потока исполнения о том, что потоки-производители завершили свою часть работы.
Реализация защищенной мьютексом очереди
Мьютекс может действовать весьма эффективно в ситуациях, когда речь идет
о продвинутых трюках! Один из таких изящных трюков, на которые он способен, состоит в создании защищенной очереди, которую несколько потоков
исполнения могут использовать для одновременного добавления и удаления
элементов, не создавая при этом путаницы в данных или конфликтов. Ниже
приведен упрощенный пример создания очереди, защищенной мьютексом:
Листинг 7.9. Пример реализации очереди, защищенной мьютексом
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let queue = Arc::new(Mutex::new(Vec::new())); // ①
let mut handles = vec![];
for i in 0..5 {
let queue_clone = Arc::clone(&queue);
}
// ②
// ③
// ④
let handle = thread::spawn(move || {
// ⑤
let mut queue = queue_clone.lock().unwrap(); // ⑥
queue.push(i);
// ⑦
});
handles.push(handle); // ⑧
for handle in handles {
// ⑨
handle.join().unwrap(); // ⑩
}
}
let final_queue = queue.lock().unwrap();
println!("Окончательная очередь: {:?}", *final_queue); // ⑪
// Вывод:
// Окончательная очередь: [0, 2, 3, 1, 4]
В приведенном выше примере:
① мы создаем коллективную очередь с помощью примитива Mutex,
обернутого в умный указатель Arc для защищенного доступа из разных потоков исполнения;
② инициализируем вектор для хранения дескрипторов потоков исполнения;
③ порождаем пять потоков исполнения в цикле;
④ клонируем коллективную очередь для каждого потока исполнения;
308
Управление конкурентностью
⑤ создаем новый поток с замыканием, имеющим доступ к коллективной очереди;
⑥ запираем мьютекс на замок для получения доступа к очереди;
⑦ добавляем значения i в коллективную очередь;
⑧ сохраняем дескриптор потока исполнения в векторе;
⑨ ожидаем завершения всех порожденных потоков исполнения посредством присоединения их дескрипторов с помощью метода join;
⑩ снова запираем мьютекс на замок для доступа к окончательному состоянию очереди;
⑪ печатаем окончательную очередь для вывода на экран значений, добавленных потоками исполнения.
В приведенном выше исходном коде мы создаем защищенную мьютексом
очередь, используя контейнер-синхронизатор std::sync::Mutex и умный указатель std::sync::Arc для коллективного владения между потоками исполнения. Каждый поток заталкивает значение в очередь, а мьютекс гарантирует, что
только один поток будет иметь к ней доступ в конкретный момент времени.
Реализация защищенной мьютексом
очереди с приоритетом
Развивая идею очереди с мьютексом, давайте разберем еще более интересную концепцию: создание защищенной мьютексом очереди с приоритетом.
Очередь с приоритетом позволяет разным потокам исполнения заталкивать
элементы с определенными приоритетами в очередь и выталкивать их из нее
в порядке приоритетности. В стандартной библиотеке Rust нет готовой многопоточной очереди с приоритетом, но ее можно сделать самостоятельно, используя контейнер-синхронизатор Mutex и двоичную кучу (BinaryHeap).
Ниже приведен пример на Rust, иллюстрирующий реализацию очереди
с приоритетом, защищенной мьютексом:
Листинг 7.10. Пример реализации очереди
с приоритетом, защищенной мьютексом
use std::collections::BinaryHeap;
use std::sync::{Arc, Mutex};
use std::thread;
struct PriorityQueue<T> {
inner: Mutex<BinaryHeap<T>>,
}
// ①
impl<T: Ord> PriorityQueue<T> {
fn new() -> Self {
PriorityQueue {
inner: Mutex::new(BinaryHeap::new()), // ②
}
}
fn push(&self, item: T) {
Конкурентные структуры данных 309
}
let mut heap = self.inner.lock().unwrap(); // ③
heap.push(item);
fn pop(&self) -> Option<T> {
let mut heap = self.inner.lock().unwrap(); // ③
heap.pop()
}
}
fn main() {
let priority_queue = Arc::new(PriorityQueue::new()); // ④
let mut push_handles = vec![];
let mut pop_handles = vec![];
for i in (1..6).rev() {
let priority_queue_clone = Arc::clone(&priority_queue); // ⑤
let handle = thread::spawn(move || {
priority_queue_clone.push(i);
});
push_handles.push(handle);
}
for handle in push_handles {
handle.join().unwrap(); // ⑥
}
for _ in 0..5 {
let priority_queue_clone = Arc::clone(&priority_queue); // ⑤
let handle = thread::spawn(move || {
let popped = priority_queue_clone.pop();
match popped {
Some(val) => println!("Извлечено: {}", val),
None => println!("Очередь пуста!"),
}
});
pop_handles.push(handle);
}
}
//
//
//
//
//
//
for handle in pop_handles {
handle.join().unwrap(); // ⑥
}
Вывод:
Извлечено:
Извлечено:
Извлечено:
Извлечено:
Извлечено:
5
4
3
2
1
В приведенном выше примере:
① поле inner структуры PriorityQueue – это мьютекс, обертывающий структуру BinaryHeap. Данный мьютекс используется для обеспечения потокозащищенности при доступе к опорной структуре данных;
310
Управление конкурентностью
② в методе new создается новый экземпляр структуры PriorityQueue с пус
той двоичной кучей, обернутой в мьютекс, в результате чего инициализируется пустая очередь с приоритетом;
③ методы push и pop запирают мьютекс на замок для получения доступа
к структуре BinaryHeap. Этот механизм запирания гарантирует, что только один поток исполнения может изменять структуру данных или обращаться к ней в конкретный момент времени, что предотвращает порчу
данных в конкурентных сценариях;
④ в функции main используется умный указатель Arc (атомарный счетчик
ссылок) для создания коллективной ссылки на структуру PriorityQueue.
Это позволяет нескольким потокам исполнения защищенно обращаться
к одному и тому же экземпляру очереди;
⑤ внутри циклов создания и порождения потоков, чтобы заталкивать и выталкивать элементы в очередь, для каждого потока создается клон умной
ссылки Arc. Этот клон позволяет каждому потоку исполнения независимо обращаться к коллективной очереди и манипулировать ею;
⑥ после порождения всех потоков исходный код использует метод join для
каждого дескриптора, чтобы дожидаться завершения всех заданий. Эта
синхронизация гарантирует, что все потоки исполнения закончат работу
до завершения программы.
В приведенном выше примере мы создаем защищенную мьютексом очередь
с приоритетом, используя структуру BinaryHeap из стандартной библиотеки.
Несколько потоков исполнения конкурентно заталкивают элементы в очередь, а другие потоки выталкивают их из нее в порядке убывания приоритета.
Обработка взаимных блокировок с помощью мьютекса
Взаимные блокировки – распространенная проблема в конкурентном программировании. Примитив синхронизации Mutex в Rust предоставляет инструменты, помогающие избегать и обрабатывать их, например метод try_lock,
который пытается приобрести замок без блокировки. Ниже приведен пример,
демонстрирующий использование метода try_lock для обработки потенциальных взаимных блокировок:
Листинг 7.11. Пример обработки взаимных блокировок
с помощью синхронизатора Mutex
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let lock = Arc::new(Mutex::new(0));
let mut handles = vec![];
// ①
// ②
for _ in 0..5 {
let lock_clone = Arc::clone(&lock);
// ③
let handle = thread::spawn(move || {
// ④
let mut data = lock_clone.try_lock(); // ⑤
match data {
Конкурентные структуры данных 311
Ok(ref mut value) => {
**value += 1; // ⑥
}
Err(_) => {
println!("Не удалось приобрести замок, продолжаю..."); // ⑦
}
}
});
handles.push(handle);
}
// ⑧
for handle in handles {
handle.join().unwrap(); // ⑨
}
}
let final_data = lock.lock().unwrap();
// ⑩
println!("Окончательные данные: {}", *final_data); // ⑪
// Вывод:
// Окончательные данные: 5
В приведенном выше примере:
① создает коллективный Mutex, содержащий целое число с начальным значением 0;
② инициализирует вектор для хранения дескрипторов потоков исполнения;
③ клонирует умный указатель Arc для использования коллективного
Mutex несколькими потоками исполнения;
④ порождает новый поток исполнения для параллельной работы с Mutex;
⑤ пытается приобрести замок на мьютексе неблокирующим способом;
⑥ увеличивает целочисленное значение внутри мьютекса на единицу;
⑦ выводит сообщение на экран, если мьютекс уже заперт на замок другим
потоком исполнения;
⑧ сохраняет дескриптор потока исполнения в векторе для последующего
ожидания (присоединения посредством join);
⑨ ожидает завершения работы всех порожденных потоков исполнения;
⑩ запирает мьютекс на замок для доступа к окончательному коллективному целому числу;
⑪ выводит окончательное значение коллективного целого числа на экран.
В приведенном выше исходном коде мы пытаемся приобрести замок с помощью метода try_lock, и если замок приобрести невозможно, то поток исполнения
продолжает работу без блокирования. Это помогает предотвращать взаимные блокировки в ситуациях, когда приобретение замка не является критически важным.
Мьютекс в многопоточном сценарии
производитель–потребитель
Контейнеры-синхронизаторы Mutex часто используются в многопоточных
сценариях «производитель–потребитель» для синхронизации доступа к коллективному буферу. Ниже приводится пример, демонстрирующий шаблон
программирования «производитель–потребитель» на основе мьютекса:
312
Управление конкурентностью
Листинг 7.12. Мьютекс в многопоточном сценарии
производитель–потребитель
use std::sync::{Arc, Mutex};
use std::thread;
const BUFFER_SIZE: usize = 5;
fn main() {
let buffer = Arc::new(Mutex::new(Vec::new())); // ①
let mut handles = vec![];
// ①
// Потоки-производители
for i in 0..3 {
let buffer_clone = Arc::clone(&buffer);
// ②
let handle = thread::spawn(move || {
// ③
for j in 0..BUFFER_SIZE {
let mut buffer = buffer_clone.lock().unwrap(); // ④
buffer.push(i * BUFFER_SIZE + j); // ④
}
});
handles.push(handle); // ⑤
}
// Потоки-потребители
for _ in 0..2 {
let buffer_clone = Arc::clone(&buffer);
// ⑥
let handle = thread::spawn(move || {
// ⑦
for _ in 0..BUFFER_SIZE {
let mut buffer = buffer_clone.lock().unwrap(); // ⑧
if let Some(item) = buffer.pop() {
// ⑧
println!("Потреблено: {}", item); // ⑧
}
}
});
handles.push(handle); // ⑨
}
}
//
//
//
//
//
//
//
//
//
//
//
for handle in handles {
handle.join().unwrap(); // ⑩
}
Вывод:
Потреблено:
Потреблено:
Потреблено:
Потреблено:
Потреблено:
Потреблено:
Потреблено:
Потреблено:
Потреблено:
Потреблено:
14
13
12
11
10
9
8
7
6
5
Конкурентные структуры данных 313
В приведенном выше примере:
① создает коллективный мьютекс, обертывающий пустой вектор Vec, который представляет буфер. Инициализирует вектор для хранения дескрипторов потоков исполнения;
② клонирует умный указатель Arc для коллективного доступа к мьютексу
буфера между потоками-производителями;
③ порождает потоки-производители;
④ запирает мьютекс на замок для исключительного доступа к буферу
и вставляет элементы в буфер;
⑤ сохраняет дескрипторы потоков-производителей;
⑥ клонирует умный указатель Arc для потоков-потребителей;
⑦ порождает потоки-потребители;
⑧ запирает мьютекс на замок для исключительного доступа к буферу, выталкивает и потребляет элементы из буфера, выводя их на экран;
⑨ сохраняет дескрипторы потоков-потребителей;
⑩ ожидает завершения всех потоков-производителей и потоков-потребителей.
В этом примере несколько потоков-производителей добавляют элементы
в коллективный буфер, тогда как потоки-потребители удаляют и потребляют
элементы из этого же буфера. Мьютекс обеспечивает исключительный доступ
к буферу, предотвращая гонки за доступ к данным.
Описанные выше продвинутые примеры демонстрируют универсальность
мьютекса в Rust и приемы его применения для решения сложных задач синхронизации. Независимо от того, реализуете ли вы очереди с приоритетом,
боретесь с потенциальными взаимными блокировками или обследуете другие
продвинутые сценарии синхронизации, мьютекс в Rust закладывает основу
для построения защищенных и эффективных конкурентных программ.
Обследование контейнера-синхронизатора RwLock
В отличие от мьютекса, который предоставляет исключительный доступ к данным, контейнер-синхронизатор RwLock (замок на чтение–запись) позволяет
нескольким потокам исполнения конкурентно читать данные, обеспечивая
при этом исключительный доступ для операции записи (конкурентное чтение – эксклюзивная запись). Это бывает особенно полезно в ситуациях, когда
данные читаются часто, но изменяются редко.
Управление коллективными данными с помощью
примитива синхронизации RwLock
Контейнер-синхронизатор RwLock – это еще один примитив синхронизации
в Rust, который позволяет нескольким потокам исполнения конкурентно
читать данные, гарантируя при этом исключительный доступ для операции
записи. Он широко применяется в сценариях, в которых данные читаются
часто, но модифицируются редко.
314
Управление конкурентностью
Листинг 7.13. Управление коллективными данными
с помощью примитива RwLock
use std::thread;
use std::sync::{RwLock, Arc};
fn main() {
let data = Arc::new(RwLock::new(0));
let mut handles = vec![];
// ①
// ②
for _ in 0..5 {
let data = Arc::clone(&data);
// ③
let handle = thread::spawn(move || {
let data_guard = data.read().unwrap(); // ④
println!("Чтение: {}", *data_guard);
// ⑤
// Освобождаем блокировку на чтение здесь, чтобы позволить
// другим потокам читать одновременно
drop(data_guard);
thread::sleep(std::time::Duration::from_millis(100)); // ⑥
}
let mut data_write = data.write().unwrap();
*data_write += 1; // ⑧
});
handles.push(handle); // ⑨
// ⑦
for handle in handles {
handle.join().unwrap(); // ⑩
}
}
//
//
//
//
//
//
//
println!("Результат: {:?}", *data.read().unwrap()); // ⑪
Вывод:
Чтение: 0
Чтение: 0
Чтение: 0
Чтение: 0
Чтение: 0
Результат: 5
В приведенном выше фрагменте исходного кода:
① мы создаем структуру данных, защищенную контейнером-синхронизатором RwLock, используя std::sync::RwLock. Мы обертываем его в умный
указатель Arc, чтобы позволить нескольким потокам исполнения коллективно владеть в защищенном режиме;
② инициализируем вектор для хранения дескрипторов потоков исполнения;
③ внутри каждого потока клонируем Arc и RwLock для коллективного доступа;
④ приобретаем замок на чтение с помощью data.read().unwrap(), который
позволяет нескольким потокам исполнения конкурентно читать данные;
⑤ читаем и выводим данные на экран;
⑥ для симуляции какой-то работы вводим паузу, имитирующую длительную операцию;
Конкурентные структуры данных 315
⑦ после симуляции работы приобретаем замок на запись с помощью data.
write().unwrap(), обеспечивающий исключительный доступ для операции записи;
⑧ изменяем данные внутри структуры, защищенной примитивом синхронизации RwLock;
⑨ дескрипторы потоков исполнения сохраняются для последующего использования;
⑩ ожидаем завершения исполнения всех потоков;
⑪ наконец, выводим результат на экран – значение, хранящееся в данных,
защищенных примитивом RwLock.
Понимание приемов использования контейнера-синхронизатора RwLock
необходимо для эффективного управления коллективными данными в конкурентных программах на Rust.
Продвинутые варианты использования
контейнера-синхронизатора RwLock в Rust
Контейнер-синхронизатор RwLock в Rust предлагает целый ряд продвинутых
вариантов использования, выходящих за рамки базовых операций чтения
и записи. Он предоставляет гибкий набор инструментов для эффективной обработки конкурентного доступа к данным.
Динамическое количество читателей
Одной из мощных особенностей примитива RwLock является его способность
динамически контролировать количество читающих потоков исполнения.
В следующем ниже примере исходного кода мы покажем, как создавать ситуацию с переменным числом конкурентных читателей:
Листинг 7.14. Пример примитива RwLock с динамическим числом читателей
use std::sync::{RwLock, Arc};
use std::thread;
fn main() {
let data = Arc::new(RwLock::new(0));
let mut handles = vec![];
for i in 0..10 {
let data = Arc::clone(&data);
let handle = thread::spawn(move || {
let data_guard = data.read().unwrap();
println!("Читатель {}: {}", i, *data_guard);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
316
//
//
//
//
//
//
//
//
//
//
//
Управление конкурентностью
Вывод:
Читатель
Читатель
Читатель
Читатель
Читатель
Читатель
Читатель
Читатель
Читатель
Читатель
0:
2:
1:
7:
5:
3:
6:
4:
8:
9:
0
0
0
0
0
0
0
0
0
0
В этом сценарии примитив синхронизации RwLock позволяет создавать динамическое количество читающих потоков, которые могут получать конкурентный доступ к коллективным данным. Эта гибкость неоценима при разработке
приложений с изменяющейся нагрузкой и требованиями к конкурентности.
Запирание на замок по времени
Запирание на замок по времени с помощью контейнера-синхронизатора
RwLock вводит важный механизм обработки ситуаций, когда требуется установить максимальный лимит времени на операции чтения или записи. Эта
функциональность гарантирует, что потоки исполнения не застрянут на не
определенный срок в ожидании доступа к коллективным данным, что особенно важно в практических сценариях, в которых могут возникать непредсказуемые задержки или соревновательность за ресурсы.
Листинг 7.15. Пример запирания на замок
по времени с использованием RwLock
use
use
use
use
std::sync::{RwLock, Arc};
std::thread;
std::time::{Duration, Instant};
rand::Rng;
fn main() {
let data = Arc::new(RwLock::new(0));
let mut handles = vec![];
for i in 0..5 {
let data = Arc::clone(&data);
let handle = thread::spawn(move || {
let timeout_duration = Duration::from_secs(1);
let start_time = Instant::now();
// Рандомно определить, должен ли поток завершиться по тайм-ауту
let should_timeout = rand::thread_rng().gen::<bool>();
loop {
thread::sleep(Duration::from_millis(100)); // Короткий сон
if start_time.elapsed() >= timeout_duration {
if should_timeout {
println!("Тайм-аут потока {} истек.", i);
break; // Тайм-аут достигнут, выходим из цикла
Конкурентные структуры данных 317
} else {
if let Ok(data_guard) = data.read() {
println!(
"Поток {} успешно прочитал {}.",
i,
*data_guard
);
}
}
}
// Симулировать успешное приобретение замка одними потоками
// и неуспешное приобретение другими
if !should_timeout && start_time.elapsed().as_secs() >= 2 {
if let Ok(_data_guard) = data.read() {
println!("Поток {} приобрел замок.", i);
break; // Замок успешно приобретен, выходим из цикла
}
}
}
});
handles.push(handle);
}
}
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
for handle in handles {
handle.join().unwrap();
}
Вывод:
Поток 0
Поток 1
Поток 3
Поток 2
Поток 4
Поток 3
Поток 3
Поток 3
Поток 3
Поток 3
Поток 3
Поток 3
Поток 3
Поток 3
Поток 3
Поток 3
завершен по тайм-ауту.
завершен по тайм-ауту.
успешно прочитал 0.
завершен по тайм-ауту.
завершен по тайм-ауту.
успешно прочитал 0.
успешно прочитал 0.
успешно прочитал 0.
успешно прочитал 0.
успешно прочитал 0.
успешно прочитал 0.
успешно прочитал 0.
успешно прочитал 0.
успешно прочитал 0.
успешно прочитал 0.
приобрел замок.
В приведенном выше примере исходного кода мы иллюстрируем способ
реализации запирания по времени с использованием примитива синхронизации RwLock с тайм-аутом для операций чтения. Каждому потоку исполнения
назначается задание, включающее указанную длительность тайм-аута. Потоки
исполнения периодически проверяют, не превысило ли прошедшее время эту
длительность, и если да, то предпринимают соответствующие действия, такие
как завершение по тайм-ауту или, при его отсутствии, выполнение операции
чтения. Этот механизм позволяет щадяще обрабатывать ситуации, в которых
318
Управление конкурентностью
критически важен доступ к данным по времени, предотвращая возможные
взаимные блокировки или бесконечное ожидание.
Предотвращение взаимных блокировок
Предотвращение взаимных блокировок – это важнейший аспект конкурентного программирования, и Rust представляет мощный инструмент для достижения этой цели посредством использования контейнера-синхронизатора
RwLock. В представленном ниже фрагменте исходного кода RwLock используется
для предотвращения взаимных блокировок путем обеспечения гибких шаблонов доступа к коллективным данным. Взаимные блокировки возникают в ситуциях, когда несколько потоков исполнения соревнуются за ресурсы и в итоге
зависают в бесконечном ожидании, пока другой поток освободит ресурс, что
приводит к полной остановке программы.
В целях смягчения этой проблемы в исходном коде используются методы
try_read и try_write структуры RwLock, которые обеспечивают неблокирующий способ приобретения замков. Задействуя эти методы, программа может
пытаться получить замок на коллективные данные, не застревая в состоянии
ожидания. Если замок не доступен, то исходный код может щадяще обрабатывать ситуацию, например повторять попытку позже или предпринимать альтернативные действия.
Листинг 7.16. Пример предотвращения взаимных
блокировок с помощью примитива RwLock
use std::sync::{RwLock, Arc};
use std::thread;
fn main() {
let data1 = Arc::new(RwLock::new(0));
let data2 = Arc::new(RwLock::new(0));
let mut handles = vec![];
for _ in 0..5 {
let data1 = Arc::clone(&data1);
let data2 = Arc::clone(&data2);
let handle = thread::spawn(move || {
// Приобрести замки в определенном порядке
let mut data_guard1 = data1.write().unwrap();
let mut data_guard2 = data2.write().unwrap();
// Выполнить операции на data1 и data2
*data_guard1 += 1;
*data_guard2 += 1;
});
handles.push(handle);
}
}
for handle in handles {
handle.join().unwrap();
}
Конкурентные структуры данных 319
В представленном выше примере используются два экземпляра контейнерасинхронизатора RwLock – data1 и data2, которые обернуты в умные указатели Arc
(атомарные счетчики ссылок) для обеспечения коллективного владения между
несколькими потоками исполнения. Потоки, созданные в цикле, пытаются приобрести замки этих экземпляров структуры RwLock. Что принципиально важно:
они делают это таким образом, который гарантирует последовательный порядок приобретения замков. Указанная стратегия важна для предотвращения взаимных блокировок, так как она позволяет избегать циклических зависимостей,
при которых один поток исполнения удерживает замок, который другой поток
исполнения ожидает приобрести, что и приводит к взаимной блокировке.
Реализация объединения ресурсов в пул
Реализация объединения ресурсов в пул в конкурентном программировании –
это ценный метод эффективного управления и коллективного использования
ограниченного набора ресурсов между несколькими потоками исполнения.
В представленном ниже фрагменте исходного кода на Rust контейнер-синхронизатор RwLock используется для создания пула ресурсов, в котором каждый ресурс
защищен замком, что обеспечивает защищенный и синхронизированный доступ.
Листинг 7.17. Пример реализации объединения
ресурсов в пул с помощью RwLock
use std::sync::{RwLock, Arc};
use std::thread;
struct Resource {
id: u32,
}
fn main() {
const NUM_RESOURCES: u32 = 3; // Количество ресурсов в пуле
let resources: Vec<Arc<RwLock<Resource>>> = (0..NUM_RESOURCES) // ①
.map(|id| Arc::new(RwLock::new(Resource { id })))
.collect();
let mut handles = vec![];
for i in 0..10 {
let resources_clone: Vec<Arc<RwLock<Resource>>> = resources.clone();// ②
let handle = thread::spawn(move || {
let idx = (i % NUM_RESOURCES) as usize;
// ③
let resource = &resources_clone[idx];
let data_guard = resource.read().unwrap(); // ④
println!("Поток {} получил доступ к ресурсу {}", i, data_guard.id);
// Замок автоматически снимается здесь,
// когда data_guard выходит из диапазона доступности
});
handles.push(handle);
}
320
Управление конкурентностью
for handle in handles {
handle.join().unwrap();
}
}
//
//
//
//
//
//
//
//
//
//
//
Вывод:
Поток 0
Поток 1
Поток 9
Поток 8
Поток 7
Поток 6
Поток 5
Поток 4
Поток 3
Поток 2
получил доступ к ресурсу 0
получил доступ к ресурсу 1
получил доступ к ресурсу 0
получил доступ к ресурсу 2
получил доступ к ресурсу 1
получил доступ к ресурсу 0
получил доступ к ресурсу 2
получил доступ к ресурсу 1
получил доступ к ресурсу 0
accessed Reso получил доступ к ресурсу urce 2
В приведенном выше примере:
① создать пул ресурсов: вектор, содержащий обертки Arc на RwLock;
② клонировать вектор умных указателей Arc для передачи в новый поток
исполнения;
③ выбрать ресурс из пула по индексу;
④ приобрести замок на чтение.
В данном примере создается массив ресурсов, каждый из которых представлен структурой Resource и обернут в Arc<RwLock<Resource>> для обеспечения
коллективного владения между потоками. Количество ресурсов определяется
константой NUM_RESOURCES, а цикл инициализирует эти ресурсы с уникальными
идентификаторами.
Затем программа порождает десять потоков исполнения, и каждый поток
пытается получить доступ к ресурсу из пула. Использование примитива RwLock
гарантирует, что несколько потоков исполнения могут конкурентно читать ресурс без каких-либо проблем, но только один поток исполнения может удерживать замок на запись для исключительного изменения. Таким образом, пул
ресурсов эффективно используется потоками, предотвращая гонки за доступ
к данным, и обеспечивает защищенный доступ к каждому ресурсу.
Для дальнейшего повышения эффективности в исходном коде используется арифметическая операция остатка от деления (i % NUM_RESOURCES), чтобы
равномерно распределять доступ потоков между имеющимися ресурсами,
предотвращая тем самым конфликты нескольких потоков на одном и том
же ресурсе. За счет этого обеспечивается эффективное коллективное использование ресурсов, и программа выдает результат в виде идентификаторов ресурсов, к которым каждый поток получил доступ.
Сочетание контейнера-синхронизатора RwLock, умного указателя Arc и тщательного распределения ресурсов в этом примере демонстрирует, как реализовывать пул ресурсов на Rust, позволяя нескольким потокам исполнения
конкурентно обращаться к ограниченному набору ресурсов и использовать их,
поддерживая при этом целостность данных и синхронизацию. Пример показывает универсальность примитива RwLock при обработке сложных сценариев
синхронизации в парадигме конкурентного программирования Rust.
Обмен данными между потоками исполнения и передача сообщений 321
Обмен данными между потоками
исполнения и передача сообщений
В конкурентном программировании нередко возникает необходимость в том,
чтобы потоки исполнения общались друг с другом, обмениваясь данными. Rust
предоставляет различные методы, которые позволяют достигать этой цели,
включая каналы, передачу сообщений и атомарные операции. Давайте обследуем некоторые из этих технических приемов в следующих далее разделах.
Использование каналов для обмена данными
Каналы – это мощный механизм, позволяющий потокам исполнения обмениваться данными между собой в конкурентной программе. В Rust каналы представлены модулем std::sync::mpsc (расшифровывается как multi-producer,
single-consumer – несколько производителей, один потребитель), который
позволяет нескольким потокам исполнения отправлять данные одному получателю, обеспечивая эффективный и синхронизированный обмен данными
в многопоточной среде.
Ниже представлен практический пример использования каналов для обмена данными:
Листинг 7.18. Пример использования каналов для обмена данными
use std::thread;
use std::sync::mpsc;
fn main() {
let (sender, receiver) = mpsc::channel(); // ①
let sender_clone = sender.clone();
// ②
let handle = thread::spawn(move || {
let message = "Привет из потока-отправителя".to_string();
// Отправить сообщение в канал
sender_clone.send(message).unwrap();
});
}
let received_data = receiver.recv().unwrap(); // ③
println!("Получено: {}", received_data);
// ④
handle.join().unwrap();
// ⑤
// Вывод:
// Получено: Привет из потока-отправителя
В приведенном выше фрагменте исходного кода:
① мы создаем канал для обмена данными, используя mpsc::channel(). Эта
функция возвращает кортеж, содержащий отправителя и получателя;
② клонируем отправителя для нового потока исполнения, чтобы иметь возможность передавать данные через канал;
322
Управление конкурентностью
③ в главном потоке исполнения получаем данные из канала, используя
receiver.recv(). Метод .unwrap() используется для обработки результата, после чего мы приступаем к работе с полученными данными;
④ обрабатываем и выводим на экран полученные данные;
⑤ ожидаем завершения работы отправляющего потока исполнения.
Каналы особенно ценны для коллективного использования данных и синхронизации поведения между потоками исполнения, что делает их фундаментальным инструментом в конкурентном программировании. Они обеспечивают защищенный и эффективный способ передачи сообщений и коллективного использования информации, способствуя эффективному обмену данными
и координации в многопоточных приложениях на Rust.
Передача сообщений с использованием структур
Использование конкретно-прикладных структур сообщений для передачи сообщений на Rust обеспечивает гибкий и организованный подход к межпоточному обмену данными. Хотя каналы отлично подходят для передачи простых
типов данных, использование конкретно-прикладных структур позволяет отправлять между потоками более сложные и структурированные сообщения.
Ниже приводится пример использования конкретно-прикладных структур
для передачи сообщений:
Листинг 7.19. Пример передачи сообщений с использованием структур
use std::thread;
use std::sync::mpsc;
fn main() {
let (sender, receiver) = mpsc::channel(); // ①
let sender_clone = sender.clone();
// ②
let handle = thread::spawn(move || {
let message = CustomMessage {
// ③
id: 42,
content: "Важные данные".to_string(),
};
sender_clone.send(message).unwrap();
});
let received_message = receiver.recv().unwrap(); // ④
// ⑤ Вывод полученного сообщения
println!("Получено сообщение - ИД: {}, Содержимое: {}",
received_message.id,
received_message.content
);
}
handle.join().unwrap(); // ⑥
struct CustomMessage {
id: u32,
content: String,
}
// ⑦
Обмен данными между потоками исполнения и передача сообщений 323
// Вывод:
// Получено сообщение − ИД: 42, Содержимое: Важные данные
В приведенном выше фрагменте исходного кода:
① мы создаем канал для конкретно-прикладных сообщений, используя
mpsc::channel();
② в потоке-отправителе клонируем отправителя для передачи данных
по каналу;
③ создаем конкретно-прикладное сообщение, используя структуру, и отправляем его по каналу;
④ в главном потоке исполнения мы получаем это сообщение, обрабатываем его содержимое и выводим на экран. Такой подход позволяет передавать более структурированные данные между потоками;
⑤ обрабатываем и выводим полученное конкретно-прикладное сообщение;
⑥ ожидаем завершения работы отправляющего потока исполнения;
⑦ определяем саму структуру конкретно-прикладного сообщения
CustomMessage.
В приведенном выше примере структура CustomMessage определена с полями для ИД и содержимого, образуя формат структурированного сообщения.
Она позволяет потокам исполнения передавать не просто сырые данные,
а полноценную информацию, упакованную внутри сообщения. Такой подход
особенно ценен в ситуациях, когда потокам необходимо обмениваться сложными структурами данных, конфигурациями или любыми данными, имеющими несколько атрибутов.
Использование конкретно-прикладных структур повышает выразительность и ясность передачи сообщений в конкурентных программах на Rust.
Оно способствует четко структурированному обмену данными между потоками исполнения, гарантируя, что сообщения будет легко истолковывать, и облегчая обмен сложными типами данных с целью эффективной синхронизации
и координации в многопоточных приложениях.
Атомарные операции
В дополнение к каналам и передаче сообщений Rust также предоставляет
атомарные операции для защищенного конкурентного доступа к коллективным данным. Атомарные операции незаменимы в ситуациях, когда нужно
обновить значение таким образом, чтобы предотвратить состояние гонки
за доступ к данным.
Ниже приводится пример использования атомарных операций для защищенного приращения счетчика:
Листинг 7.20. Пример атомарных операций
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let counter = Arc::new(AtomicUsize::new(0)); // ①
324
Управление конкурентностью
let mut handles = vec![];
// ②
for _ in 0..5 {
let counter = Arc::clone(&counter);
// ③
let handle = thread::spawn(move || {
counter.fetch_add(1, Ordering::Relaxed); // ④
});
handles.push(handle);
// ⑤
}
for handle in handles {
handle.join().unwrap();
}
}
// ⑥
let final_value = counter.load(Ordering::Relaxed); // ⑦
println!("Окончательное значение счетчика: {}", final_value); // ⑧
// Вывод:
// Окончательное значение счетчика: 5
В приведенном выше фрагменте исходного кода:
① мы создаем атомарный счетчик, используя std::sync::atomic::
AtomicUsize. Обертываем его в умный указатель Arc, чтобы позволить нескольким потокам исполнения коллективно владеть в защищенном режиме;
② инициализируем вектор для хранения дескрипторов потоков исполнения;
③ клонируем счетчик для каждого потока исполнения;
④ внутри каждого потока применяем метод fetch_add для атомарного приращения счетчика. Параметр Ordering::Relaxed определяет порядок работы с памятью – в данном случае он «расслабленный», что означает отсутствие необходимости в дополнительных примитивах синхронизации;
⑤ дескрипторы потоков исполнения сохраняются для последующего использования;
⑥ ожидаем завершения исполнения всех потоков;
⑦ загружаем окончательное значение атомарного счетчика;
⑧ выводим окончательное значение на экран. Атомарные операции позволяют защищенно выполнять действия над коллективными данными без
необходимости использования дополнительных примитивов синхронизации (таких как мьютексы).
Понимание атомарных операций играет крайне важную роль для сценариев,
в которых требуется мелкозернистый контроль над коллективными данными.
Продвинутый обмен данными
между потоками исполнения
В предыдущих разделах мы подробно изучили основные аспекты конкурентного программирования на Rust, охватив базовые концепции и механизмы
Продвинутый обмен данными между потоками исполнения 325
синхронизации. Опираясь на этот фундамент, теперь можно глубже погрузиться в сложности продвинутых шаблонов обмена данными между потоками исполнения. Сюда входит искусство координации потоков исполнения,
при которой несколько потоков работают согласованно, синхронизируют свои
действия и принимают разумные решения на основе коллективной информации. Этот раздел вооружит вас знаниями и инструментами, необходимыми
для разработки и реализации надежных и высококоординированных конкурентных приложений на Rust, что позволит создавать эффективное и стабильное многопоточное программное обеспечение. Ниже также будут представлены продвинутые практические приемы, такие как использование библиотеки
crossbeam и асинхронное программирование.
Координация потоков исполнения с помощью барьеров
Координация потоков исполнения критически важна в ситуациях, когда несколько потоков выполняют задания, которые зависят от завершения друг
друга. Rust предоставляет удобный способ координации посредством барьеров из модуля std::sync.
Давайте посмотрим, как использовать барьеры для координации потоков
исполнения:
Листинг 7.21. Пример координации потоков исполнения с помощью барьеров
use std::sync::{Arc, Barrier};
use std::thread;
fn main() {
let barrier = Arc::new(Barrier::new(3));
let mut handles = vec![];
// ①
// ②
for id in 0..3 {
let barrier = Arc::clone(&barrier);
// ③
let handle = thread::spawn(move || {
println!("Поток {} запущен", id); // ④
thread::sleep(std::time::Duration::from_secs(2)); // ⑤
println!("Поток {} закончил свою работу", id);
// ⑥
barrier.wait();
// ⑦
println!("Поток {} достиг барьера", id);
// ⑧
});
handles.push(handle); // ⑨
}
}
//
//
//
//
//
//
for handle in handles {
handle.join().unwrap(); // ⑩
}
Вывод:
Поток 2
Поток 1
Поток 0
Поток 2
Поток 1
запущен
запущен
запущен
закончил свою работу
закончил свою работу
326 Управление конкурентностью
//
//
//
//
Поток
Поток
Поток
Поток
0
0
2
1
закончил свою работу
достиг барьера
достиг барьера
достиг барьера
В приведенном выше примере:
① мы создаем барьер со значением счетчика, равным 3. Оно указывает
на то, что барьер должен дождаться прихода трех потоков исполнения
и только после этого разрешить им двигаться дальше;
② инициализируем вектор для хранения дескрипторов потоков исполнения;
③ клонируем барьер для каждого потока исполнения;
④ выполняем некоторую работу;
⑤ симулируем длительность работы с помощью засыпания;
⑥ выводим на экран сообщение о завершении этапа работы;
⑦ ожидаем, пока остальные потоки исполнения достигнут барьера;
⑧ выводим на экран сообщение после прохождения барьера;
⑨ сохраняем дескриптор потока исполнения в векторе;
⑩ ожидаем завершения каждого потока исполнения (join).
В приведенном выше фрагменте исходного кода мы создаем барьер
со счетчиком, равным 3, указывая на то, что он должен дождаться, пока три
потока исполнения его достигнут, после чего он позволит им продолжить выполнение. Каждый поток исполнения выполняет свое задание, симулирует
дополнительную работу посредством засыпания, выводит на экран сообщение о завершении и затем ожидает у барьера. В конце мы объединяем все потоки (join), чтобы гарантировать, что они полностью завершили исполнение
до завершения программы.
Этот пример демонстрирует, как механизм барьеров в Rust помогает эффективно координировать потоки исполнения, обеспечивая их синхронизацию в определенных точках программы. Такая координация жизненно важна
в сценариях, в которых разные потоки должны сотрудничать для достижения
общей цели, например в параллельной обработке, планировании заданий или
распределенных вычислениях.
Локальное для потока хранение данных
Локальное по отношению к потоку хранение данных (thread local storage, аббр.
TLS) – это фундаментальная концепция конкурентности, которая играет решающую роль в управлении данными, специфичными для конкретного потока
исполнения. В области конкурентного программирования часто возникают ситуации, когда потокам исполнения необходим доступ к данным, уникальным
для их контекста исполнения, и управление ими. Именно здесь на помощь
приходит локальное для потока хранение данных, позволяя каждому потоку
иметь собственное изолированное хранилище данных без необходимости использования сложных механизмов синхронизации.
Локальное для потока хранение данных реализуется на Rust с помощью
макрофункции thread_local! – мощного инструмента, который позволяет
определять локальные для потока переменные. Эти переменные, по сути, являются отдельными экземплярами для каждого потока, и любые изменения,
Продвинутый обмен данными между потоками исполнения 327
внесенные в них внутри одного потока, не влияют на значения в других потоках. Такая изоляция обеспечивает эффективный и защищенный способ управления данными конкретного потока, повышая производительность и надежность конкурентных программ.
Одним из распространенных вариантов использования локального для
потока хранения является управление ресурсами, создание или доступ к которым обходятся дорого, такими как соединения с базами данных, сетевые
сокеты или настройки конфигурации. Выделяя и поддерживая эти ресурсы
в локальном для потока хранилище данных, каждый поток может эффективно обращаться к ним и манипулировать ими, не создавая накладных расходов
на механизмы синхронизации, такие как мьютексы или замки.
Еще одним преимуществом локального для потока хранения данных является
его способность снижать соперничество (конфликты) за коллективные ресурсы.
В сценариях, где несколько потоков исполнения часто обращаются к коллективным данным, соперничество может приводить к узким местам в производительности. Используя локальное для потока хранение данных, потоки наделяются
способностью работать независимо со своими собственными копиями данных,
сводя к минимуму соперничество и улучшая совокупную конкурентность.
В Rust использование локального для потока хранения данных осуществляется интуитивно понятным образом благодаря макрофункции thread_local!.
Она позволяет объявлять локальные для потока переменные и инициализировать их дефолтными значениями, специфичными для каждого потока. Затем к этим переменным можно обращаться и изменять их в контексте потока,
не затрагивая другие потоки исполнения.
Давайте рассмотрим, как использовать локальное для потока хранение данных на Rust на практическом примере:
Листинг 7.22. Пример локального для потока хранения данных
use std::thread;
// Объявить локальную для потока переменную
thread_local! {
static THREAD_LOCAL_DATA: std::cell::RefCell<u32> =
std::cell::RefCell::new(42);
}
fn main() {
let mut handles = vec![];
// ①
for _ in 0..5 {
let handle = thread::spawn(|| {
// Доступ к локальным для потока данным через метод .with()
THREAD_LOCAL_DATA.with(|data| {
let mut value = data.borrow_mut();
*value += 1;
println!("Локальное для потока значение: {}", *value);
});
});
handles.push(handle); // ②
}
328
Управление конкурентностью
for handle in handles {
handle.join().unwrap(); // ③
}
// Проверить значение в главном потоке исполнения
THREAD_LOCAL_DATA.with(|data| {
let value = data.borrow();
println!("Локальное для потока значение в главном потоке: {}", *value); // ④
});
}
//
//
//
//
//
//
//
Вывод:
Локальное
Локальное
Локальное
Локальное
Локальное
Локальное
для
для
для
для
для
для
потока
потока
потока
потока
потока
потока
значение: 43
значение: 43
значение: 43
значение: 43
значение: 43
значение в главном потоке: 42
В приведенном выше примере:
① породить несколько потоков исполнения;
② породить новый поток исполнения;
③ ожидать завершения всех потоков исполнения;
④ обратиться к локальному для потока значению в главном потоке.
В приведенном выше примере мы создаем особый тип переменной, уникальной для каждого потока исполнения, используя макрофункцию thread_
local!. Таким образом, каждый поток исполнения может увеличивать свое
собственное «специальное» значение, а главный поток – проверять свое
собственное уникальное значение. Такое локальное для потока хранение
данных подобно наличию персональных шкафчиков у каждого потока, что
невероятно удобно в ситуациях, когда нужно хранить данные обособленно.
Это помогает избегать хлопот с синхронизацией всего подряд и гарантирует, что каждый поток исполнения эффективно работает со своими собственными данными.
Библиотека crossbeam
Библиотека crossbeam выделяется тем, что предлагает отличные решения продвинутых задач синхронизации на Rust. Она укомплектована целым набором
удобных структур данных и инструментов, которые выходят за рамки возможностей стандартной библиотеки. Одной из ее ключевых особенностей являются диапазонные потоки исполнения, которые предоставляют высокоточный
контроль над временем жизни потоков исполнения.
Давайте рассмотрим простой пример использования диапазонных потоков
библиотеки crossbeam:
Листинг 7.23. Пример использования библиотеки crossbeam
use crossbeam::thread;
Асинхронное программирование 329
fn main() {
// Создать диапазон доступности для потоков.
// Все потоки внутри этого блока гарантированно завершатся до его закрытия.
thread::scope(|s| {
// ①
for i in 0..5 {
s.spawn(move |_| { // ②
println!("Диапазонный поток {}", i);
});
}
// Здесь не нужно явно вызывать .join(),
// так как закрывающая скобка сама дождется завершения всех потоков.
}).unwrap(); // ③
}
//
//
//
//
//
//
Вывод:
Диапазонный
Диапазонный
Диапазонный
Диапазонный
Диапазонный
поток
поток
поток
поток
поток
4
2
1
0
3
В приведенном выше примере:
① мы используем thread::scope из библиотеки crossbeam для порождения диапазонных потоков исполнения. Замыкание, переданное
в thread::scope, определяет их диапазон доступности;
② внутри диапазонных потоков выполняем некоторую работу. В данном
случае выводим на экран сообщение с индексом потока исполнения;
③ гарантируем, что все потоки внутри диапазона доступности завершатся
до выхода из программы. Метод .unwrap() используется для обработки
любых потенциальных ошибок.
Библиотека crossbeam – это надежная и высокоценимая библиотека, которая
играет ключевую роль в обработке сложных сценариев конкурентности, благодаря чему она пользуется большой популярностью в сообществе Rust при
решении трудных задач конкурентности. Эта библиотека оснащает разработчика комплексным инструментарием по управлению конкурентными операциями, предлагая широкий спектр структур данных и утилит, превосходящих
способности стандартной библиотеки Rust.
Асинхронное программирование
Функциональное средство Rust async/await, впервые введенное в версии
Rust 1.39, произвело революцию в асинхронном программировании на этом
языке. Оно позволяет писать более удобочитаемый и простой в обслуживании
асинхронный исходный код. С помощью библиотек async-std или tokio можно с легкостью строить высококонкурентные приложения. Ниже приводится
простой пример асинхронного программирования с использованием библио
теки async-std:
330
Управление конкурентностью
Листинг 7.24. Пример асинхронного программирования
use async_std::task;
fn main() {
task::block_on(async {
let task1 = task::spawn(async { // ①
println!("Задание 1: запускается");
// Симуляция асинхронного задания с задержкой
async_std::task::sleep(std::time::Duration::from_secs(2)).await;
println!("Задание 1: выполнено");
});
let task2 = task::spawn(async { // ②
println!("Задание 2: запускается");
// Симуляция еще одного асинхронного задания с задержкой
async_std::task::sleep(std::time::Duration::from_secs(1)).await;
println!("Задание 2: выполнено");
});
});
task1.await; // ③
task2.await; // ④
println!("Все задания выполнены");
}
//
//
//
//
//
//
Вывод:
Задание 1: запускается
Задание 2: запускается
Задание 2: выполнено
Задание 1: выполнено
Все задания выполнены
В приведенном выше примере:
① мы определяем асинхронную функцию с помощью ключевого слова
async. Внутри этой функции порождаем асинхронные задания, используя task::spawn;
② порождаем еще одно асинхронное задание;
③ ожидаем завершения первого задания (task1);
④ ожидаем завершения второго задания (task2).
Асинхронное программирование обеспечивает эффективное конкурентное
исполнение заданий без необходимости явного управления потоками.
Лучшие образцы практики
программирования конкурентности
Работая с конкурентностью на Rust, следует придерживаться следующих рекомендаций.
Начинать с четкого проработанного плана: следует планировать архитектуру конкурентной системы и определять четкие границы между
потоками исполнения или акторами.
Заключение 331
Использовать пулы потоков исполнения: при распараллеливании
заданий следует рассматривать возможность использования пулов потоков исполнения (например, библиотеку rayon) с целью эффективного
управления ресурсами.
Отдавать предпочтение передаче сообщений: в конкурентных системах лучше использовать передачу сообщений вместо коллективного состояния и замков – это сводит к минимуму риск возникновения состоя
ний гонки за доступом к данным.
Избегать мьютексов, когда это возможно: мьютексы следует использовать только при необходимости. С целью повышения производительности рекомендуется рассматривать беззамковые структуры данных
и атомарные операции.
Обработка ошибок: в конкурентном исходном коде следует уделять
пристальное внимание обработке ошибок, чтобы обеспечить отказо
устойчивость приложения.
Тестирование: нужно использовать встроенный в Rust фреймворк тес
тирования для проверки правильности работы конкурентного исходного кода.
Нагрузочное тестирование: следует проводить профилирование и тес
тирование производительности, чтобы выявлять узкие места и их оптимизировать.
Заключение
В этой главе мы провели обследование различных аспектов управления конкурентностью на Rust. Мы начали с основ потоков исполнения и примитивов
синхронизации, таких как Mutex и RwLock. Затем мы углубились в тему обмена
данными между потоками исполнения с помощью каналов и конкретно-прикладной передачи сообщений. Кроме того, мы рассмотрели атомарные операции для защищенного конкурентного доступа к коллективным данным.
По мере изложения мы познакомили вас с библиотекой crossbeam, служащей
для обработки сложных сценариев синхронизации, и обследовали асинхронное программирование с использованием функциональности async/await.
Rust предоставляет обширный набор инструментов и библиотек для эффективного управления конкурентностью, благодаря чему он отлично подходит
для построения высокопроизводительных конкурентных приложений.
Мы также обследовали сложные шаблоны взаимодействия потоков исполнения, отточили навыки щадящей обработки ошибок внутри конкурентных
программ и обуздали мощь локального для потоков хранения данных (TLS)
для эффективного управления специфичными для потока данными. Указанные продвинутые методы имеют решающее значение для построения сложных и надежных конкурентных систем, способных преодолевать трудности
современных вычислений.
Осваивая методы разработки на Rust, вашими союзниками станут эти концепции конкурентности, которые позволяют создавать высокопроизводительные, отзывчивые и отказоустойчивые приложения. Не важно, над чем
вы работаете – разрабатываете серверные приложения, которым необходимо
332 Управление конкурентностью
обрабатывать тысячи конкурентных соединений, распараллеливаете задания
по обработке данных или конструируете отзывчивые пользовательские интерфейсы, – функциональные средства Rust по работе с конкурентностью станут
залогом вашего успеха.
В следующей главе вы займетесь практическим обследованием темы программирования командных интерфейсов. Эта утилитарная глава представляет
собой подробное руководство по созданию с нуля мощной утилиты для поиска
и замены текста. На протяжении всей главы вы будете получать глубокое понимание принципов и приемов работы с аргументами командного интерфейса
и эффективно разбирать вводимые данные на Rust, что делает ее ценным ресурсом для всех, кто хочет расширить свои знания в создании программ с консольным интерфейсом (CLI). К концу этой главы вы разработаете надежный
инструмент, который сможет быстро искать и заменять текст в файлах, получив
не только полезную утилиту, но и прочный фундамент в понимании способностей Rust по построению эффективных и удобных консольных приложений.
Дополнительные ресурсы
В целях углубления своих знаний в области управления конкурентностью
на Rust можно обследовать следующие ниже ресурсы.
Документация tokio: обратитесь к официальной документации Tokio –
надежной среды исполнения для асинхронного Rust. Обследуйте ее функциональные возможности, модули и руководства, чтобы освоить тонкости конкурентного программирования с помощью tokio: https://tokio.rs.
Книга по async – под капотом: исполнение объектов Future и заданий»:
погрузитесь в указанную главу книги «The Async Book». Это официальный ресурс, который подробно описывает асинхронное программирование на Rust и принятую в нем модель исполнения. Узнайте, как именно
компилятор Rust обрабатывает конкурентность и параллелизм: https://
rust-lang.github.io/async-book/02_execution/01_chapter.html.
Документация async-std: ознакомьтесь с официальной документацией
проекта Async Std – еще одной асинхронной среды исполнения для Rust.
Данный проект предоставляет необходимые инструменты и утилиты
для конкурентного программирования, способствующие эффективному
управлению ресурсами: https://docs.rs/async-std.
Объекты Future – документация модуля executor: обследуйте документацию модуля executor в пакете futures. Понимание этого модуля играет критически важную роль для управления конкурентными заданиями
и координации асинхронного исполнения на Rust: https://docs.rs/futures/
latest/futures/executor/index.html.
Книга по async-концепции: прочтите данную главу книги «The Async
Book», чтобы получить глубокое понимание конкурентного программирования на Rust. Этот ресурс охватывает такие темы, как замки (блокировки), каналы и другие примитивы синхронизации: https://book.async.
rs/concepts.
Документация пакета tokio sync: ознакомьтесь с документацией пакета
sync пакета tokio на сайте crates.io. Она предоставляет примитивы син-
Вопросы для самопроверки 333
хронизации для асинхронного программирования, такие как асинхронные версии примитивов Mutex и RwLock: https://docs.rs/tokio/latest/tokio/
sync/index.html.
Crossbeam – документация структуры Scope: погрузитесь в документацию
структуры Scope в библиотеке crossbeam. Данная библиотека предоставляет набор конкурентных структур данных, а структура Scope особенно
полезна для управления временами жизни конкурентных потоков исполнения: https://docs.rs/crossbeam/latest/crossbeam/thread/struct.Scope.html.
Документация rayon: ознакомьтесь с официальной документацией биб
лиотеки rayon, предназначенной для параллелизма данных на Rust.
Указанная библиотека упрощает параллельное программирование, предоставляя высокоуровневый и интуитивно понятный API для распараллеливания операций: https://docs.rs/rayon.
Перечисленные выше ресурсы предлагают всесторонний взгляд на управление конкурентностью в Rust, предоставляя практические руководства и документацию, которые помогут вам освоить сложности конкурентного программирования и разработки отзывчивых систем.
Вопросы для самопроверки
Вопрос 1. Каково основное предназначение потоков инструкций в конкурентном программировании на Rust?
a) определение коллективного поведения
b) предоставление дефолтных реализаций функций
c) разрешение нескольким заданиям выполняться конкурентно
d) предотвращение гонок за доступ к данным
Вопрос 2. Какая из следующих концепций в Rust отвечает за предотвращение гонок за доступ к данным, позволяя только одному потоку исполнения владеть элементом данных в конкретный момент времени?
a) умные указатели
b) времена жизни
c) владение
d) заимствование
Вопрос 3. Под конкурентностью в Rust понимается управление заданиями, которые:
a) всегда происходят по порядку
b) не зависят друг от друга
c) используют коллективные данные без ограничений
d) исполняются последовательно
Вопрос 4. Какова основная роль функции thread::spawn в Rust?
a) запирание мьютекса на замок
b) создание нового потока исполнения
334
Управление конкурентностью
c) синхронизация потоков исполнения
d) предотвращение взаимных блокировок
Вопрос 5. Что обеспечивает сторож мьютекса в Rust в отношении модификации данных?
a) разрешает одновременные изменения несколькими потоками исполнения
b) гарантирует отсутствие гонок за доступ к данным
c) обеспечивает соблюдение принятых в Rust правил владения, позволяя
только одному потоку исполнения изменять данные в конкретный момент времени
d) предотвращает коллективное использование данных между потоками
исполнения
Вопрос 6. Каково основное предназначение общих черт в Rust?
a) определение структур данных
b) предоставление дефолтных реализаций функций
c) определение общего поведения, которое типы могут реализовывать
d) ограничение доступа к данным
Вопрос 7. Какой инструмент синхронизации часто используется вместе
с примитивом Mutex для реализации более сложных шаблонов синхронизации в Rust?
a) Semaphore
b) Atomic
c) Barrier
d) Condvar
Вопрос 8. Для чего защищенная мьютексом очередь обертывается умным указателем Arc?
a) для того чтобы обеспечивать исключительный доступ к очереди
b) для того чтобы позволить нескольким потокам исполнения коллективно
владеть очередью
c) для того чтобы избегать ситуаций взаимной блокировки
d) для того чтобы сделать очередь немутируемой
Вопрос 9. Каково главное предназначение метода try_lock в мьютексе Rust?
a) принудительное приобретение замка блокировки
b) снятие замка с мьютекса
c) попытка приобретать замок без блокирования
d) обработка взаимных блокировок
Вопрос 10. Для чего в защищенной мьютексом очереди с приоритетом
мьютекс используется для обертывания двоичной кучи BinaryHeap?
a) для того чтобы предоставлять готовую реализацию очереди с приоритетом
Вопросы для самопроверки 335
b) для того чтобы обеспечить потокозащищенность при доступе к опорной
структуре данных
c) для того чтобы избегать использования очереди с приоритетом
d) для того чтобы обеспечивать строгий контроль доступа
Вопрос 11. В чем главное преимущество использования потоков исполнения в конкурентном программировании?
a) снижение потребления памяти
b) упрощенная структура исходного кода
c) улучшенная производительность и отзывчивость
d) повышенная защищенность
Вопрос 12. Какова роль компилятора Rust в модели конкурентности?
a) исполнение конкурентных заданий
b) мониторинг поведения потоков исполнения
c) управление выделением памяти
d) реализация примитивов синхронизации
Вопрос 13. Как компилятор Rust обрабатывает концепцию владения
в контексте потоков исполнения?
a) потоки могут свободно делиться владением
b) потоки не могут получать доступ к коллективным данным
c) владение управляется через примитивы синхронизации
d) владение неприменимо к конкурентному программированию
Вопрос 14. Что означает аббревиатура mpsc в контексте каналов в Rust?
a) несколько производителей, один потребитель
b) многопроцессорное системное взаимодействие
c) многоцелевой канал синхронизации
d) синхронизированный канал передачи сообщений
Вопрос 15. Каково предназначение конкретно-прикладных структур сообщений при передаче сообщений между потоками исполнения?
a) увеличение времени компиляции
b) упрощение создания потоков исполнения
c) разрешение передачи сложных и структурированных сообщений
d) уменьшение количества потоков исполнения
Вопрос 16. Каков основной сценарий использования атомарных операций в Rust?
a) эффективное выделение памяти
b) избегание гонок за доступ к данным и обеспечение защищенного конкурентного доступа
c) упрощение создания потоков исполнения
d) передача сообщений между потоками исполнения
336
Управление конкурентностью
Вопрос 17. Каково основное преимущество локального для потока хранения данных (TLS) в конкурентном программировании?
a) Уменьшение Количества Потоков Исполнения
b) Увеличение Скорости Компиляции
c) Эффективное Управление Данными, Специфичными Для Конкретного
Потока Исполнения
d) Упрощение обмена данными по каналам
Ответы
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
c) разрешение нескольким заданиям выполняться конкурентно
c) владение
b) не зависят друг от друга
b) создание нового потока исполнения
c) обеспечивает соблюдение принятых в Rust правил владения, позволяя
только одному потоку исполнения изменять данные в конкретный момент времени
c) определение общего поведения, которое типы могут реализовывать
d) Condvar
b) для того чтобы позволить нескольким потокам исполнения коллективно владеть очередью
c) попытка приобретать замок без блокирования
b) для того чтобы обеспечить потокозащищенность при доступе к опорной структуре данных
c) улучшенная производительность и отзывчивость
b) мониторинг поведения потоков исполнения
c) владение управляется через примитивы синхронизации
a) несколько производителей, один потребитель
c) разрешение передачи сложных и структурированных сообщений
b) избегание гонок за доступ к данным и обеспечение защищенного конкурентного доступа
c) эффективное управление данными, специфичными для конкретного
потока исполнения
Ключевые термины
Владение (ownership): центральная концепция в Rust, которая определяет способ управления памятью и обеспечивает защищенность данных,
позволяя только одной части исходного кода изменять данные в конкретный момент времени.
Заимствование (borrowing): в Rust это акт временного использования
ресурса, которым владеет другая часть исходного кода, с соблюдением
правил предотвращения конфликтов.
Время жизни (lifetimes): в Rust это таймеры, которые отслеживают продолжительность, с которой могут использоваться коллективные данные,
предотвращая потенциальные проблемы или конфликты в конкурентном программировании.
Ключевые термины 337
Конкурентность (concurrency): исполнение нескольких заданий программного обеспечения, позволяющее им происходить либо последовательно, либо одновременно.
Mutex (сокращение от mutual exclusion – взаимное исключение, мьютекс) – это примитив синхронизации в Rust, гарантирующий, что только один поток исполнения может иметь доступ к защищенному ресурсу
в один момент времени, что предотвращает гонки за доступ к данным.
Thread::spawn: функция в Rust, используемая для создания нового потока исполнения, которая позволяет исполнять задания конкурентно.
Arc (сокращение от atomic reference counter – счетчик атомарных ссылок): это тип данных в Rust, технически классифицируемый как умный
указатель, который позволяет нескольким потокам исполнения коллективно владеть данными, обеспечивая одновременно потокозащищенность и подсчет ссылок.
Взаимная блокировка (deadlock): ситуация в конкурентном программировании, когда потоки исполнения зависают в бесконечном ожидании, пока другой поток снимет замок с мьютекса, что приводит к полной
остановке программы (потоки стоят «намертво», ожидая друг друга).
Сторож мьютекса (mutex guard): концепция в Rust, описывающая ситуацию, при которой замок, приобретенный посредством lock(), обес
печивает соблюдение правил владения, позволяя только одному потоку
исполнения изменять данные в конкретный момент времени.
Скомпрометированный мьютекс (poisoned mutex): мьютекс, который
переходит в состояние «отравлен», если в потоке, удерживающем этот
мьютекс, произошла паника (panic!), что указывает на возможную порчу
данных и необходимость специальной обработки.
Условная переменная (Condvar): продвинутый инструмент синхронизации в Rust, который позволяет потокам исполнения ожидать соблюдения определенного условия перед продолжением работы.
Двоичная куча (binary heap): структура данных в Rust, используемая
для реализации очереди с приоритетом.
try_lock: метод мьютекса в Rust, который пытается приобрести замок
без блокирования текущего потока исполнения, предоставляя неблокирующую альтернативу обычному приобретению замка.
Производитель–потребитель (producer-consumer): шаблон конкурентности, в котором один или несколько потоков-производителей генерируют данные, а один или несколько потоков-потребителей их обрабатывают.
Атомарные операции (atomic): операции, выполнение которых гарантированно не будет прервано в многопоточной среде, что обеспечивает
непротиворечивость при манипулировании коллективными данными.
Семафор (semaphore): примитив синхронизации, используемый для
управления доступом нескольких потоков исполнения к ресурсу в конкурентной системе.
RwLock (сокращение от read-write lock – замок на чтение–запись); примитив синхронизации в Rust, позволяющий нескольким потокам исполнения конкурентно читать данные, но предоставляющий исключительный доступ для операции записи.
338
Управление конкурентностью
Запирание на замок по времени (timed locking): механизм в конкурентном программировании, устанавливающий максимальный лимит
времени на операции чтения или записи, чтобы предотвращать ситуации, когда потокам приходится ожидать бесконечно.
Объединение ресурсов в пул (resource pooling): метод, при котором
ограниченный набор ресурсов эффективно управляется и распределяется между несколькими потоками исполнения.
Каналы (channels): механизм обмена данными в Rust, позволяющий потокам отправлять и получать данные, облегчая связь между конкурентными заданиями.
Локальное для потока хранение данных (thread local storage, TLS):
фундаментальная концепция, позволяющая потокам исполнения иметь
собственное изолированное хранилище данных без необходимости
в сложных механизмах синхронизации. В Rust реализуется с помощью
макрофункции thread_local!.
Барьер (barrier): примитив синхронизации в Rust из модуля std::sync,
используемый для координации потоков исполнения. Потоки ждут у барьера, пока не наберется заданное их количество, после чего все они продолжают работу.
Передача сообщений (message passing): акт передачи данных между
потоками исполнения для обмена данными и синхронизации. В Rust основными инструментами для этого являются каналы и конкретно-прикладные структуры сообщений.
mpsc (multi-producer, single-consumer – несколько производителей, один
потребитель): шаблон, в котором несколько потоков исполнения могут
производить данные, но существует только один потребитель, принимающий и обрабатывающий их. Реализован посредством каналов в модуле
std::sync::mpsc.
Глава 8
Консольные программы
Введение
В этой главе мы подробно рассмотрим командные интерфейсы (CLI) и проведем обследование библиотек Rust, которые помогают в эффективном определении и разборе аргументов командного интерфейса. Наша цель – приступить
к практическому проекту: построению консольной утилиты на Rust для поиска
и замены текста. К концу этой главы вы будете обладать всеми необходимыми
знаниями и умениями разработки своих собственных консольных программ.
Эти программы будут не только эффективно выполнять поставленные задачи,
но и обеспечат бесшовный и приятный опыт для пользователей, работающих
в командном интерфейсе.
Содержание главы
В этой главе будут рассмотрены следующие темы:
построение консольной утилиты на Rust для поиска и замены текста;
работа с аргументами командного интерфейса и разбор вводимых данных.
Введение в разбор аргументов
Аргументы командного интерфейса играют ключевую роль в конфигурировании и персонализации функциональности консольных утилит. Они служат
для пользователей способом передачи конкретных инструкций, данных и настроек программе при ее запуске через терминал. Аргументы предоставляют
пользователям высокую степень гибкости и контроля над тем, как работает
программа, что делает их важнейшим компонентом многих консольных приложений. Поняв и эффективно реализовав аргументы командного интерфейса, вы сможете создавать инструменты, которые будут более универсальными
и удобными для пользователя.
При разработке консольных приложений на Rust две библиотеки – clap
и structopt – выделяются как мощные инструменты управления аргументами
командного интерфейса. В этой главе мы подробно изучим clap – исключительно универсальную и широко распространенную библиотеку для разработки командных интерфейсов на Rust. Указанная библиотека предоставляет
обширный набор функциональных средств, которые упрощают определение
аргументов, флагов и подкоманд, что делает ее предпочтительным выбором
340
Консольные программы
для построения интерактивных и удобных консольных утилит. Ее способности позволяют создавать интуитивно понятные и хорошо структурированные
командные интерфейсы, которые повышают общую практичность и удобство
использования приложений.
Преимущества использования библиотеки clap
Библиотека clap как мощный анализатор аргументов командного интерфейса
для Rust предлагает несколько веских причин, чтобы стать вашим основным
инструментом при работе с консольными приложениями. Выбор этой библиотеки для разбора аргументов командного интерфейса на Rust – мудрое решение по следующим причинам:
активная поддержка и обновления. Библиотека clap активно поддерживается; в сентябре 2022 года вышел значимый релиз (v4)1. Такая
приверженность гарантирует, что библиотека clap остается совместимой с последними разработками в Rust и продолжает быть надежным
инструментом для создания командных интерфейсов;
популярность и экосистема. Библиотека clap широко принята в сообществе Rust и имеет более 12 000 звезд на GitHub2. Ее популярность
способствовала появлению процветающей экосистемы ресурсов, руководств и готовых проектов, что упрощает ее интеграцию в разработку;
обширные функциональные средства. Библиотека clap предлагает
широкие функциональные средства для разработки командных интерфейсов. Она не только справляется с базовым разбором аргументов,
но и предоставляет продвинутые функциональные средства, такие как
подсказки на основе расстояния Джаро–Винклера. Эта функциональность помогает пользователям исправлять опечатки или находить команды, близкие к валидным. Кроме того, clap предлагает гибкую конфигурируемость для определения команд и аргументов, позволяя
адаптировать консольные приложения под конкретные требования;
соответствие стандартам командного интерфейса Unix. Библиотека
clap придерживается стандартных правил Unix для командных интерфейсов, обеспечивая последовательность и привычность для пользователей. Она поддерживает такие практики, как флаги с одним дефисом
(-), длинные опции с двойным дефисом (--) и подкоманды. Это соответствие улучшает пользовательский опыт, особенно для тех, кто привык к принятому в Unix стилю, что выделяет ее на фоне альтернативных
библиотек, таких как argh;
поддержка сообщества. Библиотека clap опирается на активное и отзывчивое сообщество разработчиков. Это означает, что вы можете легко
получать помощь, находить документацию и даже конкретно-приклад1
2
Knapp, K. B., & сообщество библиотеки clap. (2023). clap (версия 4). Компьютерное
программное обеспечение // https://github.com/clap-rs/clap/releases?page=9.
Knapp, K. B., & сообщество библиотеки clap. (2023). clap (версия 4). Компьютерное
программное обеспечение // https://github.com/clap-rs/clap.
Начало работы с библиотекой clap 341
ные расширения или плагины, разработанные сообществом для улучшения консольных проектов1;
дополнительные ресурсы. Официальный репозиторий библиотеки
clap на GitHub и веб-сайт с рекомендациями по командным интерфейсам на Rust предоставляют дополнительные ресурсы и примеры, облегчая процесс обучения и использования;
защищенность и надежность. Библиотека clap сконструирована с упором на защищенность и надежность. Она содержит защитные механизмы для предотвращения распространенных проблем, таких как переполнение буфера, гарантируя, что консольные приложения, построенные
на основе данной библиотеки, будут устойчивыми и защищенными;
кросс-платформенная совместимость. Библиотека clap сконструирована под работу на разных платформах и бесшовно функционирует в Windows, macOS и различных дистрибутивах Linux. Такая
универсальность делает ее идеальным инструментом разработки кроссплатформенных приложений;
обширная документация. Библиотека clap предоставляет подробную
документацию, включая примеры, руководства и справочники по API.
Она помогает разработчикам на каждом этапе – от начала работы до продвинутого использования;
встроенная справка и информация об использовании. Библиотека clap
упрощает процесс разработки, автоматически генерируя сообщения справки и инструкции по использованию. Эта функциональность экономит время
и силы программистов на разработку этих важных компонентов.
Активная поддержка, популярность, обширный набор функциональных
средств, следование стандартам Unix, сильная поддержка сообщества, защищенность, кросс-платформенность и встроенная функциональность справки
в совокупности делают библиотеку clap убедительным и мощным инструментом разработки удобных командных интерфейсов.
Начало работы с библиотекой clap
Интеграция библиотеки clap в проект на Rust – это важнейший первый шаг
в реализации разбора аргументов командного интерфейса. Этот процесс
включает в себя редактирование файла Cargo.toml, который служит манифес
том зависимостей проекта. Добавляя библиотеку clap в качестве зависимости
в этот файл, вы, по сути, даете команду менеджеру пакетов Rust, Cargo, скачать
библиотеку clap и управлять ею в рамках вашего проекта. Эту интеграцию
можно удобно выполнить одной командой:
$ cargo add clap --features=derive
Флаг --features=derive указывает на то, что мы хотим включить функцио
нальность derive (автоматический вывод), предоставляемую библиотекой
clap, которая значительно упрощает разбор аргументов.
1
Пакет clap – анализатор аргументов командного интерфейса для Rust // https://docs.
rs/clap/.
342 Консольные программы
Интегрировав библиотеку clap в проект, теперь можно приступить к обследованию методов эффективного определения и разбора аргументов командного интерфейса. В Rust библиотека clap предлагает прямолинейный и эргономичный подход к обработке аргументов.
Листинг 8.1. Простейший пример использования библиотеки clap
use clap::Parser;
// ①
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
about = "Консольная утилита для поиска и замены текста",
name = "Поиск и замена"
)]
struct Args {
// ②
/// Указывает обрабатываемый входной файл
#[arg(short = 'i', long = "input")]
input: String, // ③
}
fn main() {
// ④
// Здесь мы обращаемся к аргументу input и его используем
let args = Args::parse(); // ⑤
println!("Имя считанного входного файла: {}!", args.input) // ⑥
}
В приведенном выше фрагменте исходного кода:
① мы начинаем с импорта библиотеки clap с помощью инструкции use
clap::Parser;. Эта инструкция импорта предоставляет доступ к необходимым функциональным средствам для работы с аргументами командного интерфейса;
② затем мы определяем структуру под названием Args, которая представляет аргументы, принимаемые нашей программой. Структура Args декорирована различными атрибутами с помощью макрофункции Rust
derive. Указанные атрибуты включают author (автора), version (версию),
about (описание) и name (название), которые предоставляют метаданные
о программе. Они помогают генерировать полезную справочную информацию, когда пользователи ее запрашивают;
③ внутри структуры Args мы определяем первый аргумент под названием input (входные данные). Этот аргумент описывается комментариями
и метаданными, такими как короткий флаг (-i), длинный флаг (--input)
и описание, разъясняющее его предназначение. Мы также указываем, что
этот аргумент является обязательным и ожидает значение типа String;
④ подготовив структуру Args, мы переходим к функции main, которая служит точкой входа в программу. Здесь мы получаем доступ к разобранным аргументам командного интерфейса и используем их;
⑤ при вызове Args::parse() библиотека clap автоматически выполняет
разбор аргументов, предоставленных пользователем при запуске программы;
Обработка нескольких аргументов 343
⑥ по завершении разбора обработанные аргументы сохраняются в переменной args, что позволяет обращаться к их значениям по мере необходимости. В нашем примере мы демонстрируем это, выводя значение
аргумента input на экран с помощью макрофункции println!. Пример
показывает, насколько библиотека clap упрощает процесс обработки
и использования аргументов командного интерфейса на Rust.
В целях более глубокого понимания того, как пользователи взаимодействуют
с нашей утилитой, мы приводим сценарий реального использования. В этом
примере пользователь выполняет нашу утилиту следующей ниже командой:
Листинг 8.2. Команда запуска программы с использованием clap
$ cargo run -- -i file_name.txt
Parsed Input file name: file_name.txt!
Здесь cargo run – это команда для исполнения нашей программы, а символ -- используется для отделения аргументов, специфичных для менеджера
Cargo, от аргументов, передаваемых непосредственно нашему приложению.
Пользователь указывает аргумент input, используя флаг -i, за которым следует нужное имя файла, например file_name.txt. В результате наша программа
успешно выполняет разбор этих входных данных и выводит имя обработанного входного файла в терминал.
Разобравшись с основами, стоит отметить, что библиотека clap предлагает
широкий спектр продвинутых функциональных средств и способностей, выходящих за рамки продемонстрированного примера. Эти функциональные
средства включают обработку подкоманд, поддержку различных типов аргументов, генерацию подробных справочных сообщений и индивидуальную
настройку поведения процедуры разбора аргументов для сложных сценариев
использования. Ознакомление с этими продвинутыми функциональностями
позволит создавать мощные и удобные командные интерфейсы для ваших
приложений на Rust.
Обработка нескольких аргументов
В контексте утилиты поиска и замены часто бывает полезно предоставлять
пользователям возможность индивидуально настраивать различные аспекты
операции поиска и замены. Помимо указания входного файла, пользователям
может потребоваться задать шаблон поиска и текст замены, чтобы настроить
утилиту под свои нужды.
Листинг 8.3. Обработка нескольких аргументов в clap
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
344
Консольные программы
about = "Консольная утилита для поиска и замены текста",
name = "Поиск и замена"
)]
struct Args {
/// Указывает обрабатываемый входной файл
#[arg(short = 'i', long = "input")]
input: String,
/// Указывает поисковый шаблон
#[arg(short = 'f', long = "find")]
find: String,
}
/// Указывает текст замены
#[arg(short = 'r', long = "replace")]
replace: String,
fn main() {
// Здесь мы обращаемся к аргументам
// input, find и replace и их используем
let args = Args::parse();
}
// Теперь значения доступны через args.input, args.find и args.replace
В приведенном выше фрагменте исходного кода мы расширили обработку
аргументов, включив два дополнительных аргумента: find (найти) и replace
(заменить). Эти новые аргументы позволяют пользователям определять как
поисковый шаблон, так и текст замены, тем самым повышая универсальность
утилиты. Каждый аргумент следует той же структуре, что и аргумент input, отличаясь лишь названием и описанием.
Введя эти новые аргументы, теперь крайне важно понять, как библиотека
clap бесшовно справляется с разбором нескольких аргументов. Как и прежде,
мы используем функцию Args::parse() для автоматической обработки и извлечения значений аргументов input, find и replace.
Разобранные значения этих аргументов сохраняются в переменной args, что
позволяет обращаться к ним и использовать их по мере необходимости в логике программы на Rust. Благодаря библиотеке clap процесс обработки нескольких аргументов не только прост, но и сопровождается автоматической генерацией понятных справочных сообщений и информации об использовании.
В целях демонстрации способности библиотеки clap генерировать полезную справочную информацию попробуйте запустить программу с флагом -h,
как показано в следующей ниже команде:
Листинг 8.4. Команда генерации справочной информации в clap
$ cargo run -- -h
Исполнение этой команды приводит к созданию сообщения об использовании, которое наглядно резюмирует доступные опции и их описания, облегчая
пользователям понимание того, как взаимодействовать с утилитой. Сгенерированное сообщение включает такие детали, как ожидаемые опции, их короткие и длинные флаги, а также указание на то, принимают они значения или нет.
Обработка аргументов-флагов 345
Листинг 8.5. Сообщение со справочной информацией в clap
Usage: find-replace --input <INPUT> --find <FIND> --replace <REPLACE>
Options:
-i, --input <INPUT>
Sets the input file to process
-f, --find <FIND>
Sets the pattern to find
-r, --replace <REPLACE> Sets the replacement text
-h, --help
Print help
-V, --version
Print version
Внедрив несколько аргументов и задействовав библиотеку clap для их разбора и генерации справочной информации, мы расширили функциональность
и повысили удобство утилиты. Теперь пользователи могут легко настраивать
операции поиска и замены под свои потребности, с легкостью указывая входной файл, поисковый шаблон и текст замены.
Обработка аргументов-флагов
Когда говорят об обработке аргументов командного интерфейса для утилиты, речь идет не только о работе со значениями; иногда требуется включать
или отключать определенные функциональные средства с помощью так называемых аргументов-флагов. Давайте рассмотрим эту концепцию подробнее
на примере:
Листинг 8.6. Пример обработки аргументов-флагов
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
about = "Консольная утилита для поиска и замены текста",
name = "Поиск и замена"
)]
struct Args {
/// Указывает обрабатываемый входной файл
#[arg(short = 'i', long = "input")]
input: String,
/// Указывает поисковый шаблон
#[arg(short = 'f', long = "find")]
find: String,
/// Указывает текст замены
#[arg(short = 'r', long = "replace")]
replace: String,
/// Выполнить поиск и замену без учета регистра символов
#[arg(short = 'c', long = "ignore-case")]
ignore_case: bool,
}
346
Консольные программы
fn main() {
// Здесь мы обращаемся к аргументам
// input, find, replace и ignore-case и их используем
let args = Args::parse();
}
// Флаг args.ignore_case будет равен true, если
// пользователь указал -c или --ignore-case
В приведенном выше фрагменте исходного кода мы интегрировали
аргумент-флаг под названием ignore-case (игнорировать регистр). Он доступен как через короткий флаг -c, так и через длинный --ignore-case. В отличие
от аргументов, требующих значений (таких как имена файлов или шаблоны),
подобного рода аргументы-флаги не нуждаются в каких-либо дополнительных
входных данных от пользователя. Они служат исключительно индикаторами
того, хочет ли пользователь, чтобы операция поиска и замены выполнялась
без учета регистра символов или нет.
Волшебство обработки аргументов-флагов с помощью библиотеки clap заключается в том, что она так же проста, как и работа с аргументами, требующими значений. Внутри исходного кода можно легко проверять наличие
конкретного флага в структуре args. Если он присутствует, то мы понимаем,
что пользователь выбрал соответствующее функциональное средство. Такой
упрощенный подход позволяет безболезненно упаковывать аргументы-флаги
в консольную утилиту, повышая ее удобство и гибкость.
Обработка продвинутых аргументов
Библиотека clap предоставляет широкие возможности для обработки аргументов, включая подкоманды, позиционные аргументы и конкретно-прикладную
проверку правильности (валидацию). Хотя мы не будем рассматривать здесь
все продвинутые функциональные средства, мы кратко познакомим вас с подкомандами, так как они бывают полезными для организации сложных утилит.
Подкоманда подобна отдельной команде внутри утилиты, каждая из которых имеет свой собственный набор аргументов. Например, наша утилита поиска и замены могла бы иметь подкоманды для разных операций манипулирования текстом.
Ниже приводится упрощенный пример определения и использования подкоманды с помощью библиотеки clap:
Листинг 8.7. Пример продвинутой обработки аргументов
use clap::Args;
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
about = "Консольная утилита для манипуляций с текстом",
name = "Утилита для манипуляций с текстом"
)]
Обработка продвинутых аргументов 347
pub struct Cli {
/// Включить вывод отладочной информации.
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
/// Команды поиска и замены
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Подкоманда для обработки операций поиска
Find(FindCommands),
/// Подкоманда для обработки операций замены
Replace(ReplaceCommands),
}
/// Представляет команды, связанные с поиском
#[derive(Debug, Args)]
pub struct FindCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
}
/// Представляет команды, связанные с заменой
#[derive(Debug, Args)]
pub struct ReplaceCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
/// Указывает текст замены
#[clap(short = 'r', long = "replace")]
pub replace: Option<String>,
}
fn main() {
let args = Cli::parse();
match args.command {
Some(Commands::Find(command)) => {
match command.input {
Some(ref input) => {
// запуск:
// cargo run -- find --input имя_файла
println!("{:?}", input);
}
348
Консольные программы
None => {
println!("Укажите имя файла.");
}
};
match command.pattern {
Some(ref pattern) => {
// запуск:
// cargo run -- find --pattern конкретно_прикладной_шаблон
println!("{:?}", pattern);
}
None => {
println!("Укажите шаблон.");
}
};
}
Some(Commands::Replace(command)) => {
match command.input {
Some(ref input) => {
// запуск:
// cargo run -- replace --input имя_файла
println!("{:?}", input);
}
None => {
println!("Укажите имя файла.");
}
};
match command.pattern {
Some(ref pattern) => {
// запуск:
// cargo run -- replace --pattern конкретно_прикладной_шаблон
println!("{:?}", pattern);
}
None => {
println!("Укажите шаблон.");
}
};
match command.replace {
Some(ref replace) => {
// запуск:
// cargo run -- replace --replace строковое_значение
println!("{:?}", replace);
}
None => {
println!("Укажите заменяемый текст.");
}
};
}
None => println!(
"Неизвестная команда. Используйте '--help' для получения инструкций."
),
};
}
В приведенном выше исходном коде мы определили две подкоманды – find
(поиск) и replace (замена), каждая из которых имеет свой собственный набор
аргументов. Пользователи могут запускать утилиту с одной из этих подкоманд
и выполнять конкретные задачи по манипулированию текстом.
Реализация логики поиска и замены текста 349
Листинг 8.8. Простейший пример консольных команд в clap
$ cargo run -- -h
A command-line text manipulation utility
Usage: find-replace [OPTIONS] [COMMAND]
Commands:
find
Subcommand for handling find operations
replace Subcommand for handling replace operations
help
Print this message or the help of the given subcommand(s)
Options:
-d, --debug...
-h, --help
-V, --version
Turn debugging information on
Print help
Print version
$ cargo run -- find -h
Subcommand for handling find operations
Usage: find-replace find [OPTIONS]
Options:
-i, --input <INPUT>
-p, --pattern <PATTERN>
-h, --help
Sets the input file to process
Sets the pattern to find
Print help
$ cargo run -- replace -h
Subcommand for handling replace operations
Usage: find-replace replace [OPTIONS]
Options:
-i, --input <INPUT>
-p, --pattern <PATTERN>
-r, --replace <REPLACE>
-h, --help
Sets the input file to process
Sets the pattern to find
Sets the replacement text
Print help
Реализация логики поиска и замены текста
В предыдущих разделах мы провели обследование основ разбора аргументов
командного интерфейса с использованием библиотеки clap на Rust. Получив
четкое понимание того, как эффективно определять и разбирать аргументы,
теперь самое время приступить к реализации стержневой функциональности
утилиты поиска и замены.
Обзор функциональности утилиты поиска и замены текста
Основная задача разрабатываемой утилиты – выполнять поиск определенного шаблона в заданном входном файле и впоследствии заменять все вхождения этого шаблона на текст, указанный пользователем. Эта задача охваты-
350
Консольные программы
вает несколько ключевых операций, включая чтение содержимого входного
файла, выполнение заданий по обработке текста и последующую запись измененного содержимого обратно в тот же файл. Кроме того, утилита должна
быть достаточно гибкой, чтобы обрабатывать различные сценарии, такие как
поиск без учета регистра символов и способность обработки нескольких файлов одновременно.
Для реализации этой функциональности мы наметили системный подход,
состоящий из нескольких отдельных этапов.
Чтение входного файла: первый шаг включает открытие и получение
доступа к назначенному входному файлу. Мы читаем все его содержимое
и сохраняем его в памяти в виде строкового литерала. Такое представление в памяти позволяет эффективно выполнять последующие операции
по обработке текста.
Поиск и замена: после того как содержимое файла будет доступно
в виде строкового литерала, мы используем функции Rust для манипуляции строковыми литералами, чтобы найти вхождения указанного шаб
лона. При обнаружении совпадений мы заменяем их на текст замены,
предоставленный пользователем. Этот этап имеет решающее значение
для внесения желаемых изменений в файл.
Запись измененного содержимого: после успешного выполнения
операции поиска и замены мы переходим к записи измененного содержимого обратно в тот же входной файл. Это действие предусмат
ривает перезапись изначального содержимого обновленной версией,
что гарантирует отражение запрошенных пользователем изменений
в файле.
Опциональный поиск без учета регистра символов: утилита предлагает пользователям возможность указывать флаг --ignore-case. Если
этот флаг установлен, то программа будет выполнять операцию поиска
и замены без учета регистра символов. Благодаря ему вхождения указанного шаблона будут гарантированно обнаруживаться независимо
от того, написаны они прописными или строчными буквами.
Поддержка нескольких файлов: в целях повышения универсальности
и эффективности утилиты мы сконструировали ее с поддержкой обработки нескольких входных файлов за один запуск. Это означает, что
пользователи могут применять одну и ту же функциональность поиска
и замены к нескольким файлам, не запуская программу отдельно для
каждого из них.
Очертив этот план, мы готовы приступить к реализации первых трех этапов,
закладывая фундамент для стержневой функциональности нашей утилиты.
Чтение входного файла
Для чтения содержимого входного файла мы воспользуемся стандартной биб
лиотекой Rust, которая предоставляет надежные возможности файлового ввода-вывода. Мы откроем файл, прочитаем его содержимое и сохраним его в памяти в виде строкового литерала.
Реализация логики поиска и замены текста 351
Листинг 8.9. Пример чтения входного файла
use
use
use
use
clap::Args;
clap::{Parser, Subcommand};
std::fs::File;
std::io::{self, Read};
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
about = "Консольная утилита для манипуляций с текстом",
name = "Утилита для манипуляций с текстом"
)]
pub struct Cli {
/// Включить вывод отладочной информации
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
/// Команды поиска и замены
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Подкоманда для обработки операций поиска
Find(FindCommands),
/// Подкоманда для обработки операций замены
Replace(ReplaceCommands),
}
/// Представляет команды, связанные с поиском
#[derive(Debug, Args)]
pub struct FindCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
}
/// Представляет команды, связанные с заменой
#[derive(Debug, Args)]
pub struct ReplaceCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
352
Консольные программы
/// Указывает текст замены
#[clap(short = 'r', long = "replace")]
pub replace: Option<String>,
}
// Функция принимает путь к файлу как заимствованный строковый литерал (&str)
fn read_file_to_string(file_path: &str) -> io::Result<String> { // ①
let mut file = File::open(file_path)?; // ②
let mut contents = String::new();
// ②
file.read_to_string(&mut contents)?;
// ②
Ok(contents) // Вернуть владение строковым литералом
}
fn main() {
let args = Cli::parse();
let input_content;
// ③
match args.command {
Some(Commands::Find(command)) => { // ④
match command.input {
Some(ref input) => {
// запуск:
// cargo run -- find --input файл.txt
input_content = read_file_to_string(input).unwrap();
println!("Содержимое входного файла: {:?}", input_content);
}
None => {
println!("Укажите имя файла.");
}
};
match command.pattern {
Some(ref pattern) => {
// запуск:
// cargo run -- find --pattern конкретно_прикладной_шаблон
println!("{:?}", pattern);
}
None => {
println!("Укажите шаблон.");
}
};
}
Some(Commands::Replace(command)) => { // ⑤
match command.input {
Some(ref input) => {
// запуск:
// cargo run -- replace --input имя_файла
println!("{:?}", input);
}
None => {
println!("Укажите имя файла.");
}
};
match command.pattern {
Some(ref pattern) => {
// запуск:
// cargo run -- replace --pattern конкретно_прикладной_шаблон
Реализация логики поиска и замены текста 353
println!("{:?}", pattern);
}
None => {
println!("Укажите шаблон.");
}
};
match command.replace {
Some(ref replace) => {
// запуск:
// cargo run -- replace --replace строковое значение
println!("{:?}", replace);
}
None => {
println!("Укажите текст для замены.");
}
};
}
None => println!(
"Неизвестная команда. Используйте '--help' для получения инструкций."
),
};
}
// Пример вывода в терминале:
// $ cargo run -- find --input file.txt
// Содержимое входного файла: "Hello World\n"
Приведенный выше фрагмент исходного кода демонстрирует процесс чтения содержимого входного файла на Rust. Эта функциональность необходима
для последующих операций поиска и замены в консольной утилите манипуляций с текстом.
① Для того чтобы прочитать содержимое входного файла, в исходном коде
задействуется стандартная библиотека Rust, которая предлагает надежные возможности файлового ввода-вывода. В частности, импортируются
необходимые модули и определяется функция read_file_to_string. Эта
функция принимает путь к файлу в качестве аргумента и возвращает тип
Result, который содержит в себе содержимое файла в виде строкового
литерала либо ошибку, связанную с вводом-выводом.
② Внутри функции read_file_to_string открывается файл, указанный в параметре file_path, а любые потенциальные ошибки обрабатываются с помощью механизмов Rust по обработке ошибок. Исходный код инициализирует пустую структуру типа String с именем contents и переходит
к чтению содержимого файла в эту структуру, используя метод read_to_
string. Наконец, в случае успеха операция возвращает вариант Ok с содержимым файла.
③ Функция main служит точкой входа в программу. Она использует библио
теку clap для разбора аргументов командного интерфейса и структурирования интерфейса приложения. В зависимости от переданной команды
(find либо replace) она извлекает соответствующие опции, такие как путь
к входному файлу, поисковый шаблон и, если применимо, текст замены.
354
Консольные программы
④ Например, когда указана команда find, исходный код проверяет, были
ли предоставлены путь к файлу и шаблон в качестве аргументов командного интерфейса. Если обе опции присутствуют, то вызывается функция
read_file_to_string для чтения содержимого файла, которое затем выводится в консоль. Аналогично выводится и указанный шаблон. Если какие-либо из обязательных опций отсутствуют, то на экран выводятся соответствующие сообщения об ошибках.
⑤ Точно так же при вызове команды replace исходный код проверяет
наличие пути к файлу, шаблона и текста замены, после чего выводит
их на экран. Он также щадяще обрабатывает отсутствие опций, выводя
на экран соответствующие уведомления об ошибках.
Приведенный выше фрагмент исходного кода закладывает основу для чтения входных файлов и извлечения аргументов командного интерфейса для
последующих операций по обработке текста. В нем используются механизмы
Rust по обработке ошибок и библиотека clap для эффективного и удобного
взаимодействия с пользователем, что делает ее полезным инструментом для
обработки текстовых данных.
Логика поиска и замены
Центральная функциональность утилиты сосредоточена вокруг поиска вхождений определенного шаблона в заданном текстовом содержимом и их замещения предоставленным текстом замены. Для выполнения этой задачи мы задействуем встроенный в Rust метод replace для строковых литералов. Этот метод позволяет эффективно осуществлять операцию поиска и замены.
Для того чтобы сделать эту концепцию более конкретной, давайте углубимся в реализацию простейшей функции поиска и замены. Процесс демонстрируется в следующем ниже фрагменте исходного кода:
Листинг 8.10. Логика поиска и замены в консольной утилите
use
use
use
use
clap::Args;
clap::{Parser, Subcommand};
std::fs::File;
std::io::{self, Read};
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
about = "Консольная утилита для манипуляций с текстом",
name = "Утилита для манипуляций с текстом"
)]
pub struct Cli {
/// Включить вывод отладочной информации
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
/// Команды поиска и замены
#[command(subcommand)]
pub command: Option<Commands>,
}
Реализация логики поиска и замены текста 355
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Подкоманда для обработки операций поиска
Find(FindCommands),
/// Подкоманда для обработки операций замены
Replace(ReplaceCommands),
}
/// Представляет команды, связанные с поиском
#[derive(Debug, Args)]
pub struct FindCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
}
/// Представляет команды, связанные с заменой.
#[derive(Debug, Args)]
pub struct ReplaceCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
/// Указывает текст замены
#[clap(short = 'r', long = "replace")]
pub replace: Option<String>,
}
fn read_file_to_string(file_path: &str) -> io::Result<String> {
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
// Функция выполняет поиск и замену, принимая мутируемую ссылку
// на строковый литерал
fn find_and_replace(content: &mut String, pattern: &str, replacement: &str) {
// Метод .replace возвращает новый строковый литерал,
// который мы записываем по адресу ссылки
*content = content.replace(pattern, replacement);
}
fn main() {
let args = Cli::parse();
let mut input_content = "".to_string();
let mut pattern_to_find = "".to_string();
let mut replacement_text = "".to_string();
356
Консольные программы
match args.command {
Some(Commands::Find(command)) => {
if let Some(input) = &command.input {
input_content = read_file_to_string(input).unwrap();
println!("Содержимое входного файла: {:?}", input_content);
} else {
println!("Укажите имя файла.");
}
if let Some(pattern) = &command.pattern {
pattern_to_find = pattern.clone();
println!("Шаблон: {:?}", pattern);
} else {
println!("Укажите шаблон.");
}
}
Some(Commands::Replace(command)) => {
if let Some(input) = &command.input {
input_content = read_file_to_string(input).unwrap();
println!("Содержимое входного файла: {:?}", input_content);
} else {
println!("Укажите имя файла.");
}
if let Some(pattern) = &command.pattern {
pattern_to_find = pattern.clone();
println!("Шаблон: {:?}", pattern_to_find);
} else {
println!("Укажите шаблон.");
}
if let Some(replace) = &command.replace {
replacement_text = replace.clone();
println!("Текст замены: {:?}", replacement_text);
} else {
println!("Укажите текст замены.");
}
}
None => println!(
"Неизвестная команда. Используйте '--help' для получения инструкций."
),
};
println!("До замены: {:?}", input_content);
find_and_replace(&mut input_content, &pattern_to_find, &replacement_text);
println!("После замены: {:?}", input_content);
}
// Пример работы в терминале:
// $ cargo run -- replace -i file.txt -p World -r Rust
//
//
//
//
Шаблон: "World"
Текст замены: "Rust"
До замены: "Hello World\n"
После замены: "Hello Rust\n"
Реализация логики поиска и замены текста 357
В приведенном выше исходном коде на Rust мы начинаем с определения
консольной утилиты для манипуляций с текстом, используя библиотеку clap.
Утилита поддерживает различные команды, включая find и replace, и принимает дополнительные опции для индивидуальной настройки. Такой модульный подход повышает гибкость и удобство использования утилиты.
Структура Cli представляет основной командный интерфейс, позволяя
пользователям выбирать между разными командами, такими как find либо
replace. Каждая команда имеет свой набор параметров и опций, что делает
утилиту универсальным инструментом для различных задач по обработке
текста.
Структуры FindCommands и ReplaceCommands представляют подкоманды соответственно для операций поиска и замены. Эти структуры определяют
конкретные опции, такие как пути к входным файлам, поисковые шаблоны
и текст замены, что позволяет пользователям точно настраивать свои задачи
по обработке текста.
Для облегчения чтения файлов мы реализовали функцию read_file_to_
string, которая принимает путь к файлу в качестве аргумента и возвращает
содержимое файла в виде значения типа String. Эта функция задействует способности Rust по обработке ошибок с целью щадящего управления операциями файлового ввода-вывода.
Стержневая функциональность поиска и замены сосредоточена в функции
find_and_replace. Данная функция принимает мутируемую ссылку на значение типа String с содержимым, а также поисковый шаблон и текст замены.
Используя метод replace Rust, она эффективно выполняет операцию поиска
и замены, изменяя содержимое прямо на месте.
В функции main мы координируем исполнение утилиты на основе выбранной пользователем команды и предоставленных опций. Мы обрабатываем аргументы командного интерфейса и введенные пользователем данные, читаем
входной файл (если он указан) и выполняем операцию поиска и замены, выводя на экран содержимое до и после преобразования.
В данном примере мы выполняем команду replace, указав входной файл
(file.txt), поисковый шаблон ("World") и текст замены ("Rust"). Утилита обрабатывает файл, выявляет вхождения шаблона и заменяет их предоставленным
текстом. Результат выводится на экран, демонстрируя содержимое как до, так
и после преобразования.
Наша утилита представляет собой гибкий и мощный инструмент манипулирования текстом, который можно индивидуально настраивать с помощью
опций командного интерфейса и подкоманд. Она эффективно выполняет операции поиска и замены, что делает ее ценным помощником при обработке
текстовых данных.
Запись измененного содержимого
Для того чтобы записать измененное содержимое обратно во входной файл,
мы воспользуемся способностями Rust по файловому вводу-выводу. Мы откроем входной файл в режиме записи, перенесем в него измененное содержимое и перезапишем любые существующие данные.
358
Консольные программы
Листинг 8.11. Перезапись входного файла
use
use
use
use
clap::Args;
clap::{Parser, Subcommand};
std::fs::File;
std::io::{self, Read, Write};
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
about = "Консольная утилита для манипуляций с текстом",
name = "Утилита для манипуляций с текстом"
)]
pub struct Cli {
/// Включить вывод отладочной информации
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
/// Команды поиска и замены
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Подкоманда для обработки операций поиска
Find(FindCommands),
/// Подкоманда для обработки операций замены
Replace(ReplaceCommands),
}
/// Представляет команды, связанные с поиском
#[derive(Debug, Args)]
pub struct FindCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
}
/// Представляет команды, связанные с заменой
#[derive(Debug, Args)]
pub struct ReplaceCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
Реализация логики поиска и замены текста 359
/// Указывает текст замены
#[clap(short = 'r', long = "replace")]
pub replace: Option<String>,
}
fn read_file_to_string(file_path: &str) -> io::Result<String> {
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn find_and_replace(content: &mut String, pattern: &str, replacement: &str) {
*content = content.replace(pattern, replacement);
}
// Функция для записи строкового литерала в файл (перезаписывает содержимое)
fn write_string_to_file(file_path: &str, content: &str) -> io::Result<()> {
let mut file = File::create(file_path)?;
file.write_all(content.as_bytes())?;
Ok(())
}
fn main() -> io::Result<()> {
let args = Cli::parse();
let mut input_content = "".to_string();
let mut pattern_to_find = "".to_string();
let mut replacement_text = "".to_string();
let mut input_file_path = "".to_string();
match args.command {
Some(Commands::Find(command)) => {
if let Some(input) = &command.input {
input_content = read_file_to_string(input)?;
println!("Содержимое входного файла: {:?}", input_content);
} else {
println!("Укажите имя файла.");
}
if let Some(pattern) = &command.pattern {
pattern_to_find = pattern.clone();
println!("Шаблон: {:?}", pattern);
} else {
println!("Укажите шаблон.");
}
}
Some(Commands::Replace(command)) => {
if let Some(input) = &command.input {
input_file_path = input.to_string();
input_content = read_file_to_string(input)?;
println!("Содержимое входного файла: {:?}", input_content);
} else {
println!("Укажите имя файла.");
}
if let Some(pattern) = &command.pattern {
360
Консольные программы
pattern_to_find = pattern.clone();
println!("Шаблон: {:?}", pattern_to_find);
} else {
println!("Укажите шаблон.");
}
if let Some(replace) = &command.replace {
replacement_text = replace.clone();
println!("Текст замены: {:?}", replacement_text);
} else {
println!("Укажите текст для замены.");
}
}
None => println!(
"Неизвестная команда. Используйте '--help' для получения инструкций."
),
};
println!("Содержимое файла ДО: {:?}", input_content);
// Выполнить поиск и замену в памяти
find_and_replace(&mut input_content, &pattern_to_find, &replacement_text);
// Сохранить результат обратно в файл
write_string_to_file(&input_file_path, &input_content)?;
// Повторно прочитать файл для подтверждения изменений
input_content = read_file_to_string(&input_file_path)?;
println!("Содержимое файла ПОСЛЕ: {:?}", input_content);
Ok(())
}
// Пример работы в терминале:
// $ cargo run -- replace -i file.txt -p World -r Rust
//
//
//
//
//
Содержимое входного файла: "Hello World\n"
Шаблон: "World"
Текст замены: "Rust"
Содержимое файла ДО: "Hello World\n"
Содержимое файла ПОСЛЕ: "Hello Rust\n"
В приведенном выше исходном коде мы определяем функцию write_string_
to_file, которая принимает путь к файлу и записываемое содержимое. Она открывает файл в режиме записи, записывает содержимое и обрабатывает любые потенциальные ошибки.
Благодаря этой реализации мы можем успешно читать входной файл, выполнять поиск и замену, а затем записывать измененное содержимое обратно в файл.
Поиск без учета регистра символов (опционально)
Нашу утилиту можно улучшить, добавив поддержку поиска и замены без учета регистра символов. Для этого понадобится изменить функцию find_and_
replace, чтобы она выполняла поиск, игнорируя регистр символов. Для достижения этой цели можно задействовать регулярные выражения Rust:
Реализация логики поиска и замены текста 361
Листинг 8.12. Реализация поиска без учета регистра
use
use
use
use
use
clap::Args;
clap::{Parser, Subcommand};
std::fs::File;
std::io::{self, Read, Write};
regex::Regex;
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
about = "Консольная утилита для манипуляций с текстом",
name = "Утилита для манипуляций с текстом"
)]
pub struct Cli {
/// Включить вывод отладочной информации
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
/// Команды поиска и замены
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Подкоманда для обработки операций поиска
Find(FindCommands),
/// Подкоманда для обработки операций замены
Replace(ReplaceCommands),
}
/// Представляет команды, связанные с поиском
#[derive(Debug, Args)]
pub struct FindCommands {
/// Указывает входной файл для обработки
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает шаблон для поиска
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
}
/// Представляет команды, связанные с заменой
#[derive(Debug, Args)]
pub struct ReplaceCommands {
/// Указывает обрабатываемый входной файл
#[clap(short = 'i', long = "input")]
pub input: Option<String>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
362
Консольные программы
/// Указывает текст замены
#[clap(short = 'r', long = "replace")]
pub replace: Option<String>,
/// Выполнить поиск и замену без учета регистра
#[clap(short = 'c', long = "ignore-case")]
pub ignore_case: Option<bool>,
}
fn read_file_to_string(file_path: &str) -> io::Result<String> {
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn find_and_replace(
content: &mut String,
pattern: &str,
replacement: &str,
ignore_case: bool
)
{
let regex = if ignore_case {
// (?i) включает нечувствительность к регистру в регулярном выражении
Regex::new(&format!(r"(?i){}", pattern)).unwrap()
} else {
Regex::new(pattern).unwrap()
};
// replace_all выполняет замену всех вхождений
*content = regex.replace_all(content, replacement).to_string();
}
fn write_string_to_file(file_path: &str, content: &str) -> io::Result<()> {
let mut file = File::create(file_path)?;
file.write_all(content.as_bytes())?;
Ok(())
}
fn main() -> io::Result<()> {
let args = Cli::parse();
let mut input_content = "".to_string();
let mut pattern_to_find = "".to_string();
let mut replacement_text = "".to_string();
let mut input_file_path = "".to_string();
let mut ignore_case = false;
match args.command {
Some(Commands::Find(command)) => {
if let Some(input) = &command.input {
input_content = read_file_to_string(input)?;
println!("Содержимое входного файла: {:?}", input_content);
} else {
println!("Укажите имя файла.");
}
Реализация логики поиска и замены текста 363
if let Some(pattern) = &command.pattern {
pattern_to_find = pattern.clone();
println!("Шаблон: {:?}", pattern);
} else {
println!("Укажите шаблон.");
}
}
Some(Commands::Replace(command)) => {
if let Some(input) = &command.input {
input_file_path = input.to_string();
input_content = read_file_to_string(input)?;
println!("Содержимое входного файла: {:?}", input_content);
} else {
println!("Укажите имя файла.");
}
if let Some(pattern) = &command.pattern {
pattern_to_find = pattern.clone();
println!("Шаблон: {:?}", pattern_to_find);
} else {
println!("Укажите шаблон.");
}
if let Some(replace) = &command.replace {
replacement_text = replace.clone();
println!("Текст замены: {:?}", replacement_text);
} else {
println!("Укажите текст для замены.");
}
if let Some(ignore) = &command.ignore_case {
ignore_case = *ignore;
println!("Игнорировать регистр: {:?}", ignore_case);
} else {
println!("Укажите флаг чувствительности к регистру.");
}
println!("Содержимое файла ДО: {:?}", input_content);
find_and_replace(
&mut input_content,
&pattern_to_find,
&replacement_text,
ignore_case,
);
write_string_to_file(&input_file_path, &input_content)?;
input_content = read_file_to_string(&input_file_path)?;
println!("Содержимое файла ПОСЛЕ: {:?}", input_content);
}
None => println!(
"Неизвестная команда. Используйте '--help' для получения инструкций."
)
};
Ok(())
}
// Пример работы в терминале:
// $ cargo run -- replace -i file.txt -p world -r Rust -c
364
//
//
//
//
//
Консольные программы
Содержимое входного файла: "Hello World\n"
Шаблон: "world"
Текст замены: "Rust"
Содержимое файла ДО: "Hello World\n"
Содержимое файла ПОСЛЕ: "Hello Rust\n"
В приведенном выше модифицированном исходном коде мы используем
пакет regex для создания шаблона регулярного выражения, который выполняет поиск без учета регистра символов, если значение флага ignore_case установлено равным true. Структура regex::Regex позволяет заменять все вхождения шаблона текстом замены.
Благодаря этим изменениям утилита способна опционально выполнять замену без учета регистра символов.
Поддержка нескольких входных файлов
В целях повышения универсальности утилиты крайне важно реализовать
поддержку обработки нескольких входных файлов за один запуск. Эта функциональность дает пользователям возможность выполнять операции поиска
и замены в нескольких файлах без необходимости запускать утилиту отдельно
для каждого из них. Реализация данной функциональности не только удобна,
но и повышает эффективность работы утилиты.
Подход к реализации этой способности заключается в приеме нескольких
путей к файлам в качестве аргументов и систематическом итерировании
по ним, применяя логику поиска и замены к каждому файлу. В обновленной
функции main мы бесшовно интегрировали эту функциональность в существующий командный интерфейс.
Ниже представлена обновленная версия функции main, которая поддерживает работу с несколькими входными файлами.
Листинг 8.13. Добавление поддержки нескольких входных файлов
use
use
use
use
use
clap::Args;
clap::{Parser, Subcommand};
std::fs::File;
std::io::{self, Read, Write};
regex::Regex;
#[derive(Parser, Debug)]
#[command(
author = "Махмуд Хармуш",
version = "1.0",
about = "Консольная утилита для манипуляций с текстом",
name = "Утилита для манипуляций с текстом"
)]
pub struct Cli {
/// Включить вывод отладочной информации
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
/// Команды поиска и замены
#[command(subcommand)]
pub command: Option<Commands>,
}
Реализация логики поиска и замены текста 365
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Подкоманда для обработки операций поиска
Find(FindCommands),
/// Подкоманда для обработки операций замены
Replace(ReplaceCommands),
}
/// Представляет команды, связанные с поиском
#[derive(Debug, Args)]
pub struct FindCommands {
/// Указывает входные файлы для обработки
/// (можно указать несколько через пробел).
#[clap(short = 'i', long = "input", num_args = 1.., value_delimiter = ' ')]
pub input: Option<Vec<String>>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
}
/// Представляет команды, связанные с заменой
#[derive(Debug, Args)]
pub struct ReplaceCommands {
/// Указывает входные файлы для обработки
/// (можно указать несколько через пробел)
#[clap(short = 'i', long = "input", num_args = 1.., value_delimiter = ' ')]
pub input: Option<Vec<String>>,
/// Указывает поисковый шаблон
#[clap(short = 'p', long = "pattern")]
pub pattern: Option<String>,
/// Указывает текст замены
#[clap(short = 'r', long = "replace")]
pub replace: Option<String>,
/// Выполнить поиск и замену без учета регистра
#[clap(short = 'c', long = "ignore-case")]
pub ignore_case: bool,
}
fn read_file_to_string(file_path: &str) -> io::Result<String> {
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn find_and_replace(
content: &mut String,
pattern: &str,
replacement: &str,
ignore_case: bool
) {
let regex = if ignore_case {
366
Консольные программы
Regex::new(&format!(r"(?i){}", pattern)).unwrap()
} else {
Regex::new(pattern).unwrap()
};
*content = regex.replace_all(content, replacement).to_string();
}
fn write_string_to_file(file_path: &str, content: &str) -> io::Result<()> {
let mut file = File::create(file_path)?;
file.write_all(content.as_bytes())?;
Ok(())
}
fn main() -> io::Result<()> {
let args = Cli::parse();
let mut input_files: Vec<String> = vec!["".to_string()];
let mut pattern_to_find = "".to_string();
let mut replacement_text = "".to_string();
let mut ignore_case = false;
match args.command {
Some(Commands::Find(command)) => {
if let Some(input) = &command.input {
input_files = input.to_vec();
} else {
println!("Укажите имя файла.");
}
if let Some(pattern) = &command.pattern {
pattern_to_find = pattern.clone();
println!("Шаблон: {:?}", pattern);
} else {
println!("Укажите шаблон.");
}
for input_file_path in input_files {
let mut input_content = read_file_to_string(&input_file_path)?;
find_and_replace(&mut input_content, &pattern_to_find, "", ignore_case);
println!("Найдено содержимое: {:?}", input_content);
}
}
Some(Commands::Replace(command)) => {
if let Some(input) = &command.input {
input_files = input.to_vec();
} else {
println!("Укажите имя файла.");
}
if let Some(pattern) = &command.pattern {
pattern_to_find = pattern.clone();
println!("Шаблон: {:?}", pattern_to_find);
} else {
println!("Укажите шаблон.");
}
if let Some(replace) = &command.replace {
replacement_text = replace.clone();
println!("Текст замены: {:?}", replacement_text);
} else {
Заключение 367
}
println!("Укажите текст замены.");
ignore_case = command.ignore_case;
println!("Игнорировать регистр: {:?}", ignore_case);
for input_file_path in input_files {
let mut input_content = read_file_to_string(&input_file_path)?;
println!("Содержимое файла ДО: {:?}", input_content);
find_and_replace(
&mut input_content,
&pattern_to_find,
&replacement_text,
ignore_case
);
// Записываем измененное содержимое обратно в файл
write_string_to_file(&input_file_path, &input_content)?;
input_content = read_file_to_string(&input_file_path)?;
println!("Содержимое файла ПОСЛЕ: {:?}", input_content);
}
}
None => println!("Неизвестная команда. Используйте '--help' для инструкций.")
};
Ok(())
}
// Пример запуска в терминале:
// $ cargo run -- replace -i file1.txt file2.txt file3.txt -p world -r Rust -c
//
//
//
//
//
Шаблон: "world"
Текст замены: "Rust"
Игнорировать регистр: true
Содержимое файла ДО: "Hello World\n"
Содержимое файла ПОСЛЕ: "Hello Rust\n"
Благодаря внедрению этих улучшений утилита стала более мощным и удобным инструментом для бесшовного и эффективного выполнения операций
поиска и замены в нескольких файлах. Пользователи могут с уверенностью использовать эту утилиту для оптимизации задач по манипулированию текстом
в самых разных файлах.
Заключение
В данной главе мы подробно рассмотрели основные концепции разбора аргументов командного интерфейса на Rust, используя универсальную библиотеку
clap. В ходе обследования мы всесторонне обследовали несколько фундаментальных аспектов обработки аргументов: от их определения и разбора до эффективного управления аргументами с ассоциированными значениями и аргументами-флагами. Кроме того, мы ввели концепцию подкоманд как надежную стратегию организации сложных служебных программ. Это комплексное
368
Консольные программы
понимание процедуры разбора аргументов закладывает прочный фундамент
для создания консольных приложений на Rust.
Более того, мы не только усвоили теоретические знания, но и применили
их на практике, реализовав основную функциональность утилиты поиска и замены на Rust. Практическая работа включала чтение содержимого входных
файлов, эффективное выполнение операций поиска и замены и последующее
сохранение измененного содержимого обратно в соответствующие файлы.
Мы также обследовали такие функциональные возможности, как реализация
поиска без учета регистра символов и обработка нескольких файлов, что повысило удобство использования утилиты. Этот практический опыт продемонстрировал практическое применение процедуры разбора аргументов и показал ее ключевую роль в создании удобных консольных инструментов.
Заглядывая вперед, в следующей главе мы погрузимся в тему операций ввода с устройств и вывода на устройства, что является распространенной и важной задачей в системном программировании. Мы проведем обследование
тонкостей чтения из файлов и записи в них, используя надежные способности
стандартной библиотеки Rust. На протяжении всей главы мы будем прояснять
сложности основных операций с файловой системой, что позволит уверенно
перемещаться по файлам и манипулировать ими. Кроме того, мы затронем захватывающую область работы с аппаратными устройствами посредством интерфейсов с Rust, получив ценные сведения о взаимодействии на системном
уровне. Темы включают: ввод-вывод файлов на Rust, освоение распространенных операций файловой системы и получение базового обзора взаимодействия с оборудованием на Rust. Эти знания оснастят вас умениями, необходимыми для решения широкого спектра задач системного программирования.
Дополнительные ресурсы
В целях повышения своего профессионализма в разработке консольных программ на Rust рекомендуем обратиться к следующим ниже ресурсам.
Книга по Rust – Создание консольной программы: обратитесь к главе официальной документации Rust, посвященной построению консольных приложений. Она представляет собой пошаговое руководство
по созданию консольного приложения, охватывающее такие темы, как
разбор аргументов и эффективная организация исходного кода: https://
doc.rust-lang.org/book/ch12-00-an-io-project.html.
Книга по командным интерфейсам на Rust: почитайте книгу «Rust
CLI Book» – онлайновый ресурс, специально ориентированный на построение консольных приложений на Rust. Ознакомьтесь с такими темами, как обработка ошибок, тестирование и создание удобных интерфейсов для программ: https://rust-cli.github.io/book/index.html.
Документация библиотеки clap: ознакомьтесь с документацией биб
лиотеки clap – мощного и простого в использовании анализатора аргументов командного интерфейса для Rust. Узнайте, как определять
командные интерфейсы, разбирать аргументы и обрабатывать вводимые пользователем данные с помощью этого широко используемого
пакета: https://clap.rs.
Вопросы для самопроверки 369
Репозиторий StructOpt на GitHub: посетите репозиторий библиотеки
Rust StructOpt, предназначенной для разбора аргументов через определение структур. Библиотека StructOpt упрощает процесс создания командного интерфейса, задействуя систему типов Rust: https://github.com/
TeXitoi/structopt.
Документация env_logger: ознакомьтесь с документацией env_logger –
гибкой реализации инструмента журналирования для консольных приложений на Rust. Она поможет узнать, как настраивать и использовать env_logger для расширения возможностей журналирования в своих
программах: https://docs.rs/env_logger.
Документация fs_extra: ознакомьтесь с документацией пакета fs_
extra, который предоставляет дополнительные функциональности для
работы с файловой системой на Rust. Этот пакет полезен для эффективного управления файлами и директориями: https://docs.rs/fs_extra.
Репозиторий quicli на GitHub: ознакомьтесь с репозиторием библиотеки quicli, предназначенной для быстрого построения консольных приложений. Библиотека quicli ориентирована на сокращение шаблонного
кода и ускорение процесса создания командных интерфейсов: https://
github.com/killercup/quicli.
Документация shellfn: посмотрите документацию пакета shellfn, который позволяет писать скрипты командного интерфейса прямо внутри
Rust. Это полезно для интеграции функциональности, напоминающей
работу в оболочке, в свои программы: https://docs.rs/shellfn.
Пакет dialoguer: ознакомьтесь с пакетом dialoguer на crates.io – библиотекой для создания интерактивных командных интерфейсов. Она предоставляет набор утилит для выдачи пользователям подсказок, выбора опций и отображения индикаторов прогресса: https://crates.io/crates/dialoguer.
Репозиторий ripgrep на GitHub: посетите репозиторий быстрого и удобного инструмента ripgrep для поиска текста в исходном коде. Рекомендуем проанализировать исходный код инструмента ripgrep, чтобы понять
лучшие образцы практики построения эффективных и многофункциональных консольных программ: https://github.com/BurntSushi/ripgrep.
Перечисленные выше ресурсы предлагают всесторонний взгляд на построе
ние консольных программ на Rust, предоставляя практические руководства,
примеры и документацию, которые помогут вам создавать надежные и удобные командные интерфейсы.
Вопросы для самопроверки
Вопрос 1. Какая библиотека Rust выделена в этой главе как мощный инструмент управления аргументами командного интерфейса?
a) serde
b) clap
c) structopt
d) argh
370
Консольные программы
Вопрос 2. Какую роль играют аргументы командного интерфейса в конфигурировании и персонализации функциональности консольных утилит?
a) они определяют структуры данных
b) они предоставляют дефолтные реализации функций
c) они позволяют пользователям передавать конкретные инструкции, данные и настройки
d) они ограничивают доступ к данным
Вопрос 3. Почему библиотека clap считается убедительным выбором для
разбора аргументов командного интерфейса на Rust?
a) из-за поддержки переполнений буфера
b) потому что она придерживается стандартов командного интерфейса Unix
c) потому что она разработана исключительно для Windows
d) из-за отсутствия поддержки сообщества
Вопрос 4. Что означает флаг --features=derive в контексте интеграции
библиотеки clap в проект на Rust?
a) он включает функции генерации исходного кода, предоставляемые библиотекой clap
b) он отключает основную функциональность библиотеки clap
c) он сигнализирует о совместимости с Windows
d) он активирует продвинутые защитные функциональные средства
Вопрос 5. В контексте файлового ввода-вывода на Rust что представляет
собой тип io::Result<()>?
a) успешную операцию с возвращаемым типом ()
b) безуспешную операцию с типом, представляющим ошибку
c) файловый дескриптор
d) булево значение, указывающее на успех или неудачу
Вопрос 6. Как описанная в этой главе утилита выполняет поиск и замену
без учета регистра?
a) с помощью функции str::replace
b) используя библиотеку clap
c) путем внедрения пакета regex и установки флага ignore-case
d) путем реализации конкретно-прикладного алгоритма поиска
Вопрос 7. Что означает атрибут num_args = 1.. в определении на основе
clap для поля input?
a) он указывает минимально допустимое количество аргументов
b) он указывает на опциональный аргумент
c) он устанавливает верхний предел количества аргументов
d) он требует как минимум одного аргумента без ограничения максимального количества
Ключевые термины 371
Ответы
1. b) clap
2. c) они позволяют пользователям передавать конкретные инструкции,
данные и настройки
3. b) потому что она придерживается стандартов командного интерфейса
Unix
4. a) он включает функции генерации исходного кода, предоставляемые
библиотекой clap
5. b) безуспешную операцию с типом, представляющим ошибку (технически Result представляет либо успех, либо ошибку, но в контексте данного вопроса акцент на обработке ошибок)
6. c) путем внедрения пакета regex и установки флага ignore-case
7. d) он требует как минимум одного аргумента без ограничения максимального количества
Ключевые термины
Регулярное выражение (regular expression, regex): последовательность символов, формирующая поисковый шаблон, который используется для сопоставления с текстом внутри строковых литералов. В Rust функциональность
для работы с регулярными выражениями предоставлена в пакете regex.
Подкоманда (subcommand): команда командного интерфейса, являющая
ся частью более крупной команды; часто используется для организации
и классификации разных функциональностей внутри одной утилиты.
Файловый ввод-вывод (file I/O): операции ввода и вывода, связанные
с файлами, такие как чтение из файлов и запись в них. Для выполнения
таких операций на Rust соответствующие модули и функции предоставляются стандартной библиотекой.
Поиск без учета регистра символов (case-insensitive search): операция
поиска, которая игнорирует различия в регистре символов, считая прописные и строчные символы эквивалентными. Широко применяется для
создания гибких и инклюзивных функциональностей поиска.
Обработка ошибок (error handling): процесс управления ошибками и реа
гирования на них в ходе исполнения программы. В Rust тип Result повсеместно используется для обработки ошибок в функциях, которые могут
завершаться отказом.
Командный интерфейс (command-line interface, cli): текстовый пользовательский интерфейс, позволяющий взаимодействовать с программой
путем ввода команд в терминале или консоли.
Разбор аргументов (argument parsing): процесс извлечения и интерпретации аргументов командного интерфейса, переданных при запуске
программы, что позволяет индивидуально настраивать и конфигурировать ее поведение.
Расстояние Джаро–Винклера (Jaro-Winkler distance): мера сходства
между двумя строковыми литералами, часто используемая в командных
интерфейсах для предоставления подсказок на основе вводимых пользователем данных.
372
Консольные программы
Кросс-платформенная совместимость (cross-platform compatibility):
способность программного обеспечения или библиотеки работать в разных операционных системах без модификации, обеспечивая едино
образное поведение и пользовательский опыт.
Активная поддержка (active maintenance): непрерывный процесс разработки, исправления ошибок и улучшения библиотеки, что свидетельствует о ее своевременной адаптации к изменениям в экосистеме программирования.
Глава 9
Работа с вводом-выводом
с устройств на Rust
Введение
Системное программирование – это область, где цифровой мир встречается с физическим, и способность взаимодействовать с устройствами и выполнять операции ввода-вывода лежит в основе этого сближения. В данной обширной главе
мы проведем обследование всех тонкостей работы с вводом-выводом с устройств
на Rust. Наше обследование охватит весь спектр: от фундаментальных файловых
операций до нюансов интеграции аппаратных средств через интерфейсы с Rust.
Настоящая глава – это своего рода портал в область, где строки кода превращаются в осязаемые действия, будь то чтение данных с сенсоров, управление актуаторами и приводами или администрирование файлов в системе.
Вначале мы углубимся в основополагающие аспекты записи и чтения файлов с использованием стандартной библиотеки Rust. Файлы служат цифровыми архивами информации, и умение взаимодействовать с ними играет жизненно важную роль для системного программиста. Мы проведем обследование
библиотек и конструкций Rust, которые позволяют без труда читать и писать
файлы, подстраиваясь под различные форматы данных и требования. Вооружившись этими знаниями, вы сможете работать с журнальными и конфигурационными файлами или любыми другими формами постоянного хранения
данных, которые могут потребоваться вашей системе.
Но данная тема простирается далеко за границы файловой системы. Мы подробно рассмотрим сложную тему стандартных операций с файловой системой.
Будь то навигация по директориям, создание и удаление файлов или манипулирование их атрибутами – Rust предлагает надежную поддержку этих задач. Наше обследование затронет практические сценарии, иллюстрирующие
способы эффективного управления файлами и директориями. По мере продвижения вы осознаете всю мощь и элегантность способностей Rust по работе
с файловой системой.
Более того, мы предложим панорамный обзор работы с аппаратными
устройствами через интерфейсы с Rust. Устройства представляют собой физическое воплощение способностей вашей системы, а Rust служит мостом между
цифровым и физическим мирами. Вы получите сведения о взаимодействии
с различными аппаратными средствами, такими как сенсоры, актуаторы или
374
Работа с вводом-выводом с устройств на Rust
коммуникационные модули. Мы рассмотрим нюансы связи с устройствами,
сбора данных и управления ими, демонстрируя то, как Rust позволяет координировать поведение устройств с высокой точностью и надежностью.
Содержание главы
В этой главе будут рассмотрены следующие темы:
чтение и запись файлов на Rust;
выполнение стандартных операций с файловой системой;
обзор работы с аппаратными устройствами через интерфейсы с Rust.
Чтение из файлов и запись в файлы
Операции файлового ввода-вывода служат краеугольным камнем системного
программирования, поскольку они обеспечивают взаимодействие между программным обеспечением и опорной файловой системой. В Rust эти операции
не только доступны, но и исключительно надежны благодаря способностям
стандартной библиотеки. Давайте начнем с обследования фундаментальных
аспектов чтения из файлов и записи в файлы.
Обработка файлов на Rust
Записать
данные на диск
Выходной поток файловых данных
Входные данные
для обр аботки
Прочитать
данные
с диска
Входной поток файловых данных
Рисунок 9.1. Чтение данных из файлов и запись в них
Открытие файла
Перед тем как приступить к работе с файлами на Rust, важно усвоить основополагающие задачи, которые позволяют программам взаимодействовать
с компьютерной системой хранения данных. Файловый ввод-вывод – это
набор действий, связанных с чтением данных из файлов и записью данных
в них. Эти действия имеют решающее значение для многих задач системного
Открытие файла 375
программирования и служат ключом к обработке и манипулированию данными, которые сохраняются в течение длительного времени.
Стандартная библиотека Rust предоставляет доступные и надежные инструменты для работы с файлами. Rust был разработан с упором на защищенность
и эффективность, что особенно критично в ситуациях, когда дело касается таких задач, как управление файлами, затрагивающих само ядро операционной
системы компьютера.
Важнейшей начальной задачей при работе с файлами является процедура
их открытия, которая необходима перед выполнением таких операций, как
чтение и запись данных. Процедура открытия файлов на Rust выполняется с помощью структуры File, интегрированной в модуль std::fs. Структура
File является краеугольным камнем данной задачи: она предлагает способы
инициализации соединения с нужным файлом и в дальнейшем предоставляет программе дескриптор – важнейший инструмент, позволяющий выполнять
различные операции, связанные с файлами.
Для того чтобы открыть файл для чтения, используется метод File::open.
Этот метод нуждается в пути к файлу в качестве аргумента. Путь может быть
как абсолютным (полное расположение файла), так и относительным (расположение файла относительно текущей рабочей директории). Стоит упомянуть, что файловые операции могут приводить к ошибкам, таким как отсутствие файла или недостаточные права доступа. Компилятор Rust обрабатывает
ошибки элегантно и надежно. При использовании метода File::open он возвращает экземпляр типа Result. Указанный тип предусматривает возможность
возникновения ошибки во время операции. Если ошибка не возникла, то экземпляр данного типа содержит дескриптор открытого файла. Однако если
ошибка все же произошла, то он захватывает информацию об ошибке, позволяя программе отреагировать соответствующим образом. Такой подход к обработке ошибок гарантирует, что программа сможет защищенно справляться
с неожиданными ситуациями при работе с файлами.
Листинг 9.1. Простейшая программа для открытия файла
use std::fs::File;
fn main() -> Result<(), std::io::Error> {
let file = File::open("example.txt")?;
// Теперь file является дескриптором открытого файла
Ok(())
}
// Вывод:
// В случае если файл не существует, программа сгенерирует следующее исключение:
// Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Данный фрагмент исходного кода иллюстрирует процедуру открытия файла
с именем "example.txt". Метод File::open возвращает экземпляр типа Result,
а оператор вопроса ? используется для эффективной обработки возможных
ошибок. Если файл "example.txt" открывается успешно, то в переменной file
хранится ссылка на этот файл (именуемая файловым дескриптором), что позволяет перейти к чтению данных из него. Такой подход гарантирует, что лю-
376
Работа с вводом-выводом с устройств на Rust
бые потенциальные ошибки в процессе открытия файла будут обработаны
щадяще и эффективно, позволяя программе плавно продолжать исполнение,
если на этом критическом этапе не возникло проблем.
Понимание того, как открывать файлы, – это лишь начало пути в изучении
ввода-вывода на Rust. После получения дескриптора файла открывается целый ряд возможностей: от чтения его содержимого до записи новых данных.
Эта глава проведет вас по указанным операциям, вооружив инструментами
для эффективного управления файлами и манипулирования ими в системном
программировании.
Чтение из файла
«Открыв дверь» к файлу, теперь наш следующий шаг – прочитать и обработать
его содержимое. Стандартная библиотека Rust предлагает целый ряд методов
чтения файлов, отвечающих самым разным потребностям. Давайте рассмот
рим фундаментальный подход: чтение всего файла целиком в переменную
типа String.
Представьте сценарий, в котором вы работаете с конфигурационным или
журнальным файлом либо любыми текстовыми данными, требующими обработки. Следующий ниже фрагмент исходного кода станет вашим компасом, который проведет по процедуре чтения файла в структуру типа String в памяти.
В представленном ниже исходном коде для выполнения этой задачи мы используем метод read_to_string. Данный метод отвечает за чтение всего содержимого файла и его сохранение в переменной типа String. Такой подход
оказывается крайне полезным, особенно в сценариях, в которых содержимое
файла должно рассматриваться как текстовые данные. Он позволяет удобно
манипулировать информацией – будь то выполнение разбора на составляющие, поиск или любая другая операция текстовой обработки, которую вы намерены выполнить.
Листинг 9.2. Простейшая программа для чтения содержимого файла
use std::fs::File;
use std::io::Read; // Импорт общей черты Read, необходимой для read_to_string
fn main() -> Result<(), std::io::Error> {
// Использовать mut, так как чтение изменяет состояние курсора файла
let mut file = File::open("example.txt")?;
// Создать пустую мутируемую строковую структуру для хранения данных
let mut contents = String::new();
// Читать содержимое файла в созданную строковую структуру
file.read_to_string(&mut contents)?;
// Теперь переменная contents содержит весь текст из файла
println!("Содержимое файла: {}", contents);
}
Ok(())
Запись в файл 377
Построчное чтение файлов – это еще одна замечательная возможность, которой наделяет программиста Rust. Представьте сценарий, в котором вы обрабатываете огромный журнальный файл или набор данных, структурированный построчно. Следующий ниже фрагмент исходного кода, подобно внимательному экскурсоводу, проведет вас по файлу строка за строкой. Для достижения этой цели используется структура BufRead и метод lines, как показано
в следующем ниже примере исходного кода:
Листинг 9.3. Простейшая программа для
построчного чтения содержимого файла
use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> Result<(), std::io::Error> {
// Открыть файл
let file = File::open("example.txt")?;
// Обернуть файл в BufReader, в результате чего создается
// буфер в памяти, чтобы не обращаться к диску за каждым байтом
let reader = BufReader::new(file);
for line in reader.lines() {
// Каждая итерация возвращает Result<String, Error>,
// так как при чтении строки файла может возникнуть ошибка
let line = line?; // Извлечь строку файла
// Здесь можно обработать строку
println!("Прочитана строка: {}", line);
}
Ok(())
}
В приведенном выше исходном коде мы знакомим вас со структурой
BufReader – вашим проводником в построчном обследовании файла. Она эффективно справляется со всеми нюансами чтения, благодаря чему она идеально подходит для работы с крупными текстовыми файлами или структурированными данными.
Теперь, когда вы стоите на перепутье способностей Rust по работе с файлами, вы обладаете знаниями и инструментами, для того чтобы без труда перемещаться по ним. Выбор между чтением всего файла в строковую структуру
в памяти или построчной обработкой его содержимого остается за вами. Овладев этими фундаментальными методами, вы полностью готовы к освоению
обширных просторов манипуляции файлами на Rust.
Запись в файл
Освоив приемы чтения из файлов, пришло время перевернуть страницу и освоить не менее важное умение – запись в файлы. В системном программировании запись в файлы – это не просто способ регистрации данных.
378 Работа с вводом-выводом с устройств на Rust
Для того чтобы записать данные в файл на Rust, первым делом нужно открыть
файл в режиме записи. Это делается двумя способами: либо путем создания
нового файла, предназначенного для записи, используя метод File::create,
либо путем открытия существующего файла с правами на запись, используя
метод File::open (в сочетании с дополнительными настройками).
Для примера рассмотрим следующий ниже случай, в котором мы демонстрируем создание нового файла с именем "new_file.txt" и последующую
запись в него байтов "Привет, Rust!". Эта операция отражает фундаментальный аспект ввода-вывода файлов на Rust: вы получаете способность
создавать и изменять файлы программным путем, что открывает широкие
возможности для хранения данных и манипулирования ими.
Листинг 9.4. Простейшая программа для записи данных в файл
use std::fs::File;
use std::io::Write; // Импорт общей черты Write для метода write_all
fn main() -> Result<(), std::io::Error> {
// Создать файл
// Если файл "new_file.txt" уже существует, то он будет перезаписан
let mut file = File::create("new_file.txt")?;
// Записать данные
// Приставка b перед строковым литералом превращает его в срез
// байтов (&[u8]), так как метод write_all работает именно с байтами
file.write_all(b"Привет, Rust!")?;
// Файл автоматически закроется, когда переменная file выйдет
// из диапазона доступности
Ok(())
}
В приведенном выше фрагменте исходного кода мы используем метод
File::create, чтобы создать новый файл, а затем применяем метод write_all,
чтобы записать в него выбранные байты. Метод write_all обладает ценной
способностью гарантировать полную запись всех данных в файл, принимая
меры для подтверждения этого без какой-либо двусмысленности. В свою очередь, он возвращает объект Result, который служит индикатором результата
операции, сообщая либо об успехе, либо о возникновении ошибки. За счет этого обеспечивается надежный способ обработки исхода операции записи.
Но как быть в более сложных сценариях, когда система генерирует структурированные данные, требующие тщательного обхождения? Rust оснащает
разработчика способностью форматировать и сериализовывать данные, позволяя создавать файлы, которые будут одновременно удобочитаемы для человека и удобны для машинной обработки.
Следующий ниже фрагмент исходного кода демонстрирует процедуру
записи структурированных данных в файл. Здесь мы используем библиотеки serde и serde_json, чтобы сериализовать структурированный тип данных в JSON-файл – популярный формат для обмена данными и конфигурационных файлов:
Запись в файл 379
Листинг 9.5. Простейшая программа для записи
структурированных данных в файл
use serde::{Serialize, Deserialize};
use std::fs::File;
use serde_json::to_writer;
// Определить структуру для структурированных данных
// Атрибут #[derive(...)] автоматически генерирует код
// для превращения в JSON и обратно
#[derive(Serialize, Deserialize)]
struct Configuration {
name: String,
value: i32,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Создать экземпляр структуры
let config = Configuration {
name: String::from("setting"),
value: 42,
};
// Создать или открыть файл для записи
let file = File::create("config.json")?;
// Функция to_writer берет файл и ссылку на данные,
// превращает структуру в JSON и записывает ее в файл
to_writer(file, &config)?;
}
Ok(())
// Вывод: генерируется новый файл со следующим содержимым:
// {“name”:”setting”,”value”:42}
В приведенном выше исходном коде структура Configuration служит для хранения структурированных данных. Библиотеки сериализации, такие как serde
и serde_json, делают процесс сериализации таким же естественным, как написание рассказа. Данные вашей системы преобразовываются в формат JSON,
готовый для интерпретации другими системами или программистами.
Умение записывать данные в файлы на Rust открывает широкие горизонты,
позволяя создавать не только простые текстовые данные, но и файлы со структурированной информацией, в которой воплощена логика вашей системы.
По мере углубления в тему ввода-вывода с устройств на Rust эти фундаментальные принципы работы с файлами станут вашими верными спутниками. Они дают ключ к разгадке тайн, скрытых в файлах любых размеров
и структур. Куда бы ни привел вас путь – к анализу журналов событий, разбору конфигурационных файлов или погружению в самую суть данных, – Rust
наделяет вас инструментами и знаниями, позволяющими уверенно и легко
следовать этим курсом.
380
Работа с вводом-выводом с устройств на Rust
Закрытие файла
Закрытие файла – это критически важный шаг в любой операции ввода-вывода. Выполнение этой процедуры гарантирует, что связанные с файлом системные ресурсы будут должным образом высвобождены, что предотвращает
утечки ресурсов и гарантирует сохранение всех внесенных в файл изменений.
В Rust процедура закрытия файлов удивительно проста благодаря системе
владения и управления диапазонами доступности.
В отличие от некоторых других языков программирования, Rust не нуждается в явных вызовах для закрытия файлов. Вместо этого файлы закрываются автоматически, когда дескриптор файла (в данном случае переменная, в которой
он хранится) выходит из диапазона доступности. Этот механизм автоматического закрытия повышает защищенность и надежность файловых операций.
Ниже приведен фрагмент исходного кода, демонстрирующий описанный
механизм автоматического закрытия:
Листинг 9.6. Простейшая программа,
демонстрирующая жизненный цикл файла
use std::fs::File;
use std::io::Write;
fn main() -> Result<(), std::io::Error> {
{
// Файл создается и становится владельцем ресурса
// в этом диапазоне доступности
let mut file = File::create("new_file.txt")?;
file.write_all(b"Привет, Rust!")?;
// Здесь можно продолжить работу с file
} // Здесь переменная file выходит из диапазона доступности
// Rust автоматически вызывает метод drop(), который закрывает файл в ОС
Ok(())
}
Приведенный выше фрагмент исходного кода демонстрирует принятый
в Rust замечательный механизм автоматического закрытия файлов, подчеркивая стремление языка к простоте и надежности. В этом примере мы создаем
новый файл, записываем в него текст "Привет, Rust!" и, что примечательно,
без какой-либо необходимости в дополнительном исходном коде позволяем переменной file естественным образом выйти из диапазона доступности.
В этот момент компилятор Rust берет на себя ответственность за автоматическое закрытие файла.
Такой подход гарантирует, что даже в ситуациях, когда в исходном коде возникают ошибки или происходит неожиданное завершение работы, файл будет
обработан надлежащим образом, что предотвращает возможную порчу данных или утечку ресурсов. Это не только отражает основные конструктивные
принципы Rust, в которых сделан сильный акцент на защищенности и надежности, но и упрощает задачу управления файлами, делая это функциональное
средство языка выдающимся для разработчиков.
Выполнение распространенных операций с файловой системой 381
Понимание принятого в Rust механизма автоматического закрытия файлов имеет фундаментальное значение для написания надежного и ресурсоэффективного исходного кода ввода-вывода. Используя принятую в Rust модель
владения, можно уверенно управлять файлами, не беспокоясь о ручных операциях по их закрытию. Это не только упрощает исходный код, но и снижает
вероятность возникновения ошибок, связанных с управлением ресурсами.
Завершая этот раздел, стоит подчеркнуть, что данная глава служит прочным фундаментом для освоения операций файлового ввода-вывода на Rust.
Мы обследовали весь жизненный цикл взаимодействия с файлами: от открытия и чтения до записи и закрытия. Механизм автоматического закрытия – это
лишь один из примеров стремления Rust сделать системное программирование защищенным, эффективным и дружелюбным к разработчику.
В следующих разделах этой главы мы продолжим обследовать сложную тему
ввода-вывода с устройств на Rust. Мы углубимся в распространенные операции с файловой системой, что позволит вам уверенно перемещаться по директориям, создавать и удалять файлы, а также изменять их атрибуты. Кроме того,
мы представим обзор методов взаимодействия с аппаратными устройствами
через интерфейсы с Rust, наведя мост между цифровым и физическим мирами в задачах системного программирования.
Выполнение распространенных
операций с файловой системой
Системное программирование не ограничивается элементарным чтением
и записью данных, оно также подразумевает мастерское владение навыками
эффективного управления и контроля сложных уровней опорной файловой системы. В этом разделе мы проведем обследование тонкостей выполнения повседневных операций с файловой системой на Rust, предоставив инструменты для установления неразрывной связи между исходным кодом и сложными
реалиями файловой системы. Вы получите ценные знания о том, как перемещаться по файловой системе, создавать, изменять файлы и взаимодействовать
с ними, что вооружит вас умениями, необходимыми для надежной и эффективной работы с долговременными данными в своих программах на Rust.
Создание директорий
Программное создание директорий (каталогов) – одна из фундаментальных
задач при организации файлов. Это обычная операция, особенно в приложениях, которым необходимо хранить данные конкретных пользователей или
поддерживать структурированные конфигурации. На Rust эта задача решается
очень просто с помощью функции std::fs::create_dir.
Давайте разберем это на простые составляющие. Представьте, что вы работаете над программой и одна из ее задач – организовывать пользовательские
данные в определенные каталоги на компьютере. Rust предоставляет удобный
способ делать это программно. Вы можете использовать функцию create_dir,
которая служит инструментом создания этих папок. Все, что нужно сделать, –
это передать функции путь к директории, которую требуется создать. Считайте
382
Работа с вводом-выводом с устройств на Rust
этот путь уникальным адресом новой директории в файловой системе компьютера. При вызове функции create_dir Rust берет на себя все закулисные шаги,
необходимые для создания директории именно так, как вы указали.
В целях прояснения ситуации давайте взглянем на пример исходного кода.
В следующем ниже фрагменте мы используем модуль fs из стандартной библио
теки Rust, чтобы создать новую директорию с именем "new_directory". Более
того, мы реализовали обработку ошибок, чтобы справляться с любыми потенциальными проблемами, которые могут возникать в процессе создания. Если все
пройдет гладко, то появится совершенно новая директория, готовая к использованию в программе. Это означает, что вы можете эффективно организовывать
пользовательские данные, а программа будет брать на себя всю тяжелую работу,
гарантируя, что задача будет выполнена бесшовно и отказоустойчиво.
Листинг 9.7. Простейшая программа для создания директории
use std::fs;
fn main() -> Result<(), std::io::Error> {
// Попытка создать директорию с именем new_directory
fs::create_dir("new_directory")?;
// Если директория успешно создана, программа дойдет до этой строки кода
println!("Директория успешно создана!");
Ok(())
}
Эта способность – не просто техническая функция. Это базовый строительный блок, позволяющий приложениям динамически управлять своим рабочим пространством. Представьте сценарии, в которых ваше программное
обеспечение настраивает конфигурации для конкретных пользователей, сохраняет созданное ими содержимое или аккуратно упорядочивает файлы для
различных задач.
Операции Rust с файловой системой позволяют превращать логику программы в реальные действия, создавая более интерактивный и отзывчивый
пользовательский опыт. Будь то настройка хранилища данных для вашего
приложения или организация файлов на основе предпочтений пользователя,
понимание того, как создавать директории, является важным умением в сфере системного программирования.
Переименование и перемещение файлов
Переименование и перемещение файлов – это распространенные операции
в системном программировании, служащие для самых разных целей: версио
нирования файлов, реорганизации данных и предотвращения конфликтов
имен. Эти операции неотъемлемы для поддержания порядка и эффективности файловой системы. Выполнение этих задач на Rust становится предельно
простым благодаря функции std::fs::rename.
Функция std::fs::rename – это основной инструмент для подобных задач,
разработанный для того, чтобы сделать операции переименования или пере-
Выполнение распространенных операций с файловой системой 383
мещения файла бесшовными. Она принимает два обязательных аргумента:
путь к источнику (текущее местоположение и имя файла) и путь к месту назначения (месту, где вы хотите видеть файл по завершении операции). Особенно ценной эту операцию делает ее атомарная природа: она гарантирует,
что файл будет либо полностью переименован, либо не переименован вовсе.
Такая атомарность обеспечивает целостность файловой системы, предотвращая появление неполных или конфликтующих состояний файлов.
Давайте разберем практический пример, чтобы лучше проиллюстрировать
эту концепцию. Представьте, что у вас есть файл с именем "old_file.txt" и вы
хотите изменить его имя на "new_file.txt". Эту задачу можно легко решить
с помощью следующего ниже лаконичного фрагмента исходного кода:
Листинг 9.8. Простейшая программа для переименования файла
use std::fs;
fn main() -> Result<(), std::io::Error> {
// Переименовать "old_file.txt" в "new_file.txt"
// Если "new_file.txt" уже существует, он будет заменен (в большинстве ОС)
fs::rename("old_file.txt", "new_file.txt")?;
println!("Файл успешно переименован!");
Ok(())
}
В представленном исходном коде функция rename используется для переименования файла "old_file.txt" в "new_file.txt". Стоит подчеркнуть, что эта
операция не ограничивается переименованием файлов в пределах одной директории: ее также можно использовать для перемещения файлов между разными директориями, что делает ее универсальным инструментом для управления файловой системой. Независимо от того, автоматизируете ли вы работу
с файлами, реорганизовываете данные или просто стремитесь поддерживать
порядок в файловой системе, функция std::fs::rename в Rust представляет собой надежный и атомарный инструмент для решения этих задач.
Переименование и перемещение файлов – это фундаментальные задачи
системного программирования. Функция std::fs::rename упрощает эти операции, обеспечивая целостность файловой системы. Понимая и используя
ее функциональность, вы сможете эффективно управлять файлами, директориями и данными в соответствии с требованиями вашей системы.
Проверка метаданных из файлов
Получение метаданных из файла – это фундаментальная операция в системном программировании, которая зачастую служит отправной точкой для
различных задач по работе с файлами. В ситуациях, когда требуется собрать
основные сведения о файле, такие как его размер, время последнего изменения или права доступа, Rust предлагает элегантное решение в виде функции
std::fs::metadata. Эта функция служит своего рода «входными воротами»
к обширным метаданным файла, позволяя извлекать ценные сведения о его
характеристиках.
384
Работа с вводом-выводом с устройств на Rust
Работа с метаданными файлов на Rust устроена довольно просто. Функция
metadata возвращает экземпляр типа Result, содержащий структуру Metadata,
которая выступает источником информации об инспектируемом объекте.
С помощью этой структуры можно получать доступ к ключевым деталям, позволяющим принимать обоснованные решения и выполнять действия на основе атрибутов файла.
Листинг 9.9. Простейшая программа для чтения метаданных файла
use
use
use
use
use
chrono::NaiveDateTime;
std::fs::metadata;
std::io;
std::os::unix::fs::PermissionsExt;
std::time::SystemTime;
fn main() -> io::Result<()> {
let metadata = metadata("example.txt")?;
println!("Размер файла: {} байт", metadata.len());
if let Ok(modified_time) = metadata.modified() {
let modified_time = modified_time
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let modified_datetime = NaiveDateTime::from_timestamp_opt(
modified_time.as_secs() as i64,
0
).unwrap();
println!("Дата последнего изменения: {}", modified_datetime);
} else {
eprintln!("Не удалось получить время изменения");
}
let permissions = metadata.permissions().mode();
println!("Права доступа: {:o}", permissions & 0o777);
Ok(())
}
//
//
//
//
Вывод: результат для файла example:
Размер файла: 29 байт
Дата последнего изменения: 2023-11-01 16:42:39
Права доступа: 664
Давайте разберем вышеуказанный пример исходного кода. Сначала мы используем функцию metadata, чтобы получить метаданные файла "example.txt".
Исходный код щадяще обрабатывает возможные ошибки, обеспечивая надежность исполнения программы. Получив метаданные, мы получаем доступ
к конкретным атрибутам, таким как размер файла, время последнего изменения и права доступа.
Вызов функции metadata.len() возвращает размер файла в байтах – фундаментальную метрику, которая помогает управлять хранилищем и оцени-
Удаление файлов и директорий 385
вать требования системы к свободному пространству. Сведения о размере
файла критически важны для таких задач, как мониторинг использования
диска, обеспечение достаточной емкости хранилища и оптимизация стратегий хранения данных.
Функция metadata.modified() показывает время последнего изменения
файла. Эта временная метка неоценима для отслеживания изменений и аудиторских проверок файловых операций. Она играет ключевую роль в системах
контроля версий и процессах синхронизации файлов, помогая поддерживать
целостность и прослеживаемость данных. Мы использовали пакет chrono,
чтобы отформатировать время последнего изменения без необходимости
вручную вычислять время, прошедшее с эпохи Unix. Значение переменной
modified_time преобразовывается в значение типа NaiveDateTime, которое затем выводится на экран в удобном для чтения формате.
Функция metadata.permissions().mode() раскрывает права доступа к файлу, которые являются краеугольным камнем обеспечения кибербезопасности и контроля доступа. С помощью этой информации можно определять,
кто может читать, писать или исполнять файл. Эти сведения централизованно обеспечивают взаимодействие с файлом только для авторизованных
лиц, защищая конфиденциальную информацию и обеспечивая целостность
системы.
Операция проверки метаданных файлов на Rust отличается простотой и одновременно мощью. Она наделяет разработчика инструментами для понимания и манипулирования файлами на гранулярном уровне. Независимо от того,
создаете ли вы инструмент управления файлами, проводите анализ файловой
системы или просто обеспечиваете защищенность и целостность своих данных, возможность доступа к метаданным является ключевым элементом в вашем арсенале.
Удаление файлов и директорий
Удаление файлов и директорий, несмотря на кажущуюся простоту, играет
важную роль в системном программировании, способствуя управлению
дисковым пространством и эффективной очистке ресурсов. Эта операция
выполняется на Rust бесшовно, обеспечивая целостность данных и эффективное управление ресурсами. Давайте погрузимся в детали удаления файлов и директорий и обследуем, каким образом модуль Rust std::fs упрощает эти процедуры.
Удаление файлов на Rust реализовано посредством функции
std::fs::remove_file. Эта функция щадяще удаляет указанный файл, позволяя
освободить место в хранилище или избавиться от избыточных данных. Следующий ниже фрагмент исходного кода демонстрирует использование этой
функции: при исполнении он удаляет файл с именем "file_to_delete.txt".
Преимущество Rust заключается в способности бесшовно обрабатывать возможные ошибки путем возврата экземпляра типа Result. Такая архитектура
гарантирует, что операция либо будет успешно выполнена, либо, в случае отказа, предоставит информативное сообщение об ошибке.
386
Работа с вводом-выводом с устройств на Rust
Листинг 9.10. Простейшая программа для удаления файла
use std::fs;
fn main() -> Result<(), std::io::Error> {
// Удаление файла
fs::remove_file("file_to_delete.txt")?;
// Удаление пустой директории
fs::remove_dir("directory_to_delete")?;
Ok(())
}
Удаление директорий имеет не меньшее значение, в особенности когда
речь идет о крупномасштабных приложениях, где директории накапливаются и нуждаются в эффективном управлении. Rust удовлетворяет эту потребность с помощью функции std::fs::remove_dir. В приведенном выше примере вы могли наблюдать процедуру удаления директории с именем "directory_
to_delete". Как и в случае с файлами, эта функция обеспечивает целостность
данных, позволяя удалять директории с учетом возможных нештатных сценариев. Простота и надежность подхода Rust к удалению директорий делают его
ценным инструментом в системном программировании.
Эффективное управление ресурсами, включающее такие задачи, как удаление файлов и директорий, является важнейшим умением для любого, кто работает с компьютерными системами. Речь идет не только о наведении порядка
на компьютере, но и о том, чтобы обеспечивать его максимально плавную работу и разумное использование ресурсов. На Rust эти задачи решаются с большой
осторожностью и защищенностью, что демонстрирует стремление языка делать
системное программирование одновременно защищенным и эффективным.
Более того, возможность удаления файлов и директорий выходит за рамки
простой очистки. Она играет критически важную роль в управлении жизненным циклом данных, позволяя системам систематически организовывать информацию и управлять ею. Четкий и эффективный подход, принятый в Rust
к этим операциям, позволяет создавать надежные решения для широкого
спектра сценариев из реальной практики.
Обход директорий
При работе с файлами часто возникает необходимость перемещаться по директориям в поисках конкретных файлов, проверять их атрибуты или организовывать их в логические структуры. Rust предоставляет мощные инструменты для легкого обхода директорий, позволяя обследовать, анализировать
и манипулировать содержимым файловой системы.
Давайте начнем с идеи составления списка всего, что находится в конкретной папке на вашем компьютере. Представьте, что папка – это коробка, а все,
что внутри (файлы и другие папки поменьше), – это так называемые записи. В программировании на Rust данный процесс сделан довольно простым
с помощью функции read_dir. Это своего рода инструмент, который помогает заглядывать внутрь коробки и видеть ее содержимое. При использовании
Удаление файлов и директорий 387
функции read_dir она не просто выдает весь список целиком; вместо этого она
выдает по одному элементу за раз, как если бы вам показывали сначала один
предмет из коробки, затем следующий и так далее. Каждый увиденный вами
предмет называется DirEntry, и он содержит дополнительную информацию
о себе, например имя и другие детали. Компилятор также отлично справляется
с проблемами, которые могут возникать в процессе обследования содержимого, поэтому вы можете использовать Rust с уверенностью, зная, что программа
не завершится аварийно, если что-то пойдет не так.
Листинг 9.11. Простейшая программа для обхода директории
use std::fs;
use std::os::unix::fs::FileTypeExt; // Общая черта для проверки типов файлов
use std::io;
fn main() -> io::Result<()> {
// Указать путь к нужной директории вместо "path_to_directory"
let dir = fs::read_dir("path_to_directory")?;
for entry in dir {
let entry = entry?;
let path = entry.path();
// Получить имя файла или дефолтное значение, если имя некорректно
println!("Имя записи: {:?}", path.file_name().unwrap_or_default());
let file_type = entry.file_type()?;
let file_type_str = if file_type.is_file() {
"Файл"
} else if file_type.is_dir() {
"Директория"
} else if file_type.is_symlink() {
"Символическая ссылка"
} else if file_type.is_char_device() {
"Символьное устройство"
} else if file_type.is_block_device() {
"Блочное устройство"
} else if file_type.is_fifo() {
"FIFO (именованный конвейер)"
} else if file_type.is_socket() {
"Сокет"
} else {
"Неизвестно"
};
println!("Тип записи: {}", file_type_str);
}
Ok(())
}
//
//
//
//
//
Вывод: результат для файла example:
Имя записи: “config.json”
Тип записи: Файл
Имя записи: “new_file.txt”
Тип записи: Файл
388
Работа с вводом-выводом с устройств на Rust
Приведенный выше пример исходного кода служит практической иллюст
рацией обхода директории. Мы определяем путь к директории, вызываем
функцию read_dir и прокручиваем результирующий итератор в цикле. Мы получаем доступ к пути каждой записи и определяем ее тип. Эти базовые знания
об обходе директорий являются краеугольным камнем для целого ряда задач,
включая индексацию файлов для поиска, пакетную обработку данных или выполнение рекурсивных операций с директориями.
Обход директорий – это фундаментальное и универсальное умение для системных программистов и разработчиков. Независимо от того, что вы создаете – инструмент для индексации файлов, утилиту для резервного копирования
или систему управления контентом, – понимание того, как эффективно перемещаться по директориям, значительно расширит ваши способности в системном программировании. Используя Rust в качестве проводника, вы будете готовы ориентироваться в цифровом ландшафте с высокой точностью и легкостью.
Работа с путями к файлам
Работа с путями к файлам – важнейшая часть того, как Rust управляет файловой системой. Посредством модуля std::path Rust предоставляет удобный
набор инструментов для обработки путей таким образом, чтобы они работали единообразно в различных операционных системах. Это крайне важно, так
как правила написания путей могут сильно различаться в зависимости от ОС.
Одна из самых базовых задач при работе с путями – соединение их отдельных частей. Иногда возникает потребность объединить несколько фрагментов,
чтобы создать полный и правильный путь. Именно здесь тип PathBuf из модуля
std::path становится вашим лучшим помощником. Для этого создается объект
PathBuf, а затем используется его метод join, чтобы легко «склеивать» части
пути воедино. Прелесть этого подхода состоит в том, что он берет на себя все
тонкости, специфичные для конкретной операционной системы. Будь то Unixподобная система (например, Linux) или Windows, компилятор Rust сам выберет правильный тип разделителя (обычная либо обратная наклонная черта).
Он делает процесс разработки плавным и избавляет вас от лишних хлопот!
В следующем ниже примере мы начинаем с базового пути, представленного
как PathBuf::from("/usr/local"), а затем используем метод join, чтобы добавить к нему "bin". В результате этой операции получается новый путь. Ключевой
аспект здесь заключается в том, что не нужно беспокоиться о разделителях, принятых в конкретной платформе. Компилятор Rust сделает это за вас, обеспечивая
переносимость исходного кода между разными операционными системами.
Листинг 9.12. Простейшая программа для работы с путями к файлам
use std::path::PathBuf;
fn main() {
let base_path = PathBuf::from("/usr/local");
// Метод join создает новый PathBuf, не изменяя оригинал
let new_path = base_path.join("bin");
println!("Новый путь: {:?}", new_path);
}
Удаление файлов и директорий 389
// Вывод:
// Новый путь: "/usr/local/bin"
Кроме того, часто приходится выполнять еще одну задачу – определение родительской папки, в которой находится конкретный файл. Это удобно, когда
нужно подняться на один уровень выше в файловой системе. Для этого у типа
Path есть специальный метод parent, который позволяет делать это без лишних усилий. Он возвращает результат типа Option, так как не у всех путей есть
родитель; представьте себе самый верхний уровень, например корневую директорию, выше которой идти некуда.
Давайте разберем это на примере: мы создаем объект Path, представляющий путь "/usr/local/bin", а затем используем метод parent, чтобы получить
родительскую директорию – "/usr/local".
Еще одна распространенная операция – извлечение родительской директории из пути с помощью метода parent:
Листинг 9.13. Простейшая программа для извлечения
родительской директории из пути
use std::path::Path;
fn main() {
let path = Path::new("/usr/local/bin");
}
// Пытаемся получить родительский каталог
if let Some(parent) = path.parent() {
println!("Родительская директория: {:?}", parent);
} else {
println!("Это корневая директория, у нее нет родителя.");
}
// Вывод:
// Родительская директория: "/usr/local"
В приведенном выше фрагменте исходного кода мы создаем путь и используем метод parent для доступа к родительской директории. Правильная обработка путей к файлам критически важна для таких задач, как работа с относительными ссылками, обращение к ресурсам по символическим ссылкам
и обеспечение кросс-платформенной совместимости.
Очень важно понимать все тонкости работы с путями – «адресами», которые
сообщают программе, где искать или сохранять файлы. Эти знания являются
ключевым ингредиентом целого ряда задач: от обработки ссылок на файлы относительно друг друга до расшифровки символических ссылок (особого типа
ярлыков) и обеспечения бесперебойной работы исходного кода в разных операционных системах, таких как Windows, macOS и Linux. Вы ведь не хотите,
чтобы ваша программа сломалась только из-за того, что ее запустили на другом компьютере? К счастью, Rust предоставляет для этого удобные инструменты в модуле std::path, которые позволяют писать качественный и легко
поддерживаемый исходный код для любой платформы.
390
Работа с вводом-выводом с устройств на Rust
Чтение и запись двоичных данных
Работа с двоичными данными – это ключевая часть системного программирования, особенно в ситуациях, когда вы имеете дело с такими файлами, как изображения, аудиофайлы или данные конфигурации. Указанные файлы не предназначены для чтения человеком, так как они состоят из «сырых» байтов. Rust
обладает прочным фундаментом для обработки операций двоичного вводавывода посредством модуля std::fs. Простыми словами, двоичные данные
здесь несут в себе информацию, состоящую из сырых байтов, а не что-то, что
можно легко прочитать, как текст.
В ситуациях, когда требуется читать двоичные данные из файла, на Rust используется метод File::open, который во многом похож на способ открытия
текстовых файлов. Однако тут есть существенное различие: двоичные данные
считываются в байтовый буфер, который, по сути, представляет собой расширяемый контейнер для байтов (обычно это Vec<u8>). Этот буфер удобен для
хранения двоичных данных, полученных из файла. Ниже приводится пример
того, как этот процесс реализуется на Rust:
Листинг 9.14. Простейшая программа для чтения двоичных данных из файла
use std::fs::File;
use std::io::Read;
fn main() -> Result<(), std::io::Error> {
// Открыть двоичный файл. Оператор '?' вернет ошибку, если файла нет
let mut file = File::open("binary_file.bin")?;
// Создать пустой вектор байтов (динамический массив u8)
let mut buffer = Vec::new();
// Прочитать все данные из файла до самого конца и записать их в буфер
file.read_to_end(&mut buffer)?;
// Теперь переменная buffer содержит все сырые байты из файла
println!("Прочитано байт: {}", buffer.len());
Ok(())
}
В представленном выше фрагменте исходного кода мы работаем с особым
типом компьютерных файлов, именуемым двоичным файлом. В этом исходном
коде мы первым делом открываем один из таких файлов с именем "binary_file.
bin". Вслед за этим мы создаем то, что называется буфером. Представьте себе этот
буфер как контейнер или коробку, но особенную, потому что она может хранить
числа – а именно числа размером 8 бит. Эти 8-битные числа также называются
байтами. Таким образом, мы подготавливаем этот буфер для хранения байтов.
Далее давайте рассмотрим метод read_to_end. Его работа довольно проста.
По сути, он отвечает за сбор всех байтов, содержащихся в двоичном файле,
и их сохранение в контейнер-буфер. После исполнения этого исходного кода
в результате буфер становится хранилищем всех данных, которые изначально
находились в двоичном файле. Это, в свою очередь, дает возможность использовать буфер для самых разных целей при работе с двоичными данными: для
Обработка ошибок и типы Result 391
их обработки, анализа или манипулирования, поскольку теперь данные удобно организованы внутри контейнера.
С обратной стороны, в ситуациях, когда требуется поместить данные в сам
двоичный файл, Rust делает этот процесс относительно простым. Эта процедура чем-то похожа на запись текста в обычный текстовый файл, за исключением того, что здесь мы имеем дело с сырыми двоичными данными. Для
этого используется метод File::create. Указанный метод подобен созданию
нового пустого файла, но специально для двоичных данных. Он подготавливает новую чистую книгу, куда можно писать цифровую информацию. Пример
демонстрирует, как брать свои собственные двоичные данные и записывать
их в файл, подобно тому, как записывают текст в обычный файл. Описанный
двусторонний процесс – чтение и запись двоичных данных – является фундаментальным в программировании и позволяет эффективно работать с различными типами цифровой информации.
Листинг 9.15. Простейшая программа для записи двоичных данных в файл
use std::fs::File;
use std::io::Write;
fn main() -> Result<(), std::io::Error> {
// Создать новый файл для записи
let mut file = File::create("binary_output.bin")?;
// Данные в виде вектора байтов (используется шестнадцатеричная запись)
let data = vec![0x48, 0x65, 0x6C, 0x6C, 0x6F];
// Записать все данные в файл
file.write_all(&data)?;
// Теперь файл "binary_output.bin" содержит 5 байт данных
Ok(())
}
В приведенном выше исходном коде с помощью метода File::create создается файл для двоичного вывода binary_output.bin". Двоичные данные в указанном примере представлены в виде вектора байтов, где каждый байт указан
в шестнадцатеричном формате (например, 0x48 соответствует символу 'H').
Затем мы используем метод write_all, чтобы перенести этот список байтов
в файл. В результате файл сохраняет двоичные данные в их чистом байтовом
виде, в точности повторяя то, как они хранятся в памяти компьютера.
Именно здесь Rust доказывает свою ценность. Rust превосходно справляется с обеспечением точности и защищенности данных, помещаемых в двоичный файл, минимизируя риск возникновения проблем при работе с памятью.
Следовательно, Rust является надежным выбором для задач, связанных с манипулированием двоичными данными.
Обработка ошибок и типы Result
На протяжении всего обследования темы ввода-вывода на Rust вы могли заметить частое использование типов Result для обработки потенциальных оши-
392
Работа с вводом-выводом с устройств на Rust
бок. Сильный акцент Rust на защищенности и надежности отражается в его
механизмах обработки ошибок. Такое внимание к обработке исключительных ситуаций гарантирует, что ваши программы на Rust будут менее склонны
к возникновению неожиданных проблем и что вы сохраните полный контроль
над тем, как именно эти ошибки управляются.
Rust достигает этого, поощряя использование типа Result для обработки
ошибок. Тип Result представляет собой перечисление с двумя вариантами:
Ok, который содержит результат успешной операции, и Err, который содержит
информацию об ошибке. Используя тип Result, Rust гарантирует, что ошибки
будут признаваться и обрабатываться в исходном коде явно. Такой сильный
акцент на явной обработке ошибок снижает вероятность того, что они останутся незамеченными или проигнорированными.
Давайте вернемся к концепции типа Result и обработки ошибок в контексте
файлового ввода-вывода, наглядно разобрав это на примере исходного кода:
Листинг 9.16. Простейшая программа, демонстрирующая
обработку ошибок и тип Result
use std::fs::File;
use std::io::{Read, Write};
// Функция возвращает Result
// Если внутри произойдет ошибка, то она "всплывет" в точке вызова
fn read_and_write_files() -> Result<(), std::io::Error> {
// Попытаться открыть файл для чтения
let mut read_file = File::open("input.txt")?;
// Попытаться создать файл для записи
let mut write_file = File::create("output.txt")?;
// Определить буфер для хранения данных
let mut buffer = Vec::new();
// Прочитать данные из входного файла
read_file.read_to_end(&mut buffer)?;
// Записать данные в выходной файл
write_all(&mut write_file, &buffer)?;
Ok(())
}
fn main() {
// Использовать match для окончательной обработки результата
match read_and_write_files() {
Ok(_) => println!("Операции с файлами успешно завершены."),
Err(e) => eprintln!("Произошла ошибка: {}", e),
}
}
// Вывод:
// Операции с файлами успешно завершены.
Работа с аппаратными устройствами 393
В приведенном выше примере функция read_and_write_files отвечает за выполнение операций файлового ввода-вывода и возвращает экземпляр типа
Result. Функция main использует конструкцию match для обработки этого экземпляра типа Result, выясняя, как завершились операции: успешно либо с ошибкой.
Философия обработки ошибок на Rust призывает разработчика подходить
к потенциальным проблемам явно и щадяще. Такой подход позволяет писать
более надежный исходный код и помогает выявлять проблемы на ранних этапах
разработки. Явно обрабатывая ошибки с помощью типа Result, мы гарантируем,
что программы будут более надежными, защищенными и предсказуемыми –
а это важнейшие качества в современной разработке программного обеспечения.
Работа с аппаратными устройствами
Если говорить о системном программировании, то вам может потребоваться прямое взаимодействие с аппаратными устройствами. Rust предлагает надежный
способ сопряжения с оборудованием посредством драйверов устройств, библио
тек и создания конкретно-прикладных драйверов. Такое взаимодействие часто
необходимо для управления устройствами, сбора данных с сенсоров и обработки
в реальном времени – задач, охватывающих широкий спектр приложений.
Драйверы устройств служат важнейшим связующим звеном между программным и аппаратным слоями, обеспечивая их связь и координацию. Они
выступают в роли посредников, которые позволяют управлять периферийными устройствами, получать доступ к сенсорам и выполнять другие критически
важные задачи, по сути сокращая разрыв между высокоуровневым программным обеспечением и низкоуровневыми аппаратными операциями.
При работе с оборудованием на Rust крайне важно уделять внимание вопросам прав доступа и кибербезопасности. Прямой доступ к аппаратному
обеспечению может создавать риски для защищенности, если им не управлять должным образом. Здесь защитные гарантии Rust играют ключевую роль
в снижении таких рисков. Функциональные средства языка по обеспечению
защищенности памяти и потокозащищенности помогают предотвращать распространенные ошибки программирования, которые могут приводить к уязвимостям, благодаря чему Rust является надежным выбором для приложений,
требующих прямого взаимодействия с оборудованием при соблюдении стандартов кибербезопасности и защищенности.
Способности Rust распространяются и на низкоуровневое программирование, которое часто является необходимым компонентом работы с устройствами. Его обширный инструментарий позволяет манипулировать памятью,
работать с указателями и эффективно управлять ресурсами, поддерживая при
этом контроль над аппаратными компонентами.
Листинг 9.17. Пример работы с аппаратными устройствами
use rppal::gpio::Gpio;
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
// Инициализировать доступ к GPIO. Оператор '?' обработает ошибки доступа
let gpio = Gpio::new()?;
394
Работа с вводом-выводом с устройств на Rust
// Попытаться получить доступ к контакту номер 17
let pin = gpio.get(17)?;
// Перевести контакт в режим вывода
let mut pin = pin.into_output();
// Обработать любые ошибки, которые могут
// произойти во время операций с контактом
if pin.set_high() != () {
eprintln!("Ошибка при установке высокого уровня сигнала контакта");
}
// Здесь может быть логика (например, зажечь светодиод)
if pin.set_low() != () {
eprintln!("Ошибка установки низкого уровня на контакте");
}
// Пин автоматически высвобождается, когда переменная
// выходит из диапазона доступности
Ok(())
}
Один из распространенных сценариев взаимодействия с оборудованием
предусматривает управление контактами общецелевого интерфейса вводавывода (general-purpose input/output, аббр. GPIO). Эти контакты (или пины)
служат для различных целей, таких как включение и выключение светодио
дов или считывание состояния кнопок. В представленном выше примере
исходного кода на Rust мы используем пакет rppal, чтобы проиллюстрировать, как применять Rust для управления контактом указанного интерфейса
на Raspberry Pi, демонстрируя способности языка в области прямого управления аппаратным обеспечением.
Важно понимать, что работа с аппаратными устройствами часто требует написания исходного кода, специфичного для конкретной платформы или архитектуры. Разные аппаратные платформы и архитектуры имеют уникальные
требования и интерфейсы. Rust учитывает это и предоставляет инструменты
для перекрестной компиляции, позволяя разрабатывать исходный код, который
сможет работать в различных системах и архитектурах без существенных изменений, что способствует реиспользованию исходного кода и его переносимости.
Листинг 9.18. Пример работы с аппаратными устройствами
use i2cdev::linux::LinuxI2CDevice;
use i2cdev::core::I2CDevice;
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
// Подключиться к шине I2C номер 1 и устройству с адресом 0x68
let mut bus = LinuxI2CDevice::new("/dev/i2c-1", 0x68)?;
// Создать буфер для хранения двух байтов ответа
let mut buffer: [u8; 2] = [0; 2];
Продвинутые файловые операции
395
// Отправить команду (0x1) сенсору, чтобы инициировать чтение данных
bus.write(&[0x1])?;
// Прочитать сенсорные данные в буфер
bus.read(&mut buffer)?;
// Объединить два 8-битных байта в одно 16-битное число (битовый сдвиг)
let sensor_value = (u16::from(buffer[0]) << 8) | u16::from(buffer[1]);
println!("Значение датчика: {}", sensor_value);
}
Ok(())
Еще один типичный сценарий в аппаратном программировании – это связь
с сенсорами по таким протоколам межмикросхемной связи, как I2C (interintegrated circuit). В приведенном выше примере исходного кода мы используем пакет i2cdev для сопряжения с I2C-сенсором. Путем записи команды
в сенсор и последующего считывания его данных этот исходный код демонстрирует, как Rust может применяться для сбора данных с сенсоров по протоколу I2C, подчеркивая универсальность языка во взаимодействии с аппаратными устройствами.
Работа с аппаратным обеспечением на Rust открывает массу возможностей
для системного программирования. Независимо от того, создаете ли вы встроенную систему, работаете над приложениями интернета вещей или занимаетесь низкоуровневым управлением оборудованием, сочетание защищенности,
производительности и надежности делает Rust весомым выбором для взаимодействия с устройствами. Представленные примеры исходного кода служат
наглядным подтверждением того, как данный язык эффективно используется
для управления контактами GPIO и связи с сенсорами, раскрывая потенциал
языка в работе с «железом».
Продвинутые файловые операции
Продвинутые файловые операции на Rust открывают новые возможности для
системных программистов и разработчиков, которым необходим высокоточный контроль над файлами и данными. В этом разделе мы углубимся в две
ключевые концепции: символические ссылки и жесткие ссылки. Эти механизмы позволяют ссылаться на файлы нестандартными способами, обеспечивая
гибкость и эффективное управление данными.
Символические и жесткие ссылки
Символические ссылки, которые часто называют «симлинками» (symlink),
по сути являются ссылками на другие файлы. Вместо прямого доступа к содержимому ассоциированного с ней файла символическая ссылка работает как
указатель или ярлык, направляя вас к местоположению фактического файла.
396
Работа с вводом-выводом с устройств на Rust
– Ага! Кажется я нашел папку
с паролями к твоим криптокошелькам в директории
calendar/backup/CRYPTO/
- Эй, не открывай ее!
– Но там внутри куча
папок, заполненных
другими папками,
и через 20 уровней...
я каким-то образом
снова оказался
в корневой папке?
– Похоже, тут нет никакой
криптовалюты, а ты просто
любитель тупых розыгрышей...
– Хе-хе-хе, она
хорошо спрятана.
– А вот не надо
было соваться
в петлю из ссылок
на директории!
– Надеюсь,
когда-нибудь ты
потеряешь в этой
папке настоящий
кошелек!
Рисунок 9.2. xkcd.com/981
Стандартная библиотека Rust предоставляет удобный способ работы с символическими ссылками посредством метода std::os::unix::fs::symlink
в Unix-подобных системах. Этот метод позволяет легко создавать символические ссылки.
Ниже приводится пример, демонстрирующий создание символической
ссылки на Rust:
Листинг 9.19. Простейшая программа для создания символической ссылки
use std::os::unix::fs;
fn main() -> Result<(), std::io::Error> {
fs::symlink("target_file.txt", "symlink_to_target.txt")?;
Ok(())
}
В приведенном выше фрагменте исходного кода создается символическая
ссылка с именем "symlink_to_target.txt", указывающая на файл "target_file.txt".
С другой стороны, жесткие ссылки предоставляют иной способ адресации.
Они указывают на те же исходные данные на накопителе, что и оригинальный
файл1. Создание жесткой ссылки на Rust выполняется просто – с помощью метода std::fs::hard_link.
Ниже приведен пример создания жесткой ссылки на Rust:
1
Жесткая ссылка – это еще одно имя для тех же самых данных на диске, а символическая ссылка, подобно ярлыку в Windows, – это отдельный маленький файл, внутри
которого просто записан путь к целевому объекту. – Прим. перев.
Продвинутые файловые операции
397
Листинг 9.20. Простейшая программа для создания жесткой ссылки
use std::fs;
fn main() -> Result<(), std::io::Error> {
fs::hard_link("source_file.txt", "hard_link_to_source.txt")?;
Ok(())
}
В приведенном выше исходном коде создается жесткая ссылка "hard_
link_to_source.txt", указывающая на файл "source_file.txt". Это означает,
что теперь оба файла ссылаются на одни и те же блоки данных на устройстве
хранения.
Крайне важно понимать разницу между символическими и жесткими ссылками. При изменении содержимого файла, на который указывает символическая ссылка, изменения отражаются и в связанном файле. В отличие от этого,
жесткие ссылки не создают обособленные копии, а используют данные сообща, поэтому любые изменения в одном файле мгновенно становятся видны
в другом. Понимание этих различий необходимо для того, чтобы осознавать,
как распространяются изменения и как управляются файловые операции
в приложениях на Rust.
Запирание файлов на замок
Запирание файлов на замок – это важнейший механизм, используемый для
обеспечения непротиворечивости и целостности данных. Данный механизм
предотвращает одновременный доступ к файлу и модификацию файла несколькими процессами или потоками исполнения. В Rust эта функциональность реализуется с помощью методов std::fs::File::try_lock_exclusive
и std::fs::File::try_lock_shared, которые обеспечивают возможности соответственно исключительного и коллективного запирания файлов на замок.
Исключительное запирание на замок, продемонстрированное в следующем
ниже фрагменте исходного кода, гарантирует, что только один процесс или поток исполнения может приобрести и удерживать замок, эффективно защищая
файл от конкурентных изменений:
Листинг 9.21. Простейшая программа для запирания файлов на замок
use fs2::FileExt;
use std::fs::File;
use std::io;
fn main() -> Result<(), io::Error> {
// Создать или открыть файл, подлежащий запиранию на замок
let file = File::create("locked_file.txt")?;
// Попытаться приобрести исключительный замок на файл
if let Ok(_lock) = file.try_lock_exclusive() {
println!("Приобретен исключительный замок.");
// Здесь выполняются исключительные операции
398
Работа с вводом-выводом с устройств на Rust
// Замок будет автоматически снят, когда переменная file
// (или дескриптор) выйдет из диапазона доступности
} else {
println!("Не удалось приобрести исключительный замок.");
}
Ok(())
}
// Вывод программы:
// Приобретен исключительный замок.
В приведенном выше исходном коде мы сначала создаем файл, а затем предпринимаем попытку приобрести исключительный замок на него. Если замок
успешно приобретен, то мы получаем возможность выполнять исключительные операции внутри критической секции. Значительным преимуществом запирания файлов на замок в Rust является то, что замок автоматически снимается, когда переменная lock выходит из диапазона доступности. Это упрощает
управление ресурсами и снижает риск возникновения непредумышленных
взаимных блокировок.
Аналогичным образом можно реализовать запирание на коллективный
замок, используя метод try_lock_shared (предоставляемый общей чертой
FileExt из пакета fs2). Такой подход позволяет нескольким процессам или потокам исполнения конкурентно получать доступ к файлу в режиме только для
чтения, что и продемонстрировано в следующем ниже фрагменте исходного
кода. Это означает, что запирание на коллективный замок подходит для сценариев, в которых данные могут читаться несколькими процессами одновременно без опасений за их целостность.
Листинг 9.22. Коллективный доступ только для чтения несколькими потоками
use
use
use
use
use
use
fs2::FileExt;
std::fs::{self, File};
std::io;
std::io::prelude::*;
std::sync::Arc;
std::thread;
fn main() -> Result<(), io::Error> {
// Создать или перезаписать файл начальным содержимым
let content = "\nline 0\nline 1\nline 2\n";
fs::write("shared_file.txt", content)?;
// Прочитать содержимое файла вне запертой секции
let file = File::open("shared_file.txt")?;
let mut buffer = String::new();
if let Ok(bytes_read) = io::BufReader::new(file.try_clone()?).read_to_
string(&mut buffer) {
println!("Прочитано {} байт: {}", bytes_read, &buffer);
} else {
println!("Не удалось прочитать файл.");
}
Продвинутые файловые операции
399
// Обернуть буфер в умный указатель Arc
// для коллективного владения между потоками
let buffer = Arc::new(buffer);
// Породить несколько потоков для доступа к коллективному содержимому
let num_threads = 5;
let mut handles = vec![];
for _ in 0..num_threads {
let file_clone = file
.try_clone()
.expect("Не удалось клонировать дескриптор файла");
let buffer_clone = Arc::clone(&buffer); // Arc для коллективного владения
let handle = thread::spawn(move || {
// Попытаться получить коллективный замок
if let Ok(_lock) = file_clone.try_lock_shared() {
println!(
"Поток {:?} приобрел коллективный замок \
для доступа только для чтения.",
thread::current().id()
);
// Выполнить операции чтения на коллективном содержимом
println!(
"Поток {:?} обрабатывает коллективное содержимое: {}",
thread::current().id(),
&buffer_clone
);
} else {
println!(
"Потоку {:?} не удалось приобрести коллективный замок.",
thread::current().id()
);
}
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
Ok(())
}
//
//
//
//
//
//
//
//
//
//
Вывод:
Прочитано 22 байт:
строка 0
строка 1
строка 2
Поток ThreadId(2) приобрел коллективный замок для доступа только для чтения.
Поток ThreadId(2) обрабатывает коллективное содержимое:
строка 0
строка 1
строка 2
400
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
Работа с вводом-выводом с устройств на Rust
Поток ThreadId(4)
Поток ThreadId(4)
строка 0
строка 1
строка 2
Поток ThreadId(3)
Поток ThreadId(3)
строка 0
строка 1
строка 2
Поток ThreadId(5)
Поток ThreadId(5)
строка 0
строка 1
строка 2
Поток ThreadId(6)
Поток ThreadId(6)
строка 0
строка 1
строка 2
приобрел коллективный замок для доступа только для чтения.
обрабатывает коллективное содержимое:
приобрел коллективный замок для доступа только для чтения.
обрабатывает коллективное содержимое:
приобрел коллективный замок для доступа только для чтения.
обрабатывает коллективное содержимое:
приобрел коллективный замок для доступа только для чтения.
обрабатывает коллективное содержимое:
В приведенном выше примере:
1) мы создаем файл с именем "shared_file.txt";
2) порождаем несколько потоков исполнения (количество задается переменной num_threads) для конкурентного чтения из файла;
3) каждый поток пытается приобрести коллективный замок на файл с помощью метода try_lock_shared(). Если замок приобретен, то на экран
выводится сообщение о том, что поток может выполнять операции только для чтения;
4) после приобретения замка каждый поток исполнения читает содержимое
файла. Операция io::BufReader::new((file_clone).read_to_string(&mut
buffer) через BufReader читает данные в буфер, после чего поток выводит на экран количество прочитанных байтов и само содержимое. В случае ошибки чтения выводится соответствующее сообщение;
5) обертываем буфер в умный указатель Arc (используя Arc::new(buffer)),
чтобы коллективно владеть данными между потоками исполнения;
6) вместо копирования самого буфера мы используем Arc::clone(&buffer),
создавая ссылки на коллективный умный указатель Arc;
7) коллективный умный указатель Arc используется в потоках исполнения для
обработки содержимого. За счет этого гарантируется эффективное коллективное использование буфера без лишнего клонирования данных, позволяя нескольким потокам обращаться к нему в режиме только для чтения;
8) используем thread::spawn для порождения потоков исполнения и отслеживания их дескрипторов;
9) после порождения всех потоков исполнения ожидаем их завершения
с помощью метода join.
Благодаря такой настройке каждый поток исполнения читает и обрабатывает содержимое файла "shared_file.txt", демонстрируя коллективный доступ
в режиме только для чтения.
Важность запирания файлов на замок особенно очевидна в сценариях, когда
критически важны непротиворечивость и целостность данных. Например, при
Продвинутые файловые операции
401
управлении конфигурационными файлами или координации доступа к коллективным ресурсам замок гарантирует, что важные операции происходят
контролируемо и упорядоченно. Эта защита жизненно необходима для предотвращения порчи данных или возникновения состояний гонки за доступ,
которые могут подрывать надежность приложения.
Отображаемые файлы
Отображаемые файлы представляют собой инновационный подход к работе с данными в сфере разработки программного обеспечения. Такие файлы
по сути являются мостом между приложением и физическим носителем, позволяя отображать (проецировать) файл напрямую в адресное пространство
памяти. Пакет Rust memmap предоставляет ценный инструментарий для эффективного использования этой способности.
Отображаемые файлы в корне меняют правила игры в том, что касается обработки крупных файлов или оптимизации производительности. По сути дела,
они позволяют обращаться с файлом так, будто это массив, находящийся в оперативной памяти компьютера. Это означает, что вы можете легко и быстро получать доступ к содержимому файла и им манипулировать. Это разительно отличается от традиционных операций ввода-вывода, которые предполагают чтение или запись данных на диск в более линейной и последовательной манере.
Теперь давайте перейдем к практическому примеру, который демонстрирует
использование библиотеки memmap. В следующем ниже фрагменте исходного кода
показано, как отображать файл в память и эффективно читать его содержимое.
Листинг 9.23. Отображаемые файлы
use std::fs::File;
use std::io;
use memmap::Mmap;
fn main() -> Result<(), io::Error> {
let file = File::open("large_file.bin")?;
// Создать отображение файла в память
// Это действие помечено как unsafe, так как внешние изменения файла
// могут привести к неопределенному поведению в программе
let mmap = unsafe { Mmap::map(&file)? };
// Доступ к данным через срез
let data = &mmap;
// Выполнить операции чтения на data
let max_bytes_to_print = 100; // Изменить это значение при необходимости
for i in 0..max_bytes_to_print {
// Привести байт к символу ASCII и вывести его на экран
print!("{} ", data[i] as char);
}
Ok(())
}
402
Работа с вводом-выводом с устройств на Rust
Здесь мы инициируем процесс, открыв нужный файл с помощью File::open.
Затем используем функцию Mmap::map, чтобы создать вид на отображенный
в память файл. После установки вида на отображение в память можно обращаться к его содержимому посредством среза. Это позволяет выполнять операции чтения напрямую на данных, как если бы они находились в обычном
массиве в оперативной памяти.
Отображаемые файлы дают заметные преимущества при обработке крупных наборов данных, разборе двоичных файлов или разработке индивидуальных решений для хранения данных. Их реальный потенциал заключается
в оптимизации производительности за счет устранения традиционных узких
мест ввода-вывода. Вместо этого они позволяют задействовать способности
механизмов отображения в память с целью создания более эффективной и отзывчивой системы управления данными в приложениях на Rust. Этот улучшенный метод доступа может приводить к ускорению обработки данных и,
как следствие, к существенному повышению общей производительности приложения, особенно в сценариях с высокой нагрузкой на ресурсы.
Лучшие образцы практики файлового ввода-вывода
Для любого серьезного программного приложения важны эффективные и надежные операции ввода-вывода на Rust. В целях оказания вам помощи в достижении этой цели ниже приведены ключевые рекомендации, которые следует учитывать при работе с файлами на Rust.
Обработка ошибок: для обработки ошибок рекомендуется использовать тип Result. Ошибки должны обрабатываться явно, чтобы гарантировать отказоустойчивость и надежность исходного кода. Ниже приводится пример того, как следует работать с ошибками:
Листинг 9.24. Обработка файловых ошибок
use std::fs::File;
fn main() -> Result<(), std::io::Error> {
let file = File::open("example.txt");
match file {
Ok(_) => {
// Файл успешно открыт, можно продолжать работу
}
Err(err) => {
// Обработка ошибки, например вывод сообщения об ошибке на экран
println!("Ошибка: {}", err);
return Err(err);
}
}
Ok(())
}
В приведенном выше исходном коде мы открываем файл и явно обрабатываем
любые потенциальные ошибки, которые могут возникать в процессе открытия.
Продвинутые файловые операции
403
Управление ресурсами: следует использовать принятые в Rust механизм владения и управление диапазоном доступности для очистки
ресурсов. Когда дескриптор файла выходит из диапазона доступности,
он автоматически закрывается, что предотвращает утечку ресурсов. Такое автоматическое управление ресурсами является стержневым функциональным средством Rust, как уже было продемонстрировано в предыдущих фрагментах исходного кода.
Кросс-платформенная совместимость: следует помнить о различиях
в файловых системах в разных платформах. Рекомендуется использовать инструменты Rust для манипуляции путями, чтобы обеспечивать
кросс-платформенную совместимость. Модуль std::path предоставляет
функции для работы с путями платформонезависимым способом, гарантируя переносимость исходного кода. Ниже приведен пример исходного
кода для объединения путей платформонезависимым способом:
Листинг 9.25. Работа с путями платформонезависимым способом
use std::path::Path;
fn main() {
let base_path = Path::new("path/to/folder");
let file_name = "example.txt";
// Объединить пути платформонезависимым способом
let full_path = base_path.join(file_name);
}
// Теперь переменная full_path будет содержать:
// "path/to/folder/example.txt" в Linux/macOS
// "path\to\folder\example.txt" в Windows
Этот исходный код конструирует путь, который бесшовно работает в разных
платформах.
Права доступа и кибербезопасность: следует всегда помнить о правах
доступа к файлам и соображениях кибербезопасности. Рекомендуется
проверять, что приложение обладает необходимыми правами доступа,
изменения или создания файлов. Rust предоставляет механизмы установки и проверки прав, позволяя работать с файлами защищенно. Ниже
приводится пример того, как можно изменять права доступа к файлу:
Листинг 9.26. Работа с правами доступа к файлам
use std::os::unix::fs::PermissionsExt;
use std::fs::File;
fn main() -> std::io::Result<()> {
// Создать файл
let file = File::create("example.txt")?;
// Получить текущие права доступа из метаданных файла
let mut permissions = file.metadata()?.permissions();
404
Работа с вводом-выводом с устройств на Rust
// Установить права доступа: 0o755 (rwxr-xr-x)
// Владелец: чтение, запись, исполнение
// Остальные: чтение и исполнение
permissions.set_mode(0o755);
// Применить новые права к файлу
file.set_permissions(permissions)?;
}
Ok(())
// Вывод:
// $ ls -lah example.txt
// -rwxr-xr-x 1 user user 0 Nov 1 21:24 example.txt
В приведенном выше исходном коде мы создаем файл и устанавливаем для
него права доступа: чтение, запись и исполнение для владельца, а также чтение и исполнение для всех остальных.
Резервное копирование и контроль версий: рекомендуется реализовывать механизмы резервного копирования и контроля версий для
важных файлов. При работе с критически важными данными следует
рассмотреть возможность использования запирания файлов на замок
и систем версионирования. Например, простой механизм резервного
копирования можно реализовать следующим образом:
Листинг 9.27. Простейшая программа для резервного копирования
use std::fs::{File, copy};
fn backup_file(source: &str, destination: &str) -> std::io::Result<()> {
// Копировать содержимое исходного файла в целевой файл
copy(source, destination)?;
Ok(())
}
Эта функция копирует файл из исходного пути в целевой, фактически создавая резервную копию.
Оптимизация производительности: при работе с крупными файлами
или задачами, критичными к производительности, следует рассмотреть
возможность использования отображаемых файлов или буферизованного ввода-вывода для достижения максимальной производительности ввода-вывода. Как было продемонстрировано ранее, отображаемые
файлы значительно повышают производительность ввода-вывода, обес
печивая прямой доступ к данным файла в памяти, а буферизованный
ввод-вывод позволяет сокращать количество операций ввода-вывода.
Документирование: следует поддерживать понятную и исчерпывающую документацию исходного кода, особенно при работе со сложными
операциями на файлах. Четкая документация помогает не только вам,
но и другим разработчикам, которые будут работать с этим исходным
кодом в будущем. Rust поддерживает документационные комментарии
с использованием /// или //!, что позволяет создавать информативные
описания исходного кода.
Драйверы устройств и модули ядра 405
Тестирование: рекомендуется тщательно тестировать исходный код
ввода-вывода в условиях различных сценариев, включая условия возникновения ошибок и граничные случаи, чтобы обеспечивать стабильную работу программы. Встроенный в Rust фреймворк тестирования
предоставляет простой способ написания и выполнения тестов. Для
создания тестов используется атрибут #[test]. Ниже приведен пример
простого теста функции:
Листинг 9.28. Простейшая программа с модульным тестом
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
}
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
assert_eq!(add(-1, 1), 0);
}
Этот тест проверяет, ведет ли себя функция add ожидаемым образом при
вводе в нее разных входных значений.
Приведенные выше примеры исходного кода охватывают различные лучшие образцы практики работы с файловым вводом-выводом на Rust, помогая
эффективно реализовывать каждую рекомендацию в свои проекты.
Следуя этим лучшим образцам практик, вы будете полностью готовы к выполнению сложных файловых операций и обеспечению целостности данных.
Независимо от того, имеете ли вы дело с базовым вводом-выводом или с продвинутыми операциями, эти рекомендации помогут вам писать чистый, защищенный и высокопроизводительный исходный код для обработки файлов.
Драйверы устройств и модули ядра
Драйверы устройств и модули ядра подобны посредникам в компьютерном
программировании. У них важная задача: помогать программному обеспечению компьютера «общаться» с его аппаратной частью. Представьте свой
компьютер как большой пазл из большого количества деталей. Драйверы
устройств – это клей, который удерживает эти детали вместе. Они обеспечивают плавную работу клавиатуры, мыши, экрана и других компонентов с «мозгом» компьютера – операционной системой.
Rust становится убедительным выбором для написания драйверов устройств.
Эти программные компоненты должны обладать высокой степенью надежности и защищенности, так как они напрямую взаимодействуют с оборудованием. При написании драйверов на Rust вы часто будете работать в среде no_std.
В условиях ограниченных ресурсов, когда критически важны память и произ-
406
Работа с вводом-выводом с устройств на Rust
водительность, использование полной стандартной библиотеки бывает нецелесообразным, поэтому предпочтение отдается no_std.
Теперь давайте обследуем сложную тему разработки драйверов на Rust. В таких проектах вы будете тесно работать с «железом», получая прямой доступ
к регистрам, вводу-выводу с отображением в память и обрабатывая аппаратные прерывания. Такой уровень взаимодействия обеспечивает высокоточный
контроль над аппаратными устройствами, что является фундаментальным
требованием в системном программировании.
#![no_std]
extern crate some_platform_crate;
// Здесь идет исходный код драйвера
Ключевым аспектом разработки драйверов на Rust является использование
платформозависимых библиотек и пакетов. Более того, написание специфичного для конкретной платформы исходного кода бывает необходимо для формирования бесшовного интерфейса с оборудованием. В этой области широко
распространена перекрестная компиляция, позволяющая компоновать драйверы для разных архитектур и платформ, что является важным умением для
разработчика драйверов.
Работа с ядром Linux – распространенный сценарий в области разработки
драйверов. Rust получил существенную поддержку разработки ядра: появляется все больше модулей и драйверов на базе Rust для самого широкого спект
ра аппаратных компонентов. Этот тренд подчеркивает растущий потенциал
Rust в контексте низкоуровневого системного программирования.
// Пример модуля ядра, написанного на Rust
mod my_kernel_module {
// Здесь идет ваш исходный код
}
Важно понимать, что разработка драйверов устройств – это узкоспециализированная область, требующая глубокого понимания как аппаратного обес
печения, так и сложностей конкретной операционной системы. Хотя защитные функциональные средства Rust способны предотвращать определенные
типы дефектов и уязвимостей, низкоуровневая разработка требует глубоких
знаний архитектуры оборудования и точных требований используемой ОС.
Разработка драйверов устройств на Rust – это динамичная и эволюционирующая область, и сообщество Rust активно изучает лучшие образцы практики
и методы построения защищенных и эффективных драйверов. Работая в этой
сфере, вы встретите ценные ресурсы, форумы и проекты с открытым исходным
кодом, которые послужат эталонами, помогая вам ориентироваться в сложной
сфере системного программирования и разработки драйверов на Rust.
Сетевое программирование и ввод-вывод с устройств
В системном программировании способность обмениваться данными по сети
часто является неотъемлемой частью работы с устройствами. Независимо
от того, создаете ли вы систему дистанционного управления, сенсорную сеть
Сетевое программирование и ввод-вывод с устройств 407
или распределенную систему сбора данных, сетевое программирование может
значительно расширить способности ввода-вывода вашего устройства.
Сетевая связь может выступать в роли связующего звена между устройствами, обеспечивая дистанционное управление, обмен данными и контроль
в реальном времени. В Rust сетевое программирование реализовано надежно
и эффективно благодаря целому ряду библиотек и фреймворков, что делает
его отличным дополнением к функциональности ввода-вывода с устройств.
Сокеты и протоколы
Сетевое программирование – это тот самый секретный ингредиент, который позволяет устройствам и системам бесшовно общаться друг с другом. Оно подобно дирижеру оркестра, следящему за тем, чтобы все инструменты (устройства)
слаженно исполняли свои партии. Не важно, что вы хотите делать – отправлять
фотографии, сообщения или управлять дистанционными приборами, – сетевое
программирование служит мостом, который делает эти операции возможными. Rust представляет собой отличный выбор для этой задачи. Он выступает
в роли супергероя со специальным набором инструментов для создания таких
соединений. Его невероятная надежность гарантирует плавную и защищенную
работу, что критически важно при работе с устройствами и сетями.
В основе сетевого программирования и обмена данными лежат сокеты.
Представьте себе сокеты как вилки и розетки, которые вы используете для
подключения приборов к источнику питания. В компьютерном программировании сокеты – это разъемы, позволяющие устройствам обмениваться данными. Rust упрощает процесс работы с ними, предоставляя единообразный
способ создания и управления такими соединениями. Это означает, что независимо от того, соединяете ли вы устройства в одной комнате или по всему
интернету, Rust гарантирует стабильность и надежность коммуникационного процесса. Таким образом, Rust выступает в роли доверенного проводника,
обеспечивающего гармоничную работу всей системы, независимо от расстоя
ния между узлами.
Сокеты тесно связаны с конкретными сетевыми протоколами, и Rust поддерживает различные типы сокетов, включая TCP-сокеты, UDP-сокеты и доменные сокеты Unix. Выбор подходящего типа сокета и протокола позволяет бесшовно интегрировать сетевую связь в операции ввода-вывода с вашего
устройства. Например, при наличии подключенного к сети сенсора можно использовать поддержку TCP-сокетов в Rust, чтобы устанавливать с ним соединение, получать реально-временные данные и включать их в процессы вводавывода в рамках вашей системы.
Гибкость реализации сокетов на Rust позволяет создавать каналы связи,
стирающие преграды между устройствами, независимо от их физического
местоположения. Это неоценимо в сценариях, когда нужно собирать данные с дистанционных сенсоров или управлять оборудованием, находящимся
в разных местах.
В целях иллюстрации этой концепции ниже представлен фрагмент исходного кода на Rust, демонстрирующий работу TCP-сервера для дистанционного
сенсорного устройства:
408
Работа с вводом-выводом с устройств на Rust
Листинг 9.29. Простейший TCP-сервер
use std::io::{Read, Write};
use std::net::TcpListener;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Привязать сервер к адресу 0.0.0.0 (все интерфейсы) на порту 8080
let listener = TcpListener::bind("0.0.0.0:8080")?;
println!("Сенсорный сервер слушает на 0.0.0.0:8080");
for stream in listener.incoming() {
let mut stream = stream?;
let mut buffer = [0; 1024];
// Прочитать данные из потока в буфер
stream.read(&mut buffer)?;
// Обработать данные, полученные от дистанционного устройства
let sensor_data = process_sensor_data(&buffer);
// Выполнить операции ввода-вывода на основе полученных данных
perform_device_io(&sensor_data);
// Отправить ответ дистанционному устройству
stream.write_all(b"Данные получены и обработаны.")?;
}
Ok(())
}
fn process_sensor_data(data: &[u8]) -> SensorData {
// Обработка полученных данных и их преобразование в структурированный
// формат. В реальных условиях это могло бы включать десериализацию и
// валидацию. Для простоты в данном примере предполагается прямое
// преобразование
SensorData::from_bytes(data)
}
fn perform_device_io(sensor_data: &SensorData) {
// Выполнение операций ввода-вывода с устройства с использованием
// сенсорных данных. Это может включать управление актуаторами,
// принятие решений или сохранение данных. В данном примере мы
// выполняем гипотетическую операцию ввода-вывода, просто выводя
// сенсорные данные на экран
println!("Полученные сенсорные данные: {:?}", sensor_data);
}
#[derive(Debug)]
struct SensorData {
// Здесь описывается структура сенсорных данных
}
impl SensorData {
fn from_bytes(_data: &[u8]) -> SensorData {
// Конвертировать полученные байты в экземпляр SensorData.
// В реальном приложении это может включать десериализацию и
// валидацию данных. В данном упрощенном примере мы исходим из того,
Сетевое программирование и ввод-вывод с устройств 409
// что байты напрямую представляют сенсорные данные.
SensorData {}
}
}
В приведенном выше исходном коде мы создали TCP-сервер на базе Rust,
который работает как «дорожный регулировщик» для дистанционных сенсорных устройств. Сервер привязан к определенному интернет-адресу и ожидает данные, отправляемые этими устройствами, подобно тому, как автомобили подъезжают к контрольному пункту. Получив данные, он обрабатывает
их и использует полученную информацию для координации действий между
разными устройствами. Эти действия могут включать включение или выключение оборудования, принятие решений на основе входящих данных или сохранение информации для последующего использования. Особенность этого
исходного кода – способность обеспечивать реально-временную связь, что позволяет устройствам эффективно взаимодействовать. Это похоже на диалог,
где устройства не просто обмениваются репликами, но и используют их для
совершения действий, что неоценимо для широкого спектра приложений, где
важна бесперебойная связь.
В целях проверки этой концепции в действии можно использовать консольную утилиту netcat (или nc), чтобы сымитировать подключение клиента
к TCP-серверу. Вот простое пошаговое руководство:
1. Сначала надо запустить программу Rust, исполнив следующую ниже команду в терминале:
$ cargo run
Эта команда запустит ваш TCP-сервер, и он будет готов принимать соединения на порту 8080.
2. Затем открыть новое окно терминала и использовать netcat для установки соединения с сервером:
$ nc 127.0.0.1 8080
Эта команда инициирует подключение к серверу, запущенному на вашем
компьютере по адресу 127.0.0.1 (также именуемому localhost), через порт 8080.
3. После установления соединения можно набирать или вставлять данные
в терминале netcat. Все, что будет введено, будет отправлено на ваш сервер Rust. Например: Привет, это сенсорные данные.
4. Ваш сервер прочитает отправленные данные, обработает их согласно
логике, написанной на Rust, и ответит сообщением, например: Данные
получены и обработаны.
5. Вы можете вводить любые сенсорные данные для тестирования, и сервер будет обрабатывать их по правилам, определенным в исходном коде.
6. Для того чтобы завершить соединение и закрыть netcat, надо просто нажать сочетание клавиш Ctrl+C.
Следуя этим шагам, вы сможете эффективно оценивать функциональность
вашего сервера, передавая данные вручную. Это позволяет симулировать подключение клиента и получение ответов, что является удобным способом убедиться, что сервер работает, как и ожидается.
410 Работа с вводом-выводом с устройств на Rust
Сетевое программирование на Rust – это мощный инструмент, который
улучшает работу устройств при их общении между собой. Оно помогает связывать оборудование, даже если оно находится далеко, позволяя строить системы, требующие как локальных, так и дистанционных соединений. Rust предоставляет средства для плавной реализации этих задач благодаря специальным
библиотекам, предназначенным для работы с сокетами и передачи данных.
Это означает, что вы можете разрабатывать надежные и быстрые решения для
общения устройств, находятся ли они рядом или на разных сторонах земного
шара. Проще говоря, Rust помогает устройствам «разговаривать» друг с другом
через интернет или локальную вычислительную сеть, что крайне полезно для
самых разных приложений.
Асинхронное сетевое взаимодействие
и ввод-вывод с устройств
В современном взаимосвязанном мире отзывчивость и эффективность являются ключевыми факторами как для сетевого программирования, так и для
ввода-вывода с устройств. Традиционный блокирующий ввод-вывод, в котором каждая операция ожидает своей очереди, может существенно снижать
производительность, особенно при обработке большого количества конкурентных операций. Для решения этой задачи Rust предлагает мощное решение в виде синтаксиса async/await, позволяющего писать асинхронный исходный код ввода-вывода.
Синтаксис async/await в Rust – это своего рода «суперсила» в написании
компьютерных программ. Он помогает создавать исходный код, который
не застревает в ожидании событий, а позволяет «жонглировать» множеством
заданий одновременно без потери скорости. Представьте, что вы супергерой, который может одновременно отвечать на многочисленные телефонные
звонки, отправлять сообщения и выполнять различные поручения без какихлибо задержек. Эта суперсила незаменима, когда нужно, чтобы программа
реагировала мгновенно, справлялась с большой нагрузкой и плавно работала с большим количеством соединений – будь то общение с другими компьютерами в интернете либо управление разными гаджетами. Таким образом, async/await в Rust – это мощный инструмент построения эффективных
и быстрых программ в ситуациях, где критически важны быстродействие,
гибкость и многозадачность.
Рассмотрим практический сценарий: у вас есть система управления устройствами, которая взаимодействует с сетью сенсоров. Эти сенсоры непрерывно
генерируют данные, и вам необходимо собирать и обрабатывать показания
от нескольких устройств одновременно. Возможности асинхронного вводавывода в Rust приходят на помощь, позволяя эффективно управлять этими
сенсорами, не блокируя при этом систему управления.
В следующем ниже фрагменте исходного кода на Rust представлен пример
асинхронного TCP-сервера, который эффективно справляется с несколькими
сенсорными устройствами:
Сетевое программирование и ввод-вывод с устройств 411
Листинг 9.30. Простейший асинхронный TCP-сервер
use
use
use
use
tokio::net::TcpListener;
tokio::io::{AsyncWriteExt, AsyncReadExt};
std::error::Error;
std::net::SocketAddr;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let addr: SocketAddr = "0.0.0.0:8080".parse()?;
let listener = TcpListener::bind(&addr).await?;
println!("Сенсорный сервер слушает на 0.0.0.0:8080");
loop {
// Ожидать новое подключение
let (mut socket, addr) = listener.accept().await?;
println!("новый клиент: {:?}", addr);
// Порождать отдельное асинхронное задание для каждого клиента
tokio::spawn(async move {
let mut buf = vec![0; 1024];
// Прочитать данные асинхронно
if let Err(e) = socket.read(&mut buf).await {
eprintln!("Ошибка чтения из сокета: {}", e);
return;
}
// Обработать сенсорные данные от дистанционного устройства
let sensor_data = process_sensor_data(&buf);
// Выполнить операции ввода-вывод с устройства на сенсорных данных
perform_device_io(&sensor_data);
// Отправить дистанционному устройству конкретно-прикладной ответ
let response = b"Данные получены и обработаны\n";
// Записать ответ асинхронно
if let Err(e) = socket.write_all(response).await {
eprintln!("Ошибка записи в сокет: {}", e);
return;
}
// Сбросить буфер, чтобы данные ушли немедленно
if let Err(e) = socket.flush().await {
eprintln!("Ошибка сброса сокета: {}", e);
}
});
}
}
// --- Вспомогательные функции и структуры --fn process_sensor_data(data: &[u8]) -> SensorData {
// Обработать полученные данные и их конвертировать в структурированный
// формат. В реальных условиях этот этап может включать десериализацию и
412
Работа с вводом-выводом с устройств на Rust
// валидацию данных. Для упрощения в данном примере предполагается
// прямая конвертация
SensorData::from_bytes(data)
}
fn perform_device_io(sensor_data: &SensorData) {
// Выполнить операции ввода-вывода с устройства с использованием
// сенсорных данных. Это может включать управление актуаторами,
// принятие решений или сохранение данных. В данном примере мы
// выполняем гипотетическую операцию ввода-вывода, просто выводя
// сенсорные данные на экран.
println!("Received sensor data: {:?}", sensor_data);
}
#[derive(Debug)]
struct SensorData {
// Определить структуру сенсорных данных
}
impl SensorData {
fn from_bytes(_data: &[u8]) -> SensorData {
// Конвертировать полученные байты в экземпляр SensorData.
// В реальном приложении это может включать десериализацию и
// валидацию данных. В данном упрощенном примере мы исходим из того,
// что байты напрямую представляют сенсорные данные.
SensorData {}
}
}
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
Вывод:
Сенсорный сервер слушает на 0.0.0.0:8080
новый клиент: 127.0.0.1:40152
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:40162
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:40170
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:40178
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:40190
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50572
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50580
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50596
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50598
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50600
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50604
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50606
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50620
Сетевое программирование и ввод-вывод с устройств 413
//
//
//
//
//
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50630
Получены сенсорные данные: SensorData
новый клиент: 127.0.0.1:50646
Получены сенсорные данные: SensorData
Приведенный выше исходный код описывает асинхронный TCP-сервер,
который эффективно обрабатывает подключения от нескольких сенсорных
устройств конкурентно. Сервер обрабатывает сенсорные данные в асинхронном режиме и бесшовно интегрирует их с операциями ввода-вывода с устройств. Для имитации нескольких клиентских соединений можно
использовать простой цикл for в Bash, создающий конкурентные сеансы
netcat. Ниже приведена команда Unix для симуляции конкурентных соединений с помощью netcat:
Листинг 9.31. Простейшая команда Unix для симуляции
нескольких клиентских соединений
$ for i in {1..5}; do (echo “Клиент $i соединен” && sleep 1) | nc
127.0.0.1 8080 & done; wait
// Вывод:
// [1] 88712
// [2] 88714
// [3] 88718
// [4] 88721
// [5] 88723
// Данные получены и обработаны
// Данные получены и обработаны
// Данные получены и обработаны
// Данные получены и обработаны
// Данные получены и обработаны
// [1] Done
) | nc 127.0.0.1 8080
// [2] Done
) | nc 127.0.0.1 8080
// [3] Done
) | nc 127.0.0.1 8080
// [4]- Done
) | nc 127.0.0.1 8080
// [5]+ Done
) | nc 127.0.0.1 8080
( echo “Клиент $i соединен” && sleep 1
( echo “Клиент $i соединен” && sleep 1
( echo “Клиент $i соединен” && sleep 1
( echo “Клиент $i соединен” && sleep 1
( echo “Клиент $i соединен” && sleep 1
Приведенный выше однострочник симулирует пять конкурентных клиентских соединений с сервером: каждый клиент отправляет сообщение и выжидает паузу в 1 секунду перед подсоединением. Количество соединений можно
скорректировать, отредактировав диапазон в части {1..5}.
Использование асинхронного ввода-вывода на Rust гарантирует, что ваша
компьютерная система останется быстрой, сможет обслуживать большое количество устройств и мгновенно реагировать на события, не замедляя другие задания. Это позволяет строить мощные и высокопроизводительные системы, способные одновременно справляться как с аппаратными заданиями
414
Работа с вводом-выводом с устройств на Rust
устройств, так и с сетевым обменом данными. Иными словами, это помогает
вашему компьютеру «жонглировать» большим количеством задач сразу, обес
печивая плавную и эффективную работу всего комплекса.
Библиотеки для сетевого взаимодействия
и ввод-вывод с устройств
Обширная экосистема Rust предлагает целый ряд сетевых библиотек и фреймворков для сетевого взаимодействия, призванных упрощать сложности сетевого программирования. Эти средства предоставляют полезные абстракции
и инструменты, которые оптимизируют стандартные сетевые операции, что
делает их незаменимыми при работе с подключенными к сети устройствами.
Одной из таких библиотек в экосистеме Rust является hyper – чрезвычайно популярная библиотека для работы с HTTP. Библиотека hyper предлагает удобный
и расширяемый API для создания как HTTP-клиентов, так и HTTP-серверов,
упрощая процесс интеграции веб-управления и мониторинга устройств в вашу
систему. Следующий ниже фрагмент исходного кода демонстрирует использование библиотеки hyper для построения HTTP-сервера, способного обслуживать конечные точки управления устройствами:
Листинг 9.32. Простейший HTTP-сервер на базе библиотеки hyper
use
use
use
use
hyper::service::{make_service_fn, service_fn};
hyper::{Body, Request, Response, Server};
std::convert::Infallible;
std::net::SocketAddr;
async fn device_control(_req: Request<Body>) -> Result<Response<Body>, Infallible> {
// Здесь реализовать логику управления устройством
// Эта функция может обрабатывать входящие запросы для управления устройствами
Ok(Response::new(Body::from("Ответ управления устройством")))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let make_svc = make_service_fn(|_conn| {
let svc = service_fn(device_control);
async { Ok::<_, Infallible>(svc) }
});
let addr: SocketAddr = ([127, 0, 0, 1], 8080).into();
let server = Server::bind(&addr).serve(make_svc);
println!("Сервер управления устройствами слушает
на http://127.0.0.1:8080");
server.await?;
Ok(())
}
Вопросы защищенности и кибербезопасности 415
В приведенном выше фрагменте исходного кода мы использовали биб
лиотеку hyper для создания HTTP-сервера. Этот сервер способен отвечать
на запросы к конечным точкам управления устройствами, что позволяет
дистанционно их администрировать и контролировать. Функция device_
control представляет собой логику обработки входящих запросов, демонстрируя, насколько хорошо библиотека упрощает процесс реализации подобной функциональности.
Помимо библиотеки hyper, экосистема Rust содержит ряд других библио
тек и фреймворков для сетевого взаимодействия, таких как actix-web, warp
и Rocket. Эти фреймворки предоставляют комплексные решения для разработки веб-приложений и API. С их помощью можно создавать интерфейсы для
управления устройствами, отслеживать их состояние и бесшовно взаимодействовать с подключенным к сети оборудованием.
Используя мощь этих сетевых библиотек и фреймворков, вы сможете легко
объединять операции ввода-вывода с сетевыми коммуникациями, открывая
широкие горизонты в системном программировании. Эти инструменты дают
средства для построения эффективных, отзывчивых и масштабируемых систем, которые взаимодействуют с устройствами по сети, значительно упрощая
процесс управления ими.
Вопросы защищенности и кибербезопасности
Обеспечение защиты и кибербезопасности имеет решающее значение при работе с устройствами, подключенными к сетям, и управлении обменом информацией между ними. Rust – отличный вариант для создания надежных сетевых
приложений, так как этим аспектам уделяется первостепенное внимание. Это
гарантирует, что при взаимодействии устройств соблюдаются те же высокие
стандарты обеспечения защищенности и кибербезопасности. Таким образом,
Rust выделяется как надежный и отказоустойчивый выбор для защиты связи между устройствами через интернет или другие сети, обеспечивая уверенность в том, что ваши данные и оборудование будут под надежной защитой.
Защитные функциональные средства Rust, в частности строгий контроль
памяти и типов, значительно снижают риск возникновения распространенных уязвимостей. Минимизируются такие проблемы, как переполнение буфера, обращение к памяти по пустому указателю и гонки за доступ к данным,
которые нередко становятся точками входа для злоумышленников, стремящихся взломать сетевые устройства или системы. Этот фундаментальный
аспект кибербезопасности в Rust делает ваши приложения менее восприимчивыми к подобным угрозам, повышая общую защищенность вашей сетевой
инфраструктуры.
Валидация входных данных: надлежащая проверка правильности или допустимости входных данных является базовой практикой обеспечения защищенности. Она предусматривает инспектирование и валидацию данных, полученных от сетевых устройств, с целью предотвращения инъекций, вредоносного кода или других форм несанкционированного вмешательства. Ниже приведен фрагмент исходного кода на Rust, демонстрирующий валидацию входных данных при обработке информации, полученной от сетевых устройств:
416
Работа с вводом-выводом с устройств на Rust
Листинг 9.33. Простейшая программа для очистки входных данных
use std::error::Error; // Импорт общей черты Error для Box<dyn Error>
fn handle_input(input: &str) -> Result<(), Box<dyn std::error::Error>> {
// Проверка на наличие опасных SQL-команд
if input.contains("DROP TABLE") {
return Err("Обнаружены недопустимые входные данные".into());
}
}
// Защищенная обработка входных данных
// ...
Ok(())
fn main() {
let input = "SELECT * FROM data";
if let Err(err) = handle_input(input) {
eprintln!("Ошибка: {}", err);
} else {
println!("Входные данные валидны. Идет обработка...");
}
}
// Вывод:
// Входные данные валидны. Идет обработка...
В приведенном выше исходном коде мы проверяем входные данные на наличие ключевых слов из опасных SQL-инъекций и реагируем соответствующим образом, демонстрируя базовую форму валидации (проверку правильности/допустимости) входных данных. Функция main показывает, как вызвать
функцию handle_input для валидации входных данных и обработки возможных ошибок.
Контроль доступа: механизмы контроля доступа необходимы для регулирования взаимодействий с вашей системой. Эти механизмы аутентифицируют и авторизуют устройства или пользователей, позволяя только доверенным
сторонам взаимодействовать с вашими сетевыми устройствами. Ниже приведен пример базового контроля доступа на Rust.
Листинг 9.34. Простейшая программа контроля доступа
fn authenticate_device(_device_id: &str, _secret_key: &str) -> bool {
// В реальном приложении здесь была бы проверка по базе данных
// или сверка хеша ключа.
// Префикс подчеркивания (_) говорит компилятору, что переменные
// пока намеренно не используются.
}
true // Доступ разрешен
fn main() {
let device_id = "мое_устройство";
let secret_key = "защищенный_ключ";
Вопросы защищенности и кибербезопасности 417
}
if authenticate_device(device_id, secret_key) {
println!("Предоставлен доступ устройству: {}", device_id);
// Здесь выполняются операции по управлению устройством
} else {
println!("Отказано в доступе устройству: {}", device_id);
}
Приведенный выше фрагмент исходного кода демонстрирует аутентификацию устройства путем проверки предоставленных учетных данных, гарантируя, что только авторизованные устройства могут получать доступ к системе. В функции main мы аутентифицируем устройство, вызывая функцию
authenticate_device с его данными, и определяем, разрешен доступ или отклонен.
Правила сетевого экрана: применение правил сетевого экрана играет
жизненно важную роль для устройств, имеющих выход в интернет. Эти правила ограничивают несанкционированный сетевой трафик, защищая сетевые
устройства. Сетевые экраны можно конфигурировать с помощью различных
инструментов и фреймворков, в зависимости от требований вашей системы.
Листинг 9.35. Простейшая программа конфигурирования сетевого экрана
fn configure_firewall_rules() {
// Здесь реализуется настройка правил сетевого экрана на уровне системы.
// Это может включать выполнение внешних команд (например, iptables или
ufw)
// или использование специфических системных API
}
// Пример для Linux (заглушка логики):
// std::process::Command::new("ufw").arg("enable").status().unwrap();
fn main() {
configure_firewall_rules();
println!("Правила сетевого экрана успешно сконфигурированы.");
}
// Вывод:
// Правила сетевого экрана успешно сконфигурированы.
В приведенном выше фрагменте исходного кода функция main вызывает
функцию configure_firewall_rules, которая обычно выполняет конфигурирование правил сетевого экрана на уровне системы, используя методы, специфичные для конкретной платформы.
В контексте программирования на Rust кибербезопасность опирается на совместные усилия: защитные функциональные средства самого языка и ответственную работу программиста. Это похоже на строительство надежного мос
та, где защитные механизмы Rust служат его каркасом, а разработчик играет
роль бдительного строителя. Следование лучшим образцам практики обеспечения защищенности и кибербезопасности, а также использование защитных
инструментов Rust гарантирует создание защищенных и надежных сетевых
систем. Регулярное техническое обслуживание, своевременное обновление
систем и постоянное соблюдение правил безопасности имеют решающее зна-
418
Работа с вводом-выводом с устройств на Rust
чение для обеспечения защиты сетевых устройств и систем, к которым они
подсоединяются. Такое тесное сочетание защитных функциональных средств
Rust и тщательного подхода к разработке создает прочный и защищенный
фундамент для цифровых взаимодействий.
Параллелизм, конкурентность и ввод-вывод с устройств
Параллелизм и конкурентность – это фундаментальные элементы системного программирования, а в том, что касается ввода-вывода с устройств, они
играют ключевую роль в обеспечении эффективности и отзывчивости систем.
Rust предоставляет надежные инструменты управления конкурентными заданиями и параллельным исполнением, позволяя эффективно использовать
системные ресурсы и достигать высокой производительности.
В различных системных сценариях – от реально-временного сбора данных
до управления устройствами и обработки данных – способность выполнять задания параллельно и конкурентно значительно повышает отзывчивость и пропускную способность системы. Рассмотрим практический пример: на «умном»
заводе большое количество сенсоров собирают данные в реальном времени,
а актуаторы управляют оборудованием на основе этих данных. В такой ситуа
ции параллелизм и конкурентность необходимы для эффективного управления огромным количеством показаний сенсоров и управляющих воздействий.
Принятая в Rust модель конкурентности, дополненная его системой владения, разработана для предотвращения гонок за доступ к данным и обеспечения защищенного конкурентного доступа к устройствам и коллективным
ресурсам. Это особенно важно в ситуациях, когда несколько заданий или потоков исполнения взаимодействуют с одними и теми же устройствами или
коллективными данными. Обеспечение четкой координации и защищенности
конкурентного доступа играет крайне важную роль для стабильности и надежности системы.
Ниже приведен пример того, как конкурентные функциональные средства Rust могут использоваться для конкурентного чтения данных с сенсоров
и управления устройствами:
Листинг 9.36. Конкурентное чтение данных
с сенсоров и управление устройствами
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
// Умный указатель Arc позволяет владеть данными нескольким потокам
// Примитив Mutex гарантирует, что только один поток может изменять
// данные в моменте
let sensor_data = Arc::new(Mutex::new(SensorData {}));
let device = Arc::new(Mutex::new(Device::new()));
// Поток исполнения для чтения данных с сенсора
let sensor_thread = thread::spawn({
let sensor_data = Arc::clone(&sensor_data);
Параллелизм, конкурентность и ввод-вывод с устройств 419
move || {
// Прочитать сенсорные данные и обновить коллективные данные.
// Запирает мьютекс на замок для защищенного доступа к данным
let mut sensor_data = sensor_data.lock().unwrap();
sensor_data.read();
println!("Данные датчика обновлены.");
}
});
// Поток исполнения для управления устройством на основе сенсорных данных
let device_thread = thread::spawn({
let sensor_data_clone = Arc::clone(&sensor_data);
let device_clone = Arc::clone(&device);
move || {
// Управлять устройством на основе сенсорных данных
// Прибрести оба ресурса
let sensor_data = sensor_data_clone.lock().unwrap();
let mut device = device_clone.lock().unwrap();
device.control(&sensor_data);
println!("Команда управления отправлена на устройство.");
}
});
// Ожидать завершения обоих потоков исполнения
sensor_thread.join().unwrap();
device_thread.join().unwrap();
}
struct SensorData {
// Определить структуру для сенсорных данных
}
impl SensorData {
fn read(&mut self) {
// Симулировать чтение сенсорных данных
}
}
struct Device {
// Определить структуру для устройства
}
impl Device {
fn new() -> Device {
// Инициализировать устройство
Device {}
}
fn control(&mut self, _sensor_data: &SensorData) {
// Логика управления: управлять устройством на основе сенсорных данных
}
}
В представленном примере мы иллюстрируем применение конкурентных
функциональных средств Rust для управления конкурентными заданиями чтения данных с сенсоров и управления устройствами. Для этого мы задействуем
конструкции умного указателя Arc и примитива синхронизации Mutex, которые
420
Работа с вводом-выводом с устройств на Rust
являются частью стандартной библиотеки Rust. Эти конструкции обеспечивают
защищенное использование коллективных данных между несколькими потоками исполнения. В данном примере один поток сосредоточен на чтении данных с сенсоров, а другой поток конкурентно управляет устройством на основе
полученных сенсорных данных. Пример демонстрирует, как встроенные в Rust
механизмы конкурентности позволяют эффективно и защищенно организовывать ввод-вывод и управление устройствами, особенно в реально-временных
сценариях, где параллелизм имеет критическое значение.
Rust превосходно помогает контролировать большое количество заданий
одновременно, поддерживая их синхронизацию. Это крайне полезно в ситуациях, когда к компьютеру подключена целая группа устройств и нужно
работать с ними быстро и защищенно. Таким образом, когда опытные специалисты используют специальные инструменты Rust для работы с этими
устройствами, они могут быть вполне уверены в том, что все будет работать исправно, защищено от отказов и не замедлит систему. Это означает,
что компьютер сможет выполнять задачи быстрее и реагировать мгновенно,
и это как раз то, что нужно!
Реально-временные системы и ввод-вывод с устройств
Реально-временные системы подобны компьютерным программам со сверхстрогим расписанием. Им необходимо взаимодействовать с устройствами
предельно точно, например управлять роботизированным манипулятором,
который должен двигаться безупречно и строго вовремя. Rust приходит на помощь, так как обладает великолепными функциональными средствами, которые идеально подходят для таких систем.
В реально-временных приложениях время имеет первостепенное значение.
Такие системы требуют выполнения определенных действий в строгих временных рамках, ставя во главу угла предсказуемость и надежность. Используемая в Rust система владения и защитные гарантии играют ключевую роль
в устранении распространенных проблем, которые могли бы внести непредсказуемость в работу реально-временных систем, таких как гонки за доступ
к данным и повреждение памяти. На базе Rust можно уверенно конструировать и реализовывать системы реально-временного управления, будучи уверенными в их поведении.
В таких системах достижение низкоуровневого контроля над оборудованием имеет решающее значение. Это похоже на общение на одном языке с системой управления роботом-манипулятором. Вам необходимо передавать
высокоточные инструкции манипулятору с безупречным соблюдением временных интервалов. Rust в этой области преуспевает, позволяя осуществлять
детальный контроль и одновременно гарантируя защищенность и безотказность. Это означает, что даже когда манипулятор движется быстро, можно
оставаться уверенным, что он внезапно не остановится и не дернется в неверном направлении.
Рассмотрим практический пример, иллюстрирующий способности Rust
в контексте реально-временного управления. Представьте разработку системы управления роботом-манипулятором. Этой системе поручено читать дан-
Реально-временные системы и ввод-вывод с устройств 421
ные с сенсоров и вносить точные коррективы в положение манипулятора через определенные промежутки времени. Rust с его способностью управлять
низкоуровневым взаимодействием с оборудованием при обеспечении защищенности идеально подходит для такой задачи.
Если представить библиотеку (как набор инструментов) под названием some_
low_level_library для робота-манипулятора в реально-временной системе,
то в воображаемом мире она могла бы быть написана на Rust. Такая библиотека была бы своего рода «швейцарским армейским ножом» для робота-манипулятора, позволяя управлять им точно и защищенно. Просто следует помнить,
что в реальной жизни понадобится библиотека, созданная специально под конкретное оборудование, так как каждое устройство является уникальным.
Листинг 9.37. Реально-временная система
и программа ввода-вывода с устройств
// some_low_level_library.rs
pub mod sensors {
// Инициализировать сенсоры и другие связанные с ними функции
pub fn init() {
// Реализация инициализации сенсоров
println!("Сенсоры инициализированы.");
}
// Прочитать сенсорные данные
pub fn read() -> i32 {
// Симуляция чтения сенсорных данных (возвращаем эталонное значение)
42
}
}
pub mod robotic_arm {
// Инициализировать робот-манипулятор и другие связанные с ним функции
pub fn init() {
// Реализация инициализации роботизированного манипулятора
println!("Роботизированный манипулятор инициализирован.");
}
// Управление манипулятором на основе данных датчика
pub fn control(sensor_data: i32) {
// Симуляция управления манипулятором на основе сенсорных данных
println!("Робот-манипулятор управляется сенсорными данными: {}",
sensor_data);
}
}
pub mod timing {
// Реализовать функции задержки для соблюдения точных временных требований
pub fn delay_microseconds(microseconds: u32) {
// Симуляция задержки (например, с использованием аппаратных таймеров)
std::thread::sleep(std::time::Duration::from_micros(microseconds as u64));
}
}
422
Работа с вводом-выводом с устройств на Rust
Вымышленная библиотека some_low_level_library состоит из трех модулей:
sensors (датчики), robotic_arm (роботизированный манипулятор) и timing
(управление временными интервалами). Она содержит функции инициализации для сенсоров и роботизированного манипулятора, а также функции для
чтения сенсорных данных, управления роботом-манипулятором и реализации задержки с целью соблюдения точных временных требований.
Теперь давайте создадим простой файл main.rs, чтобы протестировать этот
исходный код:
Листинг 9.38. Реально-временная система
и программа ввода-вывода с устройств
// main.rs
// Подключить библиотеку под коротким псевдонимом ll
extern crate some_low_level_library as ll;
fn main() {
// Инициализировать и сконфигурировать робота-манипулятора и сенсоры
ll::robotic_arm::init();
ll::sensors::init();
// Бесконечный цикл управления
loop {
// Читать сенсорные данные
let sensor_data = ll::sensors::read();
// Выполнять реально-временное управление на основе сенсорных данных
ll::robotic_arm::control(sensor_data);
}
//
//
//
//
.
.
.
}
// Соблюсти требования к точности временных интервалов
// (задержка 1000 мкс = 1 мс)
ll::timing::delay_microseconds(1000);
Вывод:
Робот-манипулятор управляется сенсорными данными: 42
Робот-манипулятор управляется сенсорными данными: 42
Робот-манипулятор управляется сенсорными данными: 42
В приведенном выше файле main.rs мы используем функции, предоставленные вымышленной библиотекой some_low_level_library, для инициализации
роботизированного манипулятора и сенсоров, чтения данных, управления манипулятором и реализации задержки с целью соблюдения точных временных
требований.
В представленном примере мы исходили из использования гипотетической
низкоуровневой библиотеки, служащей для управления в реальном времени.
Исходный код инициализирует и конфигурирует роботизированный манипулятор и сенсоры, считывает показания и управляет устройством на основе
этих данных, соблюдая при этом строгие временные рамки. Примечательно,
Заключение 423
что атрибут #![no_std] используется для указания на то, что стандартная биб
лиотека не задействована – в реально-временных системах с жесткими ограничениями ресурсов это распространенная практика.
Универсальность Rust в области реально-временных систем охватывает
различные области, включая промышленную автоматизацию, робототехнику,
автомобильные системы управления и встраиваемые устройства. Благодаря
уникальному сочетанию низкоуровневого доступа к оборудованию и надежных защитных механизмов Rust вооружает разработчика инструментами, необходимыми для конструирования реально-временных систем, способных
взаимодействовать с устройствами защищенным, предсказуемым и надежным образом. Такие системы играют ключевую роль там, где критически важны высокая точность и соблюдение строгих временных регламентов, гарантируя, что действия выполняются именно так, как предписано, и строго в заданные временные рамки. Обширный набор функциональных средств и передовые способности Rust делают его мощным инструментом для оркестровки
реально-временных операций ввода-вывода с устройств.
Заключение
В этой всесторонней главе мы провели обследование широкого спектра тем: от обработки файлов и операций с файловой системой до ввода-вывода с устройств
и сетевого программирования. Это глубокое погружение в системное программирование на Rust оснастило вас ценными знаниями и практическими умениями по построению надежного и эффективного программного обеспечения.
Мы начали с углубленного изучения продвинутых файловых операций,
включая работу с метаданными, символическими и жесткими ссылками, запирание файлов на замок и отображаемые файлы. Эти концепции лежат в основании управления данными и ресурсами в различных системах.
Затем была рассмотрена тема ввода-вывода с устройств, где мы обсудили
драйверы устройств и модули ядра на Rust. Несмотря на сложность, написание
драйверов на Rust дает преимущества в виде защищенности памяти и строгой
типизации, повышая защищенность и надежность системного исходного кода.
Перейдя к сетевому программированию, мы обследовали интеграцию сетевой связи с вводом-выводом с устройств. Мы увидели, как функциональные
средства и библиотеки Rust упрощают дистанционное управление устройствами, реально-временной сбор данных и распределенное управление по сетям.
Особое внимание было уделено вопросам защищенности и кибербезопасности сетевых устройств: подчеркнута важность валидации входных данных,
шифрования, контроля доступа и других передовых практических методов защиты подключенного к сети оборудования и систем.
Также в этой главе была затронута тема значимости параллелизма и конкурентности в системном программировании, в особенности при работе с вводомвыводом. Используемая в Rust модель конкурентности и поддержка параллельного исполнения позволяют эффективно управлять конкурентными заданиями
устройств, повышая отзывчивость и пропускную способность системы.
Кроме того, мы обсудили реально-временные системы и то, как низкоуровневые способности и защитные функциональные средства Rust делают его идеаль-
424
Работа с вводом-выводом с устройств на Rust
ным выбором для разработки программного обеспечения, отвечающего строгим
временным требованиям при управлении устройствами и сборе данных.
По мере вашего продвижения в качестве системного программиста на Rust
вы будете сталкиваться с самыми разными сценариями и вызовами: от управления файлами и устройствами до оптимизации сетевых коммуникаций
и контроля в реальном времени. Rust вооружает разработчика инструментами
и защитными гарантиями, необходимыми для уверенного решения сложных
задач. Обсуждаемые здесь практические примеры и лучшие образцы практики станут ценным активом в ваших реальных проектах в области системного
и низкоуровневого программирования.
В следующей главе мы приступим к обследованию итераторов и замыканий
в Rust, которые необходимы для эффективной обработки данных. Данная глава даст всестороннее понимание указанных конструкций и их практического
применения, включая работу с итераторами, написание замыканий и использование этих концепций для решения задач из реальной практики.
Дополнительные ресурсы
В целях углубления своих знаний о работе с вводом-выводом с устройств
на Rust рекомендуется обратиться к следующим ниже ресурсам.
Документация пакета tokio по модулю ввода-вывода: обратитесь к официальной документации пакета tokio по модулю ввода-вывода. Он предоставляет мощную асинхронную среду исполнения для Rust, а его модуль ввода-вывода предлагает абстракции и утилиты для эффективных
операций ввода-вывода с устройств: docs.rs/tokio/latest/tokio/io/index.html.
Документация пакета async-io: ознакомьтесь с документацией пакета
async-io, который предоставляет асинхронные примитивы ввода-вывода. Этот пакет предлагает набор инструментов для асинхронной работы
с устройствами, обеспечивая эффективные и неблокирующие операции:
docs.rs/async-io.
Модуль ввода-вывода стандартной библиотеки Rust: проинспектируйте
модуль std::io официальной документации. Понимание фундаментальных концепций ввода-вывода в рамках стандартной библиотеки имеет
критическую важность для эффективного управления устройствами:
doc.rust-lang.org/std/io/index.html.
Документация mio: ознакомьтесь с документацией облегченной библиотеки асинхронного ввода-вывода mio. Данная библиотека предоставляет
строительные блоки для построения масштабируемых событийно-ориентированных систем, что отлично подходит для сценариев взаимодействия с устройствами: docs.rs/mio.
Репозиторий Cortex-M: ознакомьтесь с GitHub-репозиторием проекта
Cortex-M, который обеспечивает поддержку микроконтроллеров ARM
Cortex-M на Rust. Он содержит реализации слоя абстракции оборудования (HAL) и примеры работы с устройствами на платформе Cortex-M:
https://github.com/rustembedded/cortex-m.
Документация embedded-hal: ознакомьтесь с документацией пакета embedded-hal. Это слой абстракции оборудования на базе пакета для
Вопросы для самопроверки 425
встраиваемых систем, который определяет общий интерфейс для работы
с устройствами на разных аппаратных платформах: docs.rs/embedded-hal.
Репозиторий ATSAMD для Rust: ознакомьтесь с GitHub-репозиторием
проекта atsamd, который демонстрирует поддержку USB-устройств
на микроконтроллерах ATSAMD. Изучение этого репозитория поможет понять принципы реализации ввода-вывода через USB на Rust:
github.com/atsamd-rs/atsamd.
Документация tokio-serial: если ваши задачи включают работу с последовательным портом, то рекомендуется обследовать документацию пакета tokio-serial. Этот пакет обеспечивает асинхронную поддержку последовательных портов внутри среды исполнения tokio: docs.rs/tokio-serial.
Перечисленные выше ресурсы предлагают комплексный взгляд на вводвывод с устройств на Rust, предоставляя практические руководства и документацию, которые помогут вам освоить тонкости взаимодействия с оборудованием и эффективного управления операциями ввода-вывода.
Вопросы для самопроверки
Вопрос 1. Какой модуль Rust отвечает за файловые операции, такие как
открытие, чтение и запись?
a) std::fs::file
b) std::io::file
c) std::file::io
d) std::fs
Вопрос 2. Как компилятор Rust обрабатывает ошибки в файловых операциях, например, при открытии файла?
a) компилятор Rust принудительно завершает программу при возникновении ошибки
b) ошибки игнорируются, и программа продолжает исполнение
c) компилятор Rust использует тип Result для элегантного управления
ошибками
d) ошибки вызывают бесконечный цикл в программе
Вопрос 3. Что упрощает пакет BufRead в файловом вводе-выводе на Rust?
a) чтение двоичных данных из файла
b) эффективное построчное чтение файлов
c) создание нового файла в буферизованном режиме
d) запись данных в файл с буферизацией
Вопрос 4. Каково предназначение функции std::fs::rename в Rust?
a) создание нового файла
b) переименование и перемещение файлов
c) чтение метаданных файла
d) проверка прав доступа к файлу
426
Работа с вводом-выводом с устройств на Rust
Вопрос 5. Какое функциональное средство Rust играет ключевую роль
в снижении рисков безопасности при прямом взаимодействии с оборудованием?
a) перекрестная компиляция
b) владение
c) защищенность памяти и потокозащищенность
d) запирание файлов на замок
Вопрос 6. Какова роль драйверов устройств во взаимодействии между
программным и аппаратным слоями?
a) управление аппаратной периферией
b) обеспечение перекрестной компиляции
c) содействие связи и координации
d) создание символических ссылок
Вопрос 7. Какой пакет Rust используется для управления контактами
GPIO в представленном примере исходного кода?
a) i2cdev
b) rppal
c) fs2
d) std
Вопрос 8. Каково предназначение создания жестких ссылок при программировании оборудования?
a) обеспечение связи с сенсорами
b) предоставление коллективного поведения для типов
c) коллективное использование опорных данных на устройстве хранения
d) обеспечение защищенности памяти
Вопрос 9. Какое преимущество дают файлы, отображаемые в память,
при обработке крупных файлов или оптимизации производительности?
a) последовательные операции ввода-вывода
b) произвольный доступ к содержимому файла
c) ограниченное манипулирование данными
d) линейное хранение данных
Вопрос 10. Какой пакет Rust предоставляет ценный инструмент для эффективного использования способностей отображаемых файлов?
a) io
b) std
c) memmap
d) file
Вопросы для самопроверки 427
Вопрос 11. В чем заключается ключевое преимущество использования
отображаемых файлов для доступа к данным на Rust?
a) последовательный доступ к данным
b) линейная обработка файлов
c) устранение традиционных узких мест ввода-вывода
d) снижение потребления памяти
Вопрос 12. Что демонстрирует фрагмент исходного кода с использованием File::create в контексте лучших образов практики ввода-вывода?
a) чтение из файла
b) обработку ошибок во время создания файла
c) изменение прав доступа к файлу
d) создание резервной копии файла
Вопрос 13. Что означает термин «кросс-платформенная совместимость»
в контексте лучших образцов практики ввода-вывода?
a) обеспечение совместимости файлов с разными языками программирования
b) избегание коллективного использования файлов между платформами
c) учет различий в файловых системах на разных платформах
d) обеспечение совместимости с различными форматами файлов
Вопрос 14. Каково предназначение конструкций умного указателя Arc и
примитива синхронизации Mutex в принятой в Rust модели конкурентности?
a) обеспечение защищенности данных посредством механизма владения
b) обеспечение статической типизации
c) упрощение защищенного коллективного использования данных между
несколькими потоками исполнения
d) управление доступом к вводу-выводу с устройства
Вопрос 15. В чем огромная важность соблюдения временных интервалов
в контексте реально-временных систем в Rust?
a) в том, чтобы вносить непредсказуемость
b) в том, чтобы добиваться низкоуровневого контроля над оборудованием
c) в том, чтобы обеспечивать строгое владение данными
d) в том, чтобы предотвращать использование асинхронных операций
Ответы
1. d) std::fs
2. c) компилятор Rust использует тип Result для элегантного управления
ошибками
3. b) эффективное построчное чтение файлов
4. b) переименование и перемещение файлов
428
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
Работа с вводом-выводом с устройств на Rust
c) защищенность памяти и потокозащищенность
c) содействие связи и координации
b) rppal
c) коллективное использование опорных данных на устройстве хранения
b) произвольный доступ к содержимому файла
c) memmap
c) устранение традиционных узких мест ввода-вывода
b) обработку ошибок во время создания файла
c) учет различий в файловых системах на разных платформах
c) упрощение защищенного коллективного использования данных между несколькими потоками исполнения
b) в том, чтобы добиваться низкоуровневого контроля над оборудованием
Ключевые термины
BufRead: пакет Rust, предоставляющий возможности буферизованного
чтения, что позволяет эффективно читать данные, в особенности построчно.
Метаданные: информация о файле, включающая такие атрибуты, как
размер, время изменения и права доступа.
Автоматическое закрытие файлов: функциональное средство Rust,
при котором файлы закрываются автоматически в момент, когда дескриптор файла выходит из диапазона доступности, что повышает защищенность и надежность файловых операций.
std::fs::rename: функция Rust, используемая для переименования и перемещения файлов, обеспечивающая атомарность и надежность операций в файловой системе.
Драйверы устройств (device drivers): программные компоненты, которые служат посредниками между операционной системой и аппаратными устройствами, облегчая связь и координацию.
Контакты GPIO (general-purpose input/output – общецелевого интерфейса ввода-вывода): пины на устройстве, которые могут быть сконфигурированы как вход или выход; обычно используются для таких задач,
как включение/выключение светодиодов или чтение состояния кнопок.
Перекрестная компиляция (cross-compilation): процесс компиляции
исходного кода на одной платформе для сборки исполняемого кода, который может работать на другой платформе или архитектуре.
Символические ссылки (symbolic links): также именуемые симлинками; это ссылки на другие файлы; символические ссылки действуют как
указатели или ярлыки, направляющие пользователей к фактическому
местоположению файла.
Жесткие ссылки (hard links): ссылки на файлы, которые указывают
на те же опорные данные на устройстве хранения, что и исходный файл,
создавая несколько файловых записей с возможностью коллективного
пользования содержимым.
Ключевые термины 429
Запирание файлов на замок (file locking): механизм, предотвращающий одновременный доступ и изменение файла несколькими процессами или потоками исполнения, что гарантирует непротиворечивость
и целостность данных.
Защищенность памяти (memory safety): функциональная особенность
Rust, предотвращающая распространенные ошибки программирования,
связанные с доступом к памяти, что снижает риск возникновения уязвимостей.
Потокозащищенность (thread safety): функциональная особенность
Rust, обеспечивающая защищенный конкурентный доступ к данным
из нескольких потоков исполнения, предотвращая гонки за доступ
к данным и сопутствующие проблемы.
I2C (inter-integrated circuit – межмикросхемная связь): протокол связи,
используемый для подключения различных устройств, таких как сенсоры, и взаимодействия с ними в аппаратном программировании.
Отображаемые файлы (memory-mapped files): файлы, в которых используется механизм прямого отображения в память, что обеспечивает
произвольный доступ к содержимому и устраняет традиционные узкие
места ввода-вывода.
Управление ресурсами: использование системы владения и диапазонов
доступности Rust для очистки ресурсов, что гарантирует автоматическое
закрытие дескрипторов файлов при выходе из диапазона доступности.
Права доступа и кибербезопасность: учет прав доступа к файлам и вопросов кибербезопасности; Rust предоставляет механизмы для установки и проверки прав, как показано в примере исходного кода с изменением прав доступа.
Резервное копирование и контроль версий: реализация механизмов создания резервных копий и версионирования важных файлов, например простая функция резервного копирования с использованием
std::fs::copy.
Оптимизация производительности: применение таких методов, как
отображение файлов в память или буферизованный ввод-вывод при работе с крупными файлами или критичными к производительности задачами,
как рекомендовано в лучших образцах практик файлового ввода-вывода.
Глава 10
Итераторы и замыкания
Введение
В Rust существуют два ключевых понятия, открывающих путь к эффективной
обработке данных: итераторы и замыкания. Эти фундаментальные строительные блоки лежат в основе конструктивной философии Rust, в которой приоритетами являются защищенность, производительность и выразительность исходного кода. Начиная наше погружение в эту тему, мы подробно рассмотрим
сложности итераторов и замыканий, разберем их нюансы и откроем огромное
количество способов их применения для решения задач из реальной практики.
Замыкания – одно из самых примечательных функциональных средств Rust.
Это механизм, позволяющий создавать анонимные функции, обладающие возможностью обращаться к переменным из своего окружения. Указанные динамичные конструкции лежат в основе широкого спектра задач: от сортировки данных
до управления конкурентностью. В этой главе вы проведете обследование замыканий, изучите их типы, научитесь интегрировать их с функциями стандартной
библиотеки и освоите приемы захвата переменных. Также будут рассмотрены
ключевые общие черты (типажи), которые определяют работу замыканий в Rust.
Особый интерес в этом изложении представляет тесная связь между итераторами и замыканиями. В Rust эти концепции часто работают в связке: замыкания органично встраиваются в итераторы для обработки данных, которая
становится не только выразительной, но и исключительно эффективной. Давайте приступим к обследованию, начав с детального рассмотрения каждого
понятия, и первым делом рассмотрим мощь итераторов.
Содержание главы
В этой главе будут рассмотрены следующие темы:
работа с итераторами с целью эффективной обработки данных на Rust;
написание замыканий и захват переменных на Rust;
применение итераторов и замыканий для решения практических задач.
Итераторы
Итераторы играют ключевую роль в стандартной библиотеке Rust, являясь фундаментальным инструментом для работы с коллекциями в удобной и выразительной манере. Они позволяют максимально эффективно манипулировать дан-
Итераторы 431
ными, избавляя от необходимости вникать в тонкости внутреннего устройства
коллекций. Как следствие исходный код становится не только производительнее,
но и гораздо более удобочитаемым. Перед тем как приступить к обследованию
этой увлекательной темы, давайте присмотримся к итераторам поближе.
Анатомия итератора
По своей сути итератор – это механизм, генерирующий последовательность
значений, которые можно удобно использовать в циклах или других операциях.
В стандартной библиотеке Rust представлено несколько итераторов, адаптированных под разные типы данных: векторы, строковые литералы, хеш-таблицы
и многое другое. Что важно, есть возможность создавать конкретно-прикладные
итераторы под специфические нужды, получая полный контроль над данными.
Итератор обладает набором методов, которые позволяют выполнять обход
элементов коллекции один за другим. Вот его базовый инструментарий:
next(): этот метод подобен перелистыванию страниц в книге. Он позволяет извлекать следующий элемент последовательности наподобие
развертывания подарка. Если элемент готов, метод передает его вам
с радостным возгласом «Вот, держи!» в форме Some(item). Но если вы достигли конца, то он вежливо сообщает: «Извините, здесь больше не на
что смотреть», возвращая None.
for_each(): представьте метод for_each() как удобный инструмент для работы со списком дел. Этот метод проходит по каждому элементу и выполняет для него определенное действие. Он как дирижер оркестра, указывающий
каждому музыканту, когда играть свою партию. Вы передаете анонимную
функцию (замыкание) или исходный код, а for_each() берет на себя остальное, гарантируя, что каждый элемент получит свою «минуту славы».
map(): вообразите, что метод map() – это персональный стилист вашего
итератора. Он берет каждый элемент и применяет к нему преобразование,
совершая своего рода «преображение». В итоге получается совершенно новый итератор, в котором все значения изменены переданной анонимной
функцией (замыканием). Это как если бы талантливый художник вдохнул
жизнь в тусклую картину. Метод map() позволяет легко менять форму элементов, создавая новый набор данных, но сохраняя суть оригинала.
filter(): метод filter() – это ваше сито для поиска конкретных элементов. Он создает новый итератор и, словно через увеличительное стекло,
фокусируется только на тех объектах, которые соответствуют заданному
критерию (предикату). Это как если перебирать морские камушки, выбирая только самые блестящие и яркие. Метод filter() помогает убирать
все лишнее, оставляя лишь то, что действительно представляется важным для текущей задачи.
collect(): представьте collect() как метод, который собирает разрозненные ингредиенты и аккуратно превращает их в готовое блюдо.
Он аккумулирует элементы итератора, подобно шеф-повару, собирающему продукты для рецепта. Но вместо обеда он упаковывает их в структуру данных, такую как вектор типа Vec или словарь типа HashMap. Метод
collect() сводит итератор к удобной и структурированной форме, готовой для дальнейшего использования или анализа.
432 Итераторы и замыкания
chain(): метод chain() похож на связывание двух историй в одну эпическую сагу. Он позволяет конкатенировать два итератора, выстраивая
их в цепочку для последовательной обработки. Это как если сплетать две
нити в один сложный узор. Метод chain() объединяет содержимое двух
итераторов, позволяя бесшовно переходить от одного к другому.
Перечисленные методы составляют костяк функциональности итераторов, предлагая мощный набор инструментов для эффективной манипуляции,
фильтрации и агрегации данных. В них заключена сама суть лаконичного
и выразительного программирования: превращение сложных операций в элегантные и легко читаемые решения.
В целях более глубокого понимания представьте задачу: вычислить сумму
первых n простых чисел. Задача, которая на первый взгляд выглядит сложной,
становится поразительно изящной, если использовать итераторы Rust. Эти
методы работают как дирижер оркестра, следя за тем, чтобы каждый инструмент безупречно исполнил свою партию. Они наглядно показывают, как система итераторов Rust превращает трудные задачи в стройные и грациозные
решения, делая работу с исходным кодом настоящим удовольствием.
Листинг 10.1. Простейшая программа, демонстрирующая
использование итераторов
fn is_prime(n: u32) -> bool {
if n <= 1 {
return false;
}
// Проверить делители от 2 до корня из n (или n/2 для простоты)
for i in 2..=(n / 2) {
if n % i == 0 {
return false;
}
}
true
}
fn sum_of_primes(n: u32) -> u32 {
// Создать бесконечный диапазон, отфильтровать простые числа,
// взять первые n чисел и просуммировать их
(2..)
.filter(|&x| is_prime(x))
.take(n as usize)
.sum()
}
fn main() {
let n = 10;
let result = sum_of_primes(n);
println!("Сумма первых {} простых чисел равна: {}", n, result);
}
Давайте разберемся в деталях приведенного выше исходного кода и обследуем функцию is_prime, которая играет решающую роль в нашей задаче по поиску и суммированию простых чисел.
Ключевые общие черты: Iterator и IntoIterator 433
Прежде чем переходить к самой сути программы поиска простых чисел,
мы создали вспомогательную функцию is_prime. Она служит своего рода детективом в мире чисел: ее задача – выяснять, является ли конкретное число
простым. Для тех, кто подзабыл: простые числа – это особые числовые сущности, которые делятся только на самих себя и на единицу (например, 2, 3, 5
и так далее). Представьте их как «одиночек» в мире цифр, стоящих особняком
от толпы чисел, имеющих множество делителей.
Теперь перейдем к «сердцу» нашей операции – функции sum_of_primes.
Представьте эту функцию как искусного шеф-повара на кухне, где ингредиентами выступают числа. Волшебство здесь заключается в установке «конвейерной ленты» чисел, начинающейся с двойки. Каждое число проходит
через функцию is_prime, которая, словно повар, проверяет его качество.
Если число оказывается простым, оно становится ценным ингредиентом
для рецепта.
Функция sum_of_primes очень сообразительна: она не только находит прос
тые числа, но и сразу ведет их подсчет. Представьте эту сумму как кастрюлю
на плите, а каждое простое число – как уникальную специю, которая улучшает
вкус блюда. Блеск этого исходного кода в том, как в нем используются итераторы Rust, избавляя от мороки с написанием длинных и запутанных циклов.
Вместо этого мы плавно скользим по числовому ряду, попутно накапливая
нужные значения.
Этот пример похож на захватывающее путешествие, где итераторы Rust
выступают в роли надежных гидов. С ними сложные математические задачи
кажутся такими же простыми, как приготовление любимого завтрака. Он наглядно показывает, что итераторы Rust не только повышают эффективность,
но и превращают процесс написания исходного кода в настоящее удовольствие.
Ключевые общие черты: Iterator и IntoIterator
Для того чтобы понять принцип работы итераторов, сначала необходимо обследовать две фундаментальные общие черты: Iterator и IntoIterator.
Общая черта Iterator задает структуру итератора. Он конкретизирует ассоциированный тип Item, который представляет тип значений, производимых
итератором. Самым важным методом в этой общей черте является next, который извлекает следующее значение в виде Option<Self::Item>. Метод next
возвращает Some(value), когда значения доступны, и None, когда последовательность заканчивается.
С другой стороны, общая черта IntoIterator позволяет трактовать типы как
итерируемые. Она определяется методом into_iter, который возвращает итератор для заданного типа. Реализация IntoIterator для ваших конкретно-прикладных типов позволяет использовать их в цикле for, что делает исходный
код идиоматичнее.
Давайте рассмотрим пример, чтобы проиллюстрировать эти общие черты.
Предположим, у вас есть конкретно-прикладная структура данных под названием MyCollection, и вы хотите сделать ее итерируемой. Ниже показано, как
реализовать эти общие черты:
434
Итераторы и замыкания
Листинг 10.2. Простейшая программа, демонстрирующая
работу конкретно-прикладных итераторов
struct MyCollection {
data: Vec<i32>, // ①
}
impl MyCollection { // ②
fn new() -> Self {
MyCollection { data: Vec::new() }
}
// Добавление целого числа в коллекцию
fn add(&mut self, value: i32) {
self.data.push(value);
}
}
impl IntoIterator for MyCollection { // ③
type Item = i32;
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.data.into_iter()
}
}
fn main() {
let mut collection = MyCollection::new();
collection.add(42);
collection.add(100);
collection.add(7);
}
for item in collection {
// ④
println!("Элемент: {}", item);
}
В приведенном выше исходном коде:
① мы определяем структуру MyCollection, которая содержит поле data типа
Vec<i32>. Это поле подобно контейнеру, который хранит последовательность 32-битных целых чисел (типа i32);
② в этой секции кода мы реализуем методы структуры MyCollection. Метод
new() отвечает за создание нового экземпляра структуры MyCollection,
инициализируя пустой вектор типа Vec для хранения чисел. Метод add()
позволяет вставлять целые числа в вектор, эффективно добавляя их в конец коллекции;
③ здесь мы приступаем к реализации общей черты IntoIterator для структуры MyCollection. Мы указываем ассоциированные типы: Item представляет тип элементов в коллекции (в данном случае i32), а IntoIter указывает на то, как создавать итератор для коллекции. Метод into_iter()
определяет, как использовать коллекцию в качестве итератора, позволяя
выполнять итерации по ее содержимому в цикле;
Создание итераторов с помощью функций from_fn и successors 435
④ в функции main мы создаем экземпляр структуры MyCollection с именем
collection, добавляем в него несколько целых чисел, а затем используем
цикл for для выполнения итераций по коллекции. На каждой итерации
мы выводим элемент на экран. Это похоже на то, как если бы мы открывали коллекцию и раскрывали ее содержимое один элемент за другим,
выводя на экран каждое число по мере его появления. Этот наглядный
пример исходного кода демонстрирует, насколько выразительный синтаксис Rust и мощные итераторы упрощают манипулирование конкретно-прикладными структурами данных. Однако истинное чудо заключается в применении итераторов, которые оптимизируют процесс обхода
структуры данных MyCollection, делая исходный код элегантнее и понятнее. Использование системы типов Rust и общей черты IntoIterator подчеркивает мощь языка в создании четких и функциональных решений,
способствуя бесшовной интеграции с универсальной экосистемой итераторов Rust. По сути, этот исходный код демонстрирует исключительную способность Rust обеспечивать эффективность, ясность и красоту
в сфере разработки программного обеспечения.
Создание итераторов с помощью
функций from_fn и successors
Одним из фундаментальных подходов в программировании является создание последовательностей значений, и замыкания предлагают универсальный
способ достижения этой цели. Замыкания представляют собой самодостаточные блоки исходного кода, которые можно исполнять по мере необходимости, вкладывая конкретную логику для генерации значений по требованию.
Такие замыкания (или анонимные функции) служат удобным инструментом
для самых разных задач: от обработки данных и симуляций до разработки алгоритмов и статистического анализа. Они позволяют абстрагировать сложные
операции в лаконичные и реиспользуемые блоки кода, повышая его ясность
и удобство сопровождения. В Rust замыкания играют ключевую роль в обеспечении гибкости при генерации значений.
В дополнение к замыканиям стандартная библиотека Rust предоставляет
функцию std::iter::from_fn, которая расширяет способности по генерации
последовательностей. Эта функция позволяет создавать конкретно-прикладные итераторы путем многократного вызова замыкания, чтобы генерировать
элементы. Это похоже на динамический конвейер, в котором замыкание работает как работник, постоянно добавляющий новые элементы в поток. В качестве наглядного примера рассмотрим задачу генерации длин 1000 случайных отрезков, концы которых равномерно распределены на интервале [0, 1].
С помощью функции std::iter::from_fn это эффективно реализуется, так как
замыкание выступает в роли генератора, бесшовно создавая длину каждого
сегмента по мере необходимости. Такой подход упрощает процесс генерации
большого количества случайных значений, поддерживая при этом точный
контроль и оптимальное использование ресурсов, что делает его ценным методом для широкого спектра приложений – от научных симуляций до статис
тического анализа и не только.
436
Итераторы и замыкания
Листинг 10.3. Простейшая программа, демонстрирующая
использование итератора, созданного функцией from_fn
use rand::random;
use std::iter::from_fn;
let lengths: Vec<f64> =
from_fn(|| Some((random::<f64>() - random::<f64>()).abs())) // ①
.take(1000) // ②
.collect(); // ③
В приведенном выше примере:
① мы также используем функцию from_fn из стандартной библиотеки, которая позволяет создавать итератор, производящий значения на основе
функции. В данном случае в качестве функции мы передали замыкание
|| Some((random::<f64>() - random::<f64>()).abs()). Это замыкание генерирует случайные числа с плавающей точкой, вычисляет абсолютную разность между ними и возвращает результат как вариант Some. Поскольку замыкание всегда возвращает Some, итератор, созданный функцией from_fn,
продолжается бесконечно, создавая потенциально бесконечную последовательность случайных чисел;
② метод take используется для ограничения бесконечной последовательности, созданной с помощью функции from_fn. Он гарантирует, что
мы возьмем только первые 1000 элементов из этой последовательности;
③ наконец, используется метод collect, чтобы собрать 1000 случайных
чисел в вектор типа f64. Этот вектор с именем lengths содержит первую
тысячу случайных чисел с плавающей точкой из ограниченной последовательности.
Следующий ниже фрагмент исходного кода демонстрирует, как генерировать последовательность случайных чисел с плавающей точкой, ограничивать
ее определенной длиной и собирать результаты в вектор для дальнейшего
использования. Это похоже на наполнение корзины ровно 1000 случайно выбранными значениями из бесконечного потока чисел, где метод collect аккуратно организовывает их для легкого доступа и манипулирования.
Ниже приводится пример переписывания функции escape_time для построе
ния множества Мандельброта с использованием функции successors:
Листинг 10.4. Простейшая программа, демонстрирующая
использование итератора successors
use num::Complex;
use std::iter::successors;
fn escape_time(c: Complex<f64>, limit: usize) -> Option<usize> {
let zero = Complex { re: 0.0, im: 0.0 };
let result = successors(Some(zero), |&z| Some(z * z + c)) // ①
.take(limit) // ②
.enumerate() // ③
.find(|(_i, z)| z.norm_sqr() > 4.0);
// ④
Создание итераторов с помощью функций from_fn и successors 437
match result {
Some((i, _z)) => Some(i),
None => None,
}
}
fn main() {
// ⑤
let c = Complex { re: -0.7, im: 0.27015 };
let limit = 1000;
}
match escape_time(c, limit) {
Some(steps) => println!("Выход произошел через {} шагов.", steps),
None => println!("Выход не произошел в течение {} шагов.", limit),
}
// Вывод:
// Выход произошел через 96 шагов
В приведенном выше исходном коде:
① мы используем функцию successors. Она генерирует последовательность
точек на комплексной плоскости, многократно применяя предоставленное замыкание к предыдущей точке (начиная с начала координат) и добавляя параметр c. Этот процесс продолжается до тех пор, пока не будет
достигнут лимит или пока точка не «покинет» окрестность начала координат. Замыкание |&z| Some(z * z + c) на каждой итерации возводит
комплексное число в квадрат и прибавляет к нему c;
② метод take(limit) ограничивает последовательность заданным коли
чеством итераций, гарантируя, что мы не будем продолжать до бесконечности;
③ метод enumerate() соотносит каждую точку последовательности с ее индексом;
④ метод find ищет первую точку в последовательности, которая «выходит» за пределы начала координат, что определяется квадратом модуля, превышающим 4.0. Если такая точка найдена, то метод возвращает
ее индекс и соответствующее комплексное число. Если в пределах заданного лимита выходящая точка не найдена, то метод find возвращает None;
⑤ в функции main вычисляется время выхода для конкретного комплексного числа c и лимита. В зависимости от того, обнаружен выход или нет,
мы выводим количество шагов, потребовавшихся для этого, или указываем, что выход не произошел в рамках лимита. В данном примере результат гласит: "Выход произошел через 96 шагов.".
Функции from_fn и successors принимают замыкания типа FnMut, что позволяет замыканиям захватывать и изменять переменные из внешних диапазонов доступности.
438
Итераторы и замыкания
Листинг 10.5. Простейшая реализация последовательности
Фибоначчи с использованием итераторов
fn fibonacci() -> impl Iterator<Item
let mut state = (0, 1);
//
std::iter::from_fn(move || { //
state = (state.1, state.0 +
Some(state.0)
//
})
}
= usize> {
①
②
state.1); // ③
④
fn main() {
let result: Vec<usize> = fibonacci().take(8).collect(); // ⑤
let expected = vec![1, 1, 2, 3, 5, 8, 13, 21];
// ⑥
assert_eq!(result, expected); // ⑦
println!("Последовательность Фибоначчи (первые 8 чисел): {:?}",
result);
// ⑧
}
// Вывод:
// Последовательность Фибоначчи (первые 8 чисел): [1, 1, 2, 3, 5, 8, 13, 21]
В приведенном выше примере:
① мы инициализируем кортеж state, чтобы отслеживать текущее и следующее числа Фибоначчи;
② создаем итератор, используя функцию std::iter::from_fn() с замыканием;
③ внутри замыкания вычисляем следующее число Фибоначчи и обновляем
кортеж state;
④ порождаем текущее число Фибоначчи;
⑤ в функции main() генерируем первые 8 чисел Фибоначчи и собираем
их в вектор;
⑥ определяем ожидаемую последовательность Фибоначчи для сравнения;
⑦ используем макрофункцию assert_eq!, чтобы проверить совпадение сгенерированной последовательности с ожидаемой;
⑧ наконец, выводим на экран сгенерированную последовательность
из первых 8 чисел Фибоначчи.
Хотя функции from_fn и successors обеспечивают гибкость и могут заменять
многие другие шаблоны итерирования, важно помнить о ясности и стандартных правилах именования, предоставляемых другими методами итерирования. Прежде чем чрезмерно опираться на функции from_fn и successors, следует ознакомиться с остальными методами итерирования.
Метод drain
Многие типы коллекций, такие как векторы, предоставляют ценный метод исчерпания, который называется drain. Этот метод при вызове запрашивает мутируемую ссылку на коллекцию и взамен предлагает итератор. Отличительной особенностью этого итератора является то, что он передает каждый элемент получателю
во владение. В отличие от метода into_iter, который полностью поглощает саму
коллекцию, метод drain лишь заимствует мутируемую ссылку на нее.
Другие источники итераций 439
Существенное отличие здесь заключается в том, что коллекция не исчезает;
она остается доступной, но временно «замирает», пока итератор drain выполняет свою работу. Однако важно отметить, что когда этот итератор завершает
свою операцию и удаляется, он перед уходом выполняет последнее задание –
«убирает за собой». Любые оставшиеся элементы внутри коллекции, которые
он изначально заимствовал, фактически удаляются, в результате чего коллекция остается пустой, как будто она была прибрана и готова для нового набора
элементов или новой цели. По сути, метод drain не только облегчает передачу
элементов, но и берет на себя ответственность за очистку, гарантируя, что коллекция останется пустой после того, как выполнит свою роль.
В коллекционных типах, поддерживающих индексацию по диапазону (таких как строковые структуры типа String, векторы типов Vec и VecDeque), метод
drain принимает диапазон удаляемых элементов, вместо того чтобы очищать
всю последовательность целиком:
Листинг 10.6. Простейшая программа, демонстрирующая
использование метода drain
let mut outer = "Earth".to_string();
// ①
let inner = String::from_iter(outer.drain(1..4)); // ②
assert_eq!(outer, "Eh");
// ③
assert_eq!(inner, "art");
// ④
В приведенном выше примере:
① мы начинаем с создания мутируемого экземпляра типа String с именем
outer и инициализируем его значением "Earth". В результате создается
переменная outer с текстом "Earth";
② строковая переменная inner создается путем извлечения символов
из строковой переменной outer. Метод drain используется для удаления
символов из переменной outer на основе диапазона 1..4, исключая символ по индексу 4. Результат сохраняется в переменной inner, что фактически означает взятие части переменной outer и перемещение ее в переменную inner;
③ мы проверяем, что переменная outer теперь содержит "Eh", используя
макрофункцию assert_eq!, которая сверяет текущее значение переменной outer с ожидаемым значением "Eh";
④ аналогичным образом подтверждаем истинность того, что переменная
inner содержит "art", чтобы убедиться, что извлеченные символы были
правильно сохранены в переменной inner.
Если требуется полностью дренировать (выкачать) всю последовательность,
то в качестве аргумента можно использовать полный диапазон ...
Другие источники итераций
В дополнение к методам, рассмотренным ранее, в стандартной библиотеке
Rust существует целый ряд других типов, поддерживающих итерации. Ниже
приведен обзор некоторых из этих типов и способов получения итераторов
для них.
440
Итераторы и замыкания
Таблица 10.1. Типы стандартной библиотеки Rust, поддерживающие итерации
Тип или общая черта Выражение итерации
Описание
std::fs::ReadDir
fs::read_dir(path)
Выдает записи
о файлах и директориях по указанному
пути
std::path::PathBuf path.iter()
Выполняет итерации
по компонентам пути
как по экземплярам PathBuf
std::env::VarError env::vars().
Отображает переmap(|(key,
менные среды в корvalue)| (key,
теж ключ−значение
value))
Option<T>
Some(42).
Ведет себя как векiter()
тор длиной 0 (None)
либо 1 (Some(v))
Result<T, E>
Ok("success")
Аналогично Option,
.iter()
выдает значение,
только если результат равен Ok
Vec<T>, Срез
v.windows
Выдает срезы заданной длины из смежно расположенных
элементов (скользя
щие окна), слева
направо. Скользящие
окна накладываются
v.chunks
Выдает ненакладывающиеся срезы
заданной длины
из смежно расположенных элементов,
слева направо
v.chunks_mut
Аналогичен
v.chunks, но срезы
мутируемы
v.split
Выдает срезы, разделенные элементами,
которые удовлетворяют заданному
предикату
v.rsplitn
Аналогичен v.split,
но выдает срезы
справа налево, ограничиваясь максимум
двумя срезами
String, &str
s.bytes
Выдает байты в кодировке UTF-8
s.chars
Выдает символы
в кодировке UTF-8
Пример
std::fs::read_dir("/
path/to/directory")
"/path/to/file".iter()
std::env::vars().
map(|(key, value)|
(key, value))
Some(42).iter()
Ok("success").iter()
v.windows(5)
v.chunks(10)
v.chunks_mut(256)
v.split(|elem| elem
> 5)
v.rsplitn(2, ...)
"образец текста".
bytes()
"образец текста".
chars()
Другие источники итераций 441
Продолжение табл. 10.1
Тип или общая черта Выражение итерации
Описание
s.split_
Разделяет строку
whitespace
на итератор строк
по разрывам линий
s.lines
Разделяет строку
на итератор строк
по разрывам линий
s.split
Разделяет строку
по паттерну (символ,
строка или замыкание)
s.matches(char::
is_alphabetic)
std::collections::
HashMap,
std::collections::
BTreeMap
map.keys()
Выдает коллективные ссылки на ключи
словаря
map.values
Выдает коллективные ссылки на значения словаря
Выдает мутируемые
ссылки на значения
словаря
Выдает коллективные ссылки на элементы объединения
двух множеств
Выдает коллективные ссылки на элементы пересечения
двух множеств
Выдает значения,
отправленные
из другого потока
исполнения на соответствующем
отправителе
Выдает байты из потока ввода-вывода
Разбирает поток ввода-вывода на UTF-8
и выдает символы
Разбирает поток ввода-вывода на UTF-8
и выдает строки символьных литералов
Разбивает поток ввода-вывода по заданному байту и выдает
буферы типа Vec<u8>
map.values_
mut()
std::collections::
HashSet,
std::collections::
BTreeSet
set1.union(set2)
set1.
intersection(&set2)
std::sync::mpsc::
Receiver
recv.iter
std::io::Read
stream.bytes
stream.chars
stream
.charbufstream.
lines
bufstream.split
Пример
"разбить по пробелам".
split_whitespace()
"строка1\nстрока2\
nстрока3"
.lines()
"элемент1,элемент2,
элемент3".split(',')
"abc123xyz"
.matches(char::is_
alphabetic)
your_map.keys()
your_map.values()
your_map.values_mut()
set1.union(set2)
set1.intersection(set2)
recv.iter()
your_io_stream.bytes()
your_io_stream.chars()
your_io_stream.lines()
your_buf_stream.
split(10)
442
Итераторы и замыкания
Окончание табл. 10.1
Тип или общая черта Выражение итерации
std::fs::ReadDir
std::fs::read_
dir("/my_
directory")
std::net::
listener.incoming()
TcpListener
your_custom_type
your_iter_method
Описание
Выдает записи директории
Пример
std::fs::read_dir("/
my_directory")
listener.incoming()
Выдает входящие
сетевые соединения
your_custom_type.iter()
Конкретно-прикладная реализация
итераторов для
собственного специ
фического типа
Адаптерные методы итераторов
Стандартная библиотека Rust предлагает широкий спектр адаптерных методов итераторов, которые позволяют преобразовывать и манипулировать
итераторами, расширяя их мощь и выразительность. Адаптерные методы,
или просто адаптеры, являются ценными инструментами функционального
программирования, обеспечивающими элегантную и эффективную обработку данных. Мы подробно рассмотрим некоторые из наиболее часто используемых адаптерных методов, чтобы понять их функциональность и варианты
практического применения.
Начнем с метода map – это универсальный адаптер, который применяет
переданное ему замыкание к каждому элементу, производимому итератором.
Затем он генерирует новый итератор с преобразованными значениями. Этот
процесс преобразования выполняется лениво, означая, что фактические вычисления происходят только при обращении к элементам. Давайте проиллюст
рируем метод map на примере.
Листинг 10.7. Простейшая программа, демонстрирующая
использование адаптера map
fn main() {
let numbers = vec![1, 2, 3, 4, 5]; // ①
let squared: Vec<i32> = numbers
.into_iter()
.map(|x| x * x)
.collect();
}
println!("{:?}", squared);
// ②
// ③
// Вывод:
// [1, 4, 9, 16, 25]
В приведенном выше примере:
① мы начинаем с создания вектора Rust с именем numbers и заполняем его
целыми числами 1, 2, 3, 4 и 5. Как вы узнали из главы 4 «Встроенные в Rust
Адаптерные методы итераторов 443
структуры данных», векторы – это динамические коллекции, которые
могут хранить несколько значений одного типа данных, и в данном случае они содержат последовательность целых чисел;
② далее мы преобразовываем вектор numbers в итератор с помощью метода
into_iter. Затем к каждому элементу итератора применяется метод map, который возводит целые числа в квадрат, используя замыкание |x| x * x. Полученные квадраты значений собираются в новый вектор с именем squared.
Эта строка кода демонстрирует мощь Rust в преобразовании данных с помощью итераторов и замыканий, позволяя писать лаконичный и выразительный исходный код;
③ в завершение исходного кода мы выводим вектор squared в консоль с помощью макрофункции println!. Этот шаг печатает на экране значения,
полученные в результате предыдущего преобразования. Печать результатов посредством макрофункции println! является обычной практикой
в Rust, применяемой для визуализации и проверки итогов исполнения
исходного кода, что помогает в отладке и понимании логики программы.
Приведенный выше исходный код иллюстрирует создание вектора, преобразование его элементов с помощью итерационных методов Rust и вывод полученных квадратов чисел на экран.
Еще одним важным адаптерным методом итераторов является filter, который позволяет выборочно извлекать элементы из итератора на основе предоставленного предиката. Он создает новый итератор, содержащий только
те элементы, которые удовлетворяют заданному условию. Операция фильтрации тоже выполняется лениво, обеспечивая эффективные вычисления по требованию. Давайте рассмотрим, как метод filter используется для выбора четных чисел из диапазона.
Листинг 10.8. Простейшая программа, демонстрирующая
использование адаптерного метода filter
fn main() {
let numbers = 1..=10;
// ①
let evens: Vec<i32> = numbers
.into_iter()
.filter(|x| x % 2 == 0) // ②
.collect();
// ③
println!("{:?}", evens);
}
// Вывод:
// [2, 4, 6, 8, 10]
В приведенном выше исходном коде:
① мы начинаем с определения диапазона чисел с использованием синтаксиса диапазонов, а именно от 1 до 10 (включительно). Этот диапазон представляет собой последовательность целых чисел, которая станет основой для
дальнейших операций;
444
Итераторы и замыкания
② в следующем выражении мы вводим метод filter. Этот метод является
жизненно важным адаптерным методом итератора в Rust, позволяющим
выборочно извлекать элементы на основе заданного условия. В данном
случае мы используем его для отбора четных чисел из определенного
диапазона. Результатом является новый вектор с именем evens, который
содержит исключительно четные значения из исходного диапазона;
③ в завершение исходного кода мы используем макрофункцию println!,
чтобы вывести содержимое вектора evens в терминал. Этот вывод представляет собой результат успешной фильтрации четных чисел из начального диапазона, демонстрируя эффективность критерия фильтрации,
переданного в виде замыкания |x| x % 2 == 0. Это замыкание гарантирует, что в результирующий вектор попадут только те элементы, которые
удовлетворяют условию четности.
Это лишь краткий обзор адаптерных методов итераторов, доступных в стандартной библиотеке Rust. Каждый из этих методов служит определенной цели,
и, комбинируя их, можно создавать сложные и эффективные конвейеры обработки данных. Другие заслуживающие внимания методы, такие как collect,
fold, zip и enumerate, предлагают различные способы преобразования, агрегирования и манипулирования данными внутри итератора, расширяя выразительность и гибкость экосистемы итераторов Rust. Ключевое преимущество
этих адаптерных методов заключается в том, что они способствуют написанию чистого, лаконичного и декларативного исходного кода, повышая удобочитаемость и сопроводимость программ на Rust.
Построение конкретно-прикладных итераторов
Несмотря на то что стандартная библиотека Rust предлагает широкий спектр
методов итераторов, могут возникать случаи, когда придется создавать конкретно-прикладные итераторы, адаптированные под конкретные нужды.
Rust позволяет легко реализовывать свои собственные итераторы путем
определения конкретно-прикладной структуры и реализации для нее общей
черты Iterator.
Ниже приводится пример, в котором мы создаем конкретно-прикладной
итератор для простых чисел под названием PrimeGenerator. Указанный итератор будет эффективно определять и выдавать простые числа по мере запросов.
Итератор PrimeGenerator генерирует числа на лету и демонстрирует, как реализовывать свою собственную логику итерирования:
Листинг 10.9. Простейшая программа, демонстрирующая
создание конкретно-прикладных итераторов
struct PrimeGenerator {
current: u32,
}
// ①
impl Iterator for PrimeGenerator { // ②
type Item = u32;
Построение конкретно-прикладных итераторов 445
fn next(&mut self) -> Option<Self::Item> {
loop {
self.current += 1;
if is_prime(self.current) {
return Some(self.current); // Следующее простое число
}
}
}
}
fn is_prime(num: u32) -> bool {
if num <= 1 {
return false;
}
for i in 2..=(num / 2) {
if num % i == 0 {
return false;
}
}
true
}
// ③
fn main() {
// ④
let prime_sequence = PrimeGenerator { current: 1 }; // ⑤
for prime in prime_sequence.take(10) {
println!("{}", prime);
}
}
//
//
//
//
//
//
//
//
//
//
//
Вывод:
2
3
5
7
11
13
17
19
23
29
В приведенном выше исходном коде:
① прежде всего мы начинаем с создания специализированного итератора. Этот конкретно-прикладной итератор под названием PrimeGenerator
сконструирован с конкретной целью: динамически генерировать прос
тые числа. Как вы увидели в предыдущих разделах, простые числа обладают особым свойством – они делятся без остатка только на 1 и на самих
себя. Наш конкретно-прикладной итератор скроен для того, чтобы эффективно распознавать и генерировать эти простые числа именно тогда,
когда они необходимы;
② далее мы переходим к реализации общей черты Iterator для структуры
PrimeGenerator. Как вы выяснили ранее, эта конкретная черта наделяет
структуру способностями итератора, позволяя использовать такие методы, как next(), для получения следующего элемента последовательности.
446
Итераторы и замыкания
В данном примере этот следующий элемент представляет собой очередное простое число;
③ нам также необходимо включить вспомогательную функцию is_prime.
Эта функция служит для проверки того, что заданное число является
простым. Данная вспомогательная функция реализована отдельно;
④ теперь, когда итератор PrimeGenerator готов, он может генерировать простые
числа по одному за раз. Для того чтобы начать его использовать, мы создаем
экземпляр конкретно-прикладного итератора внутри функции main;
⑤ наконец, все готово к тому, чтобы опробовать наш генератор прос
тых чисел в деле. В функции main мы создаем экземпляр итератора
PrimeGenerator и инициализируем его начальным значением 1. Следом
мы задействуем цикл for, чтобы извлечь первые 10 простых чисел и вывести каждое из них в консоль.
Приведенный выше пример служит демонстрацией создания конкретноприкладного итератора для эффективной генерации простых чисел. Итератор
PrimeGenerator способен вычислять простые числа на лету, обеспечивая гибкость и эффективность использования ресурсов для различных приложений.
Пример подчеркивает мощь создания специализированных итераторов, отвечающих конкретным потребностям генерации данных.
Ленивые вычисления
Одним из ключевых преимуществ итераторов в Rust являются ленивые вычисления. Это означает, что итератор не вычисляет и не хранит все свои значения
в памяти заранее. Вместо этого он генерирует значения на лету по мере того,
как вы их запрашиваете. Благодаря ленивым вычислениям итераторы крайне
эффективны с точки зрения расхода памяти и позволяют работать с потенциально бесконечными последовательностями. Рассмотрим следующий ниже
фрагмент исходного кода:
Листинг 10.10. Простейшая программа, демонстрирующая
концепцию ленивых вычислений
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<i32> = numbers
.iter()
.map(|x| x * 2)
.collect();
}
// ①
// ②
println!("Удвоены: {:?}", doubled); // ③
// Вывод:
// Удвоены: [2, 4, 6, 8, 10]
В приведенном выше фрагменте исходного кода:
① мы начинаем с создания вектора под названием numbers, который содержит значения 1, 2, 3, 4 и 5;
Продвинутые методы: flat_map, take, skip и peekable 447
② двигаясь дальше, мы генерируем новый вектор под названием doubled
с помощью метода, именуемого отображением. Этот метод применяется
к вектору numbers, где он берет каждый элемент и выполняет операцию
удвоения. Стоит подчеркнуть, что данный процесс удвоения не происходит мгновенно для всего вектора сразу, а выполняется для каждого элемента по отдельности, именно тогда, когда это необходимо.
Эта концепция называется ленивыми вычислениями и означает, что
исходный код не вычисляет все значения за один проход, а ждет, пока
вы не запросите результаты. В данном случае расчеты производятся и собираются в новый вектор doubled только тогда, когда мы вызываем метод
collect();
③ выводим содержимое вектора doubled в терминал. Строка
println!("Удвоены: {:?}", doubled); показывает удвоенные значения,
содержащиеся в векторе.
Приведенный выше исходный код демонстрирует грамотное использование ленивых вычислений в итераторах Rust. Оно гарантирует, что вычисления
выполняются только при необходимости, что ведет к созданию эффективного
и экономного к памяти исходного кода. Метод map – это мощный инструмент
преобразования данных, который особенно полезен при работе с крупными
наборами данных, когда желательно выполнять операции на каждом элементе, не загружая все содержимое в память одновременно.
Продвинутые методы: flat_map, take, skip и peekable
Освоив основы адаптации итераторов, самое время погрузиться в более продвинутые адаптерные методы. Давайте обследуем адаптерные методы flat_
map, take, skip и peekable.
Адаптерный метод flat_map
Адаптерный метод flat_map – это ключ к управлению вложенными итераторами
и работе с коллекциями коллекций. Он позволяет преобразовывать каждый элемент в итератор, а затем «разглаживать» результаты в единую последовательность. Ниже приводится пример, демонстрирующий работу адаптера flat_map:
Листинг 10.11. Простейшая программа, демонстрирующая
использование адаптерного метода flat_map
fn main() {
let words = vec!["Rust", "is", "awesome"]; // ①
let letters: Vec<char> = words.iter()
.flat_map(|&word| word.chars())
.collect();
// ②
// Проверить, что все буквы собраны в один плоский вектор
assert_eq!(letters, vec!['R', 'u', 's', 't', 'i', 's', 'a', 'w', 'e', 's',
'o', 'm', 'e']);
// ③
}
448
Итераторы и замыкания
В приведенном выше фрагменте исходного кода:
① мы начинаем с создания вектора Rust под названием words. Этот вектор
инициализируется последовательностью строковых литералов, а именно
"Rust", "is" и "awesome". Они послужат источником для дальнейшего преобразования;
② на следующем этапе мы вводим метод flat_map – незаменимый адаптер
итератора в Rust. Он играет важнейшую роль в «разглаживании» вложенных итераторов, что делает его особенно ценным для обработки сложных
преобразований данных. В нашем конкретном сценарии метод flat_map
используется для того, чтобы превратить коллекцию слов в исчерпывающую коллекцию отдельных букв. Это преобразование разворачивается
следующим образом: мы инициируем процесс, используя метод iter()
на векторе words, создав итератор, который позволяет систематически
выполнять итерации по его элементам в цикле. Вслед за этим мы применяем метод flat_map, используя замыкание |&word| word.chars(). Данное
замыкание эффективно раскладывает каждое слово на составляющие его
символы. Результатом является новый вектор под названием letters, который содержит все отдельные символы, извлеченные из исходных слов;
③ в завершение исходного кода используется макрофункция assert_eq!,
чтобы убедиться, что вектор letters действительно содержит ожидаемую последовательность символов. Эта проверка гарантирует, что преобразование с использованием метода flat_map правильно конвертировало слова в соответствующие им буквы.
Приведенный выше исходный код служит примером преобразования коллекции слов в коллекцию составляющих их отдельных символов с помощью
метода flat_map.
Адаптерные методы take и skip
Адаптерные методы take и skip дают контроль над количеством элементов, которые нужно обработать из итератора. Метод take позволяет извлекать заданное
количество элементов с начала, тогда как метод skip дает возможность пропус
кать заданное число элементов перед обработкой остальных. Ниже приводится
пример, демонстрирующий совместное использование методов take и skip:
Листинг 10.12. Простейшая программа, демонстрирующая
использование адаптерных методов take и skip
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let selected_numbers: Vec<i32> = numbers.iter() // ①
.skip(3)
// ② ③
.take(4)
// ② ③
.cloned() // ④
.collect(); // Собрать результат в вектор
}
assert_eq!(selected_numbers, vec![4, 5, 6, 7]); // ⑤
Продвинутые методы: flat_map, take, skip и peekable 449
Приведенный выше фрагмент кода знакомит с итераторными адаптерами
take и skip, предлагая способ точного контроля над количеством элементов,
которые требуется обработать. С помощью адаптера take можно выборочно извлекать заданное количество элементов из начала итератора, тогда как
адаптер skip позволяет пропускать заранее заданное число элементов перед
обработкой последующих элементов.
① Мы создаем итератор по ссылкам на элементы вектора.
② Мы применяем оба адаптера – take и skip. Изначально имеется вектор
под названием numbers, содержащий последовательность целых чисел
от 1 до 10. Основная цель здесь – продемонстрировать эффективный
контроль и манипулирование желаемым диапазоном элементов, с которыми предполагается работать.
③ Используем вектор numbers, создав итератор с помощью метода iter().
Сначала мы применяем метод skip(3), чтобы пропустить первые три
элемента в итераторе. Вслед за этим мы используем метод take(4), который позволяет захватить следующие четыре элемента последовательности. Это комбинированное действие точно ограничивает диапазон обрабатываемых элементов.
④ После выполнения желаемых операций мы вызываем метод cloned(),
чтобы клонировать выбранные элементы и создать новый вектор под
названием selected_numbers. Этот вектор содержит элементы, которые
мы отобрали с ювелирной точностью благодаря использованию адаптеров take и skip.
⑤ Наконец, мы используем макрофункцию assert_eq!, чтобы подтвердить, что selected_numbers соответствует ожидаемому результату. В данном контексте ожидаемый результат – это вектор, включающий целые
числа 4, 5, 6 и 7. Данный этап проверки гарантирует, что исходный код
работает должным образом и успешно справляется со скрупулезным
процессом отбора.
Приведенный выше исходный код служит практическим примером совместного применения адаптеров take и skip для точного управления диапазоном
элементов, которые требуется обрабатывать из итератора.
Адаптерный метод peekable
Адаптерный метод peekable напоминает изящную хитрость, применяемую
для итераторов. Он позволяет заглядывать вперед и видеть следующий элемент в очереди, фактически не потребляя его. Это бывает невероятно удобно
в ситуациях, когда нужно решать, что делать дальше, основываясь на значении, которое идет следующим в последовательности. Например, представьте,
что вы работаете с большим списком элементов и хотите обрабатывать их особым образом, но ваше решение зависит от того, что будет дальше. Без метода
peekable вам пришлось бы продвигаться по списку, чтобы увидеть следующий
элемент, что расточительно и замедляет производительность. Но с методом
peekable можно «заглядывать» вперед, принимать взвешенное решение и поддерживать эффективность исходного кода. Это как если бы вы дали своему исходному коду хрустальный шар, чтобы видеть будущее!
450 Итераторы и замыкания
Листинг 10.13. Простейшая программа, демонстрирующая
использование адаптерного метода peekable
fn main() {
// Превратить вектор в итератор и сделать его "задглядывающим вперед"
let mut numbers = vec![1, 2, 3, 4, 5].into_iter().peekable(); // ①
if let Some(&next_number) = numbers.peek() { // ②
println!("The next number is: {}", next_number);
} else {
// ③
println!("No more numbers to process.");
}
let _ = numbers.next();
// ④
if let Some(&next_number) = numbers.peek() { // ⑤
println!("Следующее число: {}", next_number);
} else {
println!("Все числа обработаны.");
}
}
// Вывод:
// Следующее число: 1
// Следующее число: 2
В приведенном выше фрагменте исходного кода:
① мы начинаем с создания итератора numbers с помощью макрофункции
vec!, которая инициализирует вектор, содержащий целые числа от 1 до 5.
Затем мы вызываем into_iter(), чтобы получить итератор, предназначенный для обхода этого вектора, и оборачиваем его в peekable(), в результате
чего будет создан «заглядывающий» итератор, который позволяет заглядывать вперед и видеть элементы последовательности, не потребляя их;
② далее мы используем условную инструкцию, чтобы проверять присутствие
значения, когда мы «заглядываем вперед», чтобы посмотреть на следующий элемент с помощью numbers.peek(). Если значение присутствует,
то мы выводим сообщение, указывающее число, которое будет следующим;
③ в случае, если элементов для обработки больше нет (то есть итератор исчерпан), мы выводим другое сообщение;
④ после этого мы потребляем первое число в итераторе, используя numbers.
next(). Это означает, что мы переходим к следующему элементу последовательности, фактически продвигая позицию вперед;
⑤ после потребления первого числа мы снова заглядываем вперед, чтобы
увидеть следующий элемент с помощью numbers.peek(), и если значение
есть, то выводим сообщение о следующем числе. Если элементов больше
нет, то выводим соответствующее сообщение.
Приведенный выше фрагмент исходного кода иллюстрирует, как итераторы
peekable используются для принятия обоснованных решений на основе предстоящих данных, при этом эффективно управляя позицией итератора и гарантируя, что мы обрабатываем данные нужным образом. Это изящный способ
работы с итераторами на Rust, предлагающий мелкозернистый контроль и повышенную выразительность в задачах по обработке данных.
Адаптерные методы filter_map, fuse и flatten 451
Освоив эти продвинутые методы и комбинируя их с базовыми адаптерными
методами map и filter, вы получаете полный контроль над вашими итераторами, придавая задачам по обработке данных бóльшую эффективность и выразительность.
Адаптерные методы filter_map, fuse и flatten
Все больше углубляясь в тему адаптеров итераторов, давайте обследуем методы filter_map, fuse и flatten – инструменты, которые поднимут ваши умения
манипулировать последовательностями на новый уровень.
Адаптерный метод filter_map
Адаптерный метод filter_map – это мощный инструмент, который объединяет
способности по фильтрации и преобразованию в одном шаге, позволяя получать
точный контроль над итераторами. Этот адаптер позволяет применять конкретно-прикладную функцию к каждому элементу последовательности, предоставляя
выбор: либо преобразовывать его в новый элемент, либо полностью отбрасывать.
Давайте рассмотрим практический сценарий, в котором имеется коллекция
строковых литералов со смесью числовых и нечисловых данных, и ваша цель –
извлечь и выполнить разбор числовых значений, игнорируя недопустимые
записи. Адаптер filter_map легко справляется с этой задачей, отсеивая нечисловые данные и преобразовывая валидные записи в числовые эквиваленты
за одну эффективную операцию. Это упрощает извлечение данных, гарантируя получение нужной информации при поддержании элегантности исходного кода и производительности, что делает данный метод бесценным активом
в задачах программирования и обработки данных.
Листинг 10.14. Простейшая программа, демонстрирующая
использование адаптерного метода filter_map
use std::str::FromStr;
fn main() {
let text = "The numbers are: 42, 19, invalid, 27, 33";
let parsed_numbers: Vec<i32> = text
.split(',')
// ①
.map(str::trim) // ②
.filter_map(|s| { // ③
let s = s
.chars()
.skip_while(|&c| !c.is_digit(10)) // Пропустить все, что не цифра
.collect::<String>();
if !s.is_empty() {
// ④
i32::from_str(&s).ok() // ⑤
} else {
None
}
})
.collect();
}
assert_eq!(parsed_numbers, vec![42, 19, 27, 33]);
452
Итераторы и замыкания
В приведенном выше фрагменте исходного кода на Rust наша цель состоит
в том, чтобы работать с текстом, содержащим смесь чисел и нечисловых символов, извлечь и выполнить разбор допустимых чисел, отфильтровав при этом
недопустимые. Для достижения этой цели мы реализовали ряд технических
приемов манипуляции строковыми литералами и воспользовались адаптером
filter_map, который оказывается очень изящным инструментом для такого
рода задач обработки данных.
① Мы начинаем с разбивки входного текста, используя запятые в качестве
разделителей с помощью вызова метода split(','). В результате получается последовательность подстрок, каждая из которых представляет
собой часть изначального текста.
② Далее мы применяем операцию map(str::trim) к каждой из этих подстрок. Это означает, что мы обрезаем любые начальные или концевые
пробелы в каждой подстроке, гарантируя работу с чистыми данными.
③ Самая интересная часть происходит в операции filter_map. Мы создаем анонимную функцию (замыкание), которая выполняет следующие ниже шаги:
сначала мы используем метод chars(), чтобы превратить обрезанную
подстроку в итератор по ее отдельным символам;
затем используем skip_while(|&c| !c.is_digit(10)), чтобы пропус
кать символы до тех пор, пока не найдем первую цифру. Это помогает
локализовывать начало потенциального числа;
собираем оставшиеся символы в строковое значение (типа String).
④ Затем проверяем, не является ли собранное строковое значение пустым.
Эта проверка гарантирует продолжение работы только с непустыми
строковыми значениями, так как пустые значения не могут представлять
допустимые числа.
⑤ Когда собранное строковое значение не является пустым, мы пытаемся
преобразовать его в целое число типа i32, используя метод i32::from_
str(&s).ok(). Вот как он работает: этот метод пытается конвертировать
значение s типа String в значение типа i32. Если конвертация проходит
успешно, то он возвращает Some(parsed_number) с разобранным целым
числом; однако если конвертировать не получается, то он щадяще возвращает None.
Результатом является вектор типа Vec<i32> под названием parsed_numbers,
содержащий успешно распознанные и отфильтрованные значения. В данном
конкретном исходном коде инструкция assert_eq! подтверждает истинность
того, что вектор parsed_numbers действительно содержит ожидаемые числа,
а именно [42, 19, 27, 33]. Адаптер filter_map в сочетании с другими методами
манипуляции строковыми значениями оказывается умным и эффективным
способом извлечения и обработки допустимых числовых данных из текста при
игнорировании недопустимых записей.
Адаптерный метод fuse
Адаптерный метод fuse служит предохранительным механизмом при работе с итераторами, предотвращая непредвиденное поведение, которое может
Адаптерные методы filter_map, fuse и flatten 453
возникать, если продолжить использовать итератор после того, как он достиг
своего конца. При применении метода fuse к итератору он фактически запирается в состоянии, при котором он всегда будет возвращать None, как только
закончатся его элементы, независимо от того, как вы попытаетесь его использовать в дальнейшем. Эта способность неоценима в ситуациях, когда необходимо обеспечивать предсказуемое и единообразное поведение исходного
кода, предотвращая любые нежелательные последствия.
Представьте себе, что у вас есть итератор, который проходит по последовательности элементов, и вы работаете над задачей, результат которой зависит
от точного знания момента, когда итератор будет исчерпан. Без адаптера fuse
итератор не всегда сможет вести себя ожидаемым образом, что потенциально приводит к ошибкам или непредсказуемым результатам. Применяя метод
fuse, вы гарантируете, что итератор будет надежно сигнализировать о своем
окончании, придавая исходному коду более высокую надежность и делая его
понимание проще. Это похоже на наличие отказобезопасного механизма, который удерживает исходный код на правильном пути. Ниже приводится прос
тейший пример.
Листинг 10.15. Простейшая программа, демонстрирующая
использование адаптерного метода fuse
struct Unpredictable(bool);
impl Iterator for Unpredictable {
type Item = &'static str;
fn next(&mut self) -> Option<Self::Item> {
if self.0 {
self.0 = false;
Some("абсолютно последний элемент")
} else {
// Переключаем флаг обратно, что делает итератор "мерцающим"
self.0 = true;
None
}
}
}
fn main() {
// Обычный итератор с непредсказуемым поведением
let mut unpredictable = Unpredictable(true);
assert_eq!(unpredictable.next(), Some("абсолютно последний элемент"));
assert_eq!(unpredictable.next(), None);
assert_eq!(unpredictable.next(), Some("абсолютно последний элемент"));
}
// Использование адаптерного метода для создания предохранительного итератора
let mut reliable = Unpredictable(true).fuse();
assert_eq!(reliable.next(), Some("абсолютно последний элемент"));
assert_eq!(reliable.next(), None);
// Теперь итератор "перегорел" и будет всегда возвращать None
assert_eq!(reliable.next(), None);
454
Итераторы и замыкания
В приведенном выше фрагменте исходного кода итератор Unpredictable ведет себя непредсказуемо после выдачи первого элемента. Однако применение
метода fuse гарантирует, что итератор будет стабильно возвращать None после
первого завершения.
Адаптерный метод flatten
Адаптерный метод flatten – это универсальный и бесценный компонент экосистемы итераторов Rust, разработанный для эффективного решения задач,
связанных с работой внутри вложенных последовательностей. Он раскрывается в полной мере при работе с итераторами, которые сами производят последовательности, что часто усложняет исходный код из-за вложенности структур
данных. Основная задача метода flatten состоит в упрощении этого процесса
путем «развертывания» вложенных последовательностей, в результате чего
получается связная линейная последовательность элементов.
Для того чтобы понять значимость адаптера flatten, рассмотрим сценарий,
в котором нужно объединить различные списки учебных курсов, организованные по именам студентов или другим критериям, в единый всеобъемлющий
список всех учебных курсов. Без адаптера flatten эта задача могла бы потребовать сложного и подверженного ошибкам исходного кода с использованием
вложенных циклов и условной логики. Однако flatten значительно упрощает
эту операцию, предлагая более чистое, эффективное и лаконичное решение.
Он изящно объединяет последовательности учебных курсов, создавая плоский
список, которым гораздо проще управлять, который повышает удобочитаемость исходного кода и упрощает обработку данных.
Ниже приводится пример, демонстрирующий объединение нескольких последовательностей учебных курсов в единый список:
Листинг 10.16. Простейшая программа, демонстрирующая
использование адаптерного метода flatten
use std::collections::HashMap;
fn main() {
// Создать структуру данных, представляющую студентов и их курсы
let mut student_courses = HashMap::new();
student_courses.insert("Алиса", vec!["Математика", "Физика"]);
student_courses.insert("Борис", vec!["Информатика", "Химия"]);
student_courses.insert("Виктор", vec!["Физика", "Биология"]);
// Вычислить количество студентов, записанных на каждый курс
let course_enrollment: HashMap<&str, usize> = student_courses
.values() // Извлечь списки курсов
.flatten() // "Разгладить" вложенные списки в единый итератор ссылок
на курсы
.fold(HashMap::new(), |mut enrollment, course| {
// Подсчитать количество упоминаний каждого курса
*enrollment.entry(course).or_insert(0) += 1;
enrollment
});
Волшебство замыканий 455
}
//
//
//
//
//
//
// Вывести данные о записи на курсы
for (course, count) in &course_enrollment {
println!("Курс: {}, записано студентов: {}", course, count);
}
Вывод:
Курс: Биология, записано студентов: 1
Курс: Физика, записано студентов: 2
Курс: Информатика, записано студентов: 1
Курс: Математика, записано студентов: 1
Курс: Химия, записано студентов: 1
В приведенном выше примере исходного кода представьте, что вы управляе
те списком студентов и учебных курсов, которые они посещают, – это похоже на школьное расписание. Идея состоит в том, чтобы выяснить количество
студентов на каждом курсе: например, сколько человек изучают математику,
сколько – физику и так далее. Для того чтобы облегчить эту задачу, мы используем специальные инструменты подобно тому, как выбирают подходящие детали для сборки пазла. Эти инструменты помогают организовать информацию
и подсчитать студентов на каждом курсе, чтобы затем четко показать результаты, например: «На математику записано 20 студентов, а на физику – 15». Это
помогает делать обработку и понимание таких данных более прямолинейными.
Мы начинаем с извлечения списков курсов с помощью метода values(). Далее мы «разглаживаем» вложенные списки в единый итератор ссылок на курсы с помощью метода flatten(). Далее мы используем метод fold() для накоп
ления данных о курсах в новом хеш-словаре типа HashMap. Внутри замыкания
свертки (fold) обновляем счетчик каждого курса в этом хеш-словаре.
Наконец, мы выводим данные о курсах на экран, показывая название курса
вместе с количеством студентов, записанных на него. Этот пример демонстрирует мощь адаптерных методов итераторов Rust в управлении сложными структурами данных и их преобразовании, обеспечивая высокоточный контроль над
обработкой данных и делая исходный код выразительнее и эффективнее.
Начальные разделы этой главы познакомили вас с адаптерами итераторов:
от базовых map и filter до продвинутых методов, таких как flat_map, take, skip,
peekable, filter_map, fuse и flatten. Эти адаптеры наделяют разработчика способностью высокоточно манипулировать последовательностями, превращая
итераторы Rust в мощный инструмент разработки и преобразования последовательностей данных. Теперь самое время обследовать тему замыканий.
Волшебство замыканий
В Rust замыкания подобны специальным функциям, способным захватывать
и использовать переменные из своего окружения. Они невероятно полезны,
особенно при работе со списками данных. Замыкания придают исходному
коду чистоту и делают его пригодным для реиспользования, а также играют
огромную роль в том, как программист работает с коллекциями данных, позволяя выполнять всевозможные конкретно-прикладные действия с ними, что
улучшает работу исходного кода и его понимание.
456
Итераторы и замыкания
Синтаксис замыканий
Rust предлагает три разных синтаксических варианта для определения замыканий, что позволяет гибко и эффективно конструировать исходный код. Первый вариант, представляемый как || { /* исходный код */ }, предназначен
для сценариев, в которых замыканию не требуются входные аргументы. Этот
минималистичный подход идеален для создания лаконичных замыканий без
аргументов, что делает его удобным инструментом в ситуациях, требующих
простых и прямолинейных реализаций.
Второй вариант, |x| { /* исходный код */ }, ориентирован на замыкания,
принимающие один аргумент. Эта синтаксическая конструкция оптимальна
в ситуациях, когда требуется передать в замыкание один конкретный параметр. Она позволяет четко и кратко определять входной аргумент, делая исходный код выразительнее и более самодокументированным.
Наконец, третий вариант, |x, y| { /* исходный код */ }, обеспечивает гибкость при приеме нескольких аргументов внутри замыкания. Такая универсальность неоценима в случаях, когда необходимы сложные взаимодействия
между несколькими переменными или параметрами. Позволяя замыканиям
принимать несколько аргументов, Rust дает разработчику возможность создавать комплексные и исчерпывающие решения.
Выбор подходящей синтаксической конструкции замыкания основывается
на конкретных требованиях текущей задачи. Широкий диапазон синтаксических конструкций для замыканий в Rust предоставляет свободу в написании
исходного кода, который не только отвечает запросам проекта, но и повышает удобочитаемость и эффективность программ. Такая адаптивность является
свидетельством стремления Rust предоставлять разработчику инструменты,
необходимые для создания надежного, хорошо структурированного и поддерживаемого исходного кода.
Захват переменных в замыканиях
В Rust замыкания могут захватывать переменные из окружающего их контекста. Этот захват может осуществляться либо по ссылке (заимствование), либо
по значению (перемещение), в зависимости от предполагаемого поведения
замыкания и требований к использованию задействованных переменных. Захват по ссылке обеспечивает коллективный доступ к переменной, благодаря
чему он идеально подходит для операций с правом только для чтения и упрощает конкурентный доступ. С другой стороны, захват по значению передает
переменную во владение, позволяя изменять переменную внутри замыкания,
но предотвращая ее дальнейшее использование во внешнем диапазоне доступности. Такой гибкий подход к захвату переменных является фундаментальным аспектом конструктивного исполнения Rust, предоставляя способы
балансировать производительность и защищенность памяти, благодаря чему
они становятся предпочтительным выбором для системного программирования, где и то, и другое играет критически важную роль.
Захват переменных в замыканиях 457
Заимствование переменных
Когда замыкание заимствует переменную, оно сохраняет ссылку на нее, не забирая ее во владение. Это позволяет замыканию читать и использовать переменную, не «потребляя» ее. Давайте рассмотрим пример:
Листинг 10.17. Простейшая программа, демонстрирующая
заимствование переменных внутри замыканий
fn main() {
let multiplier = 5;
// ①
let numbers = vec![1, 2, 3, 4, 5]; // ①
// Замыкание в методе map захватывает multiplier по ссылке
let multiplied: Vec<i32> = numbers // ②
.iter()
.map(|x| x * multiplier)
.collect();
}
println!("Перемноженный: {:?}", multiplied); // ③
// Вывод:
// Перемноженный: [5, 10, 15, 20, 25]
В приведенном выше исходном коде:
① программа начинается с инициализации двух переменных: multiplier
и numbers. Переменной multiplier присваивается значение 5, а переменная numbers создается как вектор, содержащий целые числа 1, 2, 3, 4, 5;
② далее в исходном коде вводится новая переменная multiplied, предназначенная для хранения результата операции, примененной к вектору
numbers. Вот что происходит в этой строке кода:
numbers.iter(): метод iter() преобразовывает вектор numbers в итератор. Итераторы облегчают пошаговый обход элементов внутри
коллекции;
.map(|x| x * multiplier): метод map вызывается на итераторе вместе
с замыканием. По каждому элементу x в итераторе замыкание выполняет операцию умножения x на значение multiplier, которое равно 5.
Результатом этой операции является создание нового итератора, элементами которого являются произведения этого умножения;
.collect(): наконец, метод collect используется для сбора элементов
из итератора в новый вектор. Конечным результатом всего этого выражения является вектор, содержащий элементы из вектора numbers,
каждый элемент которого умножен на 5;
③ последний сегмент исходного кода посвящен выводу вектора multiplied
на экран. Для этого используется макрофункция println!, которая форматирует и выводит содержимое вектора multiplied.
458
Итераторы и замыкания
Результат работы программы демонстрирует элементы, содержащиеся в векторе multiplied. Каждый из этих элементов соответствует изначальным числам
из вектора numbers, умноженным на значение переменной multiplier (5). Этот
пример эффективно иллюстрирует использование в Rust итераторов и замыканий для применения определенной операции к каждому элементу коллекции –
в данном случае умножения элементов на константу.
Перемещение переменных
Замыкания также могут брать переменные в полное владение, фактически
перемещая их в свой диапазон доступности. Это бывает полезно в ситуациях,
когда требуется передавать владение переменной самому замыканию. Давайте проиллюстрируем данный подход на примере.
В Rust замыкания обеспечивают гибкость: они могут либо заимствовать
переменные по ссылке, либо брать их в полное владение, перемещая переменные в диапазон доступности замыкания. Это позволяет контролировать
доступ к переменным и их изменение внутри замыкания. Давайте детально
разберем пример, который иллюстрирует эту концепцию:
Листинг 10.18. Простейшая программа, демонстрирующая
владение переменными внутри замыканий
fn main() {
let message = "Привет, Rust!".to_string(); // ①
let greet = || {
// ②
let message = message; // Переместить владение внутрь замыкания
println!("{}", message);
};
}
greet();
// ③
// greet(); // ④
// Вывод:
// Привет, Rust!
В приведенном выше исходном коде:
① мы начинаем с создания переменной message, которая содержит строковый литерал "Привет, Rust!" типа String;
② затем определяем замыкание с именем greet, используя синтаксис ||,
и захватываем переменную message в диапазоне доступности замыкания. В данном случае захват переменной message означает ее получение
в полное владение и перемещение в замыкание. Такая передача владения означает, что к переменной message больше нельзя будет обращаться
за пределами диапазона доступности этого замыкания;
③ при вызове замыкания greet с помощью greet() оно успешно выводит
сообщение "Привет, Rust!" в терминал, так как оно явно потребляет переменную внутри своего диапазона доступности;
④ однако если попытаться вызвать замыкание во второй раз, раскомментировав строку с вызовом greet();, то мы столкнемся с ошибкой ком-
Замыкания и общие черты Fn, FnMut, FnOnce 459
пиляции. Эта ошибка связана с тем, что переменная message уже была
перемещена в замыкание и больше недоступна во внешнем диапазоне
доступности. Сообщение об ошибке «Use of moved value: message» (Использование перемещенного значения) подчеркивает это поведение.
Приведенный пример демонстрирует разницу между заимствованием
и перемещением переменных внутри замыканий. Перемещая переменную
в замыкание, вы явно передаете владение ею, что бывает полезно в сценариях, в которых требуется обеспечивать исключительный доступ к переменной
внутри замыкания, но это также означает, что переменная больше не будет доступна за его пределами.
Замыкания и общие черты Fn, FnMut, FnOnce
В Rust замыкания делятся на три категории в зависимости от того, как они захватывают переменные: Fn, FnMut и FnOnce. Эти общие черты определяют способность замыкания получать доступ к захваченным переменным и изменять их.
Fn: замыкания, которые захватывают переменные немутируемо, предоставляя доступ к захваченным данным с правом только для чтения.
FnMut: замыкания, которые захватывают переменные мутируемо, обеспечивая доступ с правом как для чтения, так и для записи захваченных данных.
FnOnce: замыкания, которые потребляют переменные, забирая захваченные данные в полное владение и предотвращая любое дальнейшее использование этих переменных.
Указывая желаемую общую черту для замыкания, можно комбинировать
ключевое слово move с общими чертами Fn, FnMut и FnOnce.
Давайте рассмотрим эти общие черты на практических примерах.
Замыкания Fn
В Rust замыкания предоставляют гибкие способы захвата переменных из их
окружения. В следующем ниже примере мы создаем замыкание, которое захватывает переменную немутируемо и использует ее значение для вычислений:
Листинг 10.19. Простейшая программа, демонстрирующая
использование замыканий Fn
fn main() {
let counter = 0;
// ①
let print_counter = || {
// ②
println!("Счетчик: {}", counter); // ③
};
print_counter();
}
// counter += 1;
// Вывод:
// Счетчик: 0
// ④
// ⑤
460
Итераторы и замыкания
В приведенном выше примере:
① мы объявляем переменную counter как немутируемую, не используя
ключевое слово mut. Это означает, что переменную counter нельзя изменять после присвоения ей значения;
② создаем замыкание с именем print_counter, которое захватывает переменную counter как немутируемую ссылку. Это означает, что замыкание
может читать значение counter, но не может его изменять;
③ внутри замыкания мы используем макрофункцию println!, чтобы вывести значение counter на экран;
④ при вызове замыкания print_counter оно успешно считывает и печатает
значение counter;
⑤ если попытаться изменить переменную counter, как показано в закомментированной строке кода, то это приведет к ошибке компиляции, которая произойдет из-за того, что переменная заимствована как немутируемая, а компилятор Rust строго следит за тем, чтобы немутируемо
заимствованные переменные не изменялись, пока заимствование является активным.
В приведенном выше исходном коде стоит отметить один важный момент:
замыкание print_counter захватывает переменную counter немутируемо, что
позволяет ему читать значение counter, но не дает изменять его напрямую
внутри замыкания. Несмотря на эту немутируемость, замыкание может получать доступ к переменной counter и выводить ее актуальное значение при
каждом вызове.
Этот пример демонстрирует способность замыканий захватывать переменные из внешнего по отношению к нему диапазона доступности и взаимодействовать с ними, соблюдая правила владения и заимствования Rust. В данном
случае переменная counter заимствуется замыканием немутируемо, что позволяет ему читать значение этой переменной.
Замыкания FnMut
В приведенном ниже примере мы определяем замыкание, которое захватывает переменную мутируемо, что позволяет модифицировать захваченные данные:
Листинг 10.20. Простейшая программа, демонстрирующая
использование замыканий FnMut
fn main() {
let mut counter = 0;
// ①
let mut increment = || { // ②
counter += 1;
// ③
println!("Счетчик: {}", counter); // ④
};
}
increment(); // ⑤
increment(); // ⑥
Замыкания и общие черты Fn, FnMut, FnOnce 461
// Вывод:
// Счетчик: 1
// Счетчик: 2
В приведенном выше исходном коде:
① мы начинаем с объявления мутируемой переменной counter и инициа
лизируем ее значением 0. Ключевое слово mut указывает на то, что эту
переменную можно изменять;
② далее мы определяем замыкание increment, используя синтаксис ||, что
означает замыкание без аргументов. Внутри этого замыкания:
③ мы увеличиваем переменную counter на 1 с помощью counter += 1. Эта
операция изменяет значение переменной counter;
④ затем мы используем макрофункцию println!, чтобы вывести на экран обновленное значение переменной counter в формате "Счетчик: {значение}";
⑤ вызываем замыкание increment в первый раз. Это приводит к тому, что
замыкание увеличивает значение переменной counter на единицу и печатает обновленное значение;
⑥ вызываем замыкание increment во второй раз, что снова увеличивает
значение переменной counter на единицу и выводит новое значение
на экран.
Замыкание increment захватывает переменную counter мутируемо, получая
доступ к захваченным данным с правом как для чтения, так и для записи. Следовательно, мы можем изменять переменную внутри замыкания, и эти изменения отражаются и за его пределами.
Замыкания FnOnce
Замыкания FnOnce в Rust – это тип замыканий, которые обладают уникальной
способностью потреблять переменные, забирая захваченные данные в полное
владение. Это означает, что как только замыкание FnOnce захватывает переменную, эта переменная больше не может быть использована в исходном коде
где-либо еще после вызова замыкания. Такое поведение демонстрируется
в следующем ниже фрагменте исходного кода:
Листинг 10.21. Простейшая программа, демонстрирующая
использование замыканий FnOnce
fn main() {
let numbers = vec![1, 2, 3, 4, 5]; // ①
let consume_numbers = || {
// ②
let numbers = numbers;
// ③
let sum: i32 = numbers.iter().sum(); // ④
println!("Сумма: {}", sum);
// ⑤
};
consume_numbers();
}
// ⑥
// consume_numbers(); // ⑦
462
Итераторы и замыкания
В приведенном выше исходном коде:
① мы начинаем с определения переменной numbers и присваиваем ей вектор типа Vec, который содержит список целых чисел от 1 до 5. Этот вектор
хранится в памяти и владеет своими элементами;
② далее мы определяем замыкание с именем consume_numbers, используя
синтаксис замыканий || Rust. Это замыкание не принимает аргументов
и не возвращает значение в явной форме;
③ внутри замыкания мы встречаем строку кода, которая на первый взгляд
создает новую переменную с именем numbers. Однако здесь происходит не создание новой переменной, а затенение внешней переменной
numbers. Замыкание потребляет и забирает переменную numbers из внешнего диапазона доступности во владение. Это означает, что изначальная
переменная numbers вне замыкания больше недоступна, и любая попытка использовать ее после этого момента приведет к ошибке компиляции;
④ продолжая работу внутри замыкания, переменная numbers используется
для создания итератора, а сумма элементов вектора вычисляется и сохраняется в новой переменной sum. Эта переменная sum является локальной внутри диапазона доступности;
⑤ после вычисления суммы замыкание выводит результат в терминал, показывая общую сумму элементов захваченного вектора;
⑥ затем мы вызываем замыкание consume_numbers, что является допустимой операцией. Замыкание поглотило переменную numbers и может обращаться к ней, пока та находится в его диапазоне доступности;
⑦ если раскомментировать второй вызов замыкания consume_numbers и попытаться вызвать его снова, то возникнет ошибка компиляции. Это происходит из-за того, что переменная numbers была перемещена в замыкание во время первого вызова и ее нельзя реиспользовать во внешнем
диапазоне доступности. Эта ошибка демонстрирует концепцию владения переменными в Rust и поведение замыканий FnOnce, которые полностью потребляют захваченные переменные.
Замыкания FnOnce в Rust позволяют потреблять переменные, забирая
их в полное владение и предотвращая их использование в других частях исходного кода. Оно может быть мощным функциональным средством обеспечения защищенного и предсказуемого поведения программ на Rust, так как
оно навязывает строгие правила доступа к переменным и их изменения внут
ри замыканий.
Общие черты Fn, FnMut и FnOnce предоставляют способ конкретизировать
поведение замыканий, позволяя разработчику контролировать доступ к переменным и их модификацию в соответствии с его нуждами.
Итераторы для эффективной обработки данных
Итераторы в Rust предоставляют мощный механизм эффективной обработки
данных, и в этом разделе мы подробно рассмотрим их использование, чтобы
разобраться в том, как эффективно выполнять различные операции на данных. Мы проведем расследование разных методов итераторов и методов
Итераторы для эффективной обработки данных 463
фильтрации, преобразования и сбора данных, а также их применение. Кроме
того, мы рассмотрим варианты использования итераторов для работы с бесконечными последовательностями и выполнения сложных операций на коллекциях, повысив выразительность и эффективность исходного кода на Rust.
Накопление данных с помощью метода fold
Метод fold, который в других языках программирования (например,
в Python1) часто называют reduce, позволяет аккумулировать значения
из итератора в единый результат (дословно: сворачивать или сводить к одному результату). Разработчик предоставляет начальное значение и замыкание, которое определяет то, как комбинировать текущее накопленное значение со следующим элементом.
Листинг 10.22. Простейшая программа,
демонстрирующая использование метода fold
fn main() {
let numbers = vec![1, 2, 3, 4, 5]; // ①
}
let sum: i32 = numbers.iter().fold(0, |acc, x| acc + x); // ②
println!("Сумма: {}", sum);
// ③
// Вывод:
// Сумма: 15
В приведенном выше фрагменте исходного кода:
① мы начинаем с создания переменной numbers и присваивания ей вектора
типа Vec, содержащего последовательность целых чисел: 1, 2, 3, 4 и 5. Этот
вектор, по сути, является итерируемой коллекцией в памяти;
② далее мы вводим переменную sum типа i32. В целях эффективного вычисления суммы всех элементов внутри вектора numbers мы используем
метод fold. Этому методу требуются два основных аргумента: начальное
значение для накопления (в данном случае 0) и замыкание, которое определяет способ комбинирования каждого элемента с накопленным результатом. Это замыкание, представленное как |acc, x| acc + x, сообщает
итератору о том, что нужно прибавлять каждый элемент (x) к текущей накопленной сумме (acc). По мере того как итератор проходит по элементам, он многократно применяет это замыкание, обновляя накопленную
сумму каждым новым числом;
③ затем мы используем макрофункцию println!, чтобы вывести окончательный результат на экран. Сумма всех элементов вектора numbers, вычисленная с помощью метода fold, выводится в терминал. Этот пример
демонстрирует компактный и эффективный способ выполнения операций агрегации на коллекциях благодаря методу fold Rust.
1
https://docs.python.org/3/library/functools.html?highlight=reduce#functools.reduce.
464 Итераторы и замыкания
Операции накопления являются обычной необходимостью при обработке
данных, и метод fold предлагает элегантное и эффективное решение таких задач. Он позволяет постепенно аккумулировать значения, обеспечивая конт
роль и гибкость посредством замыкания, гарантируя достижение желаемого
результата с минимальным количеством исходного кода. Это, в свою очередь,
способствует написанию удобочитаемого и выразительного исходного кода
на Rust, делая задачи по обработке данных более доступными и удобными
в сопровождении.
Выстраивание итераторов в цепочку
с помощью метода chain
Выстраивание итераторов в цепочку с помощью метода chain – это универсальный подход в Rust, позволяющий бесшовно конкатенировать два итератора, фактически создавая единую, целостную последовательность для дальнейшей обработки. Это бывает особенно полезно в ситуациях, когда требуется обрабатывать несколько источников данных как единое целое. Давайте разберем
предоставленный ниже исходный код на Rust, чтобы понять, как работает этот
технический прием и в чем заключается его значимость.
Листинг 10.23. Простейшая программа, демонстрирующая
использование метода chain
fn main() {
let numbers1 = vec![1, 2, 3];
let numbers2 = vec![4, 5, 6];
}
// ①
// ①
let combined: Vec<i32> = numbers1 // ②
.iter()
.chain(numbers2.iter())
.map(|x| x * 2) // ③
.collect();
// ④
println!("Объединенный и удвоенный: {:?}", combined); // ⑤
// Вывод:
// Объединенный и удвоенный: [2, 4, 6, 8, 10, 12]
В приведенном выше исходном коде:
① исходный код начинается с определения двух переменных типа Vec,
а именно numbers1 и numbers2, каждая из которых содержит последовательности целых чисел. Вектор numbers1 содержит значения [1, 2, 3],
а вектор numbers2 – значения [4, 5, 6]. Эти векторы представляют собой
итерируемые коллекции данных внутри памяти;
② далее мы создаем новую переменную с именем combined, которая явным
образом объявляется с типом Vec<i32>. Эта переменная будет хранить
результаты комбинированной и обработанной последовательности. Для
достижения этой цели мы используем метод chain, который позволяет конкатенировать итераторы, сгенерированные из векторов numbers1
Применение итераторов и замыканий в практических примерах 465
и numbers2. Выстраивая эти итераторы в цепочку, мы создаем единую последовательность, включающую элементы из обоих векторов. Это мощный механизм для работы с данными из разных источников так, будто
они являются частями одной последовательности;
③ продолжая обработку данных, мы используем метод map для применения преобразования к каждому элементу комбинированной последовательности. Преобразование задается внутри замыкания, записанного
как |x| x * 2. Это замыкание дает итератору команду удваивать каждый
встреченный элемент, фактически умножая на 2 каждое значение в комбинированной последовательности;
④ наконец, мы используем метод collect, чтобы собрать обработанные
элементы в новый вектор типа Vec<i32>, который затем сохраняется
в переменной combined. Это действие создает вектор, содержащий удвоенные значения, полученные в результате преобразования посредством
метода map;
⑤ в завершение мы используем макрофункцию println!, чтобы вывести
на экран содержимое вектора combined. Вывод покажет элементы последовательности после того, как они были удвоены. Этот пример демонстрирует эффективность и элегантность выстраивания итераторов в цепочку на Rust, позволяя плавно и производительно обрабатывать данные
из нескольких источников как единый поток.
Выстраивание итераторов в цепочки и использование таких методов, как
map, открывает широкие возможности для комбинирования и обработки данных из различных источников. Экосистема итераторов Rust с ее композиционной и функциональной природой предлагает элегантный способ выполнения
таких операций, способствуя удобочитаемости, поддерживаемости и эффективности исходного кода в задачах по обработке данных.
Применение итераторов и замыканий
в практических примерах
В этом разделе мы проведем обследование практической пользы итераторов и замыканий в Rust на различных вариантах использования из реальной практики. Эти мощные функциональные средства языка повышают эффективность, удобочитаемость и выразительность исходного кода. Мы подробно разберем каждый пример, предоставив исчерпывающие объяснения
и контекст того, как именно итераторы и замыкания способствуют улучшению исходного кода.
Вариант использования 1: анализ текста
Анализ текста играет ключевую роль в таких областях, как обработка естест
венного языка и глубокая переработка данных, в которых зачастую требуется подсчет частот слов в документе. Итераторы и замыкания Rust предлагают
элегантное решение этой задачи. В следующем ниже фрагменте исходного
кода этот процесс разбирается пошагово:
466
Итераторы и замыкания
Листинг 10.24. Простейшая программа, демонстрирующая
использование замыканий в анализе текста
use std::collections::HashMap;
fn main() {
let text = "Rust is a language that makes it easy to learn Rust and \
Rust programming is fun when you learn Rust"; // ①
}
//
//
//
//
②
③
④
⑤
⑥
let word_freq: HashMap<String, u32> = text
.split_whitespace()
.map(|word| word.to_lowercase())
.filter(|word| !word.is_empty())
.fold(HashMap::new(), |mut map, word| {
*map.entry(word).or_insert(0) += 1;
map
});
//
//
//
//
//
println!("Частоты слов: {:?}", word_freq);
// ⑦
Вывод:
Частоты слов: {"language": 1, "when": 1, "that": 1, "you": 1,
"fun": 1, "it": 1, "easy": 1, "makes": 1, "and": 1, "rust": 4, "is": 2,
"a": 1, "to": 1, "learn": 2, "programming": 1}
В приведенном выше исходном коде:
① мы начинаем с определения строковой переменной text, содержащей
предложение о программировании на Rust. Этот текст будет проанализирован для расчета частот слов;
② мы создаем переменную-словарь word_freq типа HashMap, которая будет
хранить частоты слов. Ключом будет строковое значение (представляющее слово), а значением – 32-битное беззнаковое целое число (представляющее частоту);
③ для разбивки текста на отдельные слова мы используем метод split_
whitespace(). Этот метод возвращает итератор по словам в тексте, разбивая текст по пробельным символам, таким как пробелы и табуляция;
④ метод map() применяется для преобразования каждого слова в нижний
регистр с помощью замыкания. Этот шаг гарантирует, что анализ не будет чувствителен к регистру, и предотвращает дублирование записей для
одного и того же слова с разным регистром букв;
⑤ метод filter() используется вместе с замыканием для удаления любых
пустых слов. Этот этап фильтрации повышает качество анализа частот
слов, исключая несловесные символы или лишние пробелы;
⑥ наконец, мы используем метод fold() для накопления частот слов внут
ри хеш-словаря (типа HashMap). Замыкание, переданное в метод fold(),
принимает текущий хеш-словарь и каждое слово, увеличивает его час
тоту в словаре и возвращает обновленный словарь. Метод or_insert()
используется для вставки новой пары ключ−значение, если слово еще
не представлено в словаре;
⑦ исходный код завершается выводом результирующего хеш-словаря word_
freq на экран, показывая частоты слов во входном тексте.
Применение итераторов и замыканий в практических примерах 467
Приведенный выше исходный код демонстрирует, как итераторы и замыкания Rust применяются для эффективного анализа текста, делая его нечувствительным к регистру, удаляя лишние символы и аккумулируя частоты слов
с использованием чистого и выразительного подхода.
Вариант использования 2: обработка изображений
Задачи по обработке изображений нередко предусматривают операции
на уровне пикселей, которые варьируются от элементарных преобразований
до сложных фильтров. В данном конкретном случае наша цель – удвоить значения RGB (красный, зеленый, синий) пикселей изображения. Следующий
ниже фрагмент исходного кода иллюстрирует, как итераторы и замыкания
Rust могут эффективно и элегантно упрощать эту задачу:
Листинг 10.25. Простейшая программа, демонстрирующая
использование замыканий в обработке изображений
#[derive(Debug)]
struct Pixel {
red: u8,
green: u8,
blue: u8,
}
// ①
fn main() {
let mut image: Vec<Pixel> = vec![ // ②
Pixel {
red: 100,
green: 50,
blue: 25,
},
Pixel {
red: 200,
green: 100,
blue: 50,
},
// ... больше пикселей
];
image.iter_mut().for_each(|pixel| {
// ③
// Удваивать значения с использованием циклического умножения
// во избежание паники при переполнении (255 * 2)
pixel.red = pixel.red.wrapping_mul(2);
// ④
pixel.green = pixel.green.wrapping_mul(2); // ④
});
}
pixel.blue = pixel.blue.wrapping_mul(2);
// ④
println!("Изображение после обработки: {:?}", image); // ⑤
// Вывод:
// Изображение после обработки: [Pixel { red: 200, green: 100, blue: 50 },
// Pixel { red: 144, green: 200, blue: 100 }]
468
Итераторы и замыкания
В приведенном выше исходном коде:
① мы начинаем с определения структуры Pixel, которая представляет отдельные пиксели изображения. Каждый пиксель характеризуется отдельными компонентами: красным, зеленым и синим;
② само изображение представлено в виде вектора экземпляров структуры
Pixel, хранящегося в переменной image. Каждый экземпляр структуры
Pixel задает начальные значения RGB у соответствующего пикселя;
③ для того чтобы манипулировать данными пикселей прямо на месте,
мы используем метод iter_mut(), который предоставляет мутируемый
итератор по вектору изображений. Этот итератор позволяет напрямую
модифицировать значения пикселей;
④ внутри замыкания, переданного методу for_each(), мы удваиваем значения красного, зеленого и синего компонентов у каждого пикселя. Это
достигается с помощью метода wrapping_mul(), который не только умножает значения, но и щадяще обрабатывает возможное переполнение,
которое может возникать в результате операции удвоения;
⑤ исходный код завершается печатью модифицированного вектора image,
показывая обновленные значения RGB у каждого пикселя после обработки.
В данном примере исходного кода демонстрируется, как итераторы и замыкания Rust эффективно справляются с преобразованиями на уровне пикселей при обработке изображений. Это был упрощенный пример, но в реальных
сценариях обработки изображений применялись бы более сложные операции
и фильтры. Итераторы и замыкания Rust позволяют справляться с такими сложностями, поддерживая при этом удобочитаемость и ясность исходного кода.
Вариант использования 3: фильтрация
и преобразование данных
Работа с наборами данных о товарах – распространенная задача в приложениях
сферы электронной коммерции и управления товарными запасами. В приведенном ниже примере рассматривается эффективное применение итераторов
и замыканий Rust для решения задач по фильтрации и преобразованию данных.
Листинг 10.26. Простейшая программа, демонстрирующая
использование замыканий при манипулировании данными
#[allow(dead_code)]
#[derive(Debug)]
struct Product { // ①
name: String,
price: f64,
in_stock: bool,
}
fn main() {
let products = vec![
Product {
name: String::from("Widget A"),
price: 10.0,
Применение итераторов и замыканий в практических примерах 469
in_stock: true,
},
Product {
name: String::from("Widget B"),
price: 15.0,
in_stock: false,
},
// ... больше товаров
];
let discount = 0.2; // ②
}
let discounted_products: Vec<Product> = products // ③
.into_iter()
// ④
.filter(|product| product.in_stock)
// ⑤
.map(|mut product| {
// ⑥
product.price -= product.price * discount;
product
})
.collect();
// ⑦
println!("Уцененные товары: {:?}", discounted_products); // ⑧
// Вывод:
// Уцененные товары: [Product { name: "Widget A", price: 8.0, in_stock: true }]
В приведенном выше исходном коде:
① исходный код начинается с определения структуры Product, служащей
для представления отдельных товаров, каждый из которых характеризуется такими атрибутами, как название (name), цена (price) и статус наличия на складе (in_stock). Данные о товарах хранятся в векторе products,
который представляет собой список доступных для продажи позиций;
② для того чтобы применить скидку к имеющимся в наличии товарам,
определяется процент скидки (discount);
③ создается новый вектор discounted_products для хранения товаров,
на которые будет распространяться скидка. Этот вектор будет содержать
измененные цены на товары после преобразования;
④ мы используем метод into_iter(), чтобы получить итератор, принимающий вектор products во владение. Это сделано в связи с тем, что мы хотим изменять цены товаров прямо на месте, а использование метода
into_iter() позволяет извлекать данные из изначального вектора;
⑤ метод filter() применяется с замыканием, которое проверяет наличие
товара на складе. Этот метод отфильтровывает и сохраняет только те товары, у которых поле in_stock имеет значение true;
⑥ метод map() используется с замыканием для корректировки цены каждого товара путем применения скидки. Метод map() преобразовывает каждый товар, вычисляя новую цену со скидкой;
⑦ окончательным результатом является новый вектор discounted_products,
который содержит измененные товары. Этот вектор представляет собой
список товаров после применения скидки;
470
Итераторы и замыкания
⑧ исходный код завершается печатью вектора discounted_products, показывая измененные товары с примененными скидками.
Приведенный выше пример демонстрирует, как замыкания внутри итераторов эффективно справляются с задачами фильтрации и преобразования данных
на Rust. Следует учитывать, что этот пример был намеренно упрощен – в приложениях из реальной практики задачи по фильтрации и преобразованию данных могут встречаться гораздо более сложные операции, и итераторы вместе
с замыканиями Rust являются мощными инструментами для таких сценариев.
Вариант использования 4: обработка сенсорных данных
Обработка сенсорных данных – распространенное требование в самых разных
областях: от интернета вещей до научных исследований. Такая задача зачас
тую предусматривает анализ данных с целью выявления трендов или аномалий. В приведенном ниже примере мы рассмотрим эффективное применение
итераторов и замыканий Rust для обработки сенсорных данных.
Листинг 10.27. Простейшая программа, демонстрирующая
использование замыканий при обработке данных
#[allow(dead_code)]
#[derive(Debug)]
struct SensorReading {
timestamp: u64,
value: f64,
}
// ①
fn main() {
let sensor_data = vec![ // ②
SensorReading {
timestamp: 1,
value: 20.0,
},
SensorReading {
timestamp: 2,
value: 22.0,
},
SensorReading {
timestamp: 3,
value: 23.0,
},
// ... больше показаний
];
let threshold = 22.0; // ③
}
let anomalies: Vec<&SensorReading> = sensor_data
.iter()
// ④
.filter(|&reading| reading.value > threshold) // ⑤
.collect(); // ⑥
println!("Аномальные показания: {:?}", anomalies); // ⑦
Применение итераторов и замыканий в практических примерах 471
// Вывод:
// Аномальные показания: [SensorReading { timestamp: 3, value: 23.0 }]
В приведенном выше исходном коде:
① мы начинаем с определения структуры SensorReading, предназначенной
для представления отдельных показаний сенсора. Каждое показание
включает в себя метку времени (в виде 64-битного беззнакового целого
числа) и значение (в виде числа с плавающей точкой f64). Эта структура
позволяет эффективно моделировать и обрабатывать сенсорные данные;
② вектор sensor_data создается для хранения коллекции экземпляров
структуры SensorReading, имитируя реальные показания сенсора. В данном сценарии важно отметить, что эти показания представляют собой
некую форму данных, измеряемых сенсором, например температуру,
давление или любую другую измеримую величину;
③ устанавливается пороговое значение (threshold) для выявления аномальных показаний в сенсорных данных. В этом примере любое показание, значение которого превышает этот порог, считается аномалией;
④ для эффективной фильтрации сенсорных данных используются итераторы и замыкания Rust. Метод iter() применяется для получения итератора по вектору sensor_data. Этот итератор позволяет выполнять итерации
по показаниям в цикле, сохраняя изначальные данные в целости;
⑤ внутри метода filter() к каждому показанию применяется замыкание,
проверяющее, превышает значение этого показания заданный порог
или нет. Метод filter() оставляет те показания, которые соответствуют
этому условию, эффективно отсеивая аномалии;
⑥ результатом этой обработки является новый вектор под названием
anomalies. Указанный вектор содержит ссылки на те экземпляры структуры SensorReading, значения которых превышают порог;
⑦ исходный код завершается печатью вектора anomalies, давая представление о показаниях, которые были выявлены как аномальные. Хотя в этом
упрощенном примере результат содержит только одно аномальное показание, анализ сенсорных данных в реальных условиях часто предусмат
ривает более сложные алгоритмы и критерии обнаружения аномалий.
Приведенный выше пример служит иллюстрацией эффективности обработки сенсорных данных итераторами и замыканиями, облегчая выявление
аномалий или трендов. В практическом анализе данных эти функциональные
средства Rust могут применяться для раскрытия ценных идей и закономерностей в самых разных областях – от экологического мониторинга до контроля
качества на производстве.
Вариант использования 5: финансовые расчеты
Финансовые приложения часто подразумевают сложные вычисления и преобразования данных, особенно когда речь идет о таких задачах, как отслеживание баланса счета на основе серии финансовых транзакций. В этом примере
мы подробно рассмотрим применение итераторов и замыканий Rust для финансовых расчетов с высокой эффективностью и ясностью.
472
Итераторы и замыкания
Листинг 10.28. Простейшая программа, демонстрирующая
использование замыканий в финансах
#[derive(Debug)]
struct Transaction {
// ①
amount: f64,
transaction_type: String,
}
fn main() {
let transactions = vec![ // ②
Transaction {
amount: 100.0,
transaction_type: String::from("Вклад"),
},
Transaction {
amount: -50.0, // Обратите внимание: сумма уже отрицательная
transaction_type: String::from("Снятие"),
},
// ... больше транзакций
];
let balance = transactions.iter().fold(0.0, |acc, transaction| { // ③
if transaction.transaction_type == "Вклад" {
acc + transaction.amount // ④
} else {
acc - transaction.amount // ⑤
}
});
}
println!("Итоговый баланс: {:?}", balance); // ⑥
// Вывод:
// Итоговый баланс: 150.0
В приведенном выше примере:
① исходный код начинается с определения структуры Transaction, которая представляет отдельные финансовые операции. Каждая транзакция
содержит сумму (amount), представленную числом с плавающей точкой
(f64), и тип транзакции, указанный в виде строкового значения;
② в векторе transactions агрегируется коллекция финансовых транзакций,
которые могут включать вклады денежных средств, снятия денежных
средств или различные другие финансовые операции;
③ для расчета итогового баланса на основе этих транзакций в исходном
коде используются итераторы и замыкания Rust. В частности, применяется метод fold() – идеально подходящий инструмент для накопления
значений в процессе выполнения итераций;
④ внутри метода fold() начальное значение аккумулятора acc устанавливается равным 0.0. Этот аккумулятор выступает в роли текущей суммы баланса, по мере того как мы выполняем итерации по транзакциям
в цикле;
Применение итераторов и замыканий в практических примерах 473
⑤ переданное в метод fold() замыкание отвечает за проверку вида каждой отдельной транзакции. Если тип транзакции – "Вклад", то сумма прибавляется в аккумулятор (acc). С другой стороны, если тип транзакции –
"Снятие", то сумма вычитается из аккумулятора. Эта условная логика
обеспечивает определение итогового баланса на основе серии операций;
⑥ результатом является итоговый баланс, который затем выводится в консоль. Приведенный выше пример иллюстрирует, как интегрированные
в итераторы замыкания облегчают эффективную обработку сложных финансовых операций, включая условные расчеты.
В реальных финансовых приложениях вычисления могут усложняться дополнительными факторами, такими как процентные ставки, комиссии и конвертация валют. Однако итераторы и замыкания Rust остаются универсальными и удобочитаемыми инструментами, хорошо подходящими для управления разно
образными финансовыми сценариями с высокой эффективностью и точностью.
Вариант использования 6: сортировка
Замыкания в Rust представляют собой мощный инструмент сортировки данных, что делает их универсальным решением для многочисленных задач.
В приведенном ниже конкретном сценарии мы исходим из наличия коллекции конкретно-прикладных структур Product и намерены отсортировать
их в порядке возрастания цены.
Листинг 10.29. Простейшая программа, демонстрирующая
использование замыканий при сортировке
struct Product { // ①
name: String,
price: f64,
}
fn main() {
let mut products = vec![ // ②
Product {
name: "Ноутбук".to_string(),
price: 799.99,
},
Product {
name: "Смартфон".to_string(),
price: 599.99,
},
Product {
name: "Наушники".to_string(),
price: 149.99,
},
];
products.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap()); // ③ ④
}
for product in &products { // ⑤
println!("Товар: {} − Цена: ${}", product.name, product.price);
}
474
//
//
//
//
Итераторы и замыкания
Вывод:
Товар: Наушники − Price: $149.99
Товар: Ноутбук − Price: $799.99
Товар: Смартфон − Price: $599.99
В приведенном выше исходном коде:
① исходный код начинается с определения структуры Product, которая описывает каждый товар с помощью названия (в виде строкового значения
типа String) и цены (в виде числа с плавающей точкой типа f64);
② создается мутируемый вектор products для хранения коллекции экземпляров структуры Product. В каждом экземпляре задано название и цена товара;
③ для их сортировки по ценам в порядке возрастания используется метод
sort_by. Этот метод позволяет задавать критерий сортировки с помощью
замыкания. Замыкание захватывает ссылки на два товара, обозначенные
как a и b, и сравнивает их цены;
④ в сравнении внутри замыкания используется метод partial_cmp для выполнения частичного сравнения цен. Затем применяется метод unwrap,
чтобы защищенно извлечь результат сравнения; это связано с тем, что метод partial_cmp возвращает экземпляр типа Option. Данное сравнение гарантирует, что товары будут отсортированы в порядке возрастания их цен;
⑤ вслед за этим исходный код выполняет итерации по отсортированным
товарам в цикле и выводит их названия и цены в консоль, демонстрируя
успешную сортировку экземпляров структуры Product.
Приведенный выше пример иллюстрирует, как замыкания внутри итераторов помогают в реализации операций сортировки данных. Хотя этот пример
упрощает сценарий сортировки, сведя сравнение всего к одному критерию, задачи по сортировке из реальной практики могут предусматривать более сложные условия и несколько полей. Тем не менее итераторы и замыкания Rust
обеспечивают гибкость и выразительность, необходимые для эффективного
решения самых разных задач по сортировке.
Заключение
Эта глава предложила обширное обследование итераторов, замыканий и их
сложной роли в парадигме программирования на Rust. Итераторы являются
фундаментальной концепцией Rust, служа основой для эффективной и выразительной обработки данных. Создавая, адаптируя и используя итераторы
из стандартной библиотеки Rust, а также в конкретно-прикладном исходном
коде, разработчик получает доступ к мощному инструментарию для работы с последовательностями данных. Прочитав эту главу, вы обрели глубокое
понимание итераторов: от использования стандартных адаптерных методов до разработки конкретно-прикладных итераторов, скроенных под ваши
специфические требования. Эти умения окажутся бесценными при освоении
многогранного ландшафта Rust, наделив вас способностью разрабатывать чистый, идиоматичный и эффективный исходный код.
Замыкания, еще один центр внимания в этой главе, представляют собой
мощное функциональное средство Rust, позволяющее создавать и использовать анонимные функции с доступом к окружающему их контексту. Эти дина-
Дополнительные ресурсы 475
мические функции играют ключевую роль в различных задачах программирования, включая сортировку, фильтрацию и конкурентное программирование.
В этой главе были тщательно обследованы различные составляющие замыканий: от их базового синтаксиса до сложных взаимоотношений с переменными в окружающем диапазоне доступности. Замыкания оказываются не только
удобным инструментом программирования, но и ориентированным на производительность решением, разработанным с целью минимизации накладных расходов и эффективного использования даже в критических к производительности сегментах исходного кода. Понимание нюансов общих черт Fn,
FnMut и FnOnce наделяет разработчика способностью контролировать поведение замыканий и обусловливать характер их взаимодействия с захваченными
переменными, расширяя гибкость системы замыканий, применяемой в Rust.
По мере вашего продвижения в изучении Rust понимание и мастерство владения итераторами и замыканиями будут оставаться незаменимыми элементами вашего инструментария программиста. Эти конструкции позволяют уверенно решать широкий спектр задач по обработке данных, способствуя созданию надежного, поддерживаемого и выразительного программного обес
печения. Помимо итераторов и замыканий, последующие главы погрузят вас
в обширную экосистему Rust, расширив ваши знания и навыки и подготовив
к преодолению сложностей и нюансов этого мощного языка.
В следующей главе мы подробно рассмотрим модульное тестирование
на Rust. Тестирование имеет решающее значение в разработке программного обеспечения, и эта глава будет посвящена модульным тестам. Вы научитесь
писать тестовые функции и модули, исполнять тесты, интерпретировать результаты и практиковать разработку через тестирование на Rust. Рассматриваемые темы включают написание тестовых функций и модулей, выполнение
тестов и разработку через тестирование. Эта глава усилит ваши умения тестировать свой исходный код на Rust и повысит его надежность.
Дополнительные ресурсы
В целях углубления своих знаний об итераторах и замыканиях Rust рекомендуется обратиться к следующим ниже ресурсам.
Книга по Rust – глава 13: средства функционального программирования. Почитайте 13-ю главу официальной книги по Rust, которая посвящена средствам функционального программирования на данном языке,
включая итераторы и замыкания. Это поможет заложить прочный фундамент для написания лаконичного и выразительного исходного кода:
https://doc.rust-lang.org/book/ch13-00-functional-features.html.
Стандартная библиотека Rust – документация общей черты Iterator.
Подробно изучите документацию общей черты Iterator в стандартной
библиотеке Rust. Понимание этой общей черты необходимо для работы
с мощным механизмом итераторов Rust, который позволяет эффективно выполнять операции на последовательностях данных: https://doc.rustlang.org/std/iter/index.html.
Пакет Itertools – документация общих черт расширения итераторов.
Ознакомьтесь с документацией пакета Itertools на сайте crates.io. Этот
пакет расширяет функциональности стандартных итераторов Rust, пре-
476
Итераторы и замыкания
доставляя дополнительные методы и комбинаторы, служащие для упрощения сложных итеративных шаблонов: https://docs.rs/itertools.
Rust на примерах – замыкания. Ознакомьтесь с главой «Замыкания» в интерактивном учебнике «Rust на примерах». Там представлены практические примеры и упражнения, которые помогут закрепить понимание
темы на практике: https://doc.rust-lang.org/rust-by-example/fn/closures.html.
RFC 2394 – замыкания и синтаксис async/await. Ознакомьтесь с интернет-стандартом RFC 2394, в котором обсуждаются замыкания и их
взаимодействие с синтаксисом async/await. Этот документ является
основополагающим для понимания роли замыканий в асинхронном
программировании на Rust: https://github.com/rust-lang/rfcs/blob/master/
text/2394-async_await.md.
Перечисленные выше ресурсы предлагают комплексный взгляд на итераторы и замыкания в Rust, предоставляя практические руководства и документацию, которые помогут вам освоить эти концепции с целью написания эффективного исходного кода.
Вопросы для самопроверки
Вопрос 1. Что из перечисленного лучше всего описывает замыкания
в Rust?
a) специальные итераторы
b) блоки кода, которые могут захватывать и использовать переменные
из своего окружения
c) функции стандартной библиотеки
d) мутируемые ссылки на коллекции
Вопрос 2. Какую роль играет метод map() в итераторах Rust?
a) фильтрует элементы на основе предиката
b) применяет преобразование к каждому элементу итератора
c) объединяет два итератора
d) создает новый итератор заданной длины
Вопрос 3. Какая общая черта отвечает за определение структуры итератора в Rust?
a) Iterator
b) IntoIterator
c) Option
d) Result
Вопрос 4. Каково главное предназначение метода drain для коллекционных типов в Rust?
a) потребляет всю коллекцию целиком
b) передает получателю владение каждым элементом
Вопросы для самопроверки 477
c) выполняет итерации по элементам в обратном порядке
d) фильтрует элементы на основе заданного предиката
Вопрос 5. Какой метод используется для получения следующего элемента в итераторной последовательности в Rust?
a) collect()
b) for_each()
c) next()
d) map()
Вопрос 6. Какой адаптерный метод итератора в Rust используется для
выборочного извлечения элементов на основе предоставленного предиката?
a) map
b) filter
c) collect
d) zip
Вопрос 7. Что позволяет делать адаптер peekable в Rust?
a) изменять элементы в итераторе
b) просматривать следующий элемент, не потребляя его
c) пропускать определенное количество элементов
d) разглаживать вложенные итераторы
Вопрос 8. Какой адаптер итератора используется в Rust для управления
вложенными итераторами и работы с коллекциями коллекций?
a) take
b) skip
c) flat_map
d) peekable
Вопрос 9. Что такое ленивые вычисления в итераторах Rust?
a) предварительное вычисление и хранение всех значений в памяти
b) генерация значений на лету по мере запроса
c) потребление элементов без каких-либо условий
d) немедленное применение операций ко всей последовательности
Вопрос 10. Какой адаптер итератора служит предохранительным механизмом, предотвращающим неожиданное поведение при использовании итератора после того, как он достиг конца?
a) flatten
b) filter
c) fuse
d) map
478
Итераторы и замыкания
Вопрос 11. Что делает адаптер flatten в экосистеме итераторов Rust?
a) фильтрует элементы на основе заданного условия
b) разглаживает вложенные последовательности, создавая линейную последовательность элементов
c) пропускает указанное количество элементов
d) применяет функцию к каждому элементу
Вопрос 12. Что означает общая черта FnMut в замыканиях Rust?
a) немутируемый захват переменных
b) мутируемый захват переменных
c) потребление переменных с передачей владения
d) применяется только к замыканиям без аргументов
Вопрос 13. Какова роль метода fold в контексте итераторов Rust?
a) пропускает элементы в итераторе
b) собирает элементы в новый итератор
c) сворачивает элементы в одно значение с помощью замыкания
d) разглаживает вложенные последовательности
Вопрос 14. Какой метод итератора используется в Rust для накопления
значений из итератора в один результат?
a) map
b) filter
c) fold
d) chain
Вопрос 15. Каково предназначение метода chain в итераторах Rust?
a) фильтрация элементов на основе условия
b) конкатенация двух итераторов в один
c) отображение элементов в новое значение
d) накопление значений в одном результате
Вопрос 16. Что возвращает метод partial_cmp в Rust?
a) Boolean
b) Option
c) Result
d) usize
Вопрос 17. Что обрабатывает метод wrapping_mul в Rust?
a) деление на ноль
b) переполнение и потерю значимости числа (антипереполнение)
c) ошибки чисел с плавающей точкой
d) конкатенацию строковых значений
Ключевые термины 479
Ответы
1. b) блоки кода, которые могут захватывать и использовать переменные
из своего окружения
2. b) применяет преобразование к каждому элементу итератора
3. a) Iterator
4. b) передает получателю владение каждым элементом
5. c) next()
6. b) filter
7. b) просматривать следующий элемент, не потребляя его
8. c) flat_map
9. b) генерация значений на лету по мере запроса
10. c) fuse
11. b) разглаживает вложенные последовательности, создавая линейную последовательность элементов
12. b) мутируемый захват переменных
13. c) сворачивает элементы в одно значение с помощью замыкания
14. c) fold
15. b) конкатенация двух итераторов в один
16. b) Option
17. b) переполнение и потерю значимости числа (антипереполнение)
Ключевые термины
Общая черта Iterator: общая черта в Rust, определяющая структуру
итератора. Она задает такие методы, как next(), и ассоциированный тип
Item, представляющий тип значений, генерируемых итератором.
Общая черта IntoIterator: общая черта в Rust, позволяющая трактовать
типы как итерируемые. Она задается методом into_iter и позволяет использовать типы в циклах for, делая исходный код более идиоматичным.
Метод drain: метод, предоставляемый некоторыми типами коллекций
в Rust (например, векторами), который возвращает итератор, передающий получателю владение каждым элементом. По завершении операции коллекция остается пустой.
Замыкания (closures): анонимные функции в Rust, которые могут захватывать переменные из своего диапазона доступности и обращаться
к ним. Это мощное функциональное средство, часто используемое для
написания лаконичных и реиспользуемых блоков кода.
Метод map: метод итератора в Rust, который применяет преобразование к каждому элементу, создавая новый итератор с измененными значениями.
Методы итераторов (next, for_each, collect, map, filter): методы, связанные с итераторами в Rust, предоставляющие набор инструментов для эффективного манипулирования данными, фильтрации и агрегации. Они
способствуют написанию краткого и выразительного исходного кода.
Функция successors: функция из стандартной библиотеки Rust
(std::iter::successors), расширяющая способности по генерации после-
480
Итераторы и замыкания
довательностей значений. Она создает конкретно-прикладные итераторы путем многократного вызова замыкания для создания элементов.
Очистка итератора drain: процесс очистки, выполняемый итератором
drain по завершении его работы. Он удаляет все оставшиеся элементы
в коллекции, которую он изначально заимствовал, оставляя ее пустой.
Адаптерные методы итераторов (iterator adapter methods): методы
из стандартной библиотеки Rust, которые позволяют разработчикам
преобразовывать и изменять итераторы с целью эффективной обработки данных.
Ленивые вычисления (lazy evaluation): функциональность итераторов
Rust, при которой значения генерируются на лету по запросу, обеспечивая эффективное использование памяти и наличие бесконечных последовательностей.
Адаптер flat_map: адаптер итератора в Rust, используемый для управления вложенными итераторами и работы с коллекциями коллекций путем разглаживания результатов в единую последовательность.
Адаптер filter_map: мощный адаптер итератора в Rust, который комбинирует фильтрацию и преобразование (отображение) в один шаг, позволяя точно управлять итератором, преобразовывая или отбрасывая
элементы на основе конкретно-прикладной функции.
Fn, FnMut, FnOnce: общие черты в Rust, которые классифицируют замыкания на основе их способности захватывать переменные: немутируемо, мутируемо или путем потребления (передачи владения).
Глава 11
Модульное тестирование
на Rust
Введение
Тестирование программного обеспечения играет ключевую роль в сложном ландшафте жизненного цикла разработки, выступая критически важным компонентом поддержания целостности и функциональности программного обеспечения.
Будучи комплексным процессом, оно призвано гарантировать, что созданный
продукт работает согласно ожиданиям, соответствует заданным требованиям
и выдерживает вызовы постоянно меняющихся технологий. В экосистеме Rust
приверженность качеству напрямую отражена в наличии мощного фреймворка,
который позволяет разработчикам эффективно верифицировать правильность
своего исходного кода. Эта глава открывает всестороннее обследование темы модульного тестирования на Rust, основной фокус которого направлен на проверку
отдельных компонентов или модулей кодовой базы, что укрепляет фундамент
для создания надежного и отказоустойчивого программного обеспечения.
Модульное тестирование на Rust – это не просто «хороший тон», а крае
угольный камень в стремлении к разработке программного обеспечения высшего качества. Написание тестов на Rust – это не формальная «галочка» в спис
ке проделанных работ, а важное вложение в долгосрочную жизнеспособность
и поддерживаемость проектов. Принимая дисциплину модульного тестирования на вооружение, мы не только подтверждаем текущую работоспособность
исходного кода, но и создаем защиту от регрессий и побочных эффектов при
эволюции кодовой базы. Данная глава предлагает погружение в «искусство
и науку» модульного тестирования на Rust: от базовых принципов до продвинутых концепций и технических приемов. В ходе этого обследования вы не
только освоите инструментарий Rust для тестирования, но и обретете глубокое понимание того, как надежные образцы практики тестирования способствуют гибкости и устойчивости вашего программного обеспечения.
Содержание главы
В этой главе будут рассмотрены следующие темы:
написание тестовых функций и тестовых модулей на Rust;
исполнение тестов и интерпретация результатов тестирования;
практика разработки через тестирование на Rust.
482
Модульное тестирование на Rust
Модульное тестирование
Прежде чем погрузиться в специфику модульного тестирования на Rust, необходимо понять причину, по которой тестирование играет критически важную роль в разработке программного обеспечения. Тестирование выполняет
несколько ключевых функций, среди которых:
верификация правильности исходного кода;
раннее обнаружение и устранение ошибок и дефектов;
обеспечение «защитной сети» при рефакторизации и внесении изменений;
документирование ожидаемого поведения исходного кода;
стимулирование создания надежного и поддерживаемого программного обеспечения.
В Rust, где принципы конструктивного исполнения сосредоточены на защищенности, тестирование приобретает еще большее значение. Строгая система
типов и принятая в Rust модель владения усердно работают над сокращением
ошибок на этапе исполнения, однако модульные тесты служат мощным инструментом, гарантирующим, что ваш исходный код будет неизменно придерживаться ожидаемого поведения.
Тес
ты
Тестирование (черного/белого) ящика
про
гра
мм
ног
ор
еш
Ин
тег
р
аци
онн
ые
я
Владелец продукта
тес
ты
Тестирование
Сис
тем
н
ые
Мо
дул
ени
тес
ты
Разработка
ьны
е те
сты
Рисунок 11.1. Иерархия методов тестирования «черного» и «белого» ящика
Написание тестовых функций и модулей
В Rust тестирование исходного кода напоминает проверку слаженности работы всех компонентов и того, как они подходят друг к другу. Представьте, что
вы художник и каждая строка кода – это мазок кисти. Rust позволяет создавать
специальные тесты для каждого мазка– своего рода «мини-проверки». Они
гарантируют, что каждая часть кода делает именно то, что ей предназначено.
Написание тестовых функций и модулей 483
Но Rust идет дальше: он позволяет организовывать тесты в группы, именуемые модулями. Модули похожи на главы в книге – они помогают аккуратно структурировать тесты, чтобы иметь возможность проверять разные части
программы по отдельности.
Что действительно круто, так это то, что написание тестов на Rust очень похоже на написание самого исходного кода. Это не какая-то отдельная скучная
задача, а естественная часть процесса создания программного обеспечения.
Благодаря этому ошибки легче отлавливать на ранних стадиях, а исходный код
в итоге работает плавно, как идеально построенная мелодия.
Теперь давайте погрузимся в детали создания тестовых функций на Rust.
Написание простейшей тестовой функции
Тестовая функция в Rust – это, по сути, стандартная функция, помеченная
атрибутом #[test]. Этот атрибут сообщает компилятору Rust о том, что данная
функция является тестом и должна быть включена в процесс тестирования.
Ниже приводится пример тестовой функции:
Листинг 11.1. Простейшая тестовая функция на Rust
#[test]
// ①
fn test_complex_addition() { // ②
let input_1 = 42;
// ③
let input_2 = 58;
// ③
let result = perform_complex_addition(input_1, input_2);
assert_eq!(result, 100, "Неправильный результат сложения.");
}
fn perform_complex_addition(a: i32, b: i32) -> i32 {
a + b
}
// ④
// ⑤
// ⑥
① Атрибут #[test] указывает на то, что последующая функция является тес
том. Данный атрибут используется для пометки функций как тестовых
случаев, чтобы встроенный в Rust фреймворк тестирования распознавал
и исполнял их при выполнении тестов командой cargo test.
② Инструкция fn test_complex_addition() определяет тестовую функцию
с именем test_complex_addition, следуя правилам Rust об именовании
тестов. Она отвечает за проверку функции perform_complex_addition.
③ Следующие строки, включая let input_1 = 42; и let input_2 = 58;,
являются частью фазы подготовки в рамках шаблона ПДС (Подготовка–действие–сверка, от англ. arrange-act-assert, аббр. AAA). В этой фазе
подготавливаются входные данные и любые необходимые условия для
теста. В данном случае переменным input_1 и input_2 присваиваются конкретные значения, которые послужат аргументами для функции
perform_complex_addition.
④ Строка let result = perform_complex_addition(input_1, input_2); относится к фазе действия шаблона ПДС. В этой фазе выполняется непосредственная операция или функция, которую требуется протестировать.
Здесь вызывается функция perform_complex_addition с входными данными из фазы подготовки, а результат сохраняется в переменной result.
484
Модульное тестирование на Rust
⑤ Строка assert_eq!(result, 100, "The addition result is incorrect.");
связана с фазой сверки. На этом этапе результат операции сверяется
с ожидаемым результатом. Макрофункция assert_eq! сверяет переменную result с числом 100. Если сверка не проходит (т. е. результат не равен
100), будет выведено заданное сообщение об ошибке: "Неправильный результат сложения.".
⑥ Функция perform_complex_addition, выполняющая сложение двух целых
чисел типа i32, представляет собой саму логику, тестируемую в фазе действия. Структурированный шаблон ПДС обеспечивает ясность и гарантирует, что тестовые функции организованы и понятны, упрощая выявление и устранение проблем в тестируемом исходном коде1.
Тестовая функция test_complex_addition подчиняется шаблону «Подготовка–действие–сверка» (ПДС). Она подготавливает входные данные, выполняет
операцию сложения посредством функции perform_complex_addition и сверяет, убеждаясь, что результат совпадает с ожидаемым исходом, с помощью
макрофункции assert_eq!. Такой структурированный подход помогает убеждаться в правильности работы исходного кода и вовремя обнаруживать потенциальные ошибки или регрессии.
Тестовые модули
Организация тестовых функций в тестовые модули – это полезная практика, позволяющая поддерживать структуру и управляемость тестов, особенно
по мере роста кодовой базы. Для создания тестового модуля используется
ключевое слово mod и внутри него определяются тесты.
Листинг 11.2. Базовая структура тестового модуля
// Указывает компилятору включать этот модуль только при запуске тестов
#[cfg(test)]
mod mod_tests {
// Позволяет тестам видеть функции из внешнего диапазона доступности
use super::*;
#[test]
fn test_addition() {
// Подготовка
let input_1 = 42;
let input_2 = 58;
// Действие
let result = perform_addition(input_1, input_2);
}
1
// Сверка
assert_eq!(result, 100, "Неправильный результат сложения.");
Принципы, практика и шаблоны модульного тестирования, Khorikov, V. (дата не указана). Unit testing principles, practices, and patterns. O’Reilly Online Learning. https://
www.oreilly.com/library/view/unit-testing-principles/9781617296277/.
Выполнение тестов 485
// В данном примере функция находится внутри модуля,
// но чаще она выносится за его пределы.
fn perform_addition(a: i32, b: i32) -> i32 {
a + b
}
}
Приведенный выше фрагмент исходного кода вводит понятие тестовых модулей Rust и демонстрирует, как их можно использовать для поддержания организованности и управляемости комплектов тестов. В данном примере определен
тестовый модуль с именем mod_tests, который содержит в себе тестовую функцию
test_addition. Такая структура соответствует лучшим образцам практики модульного тестирования на Rust и принципу модульной организации исходного кода.
Цель создания тестового модуля, такого как mod_tests, состоит в группировке соотнесенных функций и их упорядочивании по конкретным областям или
функциональностям тестируемого исходного кода. Такая модульность особенно ценна по мере роста проекта и увеличения количества тестов. Разнося тесты
по разным модулям, сохраняется ясность и удобство навигации в кодовой базе.
Внутри модуля mod_tests находится функция test_addition, структура которой повторяет предыдущий пример. Эта функция подчиняется шаблону «Подготовка–действие–сверка» (ПДС), где фаза подготовки включает подготовку
входных данных, фаза действия выполняет тестируемую операцию, а фаза
сверки проверяет результат. Такой структурированный подход жизненно важен для понимания и технического сопровождения исходного кода, так как
он четко определяет цель и компоненты каждого теста.
Практика создания тестовых модулей на Rust наподобие mod_tests способствует организации исходного кода и логической группировке тестов. Она
не только помогает поддерживать качество тестирования, но и упрощает выполнение конкретных групп тестов при необходимости. По мере эволюции
проекта и добавления новых функциональностей такой модульный подход
станет незаменимым для эффективного управления тестовой средой.
Тесты и результаты тестирования
Создание тестов знаменует начальную фазу процесса разработки и служит
лишь стартовой точкой на пути к обеспечению надежности исходного кода.
Помимо написания тестов, важнейшим шагом является их выполнение и последующее глубокое понимание полученных результатов. Rust предоставляет
встроенные инструменты, которые упрощают выполнение тестов и получение
бесценного отклика. Именно через тщательный анализ результатов можно выявлять потенциальные проблемы, подтверждать функциональность исходного
кода и повышать совокупную надежность программного обеспечения.
Выполнение тестов
Rust максимально упрощает процесс выполнения тестов. Вашей «дежурной»
командой является команда cargo test, которая исполняет все тесты внутри
проекта. Для выполнения тестов перейдите в директорию проекта и исполните эту команду:
486
Модульное тестирование на Rust
$ cargo test
При исполнении команды пакетный менеджер Cargo выявляет тесты и исполняет их, после чего собирает результаты и представляет сводку, указывая
на то, какие тесты прошли успешно, а какие – нет.
Когда при программировании на Rust возникает необходимость в выполнении тестов, команда cargo test предлагает несколько опций и функциональностей по управлению исполнением тестов. Один из распространенных сценариев состоит в выполнении тестов путем указания имен. Для этого используется следующая ниже синтаксическая конструкция:
$ cargo test имя_тестовой_функции
Указывая имя теста, вы сообщаете команде cargo test о необходимости отфильтровать и исполнить только те тесты, имена которых содержат указанное
имя_тестовой_функции. Это полезно, когда нужно сосредоточиваться на конкретных тестах внутри кодовой базы.
В случаях, когда указанное имя_тестовой_функции совпадает с несколькими
тестами, команда cargo test исполнит их все. Однако бывают ситуации, когда требуется исполнить только один конкретный тест, и для достижения этой
цели в качестве аргумента добавляется флаг --exact:
$ cargo test имя_тестовой_функции -- --exact
Флаг --exact гарантирует, что команда cargo test выполнит только тот тест,
имя которого полностью совпадает с указанным именем_тестовой_функции, помогая избегать непреднамеренные многочисленные исполнения тестов. Если
же тестовые функции находятся внутри модулей, то необходимо указывать
полный путь внутри пространства имен до теста, который требуется выполнить. Это делается следующей ниже командой:
$ cargo test имя_тестового_модуля::имя_тестовой_функции -- --exact
Благодаря этой конструкции команда cargo test гарантирует выполнение
указанной тестовой функции внутри указанного тестового модуля, обеспечивая
необходимую гранулярность при работе с организованным исходным кодом.
В сложных проектах с несколькими пакетами нередко возникает потребность в нацеливании тестов в конкретном пакете. Это достигается с помощью
флага --package в комбинации с указанием имени теста, как показано ниже:
$ cargo test --package имя_пакета имя_тестовой_функции -- --exact
Эта команда выполнит тесты с именем имя_тестовой_функции в пакете, указанном через имя_пакета, гарантируя, что тесты будут исполнены внутри нужного пакета.
При работе с проектами, содержащими несколько пакетов и различные модули, можно столкнуться с тестами, имеющими одинаковые имена в разных местах.
В целях решения этой проблемы используется флаг --package и предоставляется
полный путь к тесту, чтобы обеспечить нацеленность на нужный пакет и модуль:
$ cargo test --package имя_пакета имя_тестового_модуля::имя_тестовой_функции -- --exact
Предоставляемые командой cargo test способности и параметры позволяют разработчику тонко настраивать условия исполнения тестов. Эта функцио
Интерпретация результатов тестирования 487
нальность доказывает свою ценность, давая возможность сосредоточиваться
на конкретных сегментах кодовой базы, обеспечивая нацеленный и эффективный процесс тестирования. Это становится особенно критичным в сценариях,
в которых несколько тестов имеют похожие имена: система гарантирует отбор
и исполнение именно тех тестов, которые имелись в виду. Способность настраивать процесс тестирования с такой гранулярностью оказывается особенно выгодной при работе с крупными проектами, характеризующимися сложными
подходами к тестированию исходного кода. Эти функциональные возможности
не только оптимизируют рабочий процесс, но и повышают надежность и точность результатов тестирования в сложных программных проектах.
Приложение
Пакет 1
Модуль 1
Модуль 2
Модуль n
Модуль 2
Модуль n
Модуль 2
Модуль n
Пакет 2
Модуль 1
Пакет 3
Модуль 1
Рисунок 11.2. Иерархия пакетов, модулей, модульных тестов
Интерпретация результатов тестирования
После выполнения тестов создается подробный отчет, предоставляющий исчерпывающую сводку результатов. Этот отчет предложит тщательный анализ,
определяя успешно пройденные тесты, тесты, в которых возникли отказы,
а также любую дополнительную информацию, необходимую для более глу-
488 Модульное тестирование на Rust
бокого обследования. Такая детализация результатов тестов призвана давать
четкое понимание общей производительности и функциональности, позволяя
выявлять как успешные области, так и те, которые могут требовать дальнейшего внимания или анализа. Вот описание того, что можно встретить в результатах тестирования:
Тест пройден (test passed): когда тест успешно удовлетворяет сверке,
он помечается как «пройден». Эта метка указывает на то, что инспектируемый исходный код ведет себя ожидаемым образом.
Тест провален (test failed): тест помечается как «провален», когда сверка внутри тестовой функции не оказывается истинной. Проваленный тест
свидетельствует о наличии потенциальной проблемы или регрессии в исходном коде.
Тест пропущен (test skipped): тесты можно пропускать, используя
атрибут #[ignore]. Эти тесты не исполняются по умолчанию. Пропуск
тестов бывает полезен во время разработки, когда нужно сосредоточиваться на конкретных областях кодовой базы.
Результат теста (test output): тестовые функции могут генерировать дополнительный вывод с помощью макрофункции println! или общей черты
Rust std::io::Write. Отчет о тестировании будет включать этот результат,
что помогает в отладке и обеспечивает контекст для проваленных тестов.
Информация о тестах: отчет о тестировании включает дополнительную информацию о количестве исполненных тестов, затраченном времени и других данных, относящихся к делу.
Ниже приводится пример упрощенного отчета о тестировании в целях иллюстрации упомянутых выше разных элементов.
running 4 tests
test test_addition ... ok
test test_multiplication ... ok
test test_subtraction ... ok
test test_division ... FAILED
failures:
---- test_division stdout ---thread ‘test_division’ panicked at src/main.rs:37:5:
assertion `left == right` failed
left: 4
right: 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
test_division
test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished
in 0.00s
error: test failed, to rerun pass `--bin file`
Согласно данному фрагменту результата, было исполнено в общей сложности четыре теста, три из которых прошли успешно, что отмечено статусом "ok".
Сверки 489
Однако один конкретный тест под названием test_division не удовлетворил
критериям успеха и, следовательно, завершился провалом (отказом). Прилагаемый отчет предлагает более детальный разбор проваленного теста, предоставляя такую информацию, как точное местоположение отказа инструкции сверки. Кроме того, отчет содержит рекомендацию перезапустить этот конкретный
тест, что облегчает выявление и устранение первопричины проблемы.
Такая исчерпывающая отчетность о тестировании позволяет быстро обнаруживать ошибки, понимать их первопричины и работать над их исправлением. Регулярное тестирование и анализ играют критически важную роль в поддержании качества исходного кода.
Сверки
Сверки (или подтверждения истинности или правильности) – это ключевые
элементы, которые делают модульное тестирование эффективным. Стандартная библиотека Rust предоставляет целый ряд макрофункций для создания
сверок, каждый из которых подходит для определенных сценариев. Ниже перечислено несколько наиболее часто используемых макрофункций:
assert!(condition): эта макрофункция проверяет истинность переданного условия condition. Если условие является ложным, то тест считается
проваленным.
assert_eq!(left, right): эта макрофункция проверяет значения left
и right на равенство. Если они оказываются неравными, то тест считается проваленным.
assert_ne!(left, right): эта макрофункция проверяет значения left
и right на неравенство. Если они оказываются равными, то тест проваливается.
assert!(expression,
"message"):
аналогично
макрофункции
assert!(condition), этот вариант позволяет добавить необязательное
конкретно-прикладное сообщение об ошибке "message", чтобы уточнять
причину провала сверки.
assert_eq!(left, right, "message"): эта макрофункция позволяет добавлять конкретно-прикладное сообщение об ошибке "message" вместе
с проверкой на равенство.
assert_ne!(left, right, "message"): аналогично макрофункции assert_
eq!, этот вариант добавляет сообщение об ошибке "message" к проверке
на неравенство.
Используя эти макрофункции, можно проверять соблюдение самых разных
условий: соответствует ли конкретное значение ожидаемому результату, правильно ли обрабатывается ошибка или возвращает ли функция нужные данные.
Ниже приводится пример того, как эти макрофункции применяются в тес
товых функциях:
Листинг 11.3. Различные макрофункции сверки в тестах
#[test]
fn test_assertions() {
// ①
// ②
490
}
Модульное тестирование на Rust
assert_eq!(add(2, 2), 4);
assert_ne!(subtract(5, 2), 5);
assert!(is_even(4));
assert!(!is_odd(6));
fn add(a: i32, b: i32) -> i32 {
a + b
}
//
//
//
//
③
④
⑤
⑥
// ⑦
fn subtract(a: i32, b: i32) -> i32 { // ⑧
a - b
}
fn is_even(n: i32) -> bool {
n % 2 == 0
}
// ⑨
fn is_odd(n: i32) -> bool {
n % 2 != 0
}
// ⑩
Ниже представлен разбор приведенного выше исходного кода, иллюстрирующего применение макрофункций сверки в тестовых функциях:
① #[test]: этот атрибут помечает следующую за ним функцию test_
assertions как тестовую, указывая на то, что она должна быть исполнена
в составе комплекта тестов при выполнении команды cargo test;
② fn test_assertions(): эта строка кода определяет тестовую функцию
с именем test_assertions. Внутри нее используются различные сверочные макрофункции для подтверждения истинности разных условий;
③ assert_eq!(add(2, 2), 4);: здесь применяется макрофункция assert_eq!,
которая проверяет равенство результата функции add, вызванной с аргументами 2 и 2, числу 4. Если проверка на равенство не пройдет, то это
будет указывать на провал теста;
④ assert_ne!(subtract(5, 2), 5);: здесь применяется макрофункция
assert_ne!, которая проверяет неравенство результата функции subtract
с аргументами 5 и 2 числу 5. Если значения окажутся равны, то будет сообщено о провале;
⑤ assert!(is_even(4));: в этой строке кода используется макрофункция assert!, чтобы подтвердить, что функция is_even(4) возвращает
true. Эта макрофункция проверяет истинность выражения и сообщает
об ошибке, если результатом его вычисления является false;
⑥ assert!(!is_odd(6));: в этой строке кода используется макрофункция
assert!, чтобы подтвердить, что функция is_odd(6) возвращает false.
Оператор ! используется для отрицания условия. Если результат не будет
ложным, то тест провалится;
⑦ fn add(a: i32, b: i32) -> i32 { a + b }: в исходном коде определяется функция add, которая принимает два целых числа a и b и возвращает
их сумму;
⑧ fn subtract(a: i32, b: i32) -> i32 { a - b }: определяется функция
subtract, которая принимает два целых числа и возвращает их разность;
Выполнение тестов с опциями 491
⑨ fn is_even(n: i32) -> bool { n % 2 == 0 }: функция is_even принимает
число n и проверяет его на четность с помощью выражения n % 2 == 0,
возвращая булев результат;
⑩ fn is_odd(n: i32) -> bool { n % 2 != 0 }: функция is_odd принимает
число n и проверяет его на нечетность с помощью выражения n % 2 != 0,
возвращая булев результат.
Приведенный выше пример демонстрирует тестовую функцию test_
assertions, в которой применены различные макрофункции для подтверждения истинности условий. Эти сверки необходимы для валидации поведения
функций исходного кода во время тестирования. Если любая из этих сверок
не пройдет, то соответствующий тест будет помечен как проваленный.
Применение этих макрофункций сверки помогает обеспечивать правильность исходного кода путем сравнения реальных результатов с ожидаемыми,
а также вносит ясность в то, какое значение ваш тест вычисляет.
Конкретно-прикладные сообщения об ошибках
Использование макрофункций сверки в Rust дает значительное преимущество
в виде конкретно-прикладных сообщений об ошибках, которые оказываются
бесценными для понимания причин провала тестов. Такие адаптированные
сообщения предоставляют важную контекстную информацию, помогая разработчику точно определять первопричину отказа. Наглядный пример приведен в следующей ниже тестовой функции, где строка assert_eq!(add(2, 2),
4) не только проверяет результат на равенство, но и дополнена описательным
сообщением об ошибке:
Листинг 11.4. Сверка с использованием сообщения об ошибке
assert_eq!(add(2, 2), 4, "Неправильный результат сложения.");
Когда эта инструкция сверки не проходит, в отчет о тестировании включается сообщение об ошибке, что помогает в отладке и локализации проблемы.
Конкретно-прикладные сообщения об ошибках особенно полезны в случаях
со сложными или неочевидными сверками, требующими детальных пояснений.
Выполнение тестов с опциями
Встроенный в Rust фреймворк тестирования предоставляет различные опции
для конфигурирования и индивидуальной настройки процесса тестирования.
Эти опции позволяют выполнять доводку тестирования под свои нужды. Ниже
перечислены основные опции для выполнения тестов в Rust:
--test-threads: с помощью опции --test-threads можно указывать количество потоков исполнения тестов. Это позволяет контролировать уровень
параллелизма при выполнении тестов. Например, если нужно выполнить
тесты с одним потоком, то используется команда:
$ cargo test -- --test-threads=1
492
Модульное тестирование на Rust
Как вариант количество потоков можно закрепить в файле Cargo.toml
в секции [profile.test]:
[profile.test]
nthreads = 1
Это полезно при тестировании исходного кода, в котором могут возникать проблемы с потокозащищенностью;
--release: по умолчанию тесты выполняются в режиме отладки. Для
их исполнения в режиме релиза (что помогает выявлять ошибки, связанные с оптимизацией) используется флаг --release:
$ cargo test --release
-- --ignored: тесты, помеченные атрибутом #[ignore], по умолчанию
не исполняются. Если требуется выполнить только проигнорированные
тесты, то используется флаг -- --ignored:
$ cargo test -- --ignored
--quiet и --nocapture: опция --quiet скрывает подробности, делая результат тестов менее перегруженным. Напротив, опция --nocapture позволяет видеть стандартный результат, производимый самими тестами,
что крайне полезно для отладки и понимания контекста отказов.
$ cargo test -- --quiet
$ cargo test -- --nocapture
--color: позволяет указывать, как использовать цветной вывод. Доступные значения: auto, always или never. По умолчанию Cargo сам определяет способность терминала поддерживать цвета.
$ cargo test -- --color=always
Перечисленные опции обеспечивают гибкость при выполнении тестов, позволяя адаптировать процесс тестирования под специфические требования
и нужды отладки.
Фикстуры и исходный код подготовки
Исполнение модульных тестов часто нуждается в тщательно определенной и единообразной среде. Бывают случаи, когда крайне важно устанавливать определенные условия или выделять ресурсы до начала теста. Более того, может возникать
необходимость в использовании одной и той же фикстуры (или предварительной
настройки) в многочисленных тестах с целью обеспечения единообразия и связности сценариев тестирования. Для этого создают функцию-фикстуру, предназначенную для генерации необходимого значения. Впоследствии на эту функцию-фикстуру можно бесшовно ссылаться и применять ее в различных тестах, тем
самым оптимизируя процесс и способствуя единообразию во фреймворке тес
тирования. Такой подход не только повышает эффективность модульного тестирования, но и облегчает поддержание стандартизированной среды тестирования.
Ниже приводится пример коллективного использования фикстуры в нескольких тестах.
Фикстуры и исходный код подготовки 493
Листинг 11.5. Простейшие фикстуры и исходный код подготовки ресурсов
use std::collections::HashMap; // ①
fn create_fixture() -> HashMap<String, i32> {
let mut fixture = HashMap::new();
fixture.insert("foo".to_string(), 42);
fixture.insert("bar".to_string(), 37);
fixture
} // ②
#[test]
fn test_contains_foo() {
let fixture = create_fixture();
assert!(fixture.contains_key("foo")); // ④
} // ③
#[test]
fn test_contains_bar() {
let fixture = create_fixture();
assert!(fixture.contains_key("bar")); // ④
} // ③
#[test]
fn test_contains_baz() {
let fixture = create_fixture();
assert!(!fixture.contains_key("baz")); // ⑤
} // ③
Приведенный выше фрагмент исходного кода иллюстрирует распространенный шаблон тестирования с использованием фикстуры, представленной в виде хеш-словаря (типа HashMap) с заранее определенными парами
ключ–значение, которая коллективно используется в нескольких тестовых
функциях:
① исходный код начинается с инструкции импорта, которая вводит тип
HashMap из стандартной библиотеки в диапазон доступности для дальнейшего использования;
② функция create_fixture определена для конструирования и возврата экземпляра типа HashMap<String, i32>. Внутри нее создается пустой экземпляр типа HashMap с именем fixture, в который последовательно вставляются две пары: ("foo", 42) и ("bar", 37). Функция завершается возвратом заполненной фикстуры. Такое конструктивное решение гарантирует
единообразное начальное состояние для последующих тестов;
③ далее реализованы три тестовые функции: test_contains_foo, test_
contains_bar и test_contains_baz. Каждый тест создает фикстуру с помощью функции create_fixture, обеспечивая общее начальное состояние.
Этот подход способствует модульности исходного кода, сокращает избыточность и облегчает изоляцию конкретных тестовых случаев;
④ первые два теста, test_contains_foo и test_contains_bar, проверяют наличие ключей "foo" и "bar" в созданной фикстуре. Эти тесты подтверждают, что начальное состояние содержит ожидаемые элементы, выступая
в роли положительных тестов на существование ключа;
494
Модульное тестирование на Rust
⑤ третий тест проверяет, что фикстура не содержит ключ "baz". Этот отрицательный тест гарантирует отсутствие конкретного ключа, демонстрируя гибкость подхода при обработке разных сценариев.
Структурированный подход к тестированию с использованием общей фикстуры повышает поддерживаемость и связность исходного кода, обеспечивая
надежную основу для проверки правильности различных аспектов функциональности программы в нескольких тестовых случаях. Определяя отдельную
функцию-фикстуру, вы гарантируете, что ее настройка будет единообразной
во всех тестах, которые ее используют, что способствует изоляции тестов и созданию контролируемой среды.
Хотя в самом Rust нет нативно встроенной поддержки фикстур, этого можно
добиться с помощью пакета rstest, который позволяет легко создавать фикстуры и параметризованные тесты.
Для создания фикстур и подготовки параметризованных тестов необходимо
выполнить следующие ниже шаги:
добавить пакет rstest в список зависимостей проекта в файле Cargo.toml:
[dependencies]
rstest = "0.5"
определить параметризованные тестовые функции, используя атрибут
#[rstest], и конкретизировать входные параметры;
использовать аргумент fixture внутри тестовой функции для доступа
к данным.
Ниже приводится пример, иллюстрирующий подготовку фикстур и создание параметризованных тестов с использованием пакета rstest.
Листинг 11.6. Простейший пример модульного
теста с использованием пакета rstest
use rstest::{rstest, fixture}; // ①
use std::fs;
use tempfile::TempDir;
// ①
#[fixture]
fn temp_dir() -> TempDir {
// ②
// Здесь расположен исходный код создания и возврата TempDir
TempDir::new().expect("Не удалось создать временную директорию")
}
#[rstest]
fn test_with_temp_dir(temp_dir: TempDir) { // ③
// Внутри этого теста можно использовать фикстуру temp_dir.
let file_path = temp_dir.path().join("example.txt"); // ④
fs::write(&file_path, "Тестовые данные").expect("Не удалось записать в файл"); // ④
}
assert!(file_path.exists());
// ⑤
assert_eq!(
fs::read_to_string(&file_path).expect("Не удалось прочитать из файла"),
"Тестовые данные"
); // ⑤
Фикстуры и исходный код подготовки 495
В приведенном выше примере:
① исходный код начинается с импорта необходимых пакетов и модулей:
rstest для параметризованного тестирования, fixture для объявления
фикстур, а также TempDir из пакета tempfile для управления временными
директориями;
② фикстура temp_dir объявляется с помощью атрибута #[fixture]. Эта функция создает и возвращает экземпляр структуры TempDir, представляющий временную директорию. Для создания директории в реализации
используется TempDir::new(); в случае ошибки программа завершается
паникой и диагностическим сообщением;
③ функция test_with_temp_dir декорирована атрибутом #[rstest], что указывает на то, что это параметризованный тест. Тестовая функция принимает параметр temp_dir типа TempDir, который автоматически предоставляется фреймворком тестирования;
④ внутри тестовой функции путь к файлу формируется внутри временной директории с помощью инструкции temp_dir.path().join("example.
txt"). Далее тест пишет строковый литерал "Тестовые данные" в файл, обрабатывая возможные ошибки операции записи методом expect;
⑤ затем следуют две инструкции сверки: первая подтверждает существование пути к файлу (assert!(file_path.exists())), гарантируя, что файл был
успешно создан. Вторая сверка подтверждает, что содержимое файла соответствует ожидаемому значению "Тестовые данные" (assert_eq!(fs::read_
to_string(&file_path).expect("Не удалось прочитать из файла"), Тестовые
данные")).
Приведенный выше исходный код демонстрирует использование пакета
rstest для создания параметризованного теста (test_with_temp_dir) с помощью фикстуры (temp_dir). Фикстура предоставляет экземпляр типа TempDir для
каждого тестового случая, обеспечивая единообразную работу с временными
директориями. Такой подход улучшает организацию и удобочитаемость исходного кода, особенно при работе со сложными тестовыми средами, требующими настройки и очистки.
Исполнение модульных тестов часто нуждается в четко определенной и единообразной среде, и иногда необходимо подготавливать специфические условия или ресурсы перед исполнением теста. Хотя используемый в Rust стандартный фреймворк тестирования не предоставляет встроенных атрибутов
или механизмов подготовки и последующей очистки ресурсов, можно структурировать свои тесты вручную с соответствующими функциями подготовки
и очистки, чтобы достичь желаемого результата. Давайте рассмотрим пример.
Листинг 11.7. Пример конкретно-прикладной
подготовки и очистки ресурсов в тестах
fn setup() -> String { // ①
let resource = "Некий ресурс".to_string(); // ②
println!("Подготовка: {}", resource);
resource
// ③
}
496 Модульное тестирование на Rust
fn teardown(resource: String) { // ④
println!("Очистка ресурса: {}", resource); // ⑤
}
#[test]
fn test_with_setup_and_teardown() { // ⑥
let resource = setup(); // ⑦
let result = format!("Тест с использованием: {}", resource); // ⑧
assert_eq!(result, "Тест с использованием: Некий ресурс");
// ⑨
teardown(resource);
// ⑩
}
// Вывод:
// $ cargo test -- --show-output
// running 1 test
// test test_with_setup_and_teardown ... ok
// successes:
// ---- test_with_setup_and_teardown stdout ---// Подготовка: Некий ресурс
// Очистка ресурса: Некий ресурс
// successes:
// test_with_setup_and_teardown
// test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.00s
В приведенном выше показательном исходном коде рассмотрен процесс
ручной реализации механизма подготовки и очистки ресурсов внутри тестов
Rust. Этот механизм особенно ценен в ситуациях, когда нужно сформировать
специфические предусловия для тестов, управлять ресурсами или проводить
уборку по завершении тестов.
В исходном коде мы определяем ряд функций, которые в совокупности позволяют создать контролируемую среду тестирования и провести необходимую очистку. Процедура создания таких тестов включает в себя определение
функций подготовки и очистки ресурсов, создание тестовых функций и их
стратегический вызов.
Давайте разберем различные компоненты исходного кода, начиная с функции подготовки:
① первый шаг включает создание функции с именем setup. Эта функция
играет ключевую роль в инициализации ресурса, который является неотъемлемой частью сценария тестирования;
② внутри функции setup мы инициируем сам процесс подготовки. Мы создаем переменную типа String с именем resource, заполняя ее значением
"Некий ресурс". Этот ресурс лежит в основе теста и представляет собой
сущность, которой мы хотим манипулировать;
③ для поддержания ясности и понимания процесса подготовки мы включаем инструкцию println! внутри функции setup. Она выводит сообщение
о начале фазы подготовки вместе со значением переменной resource. За-
Фикстуры и исходный код подготовки 497
тем мы возвращаем значение переменной resource, гарантируя доступность ресурса для тестовых функций.
Теперь перейдем к функции teardown, которая играет важную роль в аспекте
очистки нашего механизма тестирования:
④ функция teardown вводится для обработки послетестовых операций,
в частности для очистки ресурсов. Она определена с одним параметром –
строковой переменной resource, которая обозначает ресурс, предназначенный для удаления;
⑤ внутри функции teardown мы также используем println!. Эта инструкция сигнализирует о начале процесса очистки, сообщая о намерении высвободить ресурс и указывая его значение.
Далее представлена тестовая функция test_with_setup_and_teardown, демонстрирующая интеграцию этих механизмов:
⑥ тестовая функция test_with_setup_and_teardown тщательно прописана
для демонстрации практического применения механизмов подготовки
и очистки ресурсов;
⑦ внутри теста мы запускаем процесс подготовки, вызывая функцию setup.
Это действие имеет жизненную важность для создания необходимых условий тестирования и приобретения ресурса, который будет играть ключевую роль в тесте;
⑧ затем мы приступаем к логике теста, генерируя переменную result, которая несет в себе ресурс. Этот шаг критически важен для проверки взаи
модействия с ресурсом во время теста;
⑨ в целях подтверждения валидности теста мы используем макрофункцию
assert_eq!, сравнивая результат с ожидаемым значением "Тест с использованием: Некий ресурс". Это сравнение составляет ядро теста, подтверждая, что логика работает с ресурсом так, как и предполагалось;
⑩ наконец, после исполнения логики тестовой функции мы инициируем
фазу очистки, вызывая функцию teardown. Передавая resource в качестве
параметра, мы гарантируем последовательное выполнение операции
очистки ресурса. Это необходимо для поддержания управляемой и предсказуемой среды тестирования, в которой ресурсы инициализируются
и очищаются структурированным образом.
Данный исходный код служит примером ручной реализации механизма
подготовки и очистки ресурсов в тестах на Rust. Создавая функции подготовки
и очистки и продуманно вызывая их внутри тестовой функции, можно систематически подготавливать контролируемую среду тестирования, проводить
специфические операции и гарантировать стабильное выполнение очистки.
Такой подход обеспечивает уровень предсказуемости и структурированности,
который особенно ценен в сложных сценариях тестирования, связанных с ресурсами и особыми условиями подготовки.
Тестовые фикстуры – это мощный инструмент коллективного использования общего исходного кода подготовки в нескольких тестах, гарантирующий
их изолированность и предсказуемость. Определяя функции-фикстуры и помечая их надлежащими атрибутами, можно создавать чистые и хорошо организованные комплекты тестов.
498
Модульное тестирование на Rust
Подготовка/Очистка
Очистка ресурсов
Модульный тест 1
Подготовка условий
Модульный тест 1
Очистка ресурсов
Подготовка условий
Рисунок 11.3. Иллюстрация тестов с подготовкой и очисткой (слева направо)
Условные тесты
Иногда возникает необходимость в выборочном выполнении тестов, основываясь на определенных критериях. Встроенный в Rust фреймворк тестирования позволяет делать это с помощью атрибута #[cfg(test)] и условной компиляции.
Ниже приводится пример условного тестирования на Rust:
Листинг 11.8. Пример условного тестирования на Rust
#[cfg(test)] // ①
mod tests { // ②
#[test]
fn test_feature_enabled() { // ③
// Исходный код теста для случая, когда функциональность активирована
assert_eq!(1 + 1, 2);
}
#[test]
fn test_feature_disabled() { // ④
// Исходный код теста для случая, когда функциональность деактивирована
assert_eq!(2 * 3, 6);
}
}
#[cfg(not(test))] // ⑤
fn main() {
println!("Этот исходный код не включен в
}
тестовые сборки.");
В приведенном выше примере:
① исходный код обернут в атрибут #[cfg(test)], который указывает на то,
что содержащийся внутри модуль будет скомпилирован и исполнен только
Условные тесты 499
в том случае, если в команду cargo передан флаг --test. Это позволяет
реализовывать условную компиляцию тестов;
② внутри модуля tests определены две тестовые функции: test_feature_
enabled и test_feature_disabled;
③ функция test_feature_enabled содержит исходный код теста, который
актуален при активированной функциональности (или характеристике).
В данном случае она выполняет простую сверку (assert_eq!(1 + 1, 2)),
чтобы подтвердить, что сумма 1 и 1 действительно равна 2;
④ напротив, функция test_feature_disabled содержит исходный код для
сценария, когда функциональность (характеристика) отключена. Она
содержит сверку (assert_eq!(2 * 3, 6)), проверяющую, что произведение 2 и 3 равно 6;
⑤ функция main обернута в атрибут #[cfg(not(test))], поэтому она не будет включена в тестовую сборку.
Приведенный выше исходный код демонстрирует использование условной
компиляции в тестах. Модуль tests будет скомпилирован и выполнен, только
когда команде cargo будет предоставлен флаг --test, что позволяет проверять
различные сценарии в зависимости от наличия или отсутствия определенных
функциональностей. Этот подход полезен для написания тестов, которые выбираются на основе конфигурации или флагов функциональностей, обеспечивая гибкость при проверке различных путей исполнения исходного кода.
При наличии флага функциональности my_feature, который определяет,
должны тесты выполняться или нет, условную компиляцию можно использовать следующим образом.
Листинг 11.9. Пример условного тестирования
с использованием флагов функциональностей на Rust
#[cfg(all(test, feature = "my_feature"))]
mod tests {
#[test]
fn test_feature_enabled() {
// Исходный код теста для случая, когда функциональность активирована
assert_eq!(1 + 1, 2);
}
#[test]
fn test_feature_disabled() {
// Исходный код теста для случая, когда функциональность деактивирована
assert_eq!(2 * 3, 6);
}
}
Для того чтобы протестировать функции test_feature_enabled и test_
feature_disabled при условии feature = "my_feature", необходимо активировать эту функциональность во время сборки тестов. Ниже представлены шаги
для достижения этой цели.
Проверить, чтобы файл Cargo.toml содержал необходимую конфигурацию для этой функциональности:
500
Модульное тестирование на Rust
[features]
my_feature = []
Это указывает на то, что функциональность под названием my_feature определена. Для выполнения тестов с активированной функциональностью используется следующая ниже команда:
$ cargo test --features my_feature
Эта команда сообщает Cargo о том, что нужно включить функциональность
my_feature в тестовую сборку. Если требуется выполнить конкретный тест,
то можно указать имя тестовой функции:
$ cargo test --features my_feature test_feature_enabled
Функциональность с именем test_feature_enabled можно заменить на имя
конкретного теста, который вы хотите выполнить.
Применяя этот метод, вы гарантируете, что тесты внутри модуля tests будут скомпилированы и исполнены только при активации функциональности
my_feature. Эта стратегия придает гибкость по части избирательной инициации тестов в зависимости от наличия или отсутствия определенных функцио
нальностей в кодовой базе. Внедряя такую систему условного тестирования,
вы получаете ценный механизм управления исполнением тестов в соответствии с выбранными функциональностями, что способствует более точному и эффективному процессу тестирования. Такой избирательный подход
гарантирует, что комплект тестов тесно связан с конфигурацией и набором
функциональностей исходного кода, тем самым повышая совокупную точность и актуальность результатов тестирования.
Асинхронное тестирование
Rust также поддерживает асинхронное тестирование для работы с исходным
кодом, включающим асинхронные операции. При написании асинхронных
тестов на Rust имеется возможность использовать пакеты tokio или async-std,
каждый из которых предоставляет специализированную среду исполнения.
Эти пакеты служат ценными инструментами, обеспечивающими эффективное
тестирование исходного кода, опирающегося на асинхронные функциональности. Используя эти фреймворки, можно всесторонне оценивать производительность и надежность асинхронного кода, повышая общую отказоустойчивость приложений. Эта способность подчеркивает стремление Rust предоставлять универсальную и надежную экосистему, поддерживающую разные
парадигмы программирования, включая все более востребованную модель
асинхронного программирования.
Ниже приводится пример написания асинхронного теста с использованием
пакета tokio:
[dev-dependencies]
tokio = { version = "1.34.0", features = ["full"] }
Асинхронное тестирование 501
Листинг 11.10. Пример асинхронного тестирования
с использованием пакета tokio
#[tokio::test]
// ①
async fn test_async_function() {
// ②
// Здесь выполняются асинхронные операции
let result = async_function().await; // ③
assert_eq!(result, 42);
// ④
}
async fn async_function() -> i32 {
// Симулировать асинхронную операцию (задержка 1 секунда)
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
42
}
В приведенном примере используется пакет tokio для создания асинхронного теста, помеченного атрибутом #[tokio::test], который задает тестовую
функцию как асинхронную. Тест предусматривает исполнение асинхронной
функции async_function и последующую сверку с ожидаемым результатом.
Использование пакета tokio оптимизирует исполнение асинхронных заданий,
обеспечивая надежную основу для эффективных и конкурентных приложений
на Rust. Этот пример демонстрирует практическое применение асинхронного
тестирования с помощью пакета tokio и подчеркивает значимость специализированных инструментов при решении трудностей асинхронного программирования.
Аналогичным образом используется пакет async-std, если предпочтение отдается именно этой среде исполнения:
[dev-dependencies]
async-std = { version = "1.12.0", features = ["attributes"] }
Листинг 11.11. Пример асинхронного тестирования
с использованием пакета async-std
#[async_std::test]
// ①
async fn test_async_function() {
// ②
// Здесь выполняются асинхронные операции
let result = async_function().await; // ③
assert_eq!(result, 42);
}
// Пример тестируемой асинхронной функции
async fn async_function() -> i32 {
// Симулировать асинхронную операцию
async_std::task::sleep(std::time::Duration::from_secs(1)).await; // ④
42
}
Приведенные выше фрагменты исходного кода иллюстрируют реализацию
асинхронных тестов с использованием сред исполнения tokio и async-std.
Асинхронное тестирование необходимо в сценариях, включающих неблокирующий ввод-вывод, конкурентный исходный код и интеграцию конструкций
async/await. Используя универсальность этих сред исполнения, разработчик
502
Модульное тестирование на Rust
может эффективно обрабатывать разные асинхронные функциональности в приложениях на Rust. Данный исходный код подчеркивает значимость
асинхронного тестирования для улавливания нюансов асинхронных рабочих
процессов и оценки поведения исходного кода в условиях параллельного исполнения, что особенно критично для высокопроизводительных приложений.
Кроме того, он демонстрирует бесшовную интеграцию конструкций async/
await, подчеркивая их синергию в создании надежных и масштабируемых
приложений на Rust.
Создание заглушек для зависимостей
В тестировании на Rust изоляция исходного кода с целью точной оценки зачас
тую предусматривает создание заглушек (моков) для внешних зависимостей.
Пакет mockall – это широко используемый инструмент, применяемый для создания заглушек для таких зависимостей, позволяющий точно контролировать
их поведение во время тестов. Это гарантирует, что основное внимание будет
сосредоточено на оценке логики именно того элемента кода, который тестируется. Например, когда модуль Rust взаимодействует с внешним сервисом,
использование пакета mockall позволяет бесшовно интегрировать заглушки
реализаций, симулируя различные сценарии и ответы без вызова функций
реального внешнего сервиса. Ниже приводится пример использования пакета
mockall для создания заглушек в тестах:
[dev-dependencies]
mockall = "0.8"
Листинг 11.12. Пример создания заглушек
для зависимостей с помощью пакета mockall
use mockall::automock;
#[automock] // ①
pub trait ExternalDependency {
fn do_something(&self) -> i32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_mocked_dependency() {
// Создание заглушки для экземпляра зависимости
let mut dependency = MockExternalDependency::default(); // ②
// Установка ожиданий для метода (что он должен вернуть)
dependency.expect_do_something()
.returning(|| 42); // ③
// Использование заглушки для зависимости в тестируемом коде
let result = code_under_test(&dependency); // ④
Тестирование приватных функций 503
}
// Проверка того, что тестируемый код ведет себя как ожидалось
assert_eq!(result, 42); // ⑤
fn code_under_test(external_dep: &dyn ExternalDependency) -> i32 {
// Вызов метода, принадлежащего зависимости
external_dep.do_something()
}
}
В этом примере общая черта ExternalDependency служит важной абстракцией, представляющей внешние зависимости в программной системе. Использование атрибута #[automock] оптимизирует процесс тестирования, автоматически генерируя заглушки для реализаций указанной общей черты.
Функция test_with_mocked_dependency иллюстрирует этот подход: она создает экземпляр заглушки, устанавливает ожидания относительно ее поведения
и интегрирует ее в тестируемый исходный код. Этот процесс автоматического
создания заглушек позволяет эффективно изолировать исходный код от внешних зависимостей, давая возможность тестам сосредоточиться исключительно
на конкретном поведении исходного кода и обеспечивая более эффективную
и точную стратегию тестирования.
Использование атрибута #[automock] повышает точность тестирования, отделяя тестируемый исходный код от сложностей внешних зависимостей. Такая
изоляция способствует более четкому подходу к тестированию, позволяя концентрироваться на проверке внутренней логики и функциональности исходного кода, не отвлекаясь на сложности внешних систем. Создавая контролируемую среду с помощью заглушек для реализаций, атрибут #[automock] способствует более систематическому и надежному процессу тестирования, что
помогает в разработке отказоустойчивого и поддерживаемого исходного кода.
Тестирование приватных функций
В Rust общепринятой практикой считается тестирование публичного API модулей и структур. Однако в некоторых случаях возникает потребность в тестировании приватных функций – либо с целью лучшего покрытия исходного кода
тестами, либо для проверки правильности деталей реализации. Хотя вызывать
приватные функции извне напрямую нельзя, есть возможность использовать
атрибуты #[cfg(test)] и #[allow(dead_code)], чтобы делать их доступными
для тестирования. Ниже приводится пример тестирования приватной функции на Rust:
Листинг 11.13. Пример тестирования приватной функции на Rust
#[cfg(test)]
mod tests {
// Импортировать приватную функцию из родительского модуля для тестирования
use super::private_function;
// ①
#[test]
fn test_private_function() {
504
}
Модульное тестирование на Rust
}
// Вызвать приватную функцию в целях тестирования
let result = private_function(3); // ②
assert_eq!(result, 6);
// Приватная функция, которую требуется протестировать
#[allow(dead_code)] // ③
fn private_function(x: i32) -> i32 {
x * 2
}
В приведенном выше примере использование функции private_function
подчеркивает осознанное конструктивное решение: эта функция намеренно исключена из публичного API модуля. Для того чтобы сделать возможным
ее тестирование, в том же диапазоне доступности модуля создается тестовый
модуль. Инструкция use super::private_function используется для импорта
целевой приватной функции в тестовый модуль, обеспечивая необходимый
доступ для целей тестирования. Важно отметить, что атрибут #[allow(dead_
code)] служит стратегической директивой для компилятора, предписывая ему
прекращать выдачу предупреждений о неиспользуемом исходном коде. Такой
подход не только позволяет беспрепятственно выполнять тесты приватных
функций, но и защищает от непреднамеренного выставления этих внутренних
компонентов наружу в публичный интерфейс библиотеки. Таким образом, использование тестовых модулей в сочетании с атрибутами представляет собой
продуманную стратегию поддержания четкого разграничения между деталями внутренней реализации модуля и его внешне видимым API.
Однако имейте в виду, что тестировать приватные функции в целом не рекомендуется. Лучше тестировать публичный интерфейс исходного кода, так
как он предоставляет четкий контракт для пользователей вашей библиотеки
или модуля. Тестирование приватных функций может приводить к созданию
«хрупких» тестов, которые ломаются при любой рефакторизации исходного
кода. Если вы обнаружите, что вам постоянно требуется тщательно тестировать приватную функцию, следует подумать, не должна ли она стать частью
публичного API, или переработать исходный код, чтобы сделать его более пригодным для тестирования на публичном уровне.
Эффективное написание тестов на Rust предусматривает комплексный подход, охватывающий несколько ключевых аспектов. Использование фикстур
предоставляет заранее определенные наборы данных для систематической
оценки разных сценариев, а условные тесты позволяют проверять поведение исходного кода в различных условиях и пограничных случаях. Поддержка
асинхронного тестирования в Rust критически важна для оценки исходного
кода, включающего асинхронные операции, что отвечает требованиям современного конкурентного программирования. Создание заглушек для зависимостей позволяет изолировать исходный код для сфокусированного тестирования путем замены внешних компонентов контролируемыми имитациями.
Наконец, возможность тестирования приватных функций обеспечивает тщательную оценку внутренней логики модулей. В целом всестороннее тестирование в среде Rust имеет решающее значение для обеспечения правильности
Замеры производительности 505
и надежности исходного кода, а используемая в Rust экосистема тестирования
предоставляет широкий спектр инструментов, расширяющих возможности
по созданию надежного и поддерживаемого программного обеспечения.
Замеры производительности
Rust дополнительно оснащен интегрированным фреймворком проведения замеров производительности (бенчмарков), который упрощает оценку производительности исходного кода. Это становится возможным благодаря атрибуту
#[bench] – инструменту, позволяющему создавать тесты производительности,
которые помогают выявлять «узкие места» и отслеживать улучшения кода
во временной динамике. Как правило, тесты производительности интегрируются в тот же модуль, что и обычные тесты. Следующий ниже пример иллюст
рирует процесс написания теста производительности.
Листинг 11.14. Пример теста производительности на Rust
#![feature(test)] // Активировать функциональность test для пакета test
extern crate test; // Импортировать системный пакет test
use test::Bencher; // Импортировать общую черту Bencher
#[bench]
fn bench_example(b: &mut Bencher) {
// Код, производительность которого требуется измерить
b.iter(|| {
// Код, подлежащий измерению
let result = expensive_computation();
assert!(result > 0);
});
}
fn expensive_computation() -> i32 {
// Симулировать ресурсоемкие вычисления
(1..1_000).sum()
}
В приведенном выше фрагменте исходного кода активирована функциональность test и импортирован пакет test для использования общей черты
Bencher в целях замера производительности. Функция замера, помеченная
атрибутом #[bench], определена для упаковки тестируемого исходного кода.
Внутри этой функции используется метод b.iter(), чтобы указать конкретный фрагмент исходного кода, подлежащий замеру производительности и заключенный в замыкание. Такая систематическая подготовка обеспечивает
эффективное тестирование и оптимизацию производительности исходного
кода в приложениях.
При выполнении обычных тестов посредством команды cargo test также
есть возможность выполнять замеры производительности с помощью команды cargo bench. Данная команда исполняет функции замера производительности и предоставит результаты замеров и соответствующую статистику.
506
Модульное тестирование на Rust
Покрытие исходного кода
Инструменты оценки покрытия исходного кода имеют решающее значение
для анализа эффективности тестирования при разработке программного
обеспечения, предоставляя сведения о том, какая часть кода охвачена тестами. В экосистеме Rust популярным выбором является пакет tarpaulin. Для его
интеграции необходимо установить его как глобальный инструмент командного интерфейса. За счет этого обеспечивается бесшовный анализ покрытия,
помогая выявлять непротестированные или недостаточно протестированные
участки исходного кода. Пакет tarpaulin играет ключевую роль в создании надежных и стабильных приложений на Rust, расширяя охват тестами и выявляя
потенциальные уязвимости.
$ cargo install cargo-tarpaulin
Затем можно приступить к генерированию отчетов о покрытии исходного
кода с помощью пакета tarpaulin:
$ cargo tarpaulin --out Xml
Приведенная выше команда создаст XML-отчет о покрытии исходного кода,
который можно использовать в конвейерах непрерывной интеграции / непрерывного развертывания (CI/CD) или просматривать в инструментах визуализации данных.
Непрерывная интеграция
Непрерывная интеграция играет ключевую роль в автоматизации процессов
тестирования и поддержании качества исходного кода. Для проектов на Rust
крайне важно задействовать различные платформы непрерывной интеграции,
такие как Travis CI, GitHub Actions, GitLab CI/CD и другие. Процесс настройки предусматривает разработку конфигурационных файлов, которые задают
шаги сборки, тестирования и, при необходимости, развертывания проекта.
Давайте подробно разберем пример конфигурационного файла .travis.
yml, адаптированного под платформу Travis CI:
Листинг 11.15. Базовая настройка Rust для платформы Travis CI
language: rust
rust:
- stable
- beta
- nightly
cache: cargo
script:
- cargo test
В приведенной выше конфигурации:
language: rust задает используемый язык программирования;
rust перечисляет версии Rust, на которых должен тестироваться проект:
stable, beta и nightly (ночная сборка), что обеспечивает охват различных версий компилятора;
Заключение 507
cache: cargo оптимизирует процесс непрерывной интеграции путем кеширования зависимостей Cargo, ускоряя последующие сборки;
script: - cargo test определяет выполняемую команду; в данном случае выполняется cargo test, чтобы обеспечить прохождение всех тестов.
Аналогичные конфигурационные файлы можно создать и для других платформ непрерывной интеграции (CI), таких как CircleCI, адаптируя их под
специфические требования вашего проекта Rust. У каждой системы непрерывной интеграции свой синтаксис и правила, но основные концепции остаются неизменными.
Бесшовно интегрируя непрерывную интеграцию в свой рабочий процесс,
вы создаете систему, в которой исходный код проходит автоматическую сборку и тестирование при каждом коммите. Этот проактивный подход гарантирует надежность и отсутствие дефектов в кодовой базе, формируя устойчивый
и эффективный конвейер разработки.
Написание тестов на Rust необходимо для обеспечения правильности и надежности исходного кода. Тестовая экосистема Rust предоставляет ряд инструментов и лучших образцов практики для написания комплексных модульных
тестов и замеров производительности, включая:
фикстуры: коллективное использование кода подготовки в разных
тестах;
условные тесты: выполнение тестов на основе критериев;
асинхронное тестирование: написание тестов для асинхронного исходного кода;
создание заглушек: изоляция исходного кода путем подмены внешних
зависимостей;
тестирование приватных функций: тестирование внутренних функций с помощью атрибутов;
замеры производительности: нагрузочное измерение производительности исходного кода;
покрытие исходного кода: анализ охвата исходного кода тестами с помощью таких инструментов, как tarpaulin;
непрерывную интеграцию: автоматизацию тестирования на платформах непрерывной интеграции.
Следуя этим практикам и внедряя их в свой процесс разработки, вы сможете создавать надежные и стабильные приложения. Следует помнить, что
тщательное тестирование помогает предотвращать дефекты и неожиданное
поведение, что делает его жизненно важной частью разработки на Rust или
любом другом языке.
Заключение
В этой главе мы совершили всестороннее погружение в тему модульного тестирования на Rust. Мы начали с акцента на значимости модульного тестирования, которое неразрывно связано с принципами защищенности и надежности
самого языка. Мы обследовали основы: от написания тестовых функций и модулей с использованием атрибута #[test] до эффективной организации тестов.
508
Модульное тестирование на Rust
Наше обследование охватило выполнение тестов с помощью команды cargo
test и интерпретацию результатов, что играет критически важную роль для
выявления и устранения проблем в исходном коде. Помимо основ, мы углубились в продвинутые техники, включая разработку через тестирование (testdriven development, аббр. TDD), асинхронное тестирование, создание заглушек,
тестирование на основе свойств и оптимальную организацию тестов.
Как было показано, модульное тестирование – это критически важная практика, обеспечивающая надежность и правильность кодовой базы. Разрабатываете ли вы небольшой проект или масштабную систему, продуманная стратегия тестирования – это ключ к раннему обнаружению дефектов, документированию исходного кода и поддержанию его отказоустойчивости.
Продвигаясь по пути разработчика на Rust, нужно помнить, что тестирование – это непрерывный процесс. Следует постоянно совершенствовать свои
умения тестировать и внедрять лучшие образцы практики, используя обширный набор инструментов и библиотек экосистемы Rust.
Теперь, вооружившись знаниями о модульном тестировании, вы полностью
готовы к созданию стабильных и надежных приложений, соответствующих
высоким стандартам Rust. Независимо от того, над чем вы работаете – создаете системное программное обеспечение, разрабатываете веб-приложения
или любой другой проект на базе Rust, – модульное тестирование всегда будет
вашим союзником в поддержании качества.
В следующей главе мы подробно рассмотрим сетевое программирование
на Rust. Вы научитесь создавать сетевые приложения, работать с протоколами
TCP и UDP, а также управлять асинхронными сетевыми операциями с помощью конструкции async/await.
Дополнительные ресурсы
В целях углубления своих знаний в области модульного тестирования в экосистеме Rust и повышения профессионального уровня рекомендуется обратиться
к следующим ниже ресурсам.
Книга по тестированию на Rust: официальная документация Rust, представляющая собой исчерпывающее руководство по тестированию, охватывающее как основы, так и продвинутые темы: https://doc.rust-lang.org/
book/ch11-00-testing.html.
Документация proptest: ознакомьтесь с тестированием на основе свойств
с помощью proptest, используя советы и руководства из официальной
документации: https://docs.rs/proptest/.
Документация mockito: ознакомьтесь с тем, как создавать заглушки для
HTTP-сервисов на Rust, следуя руководству в документации mockito:
https://docs.rs/mockito/.
Асинхронное программирование на Rust: книга по конструкции async
поможет глубже ознакомиться с асинхронным программированием
и продвинутыми техниками: https://rust-lang.github.io/async-book/.
nextest: библиотека тестирования для Rust, сконструированная как лаконичный и выразительный инструмент для максимально эффективного
выполнения модульных тестов: https://github.com/nextest-rs/nextest.
Вопросы для самоконтроля 509
mockall: широко используемый пакет для создания заглушек для реализаций зависимостей, являющийся ценным инструментом в экосистеме
Rust: https://docs.rs/mockall.
criterion: пакет criterion используется для замеров производительности исходного кода и оптимизации производительности: https://crates.io/
crates/criterion.
Документация criterion: подробное руководство по criterion, которое
поможет освоить искусство написания эффективных тестов производительности: https://bheisler.github.io/criterion.rs/book/.
assert-approx-eq: пакет assert-approx-eq обеспечит высокую точность
в числовых тестах (работа с числами с плавающей точкой): https://docs.
rs/assert-approx-eq.
Вопросы для самоконтроля
Вопрос 1. Почему модульное тестирование играет важную роль при разработке программного обеспечения?
a) оно увеличивает сложность исходного кода
b) оно замедляет процесс разработки
c) оно проверяет правильность исходного кода
d) оно избегает затрат на документирование
Вопрос 2. Что означает атрибут #[test] в Rust?
a) он обозначает определение структуры данных
b) он помечает функцию как тестовый случай
c) он предоставляет дефолтные реализации функций
d) он ограничивает доступ к данным
Вопрос 3. Каково предназначение шаблона ПДС в модульном тестировании?
a) организация тестовых функций по алфавиту
b) структурирование тестов по фазам подготовки, действия и сверки
c) обеспечение соблюдения строгих стандартов программирования
d) предотвращение дублирования исходного кода в тестах
Вопрос 4. Как выполнить конкретную тестовую функцию с именем test_
function с помощью команды cargo test?
a) cargo test --function test_function
b) cargo test --name test_function
c) cargo test --test-function test_function
d) cargo test test_function
Вопрос 5. Каково предназначение тестовых модулей в Rust?
a) определение новых программных конструкций
b) организация тестовых функций в группы
510 Модульное тестирование на Rust
c) предоставление дефолтных реализаций для тестов
d) ограничение доступа к тестовым данным
Вопрос 6. Что делает флаг --exact при выполнении тестов посредством
команды cargo test?
a) выполняет все тесты
b) выполняет тесты только при точном совпадении имени
c) пропускает выполнение тестов
d) устанавливает количество потоков исполнения тестов
Вопрос 7. Для чего используются конкретно-прикладные сообщения
об ошибках в инструкциях сверки Rust?
a) для запутывания разработчиков
b) для удлинения исходного кода
c) для предоставления дополнительного контекста в случае провала теста
d) для ускорения выполнения тестов
Вопрос 8. Что делает флаг --release при выполнении тестов посредством
команды cargo test?
a) выполняет тесты в режиме отладки
b) пропускает выполнение тестов
c) устанавливает количество потоков исполнения тестов
d) выполняет тесты в режиме релиза
Вопрос 9. Что такое фикстура в контексте модульного тестирования?
a) дефект в исходном коде
b) часть исходного кода, выполняющая арифметические операции
c) единообразная среда или комплект начальных условий для тестов
d) инструмент для профилирования исходного кода
Вопрос 10. Что делает флаг --ignored в команде cargo test?
a) пропускает все тесты
b) выполняет только проигнорированные тесты
c) игнорирует вывод результатов тестов
d) обеспечивает соблюдение строгих правил именования
Вопрос 11. Каково предназначение функции подготовки в примере
с подготовкой и очисткой ресурсов?
a) определение тестового случая
b) очистка ресурсов
c) инициализация ресурса для тестирования
d) деактивация функциональности
Вопрос 12. Какая команда используется для активации определенной
функциональности во время сборки теста?
Вопросы для самоконтроля 511
a)
b)
c)
d)
cargo
cargo
cargo
cargo
run
build
test
test --features
Вопрос 13. Какова роль функции очистки в примере с подготовкой
и очисткой ресурсов?
a) инициализирует ресурсы
b) выполняет очистку ресурсов по завершении теста
c) определяет логику теста
d) проверяет флаги функциональностей
Вопрос 14. Как реализуется условное тестирование на Rust?
a) используя атрибут #[test]
b) оборачивая тесты в блоки #[conditional]
c) путем определения условных функций
d) используя атрибут #[cfg(test)] и условную компиляцию
Вопрос 15. Какой атрибут используется для пометки функции как асинхронного теста при использовании пакета tokio?
a) #[async]
b) #[tokio::test]
c) #[async_test]
d) #[tokio]
Вопрос 16. Что делает атрибут automock в Rust при использовании пакета mockall?
a) генерирует документацию для заглушек
b) автоматически генерирует имитацию реализации для указанных общих
черт
c) помечает общую черту как пригодную для имитации
d) активирует автоматическое создание заглушек для зависимостей
Вопрос 17. Какой инструмент обычно используется для анализа покрытия исходного кода на Rust?
a) tarpit
b) tarpaulin
c) coveralls
d) codecov
Вопрос 18. Каково предназначение атрибута [bench] в Rust?
a) помечает функцию как тест производительности
b) генерирует документацию по тестам производительности
c) указывает зависимости теста
d) активирует условное тестирование
512
Модульное тестирование на Rust
Вопрос 19. Какова роль общей черты Bencher в тестировании производительности на Rust?
a) определяет асинхронные замеры производительности
b) представляет тестируемую функцию
c) предоставляет методы для проведения замеров производительности
d) включает условный замер производительности
Вопрос 20. Какая команда используется для генерации XML-отчета о покрытии исходного кода с использованием пакета tarpaulin?
a) cargo coverage
b) cargo report
c) cargo tarpaulin --out Xml
d) tarpaulin --out Xml
Ответы
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
c) оно проверяет правильность исходного кода
b) он помечает функцию как тестовый случай
b) структурирование тестов по фазам подготовки, действия и сверки
b) cargo test --name test_function
b) организация тестовых функций в группы
b) выполняет тесты только при точном совпадении имени
c) для предоставления дополнительного контекста в случае провала
теста
d) выполняет тесты в режиме релиза
c) единообразная среда или комплект начальных условий для тестов
b) выполняет только проигнорированные тесты
c) инициализация ресурса для тестирования
d) cargo test --features
b) выполняет очистку ресурсов по завершении теста
d) используя атрибут #[cfg(test)] и условную компиляцию
b) #[tokio::test]
b) автоматически генерирует имитацию реализации для указанных общих черт
b) tarpaulin
a) помечает функцию как тест производительности
c) предоставляет методы для проведения замеров производительности
c) cargo tarpaulin --out Xml
Ключевые термины
Модульное тестирование (unit testing): практика тестирования отдельных компонентов или единиц исходного кода с целью проверки их правильной работы. В Rust модульное тестирование поддерживается встроенным фреймворком и использованием тестовых атрибутов.
Ключевые термины 513
Шаблон ПДС (AAA pattern): аббревиатура от Подготовка–действие–
сверка (Arrange, Act и Assert) – структурированный подход к написанию
тестов. Помогает организовывать тестовые функции по фазам: подготовка входных данных, исполнение самой операции и сверка результата.
Тестовый модуль (test module): механизм группировки в Rust, служащий
для организации тестовых функций. Модули помогают поддерживать
структуру и управляемость тестов, особенно по мере роста кодовой базы.
Макрофункции сверки (assertion macros): макрофункции во фреймворке тестирования исходного кода Rust, используемые для подтверждения правильности ожидаемого поведения исходного кода. Распространенные макрофункции включают assert!, assert_eq! и assert_ne!.
Фикстура (fixture): заранее определенный и согласованный набор данных, используемый в тестировании для создания контролируемой и воспроизводимой среды. Фикстуры часто используются совместно в нескольких тестах.
Опции исполнения тестов (test execution options): настройки конфигурации и флаги команды cargo test для индивидуализации процесса
тестирования. Примеры: --test-threads, --release, --ignored и --quiet.
Отчет о тестировании (test report): подробная сводка, создаваемая
после выполнения тестов, содержащая информацию о пройденных,
проваленных и пропущенных тестах. Включает результат, ошибки и дополнительные отладочные данные.
Конкретно-прикладные сообщения об ошибках (custom error
messages): сообщения, привязанные к инструкциям сверки, которые
предоставляют дополнительный контекст при провале теста. Помогают
понимать причину отказа и облегчают отладку.
Условная компиляция (conditional compilation): функциональное средство Rust, позволяющее включать или исключать исходный код во время
компиляции на основе определенных атрибутов, таких как флаги функциональностей или тестовые конфигурации.
Функция подготовки (setup function): функция в тестировании, отвечающая за подготовку необходимой среды или ресурсов, требуемых для
исполнения теста.
Функция очистки (teardown function): функция в тестировании, отвечающая за выполнение операций по очистке после завершения теста,
гарантируя надлежащее высвобождение ресурсов.
Параметризованный тест (parameterized test): тип теста, при котором
тестовая функция выполняется несколько раз с разными наборами параметров, что позволяет эффективно проверять различные сценарии
входных данных.
automock: атрибут автоматического создания заглушек, используемый
в Rust при участии пакета mockall для автоматической генерации имитаций реализаций указанных общих черт.
Замеры производительности (benchmarking): процесс измерения производительности исходного кода с использованием тестов производительности с целью выявления узких мест и отслеживания улучшений
кода во временной динамике.
514
Модульное тестирование на Rust
Покрытие исходного кода (code coverage): мера объема исходного кода,
охваченного тестами; анализируется с помощью таких инструментов,
как tarpaulin.
#[feature(test)]: функциональное средство Rust, позволяющее использовать пакет test для замеров производительности.
#[bench]: атрибут в Rust, используемый для пометки функции как тест
производительности.
Глава 12
Сетевое программирование
Введение
Умение конструировать сетевые приложения является ключевым и порой
оказывается непростым аспектом в современном ландшафте разработки программного обеспечения. Этот сложный процесс строится вокруг скрупулезной
разработки систем, способных беспрепятственно обмениваться информацией
по сетям, обеспечивая тем самым работу распределенных и коллаборативных
функциональностей. Наше изложение в этой главе будет отмечено глубоким
обследованием тонкостей сетевого программирования с особым акцентом
на использовании Rust, который славится своей приверженностью производительности, защищенности и конкурентности.
По мере раскрытия тонкостей сетевого программирования становится очевидным, что эта область полна вызовов и сложностей, требующих комплексного подхода. Мастерство сетевого программирования заключается в способности организовывать бесшовную связь между разнообразными системами,
способствуя гармоничному обмену данными. Rust с его уникальным набором
функциональных средств оптимально подходит для навигации в этой среде.
Акцент на производительности гарантирует, что сетевые приложения будут работать с максимальной эффективностью, а стремление к защищенности предохраняет от потенциальных уязвимостей, которые могут возникать
в запутанной паутине взаимосвязанных систем. Более того, принятая в Rust
модель конкурентности становится мощным инструментом управления динамичной природой сетевых сред, позволяя создавать приложения, которые
легко справляются с многочисленными заданиями конкурентно.
Углубляясь в тему, вы увидите, что обеспечить общение программ друг
с другом по сети не всегда просто. Это отчасти напоминает модерацию беседы
между друзьями, говорящими на разных языках. Rust здесь выступает в роли
надежного помощника, привнося свои уникальные способности. Это как друг,
который не только способствует плавному течению разговора, но и следит
за тем, чтобы все происходило эффективно и без заминок. Таким образом,
в этой главе вы не просто научитесь налаживать связь между системами –
вы откроете для себя те грамотные способы, которыми Rust воплощает это
в жизнь, делая погружение в тему сетевого программирования увлекательным
и захватывающим.
516
Сетевое программирование
Содержание главы
В этой главе будут рассмотрены следующие темы:
создание сетевых приложений на Rust;
работа с протоколами связи TCP и UDP;
управление асинхронными сетевыми операциями с помощью конструкций async/await.
Сетевое программирование
Надежные функциональные средства Rust позиционируют его как отличный
выбор для разработки сетевых приложений. Как вы узнали из предыдущих
глав, система владения и контролёр заимствований играют ключевую роль
в обеспечении защищенности памяти. Это критически важно для предотвращения таких распространенных проблем, как обращение к памяти по пустым
указателям и гонки за доступ к данным. При создании сетевых приложений
на Rust разработчик получает гибкость: он может использовать стандартную
библиотеку или выбирать из многочисленных сторонних пакетов, адаптированных под конкретные сетевые требования.
Разработка сетевых приложений
Выбор между стандартной библиотекой и сторонними пакетами зависит
от сложности стоящих перед разработчиком задач. Стандартная библиотека
обеспечивает прочный фундамент для базовых сетевых операций, благодаря
чему она идеально подходит для простых приложений. Сторонние пакеты, такие как tokio, async-std, socket2, rocket или hyper, предлагают более продвинутые функциональные средства, такие как асинхронный ввод-вывод и удобные абстракции для обработки сложных сценариев сетевого взаимодействия.
В сфере фундаментальных операций сетевого взаимодействия можно задействовать способности модулей std::net и std::io из стандартной библио
теки. Эти модули предоставляют незаменимые инструменты управления сокетами, адресами и операциями ввода-вывода. В целях иллюстрации практического применения этих модулей давайте разберем пример простейшего
TCP-сервера, реализованного с помощью стандартной библиотеки.
Рассмотрим вариант использования, в котором мы продемонстрируем элементарный TCP-сервер, работающий по адресу 127.0.0.1:8080 и предназначенный для отправки простейшего HTTP-ответа. Центральным элементом
этого примера является функция handle_client, служащая для эффективной
обработки поступающих от клиента данных и формирования прямолинейного
HTTP-ответа.
Листинг 12.1. Простейший TCP-сервер
с использованием стандартной библиотеки
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
Сетевое программирование 517
fn handle_client(mut stream: TcpStream) {
println!("Обрабатывается клиентское соединение...");
let mut buffer = [0; 512];
// Читать данные из потока данных
stream.read(&mut buffer).unwrap();
// Конвертировать полученные байты в строковое значение для удобочитаемости
let request = String::from_utf8_lossy(&buffer[..]);
println!("Полученный запрос:\n{}", request);
let response = b"HTTP/1.1 200 OK\r\n\r\nПривет, пользователь!";
stream.write(response).unwrap();
println!("Ответ отправлен.");
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
println!("Сервер слушает на 127.0.0.1:8080...");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
println!("Принято новое соединение");
// Создавать новый поток исполнения для каждого клиента
std::thread::spawn(|| handle_client(stream));
}
Err(e) => {
eprintln!("Ошибка: {}", e);
}
}
}
}
// Вывод:
// Сервер
// $ cargo run
//
//
//
//
//
Сервер слушает на 127.0.0.1:8080...
Принято новое соединение
Обрабатывается клиентское соединение...
Получен запрос:
GET / HTTP/1.1
// Привет, пользователь!
// Ответ отправлен.
Приведенный выше практический пример воплощает в себе суть использования стандартной библиотеки Rust для заданий по сетевому взаимодействию. Модули std::net и std::io органично интегрируются в процесс разработки, предлагая фундамент для решения низкоуровневых сетевых вопросов.
Это не только упрощает реализацию стандартных функциональностей сетевого взаимодействия, но и способствует формированию надежного и стандартизированного подхода к сетевому программированию.
518
Сетевое программирование
Смысловым центром примера является простота архитектуры TCP-сервера.
Привязываясь к адресу 127.0.0.1:8080, сервер активно ожидает входящие соединения. Как только соединение устанавливается, функция handle_client берет управление на себя, эффективно читая поступающие от клиента данные
и своевременно отвечая элементарным HTTP-сообщением. В этом заключается стержневой рабочий процесс минималистичного взаимодействия «клиент–
сервер», подчеркивая бесшовную интеграцию стандартной библиотеки Rust
в разработку сетевых приложений.
netcat (nc)1 – это удобный консольный инструмент, который применяется
для взаимодействия с сетевыми службами. Он может стать ценным активом
на этапе разработки и тестирования сетевого приложения. В случае с нашим
TCP-сервером на Rust инструмент netcat может использоваться для имитации
клиента, подключающегося к серверу и отправляющего HTTP-запросы.
Предположим, ваш TCP-сервер на Rust запущен и ожидает подсоединений
по адресу 127.0.0.1:8080. Откройте новое окно терминала и примените netcat
для подсоединения к серверу:
$ nc 127.0.0.1 8080
Приведенная выше команда устанавливает соединение с сервером по адресу 127.0.0.1 на порту 8080. После этого вы сможете взаимодействовать с сервером, вводя HTTP-запрос и нажимая клавишу Enter. Например:
GET / HTTP/1.1
Нажмите клавишу Enter еще раз, чтобы отправить запрос. Вы должны получить HTTP-ответ от сервера, который в данном примере представляет собой
простое сообщение:
HTTP/1.1 200 OK
Привет, пользователь!
Использование консольного инструмента netcat таким образом позволяет
вручную тестировать написанный на Rust TCP-сервер и наблюдать за его поведением в ответ на различные клиентские запросы. Подобное тестирование
особенно полезно в процессе разработки, чтобы проверять, что сервер правильно обрабатывает входящие соединения и выдает ожидаемые ответы.
Интегрируя netcat в процесс тестирования, можно симулировать различные сценарии и оценивать поведение написанного на Rust сервера в разных
условиях, что способствует повышению общей надежности и отказоустойчивости сетевого приложения.
Сторонние пакеты, такие как tokio и async-std, ориентированы на более
продвинутые сценарии сетевого взаимодействия, особенно те, что связаны
с асинхронным вводом-выводом. Эти библиотеки предоставляют абстракции для асинхронного программирования, облегчая разработчикам написание неблокирующего конкурентного исходного кода. Давайте рассмотрим
упрощенный пример использования пакета tokio для создания асинхронного
TCP-сервера:
1
См. https://www.unix.com/man-page/linux/1/nc/.
Сетевое программирование 519
Листинг 12.2. Простейший TCP-сервер с использованием пакета tokio
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() {
// Привязка сервера к адресу.
// Метод .await не блокирует поток, а передает управление среде исполнения
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
println!("Сервер слушает на 127.0.0.1:8080...");
while let Ok((mut socket, _)) = listener.accept().await {
println!("Принято новое соединение");
// Вместо тяжелого потока ОС (std::thread) использовать
// легковесное задание пакета tokio
tokio::spawn(async move {
let mut buffer = [0; 512];
// Асинхронное чтение
socket.read(&mut buffer).await.unwrap();
// Конвертировать полученные байты в строковое значение
// для удобочитаемости
let request = String::from_utf8_lossy(&buffer[..]);
println!("Полученный запрос:\n{}", request);
let response = b"HTTP/1.1 200 OK\r\n\r\nПривет, пользователь!";
// Асинхронная отправка ответа
socket.write_all(response).await.unwrap();
println!("Ответ отправлен.");
});
}
}
// Вывод:
// Сервер
// $ cargo run
//
//
//
//
Сервер слушает на 127.0.0.1:8080...
Принято новое соединение
Получен запрос:
GET / HTTP/1.1
// Ответ отправлен.
// Клиент
// $ nc 127.0.0.1 8080
// GET / HTTP/1.1
// HTTP/1.1 200 OK
// Привет, пользователь!
520
Сетевое программирование
В приведенном выше примере демонстрируется использование пакета
tokio, с помощью которого создается асинхронный TCP-сервер на Rust. Наличие атрибута #[tokio::main] указывает на асинхронную природу точки
входа в приложение. Этот отличительный атрибут инициирует запуск среды
асинхронного исполнения с использованием пакета tokio, предлагающего
широко распространенную среду исполнения для Rust. В этом асинхронном
контексте сервер обрабатывает входящие соединения асинхронно, применяя специализированные операции асинхронного ввода-вывода для чтения
и записи данных.
Резюмируя: принятые в Rust надежная система владения и контролёр заимствований играют ключевую роль в разработке защищенных с точки зрения
памяти и конкурентных систем сетевого взаимодействия. Система владения,
каждое значение которой имеет уникального владельца, ответственного за его
жизненный цикл, предотвращает такие проблемы, как висячие указатели
и гонки за доступ к данным, как вы увидели в главе 1 «Системное программирование на языке Rust». Контролёр заимствований, работающий на этапе
компиляции, накладывает строгие правила на ссылки и времена жизни, тем
самым предотвращая потенциальные ошибки на этапе исполнения.
Заглядывая вперед, мы продолжим работу, перейдя к обследованию протоколов связи, таких как TCP (протокол управления передачей с установкой со
единения) и UDP (протокол пользовательских датаграмм без установки соединения). Эти протоколы составляют основу сетевого взаимодействия, и понимать
их тонкости жизненно важно для профессионального сетевого программиста
на Rust. Далее изложение будет сконцентрировано на управлении асинхронными сетевыми операциями с использованием синтаксиса async/await – мощной
парадигмы, которая повышает удобочитаемость и удобство поддержки асинхронного исходного кода на Rust. В последующих разделах будет сформировано всестороннее понимание сетевого программирования на Rust, что позволит
разрабатывать эффективные и надежные сетевые приложения.
Протоколы связи: TCP и UDP
В основе сетевых приложений лежат протоколы связи – важнейшие каркасы,
которые закладывают правила и соглашения, определяющие то, как разные
технологии обмениваются информацией. Эти протоколы выступают в роли
фундамента, обеспечивая структурированный и упорядоченный обмен данными между различными сущностями в сетевой среде. Среди множества протоколов особенно выделяются два, каждый из которых служит своим уникальным целям в сложном мире сетевых коммуникаций.
Первым в центре внимания оказывается протокол управления передачей
(TCP) – «тяжеловесный» претендент, известный своей надежностью и точностью. TCP работает по принципу установления соединения перед обменом
данными, создавая виртуальную связь между отправителем и получателем.
Он тщательно управляет потоком информации, подтверждая доставку пакетов данных и повторно передавая любые потерянные или поврежденные
биты. Такой уровень гарантии превращает TCP в основной вариант для при-
Протоколы связи: TCP и UDP 521
ложений, где точность и полнота данных не подлежат обсуждению, таких как
передача файлов, электронная почта и просмотр веб-страниц1.
С другой стороны расположен протокол пользовательских датаграмм
(UDP) – легковесный и ориентированный на быстродействие игрок в области сетевых протоколов. В отличие от TCP, протокол UDP не нуждается в установлении соединения перед передачей данных. Он выбирает более быстрый
и прямой подход, отправляя пакеты данных без подтверждения получения.
Это преимущество в скорости делает UDP идеальным кандидатом для приложений, где связь в реальном времени и быстрая передача данных важнее
гарантированной доставки, что можно наблюдать в онлайновых играх, потоковом видео и сервисах IP-телефонии (VoIP)2.
Дуализм TCP и UDP воплощает в себе разнообразные потребности сетевых
приложений. Скрупулезная и надежная природа протокола TCP подходит для
сценариев, в которых крайне важна точность, тогда как быстрый и прямолинейный подход протокола UDP ориентирован на ситуации, когда во главе угла
стоят скорость и отклик в реальном времени. Вместе эти протоколы образуют
динамичный каркас, который поддерживает огромное количество взаимодействий в постоянно расширяющемся мире сетевых коммуникаций.
Сетевые слои
Прикладной
Транспортный
Сетевой
Канальный/
физический
Сетевой интерфейс
Рисунок 12.1. Концептуальная модель протоколов TCP/IP
Протокол управления передачей (TCP)
Протокол управления передачей (от англ. transmission control protocol, аббр.
TCP) функционирует как метод связи с установлением соединения. Это означает, что в нем используется скрупулезный и детальный подход с целью
обеспечения надежной и строго последовательной передачи данных между
устройствами. В отличие от некоторых других протоколов, TCP делает «шаг на1
2
TCP – это как заказное письмо: курьер вручает его лично в руки, вы расписываетесь,
и отправитель уверен, что все дошло в целости. – Прим. перев.
UDP – это как обычное объявление в мегафон: вы выкрикнули информацию в толпу;
кто-то услышал, кто-то нет, но зато это мгновенно. – Прим. перев.
522
Сетевое программирование
встречу», сначала устанавливая прочное и стабильное соединение, прежде чем
приступать к отправке данных. Именно эта предварительная подготовка выделяет TCP в области сетевых коммуникаций.
Он гарантирует, что отправляемые пакеты информации не только достигнут
места назначения, но и прибудут в правильном порядке, обеспечивая целостность и связность передаваемых данных. Хотя такая приверженность надежности
похвальна, она имеет свою цену: из-за своего скрупулезного подхода TCP вносит
дополнительные накладные расходы. В силу этого он особенно подходит для приложений, в которых точность и полнота данных не подлежат обсуждению.
Синхронизация
Синхронизация/Подтверждение
Подтверждение
Сервер
Клиент
Рисунок 12.2. Трехпутное рукопожатие (TCP)
Теперь давайте рассмотрим протокол TCP подробнее и применим его
на практике, создав простейший TCP-сервер. В следующем ниже фрагменте
исходного кода мы проведем обследование реализации этого сервера, используя возможности модуля std::net – универсального инструментария Rust,
предоставляющего все необходимое для построения надежных сетевых приложений. Этот модуль упрощает процесс разработки, наглядно демонстрируя,
как ориентированная на производительность и защищенность архитектура
Rust органично вписывается в создание стабильного TCP-сервера. Данный
исходный код служит подтверждением способностей Rust в сетевом программировании, делая разработку функциональных и защищенных систем связи
доступной и понятной.
Листинг 12.3. Простейший TCP-сервер
с использованием стандартной библиотеки
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
fn handle_tcp_client(mut stream: TcpStream) {
// Создать буфер на 512 байт для входящих данных
let mut buffer = [0; 512];
stream.read(&mut buffer).unwrap();
// Конвертировать байты в строковое значение
// (с заменой некорректных UTF-8 символов)
let request = String::from_utf8_lossy(&buffer);
println!("Получен TCP-запрос: {}", request);
// ①
// ②
// ③
// Подготовить элементарный HTTP-ответ в виде среза байтов
Протоколы связи: TCP и UDP 523
}
let response = b"HTTP/1.1 200 OK\r\n\r\nПривет, TCP!"; // ④
stream.write(response).unwrap();
fn main() {
// ⑤
let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); // ⑥
for stream in listener.incoming() { // ⑦
match stream {
Ok(stream) => {
// ⑧
std::thread::spawn(|| handle_tcp_client(stream));
}
Err(e) => {
// ⑨
eprintln!("Ошибка: {}", e);
}
}
}
}
// Вывод:
// Сервер
// $ cargo run
// Получен TCP-запрос: GET / HTTP/1.1
// Клиент
// $ nc 127.0.0.1 8080
// GET / HTTP/1.1
// HTTP/1.1 200 OK
// Привет, TCP!
В приведенном выше показательном фрагменте исходного кода элегантно
раскрываются тонкости взаимодействия между сервером и клиентом:
① в центре внимания находится функция handle_tcp_client, содержащая
в себе стержневую функциональность, ответственную за координацию
связи с TCP-клиентом. Она включает в себя чтение входящих данных,
их обработку и формирование подходящего HTTP-ответа для отправки
обратно клиенту;
② в рамках функции handle_tcp_client ключевым этапом является чтение
данных от TCP-клиента и их сохранение в специально выделенном буфере. Этот процесс закладывает основу для последующих манипуляций
с данными и подготовки ответа;
③ находящиеся в буфере полученные данные проходят процесс приведения в тип String. Этот шаг не только подчеркивает адаптивность Rust
в работе с различными типами данных, но и стремление языка предоставлять гибкие и выразительные инструменты манипулирования ими;
④ как свидетельство основ взаимодействия сервера и клиента, внутри
функции handle_tcp_client методично конструируется простой, но наглядный HTTP-ответ. Эта практическая демонстрация служит базой для
понимания фундаментальных принципов связи между серверными
и клиентскими сущностями;
524
Сетевое программирование
⑤ точка входа в программу, функция main, координирует первоначальную
настройку и исполнение сервера. Она служит центральным хабом, из которого инициируются различные функциональности, направляя поток
исполнения программы в нужное русло;
⑥ здесь раскрывается важнейший компонент инфраструктуры сетевого взаимодействия – создание экземпляра TCP-слушателя (TcpListener), привязанного к конкретному адресу "127.0.0.1:8080". Это знаменует начало способности сервера прослушивать входящие соединения на назначенном порту;
⑦ программа бесшовно переходит в цикл ожидания входящих TCPсоединений. Этот итеративный процесс гарантирует, что сервер остается бдительным и отзывчивым к потенциальным клиентам, запуская отдельный поток исполнения для каждого успешного соединения, чтобы
конкурентно управлять каналами связи;
⑧ при каждом успешно принятом соединении порождается новый поток исполнения, привнося парадигму параллельного исполнения. Этот поток,
в свою очередь, вызывает функцию handle_tcp_client, что означает динамичный подход к конкурентной обработке взаимодействий с клиентами;
⑨ слой надежности представлен в исходном коде механизмами обработки
ошибок. За счет них гарантируется, что любые потенциальные проблемы,
возникающие в процессе принятия соединения, будут методично устраняться с выводом соответствующих сообщений об ошибках. Такая приверженность управлению ошибками повышает устойчивость сервера и усиливает его способность щадяще справляться с неожиданными ситуациями.
Приведенный выше фрагмент исходного кода не только иллюстрирует фундаментальные принципы сетевого программирования, но и демонстрирует
способность Rust предоставлять надежный и выразительный каркас для создания эффективных и стабильных клиент-серверных приложений. Тщательная
координация функций, акцент на конкурентности и скрупулезное внедрение
механизмов обработки ошибок в совокупности создают кодовую базу, которая
не только эффективно функционирует, но и воплощает в себе лучшие образцы
практики разработки программного обеспечения.
Благодаря гарантиям относительно надежности и очередности протокол
TCP отлично подходит для таких приложений, как передача файлов, вебсерфинг и электронная почта. Однако природа протокола, ориентированная
на установление соединения, вносит запаздывание, что делает его менее идеальным для реально-временных приложений, в которых критически важную
роль играет минимальное запаздывание.
Протокол пользовательских датаграмм (UDP)
В отличие от TCP, протокол пользовательских датаграмм (от англ. user datagram
protocol, аббр. UDP) является протоколом без установления соединения, ориентированным на простоту и снижение накладных расходов. Он работает
по принципу «выстрелил и забыл», при котором пакеты данных отправляются
без предварительной установки связи, при этом не гарантируется ни их доставка, ни соблюдение упорядоченности. Благодаря этому протокол UDP хорошо подходит для сценариев, в которых приоритетными являются низкое
запаздывание и минимальные системные затраты.
Протоколы связи: TCP и UDP 525
Запрос
Данные
Данные
Клиент
Сервер
Рисунок 12.3. Концептуальная схема рабочего потока протокола UDP
Давайте подробно проинспектируем протокол UDP и реализуем элементарный UDP-сервер на Rust. Следующий ниже фрагмент исходного кода демонстрирует простейший UDP-сервер с использованием модуля std::net.
Листинг 12.4. Простейший UDP-сервер
с использованием стандартной библиотеки
use std::net::UdpSocket;
fn handle_udp_client(socket: UdpSocket) { // ①
let mut buffer = [0; 512];
// ①
let (_, client_address) = socket.recv_from(&mut buffer).unwrap(); // ③
let request = String::from_utf8_lossy(&buffer); // ④
println!("Получен UDP-запрос от {}: {}", client_address, request);
}
let response = b"Привет, UDP!";
// ⑤
socket.send_to(response, client_address).unwrap();
fn main() {
// ⑥
let socket = UdpSocket::bind("127.0.0.1:8080").unwrap(); // ⑦
loop {
handle_udp_client(socket.try_clone().unwrap());
}
}
// Вывод:
// Сервер
// $ cargo run
// Получен UDP-запрос от 127.0.0.1:35213: Test message
// Клиент
// $ echo -n "Test message" | nc -u 127.0.0.1 8080
// Привет, UDP!
В приведенном выше фрагменте исходного кода:
// ⑧
// ⑨
526
Сетевое программирование
① определена функция handle_udp_client, содержащая в себе стержневую
функциональность взаимодействия с UDP-клиентом. Она принимает данные от клиента, обрабатывает их и отправляет обратно простой UDP-ответ.
Указанная функция играет ключевую роль в управлении протоколом связи;
② внутри функции handle_udp_client инициализируется буфер для хранения полученных от UDP-клиента данных. Этот буфер служит временным
хранилищем для эффективной обработки входящих данных;
③ далее функция переходит к получению данных от UDP-клиента с помощью метода recv_from, одновременно извлекая адрес клиента в этом
процессе. Указанный шаг закладывает основу для понимания источника
поступления входящих данных;
④ находящиеся в буфере полученные данные проходят обработку в виде
конвертации в значение типа String. Эта конвертация, ставшая возможной благодаря гибкой работе компилятора Rust с типами данных, подготавливает их для дальнейшего анализа или формирования ответа;
⑤ далее формулируется простой UDP-ответ, представленный байтовым
строковым литералом "Привет, UDP!", который отправляется обратно
клиенту с помощью метода send_to. Это знаменует завершение цикла
связи сервера с UDP-клиентом;
⑥ точкой входа в программу является функция main, служащая центральным хабом, координирующим работу сервера;
⑦ здесь создается UDP-сокет, привязанный к заданному адресу 127.0.0.1:8080.
Этот этап инициализации подтверждает готовность сервера прослушивать
входящие UDP-сообщения на назначенном порту;
⑧ программа бесшовно переходит в цикл, указывающий на ее постоянную
готовность непрерывно обрабатывать входящие UDP-сообщения;
⑨ внутри этого цикла по каждому входящему UDP-сообщению порождается новый поток исполнения, вызывающий функцию handle_udp_client
для управления связью. Такой подход обеспечивает конкурентную и отзывчивую обработку многочисленных UDP-клиентов.
Данный фрагмент исходного кода иллюстрирует фундаментальные процессы, связанные с взаимодействием сервера и клиента по протоколу UDP. Тщательная координация функций, использование буферов для хранения данных
и непрерывная обработка входящих сообщений в цикле в совокупности обес
печивают создание надежной и производительной реализации UDP-сервера.
Исходный код демонстрирует способность Rust справляться с низкоуровневыми сетевыми сложностями, предоставляя выразительные инструменты для
создания эффективных сетевых приложений.
Протоколы TCP и UDP лежат в самом основании сетевой связи, и каждый
из них предназначен для конкретных вариантов использования в зависимости от требований к надежности и запаздыванию. Подходящий протокол следует выбирать исходя из потребностей своего приложения.
Асинхронные сетевые операции
Асинхронное программирование, которое более детально будет рассмотрено
в главе 14, имеет важнейшее значение для сетевого программирования, позво-
Асинхронные сетевые операции 527
ляя эффективно обрабатывать конкурентные операции, не блокируя исполнение других заданий. Rust предоставляет встроенную поддержку асинхронного
программирования посредством синтаксиса async/await, что позволяет с легкостью писать неблокирующий исходный код.
Синхронное программирование
Процесс 1
Выполнить
Процесс 2
Ждать
Процесс 3
Ждать
Ждать
Выполнить
Ждать
Ждать
Выполнить
Ждать
Ждать
Выполнить
Ждать
Асинхронное программирование
Процесс 1
Процесс 2
Процесс 3
Выполнить
Выполнить
Выполнить
Выполнить
Выполнить
Выполнить
Рисунок 12.4. Синхронное программирование против асинхронного
Асинхронное программирование
Погружение в тему асинхронного программирования открывает двери в парадигму, где несколько заданий могут исполняться конкурентно, без необходимости ожидать завершения друг друга. Этот подход оказывается особенно
выгодным в контексте сетевого программирования, где такие сценарии, как
управление многочисленными клиентскими соединениями или конкурентная
отправка запросов к внешним сервисам, являются обычным делом. Суть асинхронного программирования заключается в способности эффективно «жонг
лировать» этими заданиями, повышая общую отзывчивость и пропускную
способность приложений, работающих с конкурентными операциями.
528
Сетевое программирование
Добавление синтаксиса async/await в Rust коренным образом меняет подход к написанию асинхронного исходного кода. Указанный синтаксис предлагает более интуитивное и синхронное представление, делая разработку асинхронных приложений доступнее и понятнее. Ключевым инструментом в этой
синтаксической эволюции является ключевое слово async, которое сигнализирует об инициации асинхронной функции. В дополнение к нему ключевое
слово await выступает в качестве мощного механизма управления, стратегически приостанавливая исполнение асинхронной функции до тех пор, пока
конкретная операция (например, ввод-вывод или сетевой запрос) не будет
завершена. Такая связка async/await не только упрощает структуру исходного кода, но и позволяет рассуждать об асинхронных заданиях так же, как и об
их синхронных аналогах.
Рассмотрим следующий ниже пример асинхронной функции, которая симулирует получение данных из внешнего API с использованием пакета reqwest:
Листинг 12.5. Асинхронное получение данных из внешнего
API с использованием пакета reqwest
use reqwest::Client; // ①
async fn fetch_data() -> Result<String, reqwest::Error> { // ②
let url = "https://jsonplaceholder.typicode.com/posts";
let client = Client::new(); // ③
// Выполнить GET-запрос.
// .await приостанавливает функцию до получения ответа.
// Оператор '?' автоматически распространяет ошибку вверх,
// если запрос не удался.
let response = client.get(url).send().await?;
// Прочитать тело ответа в виде строкового значения с помощью метода text()
let body = response.text().await?;
// Вернуть полученные данные как Result::Ok(body)
Ok(body)
}
#[tokio::main]
async fn main() { // ④
// Вызов асинхронной функции и ожидание ее завершения посредством .await
match fetch_data().await {
Ok(data) => println!("Доставленные данные: {}", data),
// ⑤
Err(e) => eprintln!("Ошибка доставки данных: {}", e), // ⑥
}
}
// Вывод:
//
//
//
//
//
Доставленные данные: [
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi
Асинхронные сетевые операции 529
optio reprehenderit",
// "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita
et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem
sunt rem eveniet architecto"
// },
// {
// "userId": 1,
// "id": 2,
// "title": "qui est esse",
// "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit
dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel
nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui
neque nisi nulla"
// },
// ...
В приведенном выше примере:
① исходный код начинается с инструкции импорта, которая вводит пакет
reqwest в диапазон доступности программы. Предполагается, что этот пакет будет использоваться в исходном коде для обработки HTTP-запросов;
② здесь определена асинхронная функция fetch_data, в которую заложена симуляция доставки данных из внешнего API. Она возвращает
Result<String, reqwest::Error>, где экземпляр типа String представляет
доставленные данные, а reqwest::Error – любые потенциальные ошибки,
которые могут возникать во время HTTP-запроса;
③ атрибут #[tokio::main] отмечает точку входа в асинхронное приложение.
Этот атрибут сообщает среде исполнения пакета tokio, что данная функция является главной для асинхронной программы;
④ внутри асинхронного контекста функция fetch_data вызывается с использованием ключевого слова await. Это ключевое слово приостанавливает исполнение функции до тех пор, пока не завершится асинхронный HTTP-запрос, инициированный цепочкой client.get(url).send().
await?;
⑤ если доставка данных прошла успешно (результат равен варианту Ok),
то доставленные данные выводятся в консоль с помощью макрофункции
println!;
⑥ если во время доставки данных возникает ошибка (результат равен варианту Err), то в консоль выводится сообщение об ошибке, но теперь с помощью макрофункции eprintln!. Это демонстрирует надежный подход
к обработке ошибок, позволяющий получать сведения о любых проблемах, которые могут возникать в процессе асинхронной доставки данных.
Приведенный выше исходный код иллюстрирует реализацию асинхронной функции, предназначенной для симуляции процесса доставки данных
из внешнего API. Функция fetch_data демонстрирует мощь способностей Rust
по асинхронному программированию, используя пакет reqwest для обработки HTTP-запросов в неблокирующем режиме. Стратегическое использование
ключевого слова await приостанавливает исполнение функции вплоть до завершения асинхронного HTTP-запроса, обеспечивая эффективную и отзывчивую обработку асинхронных заданий.
530 Сетевое программирование
Асинхронный TCP-сервер
Теперь давайте посмотрим, как реализуется асинхронный TCP-сервер, используя синтаксис async/await и среду исполнения tokio. Следующий ниже
фрагмент исходного кода демонстрирует простой асинхронный TCP-сервер:
Листинг 12.6. Асинхронный TCP-сервер с использованием среды tokio
use tokio::net::TcpListener; // ①
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn handle_async_tcp_client(mut socket: tokio::net::TcpStream) { // ②
let mut buffer = [0; 512]; // ③
let bytes_read = socket.read(&mut buffer).await.unwrap();
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
// ④
println!("Получен асинхронный TCP-запрос: {}", request);
let response = b"HTTP/1.1 200 OK\r\n\r\nПривет, Асинхронный TCP!"; // ⑤
socket.write_all(response).await.unwrap();
}
#[tokio::main] // ⑥
async fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); // ⑦
while let Ok((stream, _)) = listener.accept().await { // ⑧
tokio::spawn(handle_async_tcp_client(stream));
// ⑨
}
}
// Вывод:
// Сервер
// $ cargo run
// Получен асинхронный TCP-запрос: GET / HTTP/1.1
// Клиент
// $ nc 127.0.0.1 8080
// GET / HTTP/1.1
// HTTP/1.1 200 OK
// Привет, Асинхронный TCP!
В приведенном выше примере:
① исходный код начинается с импорта необходимых модулей из асинхронной среды исполнения пакета tokio, что позволяет использовать асинхронные операции ввода-вывода для сетевого взаимодействия;
② затем мы определяем асинхронную функцию handle_async_tcp_client,
отвечающую за управление связью с TCP-клиентами с использованием
синтаксиса async/await;
③ внутри функции handle_async_tcp_client мы асинхронно читаем данные
от TCP-клиента с занесением их в буфер, демонстрируя асинхронную
природу этой операции;
Варианты использования из реальной практики: обработка HTTP-запросов 531
④ далее асинхронно обрабатываем полученные данные, конвертируя
их в экземпляр типа String, что подчеркивает гибкость асинхронных
способностей Rust;
⑤ исходный код асинхронно отправляет ответ обратно клиенту, демонстрируя бесшовную интеграцию асинхронных операций записи;
⑥ здесь определена главная асинхронная функция, используя атрибут
#[tokio::main], который помечает ее как точку входа для асинхронной
среды исполнения;
⑦ программа создает асинхронного TCP-слушателя, привязанного к конкретному адресу, используя синтаксис async/await для асинхронной обработки;
⑧ программа асинхронно принимает входящие TCP-соединения, входя
в цикл обработки каждого соединения в асинхронном режиме;
⑨ по каждому успешно принятому соединению программа порождает новое асинхронное задание, вызывая функцию handle_async_tcp_client.
Такой динамичный подход обеспечивает конкурентную обработку многочисленных клиентских подключений, что полностью соответствует
принципам асинхронного программирования.
Асинхронное программирование на Rust в сочетании с синтаксисом async/
await и такими средами исполнения, как tokio, позволяет разработчику писать
масштабируемые и эффективные сетевые приложения. Это особенно полезно
при работе с большим количеством одновременных соединений или при выполнении операций, ограниченных скоростью ввода-вывода.
Варианты использования из реальной
практики: обработка HTTP-запросов
В сценариях из реальной практики применение Rust в сетевом программировании охватывает огромное количество контекстов, среди которых наиболее заметным сценарием является написание веб-серверов, предназначенных
для обработки HTTP-запросов и раздачи веб-приложений клиентам. Отличительные атрибуты Rust, включая высокую производительность и строгую
защищенность памяти, позиционируют его как исключительный выбор для
конструирования надежных веб-серверов. Преимущества в производительности, обусловленные низкоуровневым контролем над системными ресурсами,
позволяют ему эффективно отвечать требованиям современных приложений,
характерных высокой интенсивностью данных. Одновременно с этим акцент
Rust на защищенности памяти посредством строгих правил владения и заимствования повышает его пригодность для разработки защищенных систем,
оберегая от таких распространенных ловушек, как обращение к памяти по пус
тым указателям и переполнение буфера, как вы увидели в главе 1 «Системное
программирование на языке Rust».
Рассмотрим сценарий, в котором веб-сервер сконструирован для раздачи
клиентам простого статического веб-сайта. Сервер может использовать пакет
hyper – популярную асинхронную HTTP-библиотеку для Rust. Ниже приведен
наглядный пример:
532
Сетевое программирование
Листинг 12.7. HTTP-сервер с использованием пакета hyper
use hyper::service::{make_service_fn, service_fn}; // ①
use hyper::{Body, Request, Response, Server};
// ②
async fn handle_request(_: Request<Body>) -> Result<Response<Body>,
hyper::Error> {
// Обработать запрос и сгенерировать ответ
let response = Response::new(Body::from("Привет, Веб-сервер Rust!"));
Ok(response)
}
#[tokio::main]
async fn main() { // ③
let addr = ([127, 0, 0, 1], 8080).into();
// ④
let make_service = make_service_fn(|_conn| { // ⑤
async { Ok::<_, hyper::Error>(service_fn(handle_request)) }
});
let server = Server::bind(&addr).serve(make_service);
// ⑥
println!("Веб-сервер Rust работает на http://{}", addr); // ⑦
if let Err(e) = server.await { // ⑧
eprintln!("Ошибка сервера: {}", e);
}
}
В приведенном выше примере исходного кода:
① пакет hyper задействуется, помогая создать асинхронный HTTP-сервер.
Импортируются такие модули, как make_service_fn, service_fn, Body,
Request, Response и Server, чтобы обеспечить конструирование сервера
и обработку HTTP-запросов;
② функция handle_request определена как асинхронная. Она обрабатывает
входящие HTTP-запросы и, для простоты, генерирует ответ, содержащий
сообщение "Привет, Веб-сервер Rust!";
③ функция main, помеченная атрибутом #[tokio::main], служит основной
точкой входа в программу. Эта аннотация сигнализирует об использовании среды исполнения пакета tokio для управления асинхронными заданиями;
④ сервер привязывается посредством вызова ([127, 0, 0, 1], 8080).into()
к IP-адресу 127.0.0.1 и порту 8080;
⑤ здесь создается замыкание make_service для построения сервиса для
каждого соединения. В нем используется функция service_fn, чтобы задействовать функцию handle_request для обработки входящих запросов;
⑥ экземпляр сервера пакета hyper создается с помощью Server::bind(&addr).
serve(make_service), объединяя указанный адрес сервера и замыкание make_
service;
⑦ адрес сервера выводится в консоль, предоставляя удобную ссылку для
доступа к веб-серверу Rust;
Варианты использования из реальной практики: обработка HTTP-запросов 533
⑧ сервер запускается с помощью server.await, при этом любые потенциальные ошибки во время его работы перехватываются и выводятся в консоль. Сервер работает неопределенно долго, непрерывно обрабатывая
входящие HTTP-запросы и генерируя ответы. Этот пример демонстрирует способности Rust в управлении сетевыми приложениями, показывая
его универсальность в контексте раздачи веб-контента.
Приведенный выше простой веб-сервер демонстрирует многогранность
Rust в работе с сетевыми приложениями, в том числе и в задачах раздачи вебконтента.
Приложение для обмена сообщениями по сети
Еще один сценарий из реальной практики включает реализацию сетевого приложения для чата. В сфере приложений для обмена сообщениями по сети, где
критически важен одновременный обмен сообщениями между многочисленными пользователями, способность Rust работать с конкурентностью становится решающей в создании отзывчивой и эффективной системы. Задействуя
библиотеку tokio, можно без труда координировать асинхронные задания, гарантируя, что чат-сервер останется отзывчивым даже под нагрузкой многочисленных одновременных соединений.
Кроме того, библиотека serde оказывается неоценимой для сериализации
и десериализации данных, оптимизируя процесс обмена информацией между
клиентами и сервером. Ниже приведен высокоуровневый пример, воплощающий суть того, как Rust с его способностями в области конкурентности и асинхронности может служить идеальным инструментом создания надежных
и высокопроизводительных приложений для обмена сообщениями по сети:
Листинг 12.8. Асинхронное приложение для чата
с использованием tokio и serde
use
use
use
use
use
tokio::net::{TcpListener, TcpStream};
tokio::io::{AsyncReadExt, AsyncWriteExt};
tokio::sync::broadcast;
serde::{Deserialize, Serialize};
std::net::SocketAddr;
#[derive(Debug, Serialize, Deserialize, Clone)] // ①
struct ChatMessage {
username: String,
content: String,
}
async fn handle_chat_client(
// ②
stream: TcpStream,
username: String,
sender: broadcast::Sender<ChatMessage>,
) {
println!("Подсоединен новый клиент: {}", username);
let mut receiver = sender.subscribe();
let (mut reader, mut writer) = tokio::io::split(stream);
534
Сетевое программирование
tokio::spawn(async move { // ③
loop {
let mut buffer = [0; 512];
if let Ok(bytes_read) = reader.read(&mut buffer).await {
if bytes_read == 0 {
break;
}
if let Ok(message) = serde_json::from_
slice::<ChatMessage>(&buffer[..bytes_read]) {
let _ = sender.send(message);
}
}
}
});
while let Ok(message) = receiver.recv().await { // ④
if message.username != username {
let response = serde_json::to_string(&message).unwrap();
if writer.write_all(response.as_bytes()).await.is_err() {
break;
}
println!("Сообщение отправлено к {}: {}", username, message.content);
}
}
println!("Клиент отсоединен: {}", username);
}
#[tokio::main] // ⑤
async fn main() {
let (sender, _) = broadcast::channel::<ChatMessage>(100); // ⑥
let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); // ⑦
let listener = TcpListener::bind(&addr).await.unwrap();
println!("Чат-сервер работает на {}", addr);
loop {
let
let
let
let
// ⑧
(stream, _) = listener.accept().await.unwrap();
mut buffer = [0; 512]; // ⑨
bytes_read = stream.peek(&mut buffer).await.unwrap();
username = String::from_utf8_lossy(&buffer[..bytes_read])
.trim()
.to_owned();
println!("Получен запрос на соединение от: {}", username);
}
}
let sender_clone = sender.clone();
tokio::spawn(handle_chat_client(stream, username, sender_clone)); // ⑩
// $ telnet 127.0.0.1 8080
// {"username": "Алиса", "content": "Привет, сервер!"}
В приведенном выше примере:
① исходный код начинается с определения структуры ChatMessage, помеченной общими чертами Debug, Serialize, Deserialize и Clone. Эта структура содержит в себе формат сообщений, используемых для общения
Варианты использования из реальной практики: обработка HTTP-запросов 535
в чате. Общая черта Debug позволяет выводить отладочную информацию,
а Serialize и Deserialize обеспечивают конвертацию структуры в JSON
и обратно. Наконец, общая черта Clone позволяет создавать независимые
копии экземпляров структуры ChatMessage;
② асинхронная функция handle_chat_client является краеугольным камнем сервера и отвечает за управление каждым подсоединенным клиентом. Она принимает такие параметры, как TCP-поток данных клиента
(TcpStream), его пользовательское имя (username) и широковещательный
отправитель сообщения (broadcast::Sender<ChatMessage>). При подсоединении нового клиента сервер выводит приветственное сообщение.
Создается клон широковещательного отправителя, что позволяет рассылать сообщения всем подсоединенным пользователям;
③ внутри этой функции клиентский поток данных разбивается на читающий и пишущий компоненты, что упрощает раздельную обработку
входящих и исходящих сообщений. Новое асинхронное задание конкурентно порождается для непрерывного чтения поступающих от клиента
сообщений в цикле. Эти сообщения десериализуются с помощью пакета
serde_json и впоследствии широковещательно транслируются всем клиентам, обеспечивая бесшовный обмен информацией внутри чат-сервера;
④ одновременно с этим функция получает сообщения от других клиентов
по широковещательному каналу. Для того чтобы клиент не получал собственные сообщения, пользовательское имя отправителя сравнивается
с именем текущего клиента: если они различаются, то сообщение отправляется пользователю. Исходящие сообщения сериализуются в формат JSON
перед записью в клиентский поток данных. Сервер заносит каждое успешно отправленное сообщение вместе с его содержимым в журнал. При отсо
единении клиента выводится сообщение об уходе клиента с данным пользовательским именем. Эта функция воплощает динамичную и конкурентную
природу управления многочисленными клиентами в асинхронной среде;
⑤ ⑥ функция main служит точкой входа в чат-сервер. Она начинается с создания широковещательного канала с помощью функции
broadcast::channel из модуля tokio::sync. Указанный канал предназначен для передачи экземпляров структуры ChatMessage с емкостью 100 сообщений, что обеспечивает эффективную связь между клиентами;
⑦ адрес сервера, заданный как 127.0.0.1:8080, заносится в SocketAddr и затем используется для привязки TCP-слушателя через TcpListener::bind.
Выводится сообщение, анонсирующее успешный запуск чат-сервера
по указанному адресу;
⑧ ⑨ затем сервер входит в бесконечный цикл, непрерывно принимая
входящие соединения. По каждому новому соединению создается TCPпоток данных TcpStream, а пользовательское имя подключающегося клиента извлекается, заглядывая в поток данных. Этот процесс помогает
уникально идентифицировать каждого клиента в системе чата;
⑩ по каждому подключенному клиенту с помощью статической функции
tokio::spawn порождается новое асинхронное задание. Это задание вызывает функцию handle_chat_client, передавая ей клиентский поток данных,
извлеченное пользовательское имя и клонированный широковещатель-
536
Сетевое программирование
ный отправитель. Такой механизм гарантирует конкурентную обработку
каждого клиента, обеспечивая отзывчивость и масштабируемость сервера.
Приведенный выше фрагмент исходного кода демонстрирует надежный
и масштабируемый чат-сервер, в котором используются принципы асинхронного программирования и среда исполнения tokio. Управление клиентами
виртуозно дирижируется, используя широковещательные каналы с целью эффективного распределения сообщений. Модульное конструктивное исполнение и интеграция с внешними пакетами, такими как serde для сериализации,
показывают универсальность Rust в построении высокопроизводительных
и конкурентных сетевых приложений.
Сервер передачи файлов
Разработка сервера передачи файлов – это практическое применение сетевого программирования, позволяющее клиентам бесшовно закачивать файлы
на сервер и скачивать файлы с сервера по сети. В данном разделе мы продемонстрируем создание простого, но надежного сервера передачи файлов с использованием стандартной библиотеки Rust. Акцент Rust на производительности, защищенности и конкурентности играет ключевую роль в разработке
сервера, который эффективно справляется с обменом данными. Этот пример
подчеркивает универсальность Rust в упрощении задач сетевого программирования, демонстрируя его способность предоставлять защищенные и оптимизированные решения для передачи файлов в различных сценариях – от локальных сетей до более широкой среды интернета.
Листинг 12.9. FTP-сервер с использованием стандартной библиотеки
use std::fs::File;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
fn handle_file_transfer_client(mut stream: TcpStream) {
let mut filename_buffer = [0; 128]; // ①
stream.read(&mut filename_buffer).unwrap();
let filename = String::from_utf8_lossy(&filename_buffer)
.split_terminator('\0')
// ②
.next()
// ③
.unwrap_or_default()
// ④
.trim()
// ⑤
.to_string();
let mut file = File::create(&filename).unwrap(); // ⑥
let mut buffer = [0; 1024];
// ⑦
loop {
let bytes_read = stream.read(&mut buffer).unwrap();
if bytes_read == 0 {
// ⑧
break;
}
file.write_all(&buffer[..bytes_read]).unwrap();
}
println!("Файл '{}' успешно получен.", filename);
}
Варианты использования из реальной практики: обработка HTTP-запросов 537
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); // ⑨
println!("Сервер передачи файлов слушает на 127.0.0.1:8080...");
for stream in listener.incoming() { // ⑩
match stream {
Ok(stream) => {
println!("Получено новое соединение для передачи файлов");
std::thread::spawn(|| handle_file_transfer_client(stream));
}
Err(e) => {
eprintln!("Ошибка: {}", e);
}
}
}
}
Разбирая детали этого фрагмента исходного кода, следует выделить функцию handle_file_transfer_client как ядро функциональности сервера передачи файлов.
① Внутри этой функции процесс начинается с чтения имени файла от клиента и организации приема как самого имени файла, так и его содержимого. Следующий шаг предусматривает сохранение полученного файла
на стороне сервера, что закладывает основу для процесса передачи.
② Имя файла изначально читается в буфер. Эта строка кода стратегически разбивает данные по нулевым байтам ('\0'), исходя из нультерминированности – распространенная практика в сетевой связи.
③ Метод next играет ключевую роль в извлечении первой части данных
перед нулевым байтом, эффективно отделяя имя файла от остального
потока данных.
④ Здесь предусмотрен план предотвращения ошибок на случай отсутствия
валидных символов. В таких ситуациях в качестве дефолтного значения
используется пустой строковый литерал, что обеспечивает адаптивность
процесса к различным сценариям ввода.
⑤ Дальнейшая обработка включает удаление начальных и концевых пробелов в имени файла, что повышает надежность и чистоту полученной
информации.
⑥ После извлечения имени сервер переходит к созданию файла на своей
стороне для записи данных. Этот шаг критически важен для подготовки
места хранения входящего контента.
⑦ В целях оптимизации процесса данные читаются и пишутся кусками.
Такой подход повышает эффективность, особенно при работе с файлами
большого объема.
⑧ Циклическая структура содержит в себе условие выхода, которое срабатывает при встрече нулевого байта1. Оно сигнализирует о завершении процесса
передачи, позволяя серверу эффективно обрабатывать файлы любой длины.
⑨ Переходя к функции main, она инициирует создание TCP-слушателя
по адресу "127.0.0.1:8080", который служит точкой входа для входящих
соединений.
1
Обратите внимание: в исходном коде цикл прерывается не при встрече нулевого
байта, а в ситуации, когда bytes_read == 0 (конец потока данных). – Прим. перев.
538
Сетевое программирование
⑩ Функция main отлично справляется с динамической природой сетевой связи, принимая входящие соединения и выделяя под каждое из них новый поток исполнения. Использование статической функции std::thread::spawn
обеспечивает конкурентную обработку многочисленных клиентов, добавляя серверу передачи файлов слой масштабируемости.
Приведенный выше грамотно структурированный сервер не только позволяет клиентам передавать имена файлов и их содержимое, но и гарантирует
надежное хранение данных на стороне сервера. Его архитектура демонстрирует способности Rust в управлении тонкостями сетевого взаимодействия и конкурентности. Предоставляя прочный фундамент для систем передачи файлов,
данная реализация подчеркивает универсальность Rust в создании отказо
устойчивых и эффективных сетевых приложений.
Сервер дистанционного исполнения команд
Еще один убедительный пример использования Rust – создание серверов,
которые обеспечивают дистанционное исполнение команд (от англ. remote
command execution, абрр. RCE) клиентами. Подобная необходимость часто возникает в средах распределенных вычислений или при дистанционном системном администрировании, где критически важную роль играет беспрепятственное взаимодействие с дистанционно расположенными системами. На Rust можно создать простой, но мощный пример такого сервера для обработки команд,
отправляемых клиентами издалека. Задействовав способности Rust в сетевом
программировании, конкурентности и взаимодействии на системном уровне,
этот сервер становится надежным инструментом оркестровки дистанционным
исполнением команд. Такая функциональность неоценима в сценариях, где
необходимы эффективное управление и контроль систем по сети, что вновь
демонстрирует универсальность Rust в решении практических задач в области
распределенных вычислений и дистанционного администрирования.
Листинг 12.10. RCE-сервер с использованием стандартной библиотеки
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
use std::process::{Command, Stdio};
fn handle_remote_command_client(mut stream: TcpStream) {
let mut command_buffer = [0; 512]; // ①
stream.read(&mut command_buffer).unwrap();
let command_str = String::from_utf8_lossy(&command_buffer)
.split_terminator('\0')
// ②
.next()
// ③
.unwrap_or_default()
// ④
.trim()
// ⑤
.to_string();
let output = Command::new("sh")
.arg("-c")
.arg(&command_str)
.stdout(Stdio::piped())
// ⑥
Варианты использования из реальной практики: обработка HTTP-запросов 539
.output()
.unwrap();
}
stream.write_all(&output.stdout).unwrap(); // ⑦
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); // ⑧
println!("Сервер дистанционного исполнения команд слушает
на 127.0.0.1:8080...");
for stream in listener.incoming() { // ⑨
match stream {
Ok(stream) => {
println!("Принято новое соединение для дистанционного исполнения команд");
std::thread::spawn(|| handle_remote_command_client(stream)); // ⑩
}
Err(e) => {
eprintln!("Ошибка: {}", e);
}
}
}
}
// Вывод:
// Сервер
// $ cargo run
// Сервер дистанционного исполнения команд слушает на 127.0.0.1:8080...
// Принято новое соединение для дистанционного исполнения команд
//
//
//
//
Клиент
$ nc 127.0.0.1 8080
ls src
main.rs
Давайте разберем детали этого фрагмента исходного кода, который служит
примером создания простого сервера дистанционного исполнения команд:
① функция handle_remote_command_client отвечает за обработку команд
клиента. Она читает команду, очищает ее от лишних символов, исполняет с помощью системной оболочки, перехватывает результат и отправляет его обратно клиенту;
② полученная команда разбивается по нулевым байтам, что теоретически
позволяет отправлять несколько команд в одной передаче;
③ метод next извлекает первую часть разбитой на части команды, гарантируя, что сервер работает именно с предоставленными входными данными;
④ если валидные символы отсутствуют, то метод unwrap_or_default подставляет пустой строковый литерал во избежание отказов в работе;
⑤ метод trim удаляет любые начальные или концевые пробелы из текста
команды;
540
Сетевое программирование
⑥ команда исполняется посредством системной оболочки (sh -c), а ее результат перехватывается сервером;
⑦ затем сервер отправляет результат исполнения команды обратно клиенту через сетевое соединение;
⑧ в функции main создается TCP-слушатель, привязанный к адресу
"127.0.0.1:8080";
⑨ сервер ожидает входящие соединения в цикле, принимая каждое из них
и порождая новый поток исполнения для обслуживания клиента;
⑩ по каждому принятому соединению создается новый поток исполнения,
который вызывает функцию handle_remote_command_client для конкурентной обработки команд.
Выше была представлена простая, но надежная реализация сервера дистанционного исполнения команд на Rust, которая подчеркивает искусность языка
в бесшовном управлении сетевой связью и конкурентным исполнением. Привлечение потоков исполнения является ключевым элементом, дающим серверу возможность эффективно обслуживать многочисленных клиентов конкурентно. Это не только повышает производительность, но и придает решению
универсальность и практичность для различных сценариев дистанционного
управления. Уникальные функциональные средства Rust обеспечивают прочный фундамент для разработки таких сетевых приложений, где критически
важны отзывчивость и масштабируемость. Этот пример служит свидетельством способностей Rust в создании решений, сочетающих в себе простоту
и мощь, особенно в сфере сетевого программирования и исполнения команд.
Одноранговый обмен файлами
Системы однорангового (peer-to-peer, аббр. P2P) обмена файлами представляют собой децентрализованный подход к распространению данных, обеспечивая прямой обмен файлами между пользователями без опоры на центральный
сервер. Rust, с его мощными функциональными средствами обеспечения конкурентности и выдающимися способностями по низкоуровневому сетевому
взаимодействию, отлично подходит для разработки P2P-приложений. Поддержка конкурентности в языке позволяет эффективно обрабатывать многочисленные задания в конкурентном режиме, что крайне важно в динамичной
среде однорангового обмена. В отсутствие центрального посредника способности Rust позволяют разработчику создавать устойчивые и отзывчивые P2Pсерверы. Следующий ниже пример дает упрощенное представление о создании такого сервера, демонстрируя, как сильные стороны Rust бесшовно соответствуют требованиям децентрализованных систем обмена файлами.
Листинг 12.11. Одноранговый обмен файлами
с использованием стандартной библиотеки
use
use
use
use
use
std::net::{TcpListener, TcpStream};
std::io::{Read, Write};
std::fs::File;
std::thread;
std::env;
Варианты использования из реальной практики: обработка HTTP-запросов 541
fn handle_p2p_client(mut stream: TcpStream) {
let mut filename_buffer = [0; 128]; // ①
stream.read(&mut filename_buffer).unwrap();
let filename = String::from_utf8_lossy(&filename_buffer) // ②
.split_terminator('\0')
// ③
.next()
// ④
.unwrap_or_default()
// ⑤
.trim()
// ⑥
.to_string();
println!("Рабочая директория сервера: {:?}", env::current_dir());
println!("Получен запрос на файл: {}", filename);
let file = File::open(&filename);
// ⑦
let response: Vec<u8> = match file {
Ok(mut file) => {
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).unwrap();
buffer
}
Err(_) => {
println!("Файл не найден: {}", filename);
Vec::new() // ⑧
}
};
}
stream.write_all(&response).unwrap(); // ⑨
println!("Отправлен ответ клиенту в отношении файла: {}", filename);
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); // ⑩
println!("P2P-сервер обмена файлами слушает на 127.0.0.1:8080...");
for stream in listener.incoming() { // ⑪
match stream {
Ok(stream) => {
println!("Принято новое соединение для P2P-обмена файлами");
thread::spawn(|| handle_p2p_client(stream)); // ⑫
}
Err(e) => {
eprintln!("Ошибка: {}", e);
}
}
}
}
// Вывод:
// Сервер
// $ cargo run
//
//
//
//
//
P2P-сервер обмена файлами слушает на 127.0.0.1:8080...
Принято новое соединение для P2P-обмена файлами
Рабочая директория сервера: Ok("/home/mahmoud/chapter-12/p2p")
Получен запрос на файл: file.txt
Отправлен ответ клиенту в отношении файла: file.txt
542
Сетевое программирование
// Клиент
// $ nc 127.0.0.1 8080
//
//
//
//
file.txt
Строка 1
Строка 2
Строка 3
Давайте разберем каждую часть этого фрагмента исходного кода однорангового сервера обмена файлами:
① функция handle_p2p_client является ядром функциональности сервера.
Она читает имя файла, переданное клиентом через TCP-соединение;
② полученное имя файла может содержать нулевые байты. Для того чтобы
обеспечить правильность имени, эти байты удаляются из буфера;
③ метод split_terminator используется для разбивки строкового значения
по нулевым байтам, создавая итератор;
④ метод next извлекает первую часть разбитого на части строкового значения, которая представляет собой имя файла перед нулевым байтом;
⑤ метод unwrap_or_default гарантирует, что если в имени файла нет валидных символов, то будет использован пустой строковый литерал;
⑥ метод trim удаляет начальные и концевые пробелы из имени файла,
обеспечивая чистый и отформатированный результат;
⑦ рабочая директория сервера и полученное имя файла выводятся в консоль для информационных целей;
⑧ сервер пытается открыть указанный клиентом файл для чтения. В случае
успеха содержимое файла читается в буфер;
⑨ содержимое файла отправляется обратно клиенту по TCP-соединению,
завершая процесс обмена;
⑩ в функции main создается TCP-слушатель, привязанный к адресу
"127.0.0.1:8080";
⑪ сервер входит в цикл для приема входящих соединений. По каждому соединению порождается новый поток исполнения с целью конкурентного
обслуживания клиента;
⑫ каждое принятое соединение инициирует создание нового потока исполнения с помощью статической функции thread::spawn, где исполняется функция handle_p2p_client. Такой многопоточный подход расширяет возможности сервера по одновременной обработке многочисленных
клиентских запросов.
В приведенном выше P2P-сервере клиенты могут запрашивать файл, отправляя его имя, а сервер – отвечать, отправляя содержимое файла. Этот прос
той пример закладывает фундамент для создания более продвинутых одноранговых систем.
Реально-временное коллаборативное редактирование
Сетевое программирование на Rust может применяться для построения систем реально-временного коллаборативного редактирования, в которых многочисленные пользователи могут одновременно изменять общий документ.
Варианты использования из реальной практики: обработка HTTP-запросов 543
В данном примере для связи используется протокол WebSocket с применением
пакета tungstenite.
Листинг 12.12. Коллаборативное редактирование в реальном
времени с использованием пакетов tokio и tungstenite
use
use
use
use
use
futures_util::SinkExt;
futures_util::StreamExt; // ①
futures_util::stream::TryStreamExt;
tokio::net::TcpListener;
tokio_tungstenite::accept_async;
#[tokio::main]
async fn main() {
let addr: std::net::SocketAddr = "127.0.0.1:8080".parse().
expect("Недопустимый адрес"); // ②
let listener = TcpListener::bind(&addr)
.await
.expect("Не удалось привязать к адресу");
}
println!(
"Сервер реально-временного коллаборативного \
редактирования слушает на {}",
addr)
; // ③
handle_editor_connection(listener).await; // ④
async fn handle_editor_connection(listener: TcpListener) { // ⑤
while let Ok((socket, addr)) = listener.accept().await {
println!("Принято новое соединение от: {}", addr);
tokio::spawn(handle_editor_client(socket, addr)); // ⑥
}
}
async fn handle_editor_client(socket: tokio::net::TcpStream, addr:
std::net::SocketAddr) { // ⑦
let ws_stream = match accept_async(socket).await { // ⑧
Ok(ws_stream) => {
println!("WebSocket-соединение установлено с: {}", addr);
ws_stream
}
Err(e) => { // ⑨
eprintln!(
"Ошибка при установлении WebSocket-соединения с {}: {}", addr, e
);
return;
}
};
let (mut write, mut read) = ws_stream.split(); // ⑩
tokio::spawn(async move { // ⑪
while let Ok(Some(message)) = read.try_next().await {
println!(
544
Сетевое программирование
"Получено сообщение от {}: {}",
addr,
message.to_text().unwrap_or_default()
); // ⑫
if let Err(e) = write.send(message).await { // ⑬
eprintln!(
"Ошибка при отправке WebSocket-сообщения в {}: {}", addr, e
);
break;
}
}
if let Err(e) = write.close().await { // ⑭
eprintln!("Ошибка при закрытии WebSocket-соединения с {}: {}", addr,
e);
}
});
}
println!("WebSocket-соединение с {} закрыто", addr);
// Вывод:
// Сервер
// $ cargo run
//
//
//
//
//
Сервер реально-временного коллаборативного редактирования слушает на 127.0.0.1:8080
Принято новое соединение от: 127.0.0.1:38154
WebSocket-соединение установлено с: 127.0.0.1:38154
Получено сообщение от 127.0.0.1:38154: Привет, WebSocket-сервер!
WebSocket-соединение с 127.0.0.1:38154 закрыто
// Клиент
// $ websocat ws://127.0.0.1:8080
// Привет, WebSocket-сервер!
// Привет, WebSocket-сервер!
// ^C
Давайте проинспектируем детали приведенного выше фрагмента исходного
кода, который настраивает сервер для обработки WebSocket-соединений. В исходном коде используется несколько библиотек, включая futures_util, tokio
и tokio_tungstenite, для оптимизации асинхронных операций и обработки
WebSocket-соединений. Функция main, отмеченная макросом #[tokio::main],
служит точкой входа: она определяет адрес сервера, привязывает его и инициирует обработку подсоединений к редактору.
① Исходный код начинается с импорта необходимых библиотек для работы с фьючерсами, асинхронными потоками данных, TCP-соединениями
и WebSocket-операциями.
② Адрес сервера определен как "127.0.0.1:8080".
③ TCP-слушатель привязывается к указанному адресу, и сообщение о готовности сервера выводится в консоль.
④ Вызывается функция handle_editor_connection, чтобы инициировать обработку входящих соединений.
Варианты использования из реальной практики: обработка HTTP-запросов 545
⑤ Функция handle_editor_connection организована как цикл, который принимает входящие соединения и порождает новое задание для каждого
из них.
⑥ Каждое новое соединение инициирует порождение нового задания
по обработке WebSocket-соединения посредством функции handle_
editor_client.
⑦ Функция handle_editor_client определена для работы с индивидуальными клиентами редактора и пытается установить WebSocket-соединение.
⑧ WebSocket-поток данных разбивается на читающий и пишущий компоненты, что позволяет независимо обрабатывать входящие и исходящие
сообщения.
⑨ Ошибки на этапе WebSocket-рукопожатия обрабатываются с выводом соответствующих сообщений в консоль.
⑩ Порождается новое задание для асинхронной обработки входящих сообщений.
⑪ Внутри этого асинхронного задания обрабатываются входящие сообщения; там же предоставляется местозаполнитель (в коде отмечен комментарием с пунктом ⑫) для логики коллаборативного редактирования (например, обновлений документов).
⑬ Полученное сообщение широковещательно транслируется всем подсоединенным клиентам, при этом обрабатываются возможные ошибки отправки.
⑭ WebSocket-соединение щадяще закрывается, и выдаются сообщения
о любых ошибках в процессе закрытия.
Приведенный выше фрагмент исходного кода служит комплексной основой
для создания сервера реально-временного коллаборативного редактирования.
Логику взаимодействия можно адаптировать под конкретные требования проекта. Исходный код демонстрирует мощь асинхронного программирования
на Rust, эффективно задействуя такие библиотеки, как tokio и tokio_tungstenite.
Для взаимодействия с этим сервером можно использовать утилиту websocat
через командный интерфейс (CLI).
Сначала надо проверить, чтобы утилита websocat была установлена. Ее можно установить посредством менеджера пакетов cargo:
$ cargo install websocat
Предположим, исходный код был сохранен в файле main.rs. Тогда для сборки и исполнения исходного кода используется следующая ниже команда cargo:
$ cargo run
В результате будет скомпилирована программа и запущен WebSocket-сервер
по адресу 127.0.0.1:8080.
Теперь откройте второе окно терминала и подсоединитесь к серверу:
$ websocat ws://127.0.0.1:8080
Приведенная выше команда сообщит утилите websocat установить соединение. Вы увидите сообщение об успешном подсоединении. Теперь все, что
546
Сетевое программирование
вы введете в терминале websocat, будет отправляться на сервер, а любые сообщения от сервера будут отображаться экране.
Например, введите сообщение и нажмите клавишу Enter. Вы должны увидеть, как оно обрабатывается сервером, и если логика коллаборативного редактирования предусматривает широковещательную трансляцию (в качестве
местозаполнителя в исходном коде), то сообщение вернется вам обратно:
Привет, WebSocket-сервер!
Терминал сервера при исполнении команды cargo run также будет отображать сообщения о полученных и транслированных данных. Для закрытия соединения надо просто закрыть терминал websocat.
Приведенные выше варианты использования из реальной практики демонстрируют универсальность Rust в сетевом программировании и его способности обеспечивать построение разнообразных и сложных приложений.
Заключение
На протяжении этой обширной главы, посвященной сетевому программированию на Rust, мы провели обследование обширного ландшафта вариантов использования из реальной практики, продемонстрировав невероятную
универсальность языка в решении самых разных сценариев сетевого взаимодействия. От конструирования веб-серверов и чат-приложений до тонкого
использования пакета tokio – Rust везде зарекомендовал себя как надежный
фундамент для разработки эффективных и стабильных сетевых решений.
В динамичной среде сетевого программирования Rust предлагает целый
спектр инструментов. Высокоуровневые абстракции стандартной библиотеки
дополняются сторонними пакетами, такими как tokio и hyper для асинхронных
и HTTP-приложений, предоставляя гибкие варианты на выбор. В то же время пакет socket2 дает полный контроль над конфигурацией сокетов, позволяя создавать индивидуализированные решения под специфические сетевые требования.
Вооружившись знаниями из этой главы, вы полностью готовы к работе
со сложностями сетевого программирования на Rust. Полученный опыт станет вашим мощным инструментарием для разработки приложений, которые
не только производительны и масштабируемы, но и искусно справляются
с вызовами современных сред сетевого взаимодействия. Независимо от области вашего интереса – выбор высокоуровневых абстракций или погружение
в низкоуровневое взаимодействие с сокетами – Rust всегда остается ключевым
инструментом в стремлении к построению устойчивых и эффективных систем
сетевого взаимодействия.
Завершая наше обследование темы сетевого взаимодействия на Rust, в следующей главе мы погрузимся в тему незащищенного программирования. Она
познакомит вас с более рискованными аспектами Rust, где возможен максимальный контроль, но необходима крайняя осторожность. Мы разберем причины, по которым существует незащищенный исходный код, и ситуации, когда его уместно применять. Будь то работа с уникальными типами данных или
выполнение продвинутых операций, вы узнаете, как Rust позволяет решать
амбициозные задачи, сохраняя верность принципам защищенности.
Дополнительные ресурсы 547
Дополнительные ресурсы
В целях углубления своих знаний в области сетевого программирования
на Rust рекомендуется обратиться к следующим ниже ресурсам.
Книга по Rust – «Создание многопоточного веб-сервера»: обратитесь
к официальной документации Rust, чтобы построить многопоточный
веб-сервер и получить представление о практическом применении сетевого программирования: https://doc.rust-lang.org/book/ch20-00-finalproject-a-web-server.html.
Учебник по tokio: ознакомьтесь с асинхронным программированием
на Rust с использованием пакета tokio, который предлагает популярную
среду исполнения для построения масштабируемых и эффективных сетевых приложений: https://tokio.rs/tokio/tutorial.
Документация async-std: ознакомьтесь с документацией пакета asyncstd, который предоставляет асинхронные версии типов стандартной
библиотеки Rust, необходимые для асинхронного сетевого программирования: https://docs.rs/async-std/.
Документация hyper: ознакомьтесь с документацией пакета hyper – широко используемой HTTP-библиотеки Rust, чтобы понять принципы построе
ния надежных и производительных HTTP-серверов: https://docs.rs/hyper/.
Документация tungstenite: научитесь программировать протокол
WebSocket на Rust, ознакомившись с документацией пакета tungstenite –
ключевого инструмента для реально-временной связи: https://docs.rs/
tungstenite/.
Документация serde: разберитесь в тонкостях сериализации и десериализации данных для сетевого взаимодействия с помощью пакета serde, используя информацию из официальной документации: https://docs.rs/serde/.
Документация warp: ознакомьтесь с документацией пакета warp – мощного веб-фреймворка для Rust, который упрощает построения API в архитектурном стиле RESTful и веб-приложений: https://docs.rs/warp/.
Документация reqwest: научитесь выполнять HTTP-запросы из приложений на Rust с помощью пакета reqwest, изучив возможности клиентского сетевого взаимодействия: https://docs.rs/reqwest/.
async-raft: ознакомьтесь с пакетом async-raft для создания распределенных систем с использованием консенсуса Raft, расширив свои знания
о сетевых приложениях на Rust: https://github.com/async-raft/async-raft.
Книга «Сетевое программирование на Rust» (Network Programming
in Rust): обратитесь к этой книге за всеобъемлющим руководством по сетевому программированию на Rust, охватывающим широкий спектр тем
и практических примеров.
Мы уже в игре? – сетевое взаимодействие: будьте в курсе состояния сетевой экосистемы Rust благодаря веб-сайту «Are We Game Yet?», предоставляющему курируемый список сетевых пакетов и инструментов: https://
arewegameyet.rs/ecosystem/networking/.
Приведенные выше ресурсы охватывают различные аспекты сетевого программирования на Rust: от построения веб-серверов до обработки асинхронной
связи и обследования популярных библиотек для задач сетевого взаимодействия.
548
Сетевое программирование
Вопросы для самопроверки
Вопрос 1. Какое функциональное средство Rust играет ключевую роль
в обеспечении защищенности памяти в сетевых приложениях?
a) синтаксис async/await
b) система владения и контролёр заимствований
c) среда исполнения tokio
d) сторонние пакеты
Вопрос 2. При создании сетевых приложений на Rust выбор между стандартной библиотекой и сторонними пакетами зависит от:
a) популярности библиотеки
b) личных предпочтений разработчика
c) сложности сетевых задач
d) размера коллектива разработчиков
Вопрос 3. Каково предназначение функции handle_client в примере
с TCP-сервером?
a) инициирование нового TCP-соединения
b) чтение данных от клиента и генерация HTTP-ответа
c) закрытие TCP-соединения
d) обработка ошибок во время соединения
Вопрос 4. Какой протокол известен своей надежностью и высокой точностью, так как он подтверждает доставку пакетов данных и повторно
передает любые потерянные или поврежденные биты?
a) протокол управления передачей (TCP)
b) протокол пользовательских датаграмм (UDP)
c) протокол передачи гипертекста (HTTP)
d) протокол передачи файлов (FTP)
Вопрос 5. Какой пакет широко используется для асинхронного программирования на Rust?
a) serde
b) tokio
c) hyper
d) rustc
Вопрос 6. Какова роль функции broadcast::channel в примере с сетевым
приложением для чата?
a) она определяет структуру сообщений чата
b) она управляет связью с TCP-клиентами
c) она создает канал для передачи сообщений чата между клиентами
d) она ограничивает доступ к адресу сервера
Вопросы для самопроверки 549
Вопрос 7. Что означает атрибут tokio::main в примере с чат-сервером?
a) он определяет структуру функции main
b) он предоставляет дефолтные реализации для асинхронных заданий
c) он помечает функцию main как точку входа, сигнализируя об использовании среды исполнения tokio
d) он ограничивает доступ к адресу сервера
Вопрос 8. Чему способствует библиотека serde в примере с приложением
для чата?
a) она определяет структуру сообщений чата
b) она управляет связью с TCP-клиентами
c) она сериализует и десериализует обмениваемые данные
d) она ограничивает доступ к адресу сервера
Вопрос 9. Что делает функция tokio::spawn в примере с чат-сервером?
a) она определяет структуру функции main
b) она предоставляет дефолтные реализации для асинхронных заданий
c) она порождает новое асинхронное задание для каждого подсоединенного клиента
d) она ограничивает доступ к адресу сервера
Вопрос 10. Какова роль функции handle_file_transfer_client в примере
с сервером передачи файлов?
a) инициирование TCP-слушателя
b) обработка входящих соединений и управление передачей файлов
c) создание файла на стороне клиента
d) запись данных в файл на стороне сервера
Вопрос 11. Что делает метод split_terminator в исходном коде сервера передачи файлов?
a) разбивает содержимое файла на куски
b) разбивает имя файла по нулевым байтам
c) прерывает исполнение сервера
d) прерывает клиентское соединение
Вопрос 12. Как сервер передачи файлов обрабатывает завершение процесса передачи?
a) используя специальный символ завершения в содержимом файла
b) резко закрывая соединение
c) встречая нулевой байт в имени файла
d) обнаруживая, что при передаче содержимого прочитано ноль байт
Вопрос 13. Зачем используется метод stdout(Stdio::piped()) в сервере дистанционного исполнения команд?
550
a)
b)
c)
d)
Сетевое программирование
для перенаправления стандартного вывода в конвейер
для закрытия стандартного вывода
для вывода результата в консоль
для перехватывания стандартного вывода ошибок
Вопрос 14. Как P2P-сервер обмена файлами обрабатывает запрос на несуществующий файл?
a) прерывает исполнение сервера
b) отправляет сообщение об ошибке клиенту
c) выводит сообщение об ошибке в консоль
d) отправляет пустой ответ клиенту
Вопрос 15. Какова цель функции accept_async в сервере реально-временного коллаборативного редактирования?
a) принимает входящие TCP-соединения
b) принимает входящие WebSocket-соединения
c) принимает входящие передачи файлов
d) принимает входящие исполнения команд
Вопрос 16. Как сервер реально-временного коллаборативного редактирования обрабатывает ошибки передачи WebSocket-сообщений?
a) прерывает исполнение сервера
b) выводит сообщение об ошибке в консоль
c) отправляет сообщение об ошибке клиенту
d) закрывает WebSocket-соединение
Ответы
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
b) система владения и контролёр заимствований
c) сложности сетевых задач
b) чтение данных от клиента и генерация HTTP-ответа
a) протокол управления передачей (TCP)
b) tokio
c) она создает канал для передачи сообщений чата между клиентами
c) он помечает функцию main как точку входа, сигнализируя об использовании среды исполнения tokio
c) она сериализует и десериализует обмениваемые данные
c) она порождает новое асинхронное задание для каждого подсоединенного клиента
b) обработка входящих соединений и управление передачей файлов
b) разбивает имя файла по нулевым байтам
d) обнаруживая, что при передаче содержимого прочитано ноль байт
a) для перенаправления стандартного вывода в конвейер
d) отправляет пустой ответ клиенту
b) принимает входящие WebSocket-соединения
d) закрывает WebSocket-соединение
Ключевые термины 551
Ключевые термины
tokio: широко используемая асинхронная среда исполнения для Rust,
упрощающая разработку неблокирующего конкурентного исходного кода.
Стандартная библиотека: набор модулей и функциональностей, поставляемых вместе с Rust и предоставляющих основные инструменты
для решения распространенных задач программирования.
Сторонние пакеты: внешние библиотеки Rust, которые можно добавлять в проект с целью расширения его функциональности; нередко создаются и поддерживаются сообществом Rust.
Протокол управления передачей (TCP): надежный и ориентированный на соединение протокол связи, обеспечивающий точную и упорядоченную передачу данных между устройствами.
Протокол пользовательских датаграмм (UDP): легковесный протокол
связи без установления соединения, который отдает приоритет скорости
и минимальным накладным расходам, что подходит для реально-временных приложений.
Асинхронное программирование: парадигма программирования
на Rust, позволяющая многочисленным заданиям работать конкурентно, не дожидаясь завершения друг друга, что повышает отзывчивость
и пропускную способность системы.
hyper: популярная асинхронная HTTP-библиотека для Rust, используемая для построения веб-серверов и обработки HTTP-запросов.
serde: библиотека Rust для сериализации и десериализации данных, широко применяемая в сетевых приложениях с целью эффективной организации связи.
Широковещательный канал (broadcast channel): канал связи в Rust, позволяющий отправлять сообщения многочисленным получателям; обычно используется для рассылки сообщений в конкурентных системах.
Одноранговый обмен файлами (peer-to-peer, P2P): децентрализованные системы обмена файлами, позволяющие пользователям обмениваться данными напрямую без участия центрального сервера.
accept_async: функция в сервере реально-временного коллаборативного
редактирования, которая асинхронно принимает входящие WebSocketсоединения.
WebSocket: протокол связи, обеспечивающий полнодуплексные каналы
связи через одно долгоживущее соединение; в примере сервера используется для реально-временного коллаборативного редактирования.
Глава 13
Незащищенное
программирование на Rust
Введение
В Rust умение работать с незащищенным исходным кодом играет невероятную важность. Rust придерживается заботы о защищенности памяти и эффективности программ, но иногда для решения специфических задач приходится
использовать незащищенный исходный код. Эта глава станет путеводителем:
она поможет понять суть незащищенного исходного кода, а также ситуации
и причины, по которым он может понадобиться.
Rust славится своей сверхосторожностью в вопросах защиты памяти компьютера. В языке действуют строгие правила, предотвращающие типичные
ошибки, которые приводят к аварийному прекращению работы программ или
другим проблемам. Они придают программам на Rust надежность и быстроту.
Однако в определенных ситуациях – особенно при выполнении низкоуровневых заданий или интеграции с другими языками – эти строгие правила могут
становиться помехой. Именно здесь на помощь приходит незащищенный исходный код. Это мощный инструмент, который требует повышенного внимания, чтобы все оставалось в рабочем состоянии.
Понимание причин и ситуаций, когда стоит использовать незащищенный
исходный код, придает разработчику своего рода «суперсилу». В этой главе
мы не только разберем теорию, но и приведем реальные примеры. Знание ситуаций, в которых незащищенный исходный код необходим, помогает принимать рациональные решения, используя мощь этого инструмента там, где это
оправдано, и при этом придерживаясь основных принципов Rust. Цель этой
главы – наделить вас знаниями и умениями здравого суждения, чтобы вы могли работать с незащищенным исходным кодом защищенно и эффективно.
Содержание главы
В этой главе будут рассмотрены следующие темы:
введение в незащищенный исходный код;
разбор сценариев использования блоков unsafe;
вопросы производительности и защищенности незащищенного исходного кода;
Незащищенный исходный код 553
реальные примеры использования незащищенного исходного кода;
нарушения защищенности памяти;
практические примеры.
Незащищенный исходный код
Незащищенный исходный код в Rust позволяет обходить защитные гарантии,
которые обычно обеспечиваются компилятором. Он дает возможность выполнять операции, которые считаются слишком рискованными в рамках ограничений защищенного Rust. В целях понимания случаев, когда это необходимо, важно четко понимать приверженность Rust принципам защищенности и те ситуации, в которых обход этих защитных барьеров становится крайне необходимым.
Блоки unsafe – это мощное функциональное средство языка, но к ним нельзя относиться легкомысленно. Они зарезервированы для сценариев, где преимущества обхода проверок защищенности компилятором Rust перевешивают риски. Сюда относятся ситуации, когда взаимодействие с внешними языками или выполнение низкоуровневых системных операций требует выхода
за пределы «защитной сети», предоставляемой языком. Давайте рассмотрим
следующий ниже пример:
Листинг 13.1. Пример блока unsafe
fn main() {
let safe_variable = 42; // ①
println!("Это защищенный исходный код: {}", safe_variable); // ②
}
unsafe {
let raw_pointer: *const i32 = &safe_variable;
// ③
println!(
"Значение незащищенного сырого указателя: {}", *raw_pointer
); // ④
}
// Вывод:
// Это защищенный исходный код: 42
// Значение незащищенного сырого указателя: 42
В приведенном выше примере:
① здесь объявляется новая переменная с именем safe_variable, которой
присваивается значение 42. Для объявления используется ключевое слово let, и в данном случае переменная является немутируемой, на что
указывает отсутствие ключевого слова mut. Значение 42 служит примером, а сама переменная имеет тип i32 (32-битное целое число со знаком). Эта строка кода представляет собой стандартную и защищенную
инициализацию переменной на Rust;
② в следующей строке используется макрофункция println! для вывода сообщения на экран вместе со значением переменной safe_variable. Эта
строка служит примером защищенного исходного кода на Rust, соблюдающего гарантии языка по защите памяти. Макрофункция принимает
554
Незащищенное программирование на Rust
форматный строковый литерал с местозаполнителем {}, который заменяется фактическим значением переменной на этапе исполнения;
③ внутри блока unsafe вводится сырой указатель. Это делается с помощью
объявления let raw_pointer: *const i32 = &safe_variable;. Тип *const i32
обозначает сырой указатель на немутируемое целое число типа i32. Синтаксис &safe_variable извлекает адрес памяти переменной safe_variable,
а сырой указатель инициализируется этим адресом. Важно отметить, что
работа с сырыми указателями в Rust потенциально не защищена, так как
она обходит принятые в языке привычные проверки защищенности;
④ в блоке unsafe происходит обращение к памяти по сырому указателю
с помощью *raw_pointer, чтобы получить значение, на которое он указывает. Затем это значение выводится на экран с помощью макрофункции println!. Обращение к памяти по сырым указателям помечается как
незащищенное, поскольку оно предусматривает прямое взаимодействие
с адресами памяти без гарантий, предоставляемых принятыми в Rust
системой владения и правилами заимствования. Это мощное функциональное средство, которое требует взвешенного подхода и должно применяться только в сценариях, где преимущества перевешивают риски.
Приведенный выше фрагмент исходного кода демонстрирует ситуацию, когда использование незащищенного исходного кода, в частности сырых указателей, становится необходимым для низкоуровневых операций. Защищенный
исходный код перед блоком unsafe показывает соблюдение принципов Rust,
тогда как последующий блок unsafe демонстрирует осознанное отступление
от этих гарантий с целью достижения конкретной цели, иллюстрируя контролируемую и целенаправленную природу незащищенного исходного кода в Rust.
Необходимо глубоко понимать потенциальные риски, связанные с использованием незащищенного исходного кода. Сюда относится возможность возникновения неопределенного поведения, нарушений защищенности памяти
или гонки за доступ к данным. Прежде чем приступать к использованию блоков unsafe, необходимо тщательно оценивать, действительно ли конкретный
сценарий оправдывает их применение и существуют ли альтернативные, более защищенные подходы.
Сценарии использования блоков unsafe
Один из основных сценариев, который может приводить к использованию
незащищенного исходного кода, – это взаимодействие с внешними языками
(через FFI). Защитные гарантии Rust строятся вокруг его собственных абстракций, и взаимодействие с внешними языками (особенно теми, в которых отсутствуют защитные механизмы Rust) может потребовать применения незащищенного исходного кода.
Низкоуровневые системные операции, такие как прямое манипулирование
памятью или взаимодействие с оборудованием, также зачастую требуют использования незащищенного исходного кода. Принятые в Rust система владения и правила заимствования, будучи мощными инструментами обеспечения защищенности памяти, могут оказываться слишком ограничивающими
для определенных системных задач.
Незащищенный исходный код 555
Листинг 13.2. Защищенный исходный код на Rust,
вызывающий незащищенную внешнюю функцию
extern "C" {
fn external_function(); // ①
}
fn main() {
unsafe {
external_function(); // ②
}
}
В приведенном выше фрагменте исходного кода:
① внешняя функция объявляется с помощью блока extern "C". Это сигнализирует об использовании соглашения о вызовах по правилам языка
C – важнейшего аспекта интерфейса с внешними функциями (FFI) в Rust.
Блок extern "C" определяет интерфейс для взаимодействия с функция
ми на других языках, в частности с теми, что используют прикладной
двоичный интерфейс языка C (англ. C ABI). Такое включение позволяет компилятору Rust бесшовно интегрироваться и вызывать функции,
написанные на C-подобных языках. Функция external_function здесь
является местозаполнителем, представляющим внешнюю функцию
на языке C;
② функция main демонстрирует вызов этой внешней функции внутри блока
unsafe. Использование unsafe здесь указывает на потенциальные риски,
связанные с FFI. Когда компилятор Rust вызывает функции из внешних
языков (особенно из тех, в которых отсутствуют защитные механизмы
Rust), возникают неопределенности и риски, которые компилятор Rust
не может полностью проверить. Блок unsafe служит маркером, сигнализирующим о том, что данное взаимодействие включает операции, выходящие за рамки обычных защитных гарантий языка.
В более широком контексте взаимодействия с внешними языками этот
пример подчеркивает способности интерфейса FFI в Rust и необходимость
использования блоков unsafe при подобных контактах. Интерфейс с внешними функциями (FFI) – это мощный инструмент, позволяющий компилятору Rust бесшовно общаться с кодовыми базами на других языках. Однако
его использование несет в себе потенциальные риски, поэтому следует проявлять осторожность и рассудительность при включении внешних функций.
Это подчеркивает контролируемую и осознанную природу незащищенного исходного кода на Rust, особенно при рассмотрении темы интерфейса
с внешними функциями.
Продолжая вынесенную в заголовок тему, необходимо углубиться
в специфику сценариев, в которых защитные механизмы Rust могут стать
слишком ограничивающими. К ним могут относиться случаи, когда разработчику требуется прямой доступ к оборудованию или работа со сложными
структурами данных, в которых контролёру заимствований трудно ориентироваться.
556
Незащищенное программирование на Rust
Вопросы производительности и защищенности
Решение об использовании незащищенного исходного кода нередко представляет собой тонкий баланс между соображениями производительности и защищенности. В сценариях, где критическую важность имеет быстродействие,
а накладные расходы защищенных абстракций становятся недопустимыми,
можно сделать выбор в пользу незащищенного исходного кода, чтобы выжать
из программы максимум эффективности.
Хотя соблазн ставить производительность в приоритет велик, к этому решению важно подходить с осторожностью. Потенциальные риски и последствия
использования незащищенного исходного кода должны быть тщательно проанализированы, а все компромиссы – взвешены.
Листинг 13.3. Незащищенный, но производительный цикл for
fn main() {
let data = vec![1, 2, 3, 4, 5];
for &value in &data {
println!("Значение: {}", value);
}
④
// ①
// ②
unsafe {
let raw_pointer = data.as_ptr(); // ③
for i in 0..data.len() {
println!("Значение по индексу {}: {}", i, *raw_pointer.add(i)); //
}
}
}
//
//
//
//
//
//
//
//
//
//
Вывод:
Значение: 1
Значение: 2
Значение: 3
Значение: 4
Значение: 5
Значение по
Значение по
Значение по
Значение по
индексу
индексу
индексу
индексу
0:
1:
2:
4:
1
2
3
5
В приведенном выше примере:
① исходный код начинается с создания вектора data, содержащего пять целых
чисел. Как вы узнали из главы 4 «Встроенные в Rust структуры данных», векторы в Rust представляют собой динамическую массивоподобную структуру
данных. Элементы вектора инициализируются значениями 1, 2, 3, 4 и 5;
② далее следует блок защищенного кода, в котором используется цикл
for для выполнения итераций по вектору data. Циклической переменной value поочередно присваивается ссылка на каждый элемент вектора, и значения выводятся на экран с помощью макрофункции println!.
Этим демонстрируется типичный и защищенный метод выполнения
итераций по элементам коллекции на Rust;
Примеры из реальной практики 557
③ переходя к блоку незащищенного исходного кода, мы создаем сырой
указатель raw_pointer с помощью метода as_ptr() на векторе data. Этот
метод возвращает сырой указатель на первый элемент вектора. Важно
отметить, что использование сырых указателей создает потенциал для
незащищенного поведения, так как оно обходит привычные для компилятора Rust проверки защищенности;
④ внутри блока unsafe используется еще один цикл for для выполнения
итераций по элементам вектора с помощью арифметических операций на указателе. Выражение *raw_pointer.add(i) обращается к памяти
по сырому указателю, предварительно прибавля к нему индекс i, что
позволяет напрямую обращаться к значению по этому индексу в векторе. Такой метод прямого доступа к памяти может давать преимущество
в производительности, но он помечен как незащищенный из-за отсутствия стандартных защитных гарантий Rust.
В контексте обсуждения баланса между производительностью и защищенностью приведенный выше фрагмент исходного кода служит наглядным примером. Защищенный исходный код демонстрирует стандартный, защищенный подход к выполнению итераций по элементам вектора. Однако последующий блок незащищенного исходного кода показывает альтернативный, более
ориентированный на производительность подход с использованием сырых
указателей, подчеркивая, что к решению об использовании незащищенного
исходного кода нужно подходить с осторожностью, а потенциальные риски
и последствия должны тщательно оцениваться. Выгоды от оптимизации производительности посредством незащищенного исходного кода должны четко
пониматься и оправдываться на фоне защитных механизмов, предоставляемых абстракциями Rust.
Примеры из реальной практики
В целях закрепления понимания давайте рассмотрим примеры из реальной
практики, которые подчеркивают необходимость незащищенного исходного
кода в определенных сценариях. Рассмотрим ситуацию, когда проекту на Rust
требуется взаимодействие с унаследованной библиотекой на языке C, в которой отсутствуют встроенные в Rust защитные механизмы.
В таких случаях использование незащищенного исходного кода становится прагматичным выбором. Это позволяет разработчикам на Rust интегрироваться с существующими кодовыми базами, не ставя под угрозу защищенность
всего проекта в целом. Приводимые ниже примеры подчеркнут практическое
значение и полезность незащищенного исходного кода при преодолении трудностей, возникающих из-за внешних зависимостей.
Пример 1: взаимодействие с базой данных через FFI
Универсальность Rust выходит за рамки стержневых функциональных средств
языка, делая его отличным инструментом для бесшовного взаимодействия
с внешними библиотеками и языками через интерфейс с внешними функция
ми (FFI). В сценариях, где приложениям на Rust необходимо взаимодейство-
558
Незащищенное программирование на Rust
вать с устоявшимися базами данных или задействовать унаследованные кодовые базы, FFI служит важнейшим мостом. Он позволяет сочетать современный
синтаксис Rust с зачастую низкоуровневыми и незащищенными для памяти
API внешних библиотек. Следующий ниже пример демонстрирует взаимодействие с базой данных через FFI на практическом примере интеграции Rust
с SQLite – широко используемой встраиваемой реляционной системой управления базами данных (РСУБД).
Листинг 13.4. Взаимодействие с sqlite3 через FFI
unsafe extern "C" fn select_data_callback(
data: *mut std::ffi::c_void,
columns: i32,
values: *mut *mut i8,
_names: *mut *mut i8,
) -> i32 {
let data = data as *mut Vec<Vec<String>>; // ①
let mut row_data = Vec::new();
// ②
for i in 0..columns {
let value = std::ffi::CStr::from_ptr(*values.offset(i as isize)).
to_string_lossy();
row_data.push(value.into_owned());
}
println!(
"Данные строки таблицы в функции обратного вызова: {:?}",
row_data
); // ③
}
let data = &mut *data; // ④
data.push(row_data);
0 // Continue processing rows
fn advanced_database_interaction() {
unsafe {
let mut db: *mut rusqlite::ffi::sqlite3 = std::ptr::null_mut(); // ⑤
let result = rusqlite::ffi::sqlite3_open(
":память:\0".as_ptr() as *const i8,
&mut db
); // ⑥
println!("Результат соединения с базой данных SQLite: {}", result);
println!("Указатель на базу данных SQLite: {:p}", db);
if result != rusqlite::ffi::SQLITE_OK { // ⑦
println!("Ошибка при открытии базы данных SQLite");
return;
}
let create_table_query = "CREATE TABLE IF NOT EXISTS \
users (id INTEGER PRIMARY KEY, \
name TEXT);\0"; // ⑧
let create_table_query_ptr = create_table_query.as_ptr() as *const i8;
Примеры из реальной практики 559
let mut create_table_err_msg: *mut i8 = std::ptr::null_mut();
let create_table_result = rusqlite::ffi::sqlite3_exec(
db,
create_table_query_ptr,
None,
std::ptr::null_mut(),
&mut create_table_err_msg,
);
println!("Результат создания таблицы: {}", create_table_result);
println!(
"Указатель на сообщение об ошибке при создании таблицы: {:p}",
create_table_err_msg
);
if create_table_result != rusqlite::ffi::SQLITE_OK { // ⑨
println!("Ошибка при создании таблицы: {:?}", create_table_err_msg);
rusqlite::ffi::sqlite3_free(create_table_err_msg as *mut std::ffi::c_void);
return;
}
let insert_data_query = "INSERT INTO \
users (name) VALUES ('Mahmoud Harmouch');\0"; // ⑩
let insert_data_query_ptr = insert_data_query.as_ptr() as *const i8;
let mut insert_data_err_msg: *mut i8 = std::ptr::null_mut();
let insert_data_result = rusqlite::ffi::sqlite3_exec(
db,
insert_data_query_ptr,
None,
std::ptr::null_mut(),
&mut insert_data_err_msg,
);
println!("Результат вставки данных: {}", insert_data_result);
println!(
"Указатель на сообщение об ошибке вставки данных: {:p}",
insert_data_err_msg
);
if insert_data_result != rusqlite::ffi::SQLITE_OK { // ⑪
println!("Ошибка при вставке данных: {:?}", insert_data_err_msg);
rusqlite::ffi::sqlite3_free(insert_data_err_msg as *mut std::ffi::c_void);
return;
}
let
let
let
let
select_data_query = "SELECT * FROM users;\0"; // ⑫
select_data_query_ptr = select_data_query.as_ptr() as *const i8;
mut select_data_err_msg: *mut i8 = std::ptr::null_mut();
mut select_data_result: Vec<Vec<String>> = Vec::new();
let select_data_callback = Some(
select_data_callback as unsafe extern "C" fn(
*mut std::ffi::c_void,
i32,
560
Незащищенное программирование на Rust
*mut *mut i8,
*mut *mut i8,
) -> i32,
);
let data_ptr = &mut select_data_result
as *mut Vec<Vec<String>>
as *mut std::ffi::c_void; // ⑬
let select_data_result = rusqlite::ffi::sqlite3_exec(
db,
select_data_query_ptr,
select_data_callback,
data_ptr,
&mut select_data_err_msg,
);
println!("Результат выборки данных: {}", select_data_result);
println!(
"Указатель на сообщение об ошибке при выборке данных: {:p}",
select_data_err_msg
);
if select_data_result != rusqlite::ffi::SQLITE_OK { // ⑭
println!("Ошибка при выборке данных: {:?}", select_data_err_msg);
rusqlite::ffi::sqlite3_free(select_data_err_msg as *mut std::ffi::c_void);
return;
}
}
}
rusqlite::ffi::sqlite3_close(db); // ⑮
println!("Соединение с базой данных SQLite закрыто");
fn main() {
advanced_database_interaction();
}
// ⑯
// Вывод:
//
//
//
//
//
//
//
//
//
//
Результат соединения с базой данных SQLite: 0
Указатель на базу данных SQLite: 0x5629b9ed5c08
Результат создания таблицы: 0
Указатель на сообщение об ошибке при создании таблицы: 0x0
Результат вставки данных: 0
Указатель на сообщение об ошибке вставки данных: 0x0
Данные строки таблицы в функции обратного вызова: ["1", "Махмуд Хармуш"]
Результат выборки данных: 0
Указатель на сообщение об ошибке при выборке данных: 0x0
Соединение с базой данных SQLite закрыто
Теперь давайте разберем каждый шаг приведенного выше фрагмента исходного кода:
① указатель на данные, изначально имеющий тип *mut std::ffi::c_void,
приводится к мутируемой ссылке на Vec<Vec<String>>, обеспечивая за-
Примеры из реальной практики 561
щищенное представление. Это важнейший шаг в преодолении разрыва
между незащищенным миром сырых указателей и принятой в Rust системой владения. Приводя сырой указатель к типизированной ссылке,
мы обретаем способность взаимодействовать с ним, используя идиоматичный Rust, обеспечивая защищенность типа и памяти;
② цикл выполняет итерации по сырым значениям, конвертирует их в тип
String с помощью CStr::from_ptr и добавляет их в вектор row_data. Здесь
сырые значения, представляющие отдельные столбцы в строке базы данных, трансформируются в тип String. Эта конвертация необходима для
приведения данных в формат, которым компилятор Rust может управлять защищенно. Цикл гарантирует, что значение каждого столбца будет
корректно конвертировано и добавлено в вектор row_data;
③ вектор row_data, содержащий значения строки данных, выводится в терминал для целей отладки. Печать данных строк таблицы позволяет проверять и убеждаться, что полученные из SQLite данные корректно приняты
в среде Rust. Этот шаг помогает выявлять потенциальные проблемы с процессом трансформации данных или взаимодействием с РСУБД SQLite;
④ указатель data снова приводится к своему изначальному типу, и данные строки таблицы добавляются в вектор результатов. После обработки строки указатель data преобразуется обратно в свой изначальный тип
(Vec<Vec<String>>). Необходимость этого шага связана с тем, что сигнатура
функции обратного вызова нуждается в изначальном типе, а добавление
данных строки таблицы в вектор результатов гарантирует, что обработанные данные будут сохранены для дальнейшего анализа или использования;
⑤ объявляется сырой указатель db на базу данных SQLite, который инициализируется значением std::ptr::null_mut(). Эта инициализация создает
сырой указатель, который изначально ни на что не указывает, что означает отсутствие ссылки на валидную область памяти. Позже этот указатель
будет использоваться для управления соединением с базой данных SQLite;
⑥ вызывается функция sqlite3_open для открытия базы данных SQLite в оперативной памяти, и результат выводится в терминал. Открытие базы данных – критически важный шаг, и результат этой операции проверяется,
чтобы убедиться в успешном установлении соединения. В случае успеха
указатель db будет указывать на валидный экземпляр базы данных SQLite;
⑦ если при открытии базы данных происходит ошибка, то на экран выводится сообщение об ошибке, и исполнение функции прекращается. Обработка
ошибок при подсоединении к базе данных жизненно важна для надежности программы. Если базу данных не удается открыть, то вывод сообщения
об ошибке проясняет проблему, а выход из функции предотвращает дальнейшее исполнение исходного кода с невалидным соединением;
⑧ определяется запрос на создание таблицы, предназначенной для хранения данных пользователя, а для исполнения запроса используется функция sqlite3_exec. Этот шаг предусматривает исполнение SQL-запроса
на создание таблицы внутри базы данных SQLite. Функция SQLite
sqlite3_exec универсальна; ее можно использовать для исполнения различных SQL-команд. Здесь она применяется для создания таблицы пользователей (users);
562
Незащищенное программирование на Rust
⑨ если при создании таблицы происходит ошибка, то на экран выводится
сообщение об ошибке, и функция завершает работу. Подобно обработке
ошибок на этапе открытия базы данных, при отказе во время создания
таблицы крайне важно выводить диагностическое сообщение и прекращать исполнение, чтобы избегать операций с неполным или поврежденным состоянием базы данных;
⑩ определяется запрос на вставку тестовых данных в таблицу, и для его исполнения используется функция sqlite3_exec. Этот шаг предусматривает заполнение ранее созданной таблицы демонстрационными данными;
⑪ если при вставке данных происходит ошибка, то на экран выводится
сообщение об ошибке, и функция завершает работу. Успешная вставка
данных критически важна для поддержания целостности базы данных.
В случае отказа при операции вставки выводится сообщение об ошибке,
и функция возвращает управление, чтобы предотвратить последующие
операции на потенциально поврежденных данных;
⑫ определяется запрос на выборку всех данных из таблицы users. Этот шаг
предусматривает подготовку запроса на извлечение всех строк и столбцов из таблицы;
⑬ функция sqlite3_exec используется для исполнения запроса на выборку
данных, при этом функция select_data_callback служит в качестве обратного вызова для обработки отобранных строк. Исполнение запроса
на выборку данных предполагает получение данных из базы, и указанная
функция обратного вызова (select_data_callback) вызывается на каждой строке результирующего набора. Эта функция обрабатывает сырые
данные и преобразовывает их в удобный для компилятора Rust формат;
⑭ если при выборке данных происходит ошибка, то на экран выводится сообщение об ошибке, и функция завершает работу. Аналогично обработке
ошибок при создании таблицы и вставке данных, во время отказа при
выборке данных выводится диагностическое сообщение, чтобы предотвратить дальнейшие операции;
⑮ функция sqlite3_close используется для закрытия соединения с базой данных SQLite. Надлежащее закрытие соединения необходимо для
управления ресурсами. Этот шаг высвобождает любые ресурсы, связанные с базой данных, предотвращая утечки памяти и обеспечивая чистое
завершение работы соединения;
⑯ функция advanced_database_interaction вызывается как основная точка
входа для тестирования продвинутой функциональности взаимодействия с базой данных. Эта функция заключает в себе весь процесс: от открытия соединения до запросов и манипуляций с данными. Вызов этой
функции в main служит практическим способом продемонстрировать
продвинутые способности взаимодействия с базами данных на Rust.
Приведенный выше фрагмент исходного кода иллюстрирует комплексное
и контролируемое взаимодействие с базой данных SQLite, демонстрируя необходимость использования незащищенного исходного кода в определенных
сценариях. Функция advanced_database_interaction дирижирует различными
этапами: от открытия базы данных SQLite до выполнения таких операций, как
создание таблицы, вставка данных и их выборка.
Примеры из реальной практики 563
Использование незащищенного исходного кода становится очевидным
в функции обратного вызова select_data_callback, которая взаимодействует с сырыми указателями, полученными из API на C реляционной системы
управления базами данных SQLite. Благодаря осторожному приведению типов и манипуляциям сырые указатели конвертируются в дружественное для
компилятора Rust представление (Vec<Vec<String>>), что подчеркивает осо
знанную природу использования незащищенного исходного кода с целью наведения мостов между защитными гарантиями Rust и низкоуровневыми операциями внешних библиотек на языке C.
Указанный пример также подчеркивает важность обработки ошибок, гарантируя, что любые проблемы на этапах подсоединения к базе данных, создания
таблиц, вставки или выборки данных будут должным образом регистрироваться. Такая скрупулезная обработка ошибок способствует общей надежности
программы, предотвращая неопределенное поведение и поддерживая целостность приложения на Rust.
Хотя использование незащищенного исходного кода в данном сценарии
оправдано для взаимодействия с API на C, крайне важно подходить к нему
с осторожностью. Глубокое понимание API внешней библиотеки, аккуратное
приведение типов и следование лучшим образцам практик – ключевые аспекты безопасного использования незащищенного исходного кода. В целом этот
пример является практической иллюстрацией способности Rust бесшовно интегрироваться с существующими библиотеками на С, сохраняя баланс между
производительностью и защищенностью.
Пример 2: продвинутая обработка изображений
В представленном ниже примере мы подробно разберем детали взаимодействия с API на C библиотеки OpenCV, уделяя особое внимание необходимости использования незащищенного исходного кода при работе с внешними
библиотеками. Пошаговый разбор операций по обработке изображений дает
представление о применении функциональности библиотеки OpenCV в контексте Rust. Давайте проинспектируем, как защитные функциональные средства Rust уравновешиваются необходимостью эффективных низкоуровневых
манипуляций, требуемых для продвинутой обработки изображений.
Листинг 13.5. Продвинутая обработка изображений
с использованием библиотеки OpenCV
extern crate opencv;
use opencv::prelude::MatTraitConst;
use opencv::core::{BORDER_DEFAULT, CV_8UC3};
use std::ffi::c_void;
fn image_processing() {
unsafe {
// Создать тестовые данные.
// Предполагается 3 канала
let sample_data: Vec<u8> = vec![0; 480 * 640 * 3];
let mut image: opencv::core::Mat = opencv::core::Mat::new_rows_cols_with_data(
480,
640,
564
Незащищенное программирование на Rust
CV_8UC3,
sample_data.as_ptr() as *mut c_void,
0,
).unwrap(); // ①
println!(
"Изначальное изображение: Строки={}, Столбцы={}, Каналы={}",
image.rows(),
image.cols(),
image.channels()
);
let mut blurred_image = opencv::core::Mat::default();
opencv::imgproc::blur(
&image,
&mut blurred_image,
opencv::core::Size::new(5, 5),
opencv::core::Point::new(2, 2),
BORDER_DEFAULT,
).unwrap(); // ②
// Переместить временную переменную обратно в изначальную
std::mem::swap(&mut image, &mut blurred_image);
println!(
"После гауссова размытия: Строки={}, Столбцы={}, Каналы={}",
image.rows(),
image.cols(),
image.channels()
);
let mut gray_image = opencv::core::Mat::default();
opencv::imgproc::cvt_color(
&image,
&mut gray_image,
opencv::imgproc::COLOR_BGR2GRAY,
0,
).unwrap(); // ③
// Переместить временную переменную обратно в изначальную
std::mem::swap(&mut image, &mut gray_image);
println!(
"После преобразования в оттенки серого: Строки={}, Столбцы={}, Каналы={}",
image.rows(),
image.cols(),
image.channels()
);
// Детекция границ методом Кэнни с временной переменной
let mut canny_image = opencv::core::Mat::default();
opencv::imgproc::canny(
&image,
&mut canny_image,
50.0,
150.0,
3,
Примеры из реальной практики 565
false,
).unwrap(); // ④
// Переместить временную переменную обратно в изначальную
std::mem::swap(&mut image, &mut canny_image);
println!(
"После выделения границ методом Кэнни: Строки={}, Столбцы={}, Каналы={}",
image.rows(),
image.cols(),
image.channels()
);
}
}
fn main() {
image_processing(); // ⑤
}
// Вывод:
//
//
//
//
Изначальное изображение: Строки=480, Столбцы=640, Каналы=3
После гауссова размытия: Строки =480, Столбцы=640, Каналы=3
После преобразования в оттенки серого: Строки=480, Столбцы=640, Каналы=1
После выделения границ методом Кэнни: Строки=480, Столбцы=640, Каналы=1
В приведенном выше примере:
① в этой строке внутри блока unsafe создается объект OpenCV Mat с именем image. Метод opencv::core::Mat::new_rows_cols_with_data вызывается для инициализации объекта Mat размером 480×640 и типом пикселей
CV_8UC3, что представляет собой 8-битное изображение без знака с тремя
цветовыми каналами;
② затем к изображению применяется размытие по Гауссу с помощью функции opencv::imgproc::blur. Эта операция вводит более продвинутый
этап обработки изображений, демонстрируя возможность манипулировать пиксельными данными с помощью незащищенного исходного кода;
③ далее изображение конвертируется в оттенки серого с помощью функции opencv::imgproc::cvt_color, расширяя сложность конвейера обработки изображения. Конвертация предусматривает трансформацию
цвета, подчеркивая гибкость незащищенного исходного кода Rust при
выполнении сложных операций;
④ в целях дальнейшего улучшения обработки к изображению в оттенках серого применяется детектор границ методом Кэнни. Функция
opencv::imgproc::canny привносит алгоритмы обнаружения границ, демонстрируя интеграцию более сложной функциональности библиотеки
OpenCV в кодовую базу Rust;
⑤ функция main вызывается для тестирования продвинутой функциональности обработки изображений с помощью библиотеки OpenCV. Она служит точкой входа для выполнения и наблюдения за поведением исходного кода и отвечает за взаимодействие с библиотекой OpenCV. Данная
функция несет в себе логику обработки для целенаправленного тестирования и проверки правильности.
566
Незащищенное программирование на Rust
Пример 3: конкретно-прикладное управление памятью
Давайте рассмотрим проект на Rust, связанный конвейером обработки данных, который нуждается в эффективном управлении памятью. В определенных случаях возникает необходимость в использовании незащищенного исходного кода для конкретно-прикладного выделения и высвобождения памяти. Приведенный ниже пример демонстрирует упрощенный сценарий, в котором конкретно-прикладной менеджер памяти используется для эффективной
работы с буферами данных.
Листинг 13.6. Конвейер обработки с конкретноприкладным управлением памятью
struct CustomMemoryManager { // ①
allocate: unsafe extern "C" fn(size: usize) -> *mut u8,
deallocate: unsafe extern "C" fn(ptr: *mut u8, size: usize),
}
fn process_data_with_custom_memory_manager(
data: &[u8],
memory_manager: &CustomMemoryManager,
) { // ②
unsafe {
let buffer_size = data.len() * 2;
let custom_buffer = (memory_manager.allocate)(buffer_size);
println!("Выделено {} байт по адресу {:p}", buffer_size, custom_buffer);
std::ptr::copy_nonoverlapping(data.as_ptr(), custom_buffer, data.len());
// Здесь могла бы происходить дополнительная обработка
// данных с использованием буфера
(memory_manager.deallocate)(custom_buffer, buffer_size);
println!(
"Высвобождено {} байт по адресу {:p}",
buffer_size, custom_buffer
);
}
}
fn main() { // ③
let custom_memory_manager = CustomMemoryManager {
allocate: custom_allocate,
deallocate: custom_deallocate,
};
}
let input_data = vec![1, 2, 3, 4, 5];
process_data_with_custom_memory_manager(&input_data, &custom_memory_manager);
unsafe extern "C" fn custom_allocate(size: usize) -> *mut u8 { // ④
let layout = std::alloc::Layout::from_size_align(size, 1).unwrap();
let ptr = std::alloc::alloc(layout);
Примеры из реальной практики 567
println!("Выделено {} байт по адресу {:p}", size, ptr);
ptr
}
unsafe extern "C" fn custom_deallocate(ptr: *mut u8, size: usize) { // ⑤
println!("Высвобождено {} байт по адресу {:p}", size, ptr);
let layout = std::alloc::Layout::from_size_align(size, 1).unwrap();
std::alloc::dealloc(ptr, layout);
}
// Вывод:
//
//
//
//
Выделено 10 байт по адресу 0x559447656bc0
Выделено 10 байт по адресу 0x559447656bc0
Высвобождено 10 байт по адресу 0x559447656bc0
Высвобождено 10 байт по адресу 0x559447656bc0
① В основе приведенного выше исходного кода на Rust лежит определение структуры CustomMemoryManager. Эта структура служит контейнером,
содержащим два указателя на функции, необходимые для управления
памятью в программе. Эти указатели – allocate и deallocate – обозначены как незащищенные (unsafe) внешние функции, что подчеркивает
их участие в низкоуровневых операциях. Цель структуры – выступать
в качестве единого модуля, обеспечивающего организованный подход
к заданиям по работе с памятью, в широком контексте программы.
② Центральным элементом данного скрипта является функция process_
data_with_custom_memory_manager. В ней используется определенный
ранее конкретно-прикладной менеджер памяти, и, по сути, функция
дирижирует последовательностью операций: выделением памяти, обработкой данных и высвобождением ресурсов. Использование ключевого слова unsafe указывает на потенциальные риски, связанные с этими
низкоуровневыми операциями, и подчеркивает серьезность взаимодействия исходного кода со сложными механизмами управления памятью.
Функция представляет собой контролируемое и намеренное использование незащищенного исходного кода для достижения конкретных целей,
связанных с памятью, внутри парадигмы Rust.
③ Далее на передний план выходит функция main. В этой точке входа в программу создается экземпляр конкретно-прикладного менеджера памяти. Эта инициализация знаменует собой интеграцию структуры в среду
исполнения программы, где ее функции будут управлять операциями
с памятью. Затем функция main приступает к исполнению сценария обработки тестовых данных, демонстрируя практическое применение менеджера памяти в реально-практическом контексте. Весь этот подход
подчеркивает стремление Rust не только к защищенности, но и к гибкости при работе с низкоуровневыми операциями.
④ Функция custom_allocate выступает в качестве критически важного
компонента в дирижировании процессом выделения памяти внутри
программы. Эта функция содержит в себе гипотетическую реализацию
логики выделения памяти, придерживаясь принципов незащищенной
и внешней природы операций с памятью. Внутри своего блока кода
568
Незащищенное программирование на Rust
функция задействует стандартный для компилятора Rust выделитель памяти для выделения блока памяти, демонстрируя практический пример
процедуры выделения контролируемым и отслеживаемым образом. Роль
этой функции не просто техническая, но и обучающая: она дает представление о сложностях выделения памяти в Rust.
⑤ Параллельно со своей противоположностью функция custom_deallocate
играет симметричную роль в «симфонии» управления памятью. Эта
функция представляет собой гипотетическую реализацию логики высвобождения памяти, являясь дополнением к процессу выделения. Использование стандартного для Rust высвободителя памяти внутри функции
подчеркивает приверженность языка структурированным и защищенным практикам управления памятью. Во время выполнения функция
выводит информацию о высвобождении ресурсов, способствуя прозрачному и информативному подходу к работе с памятью. Эта реализация
не только демонстрирует механику высвобождения, но и служит инструментом, подчеркивающим неразрывную связь между выделением и высвобождением памяти в парадигме программирования на Rust.
Приведенные выше примеры акцентируют внимание на разнообразных
сценариях, где может применяться незащищенный исходный код: от взаимодействия с базами данных через FFI до конкретно-прикладного управления
памятью в специализированных конвейерах обработки данных. Разработчику
следует проявлять осторожность, тщательно документировать использование
незащищенного исходного кода и придерживаться лучших образцов практики, чтобы обеспечивать защищенность и надежность приложений на Rust.
Продолжая изложение, важно отметить необходимость глубокого понимания внешнего исходного кода, с которым осуществляется взаимодействие. Необходимо учитывать не только технические аспекты интеграции,
но и потенциальные последствия использования незащищенного исходного
кода в более широком контексте целей проекта и требований к обеспечению
защищенности.
Лучшие образцы практики использования
незащищенного исходного кода
Учитывая риски, присущие незащищенному исходному коду, крайне важно принимать на вооружение лучшие образцы практики с целью его осторожного использования. Обследуя эту область, разработчик должен придерживаться рекомендаций, которые минимизируют потенциальные ловушки
и обеспечивают уровень защищенности, соответствующий основополагающим принципам Rust.
На практике одним из ключевых приемов является упаковка незащищенного исходного кода внутрь защищенных абстракций, что позволяет минимизировать «площадь поверхности» и потенциальное влияние такого исходного
кода. Подобное разграничение помогает локализовывать риски в конкретных
модулях или функциях, позволяя остальной части кодовой базы оставаться
в рамках гарантий Rust.
Лучшие образцы практики использования незащищенного исходного кода 569
Листинг 13.7. Упаковка незащищенного исходного
кода внутрь защищенных абстракций
mod unsafe_module {
// Защищенная функция Rust
pub fn safe_function(data: &mut Vec<i32>) {
// Защищенный исходный код Rust
data.push(42);
println!("Защищенная функция: {:?}", data);
}
// Незащищенная функция Rust
pub unsafe fn unsafe_function(data: &mut Vec<i32>) {
// Незащищенный исходный код, упакованный внутрь абстракции
let reference = data.as_mut_ptr();
*reference.offset(0) = 10;
println!("Незащищенная функция: {:?}", data);
}
}
fn main() {
let mut data = vec![1, 2, 3, 4, 5];
unsafe_module::safe_function(&mut data);
// ①
unsafe {
unsafe_module::unsafe_function(&mut data); // ②
}
}
println!("Главная функция: {:?}", data);
// Вывод:
// ③
// Защищенная функция: [1, 2, 3, 4, 5, 42]
// Незащищенная функция: [10, 2, 3, 4, 5, 42]
// Главная функция: [10, 2, 3, 4, 5, 42]
В приведенном выше примере:
① в этой строке вызывается защищенная функция safe_function из модуля
unsafe_module. Она манипулирует вектором data защищенным образом:
добавляет значение 42 и выводит измененный вектор. Важно, что вызов
этой функции не нуждается в блоке unsafe;
② незащищенная функция unsafe_function вызывается внутри блока
unsafe. В ней заключен незащищенный исходный код, получая мутируемый сырой указатель на первый элемент вектора и изменяя данные посредством арифметических операций с указателем. Использование блока unsafe сигнализирует о том, что исходный код внутри может нарушать
защитные гарантии Rust и его следует использовать с осторожностью;
③ после вызова незащищенной функции измененные данные читаются
и выводятся на экран в функции main. Эта строка демонстрирует, что внесенные незащищенной функцией изменения видны в главном контексте.
Крайне важно понимать, что использование незащищенного исходного
кода может иметь последствия далеко за пределами самого блока unsafe.
570
Незащищенное программирование на Rust
Приведенный выше исходный код служит примером лучших образцов
практики осторожного использования незащищенного исходного кода. Упаковывая незащищенную функциональность внутрь модуля и предоставляя защищенный интерфейс (такой как safe_function), можно изолировать и конт
ролировать использование незащищенного исходного кода, ограничивая
потенциальные риски четко определенными границами. Такой модульный
подход соответствует акценту Rust на защищенности и упрощает практику
коллаборативной разработки, включая пересмотр кода и обмен знаниями. При
использовании незащищенного исходного кода следует проявлять осторожность, придерживаться лучших образцов практики и задействовать защищенные абстракции везде, где это возможно, чтобы поддерживать целостность
и защищенность кодовых баз на Rust.
В более широком смысле продемонстрированный здесь модульный подход
совпадает с рекомендациями сообщества Rust по изоляции незащищенного исходного кода. Упаковывая незащищенную функциональность внутрь модулей
или функций, можно ограничивать потенциальные риски, удерживая их влия
ние в рамках четко определенных границ. Кроме того, данный исходный код
подчеркивает важность пересмотра кода и коллаборативной работы с незащищенным исходным кодом. Когда такой код инспектируется несколькими
парами глаз, это позволяет эффективнее выявлять ошибки, гарантировать соблюдение лучших образцов практики и способствует обмену знаниями внутри
коллектива разработчиков. Такой коллаборативный подход критически важен
для поддержания целостности и защищенности кодовых баз на Rust, особенно
при обследовании области незащищенного исходного кода.
Нарушения защищенности памяти
Одной из самых критических проблем при работе с незащищенным исходным
кодом является риск нарушения защищенности памяти. Используемые в Rust
система владения и контролёр заимствований играют ключевую роль в предотвращении подобных проблем, однако при работе с незащищенным исходным кодом требуется принимать дополнительные меры предосторожности.
Нарушения защищенности памяти могут приводить к широкому спектру
проблем: от трудноуловимых дефектов до серьезных уязвимостей в системе
безопасности. Во избежание подобного рода ловушек крайне важно понимать
тонкости управления памятью в контексте незащищенного исходного кода.
Листинг 13.8. Модификация данных посредством
прямого доступа по сырому указателю
fn main() {
// ①
let mut data = vec![1, 2, 3, 4, 5]; // ②
let reference = data.as_mut_ptr(); // ③
unsafe {
*reference.offset(0) = 10;
}
}
// ④
println!("Модифицированные данные: {:?}", data); // ⑤
Нарушения защищенности памяти 571
// Вывод:
// Модифицированные данные: [10, 2, 3, 4, 5]
В приведенном выше примере:
① функция main является точкой входа в программу на Rust;
② создается мутируемый вектор data с элементами [1, 2, 3, 4, 5];
③ в этой строке мы защищенно получаем мутируемый сырой указатель
(*mut i32) на первый элемент вектора data;
④ внутри блока unsafe исходный код пытается изменить данные через мутируемую ссылку, используя арифметические операции с указателем.
Выражение *reference.offset(0) = 10; присваивает значение 10 первому элементу вектора. Эта операция считается незащищенной, так как
представляет собой прямое манипулирование памятью в обход принятых в Rust принципов владения;
⑤ на экран выводятся измененные данные. Эта строка демонстрирует, что внесенные через сырой указатель модификации влияют на изначальный вектор, что потенциально может приводить к непредсказуемому поведению.
В приведенном выше примере незащищенный исходный код несет в себе
риск нарушения используемых в Rust принципов владения, что может становиться причиной неопределенного поведения и проблем с защищенностью
памяти. Важно использовать незащищенный исходный код осторожно и с полным пониманием всех сопутствующих рисков.
Развивая этот пример, важно обсуждать альтернативные подходы, которые
поддерживают защищенность памяти. К ним относится использование защищенных абстракций, таких как метод iter_mut, предоставляемый стандартной
библиотекой. Он позволяет защищенно выполнять итерации по мутируемым
ссылкам, не прибегая к незащищенному исходному коду. Ниже приводится
пример, демонстрирующий указанный подход.
Листинг 13.9. Защищенная итерация по мутируемым ссылкам
без использования незащищенного исходного кода
fn main() {
let mut data = vec![1, 2, 3, 4, 5]; // ①
for element in data.iter_mut() {
// ②
*element = *element + 10;
// ③
}
println!("Модифицированные данные: {:?}", data); // ④
}
// Вывод:
// Модифицированные данные: [11, 12, 13, 14, 15]
В приведенном выше примере:
① создается мутируемый вектор data с элементами [1, 2, 3, 4, 5];
② метод iter_mut используется для получения мутируемого итератора
по элементам вектора. Это позволяет защищенно выполнять итерации
572
Незащищенное программирование на Rust
по мутируемым ссылкам, не прибегая к использованию незащищенного
исходного кода;
③ внутри цикла каждый элемент модифицируется через мутируемый итератор. В данном примере мы прибавляем 10 к каждому элементу;
④ измененные данные выводятся на экран, демонстрируя, что внесенные через защищенный итератор модификации влияют на изначальный вектор.
Использование защищенных абстракций, таких как iter_mut, помогает обес
печивать защищенность памяти, соблюдая принятые в Rust правила владения
и заимствования. Это позволяет выполнять операции на мутируемых ссылках
контролируемым и защищенным способом, во многих случаях избавляя от необходимости в незащищенном исходном коде.
Практические примеры
В целях углубления понимания потенциальных последствий использования
незащищенных методов программирования давайте рассмотрим практические примеры, которые проливают свет на сценарии из реальной практики.
Эти иллюстративные примеры наглядно продемонстрируют влияние использования незащищенного исходного кода в программных системах. Анализируя эти образцы практики, мы стремимся подчеркнуть критическую важность
осторожности, вдумчивого подхода и тщательного тестирования при работе
с незащищенным исходным кодом. Через эти практические примеры мы хотим показать разработчикам необходимость осознания всей серьезности принимаемых ими решений и того далеко идущего влияния, которое они могут
оказывать на надежность и защищенность программного обеспечения.
Практический пример 1: уязвимость Heartbleed
Уязвимость Heartbleed в открытой криптографической библиотеке OpenSSL
служит наглядной иллюстрацией реальных последствий, связанных с практиками написания незащищенного исходного кода. Эта пресловутая брешь в защите позволяла злоумышленникам эксплуатировать критическую слабость,
открывая несанкционированный доступ к конфиденциальным данным, находящимся в памяти затронутых систем. Фундаментальная проблема, лежащая в основе этой уязвимости, заключалась в отсутствии надежной проверки границ в реализации расширения OpenSSL Heartbeat (дословно «биение
сердца», которое из-за ошибки получило название «сердечное кровоизлияние» – heartbleed). Это упущение способствовало несанкционированному извлечению строго конфиденциальной информации, включая приватные ключи
и пароли, что создало серьезную угрозу безопасности и целостности систем,
полагающихся на скомпрометированные версии библиотеки OpenSSL1.
Инцидент с уязвимостью Heartbleed подчеркивает важность проведения
строгой аудиторской проверки исходного кода, тщательного тестирования
и ту критическую роль, которую правильное и защищенное использование па1
Synopsys, Inc. http://www.synopsys.com/. (3 июня 2020). Дефект Heartbleed. https://
heartbleed.com/.
Практические примеры 573
мяти играет в общей защищенности программных систем. Он служит напоминанием о том, что уязвимости, привнесенные через незащищенный исходный
код, могут иметь тяжелые и далеко идущие последствия.
Листинг 13.10. Симуляция уязвимости Heartbleed на Rust
use std::collections::HashMap;
struct Server {
user_data: HashMap<String, String>, // ①
}
impl Server {
fn get_user_data(&self, username: &str) -> Option<&String> { // ②
self.user_data.get(username)
}
unsafe fn unsafe_heartbleed(&self) { // ③
let buffer: Vec<u8> = self
.user_data
.values()
.flat_map(|s| s.as_bytes().to_vec())
.collect();
// ④
let mut response = Vec::new();
// ⑤
response.resize(buffer.len(), 0); // ⑥
response.copy_from_slice(&buffer); // ⑦
send_response(response);
}
}
// ⑧
fn send_response(response: Vec<u8>) {
// ⑨
let response_str = String::from_utf8_lossy(&response); // ⑩
println!("Отправка ответа: {}", response_str);
// ⑪
}
fn main() {
let server = Server {
user_data: [
("Mahmoud".to_string(), "password123".to_string()),
("Prime".to_string(), "secret456".to_string()),
]
.iter()
.cloned()
.collect(), // ⑫
};
}
unsafe {
server.unsafe_heartbleed(); // ⑬
}
// Вывод:
// Отправка ответа: password123secret456
574
Незащищенное программирование на Rust
В приведенном выше примере:
① структура Server служит представлением сервера в программе, предназначенным специально для управления конфиденциальными данными
пользователей. Данные хранятся в хеш-словаре (типа HashMap) – типе
коллекции в Rust, который позволяет эффективно соотносить пары
ключ–значение;
② метод get_user_data является защищенной функцией структуры Server.
Он предназначен для надежного извлечения данных пользователя по заданному имени. Используя гарантии принятой в Rust системы владения,
этот метод обеспечивает контролируемый доступ к данным в соответствии с правилами заимствования;
③ метод unsafe_heartbleed помечен как unsafe, что указывает на выполнение операций, отклоняющихся от обычных защитных гарантий Rust. Этот
метод симулирует печально известную уязвимость Heartbleed – критическую брешь в защите библиотеки OpenSSL. Цель симуляции – наглядно
показать риски, возникающие при использовании незащищенного исходного кода обработки конфиденциальных данных;
④ в этой строке создается буфер на основе данных пользователей. Буфер строится путем разглаживания байтовых представлений всех строковых значений пользовательских данных. Этот шаг критически важен для симуляции,
так как он воссоздает сценарий, при котором конфиденциальная информация оказывается выставленной наружу посредством буфера памяти;
⑤ переменная response представляет собой мутируемый вектор, созданный
специально для хранения симулируемого ответа. В контексте симуляции
уязвимости Heartbleed этот вектор олицетворяет данные, которые могут
утечь из-за уязвимости;
⑥ метод resize используется для того, чтобы вектор response имел ту же
длину, что и буфер. Операция инициализирует вектор нулями, резервируя место под данные. В указанной симуляции намеренно опускается надлежащая проверка границ, что отражает незащищенную природу
уязвимости Heartbleed;
⑦ метод copy_from_slice используется для копирования данных из буфера в вектор response. Эта операция выполняется без должной проверки
границ, симулируя поведение, которое привело к уязвимости Heartbleed,
когда злоумышленник мог извлекать информацию за пределами предполагаемых границ буфера;
⑧ вызывается функция send_response, обозначающая момент отправки
сформированного ответа. В этой симуляции данное действие символизирует потенциальную утечку конфиденциальных данных внешнему лицу;
⑨ функция send_response определена для симуляции самого процесса отправки ответа. Хотя реализация упрощена, она подчеркивает момент
выставления наружу конфиденциальных данных в реальном сценарии
(как в случае с Heartbleed);
⑩ вектор response, теперь содержащий симулированные данные, конвертируется в читаемый строковый формат с помощью метода String::from_
utf8_lossy. Этот шаг необходим для представления данных в человекочитаемом виде, что подчеркивает серьезность последствий;
Практические примеры 575
⑪ читаемый ответ выводится в терминал. Это символическое представление утечки данных, иллюстрирующее влияние уязвимости Heartbleed
и подчеркивающее важность защищенных практик программирования;
⑫ в функции main создается экземпляр структуры Server для симуляции
сервера с конфиденциальными данными пользователей. Эти данные
инициализируются вымышленной информацией, включая пользовательские имена и соответствующие им пароли;
⑬ незащищенный метод unsafe_heartbleed вызывается внутри блока
unsafe, что представляет собой намеренную активацию симуляции уязвимости. Это подчеркивает острую необходимость в осторожности и соблюдении правил безопасности при работе с незащищенным исходным
кодом, особенно когда речь идет о конфиденциальной информации.
Приведенная выше симуляция вскрывает ловушки незащищенного исходного кода, представляя структуру Server, которая должна защищать конфиденциальные данные, но вместо этого воспроизводит уязвимость Heartbleed.
В отличие от метода get_user_data, который демонстрирует защищенный доступ к данным, метод unsafe_heartbleed становится точкой риска, воссоздавая
условия уязвимости Heartbleed без привычных защитных механизмов Rust.
Создание буфера, манипуляции с векторами и генерация ответа без надлежащей проверки границ символизируют потенциальную утечку данных. Намеренный вызов unsafe_heartbleed подчеркивает риски, присущие незащищенному
исходному коду, акцентируя внимание на необходимости проведения строгой
аудиторской проверки исходного кода, тщательного тестирования и защищенного управления памятью с целью укрепления защиты программного обеспечения. Данная симуляция служит суровым напоминанием о том, что уязвимости
в незащищенном исходном коде могут иметь тяжелые последствия, призывая
разработчиков ставить в приоритет правила безопасности и методы защищенного программирования для создания отказоустойчивых систем.
Практический пример 2:
авария первого полета ракеты Ариан-5
Авария первого полета ракеты Ариан-5 (полет 501, где 5 указывает на поколение ракеты, а 01 на порядковый номер полета) в 1996 году – еще один пример,
иллюстрирующий последствия использования незащищенного исходного
кода в критически важных для безопасности системах. Инцидент произошел
всего через 39 секунд после старта, когда система наведения ракеты вышла
из строя из-за целочисленного переполнения в программном обеспечении.
Использовавшееся в ракете Ариан-5 ПО было унаследовано от предыдущей
модели Ариан-4, и отсутствие в нем надлежащей обработки ошибок и защитных механизмов привело к катастрофе.
Этот пример подчеркивает важность проведения тщательной аудиторской
проверки исходного кода, методик тестирования и необходимости соблюдения строгих стандартов программирования в критически важных системах.
В контексте Rust это подтверждает значимость предотвращения неопределенного поведения, особенно в системах, где на кону стоят человеческие жизни
или огромные финансовые инвестиции.
576
Незащищенное программирование на Rust
Продолжая разбор примеров, полезно проинспектировать примеры, которые подсвечивают последствия использования незащищенного исходного
кода не только в системах, критичных для безопасности или защиты данных,
но и в системах, где важна производительность. К ним могут относиться сценарии, в которых преждевременная оптимизация в незащищенном исходном
коде приводила к кошмарам во время технической поддержки проекта и вносила труднодиагностируемые дефекты.
Листинг 13.11. Симуляция аварии ракеты Ариан-5 на Rust
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
struct GuidanceSystem {
safe_mode: AtomicBool,
// ①
unsafe_counter: AtomicUsize, // ②
}
impl GuidanceSystem {
fn safe_critical_calculation(&self, payload_mass: f64) -> i32 {
if self.safe_mode.load(Ordering::SeqCst) {
let result = (32767.0 * 9.81 / payload_mass).round(); // ③
if result >= i32::MIN as f64 && result <= i32::MAX as f64 {
result as i32 // ④
} else {
if result > 0.0 {
i32::MAX
} else {
i32::MIN
}
}
} else {
// Симулировать проблемный исходный код Ариан-5:
// прямое приведение типа без проверки
(32767.0 * 9.81 / payload_mass) as i32 // ⑤
}
}
}
unsafe fn unsafe_use_result(&self, result: i32) {
self.unsafe_counter
.fetch_add(result as usize, Ordering::Relaxed); // ⑥
println!("Использование результата в системе наведения: {}", result); // ⑦
}
fn main() {
let guidance_system = Arc::new(GuidanceSystem {
safe_mode: AtomicBool::new(false),
// ⑧
unsafe_counter: AtomicUsize::new(0), // ⑨
});
let guidance_system_thread = Arc::clone(&guidance_system); // ⑩
let handle = thread::spawn(move || {
let payload_mass = 0.0000000001;
// ⑪
Практические примеры 577
});
let result = guidance_system_thread
.safe_critical_calculation(payload_mass); // ⑫
println!(
"Результат защищенного критического вычисления: {}",
result
); // ⑬
unsafe {
guidance_system_thread.unsafe_use_result(result); // ⑭
}
let handles: Vec<_> = (0..4)
.map(|_| {
let guidance_system_thread = Arc::clone(&guidance_system); // ⑮
thread::spawn(move || {
let payload_mass = 0.1; // ⑯
let result = guidance_system_thread
.safe_critical_calculation(payload_mass); // ⑰
unsafe {
guidance_system_thread.unsafe_use_result(result); // ⑱
}
})
})
.collect();
handle.join().unwrap(); // ⑲
for handle in handles {
handle.join().unwrap(); // ⑳
}
println!(
"Значение незащищенного счетчика: {}",
guidance_system.unsafe_counter.load(Ordering::SeqCst)
);
}
// Вывод:
//
//
//
//
//
//
//
Результат защищенного критического вычисления: 2147483647
Использование результата в системе наведения: 2147483647
Использование результата в системе наведения: 3214442
Использование результата в системе наведения: 3214442
Использование результата в системе наведения: 3214442
Использование результата в системе наведения: 3214442
Значение незащищенного счетчика: 2160341415
В приведенном выше примере:
① в этой строке объявляется поле safe_mode в структуре GuidanceSystem.
Оно имеет тип AtomicBool, что означает атомарную булеву переменную,
которая гарантирует атомарность операций на этой переменной, обес
печивая потокозащищенность. Указанная булева переменная критически важна для представления текущего режима работы системы наведения, сигнализируя о состоянии системы, которое может быть безопасным или нет;
578
Незащищенное программирование на Rust
② здесь в структуре объявляется поле unsafe_counter типа AtomicUsize, что
означает атомарное целое число без знака. Цель этого счетчика – отслеживать количество потенциально опасных операций. Атомарность
позволяет нескольким потокам исполнения конкурентно увеличивать
счетчик, не приводя к гонкам за доступ или несогласованности данных;
③ внутри метода safe_critical_calculation в этой строке выполняется
математический расчет критической операции. Результат определяется на основе массы полезной нагрузки, силы тяжести и константы. Этот
расчет задуман с учетом защищенности, чтобы предотвращать целочисленное переполнение и гарантировать, что результат будет вписываться
в диапазон 32-битного целого числа;
④ здесь вычисленный результат возвращается как 32-битное целое число,
если он попадает в диапазон от i32::MIN до i32::MAX. Исходный код включает следующую проверку: если значение превышает максимум, то возвращается i32::MAX; если оно ниже минимума, то i32::MIN. Данная проверка удерживает результат в допустимых границах;
⑤ если система наведения не находится в безопасном режиме, то в этой
строке выполняется критический расчет без проверок безопасности.
Он напрямую выполняет приведение результата к 32-битному целому
числу, рискуя вызвать переполнение или иное незащищенное поведение. Здесь симулируется сценарий, когда меры защиты обходят стороной
и вычисления проводятся без ограничений;
⑥ в методе unsafe_use_result эта строка увеличивает счетчик unsafe_
counter на значение результата. Операция fetch_add является атомарной, что позволяет потокам исполнения защищенно обновлять счетчик.
Этот счетчик служит итоговым показателем потенциально рискованных
операций, выполненных системой;
⑦ в данной строке выводится сообщение об использовании результата внутри системы наведения. В симуляции это просто печать текста, но в реальных условиях здесь находилась бы фактическая реализация использования вычисленного результата внутри системы наведения;
⑧ здесь инициализируется поле safe_mode. Атомарному булеву значению
присваивается false, означая, что в начале симуляции система наведения не находится в безопасном режиме работы;
⑨ поле unsafe_counter инициализируется значением 0, отражая отсутствие
каких-либо незащищенных операций на старте;
⑩ в этой строке создается клонированная ссылка на систему наведения
с помощью Arc::clone. Умный указатель Arc (атомарный счетчик ссылок)
обеспечивает коллективное владение объектом между несколькими потоками исполнения, позволяя им защищенно обращаться к системе без
конфликтов синхронизации;
⑪ для критического расчета в главном потоке исполнения симулируется
малая масса полезной нагрузки. Этот параметр является ключевым для
вычислений критических операций в системе наведения;
⑫ метод safe_critical_calculation вызывается в потоке системы наведения с передачей симулируемой массы полезной нагрузки. Метод вычисляет результат критической операции защищенным способом с учетом
текущего статуса режима работы системы;
Практические примеры 579
⑬ результат защищенного критического вычисления выводится в главном
потоке исполнения. Этот шаг позволяет выполнять наблюдение и анализ
вычисленного результата в ходе симуляции;
⑭ метод unsafe_use_result вызывается внутри блока unsafe, симулируя использование вычисленного результата незащищенным способом. Он иллюстрирует потенциальные последствия незащищенных операций в системе наведения;
⑮ по каждому потоку исполнения, порожденному в цикле, создается клонированная ссылка на систему наведения. За счет этого гарантируется,
что у каждого потока исполнения будет собственная ссылка на общую
систему наведения, что предотвратит потенциальные конфликты между потоками;
⑯ в порожденных потоках исполнения симулируется более крупная масса
полезной нагрузки для критической операции. Использование разных
значений массы в каждом потоке вносит разнообразие в симулируемые
сценарии;
⑰ метод safe_critical_calculation вызывается в каждом порожденном потоке исполнения с симулированной массой полезной нагрузки в качест
ве параметра. Каждый поток независимо вычисляет результат критической операции защищенным способом;
⑱ метод unsafe_use_result вызывается внутри блока unsafe для каждого
потока исполнения. Этим симулируется использование вычисленного
результата незащищенным образом, подчеркивая потенциальные рис
ки, связанные с незащищенными операциями;
⑲ метод join используется для ожидания завершения потока системы наведения (первого порожденного потока исполнения), чтобы завершить
исполнение перед тем, как перейти к выполнению остальной работы
главного потока, обеспечивая надлежащую синхронизацию и наблюдение за симуляцией;
⑳ метод join вызывается в цикле, чтобы ожидать завершения всех остальных потоков исполнения. Этот шаг гарантирует, что все задания будут
выполнены до завершения симуляции.
Итоговое значение счетчика unsafe_counter выводится на экран для
оценки кумулятивного количества потенциально незащищенных операций. Он дает представление о масштабе влияния незащищенных операций
в ходе симуляции.
Подобного рода симуляции призваны отражать сложные аспекты таких уязвимостей, как Heartbleed, и катастрофического инцидента с первым полетом
ракеты Ариан-5. Они подчеркивают острую необходимость в тщательном анализе и глубоком тестировании при работе с незащищенным исходным кодом
в ситуациях из реальной практики. Цель состоит в том, чтобы акцентировать
внимание на потенциальных последствиях игнорирования мер безопасности,
призывая разработчиков подходить к написанию незащищенного исходного
кода с максимальной осторожностью и основательностью, чтобы избегать подобных проблем в практических приложениях.
580 Незащищенное программирование на Rust
Риски использования незащищенного исходного кода
Для того чтобы умело ориентироваться в пространстве незащищенного исходного кода, крайне важно ознакомиться с типичными ловушками и рисками,
присущими незащищенному программированию на практике. Работа в этой
области требует всестороннего осознания потенциальных проблем, которые
могут возникать, и четкое понимание сопутствующих рисков здесь является
ключевым. Погружаясь в сложности этих рисков, мы расширяем свои возможности в принятии взвешенных решений при выборе в пользу использования
незащищенного исходного кода в проектах. Распознавание этих опасностей
служит фундаментом для разработки стратегий по упреждающему смягчению
потенциальных проблем. Это гарантирует, что преимущества использования
незащищенного исходного кода будут реализованы при одновременной защите от нежелательных последствий.
Риск 1: обращение к памяти по пустому указателю
Обращение к памяти по пустому указателю, как вы увидели в главе 1 «Системное программирование на языке Rust», является частым источником дефектов
исходного кода во многих языках программирования, приводящим к аварийным прекращениям работы или неопределенному поведению программ. Хотя
используемая в Rust система владения в значительной степени устраняет риск
появления пустых указателей, незащищенный исходный код может вновь
привносить эту опасность. При работе с сырыми указателями необходимо
проявлять осторожность и следить за тем, чтобы они ошибочно не обращались
к памяти по пустым или висячим указателям.
Листинг 13.12. Обращение к памяти по пустому указателю
fn main() {
let null_pointer: *const i32 = std::ptr::null();
unsafe {
let value = *null_pointer; // ①
println!("Извлеченное по указателю значение: {}", value); // ②
}
}
// Вывод:
// Извлеченное по указателю значение: 0
В приведенном выше примере:
① внутри блока unsafe исходный код пытается обратиться к памяти по пус
тому указателю. Обращение к памяти по пустому указателю – это опасная операция, которая может приводить к неопределенному поведению,
так как указатель ссылается на невалидную или несуществующую область памяти;
② затем извлеченное по указателю значение выводится на экран. Однако
поскольку указатель был пустым, попытка использовать это значение
может привести к аварийному завершению программы или непредска-
Риски использования незащищенного исходного кода 581
зуемому поведению. Этот пример служит предостерегающей иллюстрацией рисков, связанных с обращением к памяти по пустым указателям
в незащищенном исходном коде на Rust.
Работа с пустыми указателями на Rust требует предельной осторожности,
особенно внутри блоков unsafe. Обращение к памяти по такому указателю
может становиться причиной неопределенного поведения, ставя под угрозу стабильность и предсказуемость программы. Необходимо проявлять бдительность, тщательно проверять указатели на валидность и использовать защищенные альтернативы во избежание потенциальных аварийных отказов
и уязвимостей безопасности. Акцент Rust на защищенности является его важнейшим аспектом, и к использованию блоков unsafe следует подходить с глубоким пониманием сопутствующих рисков и ответственности.
Риск 2: переполнение буфера
Переполнение буфера – это классическая уязвимость в системе безопасности,
которая может приводить к повреждению данных, аварийному завершению
программ или даже дистанционному исполнению произвольного исходного
кода. Защищенные абстракции Rust предохраняют от переполнения буфера,
но в незащищенном исходном коде следует быть предельно внимательными
при управлении границами памяти, чтобы не выходить за пределы выделенного пространства. Несоблюдение этого правила может становиться причиной
непредсказуемого поведения и возникновения уязвимостей.
Листинг 13.13. Переполнение буфера
fn main() {
let data: [u8; 5] = [1, 2, 3, 4, 5];
unsafe {
// Чтение за границами массива
let value = *data.get_unchecked(10); // ①
println!("Значение, полученное из переполненного буфера: {}",
value); // ②
}
}
// Вывод:
// Значение, полученное из переполненного буфера: 0
В приведенном выше исходном коде:
① осуществляется попытка доступа к элементу за пределами массива, что
приводит к переполнению буфера;
② осуществляется попытка использовать значение, полученное за пределами границ, что может привести к повреждению данных или аварийному завершению программы.
Приведенный выше пример подчеркивает опасность переполнения буфера в Rust, особенно внутри незащищенного исходного кода. Доступ к элементам вне границ массива может приводить к непредсказуемому поведению,
582 Незащищенное программирование на Rust
порче данных и даже возникновению уязвимостей в системе безопасности.
Разработчику следует проявлять крайнюю осторожность, обеспечивать надлежащую проверку границ и рассматривать альтернативные стратегии, чтобы
предотвращать переполнение буфера и поддерживать целостность своих программ. Акцент Rust на защищенности памяти по-прежнему критически важен,
а понимание рисков, связанных с незащищенными практиками, необходимо
для ответственного программирования на этом языке.
Риск 3: ошибки использования памяти
после ее высвобождения
Ошибки использования памяти после ее высвобождения возникают в ситуа
циях, когда программа продолжает использовать указатель, после того как память, на которую он ссылается, была высвобождена. Принятая в Rust модель
владения устраняет большинство случаев таких ошибок, но в сфере незащищенного исходного кода необходимо тщательно управлять временем жизни
объектов, чтобы предотвращать непреднамеренное использование данных
после высвобождения памяти под них.
Листинг 13.14. Пример висячей ссылки
fn main() {
// ①
let data = Box::new(42);
// ②
let raw_pointer = Box::into_raw(data); // ③
unsafe {
drop(Box::from_raw(raw_pointer)); // ④
}
}
// Использование после освобождения
println!("Значение по сырому указателю: {}", *raw_pointer); // ⑤
// Вывод:
// Значение по сырому указателю: 1492189596
В приведенном выше исходном коде:
① функция main является отправной точкой программы;
② используется базовый умный указатель (Box) для выделения памяти
в куче и хранения значения 42;
③ метод into_raw применяется для конвертации Box в сырой указатель
(*mut i32);
④ вызывается функция drop, которая высвобождает память, выделенную
под умный указатель. Этот шаг симулирует сценарий, при котором память высвобождается;
⑤ внутри блока unsafe предпринимается попытка обратиться к значению
по сырому указателю, после того как выделенная под него память была
высвобождена. Эта ситуация может привести к неопределенному поведению.
Заключение 583
В данном примере незащищенный исходный код пытается использовать
указатель после высвобождения памяти, что приводит к потенциальной ошибке использования памяти после ее высвобождения. Этот пример подчеркивает
важность тщательного управления временем жизни объектов при работе с незащищенным исходным кодом. Минимизация таких рисков требует понимания последствий незащищенных операций и применения стратегий предотвращения нарушений защищенности памяти.
Продолжая обсуждение распространенных образцов практики применения незащищенного программирования, необходимо рассматривать возможность применения стратегий снижения этих рисков. К ним могут относиться
использование защищенных абстракций везде, где это возможно, проведение
тщательной аудиторской проверки исходного кода с упором на секции unsafe
и использование инструментов компилятора Rust для статического анализа незащищенного исходного кода, позволяющих выявлять потенциальные
проблемы на ранних стадиях разработки.
Заключение
Работа с незащищенным исходным кодом на Rust представляет собой мощную, но сложную область, требующую глубокого понимания и мудрого применения. Навигация в этом пространстве обязывает разработчиков искать
тонкую грань, соблюдая хрупкий баланс между оптимизацией производительности и поддержанием защищенности. Это равновесие становится особенно
критичным при принятии решений, адаптированных под специфические требования разрабатываемых проектов, где компромиссы между эффективностью и защищенностью становятся очевидными.
В этой главе мы провели обследование фундаментальных аспектов незащищенного исходного кода, раскрыв его необходимость в различных сценариях, таких как взаимодействие с другими языками (через FFI), выполнение
низкоуровневых системных операций и решение задач, критичных к производительности. Были использованы примеры из реальной практики и разборы практических случаев, чтобы подчеркнуть прагматичную пользу незащищенного исходного кода и осветить потенциальные последствия, вытекающие
из его неправильного использования. Погружаясь в практическое применение, разработчики получают представление о том, когда и как эффективно использовать незащищенный исходный код, оставаясь при этом внимательными к ловушкам, которые могут возникать при неосторожном обращении.
Кроме того, в главе были детально рассмотрены лучшие образцы практики, регулирующие разумное использование незащищенного исходного кода,
и затронуты сопутствующие риски, общие для типичных методов программирования, которые могут ставить под угрозу защищенность. Были разъяснены стратегии выявления и смягчения потенциальных проблем с акцентом
на важность проведения всесторонней аудиторской проверки исходного кода,
строгого тестирования и внедрения инструментов статического анализа. Благодаря этим мерам появляется возможность осторожно использовать мощь
незащищенного исходного кода, эффективно балансируя между его преимуществами и необходимостью минимизации рисков.
584
Незащищенное программирование на Rust
По мере того как Rust продолжает свой эволюционный путь, рекомендуется
следить за новыми лучшими образцами практики, использовать обилие ресурсов сообщества Rust и активно участвовать в дискуссиях вокруг защищенного и эффективного системного программирования. Подход к незащищенному исходному коду с мышлением, основанным на уважении, осторожности
и стремлении к совершенству, позволяет раскрывать заложенный в нем потенциал, оставаясь верными фундаментальным принципам Rust – защищенности
и производительности. Это сочетание использования мощи незащищенного
исходного кода и соблюдения основных постулатов конструктивной философии Rust позволяет эффективно и ответственно справляться со сложностями
современного системного программирования.
В следующей главе рассматриваются сложности асинхронного программирования на Rust – важнейшей парадигмы, предназначенной для разработки
отзывчивых и эффективных приложений. Наше изложение будет сосредоточено на Rust’овском синтаксисе async/await, обследовании его нюансов и предоставлении всестороннего понимания его применения. Кроме того, описывается библиотека tokio – ведущая среда исполнения для асинхронного исходного
кода на Rust, с рассмотрением ее функциональных средств и способностей.
Независимо от уровня опыта в Rust, полученные знания позволят создавать
высокопроизводительные приложения, эффективно справляющиеся с конкурентными и параллельными задачами.
Дополнительные ресурсы
В целях углубления своих знаний о незащищенном Rust рекомендуется обратиться к следующим ниже ресурсам.
Книга по Rust – «Незащищенный Rust»: обратитесь к официальной документации Rust, где подробно разбираются тонкости использования
незащищенного исходного кода и сценарии, в которых он необходим:
doc.rust-lang.org/book/ch19-01-unsafe-rust.html.
Rustonomicon: познакомьтесь с «Растономиконом» – ресурсом, посвященным исследованию «тайных знаний» о незащищенном Rust и предлагающим глубокий разбор продвинутых приемов программирования
и важных нюансов: doc.rust-lang.org/nomicon/.
Справочник по Rust – «Неопределенное поведение»: ознакомьтесь с разделом справочника по Rust, посвященным неопределенному поведению,
чтобы лучше понять последствия ошибок в незащищенном исходном
коде и способы избегать типичных ловушек: doc.rust-lang.org/reference/
behavior-considered-undefined.html.
Незащищенный Rust и Miri (Rust Zürich, июнь 2023): посмотрите выступление Ральфа Юнга на конференции RustConf 2023, где приводятся
практические примеры использования незащищенного исходного кода
и инструмента Miri: youtube.com/watch?v=svR0p6fSUYY.
Rust через тернии излишнего количества связных списков: ознакомьтесь
с руководством «Too Many Linked Lists», которое предлагает практический
подход к изучению Rust, включая разделы о незащищенном исходном
коде, владении и заимствовании: rust-unofficial.github.io/too-many-lists/.
Вопросы для самопроверки 585
RFC 2585 – «Разрешить незащищенные блоки в незащищенных функциях»: предложение RFC, которое разрешает (и поощряет) использование
явных блоков unsafe внутри незащищенных функций: https://github.com/
rust-lang/rfcs/blob/master/text/2585-unsafe-block-in-unsafe-fn.md.
Перечисленные выше ресурсы охватывают различные аспекты работы с незащищенным исходным кодом на Rust, предоставляя как теоретическую базу,
так и практические рекомендации для ответственного и эффективного программирования.
Вопросы для самопроверки
Вопрос 1. В каких случаях разработчики могут решать использовать незащищенный исходный код на Rust?
a) во всех задачах программирования, чтобы максимизировать производительность
b) только при работе с высокоуровневыми абстракциями
c) при взаимодействии с внешними языками или выполнении низкоуровневых операций
d) для обеспечения строгой защищенности памяти
Вопрос 2. На что указывает ключевое слово unsafe в Rust?
a) на то, что исходный код подвержен ошибкам
b) на то, что исходный код нарушает правила синтаксиса Rust
c) на то, что исходный код требует особого внимания и может обходить
проверки защищенности
d) на то, что исходный код неэффективен
Вопрос 3. Когда может потребоваться использование интерфейса с внешними функциями (FFI) с незащищенным исходным кодом в контексте
Rust?
a) для улучшения удобочитаемости исходного кода
b) при разработке исключительно на Rust без внешних зависимостей
c) при взаимодействии с внешними языками, в которых отсутствуют защитные функциональные средства Rust
d) для обеспечения строгого соблюдения принятых в Rust правил заимствования
Вопрос 4. Что является потенциальным риском при использовании незащищенного исходного кода на Rust?
a) улучшение производительности исходного кода
b) неопределенное поведение, нарушения защищенности памяти или гонки за доступ к данным
c) повышение удобочитаемости исходного кода
d) совместимость со всеми версиями Rust
586
Незащищенное программирование на Rust
Вопрос 5. Что представляет собой сырой указатель в контексте Rust?
a) высокоуровневую ссылку на данные
b) типозащищенный указатель
c) указатель со строгими правилами времени жизни
d) прямой адрес памяти без защитных гарантий
Вопрос 6. Как принятая в Rust система владения способствует защищенности памяти?
a) путем разрешения неограниченного доступа к памяти
b) путем соблюдения строгих правил заимствования
c) путем автоматического управления выделением и высвобождением памяти
d) путем разрешения нескольким частям исходного кода одновременно изменять данные
Вопрос 7. Каково предназначение метода as_ptr() в Rust?
a) конвертация указателя в ссылку
b) выполнение арифметических операций на указателе
c) получение сырого указателя на первый элемент коллекции
d) приведение указателя к другому типу
Вопрос 8. Когда разработчик может решить использовать блоки unsafe
в Rust?
a) всегда, для всех задач программирования
b) только для задач, связанных с высокоуровневыми абстракциями
c) в ситуациях, когда преимущества обхода проверок безопасности перевешивают риски
d) когда соблюдение строгих защитных правил не имеет значения
Вопрос 9. Что является основным соображением при уравновешивании
производительности и защищенности в Rust?
a) приоритет производительности без каких-либо соображений защищенности
b) игнорирование потенциальных рисков и последствий незащищенного
исходного кода
c) оценка перевешивания выгод в производительности относительно рисков незащищенного исходного кода
d) обеспечение максимальной защищенности без учета производительности
Вопрос 10. Каково предназначение функции opencv::imgproc::blur в приведенном примере продвинутой обработки изображений с использованием библиотеки OpenCV?
a) конвертация в оттенки серого
b) размытие по Гауссу
Вопросы для самопроверки 587
c) детектор границ по методу Кэнни
d) изменение размера изображения
Вопрос 11. Какова роль структуры CustomMemoryManager в примере
с конкретно-прикладным управлением памятью?
a) она определяет операции обработки изображений
b) она упаковывает два указателя на функции для конкретно-прикладного
выделения и высвобождения памяти
c) она представляет структуру данных для хранения пиксельных значений
d) она отвечает за обработку взаимодействий с базой данных
Вопрос 12. Каково предназначение функции custom_allocate в примере
с конкретно-прикладным управлением памятью?
a) она выполняет изменение размера изображения
b) она обрабатывает запросы к базе данных
c) она выделяет конкретно-прикладную память, используя стандартный
для rust выделитель памяти
d) она определяет общие черты для структур данных
Вопрос 13. Каково предназначение метода iter_mut в примере с альтернативным подходом?
a) определение общего поведения
b) выделение конкретно-прикладной памяти
c) получение мутируемого итератора по элементам без использования незащищенного исходного кода
d) ограничение доступа к данным
Ответы
1. c) при взаимодействии с внешними языками или выполнении низко
уровневых операций
2. c) на то, что исходный код требует особого внимания и может обходить
проверки защищенности
3. c) при взаимодействии с внешними языками, в которых отсутствуют защитные функциональные средства Rust
4. b) неопределенное поведение, нарушения защищенности памяти или
гонки за доступ к данным
5. d) прямой адрес памяти без защитных гарантий
6. b) путем соблюдения строгих правил заимствования
7. c) получение сырого указателя на первый элемент коллекции
8. c) в ситуациях, когда преимущества обхода проверок безопасности перевешивают риски
9. c) оценка перевешивания выгод в производительности относительно
рисков незащищенного исходного кода
10. b) размытие по Гауссу
11. b) она упаковывает два указателя на функции для конкретно-прикладного выделения и высвобождения памяти
588
Незащищенное программирование на Rust
12. c) она выделяет конкретно-прикладную память, используя стандартный
для Rust выделитель памяти
13. c) получение мутируемого итератора по элементам без использования
незащищенного исходного кода
Ключевые термины
Незащищенный исходный код (unsafe code): исходный код на Rust, который обходит привычные защитные гарантии, предоставляемые компилятором, позволяя выполнять операции, которые считались бы рис
кованными в рамках защищенного Rust.
Сырой указатель (raw pointer): прямой адрес в памяти без гарантий защищенности; часто используется в незащищенном исходном коде для низкоуровневых операций, в которых необходим полный контроль над памятью.
Интерфейс с внешними функциями (foreign function interface, FFI):
механизм в Rust, обеспечивающий взаимодействие с исходным кодом,
написанным на других языках. Он позволяет бесшовно интегрироваться
с внешними библиотеками, что часто влечет за собой использование незащищенного исходного кода.
Защищенность памяти (memory safety): ключевая характеристика
Rust, предотвращающая типичные ошибки программирования, такие
как обращение к памяти по пустому указателю, переполнение буфера
и гонки за доступ к данным путем соблюдения строгих правил владения
и заимствования.
Неопределенное поведение (undefined behavior): нежелательные и непредсказуемые результаты исполнения исходного кода, нарушающего
правила языка; часто связано с неправильным использованием незащищенного исходного кода.
Контролёр заимствований (borrow checker): компонент компилятора
Rust, который следит за соблюдением правил владения и заимствования,
гарантируя валидность ссылок и предотвращая гонки за доступ к данным.
Оптимизация производительности: процесс повышения эффективности программы на Rust, часто предусматривающий отыскание компромисса между быстродействием и защищенностью при тщательном
анализе преимуществ и рисков незащищенного исходного кода.
Размытие по Гауссу (Gaussian blur): метод обработки изображений, используемый для уменьшения шума и детализации (как показано в примере с библиотекой OpenCV) путем применения операции свертки
с фильтром Гаусса.
Конкретно-прикладной менеджер памяти (custom memory manager):
структура, продемонстрированная в примере такого менеджера, отвечающая за координацию операций выделения и высвобождения памяти
в конвейере обработки данных.
Детектор границ по методу Кэнни (Canny edge detection): продвинутый метод обработки изображений из примера с библиотекой OpenCV,
используемый для обнаружения границ на изображении путем выявления областей резкого изменения интенсивности.
Ключевые термины 589
Модульный подход (modular approach): образец практики программирования на Rust, заключающийся в упаковке незащищенного исходного
кода внутрь защищенных абстракций с целью минимизации «площади
поверхности» рисков и влияния на остальную кодовую базу.
Нарушения защищенности памяти (memory safety violations): риски,
связанные с незащищенным исходным кодом, которые могут приводить
к неопределенному поведению и уязвимостям из-за нарушения правил
владения и заимствования.
Метод iter_mut: защищенная абстракция в стандартной библиотеке
Rust, используемая для получения мутируемого итератора по элементам, обеспечивающая контролируемый способ выполнения операций
на мутируемых ссылках.
Глава 14
Асинхронное
программирование
Введение
В постоянно эволюционирующем ландшафте разработки приложений значимость асинхронного программирования невозможно переоценить. Поскольку
наш цифровой мир нуждается в том, чтобы приложения были одновременно
отзывчивыми и эффективными, понимание и освоение методов асинхронного
программирования становится краеугольным камнем для разработчиков. Эта
глава представляет собой комплексное руководство, которое поможет разобраться в сложностях асинхронного программирования на Rust, уделяя особое
внимание использованию синтаксиса async/await и мощной библиотеки tokio.
В начале этого обследования темы асинхронного программирования на Rust
мы займемся знакомством с уникальными характеристиками методики async/
await в Rust – динамичного сочетания, которое позволяет программам продолжать исполнение заданий, не дожидаясь завершения каждого из них. Представьте себе это как крутой трюк, когда ваша программа без усилий «жонглирует» несколькими заданиями, поддерживая высокую скорость работы. В этой
главе освещается стиль async/await Rust, проводя вас по процессу создания
программ, которые не только быстры и отзывчивы, но и с легкостью справляются с многочисленными заданиями конкурентно. По сути, речь идет о придании вашей программе экспертных качеств в области многозадачности, обес
печивая эффективность ее работы.
К концу этой главы вы не только получите полное понимание фундаментальных принципов и методов, лежащих в основе асинхронного программирования, но и овладеете умениями, необходимыми для конструирования надежных приложений, использующих мощь конкурентности. Углубляясь в тонкости
асинхронного программирования на Rust, мы стремимся прояснить все сложности, предоставляя практические идеи и примеры, которые позволят вам создавать приложения, превосходящие другие по отзывчивости и эффективности.
Содержание главы
В этой главе будут рассмотрены следующие темы:
Основы синтаксиса async/await Rust 591
изучение асинхронного программирования с использованием синтаксиса async/await Rust;
использование библиотеки tokio для создания асинхронных приложений.
Основы синтаксиса async/await Rust
Синтаксис async/await Rust привносит фундаментальные изменения в методы
структурирования и исполнения исходного кода. Ключевое слово async служит
входной точкой для создания асинхронных функций, превращая их в сущности, способные исполняться конкурентно с другими заданиями. Асинхронные
функции позволяют чередовать исполнение исходного кода: программа может
продвигаться дальше, ожидая при этом завершения асинхронных операций.
Это представляет собой качественное отличие от традиционного синхронного программирования, где операции обычно блокируют поток исполнения
до момента своего завершения.
Давайте рассмотрим следующий ниже пример, иллюстрирующий базовую
структуру асинхронной функции с использованием синтаксиса async/await:
Листинг 14.1. Базовая структура асинхронной функции
async fn example_async_function() {
// ①
// Здесь идет асинхронный исходный код
let result = async_operation().await; // ②
// ③
}
В приведенном выше примере:
① функция example_async_function объявлена с ключевым словом async,
что сигнализирует о ее асинхронной природе. Это объявление активирует неблокирующее исполнение, позволяя функции выполнять асинхронные операции, не останавливая работу всей программы;
② внутри тела функции переменной result присваивается значение, возвращаемое асинхронной операцией async_operation. Ключевое слово
await здесь является критически важным элементом: оно щадяще приостанавливает исполнение функции, позволяя среде исполнения переключиться на другие задания, ожидая при этом завершения операции
async_operation;
③ по завершении асинхронной операции исполнение функции возобновляется с этой точки. Здесь можно бесшовно продолжить работу с результатом, полученным в ходе операции. Этот элегантный синтаксис повышает выразительность исходного кода, позволяя органично встраивать
асинхронную логику в свои приложения – что и является отличительной
чертой синтаксиса async/await Rust.
Для того чтобы глубже погрузиться в тонкости синтаксиса async/await, давайте проинспектируем более развернутый пример, демонстрирующий универсальность этой парадигмы. Следующий ниже фрагмент исходного кода иллюстрирует асинхронную функцию с более сложной структурой.
592 Асинхронное программирование
Листинг 14.2. Усложненная структура асинхронной функции
async fn complex_async_function() {
let result_1 = async_operation_1().await;
if result_1 {
// ③
let result_2 = async_operation_2().await;
match result_2 {
// ⑤
Ok(value) => { // ⑥
println!("Результат: {}", value);
}
Err(error) => { // ⑧
eprintl!("Ошибка: {}", error);
}
}
} else {
// ⑩
println!("Операция 1 не удалась");
}
}
// ①
// ②
// ④
// ⑦
// ⑨
// ⑪
В приведенном выше примере:
① функция complex_async_function объявлена как асинхронная, что указывает на возможность ее асинхронного исполнения. Это обозначение позволяет включать асинхронные операции, не блокируя исполнение программы;
② переменной result_1 присваивается значение, возвращаемое асинхронной операцией async_operation_1(). Ключевое слово await приостанавливает функцию до момента завершения этой операции, обеспечивая
неблокирующее исполнение;
③ происходит ветвление по условию на основе значения result_1. Если
результатом его вычисления является истина, указывая на успех, то исполняется блок внутри инструкции if; в противном случае исполняется
блок else;
④ внутри блока if переменной result_2 присваивается значение еще одной асинхронной операции – async_operation_2(). И снова ключевое
слово await играет важную роль в управлении асинхронной природой
этой операции;
⑤ инструкция match задействуется для обработки возможных исходов переменной result_2. Эта конструкция позволяет реализовать исчерпывающее сопоставление с шаблоном, охватывающее как успешные, так
и безуспешные сценарии (т. е. с ошибками);
⑥ в случае успешного результата исполняется исходный код в этой ветке инструкции match, который обрабатывает и выводит результат
на экран;
⑦ здесь обрабатывается успешный результат, демонстрируя бесшовную
интеграцию асинхронной логики в приложение. Элегантность синтаксиса async/await Rust становится очевидной;
⑧ в случае ошибки исполняется исходный код в этой ветке инструкции
match. Обработка ошибок является неотъемлемой частью надежного
асинхронного программирования;
Обследование динамики синтаксиса async/await 593
⑨ здесь обрабатывается случай с ошибкой, при этом соответствующее
сообщение выводится в стандартный поток ошибок. Это служит примером приверженности Rust защищенной и выразительной обработке
ошибок;
⑩ если переменная result_1 принимает значение false, что указывает
на безуспешность первой асинхронной операции, то исполняется исходный код внутри блока else;
⑪ в блоке else программа обрабатывает случай, когда первая операция
завершилась безуспешно, выводя соответствующее сообщение. Этот
комплексный пример демонстрирует не только элегантность синтаксиса async/await Rust, но и его способность обрабатывать сложные асинхронные рабочие процессы, включая условное ветвление и обработку
ошибок.
Кроме того, крайне важно понимать, что стержневым элементом в этом
асинхронном задании является ключевое слово await. Оно щадяще приостанавливает исполнение текущей функции, позволяя среде исполнения переключаться на другие задания до тех пор, пока ожидаемая операция не завершится. Указанная неблокирующая природа имеет решающее значение для
достижения эффективной конкурентности, гарантируя, что программа продвигается беспрепятственно, даже пока определенные операции находятся
в ожидании своего решения.
Асинхронное программирование на Rust – это не просто синтаксис; это
смена мышления, которая открывает двери к эффективному конкурентному
исполнению. Принятие этой парадигмы на вооружение дает разработчику
возможность создавать отзывчивые и масштабируемые приложения, где бесшовно смешивается чередующееся исполнение заданий, что приводит к повышению производительности и скорости отклика.
Обследование динамики синтаксиса async/await
В целях глубокого понимания механики синтаксиса async/await Rust давайте проинспектируем ее посредством сложного процесса, который руководит
исполнением асинхронных функций. Представьте, что вы дирижируете работой распределенной системы, в которой определенные операции, например
одновременная доставка данных из нескольких дистанционных серверов, получили бы огромную выгоду от асинхронного исполнения.
Когда асинхронная функция (назовем ее fetchDataFromServers) встречает
выражение await, она «вежливо отступает в сторону» в процессе своего исполнения. Эта временная пауза подобна тому, как дирижер на мгновение останавливает выступление, позволяя другим музыкантам (асинхронным заданиям)
выйти на передний план. В контексте синтаксиса async/await Rust такая приостановка является намеренной и стратегической, способствуя реализации
кооперативной многозадачности.
594 Асинхронное программирование
Исполнение функции fetchDataFromServers()
Запрос к серверу A
Запрос к серверу B
|
|
v
v
+----------------+
+----------------+
| Получение
|
|
|
|
данных
|
|
Получение
|
|
из A
|
|
данных
|
|
|
|
из B
|
+----------------+
+----------------+
|
|
v
v
(выражение await)
(выражение await)
|
|
v
v
+----------------+
+----------------+
|
Сервер A
|
|
Сервер B
|
| Обработка
|
|
Быстро
|
+----------------+
+----------------+
|
|
v
v
+----------------+
+----------------+
| Возобновление |
| Возобновление |
|
из A
|
|
из B
|
+----------------+
+----------------+
|
|
v
v
+------------------------------------------+
|
Продолжение исполнения кода
|
|
с точки ожидания в функции
|
+------------------------------------------+
Рассмотрим такой сценарий: функция fetchDataFromServers инициирует запрос к серверу А, достигает выражения await и вежливо приостанавливается.
В это время среда исполнения Rust, подобно опытному дирижеру, переключает внимание на другие текущие задания, обеспечивая гармоничную симфонию конкурентных операций.
Теперь допустим, что сервер B отвечает быстрее. Пока сервер А все еще занят
обработкой, получение данных от сервера B завершается. Среда исполнения Rust,
используя мощь выражения await, бесшовно возобновляет исполнение функции
fetchDataFromServers именно с того места, где оно было прервано. Функция восстанавливает контроль и обрабатывает данные сервера B, не теряя ни секунды.
Что крайне важно, принятая в Rust система владения выступает в этой постановке в роли дирижера, гарантируя защищенность памяти и предотвращая
гонки за доступ к данным. Каждое асинхронное задание владеет своими данными, что исключает риск конфликтов и вносит гармонию в оркестровку заданий. Эта тщательная хореография, организованная с помощью синтаксиса
async/await и направляемая принятыми в Rust принципами владения, наделяет программу отзывчивостью и эффективностью без использования традиционных блокирующих вызовов.
Пара ключевых слов async/await в Rust – это не просто синтаксическое
средство; это симфония кооперативной многозадачности, где дирижер (среда
Использование ключевого слова async для повышения отзывчивости 595
исполнения Rust) руководит безупречным исполнением. Он позволяет асинхронным функциям гармонично уступать очередь и возобновлять работу, создавая мелодию эффективного конкурентного исполнения.
Использование библиотеки tokio
В экосистеме Rust библиотека tokio выделяется как надежная и широко распространенная среда исполнения для асинхронного программирования. Предоставляя необходимые инструменты и абстракции, tokio отлично подходит
для эффективной координации асинхронных заданий, становясь предпочтительным решением для разработки высокопроизводительных приложений.
В основе способностей библиотеки tokio лежит комплексная среда исполнения, включающая в себя такие функциональные средства, как планировщик,
событийный цикл и другие интегральные компоненты.
Для того чтобы использовать библиотеку tokio в проекте на Rust, необходимо добавить следующие ниже зависимости в файл Cargo.toml:
[dependencies]
tokio = { version = "1.34.0", features = ["full"] }
После интеграции библиотеки tokio появляется возможность использовать
ее среду исполнения для порождения асинхронных заданий, управления конкурентностью и эффективной обработки операций ввода-вывода.
Использование ключевого слова async
для повышения отзывчивости
Когда вы только начинаете изучать асинхронное программирование, основной мотивацией часто является повышение отзывчивости приложений. Синтаксис async/await Rust служит мощным инструментом для достижения этой
цели, позволяя конструировать системы, которые эффективно справляются
с операциями с интенсивным использованием ввода-вывода, не жертвуя при
этом производительностью.
Давайте рассмотрим пример, в котором несколько асинхронных функций
сотрудничают в конкурентном скачивании и обработке данных:
Листинг 14.3. Пример рабочего процесса
с несколькими асинхронными функциями
use tokio::time::Duration;
async fn fetch_data() -> Result<String, reqwest::Error> { // ①
tokio::time::sleep(Duration::from_secs(2)).await;
// ②
Ok::<String, _>("Данные успешно доставлены".to_owned()) // ③
}
async fn process_data(data: String) {
// ④
println!("Обрабатывается: {}", data); // ⑤
}
596
Асинхронное программирование
async fn main_async_workflow() {
// ⑥
let fetch_task = tokio::spawn(fetch_data()); // ④
let process_task = tokio::spawn(process_data("Образец данных".to_
owned())); // ⑧
let fetch_result = fetch_task.await.expect("Не удалось доставить данные"); // ⑨
let _ = process_task.await; // ⑩
}
println!("Доставка результата: {:?}", fetch_result); // ⑪
#[tokio::main]
// ⑫
async fn main() {
main_async_workflow().await; // ⑬
}
// Вывод:
// Обрабатывается: Образец данных
// Доставка результата: Ok("Данные успешно доставлены")
В приведенном выше исходном коде:
① функция fetch_data определена для симуляции асинхронного HTTPзапроса. Она возвращает экземпляр типа Result, содержащий доставленные данные либо ошибку типа reqwest::Error;
② внутри функции fetch_data вводится асинхронная задержка в 2 секунды
с помощью tokio::time::sleep. Она имитирует асинхронную природу
HTTP-запроса, выполнение которого требует времени;
③ функция завершается возвратом доставленных данных как успешного
результата с помощью Ok("Данные успешно доставлены".to_owned());
④ функция process_data объявлена для асинхронной обработки доставленных данных;
⑤ внутри функции process_data выполняется простая операция: вывод обработанных данных в консоль;
⑥ функция main_async_workflow дирижирует общим рабочим процессом, наглядно показывая, как координировать работу нескольких асинхронных
заданий;
⑦ функция tokio::spawn используется для порождения асинхронного задания для функции fetch_data, активируя конкурентное исполнение;
⑧ аналогичным образом порождается еще одно задание для функции
process_data, демонстрируя способность конкурентно выполнять несколько асинхронных операций;
⑨ здесь ожидается завершение задания по доставке данных, при этом извлекается результат либо поднимается паника с сообщением об ошибке,
если доставка не удалась;
⑩ завершение задания по обработке также ожидается. Символ подчеркивания (_) указывает на то, что нас не интересует возвращаемое значение,
так как это происходит конкурентно
⑪ функция завершается выводом результата операции доставки, демонстрируя асинхронную координацию и исполнение заданий. Этот пример
Обработка ошибок в асинхронном исходном коде 597
подчеркивает, как синтаксис async/await Rust в сочетании с такой средой
исполнения, как tokio, значительно повышает отзывчивость приложений.
Синтаксис async/await Rust в связке с асинхронными средами исполнения (такими как tokio) предоставляет мощную парадигму для построения конкурентных и отзывчивых приложений. В приведенном выше расширенном примере
оркестровка асинхронных заданий по доставке и обработке данных демонстрирует элегантность и выразительность асинхронной модели программирования
на Rust. Способность бесшовно интегрировать асинхронные операции в рабочий
процесс улучшает удобочитаемость исходного кода и упрощает создание эффективных неблокирующих приложений. Принимая эту парадигму на вооружение,
разработчик открывает потенциал для построения масштабируемых и высокопроизводительных систем, использующих все преимущества конкурентности.
Обработка ошибок в асинхронном исходном коде
Хотя синтаксис async/await Rust упрощает выражение асинхронной логики, умение ориентироваться в сложностях асинхронного программирования нуждается в глубоком понимании потенциальных проблем и их решений. Асинхронный
исходный код привносит такие трудности, как обработка ошибок, управление
временем жизни заданий и эффективная оркестровка конкурентных операций.
Обработка ошибок в асинхронном исходном коде предусматривает аспекты,
выходящие за рамки традиционной синхронной обработки. Rust предоставляет такие механизмы, как тип Result и оператор ?, для передачи ошибок вверх
по асинхронному стеку вызовов. Кроме того, функция tokio::task::spawn возвращает объект tokio::task::JoinHandle, который можно использовать для
ожидания завершения задания и обработки потенциальных ошибок.
Листинг 14.4. Обработка ошибок в асинхронной программе
use std::error::Error; // ①
async fn async_with_error_handling() -> Result<(), Box<dyn Error + Send>> { //
②
let handle = tokio::task::spawn(async { // ③
Ok::<(), Box<dyn Error + Send>>(()) // ④
});
let _ = handle.await.map_err(|error| { // ⑤
Box::new(error) as Box<dyn Error + Send> // ⑥
})?;
}
Ok(()) // ⑦
#[tokio::main]
async fn main() {
// ⑧
match async_with_error_handling().await { // ⑨
Ok(_) => println!("Асинхронная операция успешно завершена"), // ⑩
Err(error) => eprintln!("Ошибка во время асинхронной операции: {}",
error), // ⑪
}
}
598
Асинхронное программирование
// Вывод:
// Асинхронная операция успешно завершена
В приведенном выше примере:
① исходный код начинается с импорта необходимого модуля
std::error::Error, который предоставляет функциональности по обработке ошибок на Rust;
② функция async_with_error_handling объявлена как асинхронная. Она
возвращает экземпляр типа Result с пустым кортежем () и упакованным
объектом динамической диспетчеризации, представляющим любой тип,
в котором реализованы общие черты Error и Send;
③ внутри асинхронной функции с помощью tokio::task::spawn порождается новое асинхронное задание. Это задание содержит в себе асинхронную логику и настроено на возврат успешного результата Ok типа ();
④ асинхронная логика внутри порожденного задания является лишь мес
тозаполнителем; ее следует заменить реальными асинхронными операциями, которые могут приводить к ошибкам. Тип результата явно задан
как Ok<(), Box<dyn Error + Send>>;
⑤ после порождения задания исходный код ожидает ее завершения, задействуя ключевое слово await. Метод map_err используется для обработки
любых потенциальных ошибок, которые могут возникать во время исполнения асинхронного задания;
⑥ в случае возникновения ошибки она обертывается в Box<dyn Error +
Send>, чтобы соответствовать требованию к возвращаемому типу, позволяя единообразно улавливать разные типы ошибок;
⑦ сама функция возвращает Ok(()), если асинхронное задание завершилось успешно, что сигнализирует об отсутствии ошибок;
⑧ функция main помечена аннотацией #[tokio::main], указывающей на то,
что она служит точкой входа для среды исполнения tokio – популярной
асинхронной среды исполнения для исходного кода на Rust;
⑨ внутри функции main вызывается функция async_with_error_handling
с использованием ключевого слова await, что инициирует асинхронную
операцию;
⑩ если асинхронная операция завершается без ошибок, то с помощью макрофункции println! в консоль выводится сообщение об успехе;
⑪ в случае ошибки во время асинхронной операции в консоль выводится
сообщение с помощью макрофункции eprintln!.
Приведенный выше пример демонстрирует использование синтаксиса
async/await в связке со средой исполнения tokio для обработки асинхронных заданий со встроенной передачей ошибок по асинхронному стеку вызовов. Лаконичное, но мощное сочетание таких инструментов, как функция
tokio::task::spawn, ключевое слово await и оператор ?, способствует эффективному и чистому асинхронному программированию, предлагая надежную
основу для построения отзывчивых приложений.
Времена жизни конкурентных заданий 599
Времена жизни конкурентных заданий
Конкурентное программирование привносит свои трудности в управление
временами жизни асинхронных заданий и обеспечение эффективного использования ресурсов. Принятая в Rust система владения в сочетании со средой исполнения tokio предлагает надежное решение этих трудностей. Система владения гарантирует, что ссылки на данные остаются валидными на протяжении
всего времени исполнения асинхронного задания, предотвращая гонки за доступ к данным и проблемы с защищенностью памяти.
Разработчику следует внимательно следить за правилами владения
при использовании коллективных данных между асинхронными заданиями. Использование таких инструментов, как умный указатель Arc (Atomic
Reference Counting – атомарный подсчет ссылок) для создания потокозащищенного указателя с подсчетом ссылок, становится необходимым в ситуа
циях, когда нескольким заданиям требуется конкурентный доступ к коллективным данным.
Листинг 14.5. Асинхронная программа с коллективными данными
use tokio::sync::Mutex;
use std::sync::Arc;
async fn async_with_shared_data() {
// ①
let shared_data = Arc::new(Mutex::new(0)); // ②
let task1 = tokio::spawn(async_with_shared_data_task(Arc::clone(&shared_
data))); // ③
let task2 = tokio::spawn(async_with_shared_data_task(Arc::clone(&shared_
data))); // ④
println!("Порождены задание 1 и задание 2.");
task1.await.expect("Задание 1 не удалось"); // ⑤
println!("Задание 1 успешно завершилось.");
task2.await.expect("Задание 2 не удалось "); // ⑥
println!("Задание 2 успешно завершилось.");
}
println!("Продолжение работы с результатами."); // ⑦
async fn async_with_shared_data_task(shared_data: Arc<Mutex<i32>>) { // ⑧
let mut data = shared_data.lock().await; // ⑨
println!("Задание приобретает замок на коллективные данные.");
*data += 1; // ⑩
println!("Задание модифицирует коллективные данные: {}", data);
}
#[tokio::main]
async fn main() { // ⑪
async_with_shared_data().await;
// ⑫
println!("Главная функция завершена.");
}
600
Асинхронное программирование
// Вывод:
//
//
//
//
//
//
//
//
//
Порождены задание 1 и задание 2.
Задание приобретает замок на коллективные
Задание модифицирует коллективные данные:
Задание приобретает замок на коллективные
Задание модифицирует коллективные данные:
Задание 1 успешно завершилось.
Задание 2 успешно завершилось.
Продолжение работы с результатами.
Главная функция завершена.
данные.
1
данные.
2
В приведенном выше примере:
① асинхронная функция async_with_shared_data дирижирует использованием коллективных данных между несколькими заданиями;
② создается мьютех, обернутый в умный указатель Arc под названием
shared_data, чтобы коллективно использовать целое число (инициализированное значением 0) в защищенном режиме между асинхронными
заданиями;
③, ④ порождаются два асинхронных задания, каждое из которых вызывает
функцию async_with_shared_data_task, получая клонированную ссылку
(Arc::clone) на коллективные данные;
⑤, ⑥ ключевое слово await используется для ожидания завершения обоих
заданий, а возможные ошибки обрабатываются методом expect;
⑦ по завершении заданий функция продолжает исполнение, используя полученные результаты;
⑧ функция async_with_shared_data_task принимает в качестве параметра
умный указатель Arc<Mutex<i32>> для выполнения операций на коллективных данных;
⑨ внутри этой функции с помощью метода .lock().await приобретается
замок на коллективные данные, что гарантирует исключительный доступ к ним;
⑩ коллективные данные синхронно модифицируются – их значение увеличивается на 1;
⑪ функция main, помеченная аннотацией #[tokio::main], служит точкой
входа для среды исполнения tokio;
⑫ функция async_with_shared_data исполняется внутри среды исполнения
tokio с использованием ключевого слова await внутри функции main.
Ради большей наглядности давайте проиллюстрируем этот пример простой
схемой:
Продвинутые шаблоны асинхронного программирования 601
+---------------------------------------------------------------------+
|
main
|
|---------------------------------------------------------------------|
|
+ async_with_shared_data().await
|
|
|
|
+ async_with_shared_data
|
|
|
|
|
V
|
| +-----------------------------+
+-------------------------------+ |
| |
Задание 1
|
|
Задание 2
| |
| |------------------------------|
|------------------------------| |
| | + Задание приобретает замок |
| + Задание приобретает замок | |
| | | на коллективные данные
|
| | на коллективные данные
| |
| | V
|
| V
| |
| | +--------------------------+ |
| +--------------------------+ | |
| | | async_with_shared
| |
| | async_with_shared
| | |
| | | _data_task
| |
| | _data_task
| | |
| | |--------------------------| |
| |--------------------------| | |
| | | + Задание модифицирует
| |
| | + Задание модифицирует
| | |
| | | | коллективные данные: 1 | |
| | | коллективные данные: 2 | | |
| | | V
| |
| | V
| | |
| | | +----------------------+ | |
| | +----------------------+ | | |
| | | | Продолжение работы с | | |
| | | Продолжение работы с | | | |
| | | | результатами
| | |
| | | результатами
| | | |
| | | +--------------------+ | | |
| | +----------------------+ | | |
| +------------------------------+
+----------------------------+ | |
+---------------------------------------------------------------------+
Символ + используется для обозначения наличия компонента, например
метода.
Приведенный выше фрагмент исходного кода наглядно демонстрирует
мощь асинхронного программирования с коллективными данными, используя синтаксис async/await. Применение контейнера синхронизации Mutex,
обернутого в умный указатель Arc, гарантирует защищенный конкурентный
доступ к коллективным ресурсам, а асинхронная среда исполнения tokio облегчает координацию асинхронных заданий. Такая парадигма позволяет эффективно и синхронно манипулировать данными во всех конкурентных операциях, демонстрируя способности Rust в построении надежных и конкурентных приложений.
Продвинутые шаблоны асинхронного программирования
Асинхронное программирование на Rust выходит за рамки базового синтаксиса async/await, погружаясь в продвинутые шаблоны, которые расширяют
как выразительность, так и эффективность исходного кода. Эти сложные концепции критически важны для решения нетривиальных сценариев и оптимизации производительности асинхронных приложений. Одной из наиболее
значимых абстракций в этой области является использование асинхронных
потоков данных (или результатов исполнения заданий) – механизма, представляющего собой последовательность значений, генерируемых во временной динамике. Обследуя эти продвинутые шаблоны, можно открыть новые
602
Асинхронное программирование
возможности в управлении конкурентностью, обработке асинхронных событий и конструировании более сложных и отзывчивых систем.
Асинхронные потоки данных
Одним из ключевых продвинутых шаблонов в ландшафте асинхронного программирования на Rust является концепция асинхронных потоков данных.
В отличие от синхронных итераторов, которые производят конечную последовательность значений в блокирующем режиме, асинхронные потоки данных
позволяют генерировать значения асинхронно с течением времени. Это особенно выгодно в сценариях, где данные поступают непрерывно, например потоки событий, сенсорные данные или реально-временные обновления.
Для облегчения работы с ними в игру вступает пакет tokio_stream, предлагающий набор модулей и утилит. Эти инструменты придают разработчику способность бесшовно манипулировать, трансформировать и потреблять
асинхронные потоки данных, открывая обширный набор возможностей для
построения масштабируемых приложений.
В целях иллюстрации мощи асинхронных потоков данных давайте разберем практический пример. Представьте сценарий, в котором требуется генерировать асинхронный поток случайных чисел через регулярные промежутки
времени. Этот вариант использования подчеркивает гибкость асинхронных
потоков данных, позволяя эффективно моделировать динамические и непрерывные источники данных.
Следующий ниже фрагмент исходного кода демонстрирует создание конкретно-прикладного асинхронного потока данных под названием MyInterval
с использованием среды исполнения tokio и библиотеки tokio_stream. Этот
поток эмитирует экземпляры tokio::time::Instant через заданные промежутки времени. Кроме того, к нему применяется операция map, чтобы преобразовывать каждый элемент в случайное число типа u32, что демонстрирует
композицию и универсальность операций на асинхронных потоках данных.
Листинг 14.6. Асинхронные потоки данных
с использованием библиотеки tokio_stream
use
use
use
use
std::pin::Pin;
std::task::{Context, Poll};
tokio::time::Duration;
tokio_stream::{Stream, StreamExt};
struct MyInterval { // ⑤
interval: tokio::time::Interval,
}
//
//
//
//
①
②
③
④
impl MyInterval { // ⑥
fn new(duration: Duration) -> Self {
Self {
interval: tokio::time::interval(duration),
}
}
}
Продвинутые шаблоны асинхронного программирования 603
impl Stream for MyInterval {
type Item = tokio::time::Instant; // ⑦
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) ->
Poll<Option<Self::Item>> { // ⑧
match Pin::new(&mut self.interval).poll_tick(cx) { // ⑨
Poll::Ready(instant) => Poll::Ready(Some(instant)),
Poll::Pending => Poll::Pending,
}
}
}
#[tokio::main]
// ⑩
async fn main() {
let mut random_number_stream = MyInterval::new(Duration::from_secs(1))
.map(|_| rand::random::<u32>()); // ⑪
}
for _ in 0..5 { // ⑫
if let Some(random_number) = random_number_stream.next().await { // ⑬
println!("Случайное число: {}", random_number);
}
}
// Вывод:
//
//
//
//
//
Случайное
Случайное
Случайное
Случайное
Случайное
число:
число:
число:
число:
число:
2968165177
3728492754
3705231156
2459930473
3383401261
В приведенном выше примере:
① исходный код начинается с импорта типа Pin из стандартной библиотеки.
Это фундаментальный элемент для работы со самоссылающимися структурами, который имеет критическую важность для структур, хранящих ссылки
на собственные поля – обычное явление в асинхронном программировании;
② далее импортируются типы Context и Poll из модуля task. Они играют
ключевую роль в исполнении асинхронных заданий, предоставляя инструменты управления контекстом исполнения и опрашивания заданий
на предмет их завершения;
③ тип Duration импортируется из среды исполнения tokio. Он используется для указания промежутков времени, что необходимо при планировании заданий, которые должны исполняться в регулярные промежутки;
④ здесь импортируются основные компоненты из пакетов tokio и tokio_
stream, что обеспечивает доступ к асинхронной среде исполнения
и функциональностям для работы с потоками данных, требующихся для
построения асинхронных потоков;
⑤ здесь описывается структура MyInterval, которая выступает в качестве
конкретно-прикладного представления интервального потока данных.
Она содержит в себе функциональность, требующуюся для эмиссии элементов через определенные промежутки времени;
604
Асинхронное программирование
⑥ для структуры реализован метод new, который позволяет создавать новый экземпляр MyInterval с пользовательской длительностью, обеспечивая гибкость при конфигурировании промежутков времени между эмитированными элементами;
⑦ ассоциированный тип Item для потока данных указан как
tokio::time::Instant. Это означает, что поток эмитирует экземпляры
структуры Instant, которая представляет точки во времени, добавляя
в данные на выходе из потока временнóе измерение;
⑧ реализуется общая черта Stream для структуры MyInterval, включая метод poll_next, который отвечает за непосредственную эмиссию элементов потока данных;
⑨ внутри метода poll_next используется функция poll_tick, определенная
на лежащей в основе структуре tokio::time::Interval. Она проверяет
доступность следующего момента времени и сообщает о его готовности
либо ожидании момента, когда он будет готов;
⑩ основная асинхронная функция main помечается атрибутом среды исполнения tokio. Она является точкой входа в асинхронное исполнение
и дирижирует использованием конкретно-прикладным интервальным
потоком данных;
⑪ внутри главной функции создается экземпляр структуры MyInterval
с интервалом в одну секунду. К потоку данных применяется операция
map, преобразующая каждый эмитированный элемент в случайное число
типа u32, что демонстрирует гибкость операций на потоках данных;
⑫ исходный код входит в цикл, выполняя итерации по потоку данных фиксированное количество раз (5), представляя собой контролируемое потребление элементов асинхронного потока данных;
⑬ внутри цикла исходный код ожидает прибытия следующего случайного
числа из потока с помощью метода next() и выводит его на экран. Это
наглядно показывает асинхронную природу: элементы производятся
и потребляются асинхронно.
Ради большей ясности проиллюстрируем этот пример на простой схеме:
Продвинутые шаблоны асинхронного программирования 605
+---------------------------------------------------------+
|
main
|
|---------------------------------------------------------|
| + Создание MyInterval с 1-сек интервалом
|
| | и трансформация map в случайное u32
|
| |
|
| V
|
| +------------------+
|
| |
MyInterval
|
|
| |------------------|
|
| | - interval
|
|
| |------------------|
|
| | + new(duration) |
|
| |------------------|
| impl Stream для |
|
| | poll_next
| <--- |
MyInterval
|
|
| +------------------+
|
|
|
|
|
V
|
| +-----------------------------------------------------+ |
| |
+ poll_tick
| |
| |
|
| |
| |
V
| |
| |
+------------------------+
| |
| |
| tokio::time::Interval |
| |
| |
|------------------------|
| |
| |
|
poll_tick
|
| |
| |
+------------------------+
| |
| +-----------------------------------------------------+ |
|
|
|
|
|
| |
|
V
V
V
V
V |
| +-----------+ +-----------+
+-----------+
|
| | Случайное | | Случайное |
...
| Случайное |
|
| | число 1 | | число 2 |
| число 5 |
|
| +-----------+ +-----------+
+-----------+
|
+---------------------------------------------------------+
Символ + обозначает присутствие компонента, например методов, а символ – указывает на взаимосвязь или ассоциацию между компонентами, такими как поля структур и т. д.
Приведенный выше фрагмент исходного кода служит примером конструирования и использования асинхронного потока данных, демонстрируя мощь
среды исполнения tokio и потоковой обработки данных.
Структура MyInterval содержит в себе интервальный поток данных, который
эмитирует (испускает) экземпляры структуры tokio::time::Instant через заданные промежутки времени. В функции main создается экземпляр этого потока, элементы которого потребляются в управляемом цикле, преобразовывая
каждый из них в случайное число типа u32. Данный пример наглядно показывает бесшовную интеграцию асинхронных шаблонов, обработки времени
и операций на потоках данных на Rust, что помогает в конструировании эффективных и выразительных асинхронных приложений.
606
Асинхронное программирование
Управление ресурсами с помощью асинхронного
завершения жизненного цикла
В асинхронный Rust введена концепция асинхронного завершения жизненного
цикла (async drop), которая позволяет асинхронно управлять ресурсами в процессе завершения их времен жизни. Это особенно полезно в сценариях, где операции по очистке ресурсов сами по себе являются асинхронными заданиями.
В данном разделе рассматриваются продвинутые шаблоны управления ресурсами с использованием асинхронного завершения жизненного цикла и демонстрируется реализация асинхронной очистки для конкретно-прикладного типа.
Рассмотрим следующий ниже пример, демонстрирующий применение
асинхронного завершения жизненного цикла для управления ресурсами:
Листинг 14.7. Управление ресурсами с помощью
асинхронного завершения жизненного цикла
use tokio::time::{sleep, Duration}; // ①
#[derive(Clone)] // ②
struct AsyncResource {
// Поля ресурса
}
impl AsyncResource {
async fn cleanup(&self) {
println!("Асинхронная очистка ресурсов...");
sleep(Duration::from_secs(1)).await;
println!("Очистка завершена");
}
}
//
//
//
//
③
④
⑤
⑥
#[tokio::main] // ⑦
async fn main() {
let resource = AsyncResource { /* Инициализировать ресурс */ }; // ⑧
let task_handle = tokio::spawn({
let _resource = resource.clone();
// ⑨
async move {
println!("Асинхронное задание запущено"); // ⑩
sleep(Duration::from_secs(2)).await;
// ⑪
// Здесь идет фактическая асинхронная работа...
}
});
println!("Асинхронное задание завершено"); // ⑫
task_handle.await.expect("Задание не удалось"); // ⑬
}
// На данном этапе асинхронное завершение жизненного цикла
// завершило очистку ресурса
resource.cleanup().await;
// ⑭
println!("Главная функция завершена");
// ⑮
Продвинутые шаблоны асинхронного программирования 607
// Вывод:
//
//
//
//
//
Асинхронное задание запущено
Асинхронное задание завершено
Асинхронная очистка ресурсов...
Очистка завершена
Главная функция завершена
В приведенном выше примере:
① первым делом выполняется импорт необходимых компонентов из среды
исполнения tokio, в частности тех, что отвечают за управление временем
и его обработку;
② далее в исходном коде определяется структура AsyncResource, которая служит шаблоном для ресурса, оснащенного логикой асинхронной очистки,
что подчеркивает важность структурированного управления ресурсами;
③ внутри данной структуры реализован стержневой асинхронный метод
очистки, представляющий собой скоординированный процесс высвобождения ресурсов посредством асинхронного исполнения;
④ вслед за этим генерируется журнальное сообщение, сигнализирующее
о начале работы логики асинхронной очистки, предлагая сведения о продвижении работы в исходном коде и потоке исполнения;
⑤ логика асинхронной очистки имитируется с помощью функции sleep,
специально сконфигурированной под симуляцию работы по очистке
длительностью в 1 секунду;
⑥ по завершении асинхронной очистки на экран выводится соответствующее сообщение, знаменующее окончание процесса высвобождения ресурсов и способствующее общей прозрачности работы исходного кода;
⑦ здесь вводится основная асинхронная функция, имеющая решающее
значение для организации сценария управления ресурсами и четко помеченная использованием среды исполнения tokio;
⑧ внутри этой асинхронной функции создается и инициализируется экземпляр ранее определенной структуры AsyncResource, устанавливая базовое состояние ресурса;
⑨ для подготовки исполнения последующего асинхронного задания создается клонированная ссылка на инициализированный ресурс, обеспечивая поддержание изначального состояния с целью эффективного управления ресурсами;
⑩ здесь стратегически размещается журнальное сообщение, чтобы сигнализировать об инициации асинхронного задания, предоставляя ценную контекстную информацию о поведении исходного кода на этапе исполнения;
⑪ асинхронное задание начинает симуляцию работы, имитируя продолжительный процесс с задержкой в 2 секунды, воспроизводя асинхронные
операции из реальной практики;
⑫ по завершении исполнения асинхронного задания выводится сообщение, подтверждающее окончание работы и повышающее понятность исходного кода;
⑬ главная функция ожидает завершения асинхронного задания, включая
меры по обработке ошибок с целью устранения любых потенциальных
проблем, которые могут возникать на этапе исполнения;
608
Асинхронное программирование
⑭ по завершении задания вызывается асинхронный метод очистки ресурса, использующий механизм асинхронного завершения жизненного
цикла с целью обеспечения тщательного выполнения всех необходимых
действий по очистке;
⑮ наконец, выводится итоговое сообщение, сигнализирующее о полном завершении функции main и охватывающее весь сценарий асинхронного
управления ресурсами в среде исполнения tokio.
Приведенный выше фрагмент исходного кода служит примером скрупулезного подхода к асинхронному управлению ресурсами в среде исполнения tokio, отмеченного последовательностью четко аннотированных шагов.
От импорта основных компонентов и определения структуры асинхронного
ресурса до дирижирования заданиями, симуляции операций и обеспечения
надлежащей очистки – каждый шаг детально задокументирован. Исходный
код не только демонстрирует эффективное использование функциональных
средств асинхронного Rust, но и подчеркивает значимость структурированного управления ресурсами в построении надежных и поддерживаемых приложений. Благодаря подробному журналированию и методичному исполнению
этот пример предоставляет ценный шаблон реализации эффективной обработки асинхронных ресурсов в проектах на Rust.
Разветвление и сведение в асинхронных потоках данных
Асинхронные потоки данных упрощают реализацию шаблонов разветвления
(разведения)1 и сведения2, позволяя эффективно распределять нагрузку между многочисленными асинхронными заданиями и агрегировать результаты.
В данном разделе рассматриваются продвинутые подходы к организации разветвления и сведения с использованием асинхронных потоков данных из пакета tokio_stream.
Рассмотрим следующий ниже пример, иллюстрирующий применение шаб
лонов разветвления и сведения в работе с асинхронными потоками данных:
Листинг 14.8. Использование разветвления
и сведения в асинхронных потоках данных
use tokio_stream::{self as stream, Stream, StreamExt}; // ①
use tokio::time::{sleep, Duration};
// ②
async fn async_task(id: usize) -> usize {
// ③
sleep(Duration::from_secs(id as u64)).await; // ④
id * 2
// ⑤
}
fn fan_out_tasks() -> impl Stream<Item = usize> { // ⑥
stream::iter(0..5).then(async_task)
// ⑦
}
#[tokio::main]
1
2
См. https://en.wikipedia.org/wiki/Fan-out.
См. https://en.wikipedia.org/wiki/Fan-in.
Продвинутые шаблоны асинхронного программирования 609
async fn main() {
// ⑧
fan_in_results().await; // ⑨
}
async fn fan_in_results() {
// ⑩
let result_stream = fan_out_tasks(); // ⑪
tokio::pin!(result_stream);
// ⑫
}
while let Some(result) = result_stream.next().await { // ⑬
println!("Получен результат: {:?}", result);
// ⑭
}
// Вывод:
//
//
//
//
//
Получен
Получен
Получен
Получен
Получен
результат:
результат:
результат:
результат:
результат:
0
2
4
6
8
В приведенном выше примере:
① импорт необходимых библиотек и модулей, включая tokio_stream
и tokio::time, для обеспечения работы с асинхронными потоками данных;
② импорт функции sleep и структуры Duration из модуля tokio::time, которые критически важны для создания задержек в асинхронных заданиях;
③ определение асинхронного задания async_task, которое симулирует работу, засыпая на время, зависящее от ИД задания, а затем возвращает
результат;
④ использование функции sleep для постановки асинхронного задания
на паузу продолжительностью, заданной его ИД, что привносит искусственную задержку;
⑤ умножение ИД задания на 2 (представляя собой простую вычислительную операцию) и возврат полученного результата;
⑥ объявление функции разветвления fan_out_tasks, которая возвращает
поток асинхронных заданий, созданный с помощью stream::iter(0..5).
then(async_task);
⑦ использование функции-конструктора потока данных stream::iter для
создания потока значений от 0 до 4 с последующим применением комбинатора .then() для преобразования каждого значения в результат исполнения функции async_task;
⑧ в функции main инициируется координация процессов разветвления
и сведения;
⑨ вызов асинхронной функции fan_in_results, которая отвечает за аспект
сведения – конкурентный сбор и обработку результатов;
⑩ определение асинхронной функции fan_in_results, ответственной
за операцию сведения;
⑪ получение потока результатов асинхронных заданий путем вызова функции fan_out_tasks;
610
Асинхронное программирование
⑫ пригвождение потока результатов к области в памяти с помощью мак
рофункции tokio::pin! для обеспечения эффективной асинхронной обработки;
⑬ использование цикла while let для выполнения итераций по асинхронным результатам с помощью result_stream.next().await;
⑭ вывод каждого полученного результата на экран, наглядно демонстрируя
процесс обработки асинхронных заданий и операцию сведения данных.
Приведенный выше пример иллюстрирует мощь асинхронного программирования, позволяя взглянуть на бесшовную координацию конкурентных
заданий с целью повышения производительности в современной разработке
программного обеспечения.
Ради большей наглядности представим этот пример в виде простой схемы:
+-----------------------------------------------------+
|
main
|
|-----------------------------------------------------|
| + Вызов fan_in_results
|
| |
|
| V
|
| +-------------------------------------------------+ |
| |
fan_in_results
| |
| |-------------------------------------------------| |
| | + fan_out_tasks
| |
| |-------------------------------------------------| |
| | + result_stream
| |
| | |
| |
| | V
| |
| | +-------------------------------------------+
| |
| | |
tokio::pin!
|
| |
| | |-------------------------------------------|
| |
| | |
result_stream.next()
|
| |
| | +-------------------------------------------+
| |
| | |
| |
| | V
| |
| | +-------------------------------------------+
| |
| | |
Получен результат: ...
|
| |
| | +-------------------------------------------+
| |
| +-------------------------------------------------+ |
| |
fan_out_tasks
| |
| |-------------------------------------------------| |
| | + stream::iter
| |
| | |
| |
| | V
| |
| | +-------------------------------------------+
| |
| | |
async_task
|
| |
| | +-------------------------------------------+
| |
| +-------------------------------------------------+ |
+-----------------------------------------------------+
Обработка отмен заданий и реализации тайм-аутов
Асинхронный Rust предоставляет надежные механизмы для отмены заданий
и эффективного управления тайм-аутами. В данном разделе рассматриваются
продвинутые шаблоны отмены заданий и реализации тайм-аутов (ограниче-
Продвинутые шаблоны асинхронного программирования 611
ний по времени), что играет критически важную роль в построении отзывчивых и отказоустойчивых приложений.
Рассмотрим следующий ниже пример, демонстрирующий обработку отмены задания и тайм-аута:
Листинг 14.9. Обработка отмены задания и тайм-аута в асинхронной программе
use tokio::time::{sleep, timeout, Duration};
async fn async_task() {
// ①
sleep(Duration::from_secs(5)).await; // ②
println!("Задание завершено");
// ③
}
async fn cancelable_task() {
// ④
timeout(Duration::from_secs(2), async_task()) // ⑤
.await
// ⑥
.unwrap_or_else(|err| {
// ⑦
eprintln!("Ошибка: {}", err);
// ⑧
});
}
#[tokio::main]
async fn main() {
// ⑨
cancelable_task().await; // ⑩
}
// Вывод:
// Ошибка: deadline has elapsed (крайний срок истек)
В приведенном выше примере:
① функция async_task объявлена как асинхронная; она симулирует длительную операцию. В данном случае используется функция sleep с задержкой
в 5 секунд, представляя собой время исполнения симулируемого задания;
② внутри async_task функция sleep применяется для создания 5-секундной паузы, эффективно демонстрируя асинхронную природу исполнения задания;
③ после асинхронной задержки выводится простое сообщение "Задание
завершено", указывающее на успешное окончание симуляции;
④ объявляется функция отменяемого задания cancelable_task, которая использует функцию timeout, чтобы установить лимит времени в 2 секунды
на исполнение асинхронного задания async_task;
⑤ вызывается функция timeout, обертывая вызов async_task(). Эта функция
возвращает тип Result, который содержит либо результат исполнения задания (если оно уложилось в срок), либо ошибку (если время истекло);
⑥ ключевое слово await используется для ожидания завершения операции
тайм-аута, после чего исполнение программы продолжается в зависимости от полученного результата;
⑦ метод unwrap_or_else применяется для обработки возможных ошибок.
Если возникает ошибка, то исполняется указанное замыкание, которое
выводит сообщение в стандартный поток ошибок;
612
Асинхронное программирование
⑧ внутри замыкания по обработке ошибок программа печатает сообщение
об ошибке в стандартный поток ошибок, обеспечивая наглядность любых проблем, возникающих при исполнении отменяемого задания;
⑨ функция main объявляется как асинхронная с использованием атрибута
#[tokio::main]. Этот атрибут настраивает среду исполнения tokio, позволяя исполнять асинхронный исходный код прямо в функции main;
⑩ функция cancelable_task вызывается внутри функции main с использованием ключевого слова await. Этим демонстрируется исполнение
отменяемого асинхронного задания и показывается, как щадяще обрабатывать отмены заданий и тайм-ауты в контексте программирования
на асинхронном Rust.
Приведенный выше пример наглядно показывает, как синтаксис async/await
в сочетании со средой исполнения tokio позволяет создавать задания со встроенной поддержкой тайм-аутов. Эта способность повышает отказоустойчивость
и отзывчивость приложений, позволяя контролировать исполнение чувствительных ко времени операций структурированным и эффективным способом.
Управление динамическими заданиями
Управление динамическим набором асинхронных заданий с различными
временами жизни нуждается в использовании продвинутых шаблонов. Тип
FuturesUnordered из пакета futures предлагает мощное решение этой задачи.
В данном разделе рассматривается динамическое управление заданиями с использованием типа FuturesUnordered в сценариях, где задания добавляются
и удаляются динамически в процессе исполнения программы.
Рассмотрим следующий ниже пример, демонстрирующий динамическое
управление заданиями:
Листинг 14.10. Динамическое управление заданиями
use
use
use
use
futures::stream::FuturesUnordered;
futures::StreamExt;
tokio::task::spawn;
tokio::time::{sleep, Duration};
//
//
//
//
①
②
③
④
async fn dynamic_task_manager() {
// ⑤
let task_manager = FuturesUnordered::new(); // ⑥
for id in 0..5 {
let task = spawn(async move {
// ⑦
sleep(Duration::from_secs(id as u64)).await; // ⑧
id * 3
});
task_manager.push(task); // ⑨
}
tokio::pin!(task_manager);
}
// ⑩
while let Some(result) = task_manager.next().await {
// ⑪
println!("Результат задания: {:?}", result.unwrap()); // ⑫
}
Продвинутые шаблоны асинхронного программирования 613
#[tokio::main]
async fn main() {
// ⑬
dynamic_task_manager().await; // ⑭
}
// Вывод:
//
//
//
//
//
Результат
Результат
Результат
Результат
Результат
задания:
задания:
задания:
задания:
задания:
0
3
6
9
12
В приведенном выше примере:
① исходный код начинается с импорта необходимых зависимостей, включая поток результатов исполнения заданий, FuturesUnordered, который
играет ключевую роль в эффективном управлении динамическими асинхронными заданиями;
② аналогично импортируется общая черта StreamExt, предоставляющая дополнительную функциональность по работе с асинхронными потоками
результатов;
③ импортируется функция spawn из модуля tokio::task, позволяющая порождать асинхронные задания, которые могут исполняться конкурентно;
④ импортируются функция sleep и конструкция Duration из tokio::time,
подготавливая почву для симуляции асинхронной работы путем введения задержек;
⑤ объявляется функция dynamic_task_manager, выступающая в роли центра
управления динамическими заданиями;
⑥ внутри этой функции создается экземпляр структуры FuturesUnordered
под названием task_manager с целью эффективного управления динамически добавляемыми асинхронными заданиями;
⑦ цикл выполняет итерации по диапазону идентификаторов заданий, порождая асинхронные задания с помощью функции spawn. Каждое задание симулирует динамическую асинхронную работу, вводя задержку
на основе своего идентификатора;
⑧ функция sleep привносит задержки, формируя ступенчатый шаблон исполнения, что важно для симуляции динамических асинхронных нагрузок;
⑨ порожденные задания добавляются в поток FuturesUnordered с помощью
метода push, динамически наполняя менеджер заданий;
⑩ макрофункция tokio::pin! задействуется для пригвождения потока менеджера заданий task_manager к области в памяти, что гарантирует стабильность его расположения в памяти для целей эффективного опрашивания;
⑪ цикл while let непрерывно опрашивает диспетчер заданий на предмет
завершенных заданий с помощью метода next, что отражает неблокирующую природу асинхронных операций;
⑫ по мере готовности заданий их результаты извлекаются и выводятся
на экран, демонстрируя порядок, в котором они завершают свое исполнение;
614
Асинхронное программирование
⑬ функция main, помеченная атрибутом #[tokio::main], подготавливает
асинхронную среду исполнения для работы менеджера заданий;
⑭ наконец, менеджер заданий исполняется внутри функции main, приводя
в действие всю систему дирижирования динамическими асинхронными
заданиями.
Приведенный выше фрагмент исходного кода демонстрирует элегантный и эффективный подход к управлению динамическими асинхронными нагрузками на Rust. Используя поток результатов исполнения заданий
FuturesUnordered и функцию spawn из среды исполнения tokio, он показывает
гибкий и масштабируемый шаблон обработки заданий с разным временем исполнения. Эта парадигма динамического управления неоценима в сценариях,
где набор заданий неизвестен заранее, и предоставляет мощный инструмент
для построения отзывчивых и эффективных асинхронных систем.
Интеграция асинхронного
и синхронного исходных кодов
В приложениях из реальной практики асинхронный и синхронный исходные
коды часто сосуществуют друг с другом. Пакет tokio предоставляет механизмы
для их бесшовной интеграции, позволяя эффективно использовать существующие синхронные библиотеки и компоненты.
Функция tokio::task::block_in_place является важнейшим инструментом
исполнения синхронного исходного кода внутри асинхронной среды. Ее основное предназначение – защита асинхронной среды исполнения от потенциальных блокировок, обеспечивая постоянную отзывчивость приложения.
Задействуя эту функцию, разработчик может стратегически интегрировать
синхронные операции, не нарушая эффективность асинхронной модели исполнения. Этот подход особенно важен в сценариях, где определенные участки исходного кода нуждаются в синхронной обработке, но должны гармонично сосуществовать в рамках общей асинхронной архитектуры. Использование
функции tokio::task::block_in_place способствует повышению общей отзывчивости и плавному функционированию приложения, обеспечивая баланс
между парадигмами синхронного и асинхронного программирования.
Листинг 14.11. Интеграция асинхронного исходного кода с синхронным
use tokio::task;
// ①
use tokio::time::{sleep, Duration}; // ②
#[derive(Clone)]
struct AsyncResource {
// Поля ресурса
}
// ③
impl AsyncResource {
async fn cleanup(&self) { // ④
// Логика асинхронной очистки
println!("Асинхронная очистка ресурсов...");
Интеграция асинхронного и синхронного исходных кодов 615
// Имитация процесса очистки
sleep(Duration::from_secs(1)).await;
println!("Очистка завершена");
}
}
async fn async_and_sync_integration() {
let handle = task::spawn(async {
println!("Асинхронный код: Начало");
sleep(Duration::from_secs(2)).await;
println!("Асинхронный код: Конец");
tokio::task::block_in_place(|| { // ⑤
println!("Синхронный код: Начало");
for i in 1..=3 {
println!("Синхронная итерация: {}", i);
}
println!("Синхронный код: Конец");
});
println!("Продолжение асинхронной логики");
});
}
handle.await.expect("Задание завершилось с ошибкой");
#[tokio::main] // ⑥
async fn main() {
async_and_sync_integration().await;
println!("Главная функция завершена");
}
// Вывод:
//
//
//
//
//
//
//
//
//
Асинхронный код: Начало
Асинхронный код: Конец
Синхронный код: Начало
Синхронная итерация: 1
Синхронная итерация: 2
Синхронная итерация: 3
Синхронный код: Конец
Продолжение асинхронной логики
Главная функция завершена
В приведенном выше примере:
① в этой строке кода мы подключаем основные компоненты библиотеки среды
исполнения tokio, которые непосредственно связаны с обработкой заданий.
Как вы увидели ранее, импорт этих компонентов дает доступ к функциональностям, облегчающим управление и исполнение асинхронных заданий;
② в этой строке кода мы аналогичным образом импортируем компоненты
библиотеки tokio, ориентированные на работу со временем. Модуль time
предоставляет утилиты для выполнения операций, связанных со временем, в асинхронном исходном коде, таких как введение задержек или планирование запуска заданий через определенные промежутки времени;
616
Асинхронное программирование
③ здесь мы определяем структуру AsyncResource. Эта структура представляет ресурс, который содержит логику асинхронной очистки. Структура
AsyncResource задумана моделировать сущность, способную эффективно
высвобождать ресурсы, что играет критически важную роль в асинхронном программировании;
④ в реализации структуры AsyncResource находится асинхронный метод
cleanup. Этот метод определяет логику асинхронного высвобождения ресурсов;
⑤ в данной строке кода мы используем функцию tokio::task::block_in_
place для исполнения синхронного исходного кода внутри асинхронного
контекста. Функция block_in_place гарантирует, что синхронный исходный код будет исполнен без нарушения отзывчивости асинхронной среды исполнения, поддерживая общую эффективность приложения;
⑥ наконец, мы определяем главную асинхронную функцию async_and_
sync_integration. Эта функция демонстрирует интеграцию асинхронного и синхронного исходных кодов в среде исполнения tokio. Она порождает новое асинхронное задание, исполняет асинхронный исходный код,
внедряет синхронный блок с помощью block_in_place для специфических операций, а затем продолжает асинхронную логику. Приведенный
выше фрагмент исходного кода иллюстрирует, как согласованно использовать обе парадигмы программирования с помощью пакета tokio.
Освоение методов интеграции асинхронного и синхронного исходных кодов, продемонстрированное в приведенном выше примере на Rust, необходимо для построения эффективных и отзывчивых приложений. Сочетая сильные стороны асинхронного и синхронного подходов, разработчик достигает
баланса, который оптимизирует потребление ресурсов и скорость отклика, что
ведет к созданию более надежных и масштабируемых программных решений.
Заключение
В этой главе мы погрузились в тему асинхронного программирования на Rust,
сосредоточившись на основополагающих концепциях, воплощенных в синтаксисе async/await. Разбираясь в тонкостях синтаксиса async/await, вы получили твердое понимание его основ и механики. Мы провели обследование доступных инструментов эффективной обработки ошибок, грамотного управления ресурсами и реализации продвинутых шаблонов, таких как асинхронные
потоки данных.
Этот вводный раздел послужил своего рода компасом, ведущим вас по ландшафту асинхронных способностей Rust с особым акцентом на библиотеке
tokio. Понимание роли tokio как асинхронной среды исполнения стало ключевым этапом, заложившим фундамент для дальнейшего изучения ее мощных
функциональных средств как в этой книге, так и за ее пределами.
Заглядывая вперед, ваше обследование глубоких слоев Rust и пакета tokio
продолжится чтением официальной документации, которая откроет более сложные концепции, глубокие идеи и практические примеры. Расширяя свои знания
в этой области, вы не только закрепите понимание асинхронного программирования, но и овладеете инструментами, необходимыми для построения отзывчи-
Дополнительные ресурсы 617
вых и высокопроизводительных приложений. Наша дальнейшая цель – наделить
вас способностью эффективно использовать Rust и пакет tokio, успешно применяя преимущества асинхронного программирования в своих проектах.
Дополнительные ресурсы
В целях углубления своих знаний в асинхронном программировании на Rust
рекомендуется ознакомиться со следующими ниже ресурсами.
Документация tokio: обратитесь к официальной документации пакета
tokio – мощной среды исполнения асинхронного исходного кода на Rust.
Ознакомьтесь с имеющимися в нем функциями, модулями и руководствами, чтобы в совершенстве овладеть искусством асинхронного программирования с помощью пакета tokio: https://docs.rs/tokio.
Книга по асинхронному программированию: почитайте книгу «The Async
Book» – официальный ресурс, который всесторонне охватывает асинхронное программирование на Rust. Узнайте больше о синтаксисе async/await,
фьючерсах и обширной экосистеме, поддерживающей асинхронную разработку: https://rust-lang.github.io/async-book/01_getting_started/01_chapter.html.
Блог по пакету tokio: почитайте блог, посвященный пакету tokio, в котором анонсируются релизы и новые функциональные средства. Будьте
в курсе последних функциональных возможностей, улучшений и достижений асинхронной среды исполнения: https://tokio.rs/blog.
Пакет tokio-stream: ознакомьтесь с официальным пакетом tokio-stream
на crates.io. Он предоставляет важнейшие утилиты для работы с асинхронными потоками данных на Rust, предлагая ряд функций и модулей для
упрощения обработки потоков данных: https://crates.io/crates/tokio-stream.
Пакет futures – документация общей черты Stream: подробно изучите документацию общей черты Stream в пакете futures. Понимание этой общей
черты является фундаментальным для работы с асинхронными потоками
данных на Rust: https://docs.rs/futures/latest/futures/stream/trait.Stream.html.
Книга по Rust – зацикливание ссылок и утечки памяти: обратитесь к официальному учебнику по Rust, разделу о зацикливании ссылок. Понимание
этих концепций имеет критическую важность для эффективного управления ресурсами: https://doc.rust-lang.org/book/ch15-06-reference-cycles.html.
Книга по асинхронному программированию – «Обходные пути, которые стоит знать и любить»: глава из книги «Async Book», предлагающая
способы решения типичных задач в асинхронном программировании
и эффективного управления ресурсами: https://rust-lang.github.io/asyncbook/07_workarounds/01_chapter.html.
Репозиторий async-std: обследуйте GitHub-репозиторий проекта asyncstd – альтернативной асинхронной среды исполнения для Rust. Он предоставляет основные инструменты и утилиты, внося свой вклад в практики управления ресурсами: https://github.com/async-rs/async-std.
RFC 2394 – async/await: ознакомьтесь с документом RFC 2394, в котором
было предложено внедрить синтаксис async/await в Rust. Эта рекомендация RFC лежит в основе понимания концепции асинхронного программирования и ее влияния на управление ресурсами: https://github.
com/rust-lang/rfcs/blob/master/text/2394-async_await.md.
618
Асинхронное программирование
Перечисленные выше ресурсы предлагают комплексный взгляд на продвинутые шаблоны асинхронного программирования, предоставляя практические руководства и документацию, которые помогут вам освоить работу с потоками данных и конструирование отзывчивых систем.
Вопросы для самопроверки
Вопрос 1. Какое ключевое слово используется для объявления асинхронной функции на Rust?
a) sync
b) async
c) await
d) async/await
Вопрос 2. Что делает ключевое слово await в синтаксисе асинхронного Rust?
a) приостанавливает исполнение программы на неопределенный срок
b) приостанавливает поток исполнения до завершения асинхронной операции
c) прерывает исполнение асинхронной функции
d) позволяет программе пропускать асинхронную операцию
Вопрос 3. Что предлагает библиотека tokio в контексте асинхронного
программирования на Rust?
a) язык программирования
b) веб-фреймворк
c) асинхронную среду исполнения
d) систему управления базами данных
Вопрос 4. Как достигается конкурентное исполнение в синтаксисе async/
await на Rust?
a) путем использования параллельных потоков исполнения
b) путем использования синхронных функций
c) путем задействования ключевого слова await
d) путем отказа от асинхронных операций
Вопрос 5. Каково предназначение типа Result при обработке асинхронных ошибок на Rust?
a) указывать на успех без возврата значения
b) указывать на ошибку с выводом сообщения
c) передавать ошибки вверх по асинхронному стеку вызовов
d) обрабатывать ошибки только в синхронном исходном коде
Вопрос 6. Какую роль играет умный указатель Arc в асинхронном программировании на Rust?
Вопросы для самопроверки 619
a)
b)
c)
d)
обеспечивает атомарные операции на данных
активирует асинхронное исполнение
управляет временами жизни асинхронных заданий
предоставляет потокозащищенный указатель с подсчетом ссылок для
коллективных данных
Вопрос 7. Что гарантирует принятая в Rust система владения в контексте
асинхронного программирования?
a) утечки памяти
b) гонки за доступ к данным
c) неблокирующее исполнение
d) потокозащищенность
Вопрос 8. Каково основное преимущество использования асинхронного
программирования на Rust?
a) улучшенные структуры данных
b) синхронное исполнение
c) эффективная конкурентность и отзывчивость
d) снижение выразительности исходного кода
Вопрос 9. Какой пакет упоминается как инструмент для работы с асинхронными потоками данных в Rust?
a) tokio::sync
b) async-streams
c) tokio-stream
d) async-util
Вопрос 10. Каково предназначение функционального средства по асинхронному завершению жизненного цикла, представленного в асинхронном Rust?
a) определение асинхронных функций
b) асинхронное управление ресурсами во время завершения жизненного
цикла
c) обработка ошибок в асинхронном исходном коде
d) создание асинхронных потоков данных / результатов исполнения заданий
Вопрос 11. В чем основное преимущество асинхронных потоков данных
перед синхронными итераторами в Rust?
a) асинхронные потоки данных могут создавать бесконечные последовательности значений
b) асинхронные потоки данных позволяют генерировать значения асинхронно во временной динамике
c) синхронные итераторы обладают более высокой производительностью
d) синхронные итераторы более универсальны
620
Асинхронное программирование
Ответы
1. b) async
2. b) приостанавливает поток исполнения до завершения асинхронной
операции
3. c) асинхронную среду исполнения
4. c) путем задействования ключевого слова await
5. c) передавать ошибки вверх по асинхронному стеку вызовов
6. d) предоставляет потокозащищенный указатель с подсчетом ссылок для
коллективных данных
7. d) потокозащищенность
8. c) эффективная конкурентность и отзывчивость
9. c) tokio-stream
10. b) асинхронное управление ресурсами во время завершения жизненного цикла
11. b) асинхронные потоки данных позволяют генерировать значения асинхронно во временной динамике
Ключевые термины
Синтаксис async/await: синтаксис Rust для асинхронного программирования, вводящий ключевые слова async и await для создания неблокирующих функций и приостановки исполнения до момента завершения
асинхронных операций.
Тип Result: тип данных в Rust, используемый при асинхронной обработке ошибок для представления результата операции. Он указывает либо
на успех с возвратом значения, либо на отказ с сообщением об ошибке,
позволяя передавать ошибки вверх по асинхронному стеку вызовов.
Умный указатель Arc (atomic reference counting): тип в Rust, предоставляющий потокозащищенный указатель с подсчетом ссылок. Необходим
для использования коллективных данных несколькими асинхронными
заданиями, обеспечивая эффективное использование ресурсов и предотвращая гонки за доступ к данным.
Конкурентность (concurrency): исполнение нескольких заданий или
операций в накладывающиеся друг на друга интервалы времени; ключевой аспект асинхронного программирования на Rust, позволяющий
эффективно распараллеливать исполнение.
Времена жизни заданий (task lifetimes): в контексте асинхронного программирования – период, в течение которого исполняются задания, и сопутствующая сложность эффективного управления их временами жизни
с целью предотвращения таких проблем, как гонки за доступ к данным.
Отзывчивые приложения (responsive applications): основная цель
асинхронного программирования на Rust, заключающаяся в разработке
систем, которые эффективно обрабатывают операции ввода-вывода без
потери производительности, что позволяет приложениям мгновенно
реагировать на действия пользователя.
Фьючерсы (futures): базовый строительный блок асинхронного про-
Ключевые термины 621
граммирования, представляющий значение или ошибку, которые станут
доступны в будущем. Позволяют эффективно комбинировать асинхронные операции.
Опрос (poll): метод, используемый в асинхронной модели Rust для проверки статуса фьючерса Future или потока данных Stream. Он показывает, готово ли значение, ожидается ли оно либо произошла ошибка.
Исполнитель (executor): абстракция, отвечающая за планирование и исполнение заданий. Гарантирует, что асинхронные операции исполняются эффективно и в неблокирующем режиме.
Асинхронный мьютекс (async mutex): примитив синхронизации, обес
печивающий исключительный доступ к данным между асинхронными
заданиями, предотвращая гонки за доступ к данным и гарантируя защищенный конкурентный доступ.
Pin: тип в Rust, указывающий на то, что значение не должно перемещаться в памяти. Это особенно актуально при работе с самоссылающимися структурами в асинхронном исходном коде.
Среда исполнения tokio (tokio runtime): среда исполнения, предоставляемая библиотекой tokio. Включает в себя событийный цикл, планировщик заданий и другие компоненты, служащие для управления асинхронным исходным кодом.
Обессахаривание синтаксиса async/await (desugaring): процесс в компиляторе Rust, при котором синтаксис async/await преобразовывается
в низкоуровневые комбинаторы Future и Stream, способствуя бесшовной
интеграции с асинхронной средой исполнения.
Противодавление (backpressure): механизм, регулирующий перетекание данных между производителями и потребителями, предотвращающий перегрузку системы избыточным объемом данных.
Глава 15
Web Assembly на Rust
Введение
Открытый стандарт двоичного формата инструкций WebAssembly (Wasm) –
это революционная технология в области веб-разработки, которая фундаментально меняет ландшафт исполнения исходного кода внутри веб-браузеров1.
В отличие от традиционного подхода с трансляцией исходного кода на языке
JavaScript, стандарт WebAssembly предоставляет возможность исполнять низкоуровневый код с быстродействием, приближенным к нативному машинному коду. В этой главе представлен всесторонний обзор основных принципов
стандарта WebAssembly и освещаются преимущества, которые он приносит
веб-приложениям.
Открытый стандарт WebAssembly стремительно трансформирует способы реализации высокопроизводительных вычислений в веб-приложениях.
Его двоичный формат инструкций позволяет исполнять исходный код, написанный на высокоуровневых языках программирования (включая C, C++, C#
и Rust), напрямую в веб-браузерах2. Это знаменует собой отход от традиционного исполнения только на языке JavaScript, открывая двери для колоссального прироста производительности и расширяя разработчикам выбор языков.
Двоичный формат инструкций WebAssembly задуман так, чтобы быть эффективным как при скачивании, так и при исполнении, благодаря чему
он становится мощным инструментом повышения производительности вебприложений3. Компактное представление обеспечивает более быструю загрузку по сравнению с традиционными файлами JavaScript, улучшая первоначальный пользовательский опыт. Эта эффективность распространяется и на само
исполнение, обеспечивая околонативное быстродействие вычислительно интенсивных задач.
Содержание главы
В этой главе будут рассмотрены следующие темы:
1
2
3
См. WebAssembly. https://webassembly.org/.
WebAssembly | MDN. (6 декабря 2023). Веб-документация MDN. https://developer.
mozilla.org/en-US/docs/WebAssembly.
См. WebAssembly High-Level Goals – WebAssembly. (Дата не указана). https://
webassembly.org/docs/high-level-goals/.
Ограничения стандарта WebAssembly 623
введение в открытый стандарт WebAssembly и его преимущества;
конфигурирование среды Rust под разработку с использованием двоичного формата инструкций WebAssembly;
интеграция исходного кода на Rust с языком JavaScript с использованием открытого стандарта WebAssembly.
Преимущества стандарта WebAssembly
Преимущества открытого стандарта WebAssembly многочисленны, и одним
из ключевых является его высокая производительность. Благодаря использованию низкоуровневого двоичного формата инструкций WebAssembly сводит
к минимуму необходимость в ресурсозатратном разборе исходного кода и интерпретации веб-браузерами, что приводит к ускорению работы. Этот прирост
производительности оказывается особенно выгодным в задачах, требующих
значительных вычислительных мощностей, таких как отрисовка графики, обработка данных и научные симуляции.
Более того, WebAssembly обеспечивает языковую независимость, позволяя разработчику писать исходный код на его любимом высокоуровневом языке1. Если
для конкретного языка существует компилятор в двоичный формат инструкций
WebAssembly, то этот код может бесшовно быть интегрирован в веб-приложения.
Такая гибкость способствует использованию существующих кодовых баз и облегчает внедрение различных языков программирования в веб-среду.
Преимущества стандарта WebAssembly подчеркиваются эффективным использованием системных ресурсов, благодаря чему он идеально подходит для
ресурсоемких приложений. В целях более глубокого понимания вопросов управления ресурсами и взаимодействия с системой можно обратиться к WebAssembly
System Interface (WASI) – стандартному интерфейсу, разработанному для выполнения приложений с форматом инструкций WebAssembly на любой платформе.
Защищенность – еще один критически важный аспект, улучшенный в стандарте WebAssembly. Технология исполняет код внутри изолированной среды
(«песочницы»), гарантируя, что даже если модуль WebAssembly содержит уязвимости, он не сможет поставить под угрозу общую защищенность веб-браузера
или пользовательской системы. Такая надежная изоляция кода в формате
WebAssembly добавляет дополнительный слой защиты веб-приложениям, способствуя созданию более защищенной веб-экосистемы.
Ограничения стандарта WebAssembly
Хотя открытый стандарт двоичного формата инструкций WebAssembly, несомненно, произвел революцию в производительности веб-приложений,
он не застрахован от ряда проблем. Текущая его разработка направлена на их
решение, формируя более надежную платформу для разработчиков. Понимание этих текущих ограничений становится критически важным для принятия
обоснованных решений в процессе разработки.
1
См. WebAssembly | MDN. (6 декабря 2023). Веб-документация MDN. https://developer.
mozilla.org/en-US/docs/WebAssembly.
624
Web Assembly на Rust
Ограниченная поддержка веб-браузерами
На пути формата WebAssembly возникают трудности, связанные с поддержкой веб-браузерами, особенно в старых версиях, где отсутствует глобальный
объект WebAssembly, необходимый для инициализации и загрузки модулей.
Это ограничение порождает проблемы с совместимостью, так как устаревшие
веб-браузеры могут не полностью поддерживать модули WebAssembly.
Для решения данной проблемы существуют экспериментальные полифиллы1, использующие asm.js – строго ограниченное подмножество языка
JavaScript, оптимизированное под быстродействие – в качестве механизма
отката назад, если объект WebAssembly отсутствует. Однако рабочая группа
по развитию WebAssembly в настоящее время не планирует выпуск официального полифилла. Разработчики, полагающиеся на стандарт WebAssembly
в проектах с широкой аудиторией, должны взвешивать последствия ограниченной поддержки в старых веб-браузерах.
Связь между спецификацией asm.js как предтечей стандарта WebAssembly
и самим стандартом WebAssembly заслуживает внимания, поскольку asm.
js может служить альтернативой при отсутствии поддержки стандарта
WebAssembly. Несмотря на прирост производительности, важно учитывать
компромиссы, так как спецификация asm.js не обеспечивает того же уровня
оптимизации и эффективности, что и нативные модули WebAssembly.
Проверка текущей поддержки стандарта WebAssembly веб-браузерами становится ключевым этапом при планировании проекта. Веб-сайт Can I Use
(Можно ли использовать)2 предоставляет актуальный обзор совместимости
веб-браузеров с функциональными средствами стандарта WebAssembly. Этот
ресурс позволяет разработчикам принимать взвешенные решения на основе
целевой аудитории, обеспечивая бесшовную работу пользователей в различных веб-браузерных средах.
Косвенная манипуляция объектной моделью документа
Еще одно заметное ограничение стандарта WebAssembly заключается в невозможности прямого доступа к объектной модели документа (document
object model, аббр. DOM). В отличие от языка JavaScript, который позволяет
бесшовно и напрямую изменять DOM, стандарт WebAssembly требует косвенного подхода – через язык JavaScript или такие наборы инструментов,
как Emscripten.
Это ограничение обусловлено архитектурными решениями, в которых
приоритет отдан производительности и защищенности, а не прямому доступу к DOM. Цель состоит в том, чтобы предотвращать потенциальные уязвимости, возникающие при нерегламентированном доступе к структуре страницы. Обеспечивая защищенную среду исполнения, такой подход добавляет
1
2
Полифиллы (polyfills) – это небольшие фрагменты исходного кода (обычно
на JavaScript), добавляющие в старые веб-браузеры поддержку функций, которые
там изначально не реализованы, но уже есть в новых стандартах. – Прим. перев.
См. https://caniuse.com/. – Прим. перев.
Ограничения стандарта WebAssembly 625
сложности разработчикам, вынужденным наводить мосты между стандартом
WebAssembly и DOM с помощью промежуточных языков.
В настоящее время ведется работа над внедрением в стандарт WebAssembly
прямого обращения к объектам DOM и других API для веба; данные улучшения находятся на стадии рассмотрения поданных предложений. Это
нововведение призвано упростить взаимодействие между стандартом
WebAssembly и DOM, что потенциально позволит оптимизировать процессы
веб-разработки и сделает технологию доступнее для широкого круга программистов.
Разработчики, использующие стандарт WebAssembly для веб-приложений
с активным динамическим манипулированием DOM, могут столкнуться с трудностями из-за текущих ограничений. Необходимость промежуточных шагов
создает потенциальные точки отказа и усложняет проект, что подчеркивает
важность трезвой оценки требований перед выбором стандарта WebAssembly
для задач, интенсивно работающих с DOM.
Сложности управления памятью
Принятая в стандарте WebAssembly концепция линейной памяти (как один
огромный массив смежно расположенных ячеек) создает уникальные трудности по управлению ресурсами. Этот подход требует явного выделения памяти
для исполнения кода, что отличает его от таких языков, как C и C++, которые
получили приоритет в первой версии (MVP) благодаря своим врожденным
способностям по прямому управлению памятью. Исключение на начальном
этапе высокоуровневых языков (например, Java) было вызвано их зависимостью от сборки мусора (GC).
Сборка мусора работает как автоматический сборщик: она высвобождает
память, занятую объектами, которые больше не используются. В противовес
этому ручное выделение памяти напоминает управление автомобилем с ручной коробкой передач: оно дает больше контроля, но требует предельной внимательности. Активная работа над поддержкой языков со сборщиком мусора
в WebAssembly намекает на будущее, где автоматическое управление памятью
будет сосуществовать с высокой производительностью, однако точные сроки
реализации остаются неопределенными.
Отсутствие сборщика мусора в стандарте WebAssembly означает, что разработчик должен проявлять высокую точность в управлении памятью ради
предотвращения потенциальных проблем. Хотя ручное управление позволяет
оптимизировать производительность, оно нуждается в глубоком понимании
и осторожности, так как в нем нет той «снисходительности», которая присуща
языкам со встроенной сборкой мусора.
Более того, простота программирования на языках со сборкой мусора (ярким примером которых является JavaScript) позволяет сосредоточиваться
на логике, не беспокоясь постоянно о сложностях работы с памятью. Интеграция поддержки сборщика мусора в стандарт WebAssembly нацелена на то, чтобы объединить производительность с удобством автоматического управления,
создав более доступную и дружелюбную среду для разработчиков.
626
Web Assembly на Rust
Rust для разработки в сопряжении
со стандартом WebAssembly
Rust, с его упором на производительность, защищенность памяти и конкурентность, является идеальным языком для разработки в сопряжении со стандартом WebAssembly. Следующие далее разделы предлагают углубленное обследование процесса конфигурирования среды Rust под бесшовную интеграцию
со стандартом WebAssembly. Мы подробно разберем каждый шаг – от установки необходимых инструментов до создания нового проекта, а также тщательной сборки и тестирования модулей Wasm, – чтобы наделить вас способностью
полностью раскрыть потенциал этих технологий.
Установка инструментария Rust и стандарта WebAssembly
Начало разработки на Rust с использованием стандарта WebAssembly преду
сматривает установку как самого Rust, так и инструментария WebAssembly.
Ключевым звеном в этом процессе является установщик Rustup, который
упрощает установку Rust и управление сопутствующими инструментами. Это
универсальное средство позволяет без труда управлять версиями Rust и переключаться между ними. После установки Rust важным шагом становится интеграция целевой платформы WebAssembly в инструментарий. Это бесшовно
достигается с помощью основной команды установщика Rustup:
$ rustup target add wasm32-unknown-unknown
Эта команда расширяет способности инструментария Rust, добавляя поддержку стандарта WebAssembly, что является необходимым условием для компиляции исходного кода на Rust в эффективные и оптимизированные модули
в формате инструкций Wasm.
Углубляясь в процесс установки, установщик Rustup обеспечивает простую
и понятную настройку. Скачав и исполнив установщик Rustup, вы получаете
доступ к компилятору Rust (rustc) и менеджеру пакетов (cargo). Гибкость установщика Rustup распространяется и на управление версиями, позволяя легко
переключаться между разными релизами Rust, что делает его незаменимым
инструментом в экосистеме.
После успешной установки инструментария Rust и стандарта WebAssembly
вы получаете надежный фундамент и готовы приступить к разработке с использованием стандарта WebAssembly.
Настройка нового проекта на Rust в связке
с двоичным форматом инструкций Wasm
После установки инструментария Rust и стандарта WebAssembly следующим
логическим шагом является создание нового проекта на Rust, специально
скроенного под разработку с привлечением двоичного формата инструкций
WebAssembly. Этот процесс включает в себя исполнение команды, предназначенной для инициализации нового проекта и его соответствующего конфигурирования.
Rust для разработки в сопряжении со стандартом WebAssembly
627
$ cargo generate --git https://github.com/rustwasm/wasm-pack-template
// Вывод:
//
//
//
//
//
//
//
·
·
·
·
·
·
✨
Project Name: wasm-rust
Destination: /home/mahmoud/my_wasm_project/wasm-rust ...
project-name: wasm-rust ...
Generating template ...
Moving generated files into: `/home/mahmoud/my_wasm_project/wasm-rust`...
Initializing a fresh Git repository
Done! New project created /home/mahmoud/my_wasm_project/wasm-rust
Эта команда cargo заложила основу для нового проекта на Rust под названием wasm-rust. Команда cargo generate инициализирует проект из заготовки
и генерирует необходимые файлы. Эти файлы, включающие конфигурационные файлы и скрипты, играют важную роль в упрощении различных аспектов
разработки модулей WebAssembly, таких как сборка, тестирование и упаковка.
Глубокое понимание этого процесса конфигурирования имеет решающее
значение, так как данный процесс формирует начальную структуру и конфигурацию проекта на Rust. Команда cargo generate отвечает не только за создание проекта, но и за подготовку необходимых файлов и директорий, которые
станут базой для последующих задач разработки. Переход в директорию проекта (командой cd wasm-rust) подготавливает к дальнейшей индивидуальной
настройке проекта и обследованию способностей стандарта WebAssembly.
Сгенерированная структура проекта служит отправной точкой для дальнейшей работы. Она включает такие важные компоненты, как директория src для
исходного кода на Rust, файл Cargo.toml для конфигурации проекта и директория tests для написания тестовых файлов. Знание этих элементов необходимо
для эффективного управления проектом и его разработки.
Сборка и тестирование модулей WebAssembly
После того как вы подготовили фундамент для проекта на Rust, адаптированного под работу со стандартом WebAssembly, следующим этапом является
компиляция самого модуля Wasm. Этот критически важный шаг осуществляется с помощью инструмента wasm-pack:
$ wasm-pack build --target web
// Вывод:
//
//
//
//
//
//
//
//
//
[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
[INFO]: Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: ✨ Done in 13.01s
[INFO]: Your wasm pkg is ready to publish at /home/mahmoud/my_
wasm_project/pkg.
Исполнение данной команды запускает упаковщик WebAssembly – мощный
инструмент, который координирует компиляцию исходного кода на Rust в мо-
628
Web Assembly на Rust
дуль WebAssembly. Готовый модуль помещается в директорию pkg проекта,
представляя собой конечный результат, готовый к дальнейшему тестированию и интеграции.
Фаза тестирования не менее важна, так как она гарантирует надежность
и функциональность модуля WebAssembly. С помощью следующей ниже команды можно воспользоваться встроенным комплектом тестов:
$ wasm-pack test --headless –firefox
// Вывод:
// [INFO]: · Installing wasm-bindgen...
// no tests to run!
//
Running tests/web.rs (target/wasm32-unknown-unknown/debug/deps/
web-f725b10a03a23af1.wasm)
// Set timeout to 20 seconds...
// Running headless tests in Firefox on `http://127.0.0.1:45011/`
// Try find `webdriver.json` for configure browser’s capabilities:
// Not found
// running 1 test
// test web::pass ... ok
// test result: ok. 1 passed; 0 failed; 0 ignored
Эта команда не только инициирует исполнение тестов, но и делает это
в «безголовом» веб-браузере Firefox, имитируя условия из реальной практики
для подтверждения того, что модуль WebAssembly соответствует ожидаемому
поведению. Тестирование служит важнейшим шагом в обеспечении качества,
давая уверенность в производительности и функциональности модуля.
Сконфигурировав среду разработки, теперь давайте перейдем к написанию
исходного кода на Rust для WebAssembly. Синтаксис Rust выразителен, а задействование его функциональных средств гарантирует эффективное и защищенное исполнение кода внутри среды исполнения WebAssembly.
Написание функций на Rust с привязкой к Wasm
После настройки среды разработки под WASM следующим шагом является разработка функций на Rust и их привязка к WebAssembly (Wasm). Этот процесс
устанавливает надежную связь между мощью Rust и адаптивностью Всемирной
паутины. Давайте разберем простой, но показательный фрагмент исходного
кода на Rust, предназначенный для вычисления последовательности Фибоначчи.
Исходный код начинается с функции fibonacci – рекурсивного метода вычисления чисел Фибоначчи. Используя выразительный синтаксис Rust, мы применяем сопоставление с шаблоном с помощью ключевого слова match, обрабатывая как базовые случаи, так и рекурсивные вызовы.
Листинг 15.1. Простая Wasm-функция fibonacci на Rust
fn fibonacci(n: u32) -> u32 {
match n {
0 => 0,
Rust для разработки в сопряжении со стандартом WebAssembly
629
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
#[wasm_bindgen]
pub extern "C" fn calculate_fibonacci(n: u32) -> u32 {
fibonacci(n)
}
Приведенный выше фрагмент – не просто исходный код на Rust; он служит
проводником в формат Wasm. Функция calculate_fibonacci помечена атрибутом #[wasm_bindgen], что указывает на ее готовность к вызову из среды исполнения WebAssembly. Эта простая, но мощная интеграция наглядно показывает, как функции на Rust без особых усилий превращаются в фундаментальные
строительные блоки в динамичном ландшафте веб-разработки.
Тестирование модуля WebAssembly
Подготовив функции на Rust, теперь самое время испытать их в деле внутри
модуля WebAssembly. Важным шагом в этом обследовании является создание
HTML-файла, который послужит игровой площадкой для тестирования способностей Wasm. Создайте файл index.html в корневой директории проекта
для проведения веб-экспериментов.
Листинг 15.2. Файл index.html – точка входа для
приложения на Rust с привязкой к Wasm
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Assembly with Rust</title>
</head>
<body>
<script type="module">
import init, { calculate_fibonacci } from './pkg/wasm_rust.js';
async function loadWasm() {
try {
// Инициализация модуля с указанием пути к файлу .wasm
await init('./pkg/wasm_rust_bg.wasm');
// Вызов функции, написанной на Rust
const result = calculate_fibonacci(12);
console.log('Результат из WebAssembly:', result);
} catch (error) {
console.error('Не удалось загрузить модуль WebAssembly:', error);
}
}
loadWasm();
630
Web Assembly на Rust
</script>
</body>
</html>
Приведенный выше фрагмент кода в разметке HTML координирует взаимодействие между функциями на Rust и веб-средой. Асинхронная функция
loadWasm обеспечивает плавную инициацию, щадяще обрабатывая возможные
ошибки. Скрипт импортирует модуль в формате Wasm и вызывает функцию
calculate_fibonacci, результат которой выводится в консоль.
Для того чтобы раздавать этот файл HTML с помощью избранного сервера, давайте воспользуемся Python’овским модулем http.server, чтобы сделать
приложение доступным для веб-браузера:
python3 -m http.server 8080
Рисунок 15.1. Результат вычисления последовательности Фибоначчи в Wasm для числа 12
Затем загляните в консоль веб-браузера, чтобы увидеть результат вычисления последовательности Фибоначчи. Эта фаза тестирования не только закреп
ляет понимание, но и подготавливает почву для более широкого обследования.
Поздравляем! Вы успешно организовали совместную работу функций
на Rust, стандарта WebAssembly и разметки HTML, заложив фундамент для захватывающих возможностей на стыке системного программирования и вебразработки.
В следующем далее разделе мы рассмотрим несколько примеров применения Rust и стандарта WebAssembly из реальной практики.
Примеры применения стандарта
WebAssembly из реальной практики
Потенциал стандарта двоичного формата инструкций WebAssembly выходит
за рамки теоретических исследований и находит практическое применение
в самых разных сценариях. В этом разделе мы подробно рассмотрим конкретные варианты использования, где Rust и стандарт WebAssembly проявляют
себя лучше всего: от расширения возможностей веб-браузерных приложений
до выполнения высокопроизводительных вычислительных задач прямо в веббраузере. Вы получите ценное представление о тех областях, где сочетание
Rust и стандарта WebAssembly дает значительные преимущества, что вдохновит вас на создание креативных решений для самых разных прикладных сфер.
Примеры применения стандарта WebAssembly из реальной практики 631
Вариант использования 1: обработка изображений
В приведенном ниже примере мы подробно разберем вариант использования, который демонстрирует эффективность сочетания Rust и стандарта
WebAssembly при работе со сложными вычислительными нагрузками. Наше
внимание будет сосредоточено на обработке изображений – области, где преимущества производительности Rust и переносимость стандарта WebAssembly
проявляются особенно ярко.
Основой данного примера является файл на Rust под названием lib.rs. Этот
файл содержит исходный код, использующий библиотеку image для преобразования RGBA-изображения в градации серого. Функция apply_grayscale, доступная для среды исполнения WebAssembly благодаря атрибуту wasm-bindgen,
принимает на вход RGBA-изображение в виде вектора Vec<u8>, выполняет
преобразование и возвращает обработанный результат также в виде вектора
Vec<u8>. Данный исходный код демонстрирует строгую типизацию и защитные функциональные средства Rust, обеспечивая надежную работу конвейера
обработки изображений.
Листинг 15.3. Простая логика обработки изображений
на Rust с привязкой к WASM
use image::RgbaImage;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn apply_grayscale(input: Vec<u8>, width: u32, height: u32) -> Vec<u8> {
let mut img = RgbaImage::from_vec(width, height, input)
.expect("Недопустимые размеры изображения");
for pixel in img.pixels_mut() {
let gray_value =
pixel.0[0] as f32 * 0.299 +
pixel.0[1] as f32 * 0.587 +
pixel.0[2] as f32 * 0.114;
pixel.0 = [
gray_value as u8,
gray_value as u8,
gray_value as u8,
pixel.0[3],
];
}
let output = img.into_raw();
output
}
После написания исходного кода на Rust следующим шагом будет его компиляция в формат инструкций WebAssembly. Это можно сделать, исполнив
в терминале следующую ниже команду wasm-pack:
$ wasm-pack build --target web –release
632
Web Assembly на Rust
// Вывод:
// [INFO]: · Checking for the Wasm target...
// [INFO]: · Compiling to Wasm...
//
Compiling image-processing v0.1.0 (/home/mahmoud/image-processing)
//
Finished release [optimized] target(s) in 0.15s
// [INFO]: ·️ Installing wasm-bindgen...
// [INFO]: Optimizing wasm binaries with `wasm-opt`...
// [INFO]: Optional fields missing from Cargo.toml: ‘description’, ‘repository’,
and ‘license’. These are not necessary, but recommended
// [INFO]: ✨ Done in 0.54s
// [INFO]: · Your wasm pkg is ready to publish at /home/mahmoud/image-processing/pkg.
Этот процесс гарантирует, что логика обработки изображений на Rust будет упакована в модуль WebAssembly, готовый к бесшовной интеграции в вебприложения.
Для того чтобы протестировать полученный модуль WebAssembly, мы создадим HTML-файл с именем index.html. Этот файл включает в себя поле выбора
изображения, два холста для вывода на экран изначального и обработанного
изображений, а также исходный код на языке JavaScript, который координирует взаимодействие между файлом с HTML-разметкой, модулем WebAssembly
и пользователем.
Листинг 15.4. Файл index.html – точка входа
для приложения на Rust с привязкой к WASM
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Обработка изображений с использованием Rust и WebAssembly</title>
</head>
<body>
<h1>Обработка изображений с использованием Rust и среды WebAssembly</h1>
<input
type="file"
accept="image/*"
id="imageInput"
onchange="processImage()"
/>
<canvas
id="originalCanvas"
width="300"
height="300"
style="border: 1px solid #000"
></canvas>
<canvas
id="processedCanvas"
Примеры применения стандарта WebAssembly из реальной практики 633
width="300"
height="300"
style="border: 1px solid #000"
></canvas>
<script type="module" defer>
import init, { apply_grayscale } from "./pkg/image_processing.js";
async function loadWasm() {
try {
await init();
window.processImage = processImage;
} catch (error) {
console.error("Не удалось загрузить модуль WebAssembly:", error);
}
}
function processImage() {
const imageInput = document.getElementById("imageInput");
const originalCanvas = document.getElementById("originalCanvas");
const processedCanvas = document.getElementById("processedCanvas");
const ctxOriginal = originalCanvas.getContext("2d");
const ctxProcessed = processedCanvas.getContext("2d");
const reader = new FileReader();
reader.onload = function (event) {
const img = new Image();
img.src = event.target.result;
img.onload = function () {
// Отображение изначального изображения
ctxOriginal.clearRect(0, 0, originalCanvas.width,
originalCanvas.height);
ctxOriginal.drawImage(img, 0, 0, originalCanvas.width,
originalCanvas.height);
// Получение данных изображения из canvas
const imageData = ctxOriginal.getImageData(0, 0,
originalCanvas.width,
originalCanvas.height);
// Конвертация в ч/б с помощью Rust WebAssembly
const inputArray = new Uint8Array(imageData.data);
const outputArray = apply_grayscale(
inputArray,
originalCanvas.width,
originalCanvas.height
);
// Создание нового ImageData с обработанными данными
const processedImageData = new ImageData(
new Uint8ClampedArray(outputArray),
originalCanvas.width,
originalCanvas.height
);
634
Web Assembly на Rust
// Отображение обработанного изображения
ctxProcessed.clearRect(0, 0, processedCanvas.width,
processedCanvas.height);
ctxProcessed.putImageData(processedImageData, 0, 0);
};
};
// Чтение выбранного файла
const file = imageInput.files[0];
if (file) {
reader.readAsDataURL(file);
}
}
loadWasm();
</script>
</body>
</html>
Исходный код на языке JavaScript внутри файла index.html устанавливает все
необходимые соединения. Он инициализирует модуль WebAssembly, определяет функцию processImage для обработки действий пользователя и динамически
обновляет холсты, отображая изначальное и обработанное изображения.
Для раздачи файлов и тестирования функциональности обработки изображений используется Python’овский HTTP-сервер. Исполнение следующей
ниже команды в терминале запустит сервер, после чего приложение станет доступно по адресу http://localhost:8080:
python3 -m http.server 8080
Перейдя по указанному URL-адресу, можно выбрать изображение в поле
ввода. На веб-странице отобразятся как изначальная, так и обработанная (в
градациях серого) версии выбранного изображения. Этот пример иллюстрирует сложное применение Rust в паре со средой исполнения WebAssembly для
реально-временной обработки изображений прямо в веб-браузере.
Обработка изображений с использованием Rust и среды WebAssembly
Рисунок 15.2. Простое приложение на Rust с привязкой к WASM по обработке изображений
Примеры применения стандарта WebAssembly из реальной практики 635
Изучив этот пример, вы получили ценное представление о том, как сочетаются эффективность и защищенность Rust с переносимостью стандарта
WebAssembly. Данный пример создает прочную основу, позволяющую создавать сложные приложения для обработки изображений. Он подчеркивает огромный потенциал связки Rust и стандарта WebAssembly для решения
вычислительно интенсивных задач, открывая новые возможности для вебприложений, требующих передовых и высокопроизводительных мощностей
по работе с графикой.
Вариант использования 2: криптографические операции
В приведенном ниже сценарии мы проведем обследование продвинутых криптографических операций с использованием Rust и стандарта WebAssembly. Реализация включает шифрование данных по алгоритму AES (Advanced Encryption
Standard – Продвинутый стандарт шифрования) в режиме электронной кодовой книги (electronic codebook, аббр. ECB). Rust отлично подходит для решения
криптографических задач.
Листинг 15.5. Простейшая криптографическая
функция на Rust с привязкой к WASM
use aes::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit};
use cbc::Encryptor;
use wasm_bindgen::prelude::*;
type Aes128CbcEnc = Encryptor<aes::Aes128>;
#[wasm_bindgen]
pub fn encrypt(data: &[u8], key: &[u8]) -> Vec<u8> {
// Вектор инициализации из 16 нулевых байтов
let iv = [0u8; 16];
let mut buf = Vec::from(data);
// Создать экземпляр шифратора
let cipher = Aes128CbcEnc::new(key.into(), (&iv).into());
}
// Шифровать с использованием дополнения PKCS7
cipher.encrypt_padded_vec_mut::<Pkcs7>(&mut buf)
После написания исходного кода на Rust следующим шагом будет его компиляция в формат инструкций WebAssembly. Это можно сделать, исполнив
в терминале следующую ниже команду wasm-pack:
$ wasm-pack build --target web --release
// Вывод:
// [INFO]: · Checking for the Wasm target...
// [INFO]: · Compiling to Wasm...
//
Compiling crypto v0.1.0 (/home/mahmoud/crypto)
//
Finished release [optimized] target(s) in 0.20s
// [INFO]: ·️ Installing wasm-bindgen...
636
Web Assembly на Rust
// [INFO]: Optimizing wasm binaries with `wasm-opt`...
// [INFO]: Optional fields missing from Cargo.toml: ‘description’, ‘repository’,
and ‘license’. These are not necessary, but recommended
// [INFO]: ✨ Done in 0.67s
// [INFO]: · Your wasm pkg is ready to publish at /home/mahmoud/crypto/pkg.
Этот процесс гарантирует, что логика криптографических операций на Rust
будет упакована в модуль WebAssembly, готовый к бесшовной интеграции
в веб-приложения.
Листинг 15.6. Файл index.html – точка входа для
приложения на Rust с привязкой к WASM
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Криптография с использованием Rust и WebAssembly</title>
</head>
<body>
<h1>Криптография с использованием Rust и среды WebAssembly</h1>
<textarea id="inputText" placeholder="Ввести шифруемый текст..."></textarea>
<textarea
id="outputText"
placeholder="Зашифрованный результат появится здесь..."
readonly
></textarea>
<button onclick="encryptData()">Зашифровать</button>
<script type="module" defer>
import init, { encrypt } from "./pkg/crypto.js";
async function loadWasm() {
try {
await init();
window.encryptData = encryptData;
} catch (error) {
console.error("Не удалось загрузить модуль WebAssembly:", error);
}
}
function encryptData() {
const inputText = document.getElementById("inputText").value;
const key = "abcdefghijklmnop"; // Должен быть длиной 16 символов
const inputArray = new TextEncoder().encode(inputText);
const keyArray = new TextEncoder().encode(key);
// Вызов функции шифрования из Rust WASM
const outputArray = encrypt(inputArray, keyArray);
Примеры применения стандарта WebAssembly из реальной практики 637
}
// Декодирование результата в строковый литерал
const outputText = new TextDecoder().decode(outputArray);
document.getElementById("outputText").value = outputText;
loadWasm();
</script>
</body>
</html>
HTML-файл содержит текстовые поля для ввода и вывода данных, а также кнопку для запуска процесса шифрования. Исходный код на языке
JavaScript устанавливает необходимые соединения, инициализирует модуль
WebAssembly и определяет функцию encryptData для обработки действий
пользователя.
Для того чтобы протестировать модуль WebAssembly, мы раздаем файлы
с помощью Python’овского HTTP-сервера. Исполнение следующей ниже команды в терминале запустит сервер, сделав приложение доступным по адресу
http://localhost:8080:
python3 -m http.server 8080
Перейдя по указанному URL-адресу, можно ввести текст в поле ввода, кликнуть по кнопке Зашифровать и увидеть результат шифрования в поле вывода.
Приведенный выше пример иллюстрирует применение Rust в связке со стандартом WebAssembly для выполнения сложных криптографических операций
в веб-среде.
Криптография с использованием Rust и среды WebAssembly
Рисунок 15.3. Простое криптографическое приложение на Rust с привязкой к WASM
Разбор этого наглядного примера позволил глубже понять, как Rust бесшовно сочетает свои производительные и защитные функциональные возможности с переносимостью стандарта WebAssembly. Такое взаимодействие
создает мощную платформу, закладывающую основу для конструирования
638
Web Assembly на Rust
защищенных и высокоэффективных криптографических приложений. Данная
демонстрация подчеркивает огромный потенциал комбинации Rust и стандарта WebAssembly для решения сложных криптографических задач, служа
надежным фундаментом для разработки защищенных и отказоустойчивых
веб-приложений.
Вариант использования 3: обработка сетевых запросов
В приведенном ниже сценарии мы проведем обследование того, как Rust
и стандарт WebAssembly могут применяться для эффективной обработки
сетевых запросов внутри веб-приложения. Данный практический пример
предусматривает выполнение асинхронных HTTP-запросов к дистанционному серверу, обработку ответов на стороне Rust и динамическое обновление
веб-страницы. Функциональные возможности Rust в области защиты и производительности используются для создания надежного сетевого компонента,
демонстрируя бесшовную интеграцию со средой исполнения WebAssembly для
веб-приложений.
Листинг 15.7. Простейшая логика обработки
запросов на Rust с привязкой к WASM
// network_handler.rs
extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;
use reqwest;
async fn perform_network_request_impl(url: &str) -> Result<String,
reqwest::Error> {
let response = reqwest::get(url).await?;
let body = response.text().await?;
Ok(body)
}
#[wasm_bindgen]
pub async fn perform_network_request(url: &str) -> JsValue {
match perform_network_request_impl(url).await {
Ok(response_body) => JsValue::from_str(&response_body),
Err(error) => JsValue::from_str(&format!("Ошибка: {:?}", error)),
}
}
После написания исходного кода на Rust следующим шагом будет его компиляция в формат инструкций WebAssembly. Это можно сделать, исполнив
в терминале следующую ниже команду wasm-pack:
$ wasm-pack build --target web --release
// Вывод:
// [INFO]: · Checking for the Wasm target...
// [INFO]: · Compiling to Wasm...
//
Finished release [optimized] target(s) in 0.06s
Примеры применения стандарта WebAssembly из реальной практики 639
// [INFO]: ·️ Installing wasm-bindgen...
// [INFO]: Optimizing wasm binaries with `wasm-opt`...
// [INFO]: Optional fields missing from Cargo.toml: ‘description’, ‘repository’,
and ‘license’. These are not necessary, but recommended
// [INFO]: ✨ Done in 3.47s
// [INFO]: · Your wasm pkg is ready to publish at /home/mahmoud/networking/pkg.
Этот процесс гарантирует, что логика обработки сетевых запросов на Rust
будет упакована в модуль WebAssembly, готовый к бесшовной интеграции
в веб-приложения.
Листинг 15.8. Файл index.html – точка входа для
приложения на Rust с привязкой к WASM
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Обработка запросов с помощью Rust и WebAssembly</title>
</head>
<body>
<h1>Обработка запросов с помощью Rust и среды WebAssembly</h1>
<button onclick="handleNetworkRequest()">Доставить</button>
<div id="result"></div>
<script type="module" defer>
import init, { get_request } from "./pkg/networking.js";
async function loadWasm() {
try {
await init();
window.handleNetworkRequest = handleNetworkRequest;
} catch (error) {
console.error("Не удалось загрузить модуль WebAssembly:", error);
}
}
async function handleNetworkRequest() {
const resultDiv = document.getElementById("result");
// Вызов функции Rust WebAssembly для обработки сетевого запроса
const url = "https://jsonplaceholder.typicode.com/posts/1";
const response = await get_request(url);
}
// Вывод результата запроса на экран
resultDiv.innerText = `Response: ${response}`;
loadWasm();
</script>
</body>
</html>
640
Web Assembly на Rust
HTML-файл содержит кнопку для запуска сетевого запроса и блок div для
вывода ответа на экран. Исходный код на языке JavaScript устанавливает
необходимые соединения, инициализирует модуль WebAssembly и определяет функцию handleNetworkRequest для обработки действий пользователя.
Для того чтобы протестировать модуль WebAssembly, мы раздаем файлы
с помощью Python’овского HTTP-сервера. Исполнение следующей ниже команды в терминале запустит сервер, сделав приложение доступным по адресу
http://localhost:8080:
python3 -m http.server 8080
Перейдя по указанному URL-адресу, можно кликнуть по кнопке Доставить,
и на веб-странице отобразится ответ асинхронного HTTP-запроса, отправленного методом GET. Приведенный выше пример иллюстрирует применение
Rust и среды исполнения WebAssembly для обработки сетевых запросов в вебприложении, демонстрируя эффективность и функциональные средства обес
печения защищенности, предоставляемые Rust в веб-контексте.
Обработка запросов с использованием Rust и среды WebAssembly
Рисунок 15.4. Простое приложение для обработки запросов на Rust с привязкой к WASM
Обследование приведенных выше применений Rust и стандарта WebAssembly
из реальной практики дает глубокое понимание универсальных решений, которые они приносят для специфических задач, и того, как они улучшают пользовательский опыт в различных областях. Rust в сочетании со способностью
открытого стандарта двоичного формата инструкций WebAssembly исполнять
код в веб-браузерах с околонативным быстродействием открывает массу возможностей. От расширения функциональностей веб-приложений высокопроизводительными способностями до разработки вычислительно интенсивных
задач в среде веб-браузера – взаимодействие Rust и стандарта WebAssembly
доказало свою неоценимую значимость. По мере развития этих технологий
спектр потенциальных приложений будет расширяться, обещая новые возможности, которые будут задействовать их общие сильные стороны для переосмысления и оптимизации цифрового опыта.
Дополнительные ресурсы 641
Заключение
В этой главе вы узнали, что, несмотря на значительные преимущества в плане
производительности и переносимости, которые несет в себе открытый стандарт двоичного формата инструкций WebAssembly, разработчики должны
подходить к его ограничениям с изобретательностью. Сложность управления
памятью, косвенное манипулирование объектной моделью документа (DOM)
и ограниченная поддержка со стороны веб-браузеров требуют вдумчивого
рассмотрения в процессе принятия решений. Отслеживание текущих разработок в экосистеме стандарта WebAssembly – ключ к раскрытию его полного
потенциала при эффективном управлении возможными трудностями.
Для разработчика это не просто изучение синтаксиса и библиотек; это погружение в самую суть системного программирования и веб-интерактивности.
Интеграция Rust со стандартом WebAssembly становится мощным набором
инструментов, позволяющим строить приложения, которые раздвигают границы возможного во Всемирной паутине.
От создания функций на Rust до интеграции с языком JavaScript – каждый шаг открывал новые горизонты понимания. Фаза тестирования послужила практической демонстрацией слаженного взаимодействия между Rust
и веб-средой. Обследование продвинутых функциональных средств повысило
ваш уровень мастерства, раскрыв истинный потенциал Rust в контексте вебтехнологий.
Построение приложений из реальной практики стало кульминацией вашего обследования данной темы, где теория превратилась в практику. Область
реально-практической разработки позволила вам применить свои знания,
преодолеть трудности и создать приложения, которые служат свидетельством
способностей связки Rust и стандарта WebAssembly.
Завершая эту главу, поразмышляйте о приобретенных умениях, преодоленных трудностях и захватывающем пути впереди. Пересечение Rust и Всемирной паутины – это область непрерывного познания, самообучения и инноваций. Принимайте открывающиеся возможности, расширяйте границы, и пусть
ваше путешествие в мир Rust и стандарта WebAssembly станет постоянным источником вдохновения в ваших проектах по веб-разработке.
Дополнительные ресурсы
В целях углубления своих знаний об открытом стандарте двоичного формата инструкций WebAssembly и Rust, а также реализации их потенциала в различных
приложениях рекомендуется ознакомиться со следующими ниже ресурсами.
Книга по Rust и стандарту WebAssembly: официальное руководство,
содержащее всеобъемлющий обзор интеграции Rust со стандартом
WebAssembly. Оно предлагает методики создания эффективных и высокопроизводительных веб-приложений: https://rustwasm.github.io/docs/book.
Среды исполнения Rust и инструкций WebAssembly: ознакомьтесь с проектом wasmer – универсальной и расширяемой средой исполнения для стандарта WebAssembly с поддержкой Rust: https://github.com/wasmerio/wasmer.
642
Web Assembly на Rust
Wasmer: посетите официальный веб-сайт проекта Wasmer, чтобы обследовать его возможности, варианты использования и ресурсы сообщест
ва: https://wasmer.io/.
wasm-bindgen: узнайте больше о wasm-bindgen – инструменте, который
обеспечивает бесшовное взаимодействие между Rust и JavaScript в проектах WebAssembly: https://rustwasm.github.io/wasm-bindgen/.
Официальная документация WebAssembly: ознакомьтесь со спецификациями, инструментами и ресурсами, для того чтобы использовать
стандарт WebAssembly в различных сценариях разработки: https://
webassembly.org/.
Документация wasmtime: обследуйте документацию wasmtime – автономной среды исполнения инструкций WebAssembly, где подробно описаны
ее функциональные средства и способности: https://docs.rs/wasmtime/.
Фреймворк Yew: познакомьтесь с yew – современным фреймворком для
Rust, посвященным построению веб-приложений на базе стандарта
WebAssembly с использованием реактивного и компонентного подхода:
https://github.com/yewstack/yew.
Вопросы для самопроверки
Вопрос 1. В чем заключается ключевое преимущество стандарта
WebAssembly перед традиционным исполнением исходного кода на языке JavaScript в веб-браузерах?
a) более быстрая загрузка
b) улучшенная защищенность
c) прямое манипулирование объектной моделью документа (DOM)
d) совместимость со старыми веб-браузерами
Вопрос 2. Какую роль в стандарте WebAssembly играет системный интерфейс WebAssembly (WASI)?
a) определяет формат двоичных инструкций
b) упрощает прямое манипулирование объектной моделью документа
(DOM)
c) предоставляет стандартный интерфейс для управления ресурсами и системного взаимодействия
d) ориентирован на языки программирования высокого уровня
Вопрос 3. В чем заключается существенное преимущество языковой независимости стандарта WebAssembly?
a) она ограничивает разработчиков использованием одного конкретного
языка
b) она ограничивает спектр задач, выполняемых в веб-приложениях
c) она позволяет интегрировать исходный код, написанный на разных языках высокого уровня
d) она навязывает использование языка JavaScript для всех веб-приложений
Вопросы для самопроверки 643
Вопрос 4. Как стандарт WebAssembly повышает защищенность вебприложений?
a) путем исполнения исходного кода в изолированной среде («песочнице»)
b) путем ограничения доступа к объектной модели документа (DOM)
c) путем принудительного использования сборщика мусора
d) путем предоставления глобального объекта WebAssembly
Вопрос 5. Какое ограничение стандарта WebAssembly касается поддержки со стороны веб-браузеров?
a) ограниченное управление памятью
b) невозможность выполнять вычислительно интенсивные задачи
c) проблемы совместимости с новыми веб-браузерами
d) сложности в старых веб-браузерах, где отсутствует глобальный объект
WebAssembly
Вопрос 6. Как спецификация asm.js связана со стандартом WebAssembly?
a) это замена стандарта WebAssembly
b) это конкурирующая технология
c) это подмножество языка JavaScript, разработанное для повышения производительности
d) он обеспечивает более качественное управление памятью, чем стандарт
WebAssembly
Вопрос 7. Каково текущее ограничение стандарта WebAssembly в отношении манипулирования объектной моделью документа (DOM)?
a) прямой и бесшовный доступ к DOM
b) косвенный доступ через язык JavaScript или набор инструментов
Emscripten
c) автоматическое выделение памяти под операции с DOM
d) включение объектов DOM в формат двоичных инструкций
Вопрос 8. Каково предназначение системного интерфейса WebAssembly
(WASI)?
a) оптимизация исполнения кода
b) повышение защищенности веб-браузера
c) предоставление стандартного интерфейса для выполнения приложений
WebAssembly в любой платформе
d) прямой доступ к объектной модели документа (DOM)
Вопрос 9. В чем заключается уникальная проблема управления памятью
в текущем стандарте WebAssembly?
a) автоматическая сборка мусора
b) линейное выделение памяти
c) зависимость от языков со сборкой мусора
d) исключение высокоуровневых языков, таких как C и C++
644
Web Assembly на Rust
Вопрос 10. Почему отсутствие сборки мусора в стандарте WebAssembly
имеет важное значение?
a) это упрощает управление памятью
b) это повышает производительность без каких-либо компромиссов
c) это позволяет автоматически высвобождать память
d) это требует тщательного ручного выделения памяти
Вопрос 11. В чем преимущество интеграции поддержки сборки мусора
в стандарт WebAssembly?
a) повышенная защищенность
b) упрощенное управление памятью
c) улучшенная совместимость со старыми веб-браузерами
d) более быстрая загрузка
Вопрос 12. Какой инструмент Rust необходим для добавления целевой
платформы WebAssembly в инструментарий Rust?
a) Cargo
b) rustc
c) wasm-pack
d) rustup
Ответы
1. a) более быстрая загрузка
2. c) предоставляет стандартный интерфейс для управления ресурсами
и системного взаимодействия
3. c) она позволяет интегрировать исходный код, написанный на разных
языках высокого уровня
4. a) путем исполнения исходного кода в изолированной среде («песочнице»)
5. d) сложности в старых веб-браузерах, где отсутствует глобальный объект
WebAssembly
6. c) это подмножество языка JavaScript, разработанное для повышения
производительности
7. b) косвенный доступ через язык JavaScript или набор инструментов
Emscripten
8. c) предоставление стандартного интерфейса для выполнения приложений WebAssembly в любой платформе
9. b) линейное выделение памяти
10. d) это требует тщательного ручного выделения памяти
11. b) упрощенное управление памятью
12. d) rustup
Предметный указатель
А
Аргумент командного интерфейса 339
обработка аргументов-флагов 345
обработка нескольких аргументов 343
обработка продвинутых
аргументов 346
основы
изменение и запись содержимого 357
логика поиска и замены 354
поддержка нескольких входных
файлов 364
поиск без учета регистра букв 360
реализация логики поиска
и замены 349
чтение файлов 350
получение библиотеки clap 341
преимущества
активное сопровождение и
обновление 340
дополнительные ресурсы 340
защищенность и надежность 340
использование информации 340
кросс-платформенная
совместимость 340
обилие функциональных
средств 340
обширная документация 340
поддержка сообщества 340
популярность экосистемы 340
стандарты командного интерфейса
Unix 340
Б
Блок unsafe 553
вопросы защищенности 556
пример 553
производительность 556
сценарии 554
В
Вариант использования реальный
дистанционное исполнение команд 538
обработка HTTP-запросов 531
одноранговый (P2P) обмен файлами 540
построение сервера передачи
файлов 536
реально-временное коллаборативное
редактирование 542
сетевое чат-приложение 533
Вектор 154
возвращение индекса и значения
из метода enumerate 161
доступ к элементам 155
использование метода iter_mut 160
использование цикла for 159
модифицирование элементов
добавление 156
обновление 157
удаление 158
прокручивание элементов
в цикле 159
создание 155
Г
Границы общей черты 114
З
Замер производительности 505
Замыкание 103
концепции 455
заимствование переменных 457
захват переменных 456
перемещение переменных 458
синтакс 456
переменные
Fn 459
FnMut 459
FnOnce 459
Замыкание и итератор
применение
анализ текста 465
обработка изображений 467
обработка сенсорных данных 470
преобразование и фильтрация
данных 468
сортировка 473
финансовые расчеты 471
646
Предметный указатель
И
Интеграция непрерывная (CI) 506
unit test 506
шаги конфигурирования
cache 506
language 506
rust 506
script 506
Интерфейс командный
отдельные шаги
изменение и запись содержимого 350
поддержка нескольких файлов 350
поиск и замена 350
чтение файлов 350
Интерфейс командный
отдельные шаги
поиск без учета регистра букв 350
Интерфейс с внешними
функциями (FFI) 48
Итератор 430
адаптерные методы 442
анатомия 431
ленивые вычисления 446
метод drain 438
метод from_fn 435
методы
адаптер filter_map 451
адаптер flat_map 447
адаптер flatten 454
адаптер fuse 452
основополагающая общая черта
IntoIterator 433
Iterator 433
основополагающие методы
chain() 431
collect() 431
filter() 431
for_each() 431
map() 431
next() 431
реализация конкретно-прикладных
структур 444
ресурсы 439
эффективная обработка данных 462
К
Код исходный асинхронный
использование ключевого
слова async 595
конкурентное программирование 599
обработка ошибок 597
Код исходный незащищенный 553
вариант использования из реальной
практики
взаимодействие с базой данных
через FFI 557
обработка изображений 563
управление памятью 566
лучшие образцы практики 568
нарушения защищенности памяти 570
последствия
обработка пустого указателя 580
обращение к памяти по пустому
указателю 580
ошибки использования памяти после
ее высвобождения 582
переполнение буфера 581
связанные риски 580
Конкурентность 295
использование мьютекса
обработка взаимных блокировок 310
реализация очереди, охраняемой
мьютексом 307, 308
сценарии производитель–
потребитель 311
условное запирание на замок 305
коллективное использование
данных 297
концепции
владение 295
времена жизни 295
заимствование 295
лучшие образцы практики 330
обмен данными между потоками 324
библиотека crossbeam 328
координирование с помощью
барьеров 325
локальное для потока хранение
данных (TLS) 326
передача сообщений 321
приемы обработки потоков
исполнения
атомарные операции 323
использование каналов связи 321
передача сообщений с помощью
структур 322
примитив RwLock
динамическое количество
читателей 315
запирание на замок по времени 316
предотвращение взаимных
блокировок 318
Вопросы для самопроверки 647
реализация объединения ресурсов
в пул 319
управление коллективными
данными 313
сравнение с параллелизмом 295
структуры данных 297
взаимное исключение (мьютекс) 298
коллективное использование данных
между потоками 300
обработка скомпрометированных
мьютексов 304
обследование примитива RwLock 313
правила владения 303
предотвращение взаимных
блокировок 301
управление потоками исполнения 296
Конструкция управляющая 99
Кортеж 161
варианты использования
координаты 164
обработка ошибок 164
ограничения 165
варианты перечислений 172
владение переменной 168
деструктуризация 163
доступ к элементам 162
заимствование переменной 168
значения функций 171
использование кортежных шаблонов 163
оператор развертывания
доступ к срезам 170
создание срезов 169
создание 162
сопоставление с шаблоном 165
сценарии с функциями
возврат 171
использование аргументов
функций 170
шаблон
игнорирование элементов 166
навигация по структурам данных 166
опровержимость 167
Л
Логика булева 90
М
Массив
инициализация 151
вычисленные значения 151
генерирование шаблона 151
инициализация дефолтными
значениями 151
Менеджер пакетов исходного кода на
языке Rust
обследование функциональных
возможностей 62
Менеджер пакетов на языке Rust
Cargo 59, 62
каталоги 63
навигация по структуре проекта 62
создание нового проекта 62
тестирование и документация 64
управление зависимостями 63
Модуль тестовый 484
выполнение 486
исполнение и результаты 485
Н
Нарушение защищенности памяти 570
примеры
авария первого полета ракеты
Ариан-5 575
уязвимость Heartbleed 572
О
Обобщение 115
адаптивные структуры 116
Обработка ошибок 49
использование типа Option 220
использование типа Result 219
консольные приложения 234
межпотоковый обмен данными 244
многопоточный исходный код 242
разбор файлов 233
тестирование 245
тестирование условий
написание тестов для обработки
ошибок 245
тестирование на основе свойств 246
Обработка ошибок асинхронная 229
асинхронные функции 229
асинхронный исходный код с типами
ошибок 230
использование в веб-приложениях 232
обработка ошибок
конкурентности 231
Обработка ошибок встроенная
приемы
библиотека anyhow 226
использование пакета thiserror 227
Обращение к памяти по пустым
указателям 29
иллюстрации 31
Объект динамический 120
648
Предметный указатель
Ограничение общей черты
комбинирование в предикатном
условии 126
перегрузка операторов 124
расширение с помощью обобщений
115
стандартная библиотека 123
Операция сетевая асинхронная 526
асинхронное программирование 527
асинхронный TCP-сервер 530
П
Параллелизм и многопоточность 39
иллюстрации 40
конкурентность 42
разбор кода на C++ 41
Передача ошибок по стеку вызовов 221
использование типов Result и Option 224
оператор ? 221
Передача ошибок по стеку вызовов
обработка нескольких ошибок
с помощью типа Result 223
Переполнение буфера 34
библиотеки манипулирования
строковыми литералами 35
средства защиты памяти 36
Покрытие исходного кода 506
Приложение сетевое 516
разработка 516
Примитив числовой 77
использование конвертаций 82
итерация 81
символы 79
строковый тип 81
фиксированной ширины 78
целочисленная конвертация 78
Программа на языке Rust первая
запуск 60
компиляция и исполнение 60
написание 60
написание исходного кода 60
Программирование асинхронное 329
возможности
асинхронные потоки данных 602
завершение жизненного
цикла асинхронного ресурса
AsyncResource 606
интеграция синхронного исходного
кода 614
обработка тайм-аутов 611
разведение (разветвление) 608
сведение (схождение) 608
управление динамическими
наборами заданий 612
использование API 527
Программирование на уровне типов 135
Протокол пользовательских датаграмм
(UDP) 524
Протокол связи 520
протокол пользовательских датаграмм
(UDP) 524
протокол управления
передачей (TCP) 521
Протокол управления передачей (TCP) 521
сложности 523
Процесс обработки данных итераторами
методы
выстраивание в цепочку 464
накапливание данных с помощю
свертки fold 463
С
Сбор мусора 36
анализ 38
подробный разбор 37
Сверка 489
выполнение теста 491
использование макрофункций 489
использование сообщений
об ошибках 491
настройка функции 496
подготовка исходного кода 492
тестовые функции 490
шаги rstest 494
шаги фикстуры 493
Система реально-временная 420
Система файловая
дополнительные операции
жесткие и символические ссылки 395
запирание файлов на замок 397
лучшие образцы практики
файлового ввода-вывода 402
отображаемые в память файлы 401
драйверы устройств 406
закрытие файлов 380
запись 374
запись в файлы 377
модули ядра 405
обработка ошибок с использованием
типа Result 391
открытие файлов 374
параллелизм, конкурентность и вводвывод с устройств 418
работа с аппаратными устройствами 393
Вопросы для самопроверки 649
распространенные операции
переименование и перемещение
файлов 382
проверка метаданных файлов 383
создание директорий 381
реальные сценарии
обход директорий 386
работа с путями к файлам 388
чтение двоичных данных 390
сложности работы 381
удаление директорий и файлов 385
учет кибербезопасности
валидация входных данных 415
контроль доступа 415
правила сетевого экрана 417
фреймворки
включение библиотек для обработки
сетевого взаимодействия 414
рассмотрение возможности
применения синтаксиса async/
await 410
сетевое программирование 406
сокеты 407
чтение из файлов 376
Создание заглушек для зависимостей 502
Сопоставление с шаблоном 44
Среда разработки
Visual Studio Code 58
интегрированные среды разработки
(IDE) 57
Срез 173
доступ к элементам 175
изменение элементов 176
компоненты
длина 174
емкость 174
указатель на первый элемент 174
реальные применения 177
создание 175
сценарии применения
манипуляция строковыми
литералами 177
обработка данных 178
обработка двоичных данных 179
отображение в память 180
токенизация текста 179
Суперчерта 129
Сценарий возникновения ошибок
консольный
использование библиотеки clap 235
Сценарий незащищенный
интерфейс с внешними функциями
(FFI) 282
конкретно-прикладные
абстракции 281
общая черта 280
рекомендации по написанию
исходного кода 283
функции 280
Т
Тестирование асинхронное 500
Тестирование модульное 482
интерпретация результатов тестов 487
использование тестовых функций 483
написание модулей Rust 482
Тест условный 498
применения 498
Тип данных сложный
кортежи 98
массивы 96
строковые данные, текст 94
Тип ошибок встроенный
продвинутая обработка ошибок 226
Тип ошибок встроенный
асинхронная обработка ошибок 229
сигналы прерывания 236
Тип ошибок конкретно-прикладной
выполнение HTTP-запросов 240
обработка операций ввода-вывода 237
построение сетевых сервисов 240
работа с директориями 239
создание 225
Тип с динамически изменяемым
размером (DST) 132
Ф
Файл, отображаемый в память 401
Фикстура тестовая 497
Х
Хеш-множество
рельные применения
графовые алгоритмы 186
дедупликация 184
кеширование 185
операции на множествах 188
отслеживание уникальных значений
190
проверка на членство 185
тегирование и фильтрация 189
Хеш-словарь 196
доступ к значениям 199
обновление
добавление элементов 197
650
Предметный указатель
обновление элементов 198
удаление элементов 198
операции
API внесения записей 201
емкость 202
очистка ключей и значений 202
проверка существования ключа 201
прокручивание ключей и значений
в цикле 199
реальные применения
графовые алгоритмы 207
группировка данных 206
индексация баз данных 207
кеширование 204
мемоизация 204
подсчет вхождений 203
преобразование данных 206
управление конфигурацией 205
создание 196
Хранение данных локальное для потока
(TLS) 326
Ч
Черта общая 111
ассоциированные константы 124
ассоциированные функции 117
избегание конфликтов 127
маркерная 125
поведение 113
продвинутые шаблоны 124
реализация 111
реальные применения 121
соображения производительности 137
Число с плавающей точкой 86
включение математических
операций 88
глубина точности типа f64 87
использование литералов чисел 88
определение приближенного
значения 89
ориентация в сложностях 89
освоение 90
эффективность типа f32 86
Ш
Шаблон архитектурный новый тип 130
A
async, ключевое слово 591
await, ключевое слово 591
C
Cargo 59, 62
cargo run, команда 341
cargo test, команда 485
crossbeam, библиотека 328
F
flat_map, адаптер 451
адаптер peekable 449
задачи 451
использование адаптера fuse 451
использование адаптера skip 448
FnMut, замыкания 460
FnOnce, замыкания 460
Fn, замыкания 459
L
loop, ключевое слово 101
M
matches, функция 95
MyInterval, структура 602
N
netcat (nc), утилита 518
P
print_tic_tac_toe 149
R
rename, функция 383
replace, команда 354
Rust
арифметические операции 83
вычитание 84
деление 85
остаток от деления 85
сложение 84
умножение 84
ассоциированные типы 118
всеохватывающая реализация 128
выражения match 101
динамические объекты 120
заметные проекты 52
защищенность и производительность 27
инструментарий 50
командный интерфейс (CLI) 51
массивы 143
доступ к элементам 144
изменение элементов 146
клонирование массива 153
копирование массива 153
многомерные 149
нарезка 147
Вопросы для самопроверки 651
получение длины 152
проверка границ 153
прокручивание в цикле 147
работа с методами массива 150
создание 144
математические операции 83
обследование за пределами основ 52
основные инструменты
Clippy 59
менеджер пакетов на языке Rust 59
форматирование исходного кода 59
поддержка сообщества 52
программирование
затенение 74
константы 76
мутируемость 74
наглядные примеры 73
переменные 72
типы данных 72
производительность множеств
использование памяти 190
поиск 190
прокручивание в цикле 190
удаление/вставка 190
революционные концепции
интерфейс с внешними функциями
(FFI) 48
контролируемые незащищенные
операции 50
обработка ошибок 49
обращение к памяти по пустым
указателям 29
параллелизм и многопоточность 39
переполнение буфера 34
сбор мусора 36
система времен хизни 45
сопоставление с шаблоном 44
революция в защищенности памяти 28
способности
владение 103
времена жизни 103
контролер заимствования 104
повышение производительности 105
стек памяти
модель владения 263
модель заимствования 264
поддержка времен жизни 264
роль кучи 262
роль стека 262
тестирование приватных функций 503
управление памятью 254
лучшие образцы практики
аннотирование времен жизни 285
выделение памяти в стеке 284
задействование ссылок 284
использование умных
указателей 284
конкурентность и
многопоточность 286
осторожное использование
незащищенного исходного
кода 286
профилирование
и оптимизация 287
сопоставление с шаблоном 285
управление ресурсами 286
незащищенное 280
образцы практики 292
операции
выделение памяти 288
операционная совместимость
языков 291
отображение файлов в память 290
стек и куча 260
типы указателей
Arc (атомарный счетчик ссылок)
269
Box 266
Mutex 272
Rc (счетчик ссылок) 268
RefCell 271
RwLock
(замок на чтение–запись) 275
атомарные типы 278
трудности
висячие ссылки 257
владение и заимствование 255
гонки за доступом к данным 259
защищенность исходного кода 258
управление ресурсами 260
указатели 266
управляющая конструкция 99
условное соблюдение 134
условные инструкции 100
установка 54
начало работы 57
шаги установки в Linux 56
шаги установки в MacOS 57
шаги установки в Windows 54
формирование будущего 51
формы проявления 29
функции 102
модульность вложенных
функций 102
652
Предметный указатель
явные сигнатуры 102
хеш-множество 181
вопросы конкурентного
исполнения 191
добавление элементов 182
обновление
удаление элементов 182
обновление множества 182
оптимизация / нагрузочное
тестирование 195
продвинутые операции 182
сериализация/десериализация 194
симметрическая разность 183
создание множества 181
S
String 94
комбинирование 95
нарезка 95
T
tarpaulin 506
tokio
применение библиотеки Rust 595
tokio, библиотека 595
U
unsafe, ключевое слово 33
W
WebAssembly
вариант использования
криптографические
операции 635
обработка изображений 631
обработка сетевых запросов 638
ограничения
манипуляция объектной моделью
документа (DOM) 624
поддержка веб-браузерами 624
сбор мусора 625
управление памятью 625
преимущества 623
применения из реальной
практики 630
технология сопряжения с языком Rust
написание функций на языке Rust
с привязкой к Wasm 628
настройка проекта Rust в связке с
Wasm 626
сборка и тестирование модулей 627
тестирование модуля
WebAssembly 629
установка цепочки
инструментов 626
while, цикл 100
Книги издательства «ДМК Пресс» можно купить оптом и в розницу
на складе издательства по адресу:
Москва, ул. Электродная, д. 2, стр. 12, офис 7,
тел. +7 (499) 322–19–38,
а также заказать на сайте www.dmkpress.com
с доставкой в любой регион РФ
Махмуд Хармуш
Rust для системного программирования:
полное руководство
Главный редактор Яценков В. С.
editor@dmkpress.com
Перевод
Корректор
Верстка
Дизайн обложки
Логунов А. В.
Синяева Г. И.
Луценко С. В.
Мовчан А. Г.
Формат 70×100 1/16.
Гарнитура «PT Serif». Печать цифровая.
Усл. печ. л. 53,14. Тираж 200 экз.
Веб-сайт издательства: www.dmkpress.com