Осваиваем C++17 STL. 9785970606636, 9781787126824

Стандарт C++17, которому посвящена книга, удвоил объем библиотеки в сравнении с С++11. Вы узнаете о наиболее важных особ

256 17 5MB

Russian Pages [353] Year 2019

Report DMCA / Copyright

DOWNLOAD PDF FILE

Table of contents :
osvaivaem_c17_stl_1-35
osvaivaem_c17_stl_36-70
osvaivaem_c17_stl_71-105
osvaivaem_c17_stl_106-140
osvaivaem_c17_stl_141-175
osvaivaem_c17_stl_176-210
osvaivaem_c17_stl_211-245
osvaivaem_c17_stl_246-280
osvaivaem_c17_stl_281-315
osvaivaem_c17_stl_316-350
osvaivaem_c17_stl_351-353
Recommend Papers

Осваиваем C++17 STL.
 9785970606636, 9781787126824

  • 0 0 0
  • Like this paper and download? You can publish your own PDF file online for free in a few minutes! Sign Up
File loading please wait...
Citation preview

Научитесь разрабатывать то то и то то Артур О’Двайр

Современный C++ далеко ушел после 2011 года. Последнее обновление стандарта — ​C++17 — ​уже утверждено и внедряется в некоторые реализации. Эта книга послужит вам путеводителем по стандартной библиотеке C++ и познакомит с самыми новыми возможностями, включенными в C++17.

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

С этой книгой вы: • научитесь создавать свои типы итераторов, диспетчеров памяти и пулов потоков выполнения; • овладеете стандартными контейнерами и стандартными алгоритмами; • усовершенствуете свой код, заменив new/delete умными указателями; • усвоите разницу между мономорфными, полиморфными и обобщенными алгоритмами; • узнаете смысл и назначение словарных типов, типов-произведений и типов-сумм.

Интернет -магазин: www.dmkpress.com Книга - почтой: e-mail: [email protected] Оптовая продажа: «Альянс-книга» Тел/факс (499)782-3889 e-mail: [email protected]

ISBN 978-5-97060-663-6

9 785970 606636

Осваиваем C++17 STL

Издание начинается с подробного исследования стандартной библиотеки шаблонов C++STL (Standard Template Library). Вы узнаете, чем отличаются классический полиморфизм от обобщенного программирования, лежащего в основе STL. Также вы увидите, как использовать на практике разные алгоритмы и контейнеры, имеющиеся в STL. Далее следует описание инструментов современного C++. В этой части вы познакомитесь с алгебраическими типами, такими как std:: optional, словарными типами, такими как std:: function, умными указателями и примитивами синхронизации, такими как std:: atomic и std:: mutex. В заключительной части вашему вниманию будет представлена поддержка регулярных выражений в C++ и операций ввода/вывода с файлами.

Осваиваем Осваиваем

С++

C++17 STL

Используйте компоненты стандартной библиотеки C++17 в полной мере

Артур О’Двайр

Осваиваем C++17 STL Используйте компоненты стандартной библиотеки в C++17 в полной мере

Mastering the C++17 STL Make full use of the standard library components in C++17

Arthur O'Dwyer

BIRMINGHAM – MUMBAI

Осваиваем C++17 STL Используйте компоненты стандартной библиотеки в C++17 в полной мере

Артур О’Двайр

Москва, 2019

УДК ББК

Д22

004.4 32.973.202-018.2 Д22 Артур О’Двайр Осваиваем C++17 STL / пер. с англ. А. Н. Киселева – М.: ДМК Пресс, 2019. – 352 с.: ил. ISBN 978-5-97060-663-6

Стандарт C++17, которому посвящена книга, удвоил объем библиотеки в сравнении с С++11. Вы узнаете о наиболее важных особенностях стандартной библио­теки C++17 со множеством примеров, научитесь создавать свои типы итераторов, диспетчеры памяти, пулы потоков выполнения. Также рассмотрены отличия мономорфизма, полиморфизма и обобщенных алгоритмов. Издание адресовано разработчикам, желающим овладеть новыми особеннос­ тями библиотеки C++17 STL и в полной мере использовать ее компоненты. Знакомство с языком C++ является обязательным условием.



УДК 004.4 ББК 32.973.202-018.2

Copyright ©Packt Publishing 2018. First published in the English language under the title Mastering the C++17 STL – (9781787126824) Все права защищены. Любая часть этой книги не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав. Материал, изложенный в данной книге, многократно проверен. Но, поскольку вероятность технических ошибок все равно существует, издательство не может гарантировать абсолютную точность и правильность приводимых сведений. В связи с этим издательство не несет ответственности за возможные ошибки, связанные с использованием книги.

ISBN 978-1-78712-682-4 (англ.) ISBN 978-5-97060-663-6 (рус.)

Copyright © 2017 Packt Publishing. © Оформление, перевод на русский язык, издание, ДМК Пресс, 2019

Оглавление Предисловие от разработчиков С++ в России и Беларуси.............................................10 Об авторе...............................................................................................................................11 О научном редакторе...........................................................................................................11 Предисловие..........................................................................................................................12 Содержание книги...........................................................................................................................12 Что потребуется для работы с книгой.....................................................................................13 Кому адресована эта книга..........................................................................................................13 Типографские соглашения...........................................................................................................14 Отзывы и пожелания......................................................................................................................14 Скачивание исходного кода примеров..................................................................................15 Список опечаток...............................................................................................................................15 Нарушение авторских прав.........................................................................................................15 Глава 1. Классический полиморфизм и обобщенное программирование.................16 Конкретные мономорфные функции......................................................................................16 Классические полиморфные функции....................................................................................17 Обобщенное программирование с шаблонами.................................................................19 Итоги.....................................................................................................................................................22 Глава 2. Итераторы и диапазоны.......................................................................................24 Проблема целочисленных индексов.......................................................................................24 За границами указателей.............................................................................................................25 Константные итераторы................................................................................................................28 Пара итераторов определяет диапазон.................................................................................29 Категории итераторов...................................................................................................................31 Итераторы ввода и вывода.........................................................................................................33 Объединяем все вместе................................................................................................................36 Устаревший std::iterator.................................................................................................................39 Итоги.....................................................................................................................................................42 Глава 3. Алгоритмы с парами итераторов.........................................................................44 Замечание о заголовках...............................................................................................................44 Диапазонные алгоритмы только для чтения........................................................................44 Манипулирование данными с std::copy..................................................................................51 Вариации на тему: std::move и std::move_iterator..............................................................54 Непростое копирование с std::transform...............................................................................57 Диапазонные алгоритмы только для записи.......................................................................59

6    Оглавление Алгоритмы, влияющие на жизненный цикл объектов......................................................60 Наш первый перестановочный алгоритм: std::sort............................................................62 Обмен местами, обратное упорядочение и разделение.................................................63 Ротация и перестановка................................................................................................................67 Кучи и пирамидальная сортировка..........................................................................................69 Слияние и сортировка слиянием...............................................................................................71 Поиск и вставка в сортированный массив с std::lower_bound.....................................71 Удаление из сортированного массива с std::remove_if....................................................73 Итоги.....................................................................................................................................................77 Глава 4. Зоопарк контейнеров............................................................................................78 Понятие владения...........................................................................................................................78 Простейший контейнер: std::array.................................................................................80 Рабочая лошадка: std::vector...............................................................................................84 Изменение размера std::vector.............................................................................................................85 Вставка и стирание в std::vector...........................................................................................................89 Ловушки vector...............................................................................................................................90 Ловушки в конструкторах перемещения без noexcept..............................................................91

Быстрый гибрид: std::deque..................................................................................................93 Особый набор возможностей: std::list.............................................................................94

Какие отличительные особенности имеет std::list?......................................................................95

Список без удобств std::forward_list.................................................................................97 Абстракции с использованием std::stack и std::queue.......................................98 Удобный адаптер: std::priority_queue...............................................................................99 Деревья: std::set и std::map................................................................................... 100 Замечание о прозрачных компараторах....................................................................................... 104

Необычные std::multiset и std::multimap......................................................... 105

Перемещение элементов без перемещения................................................................................107

Хеши: std::unordered_set и std::unordered_map............................................. 109 Фактор загрузки и списки в корзинах............................................................................................ 111

Откуда берется память?............................................................................................................. 112 Итоги.................................................................................................................................................. 113 Глава 5. Словарные типы.................................................................................................. 114 История std::string........................................................................................................................ 114 Маркировка ссылочных типов с reference_wrapper ..................................................... 116 C++11 и алгебраические типы................................................................................................. 117 Работа с std::tuple......................................................................................................................... 118 Манипулирование значениями кортежа....................................................................................... 120 Замечание об именованных классах.............................................................................................. 121

Выражение альтернатив с помощью std::variant............................................................. 122 Чтение вариантов.................................................................................................................................... 123 О make_variant и семантике типа-значения................................................................................. 125

Оглавление    7 Задержка инициализации с помощью std::optional....................................................... 127 И снова variant............................................................................................................................... 131 Бесконечное число альтернатив с std::any......................................................................... 132 std::any и полиморфные типы............................................................................................................. 134

Коротко о стирании типа........................................................................................................... 135

std::any и копирование...........................................................................................................................137

И снова о стирании типов: std::function.............................................................................. 138 std::function, копирование и размещение в динамической памяти................................... 140

Итоги.................................................................................................................................................. 141 Глава 6. Умные указатели................................................................................................. 142 История появления умных указателей................................................................................ 142 Умные указатели никогда ничего не забывают................................................................ 143 Автоматическое управление памятью с std::unique_ptr........................................ 144

Почему в C++ нет ключевого слова finally.....................................................................................147 Настройка обратного вызова удаления......................................................................................... 148 Управление массивами с помощью std::unique_ptr........................................................ 149

Подсчет ссылок с std::shared_ptr..................................................................................... 150

Не допускайте двойного управления! .......................................................................................... 153 Удерживание обнуляемых дескрипторов с помощью weak_ptr.......................................... 153 Сообщение информации о себе с std::enable_shared_from_this.......................................... 156 Странно рекурсивный шаблон проектирования........................................................................ 159 Заключительное замечание................................................................................................................ 160

Обозначение неисключительности с observer_ptr.................................................. 160 Итоги.................................................................................................................................................. 162 Глава 7. Конкуренция......................................................................................................... 163 Проблемы с volatile..................................................................................................................... 163 Использование std::atomic для безопасного доступа в многопоточной среде.............................................................................................................. 166 Атомарное выполнение сложных операций................................................................................ 168 Большие атомарные типы.................................................................................................................... 170

Поочередное выполнение с std::mutex............................................................................... 171

Правильный порядок «приобретения блокировок»................................................................. 173

Всегда связывайте мьютекс с управляемыми данными............................................... 176 Специальные типы мьютексов................................................................................................ 180 Повышение статуса блокировки для чтения/записи................................................................ 183 Понижение статуса блокировки для чтения/записи................................................................. 183

Ожидание условия....................................................................................................................... 184 Обещания о будущем.................................................................................................................. 187 Подготовка заданий для отложенного выполнения...................................................... 190 Будущее механизма future....................................................................................................... 192 Поговорим о потоках.................................................................................................................. 194 Идентификация отдельных потоков и текущего потока............................................... 196

8    Оглавление Исчерпание потоков и std::async........................................................................................... 198 Создание своего пула потоков................................................................................................ 200 Оптимизация производительности пула потоков........................................................... 204 Итоги.................................................................................................................................................. 206 Глава 8. Диспетчеры памяти............................................................................................ 208 Диспетчер памяти обслуживает ресурс памяти............................................................... 209 Еще раз об интерфейсах и понятиях.............................................................................................. 210

Определение кучи с помощью memory_resource........................................................... 212 Использование стандартных ресурсов памяти................................................................ 215

Выделение из ресурса пулов...............................................................................................................217

500-головый стандартный диспетчер памяти.................................................................. 218

Метаданные, сопровождающие причудливые указатели....................................................... 222

Прикрепление контейнера к единственному ресурсу памяти................................... 227 Использование диспетчеров памяти стандартных типов............................................ 229 Настройка ресурса памяти по умолчанию.................................................................................... 230

Создание контейнера с поддержкой выбора диспетчера памяти........................... 231 Передача вниз с scoped_allocator_adaptor........................................................................ 237 Передача разных диспетчеров памяти.......................................................................................... 240

Итоги.................................................................................................................................................. 242 Глава 9 Потоки ввода/вывода ........................................................................................ 244 Проблемы ввода/вывода в C++.............................................................................................. 244 Буферизация и форматирование........................................................................................... 246 POSIX API.......................................................................................................................................... 247 Стандартный C API........................................................................................................................ 250 Буферизация в стандартном C API................................................................................................... 252 Форматирование с помощью printf и snprintf.............................................................................257

Классическая иерархия потоков ввода/вывода.............................................................. 260

Потоки данных и манипуляторы....................................................................................................... 264 Потоки данных и обертки.....................................................................................................................267 Решение проблемы манипуляторов................................................................................................ 269 Форматирование с ostringstream..................................................................................................... 270 Примечание о региональных настройках..................................................................................... 271

Преобразование чисел в строки............................................................................................ 273 Преобразование строк в числа............................................................................................... 275 Чтение по одной строке или по одному слову................................................................. 279 Итоги.................................................................................................................................................. 281 Глава 10. Регулярные выражения................................................................................... 282 Что такое регулярное выражение?....................................................................................... 283 Замечание об экранировании обратными слешами................................................................ 284

Воплощение регулярных выражений в объектах std::regex....................................... 286 Сопоставление и поиск.............................................................................................................. 287

Оглавление    9 Извлечение совпадений с подвыражениями.............................................................................. 288 Преобразование совпадений в значения данных..................................................................... 292

Итерации по нескольким совпадениям.............................................................................. 293 Использование регулярных выражений для замены строк....................................... 297 Грамматика регулярных выражений ECMAScript............................................................ 299 Непоглощающие конструкции........................................................................................................... 302

Малопонятные особенности и ловушки ECMAScript..................................................... 303 Итоги.................................................................................................................................................. 305 Глава 11. Случайные числа............................................................................................... 306 Случайные и псевдослучайные числа.................................................................................. 306 Проблема функции rand()......................................................................................................... 308 Решение проблем с ................................................................................................ 310 Генераторы...................................................................................................................................... 310 Истинно случайные биты и std::random_device.......................................................................... 311 Псевдослучайные биты с std::mt19937.......................................................................................... 311 Фильтрация вывода генераторов с помощью адаптеров....................................................... 313

Распределения............................................................................................................................... 316 Имитация броска игровой кости с uniform_int_distribution.................................................. 316 Генерирование выборок с normal_distribution........................................................................... 318 Взвешенный выбор с discrete_distribution................................................................................... 319 Перемешивание карт с std::shuffle................................................................................................... 320

Итоги.................................................................................................................................................. 321 Глава 12. Файловая система............................................................................................. 323 Примечание о пространствах имен...................................................................................... 323 Очень длинное примечание об уведомлениях об ошибках....................................... 325

Использование ...........................................................................................................327 Коды ошибок и условия ошибок....................................................................................................... 331 Возбуждение ошибок с std::system_error...................................................................................... 334

Файловые системы и пути........................................................................................................ 336

Представление путей в C++................................................................................................................. 338 Операции с путями................................................................................................................................. 340

Получение информации о файлах с directory_entry...................................................... 342 Обход каталогов с directory_iterator..................................................................................... 343 Рекурсивный обход каталогов........................................................................................................... 343

Изменение файловой системы............................................................................................... 344 Получение информации о диске........................................................................................... 345 Итоги.................................................................................................................................................. 346 Предметный указатель......................................................................................................347

Предисловие от разработчиков С++ в России и Беларуси Совсем немного языков могут похвастаться такой богатой и яркой историей, как язык С++. Уже несколько десятилетий С++ является де-факто стандартом для реализации такого сложнейшего программного обеспечения, как операционные системы, базы данных, компиляторы, веб-браузеры, системы защиты данных и антивирусы, микроконтроллеры; военная промышленность, космос, высоконагруженные системы и множество других доменов немыслимы без использования этой технологии. Огромные базы исходных кодов, созданные за эти годы, требуют поддержки и развития, что немыслимо без стандартизации. Закон иерархических компенсаций профессора Седова в формулировке профессора Назаретяна гласит, что многообразие на верхнем уровне иерархии систем возможно только при условии стандартизации нижележащих уровней иерархии. У каждого на слуху интернет вещей, искусственный интеллект, обработка больших данных, умные автомобили, включая беспилотные,  – эти области и являются вершиной ИТ-иерархии сегодня. Ядро большинства перечисленных трендовых направлений разрабатывается с использованием языка С++, а значит, согласно закону Седова, стандартизация С++ является необходимым условием для самого существования современных ИТ-трендов. Успехи ИТ-сферы сегодняшнего дня обусловлены в том числе преодолением «кризиса» стандартизации языка в период от С++98/03 к С++11. Сейчас комитет стандартизации выпускает новые версии языка в среднем раз в три года – С++11, 14, 17. Ведется огромная исследовательская работа ведущих ученых и программистов мира по подготовке нового стандарта С++20. Такая регулярность является замечательным показателем того, что ниша языка неуклонно растет! Освоение последних стандартов – необходимое условие для успешного профессионального роста и самореализации в рамках совре­менных ИТ-векторов. Исторически наша страна обладает огромной научно-исследовательской базой, основанной на традициях быстрого изучения новых, только зарождающихся дисциплин. Сейчас у отечественных специалистов появился уникальный шанс значительно увеличить присутствие в нише С++-разработки. Новые стандарты – это новые возможности для самореализации и рывка вперед оте­чественной ИТ-отрасли. Изучение С++ – крайне трудоемкий процесс, который проходит гораздо легче и продуктивнее при общении специалистов друг с другом. Профессиональные группы и сообщества – неотъемлемый атрибут этого процесса. Автор книги Артур О’Двайр, являясь активным участником комитета стандартизации ISO C++, одновременно известен организацией встреч пользователей С++ в Заливе Сан-Франциско. Также и в нашей стране во многих крупных городах проходят регулярные встречи местных С++-сообществ, на которых есть возможность обсудить с коллегами нюансы новых стандартов, выступить с докладом, завязать профессиональные контакты и даже поучаствовать в стандартизации языка. Давайте же читать книги, осваивать новые горизонты языка С++, общаться, развиваться и расти профессионально вместе, двигая ИТ-индустрию вперед! Антон Наумович и Антон Семенченко Cообщество C++-разработчиков CoreHard cpp-russia.ru, stdcpp.ru, corehard.by

Об авторе Артур О’Двайр (Arthur O’Dwyer) использует современный язык C++ в своей повседневной работе около десяти лет – еще с тех пор, когда под «современным C++» подразумевался «классический C++». С 2006 по 2011 год он принимал участие в работе над компилятором Green Hills C++ Compiler. Начиная с  2014  г. организовывал еженедельные встречи пользователей C++ в Заливе Сан-Франциско и регулярно выступает, освещая темы, которые можно найти в этой книге. В 2018 году он во второй раз присутствовал на заседании комитета ISO C++. Это его первая книга.

О научном редакторе Уилл Бреннан (Will Brennan) – разработчик программ на C++/Python. Живет в Лондоне и имеет богатый опыт высокопроизводительных приложений обработки изображений и машинного обучения. Вы можете найти его на GitHub: https://github.com/WillBrennan.

Предисловие Язык программирования C++ имеет долгую историю, уходящую корнями в 1980-е. Недавно он пережил возрождение благодаря появлению новых возможностей в стандартах 2011 и 2014 годов. Пока книга готовилась к печати, вышел новый стандарт C++17. Стандарт C++11 практически удвоил объем стандартной библиотеки, добавив такие заголовки, как , и . Стандарт C++17 снова удвоил объем библиотеки, добавив новые заголовки, такие как , и . Программист, много времени тративший на разработку кода и не следивший за процессом стандартизации, вполне мог почувствовать себя отставшим от жизни – в стандартной библиотеке появилось так много нового, что он никогда не смог бы овладеть всеми нововведениями в одиночку или хотя бы отделить зерна от плевел. В конце концов, кому захочется провести месяц, читая техническую документацию о std::locale и std::ratio, только чтобы узнать, что в них нет ничего, что могло бы пригодиться ему в повседневной работе? В этой книге я расскажу вам о наиболее важных особенностях стандартной библиотеки C++17. Для краткости я опущу некоторые разделы, такие как вышеупомянутый ; но мы рассмотрим всю современную стандартную библиотеку шаблонов STL (каждый стандартный контейнер и каждый стандартный алгоритм), плюс такие важные темы, как умные указатели, случайные числа, регулярные выражения и новую для C++17 библиотеку . Я буду знакомить вас с новинками на примерах. Вы научитесь создавать свои типы итераторов; свои диспетчеры памяти на основе std::pmr::memory_ resource; свои пулы потоков выполнения с использованием std::future. Я расскажу об идеях, которые вы не найдете в справочных руководствах. Вы узнаете, чем отличаются мономорфизм, полиморфизм и обобщенные алгоритмы (глава 1 «Классический полиморфизм и обобщенное программирование»); что означает для std::string или std::any быть «словарным типом» (глава 5 «Словарные типы») и чего можно ожидать от грядущего стандарта C++20 и потом. Я предполагаю, что вы уже достаточно хорошо знакомы с основами языка C++11; например, что вы уже понимаете, как писать шаблонные классы и функции, знаете, чем отличаются ссылки lvalue и rvalue, и т. д.

Содержание книги Глава 1 «Классический полиморфизм и обобщенное программирование» охватывает классический полиморфизм (виртуальные функции-члены) и обобщенное программирование (шаблоны).

Предисловие    13 Глава 2 «Итераторы и диапазоны» объясняет идею представления итератора как обобщенного указателя и практическую пользу полуоткрытых диапазонов, выраженных в виде пары итераторов. Глава 3 «Алгоритмы с парами итераторов» исследует широкое разнообразие обобщенных алгоритмов, оперирующих диапазонами, выраженными в виде пар итераторов. Глава 4 «Зоопарк контейнеров» исследует не менее широкое разнообразие стандартных шаблонных классов контейнеров и рассказывает, какие контейнеры лучше подходят для тех или иных задач. Глава 5 «Словарные типы» проведет вас через царство алгебраических типов, таких как std::optional, и ABI-совместимые стираемые типы, такие как std::function. Глава 6 «Умные указатели» рассказывает о назначении и особенностях использования умных указателей. Глава 7 «Конкуренция» охватывает атомы, мьютексы, условные переменные, потоки выполнения, объекты future и promise. Глава 8 «Диспетчеры памяти» описывает новый заголовок , появившийся в C++17. Глава 9 «Потоки ввода/вывода» исследует развитие модели ввода/вывода в C++, от до и . Глава 10 «Регулярные выражения» научит вас пользоваться регулярными выражениями в C++. Глава 11 «Случайные числа» описывает поддержку генераторов псевдослучайных чисел в C++. Глава 12 «Файловая система» охватывает новую библиотеку , появившуюся в C++17.

Что потребуется для работы с книгой Поскольку эта книга не является справочным руководством, вам может пригодиться такое руководство, как cppreference (en.cppreference.com/w/cpp), где вы сможете прояснить любые детали. Вам также определенно понадобится компилятор, поддерживающий стандарт C++17. На момент подготовки книги к печати существовало несколько компиляторов с более или менее полноценной поддержкой C++17, включая GCC, Clang и Microsoft Visual Studio. Вы можете установить их у себя локально или воспользоваться многочисленными бесплатными онлайн-службами, такими как Wandbox (wandbox.org), Godbolt (gcc. godbolt.org) и Rextester (rextester.com).

Кому адресована эта книга Эта книга адресована разработчикам, желающим овладеть новыми особенностями библиотеки C++17 STL и в полной мере использовать ее компоненты. Знакомство с языком C++ является обязательным условием.

14    Предисловие

Типографские соглашения В этой книге используется несколько разных стилей оформления текста с целью обеспечить визуальное отличие информации разных типов. Ниже приводится несколько примеров таких стилей оформления и краткое описание их назначения. Программный код в тексте, имена таблиц баз данных, имена папок, имена файлов, расширения файлов, пути в файловой системе, адреса URL, ввод пользователя и ссылки в Twitter оформляются, как показано в следующем предложении: «Функция buffer() принимает аргументы типа int». Блоки программного кода оформляются так: try { none.get(); } catch (const std::future_error& ex) { assert(ex.code() == std::future_errc::broken_promise); }

Новые термины и важные определения будут выделяться в обычном тексте жирным.

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

Отзывы и пожелания Мы всегда рады отзывам наших читателей. Расскажите нам, что вы думаете об этой книге – что понравилось или, может быть, не понравилось. Отзывы важны для нас, чтобы выпускать книги, которые будут для вас максимально полезны. Вы можете написать отзыв прямо на нашем сайте www.dmkpress.com, зайдя на страницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также можно послать письмо главному редактору по адресу [email protected], при этом напишите название книги в теме письма. Если есть тема, в которой вы квалифицированы, и вы заинтересованы в написании новой книги, заполните форму на нашем сайте по адресу http:// dmkpress.com/authors/publish_book/ или напишите в издательство по адресу [email protected].

Предисловие    15

Скачивание исходного кода примеров Скачать файлы с дополнительной информацией для книг издательства «ДМК Пресс» можно на сайте www.dmkpress.com или www.дмк.рф в разделе «Читателям – Файлы к книгам». Кроме того, примеры кода к книге доступны на сайте GitHub, по адресу: https://github.com/PacktPublishing/ Mastering-the-Cpp17-STL.

Список опечаток Хотя мы приняли все возможные меры для того, чтобы удостовериться в качестве наших текстов, ошибки всё равно случаются. Если вы найдёте ошибку в одной из наших книг – возможно, ошибку в тексте или в коде, – мы будем очень благодарны, если вы сообщите нам о ней. Сделав это, вы избавите других читателей от расстройств и поможете нам улучшить последующие версии данной книги. Если вы найдёте какие-либо ошибки в коде, пожалуйста, сообщите о них главному редактору по адресу [email protected], и мы исправим это в следующих тиражах.

Нарушение авторских прав Пиратство в Интернете по-прежнему остается насущной проблемой. Издательства «ДМК Пресс» и Packt очень серьезно относятся к вопросам защиты авторских прав и лицензирования. Если вы столкнетесь в Интернете с незаконно выполненной копией любой нашей книги, пожалуйста, сообщите нам адрес копии или веб-сайта, чтобы мы могли принять меры. Пожалуйста, свяжитесь с нами по адресу электронной почты dmkpress@gmail. com со ссылкой на подозрительные материалы. Мы высоко ценим любую помощь по защите наших авторов, помогающую нам предоставлять вам качественные материалы.

Глава

1

Классический полиморфизм и обобщенное программирование Стандартная библиотека C++ преследует две разные, но одинаково важные цели. Первая цель – предоставить надежные реализации некоторых конкретных типов данных или функций, которые могут пригодиться в разных программах, но не являются частью базового синтаксиса языка. Именно поэтому стандартная библиотека включает std::string, std::regex, std::filesystem::exists и т. д. Другая цель – предоставить надежные реализации широко используемых абстрактных алгоритмов сортировки, поиска, обращения, сравнения и т. д. В этой главе мы узнаем, что подразумевается под словами «абстрактный код», и рассмотрим два подхода к определению абстрактного кода, которые используются в стандартной библиотеке: классический полиморфизм и обобщенное программирование. В этой главе рассматриваются следующие темы:  конкретные (мономорфные) функции, поведение которых не парамет­ ризуется;  классический полиморфизм: базовые классы, виртуальные функциичлены и наследование;  обобщенное программирование: концепции, требования и модели;  практические достоинства и недостатки каждого из подходов.

Конкретные мономорфные функции Что отличает абстрактный алгоритм от конкретной функции? Ответить на этот вопрос нам поможет пример. Напишем функцию, умножающую каждый элемент массива на 2: class array_of_ints { int data[10] = {};

Классические полиморфные функции    17 public: int size() const { return 10; } int& at(int i) { return data[i]; } }; void double_each_element(array_of_ints& arr) { for (int i=0; i < arr.size(); ++i) { arr.at(i) *= 2; } }

Наша функция double_each_element работает только с объектами типа array_of_int; попытка передать объект другого типа не увенчается успехом (код просто не будет компилироваться). Функции, такие как наша double_ each_element, называют конкретными, или мономорфными. Мы называем их

конкретными, потому что они недостаточно абстрактны для наших целей. Просто представьте, насколько неудобно было бы, если бы стандартная библиотека C++ предоставляла конкретную процедуру sort, работающую только с определенным типом данных!

Классические полиморфные функции Мы можем повысить уровень абстракции наших алгоритмов, применив приемы классического объектно-ориентированного (ОО) программирования, широко используемые в таких языках, как Java и C#. Суть подхода ОО состоит в том, чтобы решить, какие варианты поведения должны быть настраиваемыми, и затем объявить их в виде общедоступных виртуальных функций-членов абстрактного базового класса: class container_of_ints { public: virtual int size() const = 0; virtual int& at(int) = 0; }; class array_of_ints : public container_of_ints { int data[10] = {}; public: int size() const override { return 10; } int& at(int i) override { return data[i]; } }; class list_of_ints : public container_of_ints { struct node { int data; node *next; };

18    Глава 1. Классический полиморфизм и обобщенное программирование node *head_ = nullptr; int size_ = 0; public: int size() const override { return size_; } int& at(int i) override { if (i >= size_) throw std::out_of_range("at"); node *p = head_; for (int j=0; j < i; ++j) { p = p->next; } return p->data; } ~list_of_ints(); }; void double_each_element(container_of_ints& arr) { for (int i=0; i < arr.size(); ++i) { arr.at(i) *= 2; } } void test() { array_of_ints arr; double_each_element(arr); list_of_ints lst; double_each_element(lst); }

Два разных вызова double_each_element в test успешно компилируются, потому что с точки зрения классического ОО array_of_ints ЯВЛЯЕТСЯ container_of_ints (то есть наследует container_of_ints и реализует соответствующие виртуальные функции-члены) и list_of_ints также ЯВЛЯЕТСЯ container_of_ints. Однако поведение любого данного объекта container_ of_ints параметризуется его динамическим типом; то есть определяется таб­ лицей указателей на функции, связанной с конкретным объектом. Поскольку теперь поведение функции double_each_element можно парамет­ ризовать, не меняя ее исходный код, а просто передавая ей объекты разных динамических типов, мы говорим, что функция является полиморфной. Но такая полиморфная функция может работать только с типами, которые наследуют базовый класс container_of_ints. Например, у вас не получится передать этой функции вектор std::vector; при попытке сделать это вы получите ошибку компиляции. Классический полиморфизм – удобный прием, но не дает нам полной обобщенности. Преимущество классического (объектно-ориентированного) полиморфизма в том, что исходный код все еще однозначно соответствует машинному

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

Обобщенное программирование с шаблонами В современном C++ полностью обобщенные алгоритмы обычно записываются в виде шаблонов. Мы все еще должны реализовать шаблонную функцию в терминах общедоступных функций-членов .size() и .at(), но при этом отпадает требование к принадлежности аргумента arr определенному типу. Поскольку наша новая функция будет шаблонной, мы должны сообщить компилятору, что нас не интересует конкретный тип arr, что для каждого нового типа arr он должен сгенерировать совершенно новую функцию (то есть создать экземпляр шаблона) с параметром этого типа. template void double_each_element(ContainerModel& arr) { for (int i=0; i < arr.size(); ++i) { arr.at(i) *= 2; } } void test() { array_of_ints arr; double_each_element(arr); list_of_ints lst; double_each_element(lst); std::vector vec = {1, 2, 3}; double_each_element(vec); }

В большинстве случаев возможность точно выразить словами, какие операции должны поддерживаться шаблонным параметром типа ContainerModel, помогает создавать более совершенные программы. Такой набор операций определяет то, что в C++ называют концепцией; в этом примере мы можем сказать, что концепция Container заключается в «наличии функции-члена с именем size, которая возвращает размер контейнера в виде значения типа int (или другого, сравнимого с int); и в наличии функции-члена с именем at, которая принимает индекс типа int (или другого, который неявно может преобразовываться в тип int) и возвращает неконстантную ссылку на элемент в контейнере с этим индексом». Если некоторый класс array_of_ints

20    Глава 1. Классический полиморфизм и обобщенное программирование поддерживает операции, требуемые концепцией Container, так что объекты этого класса могут передаваться в вызов double_each_element, мы говорим, что конкретный класс array_of_ints является моделью концепции Container. Именно поэтому я дал имя ContainerModel параметру типа шаблона в предыдущем примере.

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

Реализация абстрактного алгоритма с использованием шаблонов, когда поведение алгоритма параметризуется на этапе компиляции типами, моделирующими соответствующие концепции, называется обобщенным программированием. Обратите внимание, что в нашем описании концепции Container нигде не говорится о том, что элементы контейнера должны иметь тип int; и не случайно мы теперь можем использовать нашу обобщенную функцию double_each_ element даже с контейнерами, содержащими значения других типов, отличных от int! std::vector vecd = {1.0, 2.0, 3.0}; double_each_element(vecd);

Этот дополнительный уровень обобщенности является одним из важнейших достоинств шаблонов C++ в обобщенном программировании, в отличие от классического полиморфизма. Классический полиморфизм скрывает разность поведений разных классов за неизменной сигнатурой (например, .at(i) всегда возвращает int&), но, как только появляется необходимость использовать разные сигнатуры, классический полиморфизм перестает быть хорошим инструментом для этой работы. Другое достоинство обобщенного программирования – высокая скорость благодаря расширенным возможностям встраивания. Пример, реализованный в стиле классического полиморфизма, вынужден снова и снова обращаться к таблице виртуальных методов объекта container_of_int, чтобы получить адрес конкретного виртуального метода at, и, как правило, лишен возможности миновать этот поиск на этапе компиляции. Шаблонная функция double_each_ element, напротив, может скомпилировать непосредственный вызов array_of_int::at или даже встроить тело этой функции в код.

Обобщенное программирование с шаблонами    21 Поскольку обобщенное программирование с шаблонами легко справляется со сложными требованиями и обеспечивает гибкую поддержку разных типов – даже таких простых, как int, где классический полиморфизм терпит неудачу, – стандартная библиотека использует шаблоны для всех алгоритмов в ней и контейнеров, которыми эти алгоритмы оперируют. По этой причине часть стандартной библиотеки, имеющей отношение к алгоритмам и контейнерам, часто называют стандартной библиотекой шаблонов (Standard Template Library, STL). Все верно – технически STL является лишь малой частью стандартной библиотеки C++! Но в этой книге, так же как в реаль­ной жизни, мы можем иногда вольно использовать термин STL, подразумевая всю стандартную библиотеку, и наоборот.

Рассмотрим еще пару своих обобщенных алгоритмов, прежде чем углубиться в стандартные обобщенные алгоритмы, реализованные в STL. Вот шаблонная функция count, возвращающая общее количество элементов в контейнере: template int count(const Container& container) { int sum = 0; for (auto&& elt : container) { sum += 1; } return sum; }

А вот функция count_if, возвращающая количество элементов, удовлетворяющих пользовательской функции-предикату: template int count_if(const Container& container, Predicate pred) { int sum = 0; for (auto&& elt : container) { if (pred(elt)) { sum += 1; } } return sum; }

Эти функции можно использовать, как показано ниже:

22    Глава 1. Классический полиморфизм и обобщенное программирование std::vector v = {3, 1, 4, 1, 5, 9, 2, 6}; assert(count(v) == 8); int number_above = count_if(v, [](int e) { return e > 5; }); int number_below = count_if(v, [](int e) { return e < 5; }); assert(number_above == 2); assert(number_below == 5);

Как много заключено в этом маленьком выражении pred(elt)! Попробуйте реализовать функцию count_if в терминах классического полиморфизма, просто чтобы понять, насколько быстро все начнет разваливаться. Под синтаксическим сахаром современного C++ скрыто огромное количество сигнатур. Например, синтаксис цикла for с диапазонами в нашей функции count_if преобразуется (или упрощается) компилятором в цикл for, действующий в терминах методов container.begin() и container.end(), каждый из которых должен возвращать итератор с типом, зависящим от типа контейнера. Другой пример: в обобщенной версии мы нигде не указываем – и нам нигде не нужно указывать, – будет ли pred принимать свой параметр elt по значению или по ссылке. Попробуйте реализовать то же самое в virtual bool operator()! В продолжение темы итераторов: возможно, вы заметили, что все наши функции в этой главе (мономорфные, полиморфные или обобщенные) выражены в терминах контейнеров. В своей версии count мы подсчитывали элементы во всем контейнере. В count_if мы подсчитывали элементы во всем контейнере, соответствующие заданному условию. Как оказывается, это очень распространенный способ записи алгоритмов, особенно в современном C++; поэтому многое из того, что мы ожидаем увидеть в алгоритмах работы с контейнерами (или в их близких родственниках – алгоритмах работы с диапазонами), перекочует в C++20 или C++23. Однако STL зародилась в далеких 1990-х, до появления современного C++. Поэтому авторы STL предполагали, что обработка контейнеров будет обходиться особенно дорого (из-за всех этих дорогостоящих вызовов конструкторов копий – напомню, что семантика перемещения и конструкторы перемещения появились только в C++11) и проектировали STL в основном для работы с легковесной концепцией – итераторами. Это станет темой нашей следующей главы.

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

Итоги    23 Классический полиморфизм решает проблему за счет определения базового класса с закрытым списком виртуальных функций-членов, и реализации полиморфных функций, принимающих указатели или ссылки на экземпляры конкретных классов, наследующих базовый класс. Обобщенное программирование решает ту же проблему за счет определения концепции с закрытым набором требований и создания экземпляров шаб­ лонных функций с конкретными классами, моделирующими эту концепцию. Классический полиморфизм испытывает проблемы с параметризацией высокого уровня (например, манипулирование объектами функций с любой сигнатурой) и с отношениями между типами (например, манипулирование элементами произвольных контейнеров). Поэтому в STL большое внимание уделяется обобщенным реализациям на основе шаблонов и почти полностью отсутствует классический полиморфизм. Применение приемов обобщенного программирования помогает справиться с проблемами, если учитываются концептуальные требования к типам; но компилятор C++, по крайней мере соответствующий стандарту C++17, пока не в состоянии напрямую помочь с проверкой этих требований.

Глава

2

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

Проблема целочисленных индексов В предыдущей главе мы реализовали несколько обобщенных алгоритмов, оперирующих контейнерами. Взглянем на них еще раз: template void double_each_element(Container& arr) { for (int i=0; i < arr.size(); ++i) { arr.at(i) *= 2; } }

Этот алгоритм определен в терминах низкоуровневых операций .size() и .at(). В целом такое решение хорошо работает с такими контейнерными типами, как array_of_ints или std::vector, но намного хуже, например, со связными списками, такими как list_of_ints из предыдущей главы: class list_of_ints { struct node { int data; node *next; };

За границами указателей    25 node *head_ = nullptr; node *tail_ = nullptr; int size_ = 0; public: int size() const { return size_; } int& at(int i) { if (i >= size_) throw std::out_of_range("at"); node *p = head_; for (int j=0; j < i; ++j) { p = p->next; } return p->data; } void push_back(int value) { node *new_tail = new node{value, nullptr}; if (tail_) { tail_->next = new_tail; } else { head_ = new_tail; } tail_ = new_tail; size_ += 1; } ~list_of_ints() { for (node *next, *p = head_; p != nullptr; p = next) { next = p->next; delete p; } } };

Реализация list_of_ints::at() имеет сложность O(n), зависящую от длины списка: чем длиннее список, тем медленнее работает at(). А когда наша функция count_if выполняет обход всех элементов списка, она n раз вызывает функцию at(), увеличивая сложность обобщенного алгоритма до O(n2), – и это для простой операции подсчета, сложность которой должна быть O(n)! Как оказывается, целочисленное индексирование с .at() является не лучшим фундаментом для возведения алгоритмических за́мков. Мы должны выбрать простую операцию, которая ближе к тому, как компьютеры обрабатывают данные на самом деле.

За границами указателей В отсутствие любых абстракций как обычно идентифицируются элементы массива, связного списка или дерева? Самый простой способ – использовать указатель с адресом элемента в памяти. На рис. 2.1 показано несколько примеров указателей на элементы разных структур данных.

26    Глава 2. Итераторы и диапазоны указатель на массив

указатель на связный список

указатель на двоичное дерево

Рис. 2.1. Указатели на элементы разных структур данных Чтобы выполнить обход элементов массива, нам достаточно простого указателя; мы можем обработать все элементы массива, начав с указателя на первый элемент и потом просто наращивая указатель, пока не будет достигнут последний элемент. В языке C: for (node *p = lst.head_; p != nullptr; p = p->next) { if (pred(p->data)) { sum += 1; } }

Но для эффективного обхода элементов связного списка простого указателя недостаточно; в результате инкремента указателя типа node* едва ли получится указатель на следующий узел в списке! В этом случае нужно нечто, действующее как указатель, – в частности, нужна возможность разыменовать это нечто, чтобы получить или изменить элемент списка, – но имеющее особое поведение, характерное для контейнера, связанное с абстрактной концепцией инкрементирования. В C++, учитывая встроенную поддержку перегрузки операторов, когда я говорю: «особое поведение, связанное с абстрактной концепцией инкрементирования», – вы должны подумать: «перегружающее оператор ++». Так мы и поступим: struct list_node { int data; list_node *next;

За границами указателей    27 }; class list_of_ints_iterator { list_node *ptr_; friend class list_of_ints; explicit list_of_ints_iterator(list_node *p) : ptr_(p) {} public: int& operator*() const { return ptr_->data; } list_of_ints_iterator& operator++() { ptr_ = ptr_->next; return *this; } list_of_ints_iterator operator++(int) { auto it = *this; ++*this; return it; } bool operator==(const list_of_ints_iterator& rhs) const { return ptr_ == rhs.ptr_; } bool operator!=(const list_of_ints_iterator& rhs) const { return ptr_ != rhs.ptr_; } }; class list_of_ints list_node *head_ list_node *tail_ // ... public: using iterator = iterator begin() iterator end() { };

{ = nullptr; = nullptr;

list_of_ints_iterator; { return iterator{head_}; } return iterator{nullptr}; }

template int count_if(Container& ctr, Predicate pred) { int sum = 0; for (auto it = ctr.begin(); it != ctr.end(); ++it) { if (pred(*it)) { sum += 1; } } return sum; }

Обратите внимание, что мы также перегрузили унарный оператор * (для разыменования) и операторы == и !=; наша шаблонная реализация count_if требует, чтобы все эти операции можно было использовать для управления переменной цикла it. (Технически наша функция count_if не требует операции ==, но если вы решили перегрузить один оператор сравнения, вы должны перегрузить и второй.)

28    Глава 2. Итераторы и диапазоны

Константные итераторы Есть еще одно осложнение, которое нужно рассмотреть, прежде чем отказаться от этого примера итератора списка. Обратите внимание, что я втихомолку изменил нашу шаблонную функцию count_if, и теперь она принимает Container& вместо const Container&! Это потребовалось потому, что функции-члены begin() и end() являются неконстантными; а это, в свою очередь, объясняется тем, что они возвращают итераторы, оператор operator* которых возвращает неконстантные ссылки на элементы списка. Нам бы хотелось, чтобы наш тип списка (и его итераторы) имел полноценную константную корректность (const-correct) – то есть чтобы была возможность определять и использовать переменные типа const list_of_ints и предотвращать возможность изменять элементы константного списка. В стандартной библиотеке эта проблема решается реализацией для каждого стандартного контейнера двух видов итераторов: bag::iterator и bag::const_ iterator. Неконстантная функция-член bag::begin() возвращает iterator, а  функция-член bag::begin() const возвращает const_iterator. Символ подчеркивания важен здесь! Обратите внимание, что bag::begin() const не возвращает простой const iterator; если бы возвращаемый ею итератор был константным, мы не смогли бы применить к нему оператор ++. (Что, в свою очередь, затруднило бы итерации по элементам const bag!) В действитель­ ности bag::begin() const возвращает нечто более затейливое: неконстантный объект const_iterator, оператор operator* которого просто возвращает конс­тантную ссылку на его элемент. Возможно, пример поможет лучше понять эту идею. Давайте реализуем const_iterator для нашего контейнера list_of_ints. Поскольку большая часть кода для типа const_iterator будет совпадать с кодом для типа iterator, первым инстинктивным желанием может стать стремление скопировать одинаковые блоки кода. Но это же C++! Когда я говорю «большая часть кода для одного типа будет совпадать с кодом для другого типа», вы должны сразу смекнуть: «а давайте заключим общие части в шаблон». Вот как мы поступим на самом деле: struct list_node { int data; list_node *next; }; template class list_of_ints_iterator { friend class list_of_ints; friend class list_of_ints_iterator; using node_pointer = std::conditional_t; using reference = std::conditional_t;

Пара итераторов определяет диапазон    29 node_pointer ptr_; explicit list_of_ints_iterator(node_pointer p) : ptr_(p) {} public: reference operator*() const { return ptr_->data; } auto& operator++() { ptr_ = ptr_->next; return *this; } auto operator++(int) { auto result = *this; ++*this; return result; } // Поддержка сравнения типов iterator и const_iterator template bool operator==(const list_of_ints_iterator& rhs) const { return ptr_ == rhs.ptr_; } template bool operator!=(const list_of_ints_iterator& rhs) const { return ptr_ != rhs.ptr_; } // Поддержка неявного преобразования iterator в const_iterator // (но не наоборот) operator list_of_ints_iterator() const { return list_of_ints_iterator{ptr_}; } }; class list_of_ints { list_node *head_ = nullptr; list_node *tail_ = nullptr; // ... public: using const_iterator = list_of_ints_iterator; using iterator = list_of_ints_iterator; iterator begin() { return iterator{head_}; } iterator end() { return iterator{nullptr}; } const_iterator begin() const { return const_iterator{head_}; } const_iterator end() const { return const_iterator{nullptr}; } };

Предыдущий код реализует полноценную константную корректность типов итераторов для нашего контейнера list_of_ints.

Пара итераторов определяет диапазон Теперь, разобравшись с фундаментальным понятием итератора, применим его на практике. Мы уже знаем, что если есть пара итераторов, как те, что возвращаются функциями-членами begin() и end(), можно воспользоваться циклом for для обхода всех элементов, находящихся в контейнере. Но самое интересное, что пары итераторов можно использовать для обхода любого поддиапазона элементов! Допустим, мы решили просмотреть первую половину вектора:

30    Глава 2. Итераторы и диапазоны template void double_each_element(Iterator begin, Iterator end) { for (auto it = begin; it != end; ++it) { *it *= 2; } } int main() { std::vector v {1, 2, 3, 4, 5, 6}; double_each_element(v.begin(), v.end()); // удвоить все элементы в векторе double_each_element(v.begin(), v.begin()+3); // удвоить элементы в первой половине вектора double_each_element(&v[0], &v[3]); // еще раз удвоить элементы в первой половине вектора }

Обратите внимание, что в первый и во второй вызовы double_each_element в функции main() мы передаем пару итераторов, порожденных из v.begin(); то есть два значения типа std::vector::iterator. В третий вызов мы передаем два значения типа int*. Так как в этом случае int* удовлетворяет всем требованиям типа итератора – а именно: поддерживает операции инкремента, сравнения и разыменования, – наш код прекрасно работает даже с указателями! Этот подход наглядно демонстрирует гибкость модели пар итераторов. (Но, вообще, желательно избегать простых указателей, если контейнер, такой как std::vector, предлагает полноценный тип iterator. Используйте итераторы, полученные из begin() и end().) Можно сказать, что пара итераторов неявно определяет диапазон элементов данных. И это верно для удивительно большого семейства алгоритмов! Нам не нужен доступ к контейнеру, чтобы выполнить некоторый поиск или преобразование; нам нужен доступ лишь к определенному диапазону элементов, где требуется произвести поиск или применить преобразование. Продолжая эту мысль, мы неизбежно приходим к идее представления без владения (nonowning view), которое относится к последовательности данных так же, как ссылка C++ к единственной переменной. Но представления и диапазоны – это более современные понятия, и прежде чем говорить о них, мы должны покончить с винтажной библиотекой STL образца 1998 года. В предыдущем примере мы увидели первый настоящий обобщенный алгоритм в стиле STL. Вообще говоря, double_each_element не является особенно обобщенным алгоритмом в смысле реализации поведения, которое можно использовать в других программах; но эта версия функции теперь достаточно универсальна в смысле работы с парами итераторов, где итератор может быть любого типа, реализующего операции инкремента, сравнения и разыменования. (Еще более обобщенную версию этого алгоритма мы увидим в следующей главе, когда будем говорить о std::transform.)

Категории итераторов    31

Категории итераторов А теперь вернемся к функциям count и count_if из главы 1 «Классический полиморфизм и обобщенное программирование». Сравните функции из главы 1 с определением шаблона в следующем примере; вы увидите, что они идентичны, за исключением подстановки пары итераторов (неявно определяющей диа­пазон) вместо параметра Container&, а также за исключением смены имени первой функции с count на distance. Я изменил имя потому, что эту функцию, почти в том же виде, как она определена здесь, можно найти в стандартной библиотеке шаблонов под именем std::distance и там же можно найти вторую функцию под именем std::count_if: template int distance(Iterator begin, Iterator end) { int sum = 0; for (auto it = begin; it != end; ++it) { sum += 1; } return sum; } template int count_if(Iterator begin, Iterator end, Predicate pred) { int sum = 0; for (auto it = begin; it != end; ++it) { if (pred(*it)) { sum += 1; } } return sum; } void test() { std::vector v = {3, 1, 4, 1, 5, 9, 2, 6}; int number_above = count_if(v.begin(), v.end(), [](int e) { return e > 5; }); int number_below = count_if(v.begin(), v.end(), [](int e) { return e < 5; }); int total = distance(v.begin(), v.end()); // НЕОДНОЗНАЧНОЕ РЕШЕНИЕ assert(number_above == 2); assert(number_below == 5); assert(total == 8); }

Рассмотрим строку в этом примере, отмеченную комментарием НЕОДНОРЕШЕНИЕ. Она вычисляет расстояние между двумя итераторами, в

ЗНАЧНОЕ

32    Глава 2. Итераторы и диапазоны цикле наращивая первый, пока не будет достигнут второй. Насколько эффективным является это решение? Для некоторых видов итераторов – например, list_of_ints::iterator – у нас нет лучшего решения, чем это. Но для vector::iterator или int*, поддерживающего возможность итераций через непрерывные массивы данных, было бы глупо использовать цикл и алгоритм со сложностью O(n), когда тот же результат можно получить за время O(1) прос­ тым вычитанием указателей. То есть было бы неплохо иметь в стандартной библиотеке версию std::distance для включения шаблонной специализации, как показано ниже: template int distance(Iterator begin, Iterator end) { int sum = 0; for (auto it = begin; it != end; ++it) { sum += 1; } return sum; } template int distance(int *begin, int *end) { return end - begin; }

Но нам нужно, чтобы специализация поддерживалась не только для int* и std::vector::iterator. Нам нужно, чтобы стандартная библиотечная функция std::distance действовала эффективно с любыми типами итераторов,

поддерживающими определенные операции. То есть мы начинаем понимать, что есть (по крайней мере) два разных вида итераторов: поддерживающие инкремент, сравнение и разыменование и поддерживающие инкремент, сравнение, разыменование, а также вычитание! Как оказывается, для любого типа итераторов, где имеет смысл операция i = p – q, имеет смысл и обратная операция q = p + i. Итераторы, поддерживающие вычитание и сложение, называют итераторами с произвольным доступом (random-access iterators). То есть стандартная библиотечная функция std::distance должна быть эффективной и для итераторов с произвольным доступом, и для других видов итераторов. Чтобы упростить частичную специализацию в подобных шаблонах, стандартная библиотека вводит идею иерархии видов итераторов. Итераторы, такие как int*, которые поддерживают сложение и вычитание, известны как итераторы с произвольным доступом. Мы говорим, что они удовлетворяют концепции RandomAccessIterator. Итераторы, менее гибкие, чем итераторы с произвольным доступом, могут не поддерживать сложение или вычитание произвольных расстояний, но, по меньшей мере, поддерживают операции инкремента и декремента в виде ++p и --p. Итераторы этого вида называют двунаправленными: BidirectionalIterator.

Итераторы ввода и вывода    33 Все итераторы с произвольным доступом одновременно являются двунаправленными итераторами, но обратное утверждение верно не всегда. В некотором смысле можно представить RandomAccessIterator как подкласс или подчиненную концепцию по отношению к BidirectionalIterator; и мы можем сказать, что BidirectionalIterator является более свободной концепцией, предъявляющей меньше требований, чем RandomAccessIterator. Еще более свободная концепция – вид итераторов, не поддерживающих операцию декремента. Например, наш тип list_of_ints::iterator не поддерживает декремент, потому что наш связный список не имеет указателей на предыдущий элемент; мы можем двигаться только вперед, от первого элемента к последнему, и никогда – назад. Итераторы, поддерживающие ++p, но не --p, называют однонаправленными, или прямыми, итераторами ForwardIterator. ForwardIterator – еще более свободная концепция, чем BidirectionalIterator.

Итераторы ввода и вывода Мы можем вообразить еще более свободную концепцию, чем ForwardIterator! Например, одной из полезных операций с ForwardIterator является создание копии, ее сохранение и использование для повторного обхода тех же данных. Манипулирование итератором (или его копиями) никак не влияет на диапазон данных. Но мы могли бы изобрести итератор, как представленный в следующем фрагменте, где вообще нет данных, лежащих в основе, и даже не имеет смысла создавать копию итератора: class getc_iterator { char ch; public: getc_iterator() : ch(getc(stdin)) {} char operator*() const { return ch; } auto& operator++() { ch = getc(stdin); return *this; } auto operator++(int) { auto result(*this); ++*this; return result; } bool operator==(const getc_iterator&) const { return false; } bool operator!=(const getc_iterator&) const { return true; } };

(Фактически стандартная библиотека содержит несколько типов итераторов, очень похожих на этот; мы обсудим один такой тип, std::istream_iterator, в главе 9 «Потоки ввода/вывода».) Такие итераторы, которые бессмысленно копировать и которые не указывают на элементы данных в сколько-нибудь значимом смысле, называют итераторами ввода InputIterator. Возможен также зеркальный случай. Взгляните на следующий вымышленный тип итератора: class putc_iterator { struct proxy {

34    Глава 2. Итераторы и диапазоны void operator= (char ch) { putc(ch, stdout); } }; public: proxy operator*() const { return proxy{}; } auto& operator++() { return *this; } auto& operator++(int) { return *this; } bool operator==(const putc_iterator&) const { return false; } bool operator!=(const putc_iterator&) const { return true; } }; void test() { putc_iterator it; for (char ch : {'h', 'e', 'l', 'l', 'o', '\n'}) { *it++ = ch; } }

(И снова в стандартной библиотеке имеются типы итераторов, очень похожие на этот; например, std::back_insert_iterator, рассматриваемый в главе 3 «Алгоритмы с парами итераторов», и std::ostream_iterator, рассмат­ риваемый в главе 9 «Потоки ввода/вывода».) Такие итераторы, которые бессмысленно копировать и которые предоставляют доступ на запись, но не на чтение, называют итераторами вывода OutputIterator. Любой тип итератора в C++ попадает, по меньшей мере, в одну из следующих категорий:  InputIterator;  OutputIterator;  ForwardIterator;  BidirectionalIterator;  RandomAccessIterator. Обратите внимание, что во время компиляции легко определить соот­ ветствие данного типа итератора требованиям BidirectionalIterator или RandomAccessIterator, но нет никакой возможности понять (исходя только из поддерживаемых синтаксических операций), имеем ли мы дело с InputIte­ rator, OutputIterator или ForwardIterator. Рассмотренные нами примеры итераторов getc_iterator, putc_iterator и list_of_ints::iterator поддерживают один и тот же синтаксический набор операций: разыменование *it, инкремент ++it и сравнение it != it. Эти три класса отличаются только на семантическом уровне. Но как стандартная библиотека различает их? Как оказывается, стандартная библиотека нуждается в некоторой помощи со стороны разработчика нового типа итераторов. Алгоритмы в стандартной библиотеке работают только с классами итераторов, которые определяют член typedef с именем iterator_category. То есть:

Powered by TCPDF (www.tcpdf.org)

Итераторы ввода и вывода    35 class getc_iterator { char ch; public: using iterator_category = std::input_iterator_tag; // ... }; class putc_iterator { struct proxy { void operator= (char ch) { putc(ch, stdout); } }; public: using iterator_category = std::output_iterator_tag; // ... }; template class list_of_ints_iterator { using node_pointer = std::conditional_t; node_pointer ptr_; public: using iterator_category = std::forward_iterator_tag; // ... };

В этом случае любой стандартный (или даже нестандартный) алгоритм, настраивающий свое поведение, опираясь на категории итераторов параметров типа своего шаблона, может делать это, просто исследуя iterator_category. Категории итераторов, описанные выше, соответствуют следующим пяти стандартным тегам типов, объявленным в заголовке : struct struct struct struct struct

input_iterator_tag { }; output_iterator_tag { }; forward_iterator_tag : public input_iterator_tag { }; bidirectional_iterator_tag : public forward_iterator_tag { }; random_access_iterator_tag : public bidirectional_iterator_tag { };

Обратите внимание, что random_access_iterator_tag фактически является производным (в классическом объектно-ориентированном смысле «полиморфизм-класс-иерархия») от bidirectional_iterator_tag и так далее: концептуальная иерархия видов итераторов отражается в иерархию классов iterator_category. Это может пригодиться в метапрограммировании шаблонов, когда требуется проверить теги; но для работы со стандартной библиотекой вам достаточно просто знать, что если вы когда-либо захотите передать iterator_category в функцию, тег типа random_access_iterator_tag будет

36    Глава 2. Итераторы и диапазоны соответствовать функции, ожидающей аргумент типа bidirectional_iterator_tag: void foo(std::bidirectional_iterator_tag t [[maybe_unused]]) { puts("std::vector's iterators are indeed bidirectional..."); } void bar(std::random_access_iterator_tag) { puts("...and random-access, too!"); } void bar(std::forward_iterator_tag) { puts("forward_iterator_tag is not as good a match"); } void test() { using It = std::vector::iterator; foo(It::iterator_category{}); bar(It::iterator_category{}); }

В этой точке у многих из вас возникнет вопрос: «А как же int*? Как определить член typedef для чего-то, что вообще является не классом, а простым скалярным типом? Скалярные типы не могут иметь членов typedef». Эту проб­лему, как и многие другие, возникающие в разработке программного обеспечения, можно решить, добавив дополнительный уровень косвенности. Вместо прямой ссылки на T::iterator_category стандартные алгоритмы всегда ссылаются на std::iterator_traits::iterator_category. Шаблонный класс std::iterator_traits соответственно специализируется для случая, когда T является типом указателя. Кроме того, std::iterator_traits оказался удобным местом для подвешивания других членов typedef. Он предоставляет следующие пять членов typedef, если и только если сам T предоставляет все пять (или если T является типом указателя): iterator_category, difference_type, value_type, pointer и reference.

Объединяем все вместе Объединив знания, полученные в этой главе, мы теперь можем написать код, как в следующем примере. В нем представлена реализация нашего типа list_ of_ints с нашим классом итератора (включая версию const_iterator), которая может использоваться стандартной библиотекой благодаря наличию всех пяти важных членов typedef.

Объединяем все вместе    37 struct list_node { int data; list_node *next; }; template class list_of_ints_iterator { friend class list_of_ints; friend class list_of_ints_iterator; using node_pointer = std::conditional_t; node_pointer ptr_; explicit list_of_ints_iterator(node_pointer p) : ptr_(p) {} public: // Члены typedef для поддержки std::iterator_traits using difference_type = std::ptrdiff_t; using value_type = int; using pointer = std::conditional_t; using reference = std::conditional_t; using iterator_category = std::forward_iterator_tag; reference operator*() const { return ptr_->data; } auto& operator++() { ptr_ = ptr_->next; return *this; } auto operator++(int) { auto result = *this; ++*this; return result; } // Поддержка сравнения между типами iterator и const_iterator template bool operator==(const list_of_ints_iterator& rhs) const { return ptr_ == rhs.ptr_; } template bool operator!=(const list_of_ints_iterator& rhs) const { return ptr_ != rhs.ptr_; } // Поддержка неявного преобразования iterator в const_iterator // (но не наоборот) operator list_of_ints_iterator() const { return list_of_ints_iterator{ptr_}; } }; class list_of_ints { list_node *head_ = nullptr; list_node *tail_ = nullptr; int size_ = 0; public: using const_iterator = list_of_ints_iterator; using iterator = list_of_ints_iterator; // Функции-члены begin и end iterator begin() { return iterator{head_}; }

38    Глава 2. Итераторы и диапазоны iterator end() { return iterator{nullptr}; } const_iterator begin() const { return const_iterator{head_}; } const_iterator end() const { return const_iterator{nullptr}; } // Другие поддерживаемые операции int size() const { return size_; } void push_back(int value) { list_node *new_tail = new list_node{value, nullptr}; if (tail_) { tail_->next = new_tail; } else { head_ = new_tail; } tail_ = new_tail; size_ += 1; } ~list_of_ints() { for (list_node *next, *p = head_; p != nullptr; p = next) { next = p->next; delete p; } } };

Теперь, чтобы показать, что мы достаточно полно понимаем, как стандартная библиотека реализует обобщенные алгоритмы, напишем шаблонные функции distance и count_if так, как они могли бы быть реализованы в стандартной библиотеке C++17. Обратите внимание, что в distance используется новый для C++17 синтаксис if constexpr. В этой книге мы не будем много рассуждать о новшествах, появившихся в базовом языке C++17, но в данном случае важно отметить, что синтаксис if constexpr можно использовать для устранения массы типового кода, который приходилось писать в C++14. template auto distance(Iterator begin, Iterator end) { using Traits = std::iterator_traits; if constexpr (std::is_base_of_v) { return (end - begin); } else { auto result = typename Traits::difference_type{}; for (auto it = begin; it != end; ++it) {

Устаревший std::iterator    39 ++result; } return result; } } template auto count_if(Iterator begin, Iterator end, Predicate pred) { using Traits = std::iterator_traits; auto sum = typename Traits::difference_type{}; for (auto it = begin; it != end; ++it) { if (pred(*it)) { ++sum; } } return sum; } void test() { list_of_ints lst; lst.push_back(1); lst.push_back(2); lst.push_back(3); int s = count_if(lst.begin(), lst.end(), [](int i){ return i >= 2; }); assert(s == 2); int d = distance(lst.begin(), lst.end()); assert(d == 3); }

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

Устаревший std::iterator У многих из вас наверняка возникла мысль: «В реализации каждого класса итератора я должен приводить те же самые пять членов typedef. Но это довольно большой объем типового кода, нельзя ли избежать его ввода?» Существует ли средство, позволяющее устранить весь этот типовой код? Начиная со стандарта C++98 и вплоть до C++17 стандартная библиотека включала вспомогательный шаблонный класс, специально предназначенный для этого. Он назывался std::iterator и требовал указать пять параметров шаблонных типов, соответствующих пяти членам typedef, необходимых для std::iterator_traits. Три из этих параметров имели «разумные значения

40    Глава 2. Итераторы и диапазоны по умолчанию», то есть простейший вариант его использования выглядел достаточно просто: namespace std { template< class Category, class T, class Distance = std::ptrdiff_t, class Pointer = T*, class Reference = T& > struct iterator { using iterator_category = Category; using value_type = T; using difference_type = Distance; using pointer = Pointer; using reference = Reference; }; } class list_of_ints_iterator : public std::iterator { // ... };

К сожалению для std::iterator, реальность оказалась намного сложнее, и по некоторым причинам, которые мы обсудим далее, в C++17 класс std::iterator был объявлен устаревшим. Как мы видели в разделе «Константные итераторы», для поддержки константной корректности мы должны реализовать тип константного итератора вдобавок к каждому типу «неконстантного итератора». В итоге получается код, как в следующем примере: template< bool Const, class Base = std::iterator< std::forward_iterator_tag, int, std::ptrdiff_t, std::conditional_t, std::conditional_t > > class list_of_ints_iterator : public Base { using typename Base::reference; // Жутко неуклюже! using node_pointer = std::conditional_t; node_pointer ptr_; public:

Устаревший std::iterator    41 reference operator*() const { return ptr_->data; } // ... };

Предыдущий код тяжелее читать или писать, чем версию без использования std::iterator; а кроме того, использование std::iterator предназначенным способом усложняет код с открытым наследованием, то есть близко напоминающий классическую объектно-ориентированную иерархию классов. У  начинающих программистов вполне может возникнуть соблазн использовать такую иерархию классов при написании, например, таких функций: template int count_if(const std::iterator& begin, const std::iterator& end, Predicate pred);

Внешне это похоже на наши примеры «полиморфного программирования» из главы 1 «Классический полиморфизм и обобщенное программирование», функции, реализующей разное поведение, принимая параметры с типом ссылки на базовый класс. Но в случае с std::iterator это сходство чисто случайно и вводит в заблуждение; наследование std::iterator не дает полиморфной иерархии классов, и ссылка на этот «базовый класс» из наших собст­венных функций является ошибкой! Поэтому стандарт C++17 объявил устаревшим класс std::iterator, с прицелом на полное его удаление в стандарте 2020 года или более позднем. Вы не должны использовать std::iterator в своем коде. Однако если в своем проекте вы используете библиотеку Boost, обратите внимание на ее эквивалент std::iterator с именем boost::iterator_ facade. В отличие от std::iterator, базовый класс boost::iterator_facade предоставляет реализацию для раздражающих функций-членов, таких как operator++(int) и operator!=, которые в иных случаях превращаются в досадный типовой код. Чтобы задействовать iterator_facade, просто унаследуйте его и определите несколько простейших функций-членов, выполняющих операции разыменования, инкремента и определения равенства. (Так как наш итератор для списка является прямым итератором ForwardIterator, это все, что требуется. Для двунаправленного итератора BidirectionalIterator также нужно добавить реализацию функции-члена декремента и т. д.) Поскольку эти функции-члены являются приватными, мы можем открыть доступ к ним для Boost, добавив объявление friend class boost::iterator_ core_access;: #include template class list_of_ints_iterator : public boost::iterator_facade< list_of_ints_iterator, std::conditional_t,

42    Глава 2. Итераторы и диапазоны std::forward_iterator_tag > { friend class boost::iterator_core_access; friend class list_of_ints; friend class list_of_ints_iterator; using node_pointer = std::conditional_t; node_pointer ptr_; explicit list_of_ints_iterator(node_pointer p) : ptr_(p) {} auto& dereference() const { return ptr_->data; } void increment() { ptr_ = ptr_->next; } // Поддержка сравнения между типами iterator и const_iterator template bool equal(const list_of_ints_iterator& rhs) const { return ptr_ == rhs.ptr_;} public: // Поддержка неявного преобразования iterator в const_iterator // (но не наоборот) operator list_of_ints_iterator() const { return list_of_ints_iterator{ptr_}; } };

Обратите

внимание,

что

первый

аргумент

шаблонного

типа

в

boost::iterator_facade всегда является классом, определение которого вы

пишете: это шаблон проектирования «Странно рекурсивный шаблон» (Curiously Recurring Template Pattern), с которым мы снова встретимся в главе 6 «Умные указатели». Эта реализация итератора для списка с использованием boost::iterator_ facade получилась намного короче аналогичной реализации из предыдущего раздела; экономия в основном обусловлена отсутствием повторяющейся реализации операторов отношения. Так как наш итератор для списка является ForwardIterator, мы должны реализовать только два оператора отношения; но если бы мы писали RandomAccessIterator, тогда iterator_facade мог бы сгенерировать реализации по умолчанию для операторов –, , =, основанные на единственной простейшей функции-члене distance_to.

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

Итоги    43 Библиотека Standard Template Library в C++ предлагает идею итератора как обобщенное представление указателей. Два итератора определяют диапазон данных. Этот диапазон может быть лишь частью содержимого контейнера или просто манипулировать определенной областью памяти, как было показано на примере getc_iterator и putc_iterator. Некоторые свойства типа итератора определяются его принадлежностью к категории итераторов – ввода, вывода, прямые, двунаправленные или с произвольным доступом – и могут использоваться шаблонными функциями для выбора быстрейшего из алгоритмов, доступных для определенной категории итераторов. Определяя свой контейнерный тип, вы должны также определить свои типы итераторов обеих версий: константные и неконстантные. Шаблоны открывают удобную возможность для этого. Реализуя свои типы итераторов, избегайте устаревшего класса std::iterator и подумайте об использовании boost::iterator_facade.

Глава

3

Алгоритмы с парами итераторов Теперь, когда вы познакомились с типами итераторов – стандартными и пользовательскими, – самое время посмотреть, что можно делать с их помощью. В этой главе вы узнаете:  что такое «полуоткрытый диапазон» – понятие, которое точно определяет диапазон, образуемый двумя итераторами;  как определить категорию каждого стандартного алгоритма из числа: «только для чтения», «только для записи», «преобразующий» или «перестановочный»; а также из числа: «однодиапазонный», «двухдиапазонный» и «полуторадиапазонный»;  что некоторые стандартные алгоритмы, такие как merge и make_heap, являются единственными строительными блоками, необходимыми для создания высокоуровневых сущностей, таких как stable_sort и prio­ rity_queue;  как отсортировать диапазон, опираясь на компаратор, отличный от ope­ ratorpush_back(v); return *this; } auto& operator= (CtrValueType&& v) { c->push_back(std::move(v)); return *this; } }; template auto back_inserter(Container& c) { return back_insert_iterator(c); } } void test() { std::string s = "hello"; std::vector dest; std::copy(s.begin(), s.end(), std::back_inserter(dest)); assert(dest.size() == 5); }

Вызов std::back_inserter(dest) просто возвращает объект back_insert_ iterator. В C++17 можно положиться на автоматическое определение типа шаблона в конструкторах и определить тело этой функции просто как return std::back_insert_iterator(dest); или вообще отказаться от нее и напрямую использовать std::back_insert_iterator(dest) – где в коде C++14 можно было «довольствоваться» std::back_inserter(dest). Так зачем нам мог бы

54    Глава 3. Алгоритмы с парами итераторов понадобиться этот, казалось бы, избыточный код? Имя back_inserter было выбрано намеренно, чтобы его проще было запомнить, потому что предполагается, что эта функция будет использоваться очень часто. Несмотря на то что C++17 позволяет писать std::pair вместо std::make_pair и std::tuple вместо std::make_tuple, было бы глупо писать более «заковыристое» имя std::back_insert_iterator вместо std::back_inserter. Поэтому даже в C++17 лучше писать std::back_inserter(dest).

Вариации на тему: std::move и std::move_iterator Как нетрудно догадаться из названия или из предыдущей реализации, алгоритм std::copy копирует элементы из входного диапазона в выходной. Знатоки C++11 могли бы задаться вопросом: а что, если вместо копирования элементов использовать семантику перемещения, чтобы переместить их из входного диапазона в выходной? В STL имеется два разных подхода к решению этой задачи. Первый, самый простой – использовать алгоритм std::move (определен в заголовке ), имеющий следующее определение: template OutIt move(InIt first1, InIt last1, OutIt destination) { while (first1 != last1) { *destination = std::move(*first1); ++first1; ++destination; } return destination; }

Он почти в точности повторяет алгоритм std::copy, за исключением дополнительного вызова std::move для входного элемента (будьте внимательны – этот внутренний алгоритм std::move, с единственным аргументом, определен в заголовке , и он полностью отличается от внешнего std::move с тремя аргументами, который определяется в заголовке ! Конечно печально, что два разных алгоритма имеют одинаковые имена. По иронии судьбы, в число немногих других функций в STL, страдающих той же проблемой, входит std::remove; см. раздел «Удаление из сортированного массива» в конце этой главы, а также главу 12 «Файловая система»). Другой подход основан на использовании решения, которое мы видели выше с back_inserter. Вместо того чтобы отключать основной алгоритм, мы можем продолжать использовать алгоритм std::copy, но параметризовать его иначе. Допустим, мы передаем в него новый тип итератора, который (подобно back_inserter) обертывает исходный объект и изменяет его поведение.

Вариации на тему: std::move и std::move_iterator     55 В частности, нам нужен итератор ввода, реализация operator* которого возвращает rvalue. Мы можем сделать это! template class move_iterator { using OriginalRefType = typename std::iterator_traits::reference; It iter; public: using iterator_category = typename std::iterator_traits::iterator_category; using difference_type = typename std::iterator_traits::difference_type; using value_type = typename std::iterator_traits::value_type; using pointer = It; using reference = std::conditional_t< std::is_reference_v, std::remove_reference_t&&, OriginalRefType >; move_iterator() = default; explicit move_iterator(It it) : iter(std::move(it)) {} // Разрешить конструирование или присваивание из любого move-итератора. // Эти шаблоны также могут играть роль конструктора копирования // и оператора присваивания соответственно. template move_iterator(const move_iterator& m) : iter(m.base()) {} template auto& operator=(const move_iterator& m) { iter = m.base(); return *this; } It base() const { return iter; } reference operator*() { return static_cast(*iter); } It operator->() { return iter; } decltype(auto) operator[](difference_type n) const { return *std::move(iter[n]); } auto& operator++() { ++iter; return *this; } auto& operator++(int) { auto result = *this; ++*this; return result; } auto& operator--() { --iter; return *this; } auto& operator--(int) { auto result = *this; --*this; return result; } auto& operator+=(difference_type n) const { iter += n; return *this; } auto& operator-=(difference_type n) const { iter -= n; return *this; }

56    Глава 3. Алгоритмы с парами итераторов }; // Я опустил определения операторов нечленов // == != < >= + - ; сможете реализовать их самостоятельно? template auto make_move_iterator(InputIterator& c) { return move_iterator(c); }

Извиняюсь за такой большой фрагмент кода, но уверяю, что вы спокойно можете не вникать в его тонкости. Для тех, кому это нравится, здесь имеется шаблонный конструктор из move_iterator, который, так получилось, дуб­ лирует конструктор копирования (когда тип U совпадает с типом It); а также множество функций-членов (таких как operator[] и operator--), тела которых будут вызывать ошибку во время компиляции для многих допустимых типов It (например, если It – прямой итератор; см. главу 2 «Итераторы и диапазоны»), но это нормально, потому что их тела не будут создаваться компилятором, если только пользователь на самом деле не попытается вызвать эти функции на этапе компиляции (если пользователь попробует это сделать – move_iterator, тогда, конечно, этот код вызовет ошибку времени компиляции). Обратите внимание, что по аналогии с back_inserter в STL имеется вспомогательная функция make_move_iterator для компиляторов, появившихся перед выходом стандарта C++17, которые не поддерживают автоматический вывод типа шаблона конструктора. Имя «вспомогательной» функции, как в случае с make_pair и make_tuple, выглядит менее удобоваримым, чем фактическое имя класса, поэтому я настоятельно советую использовать возможности C++17; зачем вводить лишние пять символов и создавать лишний экземп­ ляр шаблонной функции, если она вам не нужна? Теперь у нас есть два разных способа перемещения данных из одного контейнера или диапазона в другой: алгоритм std::move и класс-адаптер std::move_ iterator. Вот примеры обеих идиом: std::vector input = {"hello", "world"}; std::vector output(2); // Первый подход: использовать алгоритм std::move std::move(input.begin(), input.end(), output.begin()); // Второй подход: использовать move_iterator std::copy( std::move_iterator(input.begin()), std::move_iterator(input.end()), output.begin() );

Непростое копирование с std::transform    57 Первый подход, с использованием std::move, более очевидный, если вам требуется просто переместить данные. Но зачем потребовалось включать в стандартную библиотеку этот «малопонятный» move_iterator? Чтобы ответить на этот вопрос, мы должны исследовать еще один алгоритм, принципиально связанный с std::copy.

Непростое копирование с std::transform Возможно, вы заметили при обсуждении реализации std::copy, что два параметра типа value_type в итераторе не обязательно должны совпадать. Это фича, а не баг! Благодаря ей можно писать код, полагающийся на неявные преобразования, и он просто «Будет Делать То, Что Должен»: std::vector input = {"hello", "world"}; std::vector output(2); std::copy(input.begin(), input.end(), output.begin()); assert(output[0] == "hello"); assert(output[1] == "world");

Выглядит тривиально, верно? Но пригладитесь внимательнее! Глубоко внут­ ри нашего экземпляра std::copy будет вызван неявный конструктор, преобразующий const char * (тип *input.begin()) в std::string (тип *output. begin()). То есть уже в который раз мы видим пример обобщенного кода, выполняющего на удивление сложные операции просто потому, что ему передаются итераторы определенных типов. Но иногда во время копирования бывает нужно применить функцию сложного преобразования – еще более сложного, чем может выполнить неявное преобразование. Стандартная библиотека поможет вам в этом! template OutIt transform(InIt first1, InIt last1, OutIt destination, Unary op) { while (first1 != last1) { *destination = op(*first1); ++first1; ++destination; } return destination; } void test() { std::vector input = {"hello", "world"}; std::vector output(2); std::transform(

58    Глава 3. Алгоритмы с парами итераторов input.begin(), input.end(), output.begin(), [](std::string s) { // Преобразование на месте также возможно! std::transform(s.begin(), s.end(), s.begin(), ::toupper); return s; } ); assert(input[0] == "hello"); assert(output[0] == "HELLO"); }

Иногда даже бывает нужно применить для преобразования функцию, принимающую два аргумента. И снова библиотека позаботится об этом: template OutIt transform(InIt1 first1, InIt1 last1, InIt2 first2, OutIt destination, Binary op) { while (first1 != last1) { *destination = op(*first1, *first2); ++first1; ++first2; ++destination; } return destination; }

Эту версию алгоритма std::transform юмористически можно описать как «одно- и двухдиапазонный с половиной» алгоритм! (А как насчет функции с тремя аргументами? А с четырьмя? К сожалению, полноценной вариативной (с переменным числом аргументов) версии std::transform не существует; вариативные шаблоны не поддерживались в C++ до появления стандарта C++11. Можете попробовать сами реализовать вариативную версию и посмотреть, с какими проблемами столкнетесь – они не тривиальны, но вполне преодолимы.) Существование std::transform дает нам третий способ перемещения данных из одного места в другое: std::vector input = {"hello", "world"}; std::vector output(2); // Третий способ: использование std::transform std::transform( input.begin(), input.end(), output.begin(), std::move );

Диапазонные алгоритмы только для записи    59 Но я не рекомендую пользоваться им! Самый большой и самый красный из его красных флажков – явная специализация шаблона std::move. Всякий раз, когда присутствует явная специализация – эти угловые скобки после имени шаблона, – это почти всегда верный признак очень чувствительного и хрупкого кода. Опытным читателям может доставить удовольствие самим понять, как компилятор определяет типы двух std::move в моем примере; напомню, что один из них определен в заголовке , а другой – в .

Диапазонные алгоритмы только для записи Эту главу мы начали с исследования таких алгоритмов, как std::find, которые выполняют обход диапазона, читая его элементы, не изменяя их. Возможно, вы удивитесь, узнав, что обратная операция также имеет смысл: существует целое семейство стандартных алгоритмов, которые выполняют обход диапазона и изменяют элементы, не читая их! std::fill(a,b,v) делает то, о чем говорит его имя: заполняет (fill) каждый элемент в заданном диапазоне [a,b) копией значения v. std::iota(a,b,v) – более интересный алгоритм: он заполняет элементы в заданном диапазоне копиями ++v. То есть вызов std::iota(a,b,42) запишет в a[0] значение 42, в a[1] – значение 43, в a[2] – значение 44 и так далее, вплоть до b. Такое забавное название алгоритма уходит корнями в язык программирования APL, где имелась функция с именем ι (то есть буква «йота» греческого алфавита), выполняющая эту операцию. Другой интересный факт об этом алгоритме: по какой-то непонятной причине его определение попало в стандартный заголовок вместо . Это довольно странно. std::generate(a,b,g) – еще более интересный алгоритм: он заполняет элементы в заданном диапазоне результатами последовательных вызовов g(), то есть: template void fill(FwdIt first, FwdIt last, T value) { while (first != last) { *first = value; ++first; } } template void iota(FwdIt first, FwdIt last, T value) { while (first != last) { *first = value; ++value; ++first; } } template

60    Глава 3. Алгоритмы с парами итераторов void generate(FwdIt first, FwdIt last, G generator) { while (first != last) { *first = generator(); ++first; } }

Вот пример использования каждого из этих стандартных алгоритмов для заполнения строками с разным содержимым. Проверьте себя: понимаете ли вы, почему каждый вызов производит именно такой вывод? Пример, который я выбрал для std::iota, особенно интересен (но вряд ли будет полезен в практическом коде): std::vector v(4); std::fill(v.begin(), v.end(), "hello"); assert(v[0] == "hello"); assert(v[1] == "hello"); assert(v[2] == "hello"); assert(v[3] == "hello"); std::iota(v.begin(), v.end(), "hello"); assert(v[0] == "hello"); assert(v[1] == "ello"); assert(v[2] == "llo"); assert(v[3] == "lo"); std::generate(v.begin(), v.end(), [i=0]() mutable { return ++i % 2 ? "hello" : "world"; }); assert(v[0] == "hello"); assert(v[1] == "world"); assert(v[2] == "hello"); assert(v[3] == "world");

Алгоритмы, влияющие на жизненный цикл объектов Заголовок определяет семейство алгоритмов с малопонятными именами, такими как std::uninitialized_copy, std::uninitialized_default_ construct и std::destroy (полный список можно найти в онлайн-справочнике, таком как cppreference.com). Рассмотрим следующий алгоритм, который использует неявный вызов деструктора для уничтожения элементов в диапазоне: template void destroy_at(T *p) {

Алгоритмы, влияющие на жизненный цикл объектов    61 p->~T(); } template void destroy(FwdIt first, FwdIt last) { for ( ; first != last; ++first) { std::destroy_at(std::addressof(*first)); } }

Обратите внимание на небольшую, удобную вспомогательную функцию

std::addressof(x), которая возвращает адрес своего параметра; она действует в точности так же, как &x, кроме редких случаев, когда x имеет тип класса, который по-садистски перегружает operator&.

А теперь взглянем на следующий алгоритм, который использует явный синтаксис размещающей формы new (placement-new) для «конструирования копированием в» элементы диапазона (обратите внимание, как он аккуратно прибирает за собой, если в ходе копирования возникло исключение). Очевидно, что этот алгоритм не должен использоваться с диапазонами, в которых уже имеются существующие элементы; поэтому следующий пример выглядит надуманным: template FwdIt uninitialized_copy(It first, It last, FwdIt out) { using T = typename std::iterator_traits::value_type; FwdIt old_out = out; try { while (first != last) { ::new (static_cast(std::addressof(*out))) T(*first); ++first; ++out; } return out; } catch (...) { std::destroy(old_out, out); throw; } } void test() { alignas(std::string) char b[5 * sizeof (std::string)]; std::string *sb = reinterpret_cast(b); std::vector vec = {"quick", "brown", "fox"}; // Сконструировать три std::string

62    Глава 3. Алгоритмы с парами итераторов auto end = std::uninitialized_copy(vec.begin(), vec.end(), sb); assert(end == sb + 3); // Уничтожить три std::string. std::destroy(sb, end); }

Больше о практическом назначении этих алгоритмов вы узнаете в главе 4 «Зоопарк контейнеров», когда пойдет разговор о std::vector.

Наш первый перестановочный алгоритм: std::sort Все алгоритмы, рассматривавшиеся до сих пор, просто выполняют обход заданного диапазона по порядку, линейно, от одного элемента к следующему. Наше следующее семейство алгоритмов проявляет иное поведение. Вместо этого они извлекают значения из элементов в заданном диапазоне и перетасовывают их так, что те же значения сохраняются в диапазоне, но следуют уже в другом порядке. В математике такая операция называется перестановкой (permutation). Проще всего, пожалуй, описать перестановочный алгоритм std::sort(a,b). Как следует из его имени, он сортирует данные в указанном диапазоне так, что наименьшие элементы оказываются в начале, а наибольшие – в конце. Для определения «наименьшего» элемента std::sort(a,b) использует operator= new_heap_size) { return; } auto biggerchild = leftchild; if (rightchild < new_heap_size && a[leftchild] < a[rightchild]) { biggerchild = rightchild; } if (a[biggerchild] < a[parent]) { return; // свойство невозрастания пирамиды восстановлено } std::iter_swap(a+parent, a+biggerchild); parent = biggerchild; } } template void make_heap(RandomIt a, RandomIt b) { for (auto it = a; it != b; ) { push_heap(a, ++it); } } template void sort_heap(RandomIt a, RandomIt b) { for (auto it = b; it != a; --it) { pop_heap(a, it); } } template void sort(RandomIt a, RandomIt b) { make_heap(a, b);

Поиск и вставка в сортированный массив с std::lower_bound    71 sort_heap(a, b); }

Другие применения push_heap и pop_heap мы увидим в главе 4 «Зоопарк контейнеров», когда будем говорить о std::priority_queue.

Слияние и сортировка слиянием Коль скоро мы занялись обсуждением алгоритмов сортировки, реализуем сортировку другим способом! std::inplace_merge(a,mid,b) принимает единственный диапазон [a,b), уже отсортированный эквивалентами std::sort(a,mid) и std::sort(mid,b), и объединяет два поддиапазона в один отсортированный диапазон. Этот строительный блок можно использовать для реализации классического алгоритма сортировки слиянием: template void sort(RandomIt a, RandomIt b) { auto n = std::distance(a, b); if (n >= 2) { auto mid = a + n/2; std::sort(a, mid); std::sort(mid, b); std::inplace_merge(a, mid, b); } }

Но будьте внимательны! Имя inplace_merge, как может показаться, подразумывает, что слияние (или объединение) происходит «на месте» (in-place) и не требуется создавать буфер с дополнительным пространством; но в действительности это не так. На самом деле функция inplace_merge сама выделяет память для буфера с помощью оператора new. Если вы пишете программу для работы в окружении, где выделение динамической памяти является большой проблемой, избегайте использования inplace_merge как чумы. К другим стандартным алгоритмам, которые могут выделять временные буферы в динамической памяти, относятся std::stable_sort и std::stable_partition. Алгоритм слияния std::merge(a,b,c,d,o) не выделяет дополнительной памяти; он принимает две пары итераторов, представляющих диапазоны [a,b) и [c,d), и объединяет их в один выходной диапазон, определяемый итератором o.

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

72    Глава 3. Алгоритмы с парами итераторов горитм, реализующий поиск методом дихотоимии, называется std::lower_

bound(a,b,v):

template FwdIt lower_bound(FwdIt first, FwdIt last, const T& value, C lessthan) { using DiffT = typename std::iterator_traits::difference_type; FwdIt it; DiffT count = std::distance(first, last); while (count > 0) { DiffT step = count / 2; it = first; std::advance(it, step); if (lessthan(*it, value)) { ++it; first = it; count -= step + 1; } else { count = step; } } return first; } template FwdIt lower_bound(FwdIt first, FwdIt last, const T& value) { return std::lower_bound(first, last, value, std::less{}); }

Эта функция возвращает итератор, указывающий на первый элемент в диапазоне, который не меньше заданного значения v. Если в диапазоне имеется экземпляр значения v, она вернет итератор, указывающий на него (на самом деле она вернет итератор, указывающий на первый такой экземпляр в диапазоне). Если в диапазоне нет такого экземпляра, функция вернет итератор на место, где такой экземпляр v должен был бы находиться. Значение, возвращаемое функцией lower_bound, можно использовать как аргумент для функции vector::insert, чтобы вставить v в сортированный вектор в соответствующее место, не нарушив порядка сортировки: std::vector vec = {3, 7}; for (int value : {1, 5, 9}) { // Найти место для вставки... auto it = std::lower_bound(vec.begin(), vec.end(), value); // ...и вставить значение value. vec.insert(it, value); } // Вектор остается отсортированным. assert((vec == std::vector{1, 3, 5, 7, 9}));

Удаление из сортированного массива с std::remove_if    73 Похожая функция std::upper_bound(a,b,v) возвращает итератор, указывающий на первый элемент в диапазоне, который больше заданного значения  v. Если v отсутствует в указанном диапазоне, std::upper_bound вернет то же значение, что и функция std::lower_bound. Но если v присутствует в диапазоне, тогда lower_bound вернет итератор, указывающий на первый экземпляр v в диапазоне, а upper_bound – на элемент, следующий за последним экземпляром. Иными словами, объединив эти две функции, можно получить полуоткрытый диапазон [lower, upper), содержащий только значения v: std::vector vec = {2, 3, 3, 3, 4}; auto lower = std::lower_bound(vec.begin(), vec.end(), 3); // Первый подход: // интерфейс upper_bound идентичен интерфейсу lower_bound. auto upper = std::upper_bound(vec.begin(), vec.end(), 3); // Второй подход: // Во второй раз необязательно выполнять поиск по всему массиву. auto upper2 = std::upper_bound(lower, vec.end(), 3); assert(upper2 == upper); // Третий подход: // Линейное сканирование от нижней границы lower может оказаться быстрее, // чем поиск методом дихотомии, если диапазон имеет очень большой размер. auto upper3 = std::find_if(lower, vec.end(), [](int v) { return v != 3; }); assert(upper3 == upper); // При любом подходе получаются одни и те же результаты. assert(*lower >= 3); assert(*upper > 3); assert(std::all_of(lower, upper, [](int v) { return v == 3; }));

Мы рассмотрели поиск и вставку значений в сортированный массив. А как выполнить удаление?

Удаление из сортированного массива с std::remove_if До сих пор, обсуждая стандартные обобщенные алгоритмы, мы не касались вопроса удаления элементов из диапазона. Это объясняется тем, что фундаментально «диапазон» доступен только для чтения: мы можем изменять значения элементов в диапазоне, но с помощью стандартных алгоритмов нельзя укоротить или удлинить сам диапазон. В разделе «Манипулирование данными с std::copy» мы использовали std::copy для «вставки в» вектор с именем dest, но это не был алгоритм std::copy, выполняющий вставку; это был объект

74    Глава 3. Алгоритмы с парами итераторов std::back_insert_iterator, хранящий ссылку на базовый контейнер и способный выполнить вставку в контейнер. Алгоритм std::copy не принимает параметры dest.begin() и dest.end(); вместо этого он принимает особый объект std::back_inserter(dest). Итак, как же нам удалить элемент из диапазона? А никак – это невозможно. Все, что мы можем, – это удалить элемент из контейнера средствами самого контейнера, а алгоритмы из STL не работают с контейнерами. Поэтому нам нужно найти способ переупорядочить значения в диапазоне так, чтобы «удаленные» элементы оказались где-то в предопределенном месте внутри контейнера, чтобы потом их можно было быстро удалить (используя другие средства, отличные от алгоритмов STL). Мы уже видели одно из возможных решений: std::vector vec = {1, 3, 3, 4, 6, 8}; // Разбить вектор так, чтобы все элементы, не равные 3, оказались в начале // а все элементы, равные 3, оказались в конце. auto first_3 = std::stable_partition( vec.begin(), vec.end(), [](int v){ return v != 3; } ); assert((vec == std::vector{1, 4, 6, 8, 3, 3})); // Теперь можно удалить "хвост" вектора. vec.erase(first_3, vec.end()); assert((vec == std::vector{1, 4, 6, 8}));

Но здесь делается много ненужной работы (не забывайте, что stable_partition – один из алгоритмов STL, которые выделяют буферы в динамической

памяти!). На самом деле алгоритм, нужный нам, выглядит намного проще: template FwdIt remove(FwdIt first, FwdIt last, const T& value) { auto out = std::find(first, last, value); if (out != last) { auto in = out; while (++in != last) { if (*in == value) { // этот элемент нас не интересует } else { *out++ = std::move(*in); } } } return out; } void test()

Удаление из сортированного массива с std::remove_if    75 { std::vector vec = {1, 3, 3, 4, 6, 8}; // Разбить вектор так, чтобы все элементы, не равные 3, оказались в начале auto new_end = std::remove( vec.begin(), vec.end(), 3 ); // std::remove_if не сохраняет "удаленные" элементы. assert((vec == std::vector{1, 4, 6, 8, 6, 8})); // Теперь можно удалить "хвост" вектора. vec.erase(new_end, vec.end()); assert((vec == std::vector{1, 4, 6, 8})); // Или выполнить оба шага в одной строке. // Это идиома "стереть-удалить": vec.erase( std::remove(vec.begin(), vec.end(), 3), vec.end() ); // Но если массив очень большой и известно, что он отсортирован, // тогда, возможно, поиск элементов для удаления лучше производить // методом дихотомии // Здесь также происходит "сдвиг вниз", но уже // внутри vector::erase, а не std::remove. auto first = std::lower_bound(vec.begin(), vec.end(), 3); auto last = std::upper_bound(first, vec.end(), 3); vec.erase(first, last); }

std::remove(a,b,v) удаляет из диапазона [a,b) все значения, равные v. При этом не требуется, чтобы данные в диапазоне были отсортированы, – но remove сохраняет исходный порядок, «сдвигая вниз» неудаленные элементы для заполнения образовавшихся «пустот» в диапазоне. Если remove удалит k элементов из диапазона, тогда по возвращении из нее эти k перемещенных элементов окажутся в конце диапазона, а возвращаемое значение remove будет содержать итератор, указывающий на первый такой перемещенный элемент. std::remove_if(a,b,p) удаляет все элементы, удовлетворяющие заданному предикату p; то есть она удалит все элементы e, для которых выполняется условие p(e) == true. Так же как remove, алгоритм remove_if сдвигает элементы вниз, чтобы заполнить образующиеся пробелы, и возвращает итератор, указывающий на первый перемещенный элемент. Типичная идиома удаления элементов из последовательного контейнера известна как идиома стирания-удаления, потому что предполагает возможность передачи возвращаемого значения непосредственно в функцию-член .erase() контейнера.

76    Глава 3. Алгоритмы с парами итераторов Другой алгоритм из стандартной библиотеки, который использует идиому стирания-удаления, – функция std::unique(a,b). Она принимает диапазон и из каждого набора одинаковых элементов, следующих друг за другом, удаляет все, кроме первого. По аналогии с std::remove, входной диапазон необязательно должен быть отсортирован; алгоритм сохраняет начальный порядок элементов: std::vector vec = {1, 2, 2, 3, 3, 3, 1, 3, 3}; vec.erase( std::unique(vec.begin(), vec.end()), vec.end() ); assert((vec == std::vector{1, 2, 3, 1, 3}));

Наконец, обратите внимание, что часто есть средство лучше, чем std::remo­ ve, обычно в виде функции-члена erase контейнера (например, в следующей главе мы увидим, что std::list::erase может действовать намного быстрее, чем идиома стирания-удаления с std::list). И даже если удаление происхо-

дит из вектора, в котором порядок следования элементов не имеет значения, все равно обычно есть лучшее решение, например напоминающее следующий алгоритм unstable_remove, предложенный для стандартизации в будущем, но (на момент написания этих строк) еще не включенный в: namespace my { template BidirIt unstable_remove(BidirIt first, BidirIt last, const T& value) { while (true) { // Найти первый экземпляр значения value... first = std::find(first, last, value); // ...и последний экземпляр значения, не равного value... do { if (first == last) { return last; } --last; } while (*last == value); // ...и переместить последний на место первого. *first = std::move(*last); // Смыть и повторить. ++first; } } } // namespace my void test() {

Итоги    77 std::vector vec = {4, 1, 3, 6, 3, 8}; vec.erase( my::unstable_remove(vec.begin(), vec.end(), 3), vec.end() ); assert((vec == std::vector{4, 1, 8, 6})); }

В следующей главе мы рассмотрим контейнеры – ответ STL на вопрос: «Где хранятся все эти элементы?».

Итоги Стандартная библиотека шаблонов (Standard Template Library) включает все (почти) необходимые алгоритмы. Если вы решаете какую-то алгоритмическую задачу, загляните сначала в STL! Алгоритмы в STL работают с полуоткрытыми диапазонами, определяемыми парами итераторов. Будьте осторожны при работе с полуторадиапазонными алгоритмами. Сравнение и сортировка алгоритмами STL по умолчанию выполняются с использованием operator int[3]; // Но можно вернуть std::array. auto cross_product(const std::array& a, const std::array& b) -> std::array { return {{ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], }}; }

Так как std::array имеет конструктор копирования и поддерживает оператор присваивания копированием, массивы этого типа можно хранить в контейнерах, например std::vector, но с простыми массивами этот трюк std::vector не пройдет.

Простейший контейнер: std::array    83 Однако если вы поймаете себя на том, что очень часто возвращаете массивы из функций или храните их в контейнерах, задумайтесь – действительно ли абстракция «массив» отвечает вашим потребностям. Возможно, лучше будет завернуть массив в какой-нибудь класс? В случае с функцией cross_product из примера выше гораздо предпочтительнее выглядит идея заключить «массив из трех целых чисел» в класс. Это позволит не только дать элементам массива имена (x, y и z), но также упростить инициализацию объектов типа Vec3 (без второй пары фигурных скобок!), и, что самое важное, пожалуй, мы можем отказаться от реализации операторов сравнения, таких как operator= не имеют смысла для Vec3 cross_product(const Vec3& a, const Vec3& b) { return { a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x, }; }

std::array хранит свои элементы непосредственно в себе. Поэтому значение sizeof (std::array) равно значению sizeof (int[100]), которое, в свою очередь, равно 100 * sizeof (int). Не допускайте ошибки, пытаясь поместить гигантский массив на стек, как локальную переменную! void dont_do_this() { // Эта переменная займет 4 мегабайта пространства на стеке --// вполне достаточно, чтобы исчерпать стек и вызвать ошибку сегментации! int arr[1'000'000];

84    Глава 4. Зоопарк контейнеров } void dont_do_this_either() { // Использование std::array не решает проблемы. std::array arr; }

В роли «гигантских массивов» лучше использовать следующий контейнер в нашем списке: std::vector.

Рабочая лошадка: std::vector std::vector представляет непрерывный массив элементов данных, но мес­ то для него выделяет в динамической памяти, а не на стеке. Имеет два основных преимущества перед std::array: во-первых, позволяет создавать по-настоящему гигантские массивы без риска переполнить стек. Во-вторых, поддерживает возможность динамического изменения размера массива – в отличие от std::array, где размер массива является неизменяемой частью типа, std::vector не имеет ограничения на размер. Метод .size() вектора фактически возвращает полезную информацию о текущем состоянии вектора. std::vector имеет еще один важный атрибут: емкость. Емкость вектора всегда больше или равна его размеру и представляет количество элементов, которое вектор мог бы хранить в данный момент, прежде чем выделить дополнительную память для базового массива:

размер=3

емкость=8

Рис. 4.2. Размер вектора и его емкость Помимо динамического изменения размера, в остальном вектор действует подобно массиву. Так же как массивы, векторы можно копировать (за линейное время) и сравнивать (std::vector::operator< выполняет лексикографическое сравнение операндов, используя T::operator= c) { // ничего не делать return; } // Пока просто игнорируем проблему // "Что делать, если вызов malloc потерпел неудачу?" T *new_ptr = (T *)malloc(c * sizeof (T)); for (size_t i=0; i < size_; ++i) { if constexpr (std::is_nothrow_move_constructible_v) { // Если элемент можно переместить, не рискуя получить // исключение, тогда переместить его. ::new (&new_ptr[i]) T(std::move(ptr_[i])); } else { // Если перемещение элементов может вызвать исключение, // тогда копировать элементы, пока эта операция завершается // успехом; затем уничтожить старые элементы. try { ::new (&new_ptr[i]) T(ptr_[i]); } catch (...) { destroy_n_elements(new_ptr, i); free(new_ptr); throw; } } } // Если элементы были благополучно перемещены или скопированы, // уничтожить старый массив и записать в ptr_ указатель на новый. destroy_n_elements(ptr_, size_); free(ptr_); ptr_ = new_ptr; capacity_ = c; } ~vector() { destroy_n_elements(ptr_, size_);

Рабочая лошадка: std::vector    87 free(ptr_); } };

Читавшие эту книгу по порядку могли заметить, что критически важный цикл for в этой функции .reserve() очень напоминает реализацию std::uninitialized_copy(a,b,c) из главы 3 «Алгоритмы с парами итераторов». На самом деле в реализации .reserve() контейнера, не зависящего от используемого диспетчера памяти (см. главу 8 «Диспетчеры памяти»), можно повторно использовать стандартный алгоритм: // Если элемент можно переместить, не рискуя получить // исключение, тогда переместить его. std::conditional_t< std::is_nothrow_move_constructible_v, std::move_iterator, T* > first(ptr_); try { // Переместить или скопировать элементы с помощью стандартного алгоритма. std::uninitialized_copy(first, first + size_, new_ptr); } catch (...) { free(new_ptr); throw; } // Если элементы были благополучно перемещены или скопированы, // уничтожить старый массив и записать в ptr_ указатель на новый. std::destroy(ptr_, ptr_ + size_); free(ptr_); ptr_ = new_ptr; capacity_ = c;

vec.resize(s) изменяет размер вектора – она усекает элементы в конце вектора (попутно вызывая их деструкторы) или добавляет в конец новые элементы (вызывая конструктор по умолчанию), пока размер вектора не станет равным s. Если s > vec.capacity(), происходит перераспределение внутреннего массива, как при вызове .reserve(). Возможно, также вы заметили, что при перераспределении памяти для массива происходит изменение адресов элементов: адрес vec[0] перед перераспределением отличается от адреса vec[0] после перераспределения. Любые указатели на старые элементы вектора после этой операции становятся недейст­вительными. А поскольку std::vector::iterator, по сути, является простым указателем, любые итераторы, ссылающиеся на старые элементы вектора, также становятся недействительными. Это явление называют аннулированием итераторов (iterator invalidation), и оно является главным источни-

88    Глава 4. Зоопарк контейнеров ком ошибок в коде на C++. Будьте внимательны, когда одновременно работаете с итераторами и изменяете размеры векторов! Вот несколько классических примеров аннулирования итераторов: std::vector v = {3, 1, 4}; auto iter = v.begin(); v.reserve(6); // iter становится недействительным! // Может показаться, что этот код произведет результат // {3, 1, 4, 3, 1, 4}; но если первая вставка // вызовет перераспределение, тогда следующая вставка // прочитает мусор из недействительного итератора! v = std::vector{3, 1, 4}; std::copy( v.begin(), v.end(), std::back_inserter(v) );

Вот еще один случай, известный во многих языках программирования, где стирание элемента из контейнера во время итераций порождает тонкую ошибку: auto end = v.end(); for (auto it = v.begin(); it != end; ++it) { if (*it == 4) { v.erase(it); // ОШИБКА! } } // Определение конца вектора вызовом .end() в каждом цикле // исправляет эту ошибку... for (auto it = v.begin(); it != v.end(); ) { if (*it == 4) { it = v.erase(it); } else { ++it; } } // ...Но намного эффективнее использовать идиому // стирания-удаления. v.erase( std::remove_if(v.begin(), v.end(), [](auto&& elt) { return elt == 4; }), v.end() );

Рабочая лошадка: std::vector    89

Вставка и стирание в std::vector vec.push_back(t) добавляет элемент в конец вектора. Вектор не имеет соответствующей функции-члена .push_front() из-за отсутствия эффективного способа добавления элементов в начало вектора, как видно из диаграммы в начале этого раздела. vec.emplace_back(args...) – шаблонная функция идеальной передачи с переменным числом аргументов, которая действует подобно .push_back(t), но помещает в конец вектора не копию t, а создает объект типа T, как если бы был вызван конструктор T(args...). Обе функции, push_back и emplace_back, имеют «амортизированную постоянную сложность». Чтобы понять, что это означает, представьте, что случится с простейшим вектором, если сто раз подряд вызвать v.emplace_back(). С каждым вызовом размер вектора должен немного увеличиваться; из-за этого происходит перераспределение внутреннего массива и перемещение всех v.size() элементов из старого массива в новый. В какой-то момент на копирование данных с места на место будет тратиться больше времени, чем на фактическую «вставку в конец» новых данных! К счастью, реализация std::vector предусматривает обходное решение, помогающее избежать этой ловушки. Всякий раз, когда операция, такая как v.emplace_back(), вызывает перераспределение памяти, вектор выделяет память не для capacity() + 1 элементов в новом массиве, а для k * capacity() элементов (где k равно 2 в libc++ и libstdc++ и примерно равно 1.5 в Visual Studio). То есть даже при том, что с рос­ том вектора перераспределение памяти обходится все дороже, потребность в перераспределении возникает все реже, и стоимость одного вызова push_back в среднем остается постоянной. Этот трюк известен как геометрическое изменение размера. vec.insert(it, t) вставляет элемент в середину вектора, в позицию, на которую ссылается итератор it. Если it == vec.end(), вызов insert становится эквивалентным вызову push_back; если it == vec.begin(), этот вызов можно считать неэффективной версией push_front. Обратите внимание, что если вставка выполняется в любую позицию, кроме конца вектора, все элементы, следующие во внутреннем массиве за позицией вставки, будут перемещены вправо, чтобы освободить место; эта операция может стоить очень дорого. Существует несколько перегруженных версий .insert(). Вообще говоря, желательно избегать использования любой из них, но вы должны знать их, чтобы расшифровывать некоторые загадочные сообщения об ошибках (или загадочные ошибки времени выполнения), которые появляются, если по ошибке передать .insert() неверные аргументы, а механизм перегрузки ошибочно выберет не ту версию, которую вы ожидали: std::vector v = {1, 2}; std::vector w = {5, 6}; // Вставить единственный элемент.

90    Глава 4. Зоопарк контейнеров v.insert(v.begin() + 1, 3); assert((v == std::vector{1, 3, 2})); // Вставить n копий единственного элемента. v.insert(v.end() - 1, 3, 4); assert((v == std::vector{1, 3, 4, 4, 4, 2})); // Вставить целый диапазон элементов. v.insert(v.begin() + 3, w.begin(), w.end()); assert((v == std::vector{1, 3, 4, 5, 6, 4, 4, 2})); // Вставить список элементов. v.insert(v.begin(), {7, 8}); assert((v == std::vector{7, 8, 1, 3, 4, 5, 6, 4, 4, 2}));

vec.emplace(it, args...) относится к insert, так же как emplace_back относится к push_back: это версия идеальной передачи функции из стандарта C++03. Используйте emplace и emplace_back вместо insert и push_back везде, где возможно. vec.erase(it) стирает единственный элемент в середине вектора, находящийся в позиции, определяемой итератором it. Существует также версия с двумя итераторами, vec.erase(it1, it2), которая стирает непрерывный диапазон элементов. Обратите внимание, это та самая версия с двумя итераторами, которая использовалась в реализации идиомы стирания-удаления в предыдущей главе. Чтобы удалить только последний элемент из вектора, можно использовать вызов vec.erase(vec.end()-1) или vec.erase(vec.end()-1, vec.end()); но, так как это довольно распространенная операция, в стандартную библиотеку был добавлен синоним vec.pop_back(). Вы можете реализовать стек, динамически изменяющий размеры, не используя никаких других функций, кроме push_back() и pop_back() вектора std::vector.

Ловушки vector Шаблон std::vector имеет один специальный случай: std::vector. Так как тип данных bool может иметь только два значения, восемь значений bool можно упаковать в один байт. std::vector использует эту оптимизацию, а это значит, что такой вектор занимает памяти в восемь раз меньше, чем можно было бы ожидать. Недостаток такой упаковки в том, что значение, возвращаемое vector::operator[], не может иметь тип bool&, потому что вектор не хранит логические значения в отдельно адресуемых ячейках памяти. По этой причине operator[] возвращает значение специализированного типа std::vector::reference, который преобразуется в тип bool, но само таковым не является (такие типы, как в этом случае, часто называют «прокситипами» или «прокси-ссылками»).

Рабочая лошадка: std::vector    91

размер=10

емкость=64

Рис. 4.3. Вектор типа std::vector Результат operator[] const «официально» имеет тип bool, но на практике operator[] const в некоторых библиотеках (например, libc++) возвращает прокси-тип. Это означает, что код, использующий vector, не только обладает тонкими особенностями, но иногда оказывается непереносимым; я советую по возможности не использовать vector: std::vector vb = {true, false, true, false}; // vector::reference имеет только одну общедоступную функцию-член: vb[3].flip(); assert(vb[3] == true); // Следующая строка не компилируется! // bool& oops = vb[0]; auto ref = vb[0]; assert((!std::is_same_v)); assert(sizeof vb[0] > sizeof (bool)); if (sizeof std::as_const(vb)[0] == sizeof (bool)) { puts("Your library vendor is libstdc++ or Visual Studio"); } else { puts("Your library vendor is libc++"); }

Ловушки в конструкторах перемещения без noexcept Вспомним реализацию vector::resize() из раздела «Изменение размера std::vector». Когда изменяется размер вектора, происходит перераспределение его внутреннего массива и перемещение элементов в новый массив – если только тип элемента поддерживает «конструирование без исключений при перемещении», иначе элементы копируются! То есть изменение размера

92    Глава 4. Зоопарк контейнеров вектора вашего собственного типа будет необоснованно «пессимизировано»1, если только вы не снабдите свой конструктор перемещения спецификацией noexcept. Взгляните на определения следующих классов: struct Bad { int x = 0; Bad() = default; Bad(const Bad&) { puts("copy Bad"); } Bad(Bad&&) { puts("move Bad"); } }; struct Good { int x = 0; Good() = default; Good(const Good&) { puts("copy Good"); } Good(Good&&) noexcept { puts("move Good"); } }; class ImplicitlyGood { std::string x; Good y; }; class ImplicitlyBad { std::string x; Bad y; };

Проверим поведение этих классов по отдельности, как показано ниже. Вызов

test() выведет "copy Bad move Good copy Bad move Good". Похоже на мантру! template void test_resizing() { std::vector vec(1); // Вынудить вектор выполнить перераспределение внутреннего массива. vec.resize(vec.capacity() + 1); } void test() { test_resizing(); test_resizing(); test_resizing(); test_resizing(); }

Это очень тонкая и малопонятная особенность, которая способна сущест­ венно влиять на производительность вашего кода на C++. Главное правило: 1



Пессимизация – противоположность оптимизации. – Прим. перев.

Быстрый гибрид: std::deque    93 всякий раз, объявляя свой конструктор перемещения или функцию перестановки (swap), добавляйте спецификатор noexcept.

Быстрый гибрид: std::deque По аналогии с std::vector, двусторонняя очередь std::deque служит интерфейсом к непрерывному массиву – она поддерживает произвольный доступ и хранит элементы в непрерывных блоках памяти, что обеспечивает дружест­ венность к кешу процессора. Но, в отличие от vector, std::deque поддерживает «фрагментарную» непрерывность хранения элементов. Единственный контейнер deque может состоять из произвольного количества «фрагментов», каждый из которых хранит фиксированное число элементов. Вставка новых элементов с любого конца контейнера стоит достаточно дешево; но вставка в середину все еще обходится дорого. В памяти deque хранится примерно так, как показано на рис. 4.4.

двусторонний массив массивов емкость=5

первый=14 размер=3

Рис. 4.4. Контейнер deque std::deque поддерживает те же функции-члены, что и std::vector, включая перегруженный operator[]. В дополнение к векторным методам push_back и pop_back, deque предоставляет эффективные версии push_front и pop_front. Обратите внимание, что многократный вызов push_back для добавления элементов в вектор рано или поздно приведет к перераспределению внутреннего массива и аннулированию всех ваших итераторов, указателей и ссылок на элементы внутри контейнера. При использовании deque также происходит аннулирование итераторов, но отдельные элементы никогда не меняют своих адресов, если только вы не будете вставлять или стирать элементы в середине очереди (в этом случае элементы с одного или с другого конца будут сдвинуты внутрь, чтобы заполнить пробел):

94    Глава 4. Зоопарк контейнеров std::vector vec = {1, 2, 3, 4}; std::deque deq = {1, 2, 3, 4}; int *vec_p = &vec[2]; int *deq_p = &deq[2]; for (int i=0; i < 1000; ++i) { vec.push_back(i); deq.push_back(i); } assert(vec_p != &vec[2]); assert(deq_p == &deq[2]);

Другое достоинство std::deque – отсутствие специального случая для std::deque; контейнер обеспечивает единообразный общедоступный интерфейс независимо от типа T. Недостаток std::deque – операции инкремента, декремента и разыменования итераторов обходятся намного дороже, потому что им приходится шагать по массиву указателей, как показано на рис. 4.4. Это достаточно серьезный недостаток, чтобы отдать предпочтение вектору, если не требуется быст­ рая вставка и удаление с обоих концов контейнера.

Особый набор возможностей: std::list Контейнер std::list представляет связный список элементов в памяти. Схематически его можно представить, как показано на рис. 4.5.

хвост

голова

размер=3

узел

узел предыдущий следующий

предыдущий следующий

узел предыдущий следующий

Рис. 4.5. Связный список std::list Обратите внимание, что каждый узел в списке содержит указатели на следующий и предыдущий узлы, то есть это двусвязный список. Преимущество двусвязного списка – в возможности перемещения итераторов в обоих на-

Особый набор возможностей: std::list    95 правлениях – вперед и назад. То есть итератор std::list::iterator является двунаправленным (но не с произвольным доступом; для получения n-го элемента списка все еще требуется время O(n)). std::list поддерживает почти те же операции, что и std::vector, кроме тех, которые требуют произвольного доступа (как operator[]). И он может позволить себе функции-члены вставки и извлечения элементов из начала и конца списка, потому что для их реализации не требуется использовать дорогостоящие операции перемещения. В целом std::list имеет намного худшую производительность, чем структуры с непрерывными массивами, такие как std::vector или std::deque, потому что следование по указателям, ссылающимся на адреса, разбросанные в «случайном» порядке, для кеша процессора намного сложнее, чем следование по указателям, ссылающимся на адреса, идущим подряд. Поэтому std::list обычно следует рассматривать как нежелательный контейнер; используйте его только в алгоритмах, где его преимущество перед vector неоспоримо.

Какие отличительные особенности имеет std::list? Во-первых, списки не страдают проблемой аннулирования итераторов! lst. push_back(v) и lst.push_front(v) всегда выполняются за постоянное время

и не требуют «перераспределения» или «перемещения» данных. Во-вторых, многие операции, меняющие содержимое контейнера, которые дорого стоят при использовании вектора или требуют создания временного хранилища, для списков обходятся очень дешево. Вот несколько примеров: lst.splice(it, otherlst) добавляет содержимое otherlst в lst, как при многократном вызове lst.insert(it++, other_elt); за исключением того, что «вставленные» узлы фактически исключаются из списка otherlst. Операция splice целиком может быть выполнена перестановкой пары указателей. После этой операции otherlst.size() == 0. lst.merge(otherlst) аналогично добавляет содержимое otherlst в lst простой перестановкой указателей, но имеет эффект «слияния сортированных списков». Например: std::list a = {3, 6}; std::list b = {1, 2, 3, 5, 6}; a.merge(b); assert(b.empty()); assert((a == std::list{1, 2, 3, 3, 5, 6, 6}));

Как обычно для операций в STL, вовлекающих сравнение, существует версия, принимающая компаратор: lst.merge(otherlst, less). Другая операция, которую можно выполнить простой перестановкой указателей, – обращение списка на месте: lst.reverse() меняет местами все указатели «следующий» и «предыдущий» так, что голова становится хвостом списка, и наоборот.

96    Глава 4. Зоопарк контейнеров Обратите внимание, что все эти операции изменяют список на месте и обычно возвращают void. Еще одна операция, которая стоит недорого в связных списках (но не в непрерывных контейнерах), – удаление элементов. Как рассказывалось в главе 3 «Алгоритмы с парами итераторов», стандартная библиотека STL включает такие алгоритмы, как std::remove_if и std::unique для использования с непрерывными контейнерами; эти алгоритмы перемещают «удаляемые» элементы в конец контейнера, чтобы потом их можно было удалить все разом одним вызовом erase(). В std::list перемещение элементов обходится дороже, чем непосредственное удаление на месте. Поэтому std::list включает следующие функции-члены с именами, к сожалению, похожими на имена нестирающих алгоритмов:  l st.remove(v) удаляет и стирает все элементы, равные v;  lst.remove_if(p) удаляет и стирает все элементы e, удовлетворяющие предикату p(e) с одним аргументом;  lst.unique() удаляет и стирает все элементы, кроме первого в каждой группе идущих подряд уникальных элементов. Как обычно для операций, использующих сравнение, существует версия с компаратором: lst.unique(p), удаляющая и стирающая элементы e2, для которых p(e1, e2) возвращает true;  lst.sort() сортирует список на месте. Это особенно полезная операция, потому что алгоритм std::sort(ctr.begin(), ctr.end()) не работает с std::list::iterator, не поддерживающим произвольный доступ. Довольно странным выглядит то обстоятельство, что lst.sort() может сор­тировать содержимое только всего контейнера, а не поддиапазон, как это делает std::sort. Если вам потребуется отсортировать только часть списка lst, это можно сделать – как показано ниже – простой перестановкой пары указателей! std::list lst = {3, 1, 4, 1, 5, 9, 2, 6, 5}; auto begin = std::next(lst.begin(), 2); auto end = std::next(lst.end(), -2); // Сортировать диапазон [begin, end) std::list sub; sub.splice(sub.begin(), lst, begin, end); sub.sort(); lst.splice(end, sub); assert(sub.empty()); assert((lst == std::list{3, 1, 1, 2, 4, 5, 9, 6, 5}));

Список без удобств std::forward_list    97

Список без удобств std::forward_list Стандартный контейнер std::forward_list – это связный список, подобный std::list, но с меньшим количеством удобств – он не позволяет получить его размер и выполнить обход элементов в обратном направлении. В памяти он хранится так же, как std::list, но его узлы имеют меньший размер (см. рис. 4.6).

голова

размер=3

узел

узел

следующий

следующий

узел следующий

Рис. 4.6. Упрощенный список std::forward_list Тем не менее std::forward_list сохраняет почти «отличительные особенности» std::list. Единственные операции, которых он не поддерживает: splice (потому что она выполняет операцию вставки «перед» заданным итератором) и push_back (потому что ей требуется выполнять поиск хвоста за постоянное время). forward_list заменяет эти отсутствующие функции-члены их версиями _after:  flst.erase_after(it) стирает элемент, следующий за указанным;  flst.insert_after(it, v) вставляет новый элемент вслед за указанным;  flst.splice_after(it, otherflst) вставляет элементы из otherflst вслед за указанным. Так же как в случае с std::list, используйте forward_list только в алгоритмах, где его преимущество неоспоримо.

98    Глава 4. Зоопарк контейнеров

Абстракции с использованием std::stack и std::queue Мы познакомились уже с тремя стандартными контейнерами, имеющим функции-члены push_back() и pop_back() (а также back(), которая не упоминалась выше – она возвращает ссылку на последний элемент в контейнере). Эти операции понадобятся нам, если у нас появится желание реализовать структуру данных типа стек. Стандартная библиотека включает удобную абстракцию стека с именем (что совершенно неудивительно) std::stack. Однако, в отличие от контейнеров, представленных до сих пор, std::stack принимает дополнительный параметр типа. std::stack представляет стек с элементами типа T, внутреннее хранилище которого управляется экземпляром контейнерного типа Ctr. Например, stack использует вектор для управления элементами; stack использует список и т. д. По умолчанию шаблонный параметр Ctr принимает значение std::deque; как вы помните, deque потреб­ ляет больше памяти, чем вектор, но обладает тем преимуществом, что никогда не производит перераспределение внутреннего массива и не перемещает элементы после вставки. Работая с std::stack, вы должны ограничивать себя только операциями push (соответствует операции push_back внутреннего контейнера), pop (соответствует операции pop_back), top (соответствует операции back) и некоторыми другими вспомогательными операциями, такими как size и empty: std::stack stk; stk.push(3); stk.push(1); stk.push(4); assert(stk.top() == 4); stk.pop(); assert(stk.top() == 1); stk.pop(); assert(stk.top() == 3);

Одной из необычных особенностей std::stack является поддержка операторов сравнения ==, !=, =, которые сравнивают внутренние контейнеры (используя семантику, определяемую самими контейнерами). Поскольку обычно внутренние контейнеры сравнивают свои значения в лексикографическом порядке, два стека также будут сравниваться в «лексикографическом порядке, снизу вверх». std::stack a, b; a.push(3); a.push(1); a.push(4); b.push(2); b.push(7); assert(a != b); assert(a.top() < b.top()); // то есть 4 < 7 assert(a > b); // потому что 3 > 2

Удобный адаптер: std::priority_queue    99 На практике, безусловно, найдут применение == и !=, также можно использовать operatorfirst == "hello");

Как видите, интерфейс к этой функциональной возможности не так прост, как lst.splice(it, otherlst); тонкости интерфейса – одна из причин, почему он был включен в стандартную библиотеку только в C++17. Хотелось бы обратить ваше внимание на следующее интересное обстоятельство: представьте, что вы извлекли узел из множества и затем, до того, как вам удалось вставить его в целевое множество, возбуждается исключение. Что случится с осиротевшим узлом – произойдет ли утечка? Как оказывается, проектировщики библио­теки предвидели эту возможность; если вызов деструктора дескриптора узла произойдет до того, как этот дескриптор будет вставлен на место, он благополучно освободит память, занимаемую узлом. То есть фактически extract (без последующего вызова insert) действует в точности как erase!

Хеши: std::unordered_set и std::unordered_map Шаблонный класс std::unordered_set представляет хеш-таблицу с цепочками, то есть массив фиксированного размера, состоящий из элементов «корзин», каждый из которых хранит односвязный список элементов данных. По мере добавления новых элементов данных каждый из них помещается в односвязный список с «хешем», соответствующим значению элемента. Это почти то же самое, что HashSet в Java. В памяти этот контейнер хранится, как показано на рис. 4.10. Хеш-таблицы детально описываются во множестве книг, и std::unordered_ set даже с натяжкой нельзя назвать современным достижением; но, так как он устраняет некоторое количество указателей, этот контейнер обычно показывает более высокую производительность, чем древовидный std::set. Чтобы избавиться от оставшихся указателей, можно заменить связные списки приемом с названием «открытая адресация». Его обсуждение далеко выходит за рамки этой книги, но вам определенно стоит познакомиться с ним, если производительность std::unordered_set покажется вам недостаточной.

110    Глава 4. Зоопарк контейнеров

массив «корзин» голова

размер=3

узел следующий

узел следующий

узел следующий

Рис. 4.10. Организация контейнера std::unordered_set в памяти std::unordered_set задумывался как упрощенная замена std::set, поэтому поддерживает тот же интерфейс: insert и erase, плюс итерации с использованием begin и end. Но, в отличие от std::set, std::unordered_set хранит элементы не в порядке сортировки (это неупорядоченная коллекция) и поддерживает только прямые итераторы, тогда как std::set поддерживает двунаправленные. (Взгляните еще раз на рис. 4.10, где можно видеть указатели «следующий», но нет указателей «предыдущий», из-за чего итерации в обратном направлении в std::unordered_set невозможны.) std::unordered_map связан с std::unordered_set так же, как std::map связан с std::set. То есть в памяти он хранится точно так же, только вместо ключей хранит пары ключ/значение. Так же как set и map, которые принимают необязательный параметр с компаратором, unordered_set и unordered_map тоже принимают два дополнительных параметра: Hash (по умолчанию std::hash) и KeyEqual (по умолчанию std::equal_to, то есть operator==). Если передать другую хеш-функцию или другую функцию сравнения ключей, хеш-таблица будет использовать их вместо функций по умолчанию. Это может пригодиться для взаимодействий с традиционными классами на C++, не реализующими семантику значений или operator==: class Widget { public: virtual bool IsEqualTo(Widget const *b) const; virtual int GetHashValue() const; };

Хеши: std::unordered_set и std::unordered_map     111 struct myhash { size_t operator()(const Widget *w) const { return w->GetHashValue(); } }; struct myequal { bool operator()(const Widget *a, const Widget *b) const { return a->IsEqualTo(b); } }; std::unordered_set s;

массив «корзин» голова

размер=3

узел следующий

узел следующий

узел следующий

Рис. 4.11. Организация контейнера std::unordered_map в памяти

Фактор загрузки и списки в корзинах Так же как HashSet в Java, std::unordered_set открывает доступ ко всей административной информации о корзинах. Но может так случиться, что вам никогда не понадобится обращаться к этим функциям!  s .bucket_count() возвращает текущее количество корзин в массиве;  s.bucket(v) возвращает индекс i корзины, где находится элемент v, если он имеется в данной коллекции unordered_set;  bucket_size(i) возвращает количество элементов в i-й корзине; важно заметить, что всегда соблюдается условие s.count(v) );

Манипулирование значениями кортежа Большинство из этих функций и шаблонов удобно использовать только в контексте метапрограммирования шаблонов; вам едва ли придется использовать их в повседневной работе:  std::get(t): возвращает ссылку на I-й элемент в t;  std::tuple_size_v: возвращает размер указанного кортежа. Так как это константное свойство типа кортежа времени компиляции, оно выражается как шаблон переменной, параметризованный этим типом. Если вы предпочитаете использовать синтаксис, который смотрится более естественно, можете написать вспомогательную функцию любым из следующих способов: template constexpr size_t tuple_size(T&&) { return std::tuple_size_v; } template constexpr size_t simpler_tuple_size(const std::tuple&)

Работа с std::tuple    121 { return sizeof...(Ts); }

 std::tuple_element_t: возвращает тип I-го элемента данного типа кортежа. И снова стандартная библиотека дает более неудобный способ получения этой информации, чем основной язык. Вообще, чтобы определить тип I-го элемента кортежа, достаточно воспользоваться выражением decltype(std::get(t));  std::tuple_cat(t1, t2, t3...): объединяет указанные кортежи, выс­ траивая их друг за другом;  s td::forward_as_tuple(a, b, c...): создает кортеж ссылок, в точности как std::tie; но если std::tie требует ссылки lvalue, то std::forward_ as_tuple принимает на входе любые ссылки и переправляет их в кортеж так, что потом их можно извлечь с помощью std::get(t)...: template void run_zeroarg(const F& f); template void run_multiarg(const F& f, Args&&... args) { auto fwd_args = std::forward_as_tuple(std::forward(args)...); auto lambda = [&f, fwd_args]() { std::apply(f, fwd_args); }; run_zeroarg(f); }

Замечание об именованных классах Как мы видели в главе 4 «Зоопарк контейнеров», когда сравнивали

std::array со структурой struct Vec3, использование шаблон-

ного класса из STL может сократить затраты времени на разработку и устранить источники ошибок за счет повторного использования проверенных компонентов STL; но может сделать код трудночитаемым или дать вашему типу излишнюю функциональность. В нашем примере в главе 4 «Зоопарк контейнеров» выбор std::array оказался неудачным по сравнению с Vec3, потому что реализует нежелательный operatorcall_on_me([](auto sb) { assert(sb.use_count() == 2); }); Widget w; try { w.call_on_me([](auto) {}); } catch (const std::bad_weak_ptr&) { puts("Caught!"); } }

Подсчет ссылок с std::shared_ptr    159

Странно рекурсивный шаблон проектирования Возможно ,вы заметили, особенно после предыдущего примера, что, наследуя enable_shared_from_this, вы должны указывать имя своего базового класса в списке параметров типов! Этот шаблон «X наследует A» известен как «Странно рекурсивный шаблон проектирования» (Curiously Recurring Template Pattern, CRTP). Он используется, когда какой-то аспект базового класса зависит от производного класса. Например, в данном случае имя производного класса внедряется в тип возвращаемого значения метода shared_from_this. Другой распространенный случай применения CRTP – когда требуется включить в базовый класс некоторое поведение производного класса. Например, используя CRTP, можно написать базовый шаблонный класс с оператором operator+, возвращающим значение любого производного класса, реализующего operator+= и конструктор копирования. Обратите внимание на обязательное приведение static_cast типа addable к Derived, чтобы обеспечить вызов конструктора копирования класса Derived, а не базового класса addable: template class addable { public: auto operator+(const Derived& rhs) const { Derived lhs = static_cast(*this); lhs += rhs; return lhs; } };

Фактически это точная реализация службы boost::addable из библиотеки Boost Operators, за исключением того, что boost::addable использует так называемый «трюк Бартона-Накмена» (Barton-Nackman trick), чтобы сделать свой operator+ дружественной свободной функцией, а не функцией-членом: template class addable { public: friend auto operator+(Derived lhs, const Derived& rhs) { lhs += rhs; return lhs; } };

Даже если вы нигде не используете enable_shared_from_this в своем коде, вы должны знать о «Странно рекурсивном шаблоне проектирования» и быть готовыми применить его на практике, когда потребуется «внедрить» некоторое поведение производного класса в метод базового класса.

160    Глава 6. Умные указатели

Заключительное замечание Мини-экосистема shared_ptr, weak_ptr и enable_shared_from_this – одна из лучших элементов современного C++; она придает коду безопасность языков с автоматизированной сборкой мусора, сохраняя скорость и предсказуемость моментов уничтожения объектов, которые всегда отличали C++. Однако старайтесь не злоупотреблять указателями shared_ptr! Большая часть кода на C++ не нуждается в применении shared_ptr, потому что не требует общего владения объектами, размещенными в куче. Вы в первую очередь должны избегать использования кучи (используя семантику значения); во вторую очередь вы должны гарантировать, что все объекты в куче имеют единственного владельца (с помощью std::unique_ptr); и только когда ни то, ни другое невозможно, можно подумать о совместном владении и применении std::shared_ptr.

Обозначение неисключительности с observer_ptr Итак, мы познакомились с двумя или тремя разными типами умных указателей (в зависимости от того, считать ли weak_ptr самостоятельным указателем или только лишь билетом на приобретение shared_ptr). Каждый из этих типов указателей несет в себе информацию, которую удобно использовать для управления жизненным циклом. Например, что можно сказать о семантике следующих двух функций C++, имея перед глазами только их сигнатуры? void remusnoc(std::unique_ptr p); std::unique_ptr recudorp();

Мы видим, что remusnoc принимает unique_ptr по значению, то есть функции remusnoc передается право владения управляемым объектом. Чтобы вызвать эту функцию, мы должны обладать единоличным правом владения объектом Widget, и после вызова функции мы уже не сможем получить доступ к нему. Мы не знаем, что сделает remusnoc с объектом Widget – уничтожит его, сохранит в памяти или передаст его в какой-то другой объект или поток выполнения; но что можно сказать определенно – объект выходит из-под нашего контроля. Функция remusnoc является потребителем объектов Widget. Также можно сказать, что, вызывая remusnoc, мы должны единолично владеть объектом Widget, который был размещен в куче оператором new и может быть безопасно уничтожен оператором delete! И наоборот: вызывая recudorp, я точно знаю, что любой Widget, который вернет эта функция, будет принадлежать только мне. Это не ссылка на чей-то чужой объект; это не указатель на некоторые статические данные. Этот объект размещен в куче и принадлежит только мне. Даже если я сразу же вызову метод

Обозначение неисключительности с observer_ptr     161 .release() полученного объекта и присвою простой указатель на некоторую «древнюю» структуру, я буду уверен, что это безопасно, потому что я – единст­ венный владелец возвращаемого значения. А что можно сказать о семантике такой функции? void suougibma(Widget *p);

Она неоднозначна. Эта функция может принимать или не принимать на себя владение переданным указателем. Сказать что-то определенное можно, только исходя из описания suougibma или стилистических соглашений, принятых в нашем проекте (таких как «передача простого указателя никогда не означает передачу права владения», что вполне разумно), но, имея только сигнатуру, нельзя делать никаких предположений. Иначе говоря, unique_ptr является словарным типом для выражения передачи владения, тогда как T* вообще не является словарным типом; в C++ это является эквивалентом бессмысленного слова или пятен Роршаха в том смысле, что два человека не обязательно увидят в нем один и тот же смысл. Поэтому, обнаружив в своем коде множество невладеющих указателей, у вас может возникнуть желание иметь словарный тип, представляющий идею невладеющего указателя. (Конечно, первым вашим действием в такой ситуации должна стать организация передачи ссылок вместо указателей везде, где только возможно, но предположим, что вы уже исчерпали эту возможность.) Такой словарный тип существует, но он (пока) не включен в стандартную библиотеку C++ – как выразился Уолтер Браун (Walter Brown): «Это самый тупой из умных указателей», – и это всего лишь класс-обертка вокруг простого, невладеющего указателя: template class observer_ptr { T *m_ptr = nullptr; public: constexpr observer_ptr() noexcept = default; constexpr observer_ptr(T *p) noexcept : m_ptr(p) {} T *get() const noexcept { return m_ptr; } operator bool() const noexcept { return bool(get()); } T& operator*() const noexcept { return *get(); } T* operator->() const noexcept { return get(); } }; void revresbo(observer_ptr p);

Благодаря наличию observer_ptr в нашем арсенале можно с полной уверенностью сказать, что функция revresbo просто наблюдает (observes) за своим аргументом – она определенно не получает никаких прав на владение им. Фактически можно даже сказать, что она не хранит копию переданного указателя, потому что допустимость этого указателя зависит от жизненного цикла управляемого объекта, а revresbo явно заявляет, что никак не участвует в этом

162    Глава 6. Умные указатели цикле. Если бы она захотела как-то повлиять на жизненный цикл управляемого объекта, она заявила бы об этом явно, потребовав у вызывающего кода unique_ptr или shared_ptr. Потребовав observer_ptr, функция revresbo «отказывается» от любых притязаний на владение объектом. Как я уже сказал, observer_ptr не является частью стандарта C++17. Одно из основных возражений, препятствующих этому, – его ужасное имя (поскольку оно никак не связано с шаблоном проектирования «Наблюдатель» (Observer)). Существует также большое количество знающих людей, которые могли бы сказать, что T* должен стать словарным типом для обозначения «невладеющего указателя» и что весь старый код, использующий T* для передачи прав владения, должен быть переписан или, по крайней мере, снабжен аннотациями в виде таких конструкций, как owner. В настоящее время именно этот подход рекомендуется редакторами «C++ Core Guidelines», включая создателя C++ Бьерна Страуструпа (Bjarne Stroustrup). Но, как бы то ни было, одно можно сказать наверняка: никогда не используйте простые указатели для передачи права владения!

Итоги В этой главе мы познакомились с некоторыми аспектами умных указателей. std::unique_ptr – словарный тип для представления владеющего указателя и передачи права владения; старайтесь использовать его вместо T*. Подумайте о возможности использования observer_ptr в ситуациях, когда право владения не передается или когда применение простого указателя T* может выглядеть двусмысленно для читателя. std::shared_ptr – хороший (и стандартный) инструмент для случаев совместного владения, когда участие в жизненном цикле единственного управляемого объекта принимает множество заинтересованных сторон. std::weak_ptr – это «билет на shared_ptr»; он предоставляет метод .lock() вместо operator*. Если вашему классу потребуется получить указатель shared_ptr на себя самого, унаследуйте std::enable_shared_from_ this. Наследование должно быть публичным, и желательно как можно ближе к листь­ям в дереве наследования. Не злоупотребляйте этой возможностью в ситуациях, где явно не требуется совместное владение! Никогда не пачкайте руки о простые указатели: используйте make_unique и make_shared для создания объектов в куче и управления ими. И вспоминайте о странно рекурсивном шаблоне проектирования, когда вам потребуется «внедрить» некоторое поведение производного класса в метод базового класса. В следующей главе мы поговорим о другой разновидности «совместного использования», которая возникает в многопоточном программировании.

Глава

7

Конкуренция В предыдущей главе мы узнали, как std::shared_ptr реализует подсчет ссылок, чтобы дать возможность совместно управлять жизненным циклом объектов сразу нескольким заинтересованным сторонам, которые, возможно, даже не подозревают о существовании друг друга – например, они могут выполняться в разных потоках. В версиях C++ до C++11 это сразу же стало бы камнем преткновения: если один код уменьшает счетчик ссылок, а другой, выполняющийся в другом потоке, в это же время находится на полпути к уменьшению, разве не возникает состояния гонки и, как результат, небезопасного поведения? В C++ до C++11 можно сразу ответить «да». (Фактически в C++ до C++11 отсутствовало стандартное понятие «потоков», поэтому на вопрос выше вполне можно было бы ответить, что он не имеет смысла.) Однако с выходом стандарта C++11 мы получили стандартную модель памяти, которая принимает во внимание такие понятия, как «потоки» и «безопасность в многопоточной среде», поэтому вопрос приобретает осмысленность и ответ на него: категоричное «нет»! Счетчик ссылок в std::shared_ptr гарантированно защищен от состояния гонки; и в этой главе вы увидите, как можно реализовать аналогичные потокобезопасные конструкции с использованием инструментов из стандартной библиотеки. Эта глава охватывает следующие темы:  различия между volatile T и std::atomic;  std::mutex, std::lock_guard и std::unique_lock;  std::recursive_mutex и std::shared_mutex;  std::condition_variable и std::condition_variable_any;  std::promise и std::future;  std::thread и std::async;  опасности применения типа std::async и как сконструировать пул потоков для его замены.

Проблемы с volatile Если последние десять лет вы провели в анабиозе или программировали на старом добром C, у вас может возникнуть вопрос: «Какие проблемы могут быть

164    Глава 7. Конкуренция связаны с ключевым словом volatile? Когда я хочу гарантировать сохранение значения в памяти, я использую volatile». Официальная семантика volatile заключается в том, что доступ к памяти происходит в строгом соответствии с правилами абстрактной машины, то есть компилятору запрещено переупорядочивать обращения к таким переменным или объединять несколько обращений в одно. Например, компилятор не должен предполагать, что значение x останется неизменным между двумя операциями чтения; он обязан генерировать машинный код, выполняющий два чтения из памяти, как в примере ниже: volatile int& x = memory_mapped_register_x(); volatile bool& y = memory_mapped_register_y(); int stack; stack = x; // чтение y = true; // запись stack += x; // чтение

Если бы переменная x не была объявлена изменчивой (volatile), тогда компилятор мог бы переупорядочить инструкции, как показано ниже: stack = 2*x; // чтение y = true; // запись

Компилятор мог бы поступить так (если бы x не была объявлена изменчивой), потому что запись в логическую переменную y никак не влияет на содержимое x. Но, так как x объявлена изменчивой, подобная оптимизация переупорядочением запрещена. Чаще всего ключевое слово volatile используется по причине, которая вытекает из имен, использованных в примере выше: когда вы работаете напрямую с аппаратурой, такой как регистры, отображаемые в память, когда что-то на уровне исходного кода выглядит как операции чтения и записи, но в дейст­ вительности отображается в более сложные операции с аппаратурой. В  предыдущем примере переменная x может служить представлением одного из аппаратных буферов, а запись в y может служить сигналом аппаратуре загрузить следующие четыре байта данных в регистр x. Это можно рассматривать как перегрузку оператора, но на аппаратном уровне. А если фраза «перегрузка оператора на аппаратном уровне» кажется вам сумасшедшей, то, вероятно, у вас нет причин использовать volatile в своих программах! Вот что делает ключевое слово volatile. Но почему его нельзя использовать, чтобы сделать наши программы безопасными в многопоточном окружении? По сути, проблема volatile в том, то это очень старый инструмент. Это ключевое слово существовало еще до того, как C++ отделился от C, и присутствовало еще в C, введенное стандартом 1989 года. В то время мало кого волновали вопросы многопоточности и компиляторы были намного проще, в том смысле, что некоторые потенциально проблематичные оптимизации

Проблемы с volatile    165 еще не были придуманы. К концу 1990-х – началу 2000-х, когда отсутствие в C++ модели памяти с поддержкой многопоточности превратилось в серьезную проблему, стало уже слишком поздно переделывать volatile для поддержки потоко­безопасного доступа к памяти, потому что все производители компиляторов уже реализовали ключевое слово volatile и документально закрепили его действие. Смена правил в тот момент нарушила бы работоспособность огромного объема кода, причем эта беда затронула бы в основном код низкоуровневого аппаратного интерфейса, что было бы крайне нежелательно. Вот несколько примеров, демонстрирующих гарантии, которые хотелось бы иметь для безопасного доступа к памяти в многопоточном окружении: // Глобальные переменные: int64_t x = 0; bool y = false; void thread_A() { x = 0x42'00000042; y = true; } void thread_B() { if (x) { assert(x == 0x42'00000042); } } void thread_C() { if (y) { assert(x == 0x42'00000042); } }

Допустим, что thread_A, thread_B и thread_C были вызваны одновременно из разных потоков выполнения. Какие ошибки мог бы породить этот код? Вопервых, проверка в thread_B обнаружит в x ноль или or 0x42'00000042. Однако в 32-разрядном компьютере ситуация сложнее; компилятор может реализовать операцию присваивания в thread_A в виде пары инструкций записи, записав сначала число 0x42 в старшую половину x; а затем число 0x42 – в младшую половину x. Если проверка в thread_B запустится в нужный (для ошибки) момент, она может обнаружить в x значение 0x42'00000000. Объявление переменной x изменчиво здесь не поможет; фактически это вообще ничего не даст, потому что 32-разрядная аппаратура просто не поддерживает эту операцию! Было бы хорошо, если бы компилятор обнаруживал попытку использовать атомарное 64-разрядное присваивание и выводил ошибку компиляции, сообщая о невозможности воплощения наших намерений. Иными словами, доступ к переменной, объявленной как volatile, необязательно будет атомарным. На практике они часто являются атомарными и поэтому не требуют

166    Глава 7. Конкуренция использования volatile, но это поведение не является гарантированным, и иногда приходится опускаться до уровня машинного кода, чтобы выяснить, действительно ли получился ожидаемый код. Нам хотелось бы иметь гарантии, что операции будут выполняться атомарно, а в случае невозможности этого получать ошибки компиляции. Теперь рассмотрим функцию thread_C. Она сначала проверяет значение y, и если оно равно true, в переменную x уже должно быть записано окончательное числовое значение. То есть функция проверяет, что запись в x «произошла до» записи в y. Это верно с точки зрения thread_A, по крайней мере, если обе переменные, x и y, объявлены изменчивыми (volatile), потому что, как мы видели, в этом случае компилятору запрещается переупорядочивать операции доступа. Однако это необязательно будет верно с точки зрения thread_C! Если физически thread_C выполняется на другом процессоре, с собственным кешем данных, тогда он может узнать об обновлении значений x и y в разные моменты времени, в зависимости от особенностей обновления соответствующих блоков кеша. Хотелось бы иметь возможность потребовать от компилятора, чтобы тот гарантировал загрузку всего кеша вместе с загрузкой y и мы никогда не получали бы «устаревшего» значения x. Однако на некоторых процессорных архитектурах для этого требуется добавлять в код специальные инструкции или дополнительную логику работы с барьерами в памяти. Компиляторы не генерируют такие инструкции для «старорежимного» ключевого слова volatile, потому что многопоточности уделялось мало внимания, когда оно появилось; а кроме того, такие инструкции могут замедлять операции доступа к volatile-переменным и даже нарушать работоспособность существующего низкоуровневого кода, где volatile используется в старом его понимании. То есть проблема никуда не исчезла, потому что даже при соблюдении последовательности операций с переменными, объявленными с ключевым словом volatile, в одном потоке их обновленные значения могут появляться в другом потоке в ином порядке. Иначе говоря, volatile не гарантирует последовательную согласованность. Нам хотелось бы, чтобы доступ был последовательно согласован с другими операциями доступа. Решение обеих проблем появилось в C++ в 2011 году. Это решение: std::atomic.

Использование std::atomic для безопасного доступа в многопоточной среде Начиная с C++11 заголовок содержит определение шаблонного класса std::atomic. Класс std::atomic можно рассматривать по-разному: как шаблонный класс, такой же как std::vector, с перегруженными операторами, гарантирующими безопасность в многопоточной среде; или как волшебное семейство встроенных типов, имена которых просто содержат угловые скобки. Последний способ более оправдан, потому что предполагает, и небезос-

Использование std::atomic для безопасного доступа в многопоточной среде    167 новательно, что std::atomic частично встроен в компилятор и, как правило, компилятор генерирует наиболее оптимальный код для атомарных операций. Последний способ также объясняет, чем atomic отличается от vector: в std::vector шаблон T может быть любым типом. В std::atomic шаблон T тоже может быть любым типом, но на практике желательно ограничиваться только узким кругом типов, для которых атомарность поддерживается наиболее просто. Подробнее об этом мы поговорим чуть ниже. К узкому кругу типов относятся все целочисленные типы (по крайней мере, с размерностью не больше размера регистров процессора) и указатели. На основных платформах операции с объектами std::atomic этих типов будут выполняться в точности, как нам хотелось бы: // Глобальные переменные: std::atomic x = 0; std::atomic y = false; void thread_A() { x = 0x42'00000042; // атомарная операция! y = true; // атомарная операция! } void thread_B() { if (x) { // Присваивание переменной x выполняется атомарно. assert(x == 0x42'00000042); } } void thread_C() { if (y) { // Присваивание переменной x выполняется "до" // присваивания переменной y, даже с точки зрения // другого потока выполнения. assert(x == 0x42'00000042); } }

std::atomic перегружает оператор присваивания, реализуя атомарную и потокобезопасную версию; а также операторы ++, --, += и -=; и для целочисленных типов, также операторы &=, |= и ^=. Важно понимать разницу между объектами типа std::atomic (которые концептуально находятся «там», в памяти) и короткоживущими значениями типа T (находящимися «прямо здесь», под рукой; например, в регистрах процессора). Так, например, для std::atomic не существует оператора присваивания копированием: std::atomic a, b; a = b; // НЕ КОМПИЛИРУЕТСЯ!

168    Глава 7. Конкуренция Оператора присваивания копированием (как и оператора присваивания пере­мещением) не существует, потому что эта операция не имеет однозначного толкования: имел ли в виду программист, что компьютер должен загрузить значение b в регистр и затем сохранить значение этого регистра в a? В данном случае производятся две атомарные операции, а не одна! Или, может быть, программист имел в виду, что компьютер должен скопировать значение из b в a в рамках одной атомарной операции? Но в этом случае происходит обращение к двум разным участкам в памяти, и большинство компьютеров не поддерживает такого атомарного копирования. Поэтому C++ требует явно описать, что вы имеете в виду: одно атомарное чтение из объекта b в регистр (в C++ представляется неатомарной переменной на стеке) с последующей атомарной записью в объект a: int shortlived = b; // атомарное чтение a = shortlived; // атомарная запись

std::atomic поддерживает функции-члены .load() и .store(v) для тех программистов, кто хочет точно видеть, что делается в каждом шаге. Пользоваться ими совсем необязательно: int shortlived = b.load(); // атомарное чтение a.store(shortlived); // атомарная запись

Фактически, используя эти функции-члены, присваивание можно записать в одной строке кода, как b.store(a.load());, но я не советую поступать так. Запись вызовов двух функций в одной строке не означает, что они непременно выполнятся «друг за другом», и определенно не означает, что вызовы произойдут «атомарно» (как говорилось выше, большая часть аппаратного обеспечения компьютеров не поддерживает такой возможности). И такая форма записи может вводить в заблуждение, заставляя думать, что вызовы происходят «вместе». Работа с многопоточным кодом и без того сложна, поэтому старайтесь в одной строке записывать только одну операцию. Программирование нескольких операций в одной строке увеличивает вероятность появления ошибок. Записывайте по одной атомарной операции в каждой строке; постепенно вы поймете, что такой подход помогает упорядочить процесс мышления и упрощает чтение кода.

Атомарное выполнение сложных операций Возможно, вы заметили, что в списке перегруженных операторов, приводившемся в предыдущем разделе, отсутствуют операторы *=, /=, %=, =. Эти операторы не поддерживаются типом std::atomic и всеми остальными целочисленными атомарными типами, потому что на существующем аппаратном обеспечении трудно обеспечить их эффективную реализацию. Однако даже операции, включенные в std::atomic, нередко требуют применения приемов, удорожающих реализацию.

Использование std::atomic для безопасного доступа в многопоточной среде    169 Допустим, что наш процессор не поддерживает «атомарной инструкции умно­жения», но нам крайне необходимо реализовать operator*=. Как это сделать? Вся хитрость заключается в использовании простой атомарной операции, известной как «сравнить и поменять местами». std::atomic a = 6; a *= 9; // Это недопустимо. // Зато можно так: int expected, desired; do { expected = a.load(); desired = expected * 9; } while (!a.compare_exchange_weak(expected, desired)); // В конце этого цикла значение a будет // "атомарно" умножено на 9.

Метод a.compare_exchange_weak(expected, desired) получает значение a и, если оно равно expected, записывает в a новое значение desired; иначе ничего не делается. Значение a возвращает true, если значение desired было записано, и false в противном случае. Но здесь происходит еще кое-что. Обратите внимание, что в каждой итерации предыдущего цикла выполняется загрузка a в переменную expected, и функция compare_exchange_weak тоже загружает его, чтобы сравнить с expected. Начиная со второй итерации было бы лучше не загружать a повторно, а просто записать в expected значение, которое прочитала функция compare_ exchange_weak. К счастью, создатели a.compare_exchange_weak(expected, desired) предвосхитили наше желание и реализовали запись в expected загруженного значения, если функция должна вернуть false. То есть в сочетании с compare_exchange_weak мы должны использовать значение expected, допускающее возможность изменения, потому что функция принимает его по ссылке. Поэтому предыдущий пример можно переписать так: int expected = a.load(); while (!a.compare_exchange_weak(expected, expected * 9)) { // продолжение цикла }

Переменная desired фактически не нужна, в предыдущем примере она использовалась только лишь потому, что дополнительная инструкция if делает код понятнее. Тип std::atomic имеет маленький секрет: большинство составных операций присваивания реализовано с применением цикла сравнения-замены, как в примере выше. На процессорах с архитектурой RISC эта реализация используется почти всегда. На процессорах x86 такая реализация используется,

170    Глава 7. Конкуренция только если вы хотите использовать значение, возвращаемое оператором, как в инструкции x = (a += b). Если атомарная переменная нечасто изменяется другими потоками, наличие цикла сравнения-замены почти не сказывается на производительности. Но если a изменяется часто, тогда может потребоваться несколько итераций, прежде чем цикл преуспеет. В абсолютно патологических ситуациях можно даже наблюдать зацикливание потока; он может продолжать выполнять цикл снова и снова, пока не исчезнут конфликты с другими потоками. Но обратите внимание, что compare_exchange_weak возвращает false, только когда значение a в памяти изменилось; а это значит, что другие потоки выполнения продвинулись немного вперед. Циклы сравнения-замены сами по себе никогда не заставят программу войти в состояние, когда никакой поток не сможет продвинуться вперед (состояние, известное как «динамическая взаимоблокировка» – «livelock»). Предыдущий абзац звучит страшнее, чем есть на самом деле. В целом не стоит беспокоиться о патологическом поведении, потому что оно проявляется только при очень высокой конкуренции, но даже тогда не вызывает скольконибудь серьезных проблем. Главное, что вы должны вынести из этого раздела, – как можно использовать цикл сравнения-замены для реализации сложных, не встроенных «атомарных» операций в объектах atomic. Просто запомните порядок следования параметров в a.compare_exchange_weak(expected, desired) и что она делает с a: «если a имеет значение expected, записать в a значение desired».

Большие атомарные типы Компилятор распознает и генерирует оптимальный код для std::atomic, когда T является целочисленным типом (включая bool) или типом указателя, таким как void *. А если T – большой тип, такой как int[100]? В таких случаях компилятор обычно вызывает подпрограммы в библиотеке времени выполнения C++, которые производят присваивание под защитой мьютекса. (Мы рассмотрим мьютексы чуть ниже.) Поскольку присваивание выполняется в библиотеке, которая не знает, как копировать произвольные пользовательские типы, стандарт C++17 допускает применение std::atomic только к типам, которые поддерживают тривиальное копирование, то есть безопасно могут копироваться с помощью memcpy. Поэтому, если вам понадобится тип std::atomic, считайте, что вам не повезло – вы должны будете написать его сами. Другая проблема, с которой можно столкнуться при использовании больших (тривиально копируемых) типов с std::atomic, состоит в том, что соответствующие процедуры в библиотеке времени выполнения C++ находятся в другом месте, отдельно от остальной части стандартной библиотеки C++. На некоторых платформах вам придется добавить -latomic в командную строку компоновщика. Но эта проблема проявляется только при использовании боль-

Поочередное выполнение с std::mutex    171 ших типов с std::atomic, а если этого не делать, тогда и проблема отпадает сама собой. Теперь посмотрим, как написать атомарный класс строк!

Поочередное выполнение с std::mutex Допустим, нам нужно написать класс, своим поведением напоминающий тип std::atomic, если бы таковой существовал. То есть нам нужен класс, поддерживающий атомарные операции чтения и записи, безопасные в многопоточной среде, чтобы при одновременном обращении двух потоков к std::string ни один из них не мог бы увидеть строку, пока другой не закончит запись в память, как это имело место в примере с типом int64_t, в разделе «Проблемы с volatile» выше. Лучше всего для реализации такого класса использовать тип std::mutex из стандартной библиотеки. Имя «mutex» (мьютекс) настолько широко используется в технических кругах, что уже воспринимается как самостоятельное слово, но первоначально это имя произошло от двух слов «mutual exclusion» (взаи­моисключающий). Это связано с тем, что мьютекс позволяет гарантировать исключительный доступ к некоторому сегменту кода для одного потока – то есть гарантировать невозможность ситуации, когда «потоки A и B одновременно выполняют этот код». В начале такого критического сегмента кода, чтобы показать нежелательность пересечения с любым другим потоком, мы приобретаем блокировку на соответствующем мьютексе. Покидая этот сегмент кода, мы освобождаем блокировку. Библиотека сама позаботится о том, чтобы никакие два потока не смогли в одно и то же время владеть блокировкой для одного и того же мьютекса. В частности, это означает, что если поток B подошел к точке приобретения блокировки, когда поток A уже захватил ее, поток B должен будет дождаться, пока поток A завершит выполнение критического сегмента кода и освободит блокировку. Пока поток A удерживает блокировку, работа потока B блокируется; поэтому такое поведение часто называют блокирующим. Фразу «приобрести блокировку на мьютексе» часто сокращают до «запереть мьютекс», а «освободить блокировку» – до «отпереть мьютекс». Иногда (хотя и редко) бывает желательно проверить текущее состояние мьютекса. Для этой цели std::mutex поддерживает не только функции-члены .lock() и .unlock(), но также функцию-член .try_lock(), которая возвращает true, если мьютекс удалось запереть, и false, если мьютекс в этот момент уже заперт каким-то другим потоком. В некоторых языках программирования, таких как Java, каждый объект снабжается своим мьютексом; с его помощью, например, в Java реализуются блоки synchronized. В C++ мьютекс является отдельным типом объектов; чтобы воспользоваться мьютексом для управления сегментом кода, вы должны создать отдельный объект мьютекса со своим жизненным циклом. Куда следует поместить мьютекс, чтобы он был единственным объектом и доступным всем,

172    Глава 7. Конкуренция кто пользуется им? Иногда, если существует только один критический сегмент кода, нуждающийся в защите, мьютекс можно заключить в область видимости функции в виде статической переменной: void log(const char *message) { static std::mutex m; m.lock(); // чтобы исключить перемешивания сообщений в stdout puts(message); m.unlock(); }

Ключевое слово static здесь очень важно! Если опустить его, m превратится в обычную локальную переменную на стеке, и каждый поток, вызывающий log, получит свою отдельную копию m. Это никак не помогло бы нам в достижении нашей цели, потому что библиотека гарантирует невозможность захвата двумя потоками одного и того же мьютекса. Но если каждый поток будет запирать и отпирать свой собственный мьютекс, библиотека не сможет ничего противопоставить этому; потому что нет борьбы за обладание мьютексами. Чтобы обеспечить взаимоисключающее выполнение для двух разных функций, то есть чтобы только один поток мог войти в функцию log1 или log2, объект мьютекса следует разместить в области видимости, где он будет доступен обеим функциям: static std::mutex m; void log1(const char *message) { m.lock(); printf("LOG1: %s\n", message); m.unlock(); } void log2(const char *message) { m.lock(); printf("LOG2: %s\n", message); m.unlock(); }

Вообще говоря, когда возникает подобная необходимость, предпочтительнее устранить глобальную переменную, создав класс и включив в него объект мьютекса как переменную-член, например: struct Logger { std::mutex m_mtx; void log1(const char *message) { m_mtx.lock(); printf("LOG1: %s\n", message); m_mtx.unlock();

Поочередное выполнение с std::mutex    173 } void log2(const char *message) { m_mtx.lock(); printf("LOG2: %s\n", message); m_mtx.unlock(); } };

Теперь сообщения, выводимые с помощью разных экземпляров Logger, могут перемежаться, но при конкурентном доступе одному и тому же экземпляру Logger будет запираться один и тот же мьютекс m_mtx, поэтому разные потоки будут блокировать друг друга и по очереди выполнять критические функции log1 и log2.

Правильный порядок «приобретения блокировок» Как рассказывалось в главе 6 «Умные указатели», одной из главных проблем программ, написанных на C и на C++ «старого стиля», является наличие ошибок, связанных с указателями, таких как утечки памяти, повторное освобождение и повреждение кучи, и одним из способов их устранения из программ на C++ «нового стиля» является использование типов RAII, таких как std::unique_ptr. Многопоточное программирование с применением простых мьютексов имеет схожие проблемы с распределением динамической памяти в куче с использованием простых указателей.  Утечка блокировок: можно приобрести блокировку для какого-то определенного мьютекса и по ошибке забыть написать код, освобождающий ее.  Утечка блокировок: можно написать код, освобождающий блокировку, но из-за исключения или преждевременного возврата он не будет выполнен, и мьютекс останется запертым!  Использование защищенных переменных без приобретения блокировки: так как обычный мьютекс – это всего лишь переменная, она физически не связана с переменными, «находящимися под защитой» этого мьютекса. Можно по ошибке обратиться к одной из таких переменных, не приобретя блокировку.  Взаимоблокировка: представьте, что поток A запер мьютекс 1, а поток B запер мьютекс 2. Затем поток A попытался запереть мьютекс 2 (и заблокировался); и пока поток A оставался заблокированным, поток B попытался запереть мьютекс 1 (и тоже заблокировался). Теперь оба потока оказались заблокированными навечно. Это далеко не исчерпывающий список ловушек многопоточного выполнения; например, мы уже коротко обсудили состояние «динамической взаимоблокировки» в сочетании с типом std::atomic. Более полное перечисление

174    Глава 7. Конкуренция ловушек многопоточного программирования и как их избежать, вы найдете в специализированных книгах, посвященных многопоточному, или конкурентному, программированию. В стандартной библиотеке C++ имеется несколько инструментов, которые могут помочь нам избавить многопоточные программы от этих проблем. В отличие от управления памятью, стандартные решения перечисленных проблем не дают 100% гарантии – многопоточное программирование намного сложнее однопоточного, и в отношении него действует одно хорошее правило: не прибегать к нему, если многопоточность не является абсолютно необходимой. Но если без многопоточности не обойтись, стандартная библиотека может оказать вам некоторую помощь. Ошибки, имеющие отношение к «утечкам блокировок», можно устранить использованием идеи RAII. Возможно, вы обратили внимание, что я постоянно использую фразу «приобрести блокировку» вместо «заблокировать»; теперь я объясню – почему. Слово «заблокировать» – это глагол, точно соответствующий коду на C++ mtx.lock(). Но в словосочетании «приобрести блокировку» слово «блокировка» – это существительное. Давайте придумаем тип, воплощающий идею существительного «блокировка»; то есть превращающий ее в существительное (класс RAII), а не глагол (метод класса не-RAII): template class unique_lock { M *m_mtx = nullptr; bool m_locked = false; public: constexpr unique_lock() noexcept = default; constexpr unique_lock(M *p) noexcept : m_mtx(p) {} M *mutex() const noexcept { return m_mtx; } bool owns_lock() const noexcept { return m_locked; } void lock() { m_mtx->lock(); m_locked = true; } void unlock() { m_mtx->unlock(); m_locked = false; } unique_lock(unique_lock&& rhs) noexcept { m_mtx = std::exchange(rhs.m_mtx, nullptr); m_locked = std::exchange(rhs.m_locked, false); } unique_lock& operator=(unique_lock&& rhs) { if (m_locked) { unlock(); } m_mtx = std::exchange(rhs.m_mtx, nullptr); m_locked = std::exchange(rhs.m_locked, false); return *this; } ~unique_lock() {

Powered by TCPDF (www.tcpdf.org)

Поочередное выполнение с std::mutex    175 if (m_locked) { unlock(); } } };

Как следует из имени, std::unique_lock – это RAII-класс, описывающий «уникальное владение» в духе std::unique_ptr. Если придерживаться существительного unique_ptr вместо глаголов new и delete, вы никогда не забудете освободить указатель; а если придерживаться существительного unique_lock вместо глаголов lock и unlock, вы никогда не забудете освободить блокировку. std::unique_lock реализует функции-члены .lock() и .unlock(), но вам редко придется их использовать. Они могут пригодиться, если потребуется приобрести или освободить блокировку в середине блока кода, далеко от точки уничтожения объекта unique_lock. В следующем разделе мы увидим также функцию, которая принимает заблокированный экземпляр unique_lock, освобождает блокировку и вновь приобретает ее в процессе своей работы. Обратите внимание, что unique_lock, как поддерживающий возможность перемещения, должен иметь «пустое» состояние, в точности как unique_ptr. В большинстве случаев не требуется перемещать блокировки; вы просто будете безусловно приобретать блокировку в начале некоторой области и затем так же безусловно освобождать ее в конце. В такой ситуации std::lock_guard. lock_guard сильно напоминает unique_lock, но не поддерживает перемещения и не имеет функций-членов .lock() и .unlock(). Поэтому ему не нужен член m_locked, и его деструктор может без лишних проверок отпереть мьютекс, защищающий объект. В обоих случаях (unique_lock и lock_guard) шаблонный класс парамет­ ризуется типом мьютекса. (Чуть ниже мы познакомимся еще с парой типов мьютексов, но на практике почти всегда вы будете использовать std::mutex.) Стандарт C++17 добавил в язык новую особенность с названием вывод аргумента шаблонного класса, которая в большинстве случаев позволяет избавиться от параметра шаблона, и вместо std::unique_lock просто писать std::unique_lock. Это один из немногих случаев, когда я лично рекомендовал бы положиться на механизм вывода аргумента шаблонного класса, потому что дополнительный параметр типа std::mutex почти не дает читателю полезной информации. Рассмотрим несколько примеров std::lock_guard с использованием вывода аргумента шаблонного класса и без него: struct Lockbox { std::mutex m_mtx; int m_value = 0; void locked_increment() {

176    Глава 7. Конкуренция std::lock_guard lk(m_mtx); m_value += 1; } void locked_decrement() { std::lock_guard lk(m_mtx); // Только в C++17 m_value -= 1; } };

Прежде чем перейти к похожим практическим примерам использования

std::unique_lock, разберем некоторые причины в пользу std::unique_lock.

Всегда связывайте мьютекс с управляемыми данными Взгляните на следующую черновую реализацию потокобезопасного класса StreamingAverage. В ней имеется ошибка. Сможете найти ее? class StreamingAverage { double m_sum = 0; int m_count = 0; double m_last_average = 0; std::mutex m_mtx; public: // Вызывается из единственного потока-производителя void add_value(double x) { std::lock_guard lk(m_mtx); m_sum += x; m_count += 1; // A } // Вызывается из единственного потока-потребителя double get_current_average() { std::lock_guard lk(m_mtx); m_last_average = m_sum / m_count; // B return m_last_average; } // Вызывается из единственного потока-потребителя double get_last_average() const { return m_last_average; // C } // Вызывается из единственного потока-потребителя double get_current_count() const { return m_count; // D } };

Всегда связывайте мьютекс с управляемыми данными    177 Ошибка в строке с комментарием // A, которая записывает новое значение в this->m_count в потоке-производителе. Она подвержена состоянию гонки со строкой // D, которая читает this->m_count в потоке-потребителе. Строка //  A правильно приобретет блокировку this->m_mtx перед записью, но строка //  D отказывается приобретать блокировку, а значит, благополучно прочитает m_count, даже когда строка // A выполнила операцию записи только наполовину. На первый взгляд кажется, что строки // B и // C содержат ту же ошибку. Но в строке // C блокировка не нужна; тогда почему она нужна в строке //  D? Дело все в том, что строка // C вызывается только из потока-потребителя, того же потока, который выполняет запись в m_last_average в строке // B. Так как строки // B и // C выполняются в единственном потоке-потребителе, они не могут выполняться одновременно – по крайней мере, пока поведение программы соответствует комментариям! (Допустим, что комментарии в коде верны. К сожалению, на практике это не всегда так, но в данном примере предположим, что комментарии отражают истину.) У нас есть повод для сомнений: для доступа к m_sum или m_count необходимо заблокировать m_mtx, но этого можно не делать перед обращением к m_ last_average. При усложнении класса в нем может появиться еще несколько мьютексов (хотя в данный момент это явно выглядит как нарушение принципа единственной ответственности, и, вероятно, будет произведен рефакторинг с выделением меньших компонентов). Поэтому на практике желательно помещать мьютексы как можно ближе к переменным, которые он «защищает». Часто для этого достаточно с особым вниманием отнестись к выбору имен: class StreamingAverage { double m_sum = 0; int m_count = 0; double m_last_average = 0; std::mutex m_sum_count_mtx; // ... };

Еще лучше – использовать вложенную структуру: class StreamingAverage { struct { double sum = 0; int count = 0; std::mutex mtx; } m_guarded_sc; double m_last_average = 0; // ... };

Выше мы понадеялись, что если вынудить программиста писать this-> m_guarded_sc.sum, он запомнит, что предварительно должен приобрести

178    Глава 7. Конкуренция блокировку this->m_guarded_sc.mtx. Чтобы избежать повторного ввода m_guarded_sc повсюду в коде, мы могли бы использовать GNU-расширение «анонимные члены структуры»; но это противоречило бы цели данного подхода – в каждой точке, где происходит доступ к данным, использовать слово «guarded» (защищенный), напоминающее программисту о необходимости приобретения блокировки this->m_guarded_sc.mtx. Еще более надежный, но менее гибкий способ – поместить мьютекс в класс, который открывает доступ к своим приватным членам, только когда мьютекс заперт, возвращая дескриптор RAII. Класс, возвращающий такой дескриптор, мог бы выглядеть примерно так: template class Guarded { std::mutex m_mtx; Data m_data; class Handle { std::unique_lock m_lk; Data *m_ptr; public: Handle(std::unique_lock lk, Data *p) : m_lk(std::move(lk)), m_ptr(p) {} auto operator->() const { return m_ptr; } }; public: Handle lock() { std::unique_lock lk(m_mtx); return Handle{std::move(lk), &m_data}; } };

А наш класс StreamingAverage мог бы использовать его так: class StreamingAverage { struct Guts { double m_sum = 0; int m_count = 0; }; Guarded m_sc; double m_last_average = 0; // ... double get_current_average() { auto h = m_sc.lock(); m_last_average = h->m_sum / h->m_count; return m_last_average; } };

Всегда связывайте мьютекс с управляемыми данными    179 В предыдущем примере никакая функция-член класса StreamingAverage не сможет обратиться к m_sum, не заперев мьютекс m_mtx; доступ к защищаемому члену m_sum возможен только посредством RAII-типа Handle. Этот шаблон проектирования включен в библиотеку Facebook Folly под именем folly::Synchronized, и многие его варианты доступны в библиотеке libGuarded, разработчики которой – Ансел Сермерсхайм (Ansel Sermersheim) и Барбара Геллер (Barbara Geller).

Обратите внимание, как используется std::unique_lock в классе Handle! Здесь мы использовали unique_lock, а не lock_guard, потому что нам необходимо иметь возможность передавать блокировку, возвращать ее из функций и т. д. – поэтому она должна быть перемещаемой. Это главная причина добавления unique_lock в наш арсенал. Имейте в виду, что этот прием не решает всех проблем с блокировками – он помогает только в простейших случаях, когда программист просто «забывает запереть мьютекс», – и может стимулировать применение шаблонов программирования, которые влекут за собой ошибки конкурентного выполнения других видов. Например, взгляните на переделанный вариант StreamingAverage::get_current_average: double get_sum() { return m_sc.lock()->m_sum; } int get_count() { return m_sc.lock()->m_count; } double get_current_average() { return get_sum() / get_count(); }

Поскольку здесь имеется два вызова m_sc.lock(), между записью в m_sum и чтением m_count появляется промежуток. Если в этом промежутке потокпроизводитель вызовет add_value, мы получим ошибочное среднее значение (уменьшенное в m_count раз). А если попытаемся «исправить» ошибку, добавив блокировку перед вычислениями, столкнемся с взаимоблокировкой: double get_sum() { return m_sc.lock()->m_sum; // LOCK 2 } int get_count() { return m_sc.lock()->m_count;

180    Глава 7. Конкуренция } double get_current_average() { auto h = m_sc.lock(); // LOCK 1 return get_sum() / get_count(); }

Строка с комментарием // LOCK 1 заблокирует мьютекс; после этого строка с комментарием // LOCK 2 попытается заблокировать мьютекс еще раз. Когда поток пытается запереть запертый мьютекс, его выполнение блокируется и возобновляется, только когда мьютекс будет отперт. То есть поток заблокируется и будет ждать, когда мьютекс освободится, чего никогда не произойдет, потому что блокировка удерживается этим же потоком! Такие проблемы (взаимоблокировки с самим собой) должны, как правило, решаться внимательным отношением к программированию, то есть вы не должны пытаться приобрести блокировку, которую уже удерживаете! Но при таком подходе блокировки неизбежно становятся частью вашего дизайна. Избежать этой проблемы вам поможет стандартная библиотека в лице recursive_mutex.

Специальные типы мьютексов Напомню, что std::lock_guard и std::unique_lock параметризуются типом мьютекса. До сих пор мы видели только тип std::mutex. Однако в стандартной библиотеке имеется несколько других типов мьютексов, которые могут пригодиться в определенных условиях. std::recursive_mutex похож на std::mutex, но помнит, какой поток запер его. Если этот конкретный поток попытается приобрести блокировку повторно, рекурсивный мьютекс просто увеличит внутренний счетчик «количества приобретенных блокировок». Если какой-то другой поток попробует запереть рекурсивный мьютекс, он будет заблокирован, пока оригинальный поток не отопрет мьютекс соответствующее число раз. std::timed_mutex похож на std::mutex, но поддерживает ограничение попытки приобрести блокировку по времени. Кроме обычной функции-члена .try_lock(), он имеет также .try_lock_for() и .try_lock_until(), которые используют стандартную библиотеку . Вот пример использования try_lock_for: std::timed_mutex m; std::atomic ready = false; std::thread thread_b([&]() { std::lock_guard lk(m); puts("Thread B got the lock."); ready = true; std::this_thread::sleep_for(100ms);

Специальные типы мьютексов    181 }); while (!ready) { puts("Thread A is waiting for thread B to launch."); std::this_thread::sleep_for(10ms); } while (!m.try_lock_for(10ms)) { puts("Thread A spent 10ms trying to get the lock and failed."); } puts("Thread A finally got the lock!"); m.unlock();

А вот пример использования try_lock_until: std::timed_mutex m1, m2; std::atomic ready = false; std::thread thread_b([&]() { std::unique_lock lk1(m1); std::unique_lock lk2(m2); puts("Thread B got the locks."); ready = true; std::this_thread::sleep_for(50ms); lk1.unlock(); std::this_thread::sleep_for(50ms); }); while (!ready) { std::this_thread::sleep_for(10ms); } auto start_time = std::chrono::system_clock::now(); auto deadline = start_time + 100ms; bool got_m1 = m1.try_lock_until(deadline); auto elapsed_m1 = std::chrono::system_clock::now() - start_time; bool got_m2 = m2.try_lock_until(deadline); auto elapsed_m2 = std::chrono::system_clock::now() - start_time; if (got_m1) { printf("Thread A got the first lock after %dms.\n", count_ms(elapsed_m1)); m1.unlock(); } if (got_m2) { printf("Thread A got the second lock after %dms.\n", count_ms(elapsed_m2));

182    Глава 7. Конкуренция m2.unlock(); }

К слову сказать, используемая здесь функция count_ms – это всего лишь небольшое лямбда-выражение, которое постепенно вытесняет некоторые шаб­ лоны, привычные для : auto count_ms = [](auto&& d) -> int { using namespace std::chrono; return duration_cast(d).count(); };

Обратите внимание, что в обоих предыдущих примерах для синхронизации потоков A и B используется std::atomic. Мы просто инициализируем атомарную переменную значением false и затем выполняем цикл, пока она не получит значение true. В теле цикла выполняется вызов std::this_ thread::sleep_for, чего достаточно, чтобы подсказать компилятору, что значение атомарной переменной может измениться. Будьте осторожны и никогда не пишите циклов опроса, которые не содержат вызова функции приостановки выполнения, потому что иначе компилятор может свернуть все последующие операции чтения в одну-единственную и бесконечный цикл. std::recursive_timed_mutex сочетает в себе черты recursive_mutex и timed_mutex; он поддерживает семантику «счета» recursive_mutex плюс методы try_lock_for и try_lock_until от timed_mutex. Тип std::shared_mutex имеет, возможно, недостаточно говорящее название. Он реализует поведение, которое в большинстве книг, посвященных конкурентному выполнению, называется блокировкой для чтения/записи. Определяющей характеристикой блокировки чтения/записи, или shared_mutex, является поддержка «блокирования» двумя разными способами. Можно приобрести обычную исключительную блокировку («для записи») вызовом sm.lock() или неисключительную («для чтения») блокировку вызовом sm.lock_shared(). Блокировку для чтения могут приобрести сразу несколько потоков; но если кто-то приобрел блокировку для чтения, никто не сможет приобрести ее для записи; а если кто-то приобрел ее для записи, никто не сможет приобрести ее ни для чтения, ни для записи. По сути, это те же правила , что определяют «состояние гонки» в модели памяти C++: два потока могут одновременно читать данные из одного объекта, при условии что в то же время нет потока, выполняющего запись. Тип std::shared_mutex добавляет в эту смесь безопасность. Он гарантирует, что если некоторый поток пытается выполнить запись (по крайней мере, если ведет себя добропорядочно и предварительно приобретает блокировку для записи на std::shared_mutex), он заблокируется до момента, когда все читающие потоки завершат чтение и операция записи не вызовет конфликтов. std::unique_lock – это существительное, соответствующее исключительной («для записи») блокировке на std::shared_mu-

Специальные типы мьютексов    183 tex. Как нетрудно догадаться, стандартная библиотека поддерживает также std::shared_lock для воплощения идеи неисключительной («для чтения») блокировки в std::shared_mutex.

Повышение статуса блокировки для чтения/записи Допустим, вы приобрели блокировку для чтения на shared_mutex (то есть для std::shared_lock lk вызвали lk.owns_lock()), и теперь вам нужно получить блокировку для записи. Можно ли «повысить статус» имеющейся блокировки? Нет, нельзя. Представьте, что случится, если потоки A и B оба удерживают блокировку для чтения и одновременно попытаются повысить ее статус до блокировки для записи, не освобождая свои блокировки для чтения. Ни один из них не сможет приобрести блокировку для записи, и к тому же они попадут в состояние взаимоблокировки. В действительности существуют сторонние библиотеки, пытающиеся решить эту проблему, как, например, boost::thread::upgrade_lock, которая работает с boost::thread::shared_mutex; но их обсуждение выходит далеко за рамки этой книги. Стандартное решение заключается в следующем: если вы удерживаете блокировку для чтения и хотите приобрести блокировку для записи, вы должны сначала освободить блокировку для чтения, а потом встать в очередь желающих получить блокировку для записи: template std::unique_lock upgrade(std::shared_lock lk) { lk.unlock(); // Здесь может вклиниться другой пишущий поток. return std::unique_lock(*lk.mutex()); }

Понижение статуса блокировки для чтения/записи Допустим, вы приобрели исключительную блокировку для чтения/записи на

shared_mutex и теперь вам нужно получить блокировку для чтения. Можно ли

«понизить статус» имеющейся блокировки? В принципе, это возможно, как будто ничто не препятствует понижению статуса блокировки для записи до блокировки для чтения; но стандарт C++17 не предусматривает такой возможности непосредственно. Так же, как в случае с повышением статуса, можно использовать boost::thread::shared_mutex. Стандартное решение заключается в следующем: если вы удерживаете блокировку для записи и хотите приобрести блокировку для чтения, вы должны сначала освободить блокировку для записи, а потом встать в очередь желающих получить блокировку для чтения: template std::shared_lock downgrade(std::unique_lock lk)

184    Глава 7. Конкуренция { lk.unlock(); // Здесь может вклиниться другой пишущий поток. return std::shared_lock(*lk.mutex()); }

Как можно видеть на этих примерах, на данный момент std::shared_mutex из C++17 еще не готов ко всем перипетиям жизни. Если ваша архитектура вызовов требует такого переключения между блокировками чтения и записи, я настоятельно советую использовать что-нибудь вроде boost::thread::shared_ mutex, который поставляется «с батарейками в комплекте». Возможно, вы заметили, что новые читающие потоки могут продолжать приобретать блокировку для чтения, пока она удерживается каким-то конк­ ретным потоком, но новые пишущие потоки лишены такой возможности. В  результате этого пишущий поток может надолго заблокироваться из-за непрекращающейся последовательности читающих потоков, если реализация не предусматривает гарантий против замораживания пишущих потоков. boost::thread::shared_mutex дает такую гарантию (по крайней мере, если такая возможность предусматривается системным планировщиком). Стандартная формулировка std::shared_mutex не включает такой гарантии, даже притом, что любая реализация, допускающая такое подвешивание, должна считаться некачественной. Но на практике реализации стандартной библио­ теки содержат shared_mutex, близкий по своим характеристикам к версии в библиотеке Boost, за исключением отсутствующих функций повышения/понижения статуса.

Ожидание условия В разделе «Специальные типы мьютексов» мы запустили выполнение задания в отдельном потоке, а потом вынуждены были ждать, пока не закончится определенный этап инициализации. В том примере мы использовали цикл опроса на основе std::atomic. Но существует лучшее решение! Проблема с нашим циклом опроса, приостанавливающимся на 50 миллисекунд, в том, что он всегда будет тратить на приостановку больше времени, чем нужно. Иногда наш поток будет возобновлять выполнение, обнаруживать, что условие его пробуждения не выполнено и снова приостанавливаться – это значит, что первая приостановка недостаточно долгая. Иногда поток будет возобновлять выполнение, обнаруживать, что условие было выполнено в течение последних 50 миллисекунд, но мы не можем сказать, когда именно, – это означает, что в среднем поток простаивает лишние 25 миллисекунд. Что бы ни случилось, вероятность возобновить выполнение в нужный момент времени ничтожно мала. Чтобы не терять времени, нужен механизм, позволяющий отказаться от циклов опроса. В стандартной библиотеке есть такой механизм, позволяю-

Ожидание условия    185 щий прервать ожидание как раз в нужный момент времени; и называется он

std::condition_variable. Имея переменную cv типа std::condition_variable, наш поток мог бы приостановиться в «ожидании на» cv, вызвав метод cv.wait(lk). Вызов cv.notify_one() или cv.notify_all() возобновит выполнение одного или всех потоков, в данный момент ожидающих на cv. Но это не единственный

способ возобновить выполнение потоков! Может так сложиться, что внешнее прерывание (например, сигнал POSIX) разбудит поток без вызова notify_one. Это явление называется ложным пробуждением. Обычно, чтобы защититься от ложных пробуждений, выполняется дополнительная проверка условия. Например, если поток ждет появления входных данных в буфере b, тогда после возобновления он должен проверить наличие данных вызовом b.empty() и, если буфер пуст, снова приостановиться. По определению должен существовать какой-то другой поток, который поместит данные в b; то есть вызов b.empty() лучше выполнять под защитой некоторого мьютекса. То есть первое, что нужно сделать сразу после возобновления, – запереть этот мьютекс, и последнее, что нужно сделать непосредственно перед повторной приостановкой, – отпереть мьютекс. (Фактически вы должны автоматически отпирать мьютекс в ходе выполнения операции повторной простановки, чтобы никто не мог «проскользнуть», изменить b и вызвать cv.notify_one() до того, как поток приостановится.) Эта логическая цепочка объясняет, почему cv.wait(lk) принимает параметр lk, – это std::unique_ lock, который будет отпираться при приостановке и запираться в момент возобновления! Вот пример ожидания некоторого условия. Сначала взгляните на реализацию с применением расточительного цикла опроса с переменной std::atomic: std::atomic ready = false; std::thread thread_b([&]() { prep_work(); ready = true; main_work(); }); // Ждать готовности потока B. while (!ready) { std::this_thread::sleep_for(10ms); } // Поток B завершил подготовку к работе.

А теперь на реализацию с использованием более эффективной условной переменой condition_variable: bool ready = false; // не атомарная! std::mutex ready_mutex;

186    Глава 7. Конкуренция std::condition_variable cv; std::thread thread_b([&]() { prep_work(); { std::lock_guard lk(ready_mutex); ready = true; } cv.notify_one(); main_work(); }); // Ждать готовности потока B. { std::unique_lock lk(ready_mutex); while (!ready) { cv.wait(lk); } } // Поток B завершил подготовку к работе.

Если ожидается, когда станет доступна для чтения структура, защищенная блокировкой для чтения/записи (то есть std::shared_mutex), тогда нужно передавать не std::unique_lock, а std::shared_ lock. Однако такое возможно, только (к сожалению, только) мы заранее планировали это и определили условную переменную с типом std::condition_variable_any, а не std::condition_variable. На практике вы едва ли заметите какую-либо разницу в производительности между типами std::condition_variable_any и std::condition_variable, а значит, старайтесь выбирать между ними, исходя из своих потребностей, или, если вас устраивают оба, исходите из ясности получаемого кода. Обычно использование std::condition_variable означает экономию четырех символов. Но обратите внимание, что из-за слоя изолирующей абстракции, предоставляемого типом std::shared_lock, фактический код для ожидания на cv под блокировкой чтения/записи почти идентичен коду ожидания на cv под обычным мьютексом. Вот версия на основе блокировки для чтения/записи: bool ready = false; std::shared_mutex ready_rwlock; std::condition_variable_any cv; std::thread thread_b([&]() { prep_work(); { std::lock_guard lk(ready_rwlock); ready = true; } cv.notify_one(); main_work();

Обещания о будущем    187 }); // Ждать готовности потока B. { std::shared_lock lk(ready_rwlock); while (!ready) { cv.wait(lk); } } // Поток B завершил подготовку к работе.

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

Обещания о будущем Если прежде вам не приходилось сталкиваться с темами конкурентного программирования, последние несколько разделов в этой главе, вероятно, покажутся вам наиболее сложными. Мьютексы просты для понимания, потому что моделируют идею, знакомую по повседневной жизни: получение исключительного доступа к некоторому ресурсу путем запирания его на замок (установки блокировки). Блокировки для чтения/записи (shared_mutex) ненамного сложнее. Однако затем мы совершили настоящий скачок, рассмотрев условные переменные, которые трудно понять с первой попытки, потому что они моделируют не существительное (такое как «замок»), а скорее некоторую глагольную форму: «спать до тех пор, пока». К тому же свою лепту в сложности вносит их не очень удачное название. Теперь мы продолжим наше путешествие по миру конкурентного программирования и исследуем тему, которая может быть незнакомой даже прошедшим курс обучения конкурентному программированию: механизмы promise и future1. В C++11 типы std::promise и std::future всегда соседствуют друг с другом. Пришедшие из языка Go могут представить пару promise/future как канал, в том смысле, что когда один поток вталкивает значение (типа T) на стороне «promise» этой пары, оно в конечном счете появится на стороне «future» (которая обычно находится в другом потоке выполнения). Пары promise/future похожи также на нестабильные червоточины (unstable wormholes): стоит только протолкнуть значение через червоточину, как она тут же схлопывается. 1

Promise (обещание) и future (будущее) – два родственных механизма асинхронного выполнения. К сожалению, пока не существует устоявшихся русскоязычных терминов для их обозначения. Поэтому, чтобы не путать вас изобретенными терминами или кальками, я буду продолжать использовать термины «promise» и «future» без перевода. Дополнительную информацию можно найти в Википедии: https://ru.wikipedia.org/wiki/Futures_and_promises. – Прим. перев.

188    Глава 7. Конкуренция Можно сказать, что пара promise/future подобна направленной, перемещаемой червоточине однократного действия. Она «направленная», потому что втолкнуть данные можно только со стороны «promise», а извлечь их – только со стороны «future». Она «перемещаемая», потому что, владея концом червоточины, вы можете передавать его в функции и из функций и даже перемещать в другие потоки выполнения; туннель, соединяющий два конца, при этом не разрывается. А «однократная» она потому, что, втолкнув данные один раз со стороны «promise», вы не сможете втолкнуть туда что-либо повторно. Другая метафора вытекает из их имен: фактически std::future не является значением типа T, в некотором смысле это будущее (future) значение – оно даст вам доступ к значению T в некоторый момент в будущем, но «не прямо сейчас». (В этом смысле этот тип является неким подобием потокобезопасного optional.) Объект std::promise, в свою очередь, можно сравнить с пока не выполненным обещанием (promise), или долговой распиской. Владелец объекта promise обещает (promises) передать значение типа T в некоторый момент; если он этого не сделает, значит, он «нарушит обещание». В общем случае использование пары promise/future начинается с создания std::promise, где T – это тип данных, которые планируется передать через эту пару; затем создается конец «future» червоточины вызовом p.get_ future(). Когда все будет готово к выполнению обещания, вызывается p.set_ value(v). Между тем, когда какой-то другой поток выполнения будет готов извлечь значение, он вызывает f.get(). Если вызов f.get() произойдет до того, как обещание будет выполнено, поток заблокируется до момента, пока не появится ожидаемое значение. С другой стороны, когда поток, удерживающий конец promise, вызовет p.set_value(v) и при этом никто не ожидает значения с другой стороны, ничего не произойдет; set_value просто запишет значение v в память, и другой поток сможет извлечь его в удобный для себя момент вызовом f.get(). Давайте рассмотрим promise и future в работе! std::promise p1, p2; std::future f1 = p1.get_future(); std::future f2 = p2.get_future(); // Если обещание будет выполнено раньше, // тогда f.get() не заблокирует поток. p1.set_value(42); assert(f1.get() == 42); // Если f.get() будет вызван раньше, тогда он // заблокируется до момента, пока не будет вызван set_value() // каким-то другим потоком. std::thread t([&](){ std::this_thread::sleep_for(100ms); p2.set_value(43); });

Обещания о будущем    189 auto start_time = std::chrono::system_clock::now(); assert(f2.get() == 43); auto elapsed = std::chrono::system_clock::now() - start_time; printf("f2.get() took %dms.\n", count_ms(elapsed)); t.join();

(Определение count_ms вы найдете в разделе «Специальные типы мьютексов» выше.) Реализация std::promise в стандартной библиотеке имеет одну замечательную особенность – специализацию для типа void. На первый взгляд, идея std::future может показаться странной – какая польза от червоточины, если единственный тип данных, который можно протолкнуть через нее, является типом без значения? Но на самом деле future имеет высокую практическую ценность в ситуациях, когда нас интересует не столько конкретное значение, сколько сам сигнал. Например, std::future можно использовать для реализации третьей версии нашего примера «ожидания запус­ ка потока B»: std::promise ready_p; std::future ready_f = ready_p.get_future(); std::thread thread_b([&]() { prep_work(); ready_p.set_value(); main_work(); }); // Ждать готовности потока B. ready_f.wait(); // Поток B завершил подготовку к работе.

Сравните эту версию с версией из раздела «Ожидание условия». Эта версия намного понятнее! В ней практически нет лишнего, шаблонного кода. Отправка сигнала «готовности потока B» и операция «ожидания готовности потока B» требуют всего по одной строке кода. Это определенно более удобный способ обмена сигналами между потоками. Существует также четвертый способ послать сигнал из одного потока целой группе других потоков, о котором рассказывается в разделе «Идентификация отдельных потоков и текущего потока». Однако за удобство std::future приходится платить, платить распределением динамической памяти из кучи. Дело в том, что обоим механизмам, promise и future, требуется доступ к общему хранилищу, чтобы, например, значение 42, сохраненное на стороне promise, можно было извлечь на стороне future. (Доступ к такому хранилищу также регулируется посредством мьютекса и условной переменной, необходимых для синхронизации потоков. Мьютекс и условная переменная при этом не исчезают из нашего кода; они прос­ то перемещаются на более низкий уровень абстракции, и нам не приходится беспокоиться о них.) То есть promise и future действуют как своеобразный

190    Глава 7. Конкуренция «дескриптор» этого общего состояния; но оба являются перемещаемыми типами, поэтому ни одному из них в действительности не требуется хранить общее состояние в виде члена данных. Они выделяют место в куче для общего состоя­ ния и хранят указатели на него; а поскольку общее состояние не должно освобождаться до уничтожения обоих дескрипторов, мы имеем дело с совместным владением, посредством чего-то, похожего на shared_ptr (см. главу 6 «Умные указатели»). Схематически promise и future выглядят, как показано на рис. 7.1.

Рис. 7.1. Схематическое представление типов promise и future Общее состояние на этой диаграмме размещается оператором new, если только не используется специализированная версия конструктора std::promise с нестандартным диспетчером памяти. Ниже показано, как использовать std::promise и std::future со своим диспетчером памяти: MyAllocator myalloc{}; std::promise p(std::allocator_arg, myalloc); std::future f = p.get_future();

std::allocator_arg определен в заголовке . Подробности относительно MyAllocator смотрите в главе 8 «Диспетчеры памяти».

Подготовка заданий для отложенного выполнения Еще одна интересная особенность на предыдущей диаграмме, которую хотелось бы отметить, – общее состояние содержит не просто optional; в действительности в нем хранится variant (см. главу 5 «Словарные типы»). Это означает, что в червоточину можно проталкивать не только данные типа T, но также исключения. Это особенно удобно, потому

Подготовка заданий для отложенного выполнения    191 что позволяет типу std::future представлять все возможные результаты вызова функции с сигнатурой T(). Она может вернуть T; может возбудить исключение; а может вообще никогда ничего не вернуть. Аналогично вызов f.get() может вернуть T; возбудить исключение или (если поток, владеющий стороной promise, завис) вообще никогда не вернуть управления. Чтобы протолкнуть исключение через червоточину, следует использовать метод p.set_ exception(ex), где ex – объект типа std::exception_ptr, который может быть возвращен из std::current_exception() внутри обработчика исключения. Давайте возьмем функцию с сигнатурой T() и упакуем ее в тип std::future: template class simple_packaged_task { std::function m_func; std::promise m_promise; public: template simple_packaged_task(const F& f) : m_func(f) {} auto get_future() { return m_promise.get_future(); } void operator()() { try { T result = m_func(); m_promise.set_value(result); } catch (...) { m_promise.set_exception(std::current_exception()); } } };

Внешне этот класс напоминает тип std::packaged_task из стандартной библиотеки, отличаясь только тем, что стандартный тип принимает аргументы и использует дополнительный уровень косвенности, гарантирующий возможность использования функторов, поддерживающих только перемещение. В главе 5 «Словарные типы» мы видели несколько решений, помогающих обойти ограничение std::function, не позволяющее хранить типы функций, поддерживающих только перемещение; к счастью, при работе с std::packaged_task эти обходные решения не нужны. С другой стороны, в реальной жизни вам едва ли придется использовать std::packaged_task. Он интересен в основном только как пример объединения promise, future и функций в удобные классы-типы с очень простым внешним интерфейсом. Остановимся на минуту: класс simple_packaged_task, представленный выше, использует стирание типа в std::function и имеет член std::promise, реализованный в терминах std::shared_ptr, который выполняет подсчет ссылок; общее состояние, на которое ссылается этот указатель с подсчетом ссылок, хранит мьютекс и переменную состояния. Как видите, в таком маленьком объеме оказалось

192    Глава 7. Конкуренция упаковано довольно много идей и технологий! Но, несмотря на это, simple_ packaged_task имеет действительно очень простой интерфейс: его можно

сконструировать на основе функции или лямбда-выражения, затем вызвать pt.get_future(), чтобы получить future, для которого, в свою очередь, можно вызвать f.get(); и в то же время вызвать pt() (возможно, из другого потока), чтобы фактически выполнить хранимую функцию и протолкнуть результат через червоточину в f.get(). Если хранимая функция сгенерирует исключение, тогда packaged_task перехватит его (в потоке на стороне promise) и втолкнет его в червоточину. Затем, когда другой поток вызовет f.get() (или уже вызвал его и в данный момент ожидает возврата из f.get()), f.get() возбудит это исключение в потоке на стороне future. Иными словами, с помощью promise и future мы фактически «телепортируем» исключение между потоками. К сожалению, всестороннее обсуждение механизма телепортации, std::exception_ptr, выходит далеко за рамки этой книги. Если вы проектируете библиотеку, которая широко использует исключения, вам определенно стоит поближе познакомиться с std::exception_ptr.

Будущее механизма future По аналогии с std::shared_mutex, версия std::future в стандартной библиотеке готова только наполовину. Гораздо более полная и полезная версия future появится, как я надеюсь, в C++20, но уже сейчас есть множество сторонних биб­ лиотек, реализующих лучшие черты будущего механизма future. К числу лучших в этом отношении относятся boost::future и folly::Future. Основная проблема std::future – в вероятности «падения на землю» в потоке после каждого этапа в потенциально многоэтапных вычислениях. Рассмотрим следующий патологический случай использования std::future: template auto pf() { std::promise p; std::future f = p.get_future(); return std::make_pair(std::move(p), std::move(f)); } void test() auto [p1, auto [p2, auto [p3,

{ f1] = pf(); f2] = pf(); f3] = pf();

auto t1 = std::thread([p1 = std::move(p1)]() mutable { Connection conn = slowly_open_connection(); p1.set_value(conn); // ОПАСНО: что, если slowly_open_connection возбудит исключение? });

Будущее механизма future    193 auto t2 = std::thread([p2 = std::move(p2)]() mutable { Data data = slowly_get_data_from_disk(); p2.set_value(data); }); auto t3 = std::thread( [p3 = std::move(p3), f1 = std::move(f1)]() mutable { Data data = slowly_get_data_from_connection(f1.get()); p3.set_value(data); }); bool success = (f2.get() == f3.get()); assert(success); }

Обратите внимание на строку с комментарием ОПАСНО: все три потока содержат одну и ту же ошибку – они не перехватывают исключение и не вызывают .set_exception(). Исправить ошибку можно с помощью блока try... catch, как мы сделали это в simple_packaged_task, в предыдущем разделе; но, поскольку писать каждый раз один и тот же код слишком утомительно, в стандартную библиотеку была добавлена интересная функция-обертка std::async(), которая создает пару promise/future и запускает новый поток. Применив std::async(), можно получить намного более ясный код: void test() { auto f1 = std::async(slowly_open_connection); auto f2 = std::async(slowly_get_data_from_disk); auto f3 = std::async([f1 = std::move(f1)]() mutable { return slowly_get_data_from_connection(f1.get()); // Больше не опасно. }); bool success = (f2.get() == f3.get()); assert(success); }

Однако это только эстетическая ясность; этот код чрезвычайно плохо сказывается на производительности и безопасности кода. Это плохой код! Каждый раз, встречая .get() в этом коде, вы должны подумать: «Какая непростительная трата времени на переключение контекста!» И каждый раз, замечая запуск нового потока (явно или вызовом async), вы должны подумать: «Какая потрясающая возможность для операционной системы исчерпать потоки ядра и возбудить в конструкторе std::thread неожиданное исключение!» Вместо любого из предыдущих вариантов я предпочел бы написать что-нибудь этакое в стиле JavaScript: void test() { auto f1 = my::async(slowly_open_connection); auto f2 = my::async(slowly_get_data_from_disk); auto f3 = f1.then([](Connection conn) { return slowly_get_data_from_connection(conn);

194    Глава 7. Конкуренция }); bool success = f2.get() == f3.get(); assert(success); }

Здесь .get() вызывается только в самом конце, когда уже ничего не нужно делать и остается только дождаться окончательного ответа; и здесь порождается на один поток меньше. Вместо запуска нового потока мы подключаем к f1 «продолжение» до того, как тот завершит свое задание, чтобы сразу после этого поток на стороне promise смог перейти к выполнению продолжения (если первое задание f1 возбудит исключение, вход в продолжение просто не будет выполнен. Библиотека должна также предоставить симметричный метод f1.on_error(continuation), чтобы иметь возможность обработать исключение). Нечто подобное уже имеется в библиотеке Boost; а библиотека Facebook Folly содержит особенно надежную и полноценную реализацию, даже лучшую, чем в Boost. Пока мы ждем, что C++20 улучшит ситуацию, я советую использовать Folly, если вы можете позволить себе дополнительные умственные нагрузки, связанные с интеграцией библиотеки в вашу систему сборки. Единственным преимуществом типа std::future является его стандартность; его можно использовать практически на любой платформе, не заботясь о загрузке дополнительных библиотек, путях поиска подключаемых файлов и лицензионных соглашениях.

Поговорим о потоках... На протяжении всей этой главы мы использовали слово «поток» (или «поток выполнения»), даже не определив, что оно означает в действительности; и вы, вероятно, заметили, что во многих примерах многопоточного кода используется класс типа std::thread и пространство имен std::this_thread без каких-либо пояснений. Мы сосредоточились на том, как синхронизировать работу разных потоков выполнения, но до сих пор умалчивали о том, кто или что выполняется! Иначе говоря: когда выполнение достигает выражения mtx.lock(), где mtx – это заблокированный мьютекс, семантика std::mutex говорит, что текущий поток выполнения должен заблокироваться и ждать. Что происходит, пока этот поток заблокирован? Наша программа на C++ все еще «отвечает» на происходящее, но очевидно, что этот заблокированный код на C++ больше не выполняется; тогда кто или что выполняется? Ответ прост: другой поток. Мы указываем на существование других потоков и код, который они должны выполнить, используя класс std::thread из стандартной библиотеки, который определен в заголовке . Чтобы запустить новый поток выполнения, нужно лишь сконструировать объект типа std::thread, передав конструктору единственный аргумент:

Поговорим о потоках...    195 лямбда-выражение или функцию с кодом, который должен выполнить новый поток. (Технически можно передать несколько аргументов; все остальные аргументы, следующие за первым, будут переданы в указанную функцию в виде параметров, после развертывания с reference_wrapper, как описывалось в главе 5 «Словарные типы». С появлением поддержки лямбда-выражений в C++11 дополнительные аргументы конструктора thread стали не нужны и даже нежелательны; я советую избегать их.) Новый поток запускается немедленно; если понадобится выполнить «запуск с задержкой», вам придется самим реализовать это, используя один из при­ емов синхронизации, показанных в разделе «Ожидание условия» или в разделе «Идентификация отдельных потоков и текущего потока». Новый поток выполнит переданный ему код и, достигнув конца лямбдавыражения или функции, станет «доступным для присоединения». Это очень похоже на происходящее с std::future, когда он становится «готовым к чтению»: поток завершает вычисления и готов передать полученные результаты. Так же как в случае с std::future, результат вычислений «не имеет значения»; но важен сам факт завершения вычислений! В отличие от std::future, однако нельзя уничтожить объект std::thread не получив результат без значения. По умолчанию, если уничтожить любой новый поток, не обработав его результат, деструктор вызовет std::terminate и тем самым завершит всю программу. Чтобы избежать такой судьбы, нужно указать на поток и подтвердить его завершение – «Хорошая работа, поток, молодец!» – вызвав функцию-член t.join(). Как вариант, если вы не ожидаете завершения потока (например, если фоновый поток выполняет бесконечный цикл) или вас не интересует его результат (например, если это была короткоживущая задача, действующая по принципу «запустил и забыл»), можно перевести поток в фоновый режим – «Все, уходи, я больше не желаю ничего знать о тебе!» – вызовом t.detach(). Вот несколько законченных примеров использования std::thread: using namespace std::literals; // для "ms" std::thread a([](){ puts("Thread A says hello ~0ms"); std::this_thread::sleep_for(10ms); puts("Thread A says goodbye ~10ms"); }); std::thread b([](){ puts("Thread B says hello ~0ms"); std::this_thread::sleep_for(20ms); puts("Thread B says goodbye ~20ms"); }); puts("The main thread says hello ~0ms"); a.join(); // ждать завершения потока A

196    Глава 7. Конкуренция b.detach(); // не ждать завершения потока B puts("The main thread says goodbye ~10ms");

Идентификация отдельных потоков и текущего потока Объекты типа std::thread, подобно всем другим типам, описанным в этой главе, не поддерживают operator==. Вы не сможете напрямую спросить: «Эти два объекта потоков – одно и то же?» Это также означает, что объекты std::thread нельзя использовать в качестве ключей в ассоциативных контейнерах, таких как std::map или std::unordered_map. Зато о равенстве можно спросить косвенно, обратившись к механизму идентификации потоков. Функция-член t.get_id() возвращает уникальный идентификатор типа std::thread::id, который, хотя технически является классом типа, во многом ведет себя как целочисленный тип. Идентификаторы потоков можно сравнивать с помощью операторов < и == и использовать в качестве ключей в ассоциативных контейнерах. Другая ценная особенность идентификаторов потоков – возможность их копировать, в отличие от самих объектов std::thread, которые поддерживают только перемещение. Не забывайте, что каждый объект std::thread фактически представляет действующий поток выполнения; если бы можно было копировать объекты потоков, тогда должна была бы иметься возможность «скопировать» сами потоки выполнения, что лишено всякого смысла и могло бы породить некоторые интересные ошибки! Третья ценная особенность std::thread::id – возможность получить идентификатор текущего и даже главного потока. Внутри потока нельзя сказать: «Пожалуйста, дай мне объект std::thread, который управляет этим потоком». (Это можно сравнить с трюком std::enable_shared_from_this из главы 6 «Умные указатели», но, как мы видели, такой трюк требует поддержки со стороны библиотеки, создающей управляемые ресурсы, каковым в данном случае оказался бы конструктор std::thread.) Главный поток – поток, в котором начинает выполняться функция main, – вообще не имеет соответствующего объекта std::thread, но он имеет идентификатор! Наконец, идентификаторы потоков можно преобразовывать некоторым способом, зависящим от реализации, в строковое представление, которое гарантирует уникальность, то есть условие to_string(id1) == to_string(id2) выполнится тогда и только тогда, когда id1 == id2. К сожалению, это строковое представление доступно только посредством оператора потока (см. главу 9 «Потоки ввода/вывода»). Если вы захотите использовать синтаксис to_ string(id1), вам придется написать свою функцию-обертку, например: std::string to_string(std::thread::id id) { std::ostringstream o; o worker_ loop(), которую вы увидите далее: public: ThreadPool(int size) { for (int i=0; i < size; ++i) { m_workers.emplace_back([this]() { worker_loop(); }); } }

Как обещалось, деструктор присваивает члену m_state.aborting значение true и затем ждет, пока все потоки заметят изменение и прекратят выполнение. Обратите внимание, что чтение m_state.aborting производится под защитой m_state.mtx; мы стараемся следовать правилам личной гигиены, чтобы избежать появления жучков в коде!

~ThreadPool() { if (std::lock_guard lk(m_state.mtx); true) { m_state.aborting = true; } m_cv.notify_all(); for (std::thread& t : m_workers) { t.join(); } }

Теперь посмотрим, как добавлять задания в рабочую очередь. (Здесь не показано, как задания извлекаются из очереди, – эту операцию вы увидите в функции-члене worker_loop.) Это просто: доступ к m_state должен производиться только под защитой мьютекса, а после добавления задания в очередь нужно вызвать m_cv.notify_one(), чтобы разбудить один из свободных рабочих потоков для выполнения задания: void enqueue_task(UniqueFunction task) { if (std::lock_guard lk(m_state.mtx); true) { m_state.work_queue.push(std::move(task)); } m_cv.notify_one(); }

202    Глава 7. Конкуренция А теперь сам рабочий цикл. Это – функция-член, которую выполняют все рабочие потоки: private: void worker_loop() { while (true) { std::unique_lock lk(m_state.mtx); while (m_state.work_queue.empty() && !m_state.aborting) { m_cv.wait(lk); } if (m_state.aborting) break; // Вытолкнуть следующее задание под зажитой мьютекса. assert(!m_state.work_queue.empty()); UniqueFunction task = std::move(m_state.work_queue.front()); m_state.work_queue.pop(); lk.unlock(); // Выполнить задание. На это может потребоваться некоторое время. task(); // После выполнения задания проверить наличие следующего в очереди. } }

Обратите внимание на неизбежный цикл вокруг m_cv.wait(lk) и на то, что по соображениям гигиены доступ к m_state осуществляется только под защитой мьютекса. Также отметьте, что перед фактическим выполнением задания мы сначала освобождаем мьютекс; это гарантирует, что блокировка не будет удерживаться излишне долго, пока выполняется задание. Если не освободить блокировку, тогда никакой другой рабочий поток не смог бы получить ее, чтобы извлечь следующее задание из очереди – мы фактически исключили бы возможность параллельного выполнения нескольких заданий. Кроме того, если не освободить блокировку перед началом выполнения задания, тогда при попытке добавить новое задание в очередь (для чего требуется приобрести блокировку) это задание вызвало бы состояние взаимоблокировки с программой и сделало бы невозможным продолжение работы. Это особый случай более общего правила: никогда не вызывать пользовательскую функцию, удерживая блокировку. Наконец, завершим класс ThreadPool реализацией безопасной версии async. Наша версия позволит вызвать tp.async(f) для любой функции f без аргументов, и так же, как std::async, будем возвращать std::future, с помощью которого вызывающий код получит результат f, когда тот будет готов. В отличие от future, возвращаемого из std::async, наш future можно безопасно игнорировать, если вызывающий код решит не ждать результата, задание при этом останется в очереди и в конечном итоге будет выполнено, а результат выполнения будет просто отброшен: public: template

Создание своего пула потоков    203 auto async(F&& func) { using ResultType = std::invoke_result_t; std::packaged_task pt(std::forward(func)); std::future future = pt.get_future(); UniqueFunction task( [pt = std::move(pt)]() mutable { pt(); } ); enqueue_task(std::move(task)); // Вернуть future для извлечения результата. return future; } }; // class ThreadPool

Используя класс ThreadPool, можно написать, например, такой код, который создает 60 000 заданий: void test() { std::atomic sum(0); ThreadPool tp(4); std::vector futures; for (int i=0; i < 60000; ++i) { auto f = tp.async([i, &sum](){ sum += i; return i; }); futures.push_back(std::move(f)); } assert(futures[42].get() == 42); assert(903 m_state.work_queue, к которой вынуждены обращаться все рабочие потоки, чтобы получить следующее задание. Поэтому уменьшить конкуренцию и повысить производительность программы можно, если найти способ распределения заданий между рабочими потоками без использования общей центральной очереди. Простейшее решение – дать каждому рабочему потоку свой «список дел»; то есть заменить единую очередь std::queue вектором std::vector, содержащим по одному элементу для каждого рабочего потока. Конечно, тогда нам придется также добавить std::vector с мьютексом для каждой отдельной рабочей очереди. Функция enqueue_task могла бы распределять задания по рабочим очередям друг за другом (используя атомарный счетчик std::atomic для одновременной обработки нескольких очередей). В качестве альтернативы можно использовать счетчик thread_local для каждого отдельного потока, если вам посчастливилось работать на платформе, поддерживающей ключевое слово thread_local из стандарта C++11. На платформах x86-64 POSIX доступ к переменной thread_local осуществляется почти так же быстро, как доступ к обычной глобальной переменной; все сложности, связанные с подготовкой локальных переменных потоков, скрыты за кулисами и преодолеваются только один раз, при запуске нового потока. Однако поскольку эти сложности имеют место и должны поддерживаться средой выполнения, многие платформы пока не поддерживают спецификатор класса хранения thread_local. (На тех, где он поддерживается, объявление thread_ local int x почти эквивалентно объявлению static int x, за исключением

Оптимизация производительности пула потоков    205 того, что когда код обращается к x по имени, фактический адрес х в памяти зависит от std::this_thread::get_id(). В принципе, где-то за кулисами существует целый массив x, индексируемый идентификаторами потоков и заполняемый средой выполнения C++ при создании и уничтожении потоков.) Следующим значительным усовершенствованием нашего класса ThreadPool могла бы стать организация «кражи заданий»: теперь, когда каждый рабочий поток имеет свой список дел, может так получиться, случайно или намеренно, что один рабочий поток окажется перегружен, а другие в это время будут простаивать. В таком случае хотелось бы, чтобы простаивающие рабочие потоки проверяли очереди занятых потоков и «крали» у них задания, если это возможно. При этом снова возникает состязание за обладание блокировками среди рабочих потоков, но только когда возникает неэффективность, вызванная несправедливым распределением заданий, – неэффективность, которую мы пытаемся исправить посредством кражи заданий. Реализацию отдельных рабочих очередей и приема кражи заданий я оставляю читателям как самостоятельное упражнение; и надеюсь, что после знакомства с базовой реализацией ThreadPool вас не испугает идея изменить его и включить эти дополнительные возможности. Конечно, существуют также классы пулов потоков, написанные профессионалами. Boost.Asio, например, содержит один такой класс, и Asio находится на пути включения в стандарт, возможно, даже в C++20. Если задействовать Boost. Asio, наш класс ThreadPool мог бы выглядеть как-то так: class ThreadPool { boost::thread_group m_workers; boost::asio::io_service m_io; boost::asio::io_service::work m_work; public: ThreadPool(int size) : m_work(m_io) { for (int i=0; i < size; ++i) { m_workers.create_thread([&](){ m_io.run(); }); } } template void enqueue_task(F&& func) { m_io.post(std::forward(func)); } ~ThreadPool() { m_io.stop(); m_workers.join_all(); } };

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

206    Глава 7. Конкуренция условий, зависящих от других заданий в том же пуле потоков. Классическим примером может служить задача A, приостанавливающаяся на условной переменной в ожидании, что некоторая задача B позднее изменит условную переменную. Если создать пул потоков ThreadPool с размером 4, добавить в очередь четыре копии задания A, а потом четыре копии здания B, обнаружится, что задание B не может запуститься – четыре потока в пуле заняты четырьмя копиями задания A и все они приостановились в ожидании сигнала, который некому послать! «Обработка» этого случая равносильна написанию собственной библиотеки поддержки многопоточного выполнения; если вы не хотите заниматься этой работой, тогда вам остается только проявить здоровую осторожность, чтобы не допустить подобного сценария.

Итоги Многопоточность – сложная тема, полная нюансов и ловушек, что очевидно, только когда оглядываешься назад. В этой главе мы узнали: Что спецификатор volatile хорош для работы с аппаратурой, но его недостаточно для безопасности в многопоточном окружении. Что std::atomic для скалярного типа T (умещающегося в регистре процессора) – хороший способ доступа к общим данным без блокировок и без опасности попасть в состояние гонки. Наиболее важной атомарной операцией является операция «сравнить и поменять», которая на языке C++ записывается как compare_exchange_weak. Чтобы заставить потоки по очереди обращаться к общим неатомарным данным, мы обычно используем std::mutex. Всегда запирайте мьютексы с помощью RAII-класса, такого как std::unique_lock. Помните: даже при том, что автоматическое определение шаблонного аргумента в C++17 позволяет нам опускать в именах этих шаблонов, это всего лишь синтаксическое соглашение; они продолжают оставаться шаблонными классами. В своих программах всегда ясно показывайте, какие данные контролирует каждый мьютекс. Для этого прекрасно подходят вложенные структуры. std::condition_variable позволяет «дождаться» некоторого условия. Если условие может наступить только один раз, как, например, завершение потока, тогда для этой цели вместо условной переменной, вероятно, лучше использовать пару promise/future. Если условие может наступать снова и снова, подумайте – можно ли вашу задачу сформулировать в терминах шаблона рабочей очереди. std::thread воплощает идею потока выполнения. «Текущим потоком» нельзя напрямую манипулировать посредством объекта std::thread, тем не менее некоторые операции все же доступны в виде ограниченного набора свободных функций в пространстве имен std::this_thread. Наиболее важными из них являются операции sleep_for и get_id. Каждый объект std::thread всегда должен присоединяться или отсоединяться перед уничтожением. Отсо-

Итоги    207 единение может пригодиться только для фоновых потоков, которые не требуют явного уничтожения. Стандартная функция std::async принимает функцию или лямбда-выражение для выполнения в некотором другом потоке и возвращает объект std::future, который получает результат, когда функция завершит выполнение. Стандартная реализация std::async, однако, содержит фатальную ошибку (деструктор присоединяет поток; возможно исчерпание потоков ядра) и не должна использоваться в промышленном коде, поэтому для решения проблем конкуренции предпочтительнее использовать механизм future. Используйте реализацию promise/future, поддерживающую метод .then. Лучшей считается реализация в библиотеке Folly. Многопоточность – сложная тема, полная нюансов и подводных камней, которые становятся очевидными только после накопления опыта.

Глава

8

Диспетчеры памяти В предыдущих главах мы видели, что C++ испытывает любовь и ненависть к размещению данных в динамической памяти. С одной стороны, использование динамической памяти является явным признаком «плохого кода»; погоня за указателями может ухудшить производительность программы, куча может неожиданно исчерпаться (и привести к исключению типа std::bad_alloc), а ручное управление памятью настолько сложно, что в C++11 было введено несколько типов «умных указателей» для устранения сложностей (см. главу 6 «Умные указатели»). В версии C++, появившиеся после 2011 года, было добавлено большое количество алгебраических типов данных, не использующих динамическую память, таких как tuple, optional и variant (см. главу 5 «Словарные типы»), которые могут выражать владение, не касаясь кучи. С другой стороны, новые типы умных указателей позволяют эффективно справляться со сложностями, связанными с управлением памятью; в современном C++ можно эффективно выделять и освобождать память вообще без использования new и delete, не опасаясь утечек памяти. И распределение из кучи используется «за кулисами» многими новыми возможностями, появившимися в C++ (any, function, promise), потому что они пользуются многими старыми механизмами (stable_partition, vector). Возникает конфликт: как можно использовать новые (и старые) механизмы, которые зависят от распределения памяти из кучи, если при этом утверждается, что хороший код на C++ не пользуется динамической памятью кучи? Обычно предпочтение следует отдавать стандартным инструментам C++. Если вам нужен массив элементов с возможностью динамического изменения размеров, вы по умолчанию должны использовать std::vector, если при этом не возникают неприемлемые издержки производительности. Но существует также другая группа программистов, создающих программное обеспечение для выполнения в окружениях с очень ограниченными ресурсами, как, например, программное обеспечение управления полетом, которые должны избегать использования динамической памяти по одной простой причине: в их окружениях нет «кучи»! В таких встраиваемых системах вся раскладка памяти должна быть известна уже на этапе компиляции. В некоторых из таких программ просто не используются алгоритмы, выполняющие распределение

Диспетчер памяти обслуживает ресурс памяти    209 памяти из кучи, – вы никогда не столкнетесь с проблемой исчерпания ресурса, если никогда не прибегаете к механизмам динамического распределения этого ресурса! В других таких программах используются алгоритмы, выполняющие распределение памяти из кучи, но они требуют, чтобы «куча» была явно представлена в них (например, в виде большого массива char и функций, «резервирующих» и «возвращающих» последовательные блоки памяти в этот массив). Было бы очень неудобно, если бы подобные программы не могли использовать механизмы языка C++, такие как std::vector и std::any. Поэтому, начиная со стандарта, вышедшего в 1998, стандартная библиотека предоставляет механизм поддержки сторонних диспетчеров памяти. Когда тип или алгоритм поддерживает возможность использовать такой сторонний диспетчер памяти, он дает программисту возможность явно указать, как этот тип или алгоритм будет резервировать и возвращать динамическую память. Это «как» воплощается в объект, известный как диспетчер памяти, или распределитель (allocator). В этой главе вы узнаете:  как определяются понятия «диспетчер памяти» и «ресурс памяти»;  как создать свой ресурс памяти для выделения блоков из статического буфера;  как определить свои контейнеры, использующие нестандартный диспетчер памяти;  стандартные типы ресурсов памяти из пространства имен std::pmr и их подводные камни;  многие из странных особенностей модели диспетчеров памяти в C++11, предназначенных исключительно для поддержки scoped_allocator_ adaptor;  что делает тип «причудливым указателем» и где такой тип может пригодиться.

Диспетчер памяти обслуживает ресурс памяти Читая эту главу, вы должны постоянно помнить о различиях между двумя фундаментальными понятиями, которые я называю ресурсом памяти (memory resource) и диспетчером памяти, или распределителем (allocator). Ресурс памяти (это название взято из собственной терминологии стандарта – возможно, вам более естественным покажется название «куча») – это долгоживущий объект, который может выделять фрагменты памяти по запросу (обычно из более крупного блока памяти, который принадлежит самому ресурсу памяти). Ресурсы памяти обладают классической объектно-ориентированной семантикой (см. главу 1 «Классический полиморфизм и обобщенное программирование»):

Powered by TCPDF (www.tcpdf.org)

210    Глава 8. Диспетчеры памяти вы создаете ресурс памяти один раз и никогда не копируете и не перемещаете его, и равенство ресурсов памяти обычно определяется идентичностью объектов. С другой стороны, диспетчер памяти (распределитель) – короткоживущий дескриптор, указывающий на ресурс памяти. Диспетчеры памяти проявляют семантику указателей: их можно копировать, перемещать и вообще оперировать ими почти без ограничений, и равенство диспетчеров памяти обычно определяется равенством указателей на ресурс памяти. Вместо «диспетчер памяти указывает на такой-то ресурс памяти» можно также сказать, что «диспетчер памяти встроен в ресурс памяти»; эти фразы взаимозаменяемы. Когда я буду рассказывать о «ресурсах памяти» и «диспетчерах памяти» в этой главе, я также поведаю об идеях, предшествовавших им. В стандартной библиотеке имеется также несколько типов с именами memory_resource и allocator; всякий раз, когда речь будет заходить об этих типах, я буду использовать оформление моноширинным шрифтом. Пусть это не сбивает вас с толку. Аналогичная ситуация возникала в главе 2 «Итераторы и диапазоны», где я рассказывал об «итераторах» и о типе std::iterator. Конечно, тогда было проще, потому что я говорил, что не следует использовать std::iterator в своих программах; ему нет места в хорошем коде на C++. В этой главе вы узна­ ете, что использование std::pmr::memory_resource вполне оправданно в некоторых программах на C++! Несмотря на то что я назвал диспетчера памяти дескриптором, «указывающим на» ресурс памяти, имейте в виду, что иногда ресурс памяти является глобальным синглтоном – ярким примером такого синглтона может служить глобальная куча, управление которой реализуется глобальными операторами new и delete. Подобно тому, как лямбда-выражение, «замыкающее» глобальную переменную, в действительности ничего не замыкает, диспетчер, встроенный в глобальную кучу, не нуждается ни в каком состоянии. Фактически std::allocator – именно такой тип диспетчера без состояния, но не будем забегать вперед!

Еще раз об интерфейсах и понятиях В главе 1 «Классический полиморфизм и обобщенное программирование» рассказывалось, что C++ предлагает два, в основном несовместимых, способа интерпретации полиморфизма. Статический полиморфизм времени компиляции называется обобщенным программированием; он опирается на выражение полиморфного интерфейса как идеи со множеством возможных моделей, а код, взаимодействующий с интерфейсом, выражается в терминах шаблонов. Динамический полиморфизм времени выполнения называется классическим полиморфизмом; он опирается на выражение полиморфного интерфейса в виде базового класса со множеством возможных производных классов, а код, взаимодействующий с интерфейсом, выражается в терминах вызовов виртуальных методов. В этой главе мы в первый (и в последний) раз по-настоящему близко столкнемся с обобщенным программированием. Понять диспетчеры памяти в C++

Диспетчер памяти обслуживает ресурс памяти    211 невозможно, не подразумевая два понятия сразу: с одной стороны, понятие

Allocator, определяющее интерфейс, и с другой – некоторая конкретная модель, такая как std::allocator, реализующая поведение, соответствующее понятию Allocator. Самое примечательное, что Allocator в действительности является се-

мейством понятий! Точнее было бы называть его семейством понятий

Allocator; например, Allocator определяет понятие «диспетчера памяти, распределяющего объекты int», Allocator – «диспетчер памяти, распределяющий объекты char», и т. д. И например, конкретный класс std::allocator является моделью понятия Allocator, но он не является моделью для Allocator. Каждый диспетчер типа T (каждый Allocator) должен иметь функциючлен с именем allocate, такую как a.allocate(n), возвращающую указатель

на блок памяти с объемом, достаточным для хранения массива n объектов типа T. (Этот указатель будет приобретаться с помощью ресурса памяти, включающего экземпляр диспетчера памяти.) Стандарт не указывает, должна ли функция-член allocate быть статической и должна ли она принимать точно один параметр (n) или может принимать какие-то дополнительные параметры со значениями по умолчанию. Поэтому оба следующих класса типов в этом отношении являются допустимыми моделями Allocator: struct int_allocator_2014 { int *allocate(size_t n, const void *hint = nullptr); }; struct int_allocator_2017 { int *allocate(size_t n); };

Класс, обозначенный как int_allocator_2017, представляет, очевидно, более простую модель Allocator, но int_allocator_2014 тоже реализует правильную модель, и в обоих случаях выражение a.allocate(n) будет приниматься компилятором; и это все, что нам нужно, когда мы говорим об обобщенном программировании. В классическом полиморфизме, напротив, мы определяем фиксированную сигнатуру для каждого метода базового класса, и производным классам не позволяется отклоняться от нее: struct classical_base { virtual int *allocate(size_t n) = 0; }; struct classical_derived : public classical_base { int *allocate(size_t n) override; };

212    Глава 8. Диспетчеры памяти В производном классе classical_derived не получится добавить дополнительные параметры в сигнатуру метода allocate; получится изменить тип возвращаемого значения; получится сделать метод статическим. В классическом полиморфизме интерфейс «отливается в граните», в отличие от обобщенного программирования. Поскольку «отлитый в граните» классический интерфейс легче описать, чем более широкий концептуальный, мы начнем наше знакомство с библиотекой диспетчеров памяти с совершенно нового, появившегося в C++17, классически полиморфного memory_resource.

Определение кучи с помощью memory_resource Напомню, что на платформах с ограниченными ресурсами мы не можем использовать «кучу» (например, посредством new и delete), потому что среда выполнения такой платформы может не поддерживать распределение из динамической памяти. Но мы можем создать свою маленькую кучу – не «кучу», а всего лишь «кучку» – и смоделировать распределение динамической памяти, написав пару функций, выделяющих и освобождающих зарезервированные блоки памяти из большого статического массива типа char, например так: static char big_buffer[10000]; static size_t index = 0; void *allocate(size_t bytes) { if (bytes > sizeof big_buffer - index) { throw std::bad_alloc(); } index += bytes; return &big_buffer[index - bytes]; } void deallocate(void *p, size_t bytes) { // сбросить }

Чтобы сохранить код максимально простым, я определил deallocate как пустую функцию. Эта маленькая куча позволит вызывающему коду выделить до 10  000 байт памяти, после чего начнет возбуждать исключения bad_allocfrom. Приложив чуть больше усилий, можно позволить вызывающему коду не только выделять, но и освобождать память бесконечное число раз, при условии, что общий объем выделенной памяти не превысит 10 000 байт и вызывающий код неуклонно следует правилу «последний выделенный освобождается первым»:

Определение кучи с помощью memory_resource     213 void deallocate(void *p, size_t bytes) { if ((char*)p + bytes == &big_buffer[index]) { // ага! индекс можно открутить назад! index -= bytes; } else { // сбросить } }

Самым существенным моментом здесь является наличие у нашей кучи некоторого состояния (в данном случае big_buffer и index) и пары функций, управляющих этим состоянием. Мы уже видели два возможных варианта реализации освобождения – есть и другие возможности, с дополнительным общим состоянием, не настолько подверженные «утечкам», – но интерфейс, сигнатуры функций allocate и deallocate, остается постоянным. Это говорит о том, что состояние и функции доступа можно завернуть в объект C++; а широкое разнообразие возможных реализаций плюс постоянство сигнатур функций предполагает, что мы можем использовать некоторую форму классического полиморфизма. Модель диспетчера памяти в C++17 построена именно так. Стандартная библиотека предоставляет определение классически полиморфного базового класса, std::pmr::memory_resource, а мы на его основе реализуем свою маленькую кучу в производном классе. (На практике можно использовать один из производных классов, имеющихся в стандартной библиотеке, но давайте сначала закончим наш маленький пример и только потом поговорим об этом.) Базовый класс std::pmr::memory_resource определен в стандартном заголовке : class memory_resource { virtual void *do_allocate(size_t bytes, size_t align) = 0; virtual void do_deallocate(void *p, size_t bytes, size_t align) = 0; virtual bool do_is_equal(const memory_resource& rhs) const = 0; public: void *allocate(size_t bytes, size_t align) { return do_allocate(bytes, align); } void deallocate(void *p, size_t bytes, size_t align) { return do_deallocate(p, bytes, align); } bool is_equal(const memory_resource& rhs) const { return do_is_equal(rhs); } };

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

214    Глава 8. Диспетчеры памяти ческого полиморфизма, у нас есть только один наборов методов, одновременно общедоступных и виртуальных; но в данной ситуации мы имеем общедоступный, невиртуальный интерфейс, который вызывает приватные виртуальные методы. Такое отделение интерфейса от реализации имеет несколько неясных преимуществ. Например, он не позволяет в дочерних классах выполнять вызовы this->SomeBaseClass::allocate() с использованием синтаксиса «прямого вызова виртуальных методов невиртуальным способом», но, честно говоря, главным преимуществом для нас является тот факт, что, определяя производ­ ный класс, мы вообще не можем использовать ключевое слово public. Так как мы определяем только реализацию, но не интерфейс, весь написанный нами код может быть приватным. Вот тривиальный пример маленькой кучи, подверженной утечкам: class example_resource : public std::pmr::memory_resource { alignas(std::max_align_t) char big_buffer[10000]; size_t index = 0; void *do_allocate(size_t bytes, size_t align) override { if (align > alignof(std::max_align_t) || (-index % align) > sizeof big_buffer - index || bytes > sizeof big_buffer - index - (-index % align)) { throw std::bad_alloc(); } index += (-index % align) + bytes; return &big_buffer[index - bytes]; } void do_deallocate(void *, size_t, size_t) override { // сбросить } bool do_is_equal(const memory_resource& rhs) const override { return this == &rhs; } };

Обратите внимание, что стандартная функция std::pmr::memory_reso­ urce::allocate принимает не только размер в байтах, но также шаг вырав-

нивания. Мы должны гарантировать, что любой указатель, возвращаемый из do_allocate, имеет соответствующее выравнивание; например, если вызывающий код планирует хранить в памяти число int, мы выделяем необходимый объем, при этом он может потребовать четырехбайтное выравнивание. Последнее, что следует отметить в нашем производном классе example_ resource, – он представляет фактические ресурсы, управляемые нашей «кучкой»; то есть он фактически содержит, владеет и управляет буфером big_buffer, из которого выделяет память. Для любого данного буфера big_buffer в нашей программе будет существовать только один объект example_resource, управляющий этим буфером. Как уже говорилось выше: объекты типа example_re-

Использование стандартных ресурсов памяти    215 source являются «ресурсами памяти», и, следовательно, они не предназначены для копирования или перемещения; они поддерживают классическую объект­ но-ориентированную модель и не поддерживают семантику значения. В стандартной библиотеке есть несколько разновидностей ресурсов памяти и все они являются производными от std::pmr::memory_resource. Давайте познакомимся с некоторыми из них.

Использование стандартных ресурсов памяти Ресурсы памяти в стандартной библиотеке делятся на две разновидности. Некоторые являются настоящими классами типов, из которых можно создавать экземпляры; а некоторые – «анонимными» классами, доступными только через функции синглтона. Догадаться, к какой разновидности относится тот или иной ресурс, можно, подумав о том, могут ли быть «разными» два экземпляра типа или же тип всегда представлен единственным экземпляром. Простейший ресурс памяти в заголовке является «анонимным» синглтоном, доступным через std::pmr::null_memory_resource(). Определение этой функции выглядит примерно так: class UNKNOWN : public std::pmr::memory_resource { void *do_allocate(size_t, size_t) override { throw std::bad_alloc(); } void do_deallocate(void *, size_t, size_t) override {} bool do_is_equal(const memory_resource& rhs) const override { return this == &rhs; } }; std::pmr::memory_resource *null_memory_resource() noexcept { static UNKNOWN singleton; return &singleton; }

Обратите внимание, что функция возвращает указатель на экземпляр синглтона. Как правило, объекты std::pmr::memory_resource управляются с помощью указателей, потому что сами объекты memory_resource не могут перемещаться. null_memory_resource кажется довольно бесполезным; все, что он делает, – возбуждает исключение, когда кто-то пытается распределить память с его помощью. Однако он может пригодиться, когда вы начнете использовать более сложные ресурсы памяти, к которым мы вскоре подойдем. Следующий, более сложный ресурс памяти – синглтон, доступный через std::pmr::new_delete_resource(); для выделения и освобождения памяти он использует ::operator new и ::operator delete.

216    Глава 8. Диспетчеры памяти Теперь поговорим об именованных классах типов. Они определяют ресурсы, когда имеет смысл в одной программе создать несколько ресурсов одного типа. Возьмем для примера класс std::pmr::monotonic_buffer_resource. Этот ресурс почти ничем не отличается от нашего example_resource, кроме двух аспектов: он хранит не большой буфер в виде члена данных (в стиле std::array), а указатель на большой буфер, выделенный где-то еще (в стиле std::vector). И когда заканчивается первый большой буфер, вместо возбуждения исключения bad_alloc он пытается выделить второй буфер и начинает распределять блоки из этого буфера, пока не исчерпает его; после чего выделяется третий буфер... и т. д., пока не столкнется с невозможностью выделить дополнительный буфер. Как и наш example_resource, он не освобождает выделенную память, пока сам объект ресурса не будет уничтожен. Этот ресурс имеет один полезный предохранительный клапан: если вызвать метод a.release(), ресурс monotonic_buffer_resource освободит все буферы, удерживаемые им в данный момент, подобно методу clear() вектора. Создавая ресурс типа std::pmr::monotonic_buffer_resource, нужно ответить на два вопроса: где находится первый буфер, и куда обращаться, когда этот буфер будет исчерпан? Ответ на первый вопрос определяется парой аргументов void* и size_t, которые описывают первый буфер (может быть nullptr); и ответ на второй вопрос определяется аргументом std::pmr::memory_ resource*, который указывает на ресурс «выше по течению». Во втором аргументе вполне можно передать std::pmr::new_delete_resource(), чтобы выделять новые буферы с помощью ::operator new. Также в нем можно передать std::pmr::null_memory_resource(), чтобы установить верхний предел на использование памяти данным конкретным ресурсом. Вот пример использования последнего: alignas(16) char big_buffer[10000]; std::pmr::monotonic_buffer_resource a( big_buffer, sizeof big_buffer, std::pmr::null_memory_resource() ); void *p1 = a.allocate(100); assert(p1 == big_buffer + 0); void *p2 = a.allocate(100, 16); // выравнивание assert(p1 == big_buffer + 112); // Теперь очистить все распределенные блоки и начать сначала. a.release(); void *p3 = a.allocate(100); assert(p3 == big_buffer + 0); // Когда буфер исчерпается, произойдет обращение к вышестоящему ресурсу // за получением следующего буфера... и не получит ничего.

Использование стандартных ресурсов памяти    217 try { a.allocate(9901); } catch (const std::bad_alloc&) { puts("The null_memory_resource did its job!"); }

Если вы забыли, какой вышестоящий ресурс использует конкретный monotonic_buffer_resource, всегда можно вызвать a.upstream_resource(); этот метод вернет указатель на вышестоящий ресурс, переданный конструктору.

Выделение из ресурса пулов Последняя разновидность ресурсов памяти из предоставляемых стандартной библиотекой C++17 – так называемый «ресурс пулов». Ресурс пулов управляет не просто одним большим буфером, как example_resource; и даже не непрерывной цепочкой буферов, как monotonic_buffer_resource. Он управляет множествами «блоков» разных размеров. Все блоки заданного размера хранятся вместе в одном «пуле», что дает нам возможность говорить: «пул блоков с размером 4», «пул блоков с размером 16» и т. д. Когда поступает запрос на выделение блока памяти размером k, ресурс пулов просматривает пул блоков с размером k, извлекает один и возвращает вызывающему коду. Если пул блоков с размером k пуст, тогда ресурс пулов пытается выделить больше блоков из вышестоящего ресурса. Кроме того, если поступает запрос на выделение блока с очень большим размером, для которого нет собственного пула, тогда ресурсу пулов разрешается передать запрос непосредственно вышестоящему ресурсу. Ресурсы пулов имеют две разновидности: синхронизированные и несинхронизированные, то есть безопасные и небезопасные в многопоточной среде. Если вы собираетесь обращаться к ресурсу пулов одновременно из двух разных потоков, тогда вам следует использовать std::pmr::synchronized_pool_resource, а если вы точно никогда не будете делать этого и вам нужна максимальная скорость, используйте std::pmr::unsynchronized_pool_resource. (Кстати, std::pmr::monotonic_buffer_resource всегда безопасен в многопоточной среде; new_delete_resource() тоже безопасен, потому что действует только посредством операторов new и delete.) Конструируя ресурс типа std::pmr::synchronized_pool_resource, вы должны определить три параметра: размеры блоков в его пулах, сколько блоков он должен объединить в «фрагмент», когда собирается запросить больше блоков у вышестоящего ресурса и сам вышестоящий ресурс. К сожалению, стандартный интерфейс оставляет желать лучшего, причем настолько, что я положа руку на сердце советую, если эти параметры действительно имеют для вас значение, реализовать свой ресурс, производный от memory_resource, и вообще не касаться версии из стандартной библиотеки. Синтаксис выражения этих параметров также не блещет удобством: std::pmr::pool_options options; options.max_blocks_per_chunk = 100;

218    Глава 8. Диспетчеры памяти options.largest_required_pool_block = 256; std::pmr::synchronized_pool_resource a( options, std::pmr::new_delete_resource() );

Обратите внимание, что нет никакого способа точно определить желаемые размеры блоков; эти хлопоты перекладываются на создателя реализации synchronized_pool_resource. Если вам повезет, он выберет размеры, соответствующие вашим требованиям; но лично я не стал бы полагаться на это предположение. Обратите также внимание на отсутствие возможности использовать разные вышестоящие ресурсы для блоков разных размеров, а также отдельный вышестоящий «резервный» ресурс, который использовался бы для обработки запросов на выделение блоков необычного размера. Проще говоря, в обозримом будущем я бы воздержался от использования встроенных классов, производных от pool_resource. Но сама идея создания своих классов на основе memory_resource выглядит очень привлекательно. Если вас заинтересовала тема выделения памяти и управления своими маленькими кучками, я советую адаптировать memory_resource под свои нужды. До сих пор мы говорили только о разных стратегиях распределения, «персонифицированных» разными классами, производными от memory_resource. Но нам еще нужно рассмотреть, как связать memory_resource с алгоритмами и контейнерами из стандартной библиотеки шаблонов. И для этого нам придется покинуть мир классического полиморфизма memory_resource и вернуться обратно в мир семантики значения C++03 STL.

500-головый стандартный диспетчер памяти Модель стандартного диспетчера памяти должна была бы показаться удивительной в 2011. Далее мы посмотрим, как с помощью единственного типа C++ можно добиться всего из перечисленного ниже.  Указать ресурс памяти, используемый для распределения памяти.  Снабжать каждый выделенный указатель некоторыми метаданными, которые будут сопровождать его в течение всей жизни, до самого освобождения.  Связать объект контейнера с конкретным ресурсом памяти и гарантировать «нерушимость» этой связи – этот объект контейнера всегда будет использовать указанную кучу для получения памяти.  Связать значение контейнера с конкретным ресурсом памяти, чтобы контейнер мог эффективно перемещаться с использованием семантики значения и не забывал при этом, как освобождать свое содержимое.  Возможность выбирать между двумя взаимоисключающими поведения­ ми выше.

500-головый стандартный диспетчер памяти    219  Задавать стратегию выделения памяти на всех уровнях многоуровневого контейнера, такого как вектор векторов.  Переопределять понятие «конструирования» содержимого контейнера, чтобы, например, для vector::resize можно было определить значение по умолчанию для инициализации вместо инициализации нулями. Это просто безумное количество голов для одного класса типов – серьезное нарушение принципа единственной ответственности. Но именно это делает модель стандартного диспетчера памяти; поэтому попробуем разобраться во всех этих особенностях. Как вы наверняка помните, «стандартный диспетчер памяти» – это любой класс, удовлетворяющий понятию Allocator для некоторого типа T. В стандартной библиотеке имеется три типа стандартных диспетчеров памяти: std::allocator, std::pmr::polymorphic_allocator и std::scoped_ allocator_adaptor. Сначала рассмотрим std::allocator: template struct allocator { using value_type = T; T *allocate(size_t n) { return static_cast(::operator new(n * sizeof (T))); } void deallocate(T *p, size_t) { ::operator delete(static_cast(p)); } // ПРИМЕЧАНИЕ 1 template explicit allocator(const allocator&) noexcept {} // ПРИМЕЧАНИЕ 2 allocator() = default; allocator(const allocator&) = default; };

std::allocator имеет функции-члены allocate и deallocate, требуемые идеей Allocator. Напомню, что сейчас мы находимся в концептуальном мире обобщенного программирования! Классически полиморфный memory_resource также имеет функции-члены с именами allocate и deallocate, но они всегда возвращают void*, а не T*. (Кроме того, memory_ resource::allocate() принимает два аргумента – bytes и align, – тогда как allocator::allocate() принимает только один аргумент. Первая причина этого в том, что появление allocator предшествовало появлению понимания важности выравнивания; напомню, что оператор sizeof был уна­

220    Глава 8. Диспетчеры памяти следован из C в 1980-х, а оператор alignof появился только в C++11. Вторая причина в том, что в контексте std::allocator память выделяется для объектов типа T, поэтому выравнивание обязательно должно быть alignof(T). std::allocator не использует эту информацию, потому что появился раньше alignof, но, в принципе, это возможно, и именно поэтому Allocator требует только сигнатуру a.allocate(n), а не a.allocate(n, align).) Конструктор, отмеченный комментарием ПРИМЕЧАНИЕ 1, играет важную роль; каждый диспетчер памяти должен иметь шаблонный конструктор. Конструкторы, следующие за комментарием ПРИМЕЧАНИЕ 2, не играют существенной роли; единственная причина, по которой я добавил их, – если не определить их явно, они будут удалены из-за присутствия пользовательского конструктора (с комментарием ПРИМЕЧАНИЕ 1). Идея любого стандартного диспетчера памяти заключается в возможности подключить его через последний шаблонный параметр типа к любому стандартному контейнеру (глава 4 «Зоопарк контейнеров»), после чего контейнер будет использовать его вместо обычного механизма для распределения памяти, независимо от причины. Рассмотрим пример: template struct helloworld { using value_type = T; T *allocate(size_t n) { printf("hello world %zu\n", n); return static_cast(::operator new(n * sizeof (T))); } void deallocate(T *p, size_t) { ::operator delete(static_cast(p)); } }; void test() { std::vector v; // выведет "hello world 1" // выведет "hello world 2" // выведет "hello world 4"

Здесь наш класс helloworld моделирует Allocator, но мы опус­ тили шаблонный конструктор. Это нормально, если он будет использоваться только в паре с vector, потому что vector выделяет только массивы с типом его элементов. Но посмотрите, что получится, если изменить функцию test, как показано ниже: void test() { std::list v; v.push_back(42); }

500-головый стандартный диспетчер памяти    221 Для libc++ этот код породит несколько десятков строк с сообщениями об ошибках, которые сводятся к единственному: "no known conversion from helloworld to helloworld" (не-

известное преобразование из helloworld в helloworld). Как было показано на рис. 4.5, в главе 4 «Зоопарк контейнеров» std::list хранит свои элементы в узлах, размер которых больше размера типа T. Поэтому std::list пытается выделить память не для объектов типа T, а для объектов типа __list_node. А для этого ему нужен диспетчер памяти, моделирующий идею Allocator, а не Allocator. Внутренне конструктор std::list принимает наш helloworld и пытается «перепривязать» его, чтобы выделить память для объектов __list_ node, а не int. Это достигается посредством идиомы traits class--a, с кото-

рой мы впервые столкнулись в главе 2 «Итераторы и диапазоны»: using AllocOfInt = helloworld; using AllocOfChar = std::allocator_traits::rebind_alloc; // Теперь alloc_of_char - это helloworld

Стандартный шаблонный класс std::allocator_traits заключает большой объем информации о типе диспетчера A, поэтому ее легко получить. Например, std::allocator_traits::value_type – это псевдоним типа T, память для которого выделяется диспетчером A; а std::allocator_ traits::pointer – это псевдоним соответствующего типа указателя (обычно T*). Вложенный шаблон псевдонима std::allocator_traits::rebind_ alloc – это способ «преобразовать» тип диспетчера из T в U. Этот прием метапрограммирования используется, чтобы вскрыть тип A и увидеть: во-первых, имеет ли A вложенный шаблон псевдонима A::rebind::other (что бывает редко), и, во-вторых, можно ли выразить тип A в форме Foo (где Baz... – некоторый список типов, который может быть пустым). Если A можно выразить таким способом, тогда std::allocator_traits::rebind_ alloc будет служить синонимом для Foo. С философской точки зрения в этом мало смысла; но на практике этот прием работает для всех типов диспетчеров, которые вам могут встретиться. В частности, он работает для helloworld – что объясняет, почему нам не пришлось возиться с предоставлением вложенного псевдонима rebind::other в нашем классе helloworld. Предоставляя разумное поведение по умолчанию, шаблон std::allocator_traits избавил нас от необходимости писать массу типового кода. В этом заключается причина существования std::allocator_traits. Возможно, вам интересно, почему std::allocator_traits::value_type не работает по умолчанию для Bar. Признаться честно,

222    Глава 8. Диспетчеры памяти я сам не знаю. Я не вижу в этом большой проблемы, но стандартная библиотека не делает этого. Поэтому каждый тип диспетчера, который вы пишете (не забывайте, что сейчас речь идет о классах, моделирующих Allocator, а не о классах, производных от memory_resource), должен предоставлять вложенное определение типа value_type, который является псевдонимом для T. Однако после определения вложенного типа для value_type можно быть уверенным, что std::allocator_traits выведет правильные определения для его вложенного типа pointer (то есть T*), и const_pointer (const T*), и void_ pointer (void*), и т. д. Следившие за предыдущим обсуждением rebind_alloc могли бы предположить, что «преобразование» типа указателя, такого как T*, в void*, не более сложно или просто, чем «преобразование» типа диспетчера Foo в Foo, и будут правы! Значения этих псевдонимов типов, связанных с указателями, определяются посредством второго стандартного класса трейта std::pointer_traits

: using PtrToInt = int*; using PtrToChar = std::pointer_traits::rebind; // Теперь PtrToChar имеет тип char* using PtrToConstVoid = std::pointer_traits::rebind; // Теперь PtrToConstVoid имеет тип const void*

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

Метаданные, сопровождающие причудливые указатели Рассмотрим следующую высокоуровневую архитектуру ресурса памяти, напоминающего std::pmr::monotonic_buffer_resource.  Хранит список фрагментов памяти, полученных от системы. Для каждого фрагмента хранится также index – количество байтов, выделенных от начала этого фрагмента, и счетчик freed – количество байтов, возвращенных в этот фрагмент.  Когда какой-то код вызывает allocate(n), увеличивается index фрагмента на запрошенное количество байтов, если возможно, или у вышестоящего ресурса запрашивается новый фрагмент.  Когда какой-то код вызывает deallocate(p, n), выясняется, какому из фрагментов принадлежит адрес p, и увеличивается его счетчик

500-головый стандартный диспетчер памяти    223 freed += n. Если freed == index, тогда считается, что весь фрагмент свободен, поэтому выполняется freed = index = 0.

Это описание довольно легко преобразуется в программный код. Единственная проблема: как в deallocate(p, n) определить, какому из фрагментов принадлежит адрес p? Это легко сделать, если записать идентичность фрагмента в сам «указатель»: template class ChunkyPtr { T *m_ptr = nullptr; Chunk *m_chunk = nullptr; public: explicit ChunkyPtr(T *p, Chunk *ch) : m_ptr(p), m_chunk(ch) {} T& operator *() const { return *m_ptr; } explicit operator T *() const { return m_ptr; } // ... и т. д. ... // ... плюс следующий дополнительный метод доступа: auto chunk() const { return m_chunk; } };

После этого нам останется только вызвать p.chunk() в функции deal­ locate(p, n). Но для этого придется изменить сигнатуры функций allocate(n) и deallocate(p, n), чтобы deallocate принимала ChunkyPtr вместо T*, а allocate возвращала ChunkyPtr вместо T*.

К счастью, стандартная библиотека C++ дает такую возможность! Мы должны лишь определить свой тип, моделирующий Allocator, и добавить в него определение типа pointer как ChunkyPtr: template struct ChunkyAllocator { using value_type = T; using pointer = ChunkyPtr; ChunkyAllocator(ChunkyMemoryResource *mr) : m_resource(mr) {} template ChunkyAllocator(const ChunkyAllocator& rhs) :

224    Глава 8. Диспетчеры памяти m_resource(rhs.m_resource) {} pointer allocate(size_t n) { return m_resource->allocate( n * sizeof(T), alignof(T)); } void deallocate(pointer p, size_t n) { m_resource->deallocate( p, n * sizeof(T), alignof(T)); } private: ChunkyMemoryResource *m_resource; template friend struct ChunkyAllocator; };

Классы трейтов std::allocator_traits и std::pointer_traits позаботятся о выводе других определений типов, таких как void_pointer, который благодаря волшебству pointer_traits::rebind превратится в псевдоним для ChunkyPtr. Я не стал приводить здесь реализацию функций allocate и deallocate, потому что они зависят от интерфейса ChunkyMemoryResource. Мы могли бы реализовать ChunkyMemoryResource примерно так: class Chunk { char buffer[10000]; int index = 0; int freed = 0; public: bool can_allocate(size_t bytes) { return (sizeof buffer - index) >= bytes; } auto allocate(size_t bytes) { index += bytes; void *p = &buffer[index - bytes]; return ChunkyPtr(p, this); } void deallocate(void *, size_t bytes) { freed += bytes; if (freed == index) { index = freed = 0; } } }; class ChunkyMemoryResource { std::list m_chunks; public: ChunkyPtr allocate(size_t bytes, size_t align) {

500-головый стандартный диспетчер памяти    225 assert(align deallocate(p, n * sizeof(T), alignof(T)); } }; class Widget { char buffer[10000]; std::pmr::monotonic_buffer_resource mr {buffer, sizeof buffer}; std::vector v {&mr}; std::list lst {&mr}; public: static void swap_elems(Widget& a, Widget& b) { std::swap(a.v, b.v); } };

Это наш класс Widget – классический объектно-ориентированный тип. Предполагается, что весь свой жизненный цикл его экземпляр будет находиться в определенной области памяти. Поэтому, чтобы уменьшить фрагментацию кучи и улучшить локальность кеша, мы решили добавить большой буфер в каждый объект Widget и использовать этот буфер как для членов данных v и lst. Теперь рассмотрим функцию Widget::swap_elems(a, b). Она меняет мес­ тами члены данных v двух экземпляров, Widget a и Widget b. Как рассказывалось в главе 4 «Зоопарк контейнеров», std::vector – это чуть больше, чем указатель на массив, размещенный в динамической памяти, поэтому, чтобы поменять местами два экземпляра std::vector, обычно достаточно поменять их внутренние указатели, не перемещая самого содержимого векторов, благодаря чему операция swap для векторов получает сложность O(1) вместо O(n). Кроме того, тип vector достаточно интеллектуальный, чтобы знать, что кроме указателей нужно также поменять местами диспетчеры памяти, чтобы информация о порядке освобождения памяти перемещалась вместе с указателем, который в конечном счете потребуется освободить. Но в этом случае, если библиотека просто поменяет местами указатели и диспетчеры памяти, это станет катастрофой! Мы получим вектор a.v, чьим внутренним массивом теперь будет «владеть» b.mr, и наоборот. Если мы решим уничтожить Widget b, тогда при очередной попытке доступа к элементам a.v мы обратимся к освобожденной памяти. Но даже если мы никогда не будем обращаться к a.v, наша программа почти наверняка завершится аварийно, когда деструктор a.v попытается вызвать метод deallocate давно уничтоженного b.mr! К счастью, стандартная библиотека хранит нас от такой судьбы. Одной из обязанностей контейнера с поддержкой выбора диспетчера памяти является передача этого диспетчера в операции присваивания копированием и перемещением, а также в операцию обмена местами. По исторически сложившимся

Использование диспетчеров памяти стандартных типов    229 причинам это обрабатывается путем определений типов в шаблонном классе

allocator_traits, но для правильного использования передачи диспетчера

памяти достаточно знать следующее:

 будет ли передаваться диспетчер памяти или жестко прикрепляться к определенному контейнеру, зависит от свойств типа диспетчера. Если потребуется, чтобы один диспетчер «закреплялся», а другой передавался, вы должны сделать их разными типами;  «закрепляемый» диспетчер прикрепляется к конкретному объекту контейнера (классическим объектно-ориентированным способом). Операции обмена указателями, которые для «незакрепляемых» типов диспетчеров могут иметь сложность O(1), в «закрепляемых» типах получают сложность O(n), потому что для переноса элементов из-под управления одного диспетчера под управление другого требуется вновь выделить память;  «закрепляемые» диспетчеры имеют четко очерченный круг применения (как в примере с классом Widget), и последствия использования «незакрепляемых» диспетчеров иногда могут оказаться катастрофическими (и снова вспомните пример класса Widget). Поэтому std::allocator_ traits по умолчанию предполагает, что тип диспетчера памяти является «закрепляемым», если не может сказать, что он пустой и потому определенно не имеет состояния. По умолчанию для пустых типов диспетчеров предполагается невозможность «закрепления»;  как программист вы почти всегда будете следовать за умолчаниями: использовать диспетчеры без состояния там, где требуется их передача, и почти никогда не использовать диспетчеры с состоянием за рамками сценариев в стиле Widget.

Использование диспетчеров памяти стандартных типов Давайте поговорим о типах диспетчеров памяти, имеющихся в стандартной библиотеке. std::allocator – тип диспетчера памяти по умолчанию; это значение по умолчанию для шаблонного параметра типа любого контейнера. Например, когда в своем коде вы объявляете вектор std::vector, за кулисами компилятор определит его как тип std::vector. Как уже упоминалось выше в этой главе, std::allocator – это пустой тип, не имеющий состояния; он использует глобальную кучу посредством операторов new и delete. Так как тип std::allocator не имеет состояния, allocator_traits предполагает (совершенно справедливо), что его нельзя прикрепить к контейнеру. Это означает, что такие операции, как std::vector::swap и std::vector::operator=, гарантированно будут выполняться очень эффективно, простой сменой ука-

230    Глава 8. Диспетчеры памяти зателей, потому что любой объект типа std::vector всегда знает, как освободить память, преж­де выделенную другим экземпляром std::vector. std::pmr::polymorphic_allocator – новый тип, появившийся в C++17. Это непустой тип с состоянием; его единственный член данных – указатель на std::pmr::memory_resource. (Фактически он почти идентичен WidgetAlloc из нашего примера выше!) Два разных экземпляра std::pmr::polymorphic_ allocator не всегда могут оказаться взаимозаменяемыми, потому что их указатели могут указывать на совершенно разные ресурсы memory_resource; это значит, что объект типа std::vector может не знать, как освободить память, распределенную некоторым другим экземпляром std::vector. А это, в свою очередь, значит, что std::pmr::polymorphic_allocator является «закрепляемым» типом диспетчера памяти, то есть операции, такие как std::vector::operator=, могут приводить к копированию больших объемов данных. К слову сказать, довольно утомительно снова и снова писать полное имя типа std::vector. К счастью, производители реализаций стандартной библиотеки заметили это и добавили псевдонимы типа в пространство имен std::pmr: namespace std::pmr { template using vector = std::vector; template using map = std::map; // ... } // namespace std::pmr

Настройка ресурса памяти по умолчанию Главное отличие стандартного polymorphic_allocator от WidgetAlloc в нашем примере – стандартный polymorphic_allocator конструируется по умолчанию. Возможно, это привлекательная черта диспетчера памяти; она означает, что первую строку (см. ниже) можно записать как вторую: std::pmr::vector v2({1, 2, 3}, std::pmr::new_delete_resource()); // Определение конкретного ресурса памяти std::pmr::vector v1 = {1, 2, 3}; // Использование ресурса памяти по умолчанию

Создание контейнера с поддержкой выбора диспетчера памяти    231 С другой стороны, взглянув на вторую строку, вы можете спросить: «Где действительно размещается внутренний массив?» В конце концов, диспетчер памяти определяется в основном, чтобы точно знать, откуда будут взяты необходимые байты памяти! Вотпочему при обычном способе конструирования стандартного polymorphic_allocator передается указатель на memory_resource – фактически предполагается, что эта идиома станет настолько общей, что преобразование из std::pmr::memory_resource* в std::pmr::polymorphic_ allocator стало неявным. Но в polymorphic_allocator тоже есть конструктор по умолчанию (без аргументов). Создавая polymorphic_allocator по умолчанию, вы получаете дескриптор «ресурса памяти по умолчанию» – new_delete_ resource(). Но вы можете изменить это поведение! Указатель на ресурс памяти по умолчанию хранится в глобальной атомарной (безопасной в многопоточной среде) переменной, управление содержимым которой осуществляется посредством функций std::pmr::get_default_resource() (возвращает указатель) и std::pmr::set_default_resource() (сохраняет новый указатель и возвращает прежний). Если вы захотите полностью избежать выделения памяти из кучи посредст­ вом new и delete, тогда имеет смысл вызвать std::pmr::set_default_ resource(std::pmr::null_memory_resource()) в начале программы. Конечно, вы не сможете помешать другой части программы нарушить ваш замысел и также вызвать set_default_resource; а так как всеми потоками используется одна и та же глобальная переменная, можно столкнуться с очень странным поведением, если попытаться менять ресурс по умолчанию во время работы программы. Нельзя, например, сказать: «установить этот ресурс памяти по умолчанию для текущего потока». Кроме того, вызов get_default_resource() (например, из конструктора по умолчанию polymorphic_allocator) реализует атомарный доступ, из-за чего выполняется чуть медленнее. Поэтому лучше избегать конструктора по умолчанию polymorphic_allocator – всегда явно указывайте, какой ресурс памяти пытаетесь использовать. В случаях, когда требуется абсолютная надежность, можно даже подумать об использовании чего-то подобного WidgetAlloc из примера выше вместо polymorphic_allocator; отсутствие конструктора по умолчанию не позволит использовать WidgetAlloc неправильно.

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

232    Глава 8. Диспетчеры памяти  иметь конструктор, принимающий аргумент с диспетчером памяти;  фактически использовать диспетчер для выделения и освобождения памяти; полностью исключить любое использование new или delete;  конструктор перемещения контейнера, оператор присваивания перемещением и функция swap должны передавать диспетчер согласно его allocator_traits. Вот пример очень простого контейнера с поддержкой выбора диспетчера памяти – контейнера, который размещает в куче единственный объект. Это версия std::unique_ptr из главы 6 «Умные указатели»: template class uniqueish { using Traits = std::allocator_traits; using FancyPtr = typename Traits::pointer; A m_allocator; FancyPtr m_ptr = nullptr; public: using allocator_type = A; uniqueish(A a = {}) : m_allocator(a) { this->emplace(); } ~uniqueish() { clear(); } T& value() { return *m_ptr; } const T& value() const { return *m_ptr; } template void emplace(Args&&... args) { clear(); m_ptr = Traits::allocate(m_allocator, 1); try { T *raw_ptr = static_cast(m_ptr); Traits::construct(m_allocator, raw_ptr, std::forward(args)... ); } catch (...) { Traits::deallocate(m_allocator, m_ptr, 1); throw; } } void clear() noexcept { if (m_ptr) {

Создание контейнера с поддержкой выбора диспетчера памяти    233 T *raw_ptr = static_cast(m_ptr); Traits::destroy(m_allocator, raw_ptr); Traits::deallocate(m_allocator, m_ptr, 1); m_ptr = nullptr; } } };

Обратите внимание, что там, где unique_ptr использует T*, наш код использует allocator_traits::pointer; а там, где make_unique использует new и delete, наш код использует пару вызовов allocator_traits::allocate/ construct и allocator_traits::destroy/deallocate. Мы уже обсуждали назначение allocate и deallocate – они управляют выделением памяти из соответствующего ресурса. Но этот фрагмент памяти – всего лишь массив байтов; чтобы превратить его в объект, мы должны сконструировать экземпляр T по этому адресу. Мы могли бы использовать синтаксис «размещающей формы new» (placement-new); но в следующем разделе мы увидим, почему так важно вместо нее использовать construct и destroy. Наконец, обратите внимание, что деструктор uniqueish проверяет наличие распределенной памяти, перед тем как освободить ее. Это важно, потому что позволяет нам иметь значение uniqueish, представляющее «пустой объект», – значение, которое можно сконструировать без выделения памяти и использовать как «перемещенное» представление для нашего типа. Теперь реализуем операции перемещения для нашего типа. Нам хотелось бы, чтобы после перемещения объекта uniqueish по прежнему адресу оказался «пустой объект». Кроме того, если оба объекта, слева и справа, используют один и тот же диспетчер памяти или если тип диспетчера нельзя закрепить за контейнером, тогда желательно было бы вообще избежать применения конструктора перемещения T, а передать владение выделенным указателем из объекта справа объекту слева: uniqueish(uniqueish&& rhs) : m_allocator(rhs.m_allocator) { m_ptr = std::exchange(rhs.m_ptr, nullptr); } uniqueish& operator=(uniqueish&& rhs) { constexpr bool pocma = Traits::propagate_on_container_move_assignment::value; if constexpr (pocma) { // Мы можем принять новый диспетчер памяти, потому что // наш диспетчер не является "закрепляемым". this->clear(); // используется прежний диспетчер this->m_allocator = rhs.m_allocator; this->m_ptr = std::exchange(rhs.m_ptr, nullptr); } else if (m_allocator() == rhs.m_allocator()) { // Наш диспетчер закреплен за этим контейнером;

234    Глава 8. Диспетчеры памяти // но поскольку он эквивалентен диспетчеру в rhs, // мы можем использовать память из rhs. this->clear(); this->m_ptr = std::exchange(rhs.m_ptr, nullptr); } else { // Мы не должны передавать этот новый диспетчер // и поэтому не можем использовать его память. if (rhs.m_ptr) { this->emplace(std::move(rhs.value())); rhs.clear(); } else { this->clear(); } } return *this; }

Конструктор перемещения реализуется так же просто, как прежде. Единственное небольшое отличие – мы должны не забыть сконструировать наш m_allocator, копию диспетчера памяти в объекте справа. Мы могли не копировать, а переместить диспетчер памяти с помощью std::move, но я не думаю, что для данного примера это имеет какое-то значение. Не забывайте, что диспетчер памяти – это лишь «дескриптор», указывающий на фактический ресурс памяти, и что в действительности многие типы диспетчеров, такие как std::allocator, – «пустые». Копирование типа диспетчера практически всегда – относительно недорогая операция. Однако и использование std::move здесь не нанесло бы большого ущерба.

Оператор присваивания перемещением, напротив, очень сложен! В первую очередь нужно проверить «закрепляемость» типа диспетчера. «Незакрепляемость» обозначается значением true в propagate_on_container_move_ assignment::value, или более кратко pocma. (Фактически стандарт утверждает, что значение propagate_on_container_move_assignment должно совпадать с std::true_type; и GNU libstdc++ будет твердо придерживаться этого требования. Поэтому имейте это в виду, когда будете определять свои типы диспетчеров памяти.) Для незакрепляемых типов диспетчеров при присваивании перемещением, эффективнее уничтожить текущее значение (если имеется) – с использованием прежнего диспетчера m_allocator, – а затем использовать объект справа с его диспетчером. Поскольку указатель перемещается с диспетчером, можно быть полностью уверенным, что потом мы сможем освободить память. С другой стороны, если диспетчер памяти относится к «закрепляемому» типу, нельзя использовать диспетчер из объекта справа. Если текущий (закреп­

Создание контейнера с поддержкой выбора диспетчера памяти    235 ленный) экземпляр диспетчера равен экземпляру диспетчера в объекте справа, тогда можно смело принять указатель из объекта справа, потому что мы уже знаем, как освободить память, выделенную данным конкретным экземпляром диспетчера. Наконец, если мы не можем принять экземпляр диспетчера из объекта справа и наш текущий экземпляр диспетчера не равен экземпляру в объекте справа, значит мы не можем принять его, потому что позднее не сможем освободить этот указатель, и единственная возможность сделать это – использовать экземпляр диспетчера правого объекта прямо сейчас. В этом случае мы вынуждены распределить совершенно новый фрагмент памяти, используя свой экземпляр диспетчера, а затем скопировать данные из rhs.value() в свое значение вызовом конструктора перемещения типа T. Этот последний случай – единственный, когда мы фактически вызываем конструктор перемещения T! Присваивание копированием следует похожей логике передачи экземпляра диспетчера из объекта справа, за исключением проверки трейта propagate_ on_container_copy_assignment, или pocca. Операция swap особенно интересна, потому что в ней заключительный случай (когда используется экземпляр диспетчера закрепляемого типа и два экземпляра – слева и справа – не равны) требует выполнить дополнительное распределение памяти: void swap(uniqueish& rhs) noexcept { constexpr bool pocs = Traits::propagate_on_container_swap::value; using std::swap; if constexpr (pocs) { // Можно выполнить обмен диспетчерами, потому что // наш тип диспетчера "незакрепляемый". swap(this->m_allocator, rhs.m_allocator); swap(this->m_ptr, rhs.m_ptr); } else if (m_allocator == rhs.m_allocator) { // Наш диспетчер "закреплен" за этим контейнером; // но так как он эквивалентен диспетчеру в rhs, // мы можем принять память из rhs, и наоборот. swap(this->m_ptr, rhs.m_ptr); } else { // Ни одна из сторон не может принять память другой стороны, // и поэтому на той или другой стороне нужно вновь выделить память. auto temp = std::move(*this); *this = std::move(rhs); // может возбудить исключение rhs = std::move(temp); // может возбудить исключение } }

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

236    Глава 8. Диспетчеры памяти выделить память. Если соответствующий ему ресурс памяти окажется исчерпанным, Traits::allocate(m_allocator, 1) может возбудить исключение, и тогда мы окажемся перед лицом двух проблем. Во-первых, мы уже начали перемещение состояния и освободили старую память, и может так оказаться, что мы не сможем «вернуться» к прежнему состоянию. Во-вторых, что особенно важно, swap принадлежит к разряду настолько простых и фундаментальных функций, что стандартная библиотека не предусматривает возможности сбоя в ней. Например, алгоритм std::swap (глава 3 «Алгоритмы с парами итераторов») объявлен как noexcept, а это означает, что он всегда должен завершаться успехом; ему не позволено возбуждать исключения. То есть если попытка выделить память потерпит неудачу в процессе выполнения функции swap noexcept, исключение bad_alloc начнет просачиваться вверх по стеку вызовов, пока не встретит объявление функции swap noexcept. В этой точке среда выполнения C++ прекратит раскрутку стека и вызовет функцию std::terminate, которая (если программист не изменил ее поведение с помощью std::set_terminate) вызовет аварийное завершение программы. Стандарт C++17 пошел еще дальше в своем описании, что должно происходить в процессе обмена содержимого контейнеров стандартных типов. Вопервых, стандарт не утверждает, что ошибка выделения памяти в swap должна приводить к вызову std::terminate, он просто говорит, что ошибка в ходе выполнения swap приводит к неопределенному поведению. Во-вторых, стандарт не ограничивает неопределенное поведение ошибками распределения памяти! Согласно стандарту C++17, простой вызов swap для экземпляров любых стандартных контейнеров, диспетчеры памяти которых не равны, приводит в результате к неопределенному поведению, независимо от наличия или отсутствия ошибки распределения памяти! Фактически libc++ использует эту возможность оптимизации, чтобы сгенерировать код для всех функций swap стандартных контейнеров, который выглядит примерно так: void swap(uniqueish& rhs) noexcept { constexpr bool pocs = Traits::propagate_on_container_swap::value; using std::swap; if constexpr (pocs) { swap(this->m_allocator, rhs.m_allocator); } // Вообще не проверять - знаем ли мы как освободить // полученный указатель; просто предположить, что это возможно. swap(this->m_ptr, rhs.m_ptr); }

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

Передача вниз с scoped_allocator_adaptor    237 попытаться освободить один из указателей с помощью несоответствующего диспетчера. Помните об этой ловушке, используя «вспомогательные» типы C++17, такие как std::pmr::vector! char buffer[100]; auto mr = std::pmr::monotonic_buffer_resource(buffer, 100); std::pmr::vector a {1,2,3}; std::pmr::vector b({4,5,6}, &mr); std::swap(a, b); // НЕОПЕРЕДЕЛЕННОЕ ПОВЕДЕНИЕ a.reserve(a.capacity() + 1); // эта строка вызовет аварийное завершение, // так как попытается применить delete[] к указателю на стек

Если архитектура вашего кода позволяет обменивать содержимое контейнеров, хранящееся в разных ресурсах памяти, избегайте применения std::swap и пользуйтесь следующей безопасной идиомой: auto temp = std::move(a); // OK a = std::move(b); // OK b = std::move(temp); // OK

Под словами «избегайте применения std::swap» я подразумеваю «избегайте любых перестановочных алгоритмов из STL», включая такие, как std::reverse и std::sort. Добиться правильной их работы можно, но это очень сложная задача, и я не советую браться за нее! Если архитектура вашего кода позволяет обменивать содержимое контейнеров, хранящееся в разных ресурсах памяти, это верный признак, что ее стоит пересмотреть. Если вы сможете реорганизовать архитектуру так, что обмениваться будут только контейнеры, использующие общий ресурс памяти, или вам удастся избежать применения диспетчеров с состоянием и/или закрепляемых диспетчеров, тогда вы смело сможете позабыть об этой ловушке.

Передача вниз с scoped_allocator_adaptor В предыдущем разделе я представил функцию std::allocator_traits ::construct(a, ptr, args...) и описал ее как предпочтительную альтернативу синтаксису «размещающей формы new» ::new ((void*)ptr) T(args...). Теперь мы посмотрим, почему автору конкретного диспетчера памяти может потребоваться дать ей другую семантику. Один из очевидных способов изменения семантики construct своего диспетчера памяти – сделать его тривиально простым типом с инициализацией по умолчанию вместо инициализации нулями. Вот как может выглядеть такой код:

238    Глава 8. Диспетчеры памяти template struct my_allocator : std::allocator { my_allocator() = default; template my_allocator(const my_allocator&) {} template void construct(T *p, Args&&... args) { if (sizeof...(Args) == 0) { ::new ((void*)p) T; } else { ::new ((void*)p) T(std::forward(args)...); } } };

Теперь std::vector можно использовать как «векторный» тип, удовлетворяющий всем обычным ограничениям std::vector, кроме случая неявного создания новых элементов посредст­ вом v.resize(n) или v.emplace_back(), когда новые элементы будут создаваться неинициализированными, подобно переменным на стеке. То, что мы здесь создали, можно назвать «адаптером», который накладывается поверх std::allocator и изменяет его поведение, как нам это нужно. Было бы еще лучше, если бы у нас была возможность аналогичным образом изменить, или «адаптировать», любой произвольный диспетчер памяти. И такая возможность имеется! Для этого нужно изменить наш template на template и наследовать A везде, где старый код наследует std::allocator. Конечно, список шаблонных параметров адаптера больше не начинается с T, поэтому придется реализовать свою версию rebind. Однако этот путь ведет в густые дебри метапрограммирования, поэтому я не буду отвлекаться на его демонстрацию. Но существует другой, более удобный подход к реализации метода const­ ruct для собственного типа диспетчера памяти. Взгляните на следующий пример, где создается вектор векторов целых чисел int: std::vector vv; vv.emplace_back(); vv.emplace_back(); vv[0].push_back(1); vv[1].push_back(2); vv[1].push_back(3);

Допустим, мы решили «закрепить» этот контейнер за ресурсом памяти собст­венной разработки, таким как WidgetAlloc. Для этого нам нужно написать примерно такой код:

Передача вниз с scoped_allocator_adaptor    239 char buffer[10000]; std::pmr::monotonic_buffer_resource mr {buffer, sizeof buffer}; using InnerAlloc = WidgetAlloc; using InnerVector = std::vector; using OuterAlloc = WidgetAlloc; std::vector vv(&mr); vv.emplace_back(&mr); vv.emplace_back(&mr); vv[0].push_back(1); vv[1].push_back(2); vv[1].push_back(3);

Обратите внимание на повторение инициализатора &mr экземпляра диспетчера памяти на обоих уровнях. Необходимость повторения &mr усложняет использование нашего вектора vv в обобщенном контексте. Например, у нас не получится просто передать его в шаблон функции для заполнения данными, потому что для emplace_back нового вектора целых чисел int вызываемый код должен знать адрес &mr, известный только вызывающему. В этом случае можно создать обертку, воплощающую правило: «при создании нового элемента вектора векторов необходимо передать &mr в конце списка аргументов». И стандартная библиотека поможет нам в этом! Начиная с версии C++11 стандартная библиотека предоставляет (в заголовке с именем ) шаблонный класс scoped_allocator_adaptor. Так же как наш «адаптер» с инициализацией по умолчанию, scoped_allocator_adaptor наследует A, заимствуя все черты поведения A; и затем переопределяет метод construct. В этом методе он пытается определить, конструируется ли объект T «с использованием диспетчера памяти», и если это так, передает себя конструктору T как дополнительный аргумент. Выяснение, что тип T «использует диспетчера памяти», scoped_allocator_ adaptor::construct, производится с помощью типа std::uses_allocator_v, который (если не специализирован вами, чего вы в большинстве случаев не должны делать) получит значение true, только если A неявно преобразуется в T::allocator_type. Если T не имеет allocator_type, тогда библиотека предположит, что T не заботится о выборе диспетчера памяти, кроме особых случаев pair и tuple (которые имеют специальные перегруженные версии конструкторов, предназначенные специально для передачи диспетчера вниз своим членам) и особого случая promise (который может выделять память для общего состояния даже притом, что не предусматривает никакого способа сослаться на объект диспетчера; мы говорим, что «стирание типов» в диспетчере памяти для promise выполняется еще тщательнее, чем в примерах, которые мы видели в главе 5 «Словарные типы»). По исторически сложившимся причинам конструкторы типов с поддержкой диспетчеров памяти могут следовать одному из двух шаблонов программирования, и scoped_allocator_adaptor распознает оба. Более старые и простые ти-

240    Глава 8. Диспетчеры памяти пы (то есть все, кроме tuple и promise), как правило, имеют конструкторы вида: T(args..., A), где диспетчер памяти A следует последним в списке. Для tuple и promise стандартная библиотека ввела новый шаблон: T(std::allocator_ arg, A, args...), где диспетчер A следует первым, но ему предшествует специальный тег std::allocator_arg, единственное назначение которого – показать, что следующий аргумент в списке аргументов представляет диспетчер памяти, подобно тому, как единственное назначение тега std::nullopt – показать, что optional не имеет значения (см. главу 5 «Словарные типы»). По аналогии с запретом создания типа std::optional стандарт также запрещает создавать std::tuple. Используя scoped_allocator_adaptor, мы можем переписать наш громоздкий пример, сделав его менее громоздким: char buffer[10000]; std::pmr::monotonic_buffer_resource mr {buffer, sizeof buffer}; using InnerAlloc = WidgetAlloc; using InnerVector = std::vector; using OuterAlloc = std::scoped_allocator_adaptor; std::vector vv(&mr); vv.emplace_back(); vv.emplace_back(); vv[0].push_back(1); vv[1].push_back(2); vv[1].push_back(3);

Обратите внимание, что тип диспетчера памяти получился более громоздким, зато, что особенно важно, из вызовов emplace_back исчез аргумент &mr. Теперь мы можем использовать vv в контекстах, где применяется традиционный синтаксис добавления элементов, без передачи &mr. В нашем случае, поскольку мы используем свой диспетчер памяти, WidgetAlloc, не имеющий конструктора по умолчанию, симптомом забытого &mr являются ошибки времени компиляции. Но, как рассказывалось в предыдущих разделах в этой главе, std::pmr::polymorphic_allocator благополучно позволит создавать его экземпляры с конструктором по умолчанию и потенциально разрушительными результатами; поэтому, если вы планируете использовать polymorphic_allocator, взгляните также на scoped_allocator_adaptor, просто чтобы ограничить количество мест, в которых вы могли бы забыть определить свою стратегию выделения памяти.

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

Передача вниз с scoped_allocator_adaptor    241 перечислив несколько аргументов с типами диспетчеров, как в следующем примере: using InnerAlloc = WidgetAlloc; using InnerVector = std::vector; using MiddleAlloc = std::scoped_allocator_adaptor< WidgetAlloc, WidgetAlloc >; using MiddleVector = std::vector; using OuterAlloc = std::scoped_allocator_adaptor< WidgetAlloc, WidgetAlloc, WidgetAlloc >; using OuterVector = std::vector;

Определив все эти typedef, перейдем к созданию трех отдельных ресурсов памяти и сконструируем экземпляр scoped_allocator_adaptor, способный запомнить все три ресурса (потому что включает три отдельных экземпляра WidgetAlloc, по одному на «уровень»): char bi[1000]; std::pmr::monotonic_buffer_resource mri {bi, sizeof bi}; char bm[1000]; std::pmr::monotonic_buffer_resource mrm {bm, sizeof bm}; char bo[1000]; std::pmr::monotonic_buffer_resource mro {bo, sizeof bo}; OuterAlloc saa(&mro, &mrm, &mri);

Наконец, можно сконструировать экземпляр OuterVector, передав аргумент scoped_allocator_adaptor, и все! Переопределенный метод construct, глубоко скрытый в нашем тщательно подготовленном типе диспетчера, позаботится о передаче аргумента &bm или &bi в любой конструктор, нуждающийся в них: OuterVector vvv(saa); vvv.emplace_back(); // В этом случае память будет зарезервирована в буфере "bo". vvv[0].emplace_back(); // В этом случае память будет зарезервирована в буфере "bm". vvv[0][0].emplace_back(42); // В этом случае память будет зарезервирована в буфере "bi".

Как видите, глубоко вложенный scoped_allocator_adaptor – не для слабых духом и имеет практическую ценность, только если определить множество «вспомогательных» определений typedef, как в этом примере.

242    Глава 8. Диспетчеры памяти И еще одно замечание о std::scoped_allocator_adaptor: Если вложенность контейнеров глубже, чем количество типов диспетчеров в спис­ке параметров шаблона, тогда scoped_allocator_adaptor будет действовать так, как если бы последний тип диспетчера в списке параметров повторялся до бесконечности. Например: using InnerAlloc = WidgetAlloc; using InnerVector = std::vector; using MiddleAlloc = std::scoped_allocator_adaptor< WidgetAlloc >; using MiddleVector = std::vector; using TooShortAlloc = std::scoped_allocator_adaptor< WidgetAlloc, WidgetAlloc >; using OuterVector = std::vector; TooShortAlloc tsa(&mro, WidgetAlloc(&mri)); OuterVector tsv(tsa); tsv.emplace_back(); // В этом случае память будет зарезервирована в буфере "bo". tsv[0].emplace_back(); // В этом случае память будет зарезервирована в буфере "bi". tsv[0][0].emplace_back(42); // В этом случае память СНОВА будет зарезервирована в буфере "bi"!

Фактически именно на такое поведение мы положились в нашем первом примере использования scoped_allocator_adaptor для создания вектора векторов vv, даже притом, что тогда я не упомянул об этом явно. Теперь, когда вы познакомились с ним, можете вернуться назад и исследовать пример еще раз, чтобы заметить «повторение до бесконечности» и как можно изменить тот код, чтобы для внутреннего вектора целых чисел использовать другой ресурс памяти, отличный от ресурса, используемого для внешнего вектора InnerVector.

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

Итоги    243 Стандарт C++17 предложил библиотечный тип std::pmr::memory_resource, чтобы более четко провести грань между ресурсами памяти (или кучами) и диспетчерами памяти (или дескрипторами куч). Ресурсы памяти поддерживают методы allocate и deallocate; диспетчеры памяти тоже предлагают эти методы, а также методы construct и destroy. Решив реализовать свой тип диспетчера памяти A, вы должны объявить его шаблоном; первым параметром этого шаблона должен быть тип T объектов, для которых предполагается выделять память. Кроме того, ваш тип диспетчера A должен иметь шаблонный конструктор для поддержки «перепривязки» из A в A. Так же как любой указатель, тип диспетчера памяти должен поддерживать операторы == и !=. Метод кучи deallocate может требовать передачи дополнительных метаданных, присоединенных к входному указателю. В C++ эта возможность поддерживается посредством причудливых указателей. Тип std::pmr::memory_re­ source в C++17 не поддерживает причудливых указателей, но вы легко сможете реализовать свою поддержку. Типы причудливых указателей должны удовлетворять всем требованиям, которые предъявляются к итераторам с произвольным доступом, поддерживать пустые значения и давать возможность преобразовывать их в обычные указатели. Если вы решите использовать свой тип причудливых указателей с контейнерами на основе узлов, такими как std::list, вы должны реализовать статическую функцию-член pointer_to. C++17 различает типы «закрепляемых» и «незакрепляемых» диспетчеров памяти. Типы диспетчеров без состояния, такие как std::allocator, являются незакрепляемыми; типы диспетчеров с состоянием, такие как std::pmr::polymorphic_allocator, по умолчанию являются закрепляемыми. Чтобы получить свой тип диспетчера памяти с нестандартным свойством закрепляемости или незакрепляемости, нужно добавить в него все три члена typedef, известные как «POCCA», «POCMA» и «POCS». Типы закрепляемых диспетчеров, такие как std::pmr::polymorphic_allocator, в основном используются в традиционном объектно-ориентированном программировании, когда объект контейнера закрепляется за конкретным адресом памяти. Типы диспетчеров без состояния в основном применяются в программировании с использованием семантики значений (со множеством операций move и swap), а также везде, где программа использует одну кучу и один закрепляемый диспетчер памяти, фактически не имеющий состояния. Тип scoped_allocator_adaptor может помочь упростить использование глубоко вложенных контейнеров, использующих нестандартные ресурсы памяти или диспетчеры памяти. Почти любой глубоко вложенный контейнер, использующий нестандартный тип диспетчера памяти, требует определения множества вспомогательных типов в ущерб удобочитаемости кода. Обмен местами двух контейнеров с разными закрепляемыми диспетчерами памяти в теории порождает неопределенное поведение, но на практике повреждает данные в памяти и вызывает аварийное завершение. Не делайте этого!

Глава

9

Потоки ввода/вывода До сих пор мы видели классический полиморфизм в стандартной библиотеке лишь в нескольких местах. В главе 8 «Диспетчеры памяти» мы познакомились с классически полиморфным типом std::pmr::memory_resource, и в главе  5 «Словарные типы» видели, как «за кулисами» классический полиморфизм используется типами std::any и std::function. Однако в большей своей части стандартная библиотека благополучно обходится без классического полиморфизма. Но в двух местах классический полиморфизм используется очень широко. Одно из них – иерархия стандартных исключений (для большего удобства все исключения, возбуждаемые стандартной библиотекой, наследуют класс std::exception), но в этой книге мы не будем рассматривать иерархию исключений. Другое – стандартный заголовок , который рассмат­ривается в этой главе. Однако мы должны прояснить еще кое-что, прежде чем углубиться в исследование этого заголовка! В этой главе рассматриваются следующие темы:  разделение вывода на буферизацию и форматирование и разделение ввода на буферизацию, лексемизацию и парсинг;  POSIX API для неформатированного ввода/вывода в файлы;  «C» API в , добавляющий буферизацию и форматирование;  достоинства и недостатки классического API ;  опасности форматирования в зависимости от региональных настроек и новые возможности C++17, которые помогут избежать их;  множество способов преобразования числовых данных в строки и обратно.

Проблемы ввода/вывода в C++ Типичной мерой удобства языков программирования является степень сложности программ типа «Hello world». Многие популярные языки программирования имеют очень низкий показатель «Hello world»: в большинстве языков сценариев, таких как Python и Perl, программа «Hello world» состоит буквально из одной строки: print "hello world".

Powered by TCPDF (www.tcpdf.org)

Проблемы ввода/вывода в C++    245 C++ и его предшественник C являются языками системного программирования, в первую очередь ориентированными на управление машиной, высокую скорость выполнения и (в случае с C++) возможность использовать систему типов для реализации обобщенных алгоритмов. В это разнообразие целей не вписываются такие маленькие программы, как «Hello world». Каноническая программа «Hello world» на языке C имеет следующий вид: #include int main() { puts("hello world"); }

В C++: #include int main() { std::cout ; coinflipper onecoin; std::array results; std::generate(results.begin(), results.end(), onecoin); assert((results == std::array{{ 0,0,0,1, 0,1,1,1, 0,1,1,1, 0,0,1,0, 1,0,1,0, 1,1,1,1, 0,0,0,1, 0,1,0,1, 1,0,0,1, 1,1,1,0, 0,0,1,0, 1,0,1,0, 1,0,0,1, 0,0,0,0, 0,1,0,0, 1,1,0,0, }})); std::independent_bits_engine manycoins; assert(manycoins() == 0x1772af15); assert(manycoins() == 0x9e2a904c);

Обратите внимание, что independent_bits_engine не выполняет никаких сложных операций с битами из внутреннего генератора; в частности, он предполагает, что внутренний генератор действует непредвзято. Если генератор WeightedCoin будет отдавать предпочтение четным числам, это смещение будет также наблюдаться в выводе independent_bits_engine. Несмотря на то что мы потратили несколько страниц на обсуждение генераторов, у вас нет никаких причин использовать эти малопонятные классы в своем коде! Если вам нужен PRNG, используйте std::mt19937; если вам нужен криптографически безопасный PRNG, используйте такие алгоритмы, как AES-CTR или ISAAC; а если вам нужно небольшое количество по-настоящему случайных битов, чтобы инициализировать свой PRNG, используйте std::random_device. Это все генераторы, которые вам доведется применять на практике.

316    Глава 11. Случайные числа

Распределения Теперь, узнав, как генерируются случайные биты, посмотрим, как преобразовать эти случайные биты в числовые значения, подчиняющиеся определенному закону распределения. Этот процесс из двух этапов – генерация битов и их последующее преобразование в значения данных – очень похож на процесс буферизации и парсинга, рассматривавшийся в главе 9 «Потоки ввода/вывода». Сначала нужно получить биты и байты, а затем выполнить некоторую операцию для их преобразования в значения определенных типов. Любой объект распределения dist позволяет выполнять следующие операции с ним.  dist(g): возвращает следующий результат, соответствующий заданному математическому распределению. Для этого может потребоваться выполнить несколько вызовов g() или ни одного, в зависимости от внут­реннего состояния объекта dist.  dist.reset(): очищает внутреннее состояние объекта dist, если имеется. Вам никогда не придется использовать эту функцию-член.  dist.min() и dist.max(): возвращают наименьшее и наибольшее значения, которое можно получить вызовом dist(g) для любого генератора g. Обычно эти числа либо самоочевидны, либо бессмысленны; например, std::normal_distribution().max() вернет INFINITY. Посмотрим, как действуют распределения.

Имитация броска игровой кости с uniform_int_distribution Метод std::uniform_int_distribution – самый простой тип распределения в стандартной библиотеке. Он выполняет те же операции, которые мы пытались выполнять с randint0 выше в этой главе, – отображение случайного целого числа в заданный диапазон, – но делает это без какого-либо смещения. Простейшая реализация uniform_int_distribution выглядит примерно так: template class uniform_int_distribution { using UInt = std::make_unsigned_t; UInt m_min, m_max; public: uniform_int_distribution(Int a, Int b) : m_min(a), m_max(b) {} template Int operator()(Gen& g) { UInt range = (m_max - m_min); assert(g.max() - g.min() >= range); while (true) { UInt r = g() - g.min();

Распределения    317 if (r *wi) { cutoff -= *wi++; ++vi; } return *vi; }

Однако выбор вещественной арифметики для библиотечной реализации по умолчанию discrete_distribution имеет веское основание: она избавляет от беспокойства о целочисленном переполнении. Предыдущий код начнет производить ошибочные результаты, если значение суммы превысит диапазон int.

Перемешивание карт с std::shuffle Завершим эту главу знакомством с std::shuffle(a,b,g), стандартным алгоритмом, который принимает генератор случайных чисел. Это перестановочный алгоритм, как определено в главе 3 «Алгоритмы с парами итераторов». Он принимает диапазон элементов [a,b) и перемешивает их, сохраняя значения, но не местоположения. Метод std::shuffle(a,b,g) появился в C++11 взамен более старого алгоритма std::random_shuffle(a,b). Старый алгоритм «случайно» перемешивал диапазон [a,b), но ему нельзя было передать источник случайных чисел. На практике это означало использование глобальной функции rand() со всеми сопутствующими проблемами. Как только стандарт C++11 добавил заголовок

Итоги    321 , определяющий стандартный способ представления генераторов случайных чисел, появилась возможность избавиться от старого алгоритма random_shuffle, основанного на rand(), и начиная с C++17 std::random_ shuffle(a,b) больше не является частью стандартной библиотеки C++. Вот как можно использовать C++11-версию std::shuffle для перемешивания колоды игральных карт: std::vector deck(52); std::iota(deck.begin(), deck.end(), 1); // теперь deck содержит целые числа от 1 до 52. std::mt19937 g(std::random_device{}()); std::shuffle(deck.begin(), deck.end(), g); // теперь содержимое deck перемешано в случайном порядке.

Напомню, что все генераторы в полностью детерминированы, то есть экземпляр std::mt19937, например, инициализированный фиксированным значением, будет производить в точности одну и ту же последовательность на любой платформе. Но это не относится к распределениям, таким как uniform_real_distribution, и не относится к алгоритму shuffle. Переключение с libc++ на libstdc++ или даже простое обновление версии компилятора может изменить поведение std::shuffle. Обратите внимание, что в предыдущем примере используется «простой» метод инициализации алгоритма Mersenne Twister, а это значит, что он будет поддерживать только 4 × 109 разных вариантов перемешивания из 8 × 1067 возможных! Если вы собираетесь организовать перемешивание карт для игры в настоящем казино, вам определенно стоит использовать «утомительный» метод инициализации, описанный выше в этой главе, или – еще проще, если произ­водительность не является проблемой, – используйте std::random_device непосредственно: std::random_device rd; std::shuffle(deck.begin(), deck.end(), rd); // теперь содержимое deck перемешано в ИСТИННО случайном порядке.

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

Итоги Стандартная библиотека реализует два механизма, имеющих отношения к генерации случайных чисел, – генераторы и распределения. Генераторы имеют внутреннее состояние, должны инициализироваться начальным значением и производят беззнаковые целочисленные значения (биты) посредством operator()(void). К наиболее важным относятся два типа генераторов:

322    Глава 11. Случайные числа std::random_device, который производит по-настоящему случайные биты, и std::mt19937, который производит псевдослучайные биты.

Распределения обычно не имеют своего состояния и производят числовые значения посредством operator()(Gen&). Чаще других в практике программирования используется распределение std::uniform_int_distribu­ tion(a,b), которое производит целые числа в закрытом диапазоне [a,b]. В стандартной библиотеке реализованы также другие распределения, такие как std::uniform_real_distribution, std::normal_distribution и std::discrete_distribution, а также несколько довольно замысловатых распределений, более интересных математикам и статистикам. Примером использования случайности может служить стандартный алгоритм std::shuffle, который заменил более старый std::random_shuffle. Не используйте random_shuffle в новом коде. Помните, что std::mt19937 действует одинаково на всех платформах, но это не относится к распределениям и std::shuffle.

Глава

12

Файловая система Одним из самых заметных новшеств, появившихся в C++17, стала библиотека . Эта библиотека, как и многие другие важные особенности современного C++, была заимствована из проекта Boost. В 2015 г. она была включена в техническую спецификацию стандарта для сбора отзывов и, наконец, вошла в состав стандарта C++17 с некоторыми изменениями, внесенными по результатам отзывов. В этой главе вы познакомитесь:  с реализацией в возврата динамически типизируемых ошибок без возбуждения исключений, и как то же самое можно реализовать вручную;  с форматом пути и принципиально не совместимыми положениями POSIX и Windows;  с приемами получения информации о файлах и обхода каталогов на основе использования переносимых алгоритмов из C++17;  со способами создания, копирования, переименования и удаления файлов и каталогов;  с методом определения объема свободного пространства в файловой системе.

Примечание о пространствах имен Все стандартные инструменты для работы с файловыми системами в C++17 сосредоточены в единственном заголовке, , и в единственном пространстве имен namespace std::filesystem. Такая организация следует прецеденту, созданному заголовком в C++11 с его пространством имен std::chrono. (В этой книге отсутствует полное описание . А использование соответствующих инструментов с std::thread и std::timed_mutex кратко описывается в главе 7 «Конкуренция».) Такая организация пространств имен означает, что при использовании инструментов из требуется использовать такие идентификаторы, как std::filesystem::directory_iterator и std::filesystem::temp_directory_path(). Эти полные имена выглядят довольно громоздко! Но внедрение всего пространства имен в текущий контекст с помощью объявления using, –

324    Глава 12. Файловая система вероятно, еще худший выбор. Последнее десятилетие нас настойчиво учили не использовать объявление using namespace std, и этот совет распространяется на все пространства имен в стандартной библиотеке, как бы глубоко они не были вложены. Взгляните на следующий код: using namespace std::filesystem; void foo(path p) { remove(p); // Какая функция вызывается здесь? }

Гораздо лучше определить псевдоним пространства имен в области видимос­ ти файла (в файле .cc) или в области видимости пространства имен (в файле .h). Псевдоним позволит ссылаться на пространство имен по новому имени, как показано ниже: namespace fs = std::filesystem; void foo(fs::path p) { fs::remove(p); // Намного понятнее! }

Для ссылки на пространство имен std::filesystem в оставшейся части этой главы я буду использовать псевдоним fs. Под ссылкой fs::path я буду подразумевать std::filesystem::path. А под ссылкой fs::remove я буду подразу­ мевать std::filesystem::remove. Определение глобального псевдонима пространства имен fs где-то еще имеет также чисто прагматическое преимущество. На момент написания этих строк все производители реализаций стандартной библиотеки, кроме Microsoft Visual Studio, заявили о добавлении заголовка . Однако инструменты в очень похожи на инструменты в libstdc++ и libc++ из заголовка и в Boost из . Поэтому, если последовательно ссылаться на них с использованием псевдонима пространства имен, такого как fs, вы сможете переключаться с одной реализации на другую, просто изменяя определение псевдонима, вместо того чтобы выполнять поиск с заменой во всей базе кода. Следующий фрагмент иллюстрирует правоту моих слов: #if USE_CXX17 #include namespace fs = std::filesystem; #elif USE_FILESYSTEM_TS #include namespace fs = std::experimental::filesystem; #elif USE_BOOST #include

Очень длинное примечание об уведомлениях об ошибках    325 namespace fs = boost::filesystem; #endif

Очень длинное примечание об уведомлениях об ошибках Программисты на C++ испытывают любовь и ненависть к уведомлениям об ошибках. В данном случае под «уведомлениями об ошибках» я подразумеваю: «что делать, если нет возможности выполнить запрошенную операцию». Классический, типичный и все еще лучший вариант уведомить о проблеме в C++ – возбудить исключение. В предыдущих главах мы видели, что иногда исключение – единственный разумный способ из-за невозможности вернуть что-то вызывающему коду. Например, если вашей задачей было конструирование объекта, и операция его создания потерпела неудачу, вы ничего не сможете вернуть; когда конструктор терпит неудачу, остается единственный путь – возбудить исключение. Но мы также видели, что (в главе 9 «Потоки ввода/вывода»), что библиотека отступает от этого правила! Если операция создания объекта std::fstream потерпела неудачу (например, из-за невозможности открыть указанный файл), она не возбудит исключения; вместо этого вы получите полноценный объект fstream с f.fail() && !f.is_open(). Причина такого «плохого» поведения fstream заключается в относительно высокой вероятности, что указанный файл не может быть открыт. Возбуждение исключения каждый раз, когда нельзя открыть файл, по своему неудобству близко к использованию исключений для управления порядком выполнения, и этого желательно избегать. Поэтому, чтобы не заставлять программиста повсеместно использовать конструкцию try/catch, библиотека возвращает тот же результат, что и при успешной операции, но дает возможность проверить результат (с помощью обычной инструкции if, а не catch). Благодаря этому можно избежать такого запутанного кода: try { f.open("hello.txt"); // Файл благополучно открыт. } catch (const std::ios_base::failure&) { // При открытии произошла ошибка. } и записать то же самое проще: f.open("hello.txt"); if (f.is_open()) { // Файл благополучно открыт. } else { // При открытии произошла ошибка. }

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

326    Глава 12. Файловая система го состояния, или где такой признак можно добавить на этапе проектирования. Но он имеет свои недостатки и неприменим в ситуациях, где нет такого тяжеловесного типа. Мы видели подобную ситуацию в главе 9 «Потоки ввода/вывода», когда рассматривали способы парсинга целых чисел из строк. Если ошибка маловероятна или «использование исключений для управления порядком выполнения» не ухудшает производительность, мы используем std::stoi: // Подход на основе исключений. try { int i = std::stoi(s); // Парсинг выполнен успешно. } catch (...) { // Ошибка парсинга. }

А если нужна совместимость с C++03, мы используем strtol, которая уведомляет об ошибке через глобальную переменную (локальную для потоков выполнения) переменную errno: char *endptr = nullptr; errno = 0; long i = strtol(s, &endptr, 10); if (endptr != s && !errno) { // Парсинг выполнен успешно. } else { // Ошибка парсинга. }

Следуя суперсовременному стилю C++17, мы используем std::from_chars, которая возвращает легковесную структуру с указателем на конец строки и значением типа перечисления std::errc, определяющим успех или неудачу: int i = 0; auto [ptr, ec] = std::from_chars(s, end(s), i); if (ec != std::errc{}) { // Парсинг выполнен успешно. } else { // Ошибка парсинга. }

Библиотеке требуется аналогичная возможность уведомления об ошибках, как в std::from_chars. Практически любая операция с файловой системой может завершиться неудачей из-за действий других процессов, выполняющихся в системе; поэтому возбуждение исключений (а-ля std::stoi) по своему неудобству близко к использованию исключений для управления порядком выполнения. Но передача и проверка «ошибочного результата», такого как ec, тоже может стать утомительным занятием, чреватым ошибками. Поэтому было решено реализовать по два интерфейса почти для каждой функции в заголовке !

Очень длинное примечание об уведомлениях об ошибках    327 Например, вот две функции из , определяющие размер файла на диске: uintmax_t file_size(const fs::path& p); uintmax_t file_size(const fs::path& p, std::error_code& ec) noexcept;

Обе принимают fs::path (обсуждается далее в этой главе) и возвращают значение uintmax_t с размером указанного файла в байтах. Но что случится, если указанный файл не существует или существует, но текущий пользователь не имеет права запрашивать его размер? Первая функция в этом случае возбудит исключение fs::filesystem_error, а вторая (которая фактически отмечена спецификатором noexcept) заполнит параметр типа std::error_code информацией об ошибке (или очистит его, если операция выполнилась успешно). Сравнив сигнатуры fs::file_size и std::from_chars, можно заметить, что from_chars принимает параметр типа std::errc, а file_size – параметр типа std::error_code. Эти два типа, несмотря на сходство, далеко не одно и то же! Чтобы понять разницу – и саму архитектуру API, не возбуждающего исключений, – совершим короткий тур по другой части стандартной библиотеки C++11.

Использование Различия между механизмами уведомлений об ошибках в std::from_chars и fs::file_size обусловлены их природной сложностью. from_chars может потерпеть неудачу в двух случаях – либо когда строка не начинается с цифры, либо когда цифр настолько много, что их невозможно преобразовать в целое число, не вызвав переполнения. В первом случае классический (но неэффективный и небезопасный) способ уведомить об ошибке заключается в том, чтобы присвоить переменной errno значение EINVAL (и вернуть некоторое бессмысленное значение, такое как 0). Во втором случае классический способ заключается в том, чтобы присвоить переменной errno значение ERANGE (и вернуть некоторое бессмысленное значение). Примерно такой подход используется в strtol. Суть в том, что from_chars может столкнуться только с двумя, точно определенными ошибками. И их легко описать единым набором кодов, определяемым в . То есть чтобы перенести strtol из 1980-х в двадцать первый век, достаточно просто вернуть код ошибки непосредственно, а не косвенно, через errno. Именно это и делает стандартная библиотека. Классические значения из по-прежнему доступны как макросы, объявленные в , но начиная с C++11 они также определяются как строго типизированное перечисление в , как демонстрирует следующий фрагмент: namespace std { enum class errc { // "0" означает "нет ошибки"

328    Глава 12. Файловая система operation_not_permitted = EPERM, no_such_file_or_directory = ENOENT, no_such_process = ESRCH, // ... value_too_large = EOVERFLOW }; } // namespace std

std::from_chars уведомляет об ошибках, возвращая структуру (struct from_chars_result) с переменной-членом типа enum std::errc, которая хра­

нит 0 в случае отсутствия ошибок, или одно из двух возможных значений, определяющих вид ошибки. А теперь вернемся к fs::file_size. Количество ошибок, с которыми может столкнуться file_size, намного, намного больше – только подумайте о разнообразии операционных систем и файловых систем, которые ими поддерживаются, учтите также, что некоторые файловые системы (такие как NFS) распределены в сетях разных типов, и вы поймете, что множество возможных ошибок невообразимо велико. Их можно было бы объявить в виде 78 стандартных вариантов в sys::errc (по одному для каждого POSIX-значения errno, кроме EDQUOT, EMULTIHOP и ESTALE), но тогда потерялся бы огромный объем информации. По крайней мере, один из выпавших вариантов POSIX (ESTALE) является допустимым кодом ошибки в fs::file_size! И конечно, сама файловая система могла бы возвращать свои коды ошибок; например, несмотря на наличие в POSIX стандартного кода для ошибки «слишком длинное имя файла», в POSIX нет кода ошибки «недопустимый символ в имени» (по причинам, с которыми мы познакомимся в следующем разделе). Возможно, файловая система пожелает сообщить именно эту ошибку, не заботясь о том, что fs::file_size может исчерпать доступный диапазон вариантов некоторого фиксированного типа перечисления. Существенная проблема здесь в том, что не все ошибки, возвращаемые из fs::file_size, имеют один и тот же источник, а значит, их нельзя представить одним фиксированным типом (например, std::errc). Механизм исключений в C++ элегантно решает эту проблему; для разных уровней программы нормально и естественно возбуждать разные типы исключений. Если самый нижний уровень программы возбудит исключение myfs::DisallowedCharac­ terInName, самый верхний сможет перехватить его – по имени, по классу или по .... Если следовать правилу – любое исключение должно быть потомком std::exception, – тогда любой блок catch сможет вызвать e.what(), чтобы вывести более или менее осмысленное описание проблемы, независимо от ее природы. Стандартная библиотека воплощает идею разнородных ошибок в базовом классе std::error_category: namespace std { class error_category {

Очень длинное примечание об уведомлениях об ошибках    329 public: virtual const char *name() const noexcept = 0; virtual std::string message(int err) const = 0; // другие виртуальные методы не показаны bool operator==(const std::error_category& rhs) const { return this == &rhs; } }; } // namespace std

Класс error_category действует почти так же, как memory_resource из главы 8 «Диспетчеры памяти»; он определяет классический полиморфный интерфейс, а библиотеки, желающие использовать его, могут определить свои подклассы it. Обсуждая memory_resource, мы видели, что некоторые его подклассы являются глобальными синглтонами, а некоторые – нет. В случае с error_category каждый подкласс должен быть глобальным синглтоном, иначе он не будет работать. Чтобы извлечь практическую пользу из ресурсов памяти, библиотека дает нам контейнеры (см. главу 4 «Зоопарк контейнеров»). На самом простом уровне контейнер – это указатель, представляющий некоторую область памяти, плюс дескриптор ресурса памяти, который знает, как освободить эту память. (Напомню, что этот дескриптор называется диспетчером памяти.) Чтобы извлечь практическую пользу из подклассов error_category, библио­ тека дает нам std::error_code. На самом простом (и единственном в данном случае) уровне error_code – это значение типа int, представляющее код ошибки, плюс дескриптор error_category, который знает, как интерпретировать этот код: namespace std { class error_code { const std::error_category *m_cat; int m_err; public: const auto& category() const { return m_cat; } int value() const { return m_err; } std::string message() const { return m_cat->message(m_err); } explicit operator bool() const { return m_err != 0; } // другие вспомогательные методы не показаны }; } // namespace std

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

330    Глава 12. Файловая система namespace FinickyFS { enum class Error : int { success = 0, forbidden_character = 1, forbidden_word = 2, too_many_characters = 3, }; struct ErrorCategory : std::error_category { const char *name() const noexcept override { return "finicky filesystem"; } std::string message(int err) const override { switch (err) { case 0: return "Success"; case 1: return "Invalid filename"; case 2: return "Bad word in filename"; case 3: return "Filename too long"; } throw Unreachable(); } static ErrorCategory& instance() { static ErrorCategory instance; return instance; } }; std::error_code make_error_code(Error err) noexcept { return std::error_code(int(err), ErrorCategory::instance()); } } // namespace FinickyFS

Предыдущий пример определяет новую область ошибок, FinickyFS::Error, воплощенную как FinickyFS::ErrorCategory::instance(). Он позволяет создавать объекты типа std::error_code, как make_error_code(FinickyFS::Er­ ror::forbidden_word). Обратите внимание, что механизм аргумент-зависимого поиска (Argument-Dependent Lookup, ADL) правильно отыщет перегруженную версию make_error_code без нашей помощи. make_error_code является точкой настройки, точно так же как swap: просто определите функцию с этим именем в пространст­ве имен с вашим перечислением, а все остальное компилятор сделает самостоятельно.

Очень длинное примечание об уведомлениях об ошибках    331 // Ошибка благополучно укладывается в статически типизированный // объект с семантикой значения std::error_code ... std::error_code ec = make_error_code(FinickyFS::Error::forbidden_word); // ...и его "строка с описанием" остается // доступной, так же как в случае динамически // типизированного исключения! assert(ec.message() == "Bad word in filename");

Теперь у нас есть возможность передавать коды FinickyFS::Error через систему без потери информации – заворачивая их в тривиально копируемые объекты std::error_code – и извлекать их обратно на верхнем уровне. Написав эти слова, я задумался: звучит как волшебство, как обработка исключений без исключений! Но, как вы только что видели, реализовать все это не составляет труда.

Коды ошибок и условия ошибок Обратите внимание, что FinickyFS::Error явно преобразуется в std::error_code; в последнем примере мы использовали синтаксис make_error_code(FinickyFS::Error::forbidden_word), чтобы сконструировать начальный объект error_code. Мы можем сделать FinickyFS::Error более удобным для программиста, если сообщим , что FinickyFS::Error можно неявно преобразовать в std::error_code, как показано ниже: namespace std { template struct is_error_code_enum : true_type {}; } // namespace std

Будьте внимательны, повторно открывая пространство имен std, – помните, что это должно делаться за пределами любого другого пространства имен! Иначе будет создано вложенное пространство имен, такое как FinickyFS::std. В данном конкретном случае, если вы ошибетесь, компилятор известит вас при попытке специализировать несуществующим типом FinickyFS::std::is_error_code_enum. Если вы повторно открываете пространство имен std, только чтобы специализировать тот или иной шаблон (и не нарушаете синтаксис специализации шаблонов), можете не беспокоиться, что ошибка останется незамеченной. После специализации std::is_error_code_enum вашим типом перечисления обо всем остальном библиотека позаботится сама: class error_code { // ... template< class E, class = enable_if_t

332    Глава 12. Файловая система > error_code(E err) noexcept { *this = make_error_code(err); } };

Неявное преобразование, показанное в предыдущем примере, обеспечивает удобный синтаксис, например прямое сравнение посредством ==, но, так как каждый объект std::error_code несет информацию об области, которой принадлежит ошибка, сравнение выполняется с проверкой типов. Равенство значений объектов error_code зависит не только от их целочисленных значений, но также от адресов, связанных с ними синглтонов error-category. std::error_code ec = FinickyFS::Error::forbidden_character; // Сравнения выполняются с учетом типов. assert(ec == FinickyFS::Error::forbidden_character); assert(ec != std::io_errc::stream);

Специализация is_error_code_enum может пригодиться, если вы собираетесь часто присваивать значения типа X переменным типа std::error_code или возвращать их из функций с типом возвращаемого значения std::error_ code. Иными словами, специализация полезна, если ваш тип X действительно представляет источник ошибок – так сказать, сторону уравнения, возбуждающую исключения. А как насчет другой стороны, перехватывающей эти исключения? Допустим, вы заметили, что написали следующую функцию и еще несколько похожих на нее: bool is_malformed_name(std::error_code ec) { return ( ec == FinickyFS::Error::forbidden_character || ec == FinickyFS::Error::forbidden_word || ec == std::errc::illegal_byte_sequence); }

Эта функция определяет унарный предикат для всей вселенной кодов ошибок; она возвращает true для любого кода ошибки, с которым связано понятие неправильно сформированного имени, с точки зрения нашей библиотеки Finic­kyFS. Мы можем просто поместить эту функцию прямо в нашу библиотеку, как FinickyFS::is_malformed_name() – и фактически я советую поступать именно так, – но стандартная библиотека реализует другой возможный подход. Вместо error_code можно определить error_condition, например: namespace FinickyFS { enum class Condition : int { success = 0, malformed_name = 1,

Очень длинное примечание об уведомлениях об ошибках    333 }; struct ConditionCategory : std::error_category { const char *name() const noexcept override { return "finicky filesystem"; } std::string message(int cond) const override { switch (cond) { case 0: return "Success"; case 1: return "Malformed name"; } throw Unreachable(); } bool equivalent(const std::error_code& ec, int cond) const noexcept override { switch (cond) { case 0: return !ec; case 1: return is_malformed_name(ec); } throw Unreachable(); } static ConditionCategory& instance() { static ConditionCategory instance; return instance; } }; std::error_condition make_error_condition(Condition cond) noexcept { return std::error_condition(int(cond), ConditionCategory::instance()); } } // namespace FinickyFS namespace std { template struct is_error_condition_enum : true_type {}; } // namespace std

После этого вызов FinickyFS::is_malformed_name(ec) можно заменить сравнением (ec == FinickyFS::Condition::malformed_name), как показано ниже: std::error_code ec = FinickyFS::Error::forbidden_word; // Правая сторона неявно преобразуется в error_code

334    Глава 12. Файловая система assert(ec == FinickyFS::Error::forbidden_word); // Правая сторона неявно преобразуется в error_condition assert(ec == FinickyFS::Condition::malformed_name);

Однако так как мы не реализовали функцию make_error_code(Finic­ kyFS::Condition), будет непросто сконструировать объект std::error_code,

хранящий одно из этих условий. Но это нормально; перечисления условий предназначены для проверки на стороне, перехватывающей исключения, а не для преобразования в error_code на стороне, возбуждающей их. Стандартная библиотека предоставляет два типа перечислений с кодами ошибок (std::future_errc и std::io_errc) и один тип перечисления с условиями (std::errc). Именно так – std::errc в действительности перечисляет условия, а не коды ошибок! То есть было бы неправильно пытаться присвоить код ошибки POSIX объекту std::error_code; это условия, предназначенные для проверки на стороне, перехватывающей исключения, а не возбуждающей их. К сожалению, стандартная библиотека проявляет непоследовательность в этом отношении, по меньшей мере дважды. Во-первых, как мы видели, std::from_chars возвращает значение типа std::errc (что вдвойне неудобно; правильнее было бы возвращать std::error_code). Во-вторых, существует функция std::make_error_code(std::errc), вносящая путаницу в семантическое пространство, хотя в действительности должна существовать (и сущест­ вует) только std::make_error_condition(std::errc).

Возбуждение ошибок с std::system_error До сих пор мы рассматривали std::error_code – отличную альтернативу использованию механизма обработки исключений в C++. Но иногда приходится смешивать библиотеки, возбуждающие и не возбуждающие исключения, на разных уровнях системы. Стандартная библиотека поможет вам решить, по крайней мере, половину этой задачи. std::system_error – конкретный тип исключения, производный от std::runtime_error, который имеет достаточно места для хранения одного error_code. То есть если вы пишете библиотеку, использующую исключения вместо error_code, которая может получить error_ code, указывающий на ошибку на более низком уровне системы, удачным решением будет завернуть этот error_code в объект system_error и возбудить исключение. // Нижний уровень возвращает коды ошибок в error_code. uintmax_t file_size(const fs::path& p, std::error_code& ec) noexcept; // Мой уровень, возбуждающий исключения. uintmax_t file_size(const fs::path& p) { std::error_code ec; uintmax_t size = file_size(p, ec);

Очень длинное примечание об уведомлениях об ошибках    335 if (ec) { throw std::system_error(ec); } return size; }

Противоположная ситуация – когда вы пишете библиотеку, не возбуждающую исключений, но исключения могут порождаться на более низком уровне. В этом случае стандартная библиотека почти ничего не предлагает вам в помощь. Но вы легко можете сами написать код, разворачивающий error_code: // Нижний уровень возбуждает исключения. uintmax_t file_size(const fs::path& p); // Мой уровень, использующий error_code. uintmax_t file_size(const fs::path& p, std::error_code& ec) noexcept { uintmax_t size = -1; try { size = file_size(p); } catch (...) { ec = current_exception_to_error_code(); } return size; }

Предыдущий фрагмент вызывает нестандартную функцию current_exception_to_error_code(), которую можно написать самим, например: namespace detail { enum Error : int { success = 0, bad_alloc_thrown = 1, unknown_exception_thrown = 2, }; struct ErrorCategory : std::error_category { const char *name() const noexcept override; std::string message(int err) const override; static ErrorCategory& instance(); }; std::error_code make_error_code(Error err) noexcept { return std::error_code(int(err), ErrorCategory::instance()); } } // namespace detail std::error_code current_exception_to_error_code()

336    Глава 12. Файловая система { try { throw; } catch (const std::system_error& e) { // также перехватывает std::ios_base::failure // и fs::filesystem_error return e.code(); } catch (const std::future_error& e) { // необычные исключения return e.code(); } catch (const std::bad_alloc&) { // bad_alloc часто представляет особый интерес return detail::bad_alloc_thrown; } catch (...) { return detail::unknown_exception_thrown; } }

На этом мы завершаем экскурс в мир и продолжим обсуждение .

Файловые системы и пути В главе 9 «Потоки ввода/вывода» мы обсудили идею файловых дескрипторов POSIX. Файловый дескриптор представляет источник или приемник данных, который можно настроить на чтение и/или запись. Часто, но не всегда, он соответствует файлу на диске. (Напомню, что файловый дескриптор с номером 1 ссылается на стандартный вывод stdout, который обычно подключен к экрану монитора. Файловые дескрипторы могут также ссылаться на сетевые сокеты, устройства, такие как /dev/random, и т. д.) Кроме того, файловые дескрипторы POSIX, и имеют непосредственное отношение к содержимому файла на диске – последовательности байтов, составляющей содержимое файла. Файл в представлении файловой системы имеет множество более существенных атрибутов, которые недоступны через API чтения/записи в файлы. Используя инструменты, описанные в главе 9 «Потоки ввода/вывода», нельзя определить принадлежность файла или дату/время последнего изменения; с их помощью также нельзя определить количество файлов в заданном каталоге. Цель – дать нашим программам на C++ возможность обращаться с этими атрибутами файловой системы переносимым способом. Начнем с самого начала. Что такое файловая система? Файловая система – это абстракция сопоставления путей с файлами посредством элементов каталога. Лучше понять суть сказанного вам поможет диаграмма на рис. 12.1. Вверху на диаграмме (рис. 12.1) изображен абстрактный мир «имен». У нас имеется отображение этих имен (таких как speech.txt) в конкретные структуры, которые в терминологии POSIX называются индексными узлами (inode).

Файловые системы и пути    337 Термин «индексный узел» не используется стандартом C++ – он использует обобщенный термин «файл», – но я буду использовать термин «индексный узел» для большей точности. Каждый индексный узел содержит полный набор атрибутов, описывающих единственный файл на диске: его владельца, дату последнего изменения, тип и т. д. Но самое главное индексный узел определяет размер файла и содержит указатель на фактическое содержимое (подобно тому, как std::vector или std::list хранит указатель на свое содержимое). Точное представление индексных узлов и блоков данных на диске зависит от типа файловой системы. К наиболее распространенным файловым системам можно отнести ext4 (в Linux), HFS+ (в OS X) и NTFS (в Windows). Имена

Индексные узлы

Блок данных Пробил час, когда каждый добропорядочный гражданин должен прийти на помощь своей стране...

Рис. 12.1. Файловая система Обратите внимание, что некоторые блоки на этой диаграмме содержат данные, которые являются табличным представлением имен и соответствующих им номеров индексных узлов. Круг замкнулся! Итак, каталог – это индексный узел определенного типа, содержащий табличное представление с именами и номерами индексных узлов. В каждой файловой системе имеется один специальный индексный узел, который называется корневым каталогом. Допустим, что корневой узел с меткой «2» на нашей диаграмме является корневым каталогом. Тогда мы можем однозначно идентифицировать файл с текстом «Пробил час...» цепочкой имен, ведущей к нему от корневого каталога. Например, /My Documents/speech.txt: она начинается с корневого каталога, имя My Documents отображается в индексный узел 42, который также является каталогом и отображает имя speech.txt в индексный узел 17, представляющий обычный файл, хранящий на диске текст «Пробил час...». Для разделения

338    Глава 12. Файловая система имен в этой цепочке мы используем символы слеша (/) и добавляем один слеш в начало цепочки – он соответствует корневому каталогу. (В Windows каждый раздел, или диск, имеет свой корневой каталог. Поэтому вместо /My Documents/speech.txt путь к файлу записывается немного иначе – c:/My Documents/speech.txt, – чтобы показать, что путь начинается в корневом каталоге на диске C.) Напротив, "/alices-speech.txt" – это путь, ведущий к файлу прямо из корневого каталога к индексному узлу 17. Мы говорим, что эти два пути ("/My Documents/speech.txt" и "/alicesspeech. txt") являются жесткими ссылками на один и тот же индексный узел, а значит, соответствуют одному и тому же файлу. Некоторые файловые системы (такие как FAT,используемая на многих USB-накопителях) не поддерживают возможности создания нескольких жестких ссылок на один и тот же файл. Файловая система, поддерживающая множест­венные жесткие ссылки, должна подсчитывать количество ссылок на каждый индексный узел, чтобы знать, когда можно безопасно удалить и освободить индексный узел, подобно тому, как ведется подсчет ссылок в shared_ ptr (см. главу 6 «Умные указатели»). Когда мы вызываем библиотечную функцию, такую как open или fopen, чтобы «открыть файл», она выполняет следующую процедуру, скрытую глубоко в недрах файловой системы: извлекает имя файла, переданное ей, и преобразует его в путь – разбивает строку по символам слеша и спускается по дереву каталогов в файловой системе, пока не достигнет индексного узла с требуемым файлом (или не обнаружит, что такого файла нет). Обратите внимание, что по достижении индексного узла отпадает необходимость в имени файла, потому что он может иметь столько же имен, сколько имеется жестких ссылок.

Представление путей в C++ Все функции, представленные в главе 9 «Потоки ввода/вывода», с парамет­ ром «имя файла» (то есть путь) благополучно принимают этот путь в виде const char *. Но в библиотеке картина усложняется, в основном из-за Windows. Все файловые системы, соответствующие стандарту POSIX, хранят имена (такие как speech.txt) в виде простых строк байтов. POSIX выдвигает единственное требование – имена не должны содержать символы '\0' и '/' (потому что последний используется как разделитель компонентов пути). Строка "\xC1.h" считается в POSIX вполне допустимым именем файла, даже притом, что она не является допустимой строкой UTF-8 и ASCII, а вид такого имени на экране, как его выведет команда ls ., полностью зависит от региональных настроек. В конце концов, это всего лишь строка из трех байтов, ни один из которых не соответствует символу '/'. В Window, напротив, встроенные API доступа к файлам, такие как CreateFileW, всегда хранят имена в кодировке UTF-16. То есть пути в Windows по определению всегда являются допустимыми строками Юникода. Это важное

Файловые системы и пути    339 философское различие между POSIX и NTFS! Повторю еще раз и помедленнее: имена файлов в POSIX – это строки байтов. Имена файлов в Windows – это строки символов Юникода. Если следовать основному принципу из главы 9 «Потоки ввода/вывода», что все в мире должно быть представлено в кодировке UTF-8, тогда различия между POSIX и Windows окажутся вполне преодолимыми, возможно, даже незначительными. Но если вам доведется решать проблемы с необычными именами файлов в той или в другой системе, имейте в виду: имена файлов в POSIX – это строки байтов, а в Windows – это строки символов. Поскольку Windows API оперирует строками UTF-16 (std::u16string), а POSIX API – строками байтов (std::string), ни одно из представлений не может в точности соответствовать требованиям кросс-платформенности. Поэтому в добавлен новый тип: fs::path. (Напомню, что мы используем псевдоним имени пространства имен, то есть в действительности std::filesystem::path.) Определение типа fs::path выглядит примерно так: class path { public: using value_type = std::conditional_t< IsWindows, wchar_t, char >; using string_type = std::basic_string; const auto& native() const { return m_path; } operator string_type() const { return m_path; } auto c_str() const { return m_path.c_str(); } // другие конструкторы и методы опущены private: string_type m_path; };

Обратите внимание, что fs::path::value_type – это тип wchar_t в Windows, даже притом, что на эту роль больше подходит тип char16_t из C++11. Это всего лишь наследие исторических корней библиотеки Boost. В этой главе, когда мы будем говорить о wchar_t, вы можете смело предполагать, что речь идет об UTF-16, и наоборот. Переносимый код должен преобразовывать возвращаемые значения fs::path всех функций в строки. Например, обратите внимание, что функция path.c_str() возвращает не const char *, а const value_type *! fs::path p("/foo/bar"); const fs::path::value_type *a = p.c_str(); // Переносимый для любой системы. const char *b = p.c_str();

340    Глава 12. Файловая система // Допустим в POSIX; ошибка компиляции в Windows. std::string s = p.u8string(); const char *c = s.c_str(); // Допустим в POSIX и в Windows. // В Windows выполняется преобразование 16-to-8.

Вариант с c в предыдущем примере гарантированно компилируется, но его поведение отличается на разных платформах: на платформах POSIX вы получите простую строку байтов, а в Windows выполнится дорогостоящее преобразование path.native() из UTF-16 в UTF-8 (именно то, что запрошено, но программа может работать быстрее, если найти способ избежать запроса). fs::path имеет шаблонный конструктор, который может создать экземпляр path практически из любого аргумента. Аргумент может быть последовательностью символов любого типа (char, wchar_t, char16_t или char32_t), и эта последовательность может быть представлена указателем на строку, завершающуюся нулевым символом, итератором на такую же строку, basic_string, basic_string_view или парой итераторов. Как обычно, я упоминаю об этом разнообразии перегруженных версий не потому, что вы должны использовать их в своей практике, но затем, чтобы вы знали, как их избегать. Стандарт также предоставляет свободную функцию fs::u8path("path"), которая в действительности является всего лишь синонимом для fs::path("path"), но может служить напоминанием, что передаваемая строка должна быть представлена в кодировке UTF-8. Лично я советую не использовать u8path. Все это только кажется пугающим. Имейте в виду, что если использовать имена файлов в кодировке ASCII, вам не придется беспокоиться о проблемах кодирования; а если избегать методов path.native() и path.c_str() и неявного преобразования в fs::path::string_type, вам почти не придется беспокоиться о переносимости.

Операции с путями Получив объект пути, вы получаете возможность обращаться ко множеству его компонентов с использованием разделителя-слеша. В следующем фрагменте каждый идентификатор x (кроме самого path) представляет значение, возвращаемое функцией-членом path.x(): assert(root_path == root_name / root_directory); assert(path == root_name / root_directory / relative_path); assert(path == root_path / relative_path); assert(path == parent_path / filename); assert(filename == stem + extension); assert(is_absolute == !is_relative); if (IsWindows) {

Файловые системы и пути    341 assert(is_relative == (root_name.empty() || root_directory.empty())); } else { assert(is_relative == (root_name.empty() && root_directory.empty())); }

Например, для пути p = "c:/foo/hello.txt" мы получим p.root_name() == "c:", p.root_directory() == "/", p.relative_path() == "foo/hello. txt", p.stem() == "hello" и p.extension() == ".txt". По крайней мере,

такие результаты мы получили в Windows! Обратите внимание, что в Windows абсолютный путь должен содержать не только корневой каталог, но также имя диска (ни "c:foo/hello.txt", ни "/foo/hello.txt" не являются абсолютными путями), тогда как в POSIX, где имя диска отсутствует, абсолютный путь должен начинаться только с символа слеша ("/foo/hello.txt" – абсолютный путь, а "c:foo/hello.txt" – относительный, начинающийся в каталоге с необычным именем "c:foo"). В последнем примере мы использовали operator/ для объединения компонентов пути. Для этой цели fs::path поддерживает operator/ и operator/=, и их поведение почти в точности соответствует нашим ожиданиям – они объединяют фрагменты пути, добавляя символ слеша между ними. Если символ слеша вам не нравится, объединяйте компоненты с помощью operator+=. К сожалению, в стандартной библиотеке C++17 отсутствует operator+ для путей, но его легко добавить в виде свободной функции, как показано ниже: static fs::path operator+(fs::path a, const fs::path& b) { a += b; return a; }

Пути также поддерживают конкатенацию с и без слешей в виде функцийчленов с именами path.concat("foo") (без слешей) и path.append("foo") (со слешами), способными вызвать путаницу. Будьте внимательны, не перепутайте! Лично я советую никогда не использовать эти функции-члены; всегда применяйте операторы (включая собственную реализацию operator+, представленную выше). Последний источник возможной путаницы в fs::path – методы begin и end действуют точно так же, как одноименные методы типа std::string. Но, в отличие от std::string, итерации выполняются не по символам, а по именам! Взгляните на следующий пример: fs::path p = "/foo/bar/baz.txt"; std::vector v(p.begin(), p.end()); assert((v == std::vector{ "/", "foo", "bar", "baz.txt" }));

342    Глава 12. Файловая система У вас едва ли будут веские причины организовать обход элементов абсолютного пути fs::path. Однако итерации по p.relative_path().parent_path() – где каждый элемент гарантированно будет именем каталога – могут пригодиться в некоторых необычных ситуациях.

Получение информации о файлах с directory_entry Внимание! directory_entry – самая малопонятная часть библиотеки в C++17. Все, о чем рассказывается далее, не реализовано ни в Boost, ни в .

Извлечение метаданных о файле из его индексного узла выполняется посредством объекта типа fs::directory_entry. Если вы знакомы с инструментами POSIX для получения метаданных, вообразите, что fs::directory_entry содержит члены с типами fs::path и std::optional. Вызов entry.refresh() фактически действует точно так же, как POSIX-функция stat(); а методы доступа, такие как entry.file_size(), неявно вызывают stat(), только если член optional пока не получил значения. Простое создание экземпляра fs::directory_entry не посылает запрос в файловую систему; библиотека ждет, когда вы зададите конкретный вопрос. Конкретный вопрос, такой как entry.file_size(), может заставить библиотеку послать запрос в файловую систему или (если член optional уже получил значение) использовать кешированное значение, оставшееся от предыдущего запроса. fs::path p = "/tmp/foo/bar.txt"; fs::directory_entry entry(p); // Здесь еще нет обращения к файловой системе. while (!entry.exists()) { std::cout