КулЛиб - Классная библиотека! Скачать книги бесплатно 

Оптимизация программ на С++. Проверенные методы для повышения производительности [Курт Гантерот] (pdf) читать онлайн

Книга в формате pdf! Изображения и текст могут не отображаться!


 [Настройки текста]  [Cбросить фильтры]
Оптимизация
программ на с++

Optimized С++

Kurt Guntheroth

Beijing Boston Farnham Sebastopol Tokyo








o·REILLY''

Оптимизация
программ на С++
ПРОВЕРЕННЫЕ МЕТОДЫ
ДЛЯ ПОВЫШЕНИЯ ПРОИЗВОДИТЕЛЬНОСТИ

Курт Гантерот

Москва Санкт-Петербург· Киев


201 7

ББК 32.973.26-018.2.75

Г19

УДК 681.3.о?
Компьютерное издательство "Диалектика"

Зав. редакцией С.Н.

Тригуб

Перевод с анrлийскоrо и редакция канд. техн. наук И.В.

Красикова

По общим вопросам обр ащ айтесь в издательс тво "Диалектика" по адресу:
info@dialektika.com, http://www.dialektika.com

Гl9

Гантерот, Курт.

Оптимизация программ на С++. Проверенные методы для повышения производительности.:

Пер. с анrл. -СпБ.:

ООО "Альфа-книrа� 2017.

-400 с.: ил. - Парал. тит. анrл.

ISBN 978-5-9908910-6-7 (рус.)

ББК 32.973.26-018.2.75

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

ни было форме и какими бы то ни было средствами, будь то электронные или механические, включая
фотокопирование и запись на магнитный носитель, если на это нет письменного разрешения издательства
O'Reilly & Associates.

Authorized Russian translation of the English edition ofOptimized С++©

2016 Kurt

Guntheroth. (ISBN

978-1-491-92206-4).

This translation is puЬlished and sold Ьу permission of O'Reilly Media, lnc., which owns or controls all rights to puЬlish and
sell the same.
All rights reserved. No part of this work may Ье reproduced or transmitted in any form or Ьу any means, electronic or me­
chanical, including photocopying,
recording, or Ьу any information storage or retrieval system, without the prior written permission of the copyright owner and
the PuЬlisher.

Научно-популярное издание
Курт Гаитерот

Оптимизация проrрамм на С++
Проверенные методы для повышения производительности
Литературный редактор
Верстка
Художест ве н ный редактор
Корректор

Л.Н. Красножон
Л.В. Чернокозинская
Е.П.Дынник
Л.А. Гордиенко

Подписано в печать 06.03.2017. Формат 70х100/16.
Гарнитура Times.

Усл. печ. л. 32,25. Уч.-изд. л. 24.41.
Тираж 300 экз. Заказ № 1488.

Отпечатано в АО «Первая Образцовая типография»
Филиал •Чеховский Печатный Двор»

142300, Московская область, г. Чехов, ул. Полиграфистов, д.1

Сайт: www.chpd.ru, E-mail: sales@chpd.ru, тел.

8(499)270-73-59

ООО "Альфа-книга·: 195027, Санкт-Петербург, Магнитогорская ул., д. 30

2 0 17 ,

ISBN 978-5-9908910-6-7 {рус.)

©

ISBN 978-1-491-92206-4 (анrл.)

© 2016, Kurt Guпtheroth

Компьютерное изд-во "Диалектика",

перевод, оформление, макетирование

Оrnавnение
Предисnовие

17

Гnава 1. Обзор оптимизации

23

Гnава 2. Оптимизация, вnияющая на поведение компьютера

37

Гnава 3. Измерение производитеnьности

49

Гnава 4. Оптимизация испоnьзования строк

91

Гnава S. Оптимизация аnrоритмов

113

Гnава 6. Оптимизация переменных в динамической памяти

131

Гnава 7. Оптимизация инструкций

173

Гnава 8. Испоnьзование лучших библиотек

213

Гnава 9. Оптимизация сортировки и поиска

229

Гnава 1 О. Оптимизация структур данных

259

Глава 11. Оптимизация ввода-вывода

293

Глава 12. Оптимизация параллельности

307

Гnава 13. Оптимизация управления памятью

353

Предметный указатель

387

Содержание
Предис11овие

Извинения за код
Использование примеров кода
Соглашения, использованные в книге
Об авторе
Об изображении на обложке
Ждем ваших отзывов!

17
19
19
20
20
20
21

Г11ава 1. Обзор оптимизации

23

Оптимизация - часть разработки программного обеспечения
Эффективность оптимизации
Оптимизируйте!
Наносекунда туда, наносекунда сюда
Стратегии оптимизации кода на С++
Используйте компилятор получше; используйте компилятор лучше
Использование лучших алгоритмов
Использование лучших библиотек
Уменьшение количества выделений памяти и копирований
Устранение вычислений
Использование лучших структур данных
Увеличение параллельности
Оптимизация управления памятью
Резюме

24
25
25
28
28
29
30
32
33
33
34
34
35
35

Г11ава 2. Оптимизация, в11ияющая на поведение компьютера

37

Ложь о компьютерах, в которую верит С++
Правда о компьютерах
Медленная память
Недоступность байтов
Одни обращения к памяти медленнее других
Остроконечные и тупоконечные слова
Количество памяти ограничено
Медленное выполнение команд
Трудное принятие решений
Множественные потоки выполнения
Вызовы операционной системы являются дорогостоящими
С++ тоже лжет
Не все инструкции одинаково дорогие
Инструкции выполняются не по порядку
Резюме

38
39
40
41
41
42
43
43
44
45
46
46
47
47
48

Глава 3. Измерение производительности

49

Оптимизирующее мышление
Производительность должна быть измерена
Оптимизация - большая игра
Правило 90/10
Закон Амдала
Проведение экспериментов
Ведение лабораторного журнала
Измерение базовой производительности и постановка целей
Улучшить можно только измеряемое
Профилирование выполнения программы
Длительно работающий код
"Полузнайство" об измерении времени
Измерение времени с помощью компьютеров
Преодоление проблем измерений
Создание класса-секундомера
Хронометраж функции в тесте
Оценка стоимости кода для поиска узких мест
Оценка стоимости отдельных инструкций С++
Оценка стоимости циклов
Другие пути поиска узких мест
Резюме

50
50
51
51
53
54
56

57
60
60

63
64

69
77

81
85
86
87
88
89
90

91

Гnава 4. Оптимизация испоnьзования строк

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

91
92
92

93
94

96
96

97
98
99
100
102
102
102
104
105
109

Содержа н и е

7

Устранение преобразования строк
Преобразование строк в стиле С в std : : string
Преобразование между кодировками
Резюме
Гnава 5. Оптимизация аnrоритмов

110
111
111

112
113

Временная стоимость алгоритмов
Временная стоимость в наилучшем, среднем и наихудшем случаях
Амортизированная временная стоимость
Прочие стоимости
Оптимизации сортировки и поиска
Эффективные алгоритмы поиска
Временная стоимость алгоритмов поиска
Все поиски равноценны при малых п
Эффективные алгоритмы сортировки
Временная стоимость алгоритмов сортировки
Замена сортировки с плохой производительностью в наихудшем случае
Использование информации о входных данных
Шаблоны оптимизации
Предвычисления
Отложенные вычисления
Пакетирование
Кеширование
Специализация
Гр уппировка
Подсказки
Оптимизация ожидаемого пути
Хеширование
Двойная проверка
Резюме

128
1 28
1 28
129
1 29

Гnава 6. Оптимизация переменных в динамической памяти

131

Переменные С++
Длительность хранения переменной
Владение переменными
Объекты-значения и объекты-сущности
API динамических переменных С++
Автоматизация владения интеллектуальными указателями
Динамические переменные имеют стоимость времени выполнения
Уменьшение использования динамических переменных
Статическое создание экземпляров класса
Использование статических структур данных
Использование std : : make shared вместо new

1 32
1 32
135
1 36
1 38
1 40

8

Содержа ни е

1 15

117
1 18
118
118
1 19
1 19
120

121
121
122

1 23
1 23
1 24
125

1 26
1 26
1 27
127

143

1 44
145
1 46
150

Не следует разделять владение без необходимости
Использование "главного указателя" для владения динамическими переменными
Уменьшение количества перераспределений динамических переменных
Предварительное выделение памяти для динамических переменных
для предотвращения перераспределений
Создание динамических переменных вне циклов
Устранение излишнего копирования
Устранение нежелательного копирования в определении класса
Устранение копирования при вызове функции
Устранение копирования при возврате из функции
Библиотеки без копирования
Реализация идиомы "копирования при записи"
Срезы
Реализация семантики перемещения
Нест а ндар т ная семантика копирования: болезненный хак
std: : swap () : семантика перемещения для бедных
Разделяемое владение сущностями
Переме щающая часть семантики перемещения
Изменения кода для использования семантики перемещения
Тонкости семантики перемещения
Плоские структуры данных
Резюме

1 50
1 52
1 52
1 52
1 53
1 54
1 55
1 56
1 58
1 60
161
1 62
1 63
lб3
1 64
1 65
1 66
1 67
1 68
171
1 72
173

Гnава 7. Оптимизация инструкций

Удаление кода из циклов
Кеширование конечного значения цикла
Применение более эффективных инструкций циклов
Изменение направления цикла
Устранение инвариантного кода из циклов
Удаление ненужных вызовов функций из циклов
Удаление скрытых вызовов функций из циклов
Удаление дорогих медленно меняющихся вызовов из циклов
Перемещение циклов в функции для снижения накладных
расходов при вызовах
Выполняйте некоторые действия пореже
И все остальное
Удаление кода из функций
Стоимость вызовов функций
Объявление коротких функций встраиваемыми
Определение фун к ций до их первого использования
Устранение неиспользуемого полиморфизма
Удаление неиспользуемых интерфейсов
Выбор реализации во время компиляции с помощью шаблонов
Исключение применения идиомы PIMPL
Содержа н и е

1 74
1 75
1 75
1 76
1 77
1 77
1 80
1 82
1 83
1 84
1 86
1 86
1 86
1 90
191
191
1 92
1 96
1 96
9

Устранение вызовов кода в DLL
Используйте статические функции-члены вместо функций-членов экземпляров
Перенесение виртуального деструктора в базовый класс
Оптимизация выражений
Упрощение выражений
Группирование констант
Используйте менее дорогостоящие операторы
Использование целочисленной арифметики вместо арифметики
с плавающей точкой
douЫe может быть быстрее, чем float
Замена итеративных вычислений аналитическими выражениями
Идиомы оптимизации потока управления
Применение switch вместо if-elseif-else
Применение виртуальных функций вместо switch или i f
Используйте обработку исключений без стоимости
Резюме

203
205
206
207
208
208
209
21 1

Гnава 8. Испоnьзование 11учwих бибnиотек

213

Оптимизация использования стандартной библиотеки
Философия стандартной библиотеки С++
Вопросы применения стандартной библиотеки С++
Оптимизация существующих библиотек
Изменения должны быть небольшими
Добавление функций, а не изменение функциональности
Проектирование оптимизированных библиотек
Кодировать н а скорую руку - обречь себя н а долгую муку
При разработке библиотек скупость является добродетелью
Принятие решений о выделении памяти вне библиотеки
Если сомневаетесь, выбирайте скорость
Оптимизация функций проще оптимизации каркасов
Плоские иерархии наследования
Упрощение цепочки вызовов
Упрощение проектирования слоев
Избегай те динамического поиска
Остерегайтесь "функций Бога"
Резюме

213
214
215
217
218
219
219
219
22 1
22 1
222
222
223
223
223
225
226
227

Гnава 9. Оптимизация сортировки и поиска

Таблицы "ключ/значение" с использованием std : :map и std: : string
Инструментарий для повышения производительности поиска
Выполнение базовых измерений
Идентификация оптимизируемой деятельности
Разделение оптимизируемой деятельности

10

Содер ж а н ие

1 98
1 99
1 99
200
20 1
202
203

229

230
23 1
232
232
233

Изменение или замена алгоритмов и структур данных
Использование процесса оптимизации для пользовательских абстракций
Оптимизация поиска с использованием std::m ap
Применение символьных массивов фиксированного размера
в качестве ключей std::map
Использование строк в стиле С в качестве ключей std::map
Использование std:: set, когда ключ является значением
Оптимизация поиска с использованием заголовочного файла
Таблица "ключ/значение" для поиска в последовательных контейнерах
std::find ( ):очевидное имя, стоимость - О(п )
std::Ьinary_search () : не возвращает значения
Бинарный поиск с использованием std::equal_range ()
Бинарный поиск с использованием s td : : 1 ower_bound ( )
Самостоятельное кодирование бинарного поиска
Самостоятельное кодирование бинарного поиска с использованием
strcmp ()
Оптимизация поиска в хешированных таблицах "ключ/значение"
Хеширование с использованием std: : unordered_map
Хеширование с фиксированными символьными массивами в качестве ключей
Хеширование с ключами в виде строк с завершающими нулевыми символами
Хеширование с пользовательской хеш-таблицей
Цена абстракций Степанова
Оптимизация сортировки с использованием стандартной библиотеки С++
Резюме

234
236
237
237
238
24 1
24 1
243
244
245
245
246
247
248
248
249
250
25 1
253
254
255
257

Гnава 1 О. Оптимизация структур данных

259

Знакомство с контейнерами стандартной библиотеки
Последовательные контейнеры
Ассоциативные контейнеры
Эксперименты с контейнерами стандартной библиотеки
std::vector и std:: string
Следствия перераспределения для производительности
Вставка и удаление в std: : vector
Итерирование std::vector
Сортировка std::vector
Пoиcк в std : : vector
std::deque
Вставка и удаление в std: :deque
Итерирование std::deque
Сортировка std::deque
Поиск в std::deque
std::list
Вставка и удаление в s td : : 1 i st
Итерирование std:: list

259
260
260
26 1
266
267
268
270
271
27 1
27 1
273
275
275
275
275
278
278
Содержание

11

Сортировка std:: list
Поиск в std : : list
std : : forward list
Вставка и удаление в std : : forward_list
Итерирование std:: forward_list
Сортировка std : : forward_list
Поиск в std : : forward list
std : : map and std : : multimap
Вставка и удаление в std: : map
Итерирование std : : map
Сортировка std : : map
Поиск в std : : map
std : : set и std : :multiset
std : : unordered_map и std : : unordered_multimap
Вставка и удаление в std : : unordered_map
Итерирование std : : unordered_map
Поиск в std : : unordered_map
Другие структуры данных
Резюме

278
279
279
280
280
28 1
28 1
28 1
282
284
285
285
285
286
289
290
290
290
292

Гnава 1 1 . Оптимизация ввода-вывода

293

Рецепты для чтения файлов
Создание экономной сигнатуры функции
Сокращение цепочек вызовов
Снижение количества перераспределений
Использование большего входного буфера
Использование построчного чтения
Еще одно сокращение цепочек вызовов
Бесполезные вещи
Запись файлов
Чтение из std:: cin и запись в std: : cout
Резюме

293
295
297
297
299
300
302
303
303
304
305

Гnава 1 2. Оптимизация параnnеnьности

307

Введение в параллельные вычисления
Экскурсия по зоопарку параллелизма
Чередующееся выполнение
Последовательная согласованность
Гонки
Синхронизация
Атомарность
Возможности параллельности в С++
Потоки
Обещания и фьючерсы

308
309
313
314
315
316
317
319
320
32 1

12

Соде р ж ание

323
325
326
327
330
333
334
335
337
338
339
339
342
343
344
344
346
346
347
348
349
349
349
350
351

Асинхронные задания
Мьютексы
Блокировки
Условные переменные
Атомарные операции над общими переменными
Будущие возможности параллелизма С++
Оптимизация многопоточных программ С++
Предпочитайте std: : async, а не std: : thread
Создавайте потоков столько же, сколько имеется ядер
Реализуйте очередь заданий и пул потоков
Выполняйте ввод-вывод в отдельном потоке
Программа без синхронизации
Удаление кода запуска и завершения
Более эффективная синхронизация
Уменьшайте критические разделы
Ограничивайте количество параллельных потоков
Избегайте громового стада
Избегайте очереди на блокировку
Уменьшайте конкуренцию
Не пользуйтесь активным ожиданием в одноядерных системах
Не ждите вечно
Собственные мьютексы могут быть неэффективными
Ограничивайте длину очереди вывода производителя
Библиотеки для параллельных вычислений
Резюме

353

Гnава 1 3 . Оптимизация управnения памятью

API управления памятью С++
Жизненный цикл динамических переменных
Функции для выделения и освобождения памяти
Построение динамических переменных с помощью выражений new
Уничтожение динамических переменных с помощью выражения delete
Явный вызов деструктора уничтожает динамическую переменную
Высокопроизводительные диспетчеры памяти
Диспетчеры памяти для конкретных классов
Диспетчер памяти для блоков фиксированного размера
Арена блоков
Добавление operator new ( ) для конкретного класса
Производительность диспетчера памяти для блоков
фиксированного размера
Вариации диспетчера памяти для блоков фиксированного размера
Небезопасные с точки зрения параллельности диспетчеры
более эффективны

Содержание

3 54
354
355
358
36 1
362
363
365
366
369
37 1
372
372
373

13

Пользовательские аллокаторы стандартной библиотеки
Минимальный аллокатор в С++ 1 1
Дополнительные определения для аллокатора С++98
Аллокатор блоков фиксированного размера
Аллокатор блоков фиксированного размера для строк
Резюме
Предметный указатеnь

14

Сод ержание

374

376
378
382
384
385
387

Все благодарят супруг за помощь в работе над книгой. Я знаю, что это
банально. Однако именно благодаря моей жене Рене Остлер (Rепее Ostler)
появилась эта книга. Рене месяцами позволяла мне работать над ней,
обеспечивая мне время и мес то для работ ы и даже беседуя со мной об
оптимизации программ на С++
несмотря на то, что это не ее тема,
просто чтобы меня поддержать. Этот проект стал важн ым для нее,
потому что он был важен для меня. Я не могу про с ить у нее большего.
-

Предисnовие

Приветствую вас! Меня зовут Курт, и я кодо голик1•
Я пишу программы более 35 лет. Я никогда не работал в Microsoft, Google,
Facebook, Apple или в каких-то иных знаменитых фирмах. Но, не считая несколь­
ких коротких перерывов, я ежедневно в течение всего этого времени пишу код.
Последние 20 лет я пишу почти исключительно на С++ и общаюсь с очень ярки ­
ми разработчиками на этом языке про граммирования. Все это позволяет мне на­
писать книгу об оптимизации кода на С++. За свою жизнь я написал мн ого прозы,
включая спецификации, руководства, комментарии, заметки и сообщения в блоге
(http: / / oldhandsЬlog. Ьlogspot. сот) . Поэтому меня всегда удивляет тот факт,
что не больше половины ярких, компетентных про граммистов, с которыми я рабо­
тал, в состоянии соединить в одно целое пару предложений на обычном человечес­
ком языке.
Одна из моих любимых цитат - из письма Исаака Ньютона, в котором он пишет:
"Если я и видел дальше других, то только потому, что стоял на плечах гигантов". Я не
только стоял на плечах гигантов, но и читал их книги: эле гантные небольшие кни­
ги, такие как книга Брайана Кернигана (Brian Kernighan) и Денниса Ритчи (Dennis
Ritchie) Яз ы к программирования С; интеллектуальные и просвещающие книги напо­
добие книг серии Скотта Мейерса (Scott Meyers) Эффективный С++; сложные, рас­
ширяющие сознание книги, такие как Со временное проектирование на С++ Андрея
Александреску (Andrei Alexandгescu); точные и выверенные книги, как Справочное
руководство по языку программирования С+ + Бьярне Страуструпа (Bjarne Stroustrup)
и Маргарет Эллис (Margaret Ellis). Читая эти книги, я даже не думал, что когда-ни­
будь сам напишу книгу. Но в один прекрасный день я внезапно понял, что должен
это сделать.
Но зачем писать книгу о настройке производительности программ на С++?
В начале XXI века С++ оказался под постоянными нападками. Поклонники С
указывали на программы на С++, производительность которых уступала предпо­
ложительно эквивалентному коду, написанному на С. Известные корпорации с
большими бюджетами расхваливали собственные патентованные объектно-ори ­
ентированные языки, утверждая, что С++ слишком трудно использовать и что их
1

Аллюзия на собрания rpynп психологической поддержки алкоголиков.

- Примеч. пер.

языки - настоящие инструменты будущего. Университеты выбирали для препода­
вания Java, потому что этот язык имел массу бесплатных инструментальных средств.
В результате всего этого шума крупные компании сделали большие ставки на коди­
рование веб-сайтов и операционных систем на Java, С# или РНР. Казалось, что насту­
пил закат С++. Это было тяжелое время для тех, кто считал, что С++ был и остается
мощным, полезным инструментом.
Затем произошли забавные вещи. Производительность ядер процессоров пере­
стала расти, в отличие от постоянного роста рабочих нагрузок. И эти же компании
принялись нанимать программистов на С++ для решения проблем масштабирова­
ния. Стоимость переписывания кода "с нуля" на С++ оказалась меньше расходов на
электроэнергию в центрах данных. Внезапно С++ вновь стал популярен.
Среди языков программирования, широко использовавшихся в начале 2016 года,
по сути, только С++ предлагает разработчикам массу вариантов реа л иза ций , начи­
ная от автоматизированной поддержки для тонкого ручного управления кодом. С++
позволяет разработчикам получить полный контроль над производительностью про­
грамм, делающий возможной оптимизацию кода.
Есть не так уж много книг, посвященных оптимизации кода на С++. Одной из
них является педантичное исследование Балка (Bulka) и Мэйхью (Mayhew) Optiтizing
С++. Похоже, у этих авторов такой же опыт работы, как и у меня, и они открыли
массу тех же принципов оптимизации, что и я. Для читателей, которые заинтере­
сованы в другом взгляде на вопросы, поднимаемые в моей книге, их книга являет­
ся хорошим подспорьем. Кроме того, вопросы оптимизации часто освещает в своих
книгах Скотт Мейерс.
Материала по оптимизации программ достаточно, чтобы написать не одну, а де­
сяток книг. Я попытался выбрать тот материал, который часто встречался в моей
личной работе или который предлагает существенное повышение производитель­
ности. Многие читатели имеют собственный опыт борьбы за производительность,
и о ни могут задаться вопросом, почему я ничего не сказал о стратегиях, которые
творили чудеса в их программах. Увы, я могу ответить одно - так мало времени,
так много надо рассказать!2
Я жду ваши комментарии, замечания об ошибках и описания ваших стратегий
оптимизации по адресу antelope book@guntheroth. com.
Я люблю свое ремесло программиста. Мне нравится бесконечно "вылизывать"
_

каждый новый цикл или интерфейс. Это смесь науки и искусства, которую может
оценить только столь же увлеченный программист. Элегантно закодированная фун ­
кция полна своей внутренней красоты, а использование идиом программирования
п ол но мудрости. К сожалению, на каждую поэму программного обеспечения напо­
добие стандартной библиотеки шаблонов Степанова приходится по 10 тысяч одно­
образно-безвкусных томов неодухотворенного кода.
Главное назначение этой книги - помочь каждому читателю не только оценить
красоту хорошо настроенного и оптимизированного программного обеспечения, но
и самостоятельно его создать!
2

18

Парафраз припева песни из сериала "Так мало времени" ("So little time").

П редисnовие

-

Примеч. пер.

И з винени я за

код

Хотя я пишу и оптимизирую код на С++ более 20 лет, большая часть кода, содер­
жащегося в этой книге, была разработана специально для нее. Как весь новый код,
он, безусловно, содержит дефекты. Я заранее приношу мои извинения за него.
Я в течение многих лет программировал для Windows, Linux и различных встро­
енных систем. Код, представленный в этой книге, разработан для Windows. Код и
сама книга, несомненно, демонстрируют склонность к Windows. Тем не менее ме­
тоды оптимизации кода С++, которые иллюстрируются с помощью Visual Studio в
Windows, применимы и в Linux, Мае OS Х или любой другой среде С++. Однако точ­
ные цифры, связанные с различными методами оптимизации, зависят от компиля­
тора, реализации стандартной библиотеки и процессора, на котором выполняется
код. Оптимизация - наука экспериментальная. Принятие советов по оптимизации
на веру чревато различными неприятными сюрпризами.
Я знаю, что совместимость кода с различными компиляторами, а также с опера­
ционной системой Unix и встраиваемыми системами может оказаться сложной зада­
чей, так что я заранее прошу прощения, если приведенный в книге код не компили­
руется в вашей любимой системе. Поскольку эта книга не о переносимости кода, я
позволил себе жертвовать переносимостью в пользу простоты и понятности кода.
Используемый в книге стиль форматирования кода - не мой любимый, но в свя­
зи с тем, что мне надо было размещать на странице как можно больше строк кода, я
вынужден был отказаться от своих предпочтений.

И споnьзова ние п римеров код а
Сопутствующие материалы (примеры кода, решения и т.п.) доступны по адресу
www.guntheroth.com.
Эта книга призвана помочь вам выполнить вашу работу. В общем случае вы мо­
жете использовать примеры кода из нее в своих программах и документации. Вам
не нужно связываться с издательством для получения разрешения, если только вы
не воспроизводите значительную часть кода. Например, для написания программы,
в которой используется несколько фрагментов кода из этой книги, разрешение по­
лучать не нужно. Однако для продажи или распространения на CD-ROM примеров
из книг издательства O'Reilly необходимо отдельное разрешение. Ссылаться на книгу
или пример кода в ответах на вопросы можно и без разрешения. Но для включения
значительного объема кода из этой книги в документацию вашего продукта следует
получить разрешение.
Мы не требуем точного указания источника при использовании примеров кода,
но были бы признательны за него. Обычно достаточно названия книги, имени авто­
ра, названия издательства и ISBN.
Если вы полагаете, что использование примеров кода выходит за рамки опи­
санных разрешений, не постесняйтесь связаться с нами по адресу permissions@
oreilly. сот.

П р едисnовие

19

Со rnа w ения , ис п оnьзова н н ы е в кн и rе
В книге использованы некоторые типографские соглашения.
Обычный текст
Используется для обычного текста, пунктов меню и названий клавиш (таких,
как и ).
Курсив
Используется для новых терминов, важных концепций и т.п.
Моноширинный шрифт

Используется для листингов, а также в тексте для элементов программного кода
(имен переменных, функций, инструкций и т.д.), URL, имен файлов и т.п.

Об авто р е
Курт Гантерот
программист более чем с 35-летним стажем, интенсивно рабо­
тающий с языком программирования С++ более 25 лет. Он разрабатывал програм­
мное обеспечение для Windows, Linux и различных встроенных устройств.
Все свободное время Курт проводит с женой и четырьмя сыновьями. Живет в
Сиэттле, штат Вашингтон.
-

Об изо б р ажен и и на о бnожке
На обложке книги изображена антилопа каама (Alcelaph us buselaphus саата),
обитающая на равнинах и в лесах Юго-Западной Африки. Это большая антилопа,
принадлежащая к семейству полорогих (Bovidae). Антилопы обоих полов обладают
витыми рогами, достигающими 60 сантиметров в длину. Животные также облада­
ют завидными обонянием и слухом. Бегая зигзагом, чтобы спастись от хищников,
они развивают скорость до 55 километров в час. Хотя львы, леопарды и гепарды
иногда охотятся на представителей этого вида, обычно они предпочитают других
жертв.
Многие из животных, изображенных на обложках книг издательства O'Reilly, на­
ходятся под угрозой исчезновения; все они имеют важное значение для мира. Что­
бы узнать больше о том, как вы можете им помочь, перейдите на сайт по адресу
animals.oreilly.com.

20

П редисnовие

Ждем ваwих отзывов!
Вы, читатель этой книги, и есть главный ее критик. Мы ценим ваше мнение и
хотим знать, что было сделано нами правильно, что можно было сделать лучше и что
еще вы хотели бы увидеть изданным нами. Нам интересны любые ваши замечания
в наш адрес.
Мы ждем ваших комментариев и надеемся на них. Вы можете прислать нам бу­
мажное или электронное письмо либо просто посетить наш веб-сайт и оставить свои
замечания там. Одним словом, любым удобным для вас способом дайте нам знать,
нравится ли вам эта книга, а также выскажите свое мнение о том, как сделать наши
книги более интересными для вас.
Отправляя письмо или сообщение, не забудьте указать название книги и ее авто­
ров, а также свой обратный адрес. Мы внимательно ознакомимся с вашим мнением
и обязательно учтем его при отборе и подготовке к изданию новых книг.
Наши электронные адреса:
E-mail: info@dialektika. сот
WWW:

http:/ / www. dialektika. сот

Наши почтовые адреса:
в России: 1 95027, Санкт-Петербург, Магнитогорская ул., д. 30, ящик 1 1 6
в Украине: 03 1 50 , Киев, а/я 1 52

П редися о вие

21

ГЛАВА 1

Обзор оптимизации

У всего мира в настоящее время колоссальный аппетит на вычисления. Где бы ни
выполнялся код, в часах, телефоне, на планшете, рабочей станции, в суперкомпью ­
тере или глобальной сети центров обработки данных, существует множество про­
грамм, которые должны выполняться все время. Поэтому недостаточно просто точно
преобразовать блестящую идею, возникшую в вашей голове, в строки кода. Недоста­
точно даже просто отлаживать код в поисках дефектов до тех пор, пока он не будет
постоянно правильно работать. Приложение может оказаться слишком медленным
для того типа оборудования, которое ваши клиенты могут себе позволить. Компания
может ограничить вас крошечным процессором для экономии электроэнергии. Вы
можете драться с конкурентами за пропускную способность или за количество кад­
ров в секунду. Словом, в жизни вс е гда есть место оптимизации.
Эта книга об оптимизации, конкретнее - об оптимизации программ С++, с осо­
бым акцентом на модели поведения кода С++. Одни методы из этой книги применимы
и к другим языкам программирования, но я не пытался объяснять предлагаемые ме­
тод ы в универсальной форме. Другие оптимизации, эффективные для кода С++, никак
не влияют на производительность (или просто невозможны) на других языках.
Эта книга - о получении правильного кода, который воплощает в себе лучшие
практики проектирования С++, и об его преобразовании в правильный код, который
по-прежнему воплощает в себе хороший дизайн С++, но при этом работает быстрее
и потребляет меньше ресурсов практически на любом компьютере. Многие возмож­
ности оптимизации доступны потому, что некоторые мимоходом использованные
функции С++ медленно работают и потребляют много ресурсов. Такой код, будучи
корректным, является необдуманным, учитывающим только небольшое количество
знаний о современных микропроцессорных устройствах или мало заботящимся о
стоимости различных конструкций С++. Другие виды оптимизации доступны бла­
годаря возможностям тонкого управления памятью и копирования, предлагаемым
языком программирования С++.
Эта книга - не о том, как кропотливо кодировать подпрограммы на языке ас­
семблера, подсчитывать такты процессора или выяснить, сколько команд последний
проц ессор Intel в состоянии выполнить одновременно. Есть разработчики, которые
годами работают с одной платформой (хорошим примером является ХЬох), так что у
них есть время и необходимость освоить эти темные искусства. Однако большинство
-

разработчиков пишут для телефонов, планшетов или персональных компьютеров,
которые содержат бесконечное множество микропроцессоров (некоторые из них еще
даже не разработаны). Разработчики программного обеспечения для встроенных ус­
тройств также сталкиваются с различными процессорами и архитектурами. Попыт­
ка изучить конкретный процессор может парализовать всю работу такого програм­
миста. Я не рекомендую этот путь. Оптимизация, зависящая от процессора, просто
не оказывается плодотворным решением для большинства приложений, которые по
определению должны работать на различных процессорах.
Эта книга не посвящена также изучению операционных систем - выяснению,
как именно некоторая операция выполняется в Windows, OS Х и Linux, а равно и
в каждой из встроенных систем. Книга посвящена тому, что можно сделать в С++,
включая стандартную библиотеку языка. Выход за рамки С++ для выполнения оп­
тимизации может усложнить просмотр или комментарии оптимизированного кода.
Не следует относиться к этому легкомысленно. Эта книга - об обучении тому, как
следует оптимизировать код. В этой области любой статичный каталог методов или
функций обречен, так как постоянно разрабатываются новые алгоритмы и становят­
ся доступными новые возможности языка. Вместо этого в настоящей книге содер­
жится несколько рабочих примеров постепенного улучшения кода, так что читатель
знакомится с постепенным процессом настройки производительности и развивает
свое мышление в этом направлении.
Эта книга - об оптимизации самого процесса кодирования. Памятуя о стоимости
выполнения, разработ ч ики могут писать код, который эффективен с самого начала.
С ростом опыта для написания быстрого кода обычно не требуется больше времени,
чем для написания более медленного кода.
Наконец это книга о чудесах производительности, которыми будут восхищены
ваши коллеги. Оптимизация - это то, что вы всегда можете делать как разработчик
и чем вы всегда можете гордиться.

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

24

Гn ава 1. Обэор оnтимиэации

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

Эффекти вн о ст ь оп т и мизации
Разработчикам трудно судить о воздействии отдельных решений на общую
производительность большой программы. Таким образом, практически все полные
программы содержат значительные возможности оптимизации. Даже код, создан­
ный опытными группами за длительное время, зачастую можно ускорить на 30100%. В случае более быстрого кодирования или менее опытной команды улучше­
ние производительности может составлять от 3 до 1 О раз - я лично сталкивался с
такими ситуациями. Увеличение скорости более чем в 1 О раз путем настройки кода
оказывается куда менее вероятным. Однако выбор лучшего алгоритма или более
подходящей структуры данных может обеспечить и такую разницу в производи­
тельности.

Оптимизируйте!
Многие учебники по оптимизации начинаются с предупреждения: не дела йте
этого! Не оптимизируйте, а если вам и нужно оптимизировать, то не делайте этого
до конца проекта и не делайте оптимизацию сверх необходимой. Например, знаме­
нитый ученый Дональд Кнут (Donald Knuth) сказал об оптимизации:
не
Мы
должны помнить о малой эффективност и, с кажем, около 97 про­
центов времени: преждевременная оптимизация является корнем всех зол.

Donald Knuth, Structиred Programming with go to Statements, АСМ
Computing Surveys 6(4), December 1974, р. 268. CiteSeerX: 10.1.1.103.6084
(http : / /bit . l y/ knuth-1974)
-

Эффекти вность оnтимиэа ци и

25

А вот что сказал Уильям А. Вульф (William А. Wulf):
Во имя эффективности (но не обязательно ее достижения) совершается
больше вычислительных грехов, чем п о любо й ин о й причине - включая
слепую глупость.
- "А Case Against the GOTO;' Proceedings of the 25th National АСМ Conference
(1972): 796

Совет не оптимизировать стал высшей мудростью, непререкаемой даже для мно­
гих опытных кодировщиков, которые рефлекторно содрогаются, когда речь заходит о
настройке производительности. Я думаю, что циничное выпячивание этой рекомен­
дации слишком часто используется для оправдания отсутствия привычки даже к ми­
нимальному анализу, результатом которого мог бы стать значительно более быстрый
код. Я также думаю, что некритическое принятие этой рекомендации ответственно
за огромное количество потраченного впустую процессорного времени, многие часы
пользовательског о разочарования и слишком большое время, затраченное на пере­
делку кода, который должен был быть более эффективным с самого начала.
Мой совет менее догматичен. Оптимизация - это нормально. Вы можете изучать
идиомы эффективного программирования и применять их постоянно, даже если не
знаете, для какого кода производительность является критичной. Эти идиомы и есть
"хороший С++': Если же кто-то спросит, почему вы не написали что-то "просто" и
неэффективно, можете ответить, что "написание моего кода занимает столько же
времени, сколько и написание медленного, расточительного кода. Так почему я дол­
жен преднамеренно писать неэффективный код?"
Что не нормально - так это отсутствие какого-либо прогресса в течение многих
дней, потому что вы не можете решить, какой алгоритм будет лучше, если не уве­
рены, что это имеет какое-то значение. Не нормально - потратить недели, кодируя
что-то на языке ассемблера, поскольку вы предп олагаете, что этот код может быть
критичным в плане производительности, а затем свести на нет все усилия, вызы­
вая этот код как функцию С++, в то время как компилятор может встроить его для
вас. Не нормально - требовать от команды писать половину программы на языке С
просто потому, что "все знают, что С быстрее': в то время как на самом деле неизвес­
тно ни что С действительно быстрее, ни что С++ не такой быстрый. Другими слова­
ми, следует по-прежнему применять все лучшие практики разработки программного
обеспечения. Оптимизация - не повод для нарушения правил.
Не нормально - тратить кучу времени на оптимизацию, когда вы не знаете, где
именно у вас есть проблемы с производительностью. В главе 3, "Измерение произво­
дительности': вводится правило 90/ 1 0, гласящее, что только около 1 0% кода програм­
мы критично в смысле производительности. Таким образом, не является необходи­
мым или полезным вмешательство в каждую строку с целью улучшения произво­
дительности программы. Поскольку лишь 1 0% программы оказывает значительное
влияние на производительность, ваши шансы выбора случайным образом хорошей
отправной точки для повышения производительности оказываются слишком малы.
В главе 3, " Измерение производительности': описаны инструменты, помогающие оп­
ределить, где находятся действительно важные точки кода.
26

Гnава 1. Обзор оптимиза ции

Когда я учился в колледже, преподаватель предупреждал, что оптимальные алго­
ритмы могут иметь более высокую начальную стоимость, чем более простые. Таким
образом, их следует использовать только для очень больших наборов данных. Хотя
это, вполне возможно, верно для ряда сложных алгоритмов, мой опыт показывает,
что оптимальные алгоритмы для задач простого поиска и сортировки требуют не
так уж много времени для написания и дают улучшение производительности даже
на небольших наборах д анных .
Мне также рекомендовали разрабатывать программы, используя алгоритмы, ко­
торые проще всего кодировать, а затем при необходимости возвращаться и оптими­
зировать, если программа работает слишком медленно. Хотя это, несомненно, хо­
ро ший совет - постоя нно добиваться улучшений, но после того, как вы несколько
раз написали оптимальный поиск или сортировку, эта работа становится ничуть не
сложнее написания более медленного алгоритма. Вы можете с самого начала писать
и отлаживать только один, оптимальный алгоритм.
Пожа лу й , самый страшный в ра г оптимальности - общепризнанная мудрость.
Например, "все знают': что оптимальный алгоритм сортировки имеет время работы
О(п -log п), где п
размер набора данных (в главе 5, "Оптимизация алгоритмов':
вы познакомитесь с О-записями и сложностью алгоритмов). Это ценное знание, ко­
торое удержит программиста от веры в то, что написанная им сортировка вставка­
ми со временем работы О(п2) оптимальна . . . но далеко не такое ценное, если заодно
удержит программиста от поиска в литературе информации о том, что есть более
б ыс т рая поразрядная сортировка со временем работы О(п log,n) (где r - основание
системы счисления или количество корзин сортировки), чтосортировка распределе­
нием (flashsort) б ыс тр ее О(п) для случайно распределенных данных или что быстрая
сортировка, несмотря на превосходство в среднем над другими методами сортиров­
ки, в наихудшем случае имеет время работы О(п2). В свое время Аристотель утверж­
дал, что у женщин з убо в меньше, чем у мужчин (Тhе Нistory of Animals, Book 11, part 1
(http://Ьit.ly/aristotle-animals)), и прошло около 1 500 лет, прежде чем кто-то
оказался достаточно любознательным, чтобы заглянуть в несколько ртов и пересчи­
тать зубы. Противоядием для общепризнанной мудрости является научный метод в
форме эксперимента. В главе 3, " Измерение производительности': рассматриваются
инструменты для измерения производительности программного обеспечения и эк­
сперименты для проверки оптимизаций.
В мире разработки программного обеспечения существуют также общепризнан­
ные мудрости, не имеющие отношения к оптимизации. Так, утверждают, что даже
если ваш код сегодня работает медленно, то, поскольку каждый год появляются все
более и более быстрые процессоры, решение п робл ем с производительностью при­
дет само - через некоторое время. Как и большинство общепризнанных мудростей,
этот самородок мысли никогда не соответствовал действительности. Это еще могло
показаться верным в 1 980- и 1 990-е годы, когда доминировали настольные компью­
теры и автономные приложения, а скорость одноядерных процессоров вырастала в
два раза каждые 18 месяцев. Но сейчас, когда многоядерные процессоры становятся
все более мощными в совокупности, производительность отдельных ядер улучша­
ется очень не намного, а то и вовсе снижается. Сегодняшние программы должны
-

·

О nт11м11з11 руйте l

27

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

На носекунд а туда, н а носекунда с юд а
Миллиард туда, миллиард сюда - так вскоре мы буд ем говорить о ре­
альных деньгах.
- Часто ложно приписывается сенатору Эв еретту Дир ксону (Everett Dirksoп)
(1898-1969), который утверждал,
высказывал похожие мысли

что н икогда не говорил этой

фразы, хотя

и

Настольные компьютеры удивительно быстрые. Они могут выполнять новую ко­
манду каждую наносекунду (а то и быстрее). Каждые 1 0-9 секунд! Очень соблазни­
тельно считать, что оптимизация не имеет значения для таких быстрых компьютеров.
Проблема такого образа мышления заключается в том, что чем быстрее процес­
сор, тем быстрее накапливаются ненужные команды. Если не нужны 50% команд,
выполняемых программой, то такая программа может ускориться в два раза путем
устранения ненужных команд, независимо от того, как быстро они выполняются.
Ваши коллеги, которые говорят, что "эффективность не имеет значения", могут
также подразумевать, что она не имеет значения для некоторых приложений, кото­
рые отвечают на запросы человека и работают на настольных компьютерах, - мол,
время реакции и так очень мало. Но эффективность очень много значит для малых
встраиваемых и мобильных процессоров с ограничениями памяти, питания или ско­
рости. Это также важный вопрос для серверных программ, работающих на больших
машинах. Эффективность имеет важное значение для любого приложения, которое
должно работать в условиях ограниченных ресурсов ( памяти, мощности, скорости
процессора). Вопросы эффективности важны и в случаях достаточно большой рабо­
чей нагрузки, распределенной между несколькими компьютерами.
За 50 лет производительность компьютеров выросла на шесть порядков. И тем не
менее мы все равно говорим об оптимизации. И скорее всего, вопросы оптимизации
останутся актуальными и в будущем.

Ст р ате r и и оптимиза ц ии кода н а С++
Соберем обычных подозреваемых.
- Капитан Луи Рено (Клод Рейне), к/ф Касабланка, 1942

Сочетание возможностей С++ предоставляет бесконечное количество вариантов
реализации, начиная с полной автоматизации и выразительности, с одной стороны,
28

Гnава 1 . Обэо р оптимиэа ции

и закачивая полным тонким контролем производительности - с другой. Именно эта
возможность выбора позволяет настроить программы на С++ для удовлетворения
требованиям производительности.
С++ имеет своих "обычных подозреваемых" в смысле горящих мест для опти­
мизации, включая вызовы функций, выделения памяти и циклы. Ниже приведено
несколько способов повышения производительности программ С++, который одно­
временно является и планом данной книги. Все советы поразительно просты. Все
они были опубликованы раньше. Но, как всегда, дьявол прячется в подробностях.
Примеры и эвристики из этой книги помогут вам лучше распознавать возможности
оптимизации, когда вы их увидите.
Исnо11ьзуйте комnи11ятор no11yчwe; исnо11ьзуйте комnи11ятор 11учw е
Компиляторы С++ - сложные компьютерные программы. Каждый компилятор
принимает различные решения о том, какой машинный код должен быть сгенериро­
ван для тех или иных инструкций С++. Компиляторы видят различные возможности
для оптимизации и производят различные выполнимые файлы из одного и того же
исходного кода. Если вы пытаетесь выжать всю возможную производительность из
своего кода, возможно, вам стоит попробовать несколько компиляторов, чтобы вы­
яснить, какой из них даст наиболее быстрый выполнимый файл для вашего кода.
Наиболее важный совет при выборе компилятора С++ - используйте комп иля­
т ор, соотве тствую щ ий стандарту С+ + 1 1 . Этот стандарт реализует rvаluе-ссылки
и семантику перемещения, которые устраняют многие операции копирования, не­
избежные в предыдущих версиях С++. (Семантика перемещения рассматривается в
разделе "Реализация семантики перемещения" главы 6, "Оптимизация переменных в
динамической памяти").
Иногда использ овать лучший компиля тор на самом деле означает исп ользовать
компилятор лучше. Например, если приложение кажется вам медленно работающим,
взгляните на параметры компилятора, чтобы убедиться, что оптимизатор включен.
Это совершенно очевидно, но я не могу даже прикинуть , сколько раз я давал этот
совет программистам, которые впоследствии признавались, что после этого их код
стал работать гораздо быстрее. Во многих случаях больше ничего и не требуется.
Один лишь компилятор в состоянии сделать программу в несколько раз быстрее,
если, конечно, хорошенько его об этом попросить.
По умолчанию у большинства компиляторов какая-либо оптимизация не включе­
на. Без оптимизации время компиляции программы немного короче. Это имело зна­
чение в 1 990-х годах, но в настоя щее время компиляторы и компьютеры так быст­
ры, что дополнительная плата за оптимизацию оказывается весьма незначительной.
Кроме того, при выключенной оптимизации отладка также проще, потому что поток
выполнения точно соответствует исходному коду. Оптимизатор может вынести код
из цикла, удалить некоторые вызовы функций или полностью удалить некоторые
переменные. Многие компиляторы вообще не выдают отладочную информацию
при включенной оптимизации. Другие компиляторы оказываются более доброже­
лательными в этом от ношен ии, но поня ть, что делает про грамма, наблюдая ход ее
выполнения в отладчике, может оказаться непросто. Ряд компиляторов позволяет
С т ра теrии о п тимизации кода н а С++

29

включать и выключать отдельные методы оптимизации в отладочном варианте, что­
бы не слишком усложнять отладку. Простое включение встраивания функций мо­
жет иметь значительное влияние на программы С++, поскольку хороший стиль С++
включает написание множества небол ьших функций-членов для доступа к перемен­
ным-членам классов.
В документации, которая поставляется с компилятором С++, содержится об­
ширное описание доступных параметров и прагм оптимизации. Эта документация
подобна руководству пользователя, которое поставляется с новым автомобилем. Вы
можете сесть в свой новый автомобиль и повести его и без чтения руководства, но
есть масса информации, которая может помочь вам использовать этот большой и
сложный инструмент более эффективно.
Если вам достаточно повезло и вы разрабатываете приложения для архитектуры
х86 под управлением Windows или Linux, то у вас есть выбор из нескольких отлич­
ных активно развивающихся компиляторов. Microsoft выпустила три версии Visual
С++ за пять лет, предшествовавших написанию этой книги. GCC выходит со скоро­
стью более одной версии в год.
По состоянию на начало 20 1 6 года имелось общее согласие в том, что компилятор
Intel С++ создает наиболее короткий код для Linux и Windows, что компилятор GNU
С++
GCC
имеет более низкую производительность, но отличное соответствие
стандарту и что Microsoft Visual С++ находится между ними. Я хотел бы помочь вам
в принятии решения, приведя небольшую диаграмму, которая показывала бы, на
сколько процентов код, создаваемый Intel С++, быстрее кода, генерируемого GCC, но
это зависит от конкретного кода и конкретной версии. Intel С++ стоит более тысячи
долларов, но предлагает 30-дневную бесплатную пробную версию. Имеются бесплат­
ные экспресс-версии Visual С++. В Linux компилятор GCC всегда был и остается бес­
платным. Несложно провести небольшой эксперимент, исп ы тывая каждый компиля­
тор с вашим кодом, чтобы увидеть, дает ли какой-то из них особое преимущество в
производительности.
-

-

Исnо11ьзование 11 учwих а11rоритмов

Самый большой эффект дает выбор оптимального алгоритма. Эти усилия могут
повысить производительность программы самым драматическим образом. Они мо­
гут повлиять на код так же, как кардинальное обновление старенького компьютера.
К сожалению, так же, как и модернизация компьютера, большая часть оптимизаций
повышает производительность на константный множитель. Многие усилия по оп­
тимизации дают улучшение 30- 1 00%. Если вам повезет, производительность может
утроиться. Но качественный скачок в производительности маловероятен, если толь­
ко вы не находите более эффективный алгоритм.
Глупо героически оптимизировать плохой алгоритм. Изучение и использование
оптимальных алгоритмов для поиска и сортировки открывают широкий путь для
оптимального кода. Неэффективная процедура поиска или сортировки может со­
вершенно испортить программу. Настройка оптимизации без изменения алгорит­
ма ведет к сокращению времени выполнения на постоянный множитель. Переход к
более эффективному алгоритму может сократить время выполнения на множитель,
30

Глава 1 . Обзор о птимизации

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

Еще в дни 8-дюймовых дискет и процессоров с частотой 1 МГц некото­
рый разработчик делал программу для управления радиостанциями. Од­
ной из составных частей этой программы была подготовка отсортирован­
ного журнала песен, проигранных в течение дня. Проблема заключалась
в том, что сортировка происшедшего за 24 часа занимала около 27 часов,
что было явно неприемлемо. Чтобы заставить сортировку работать быс ­
трее, разр аб о т ч ик предпринял героические усилия. Он применил методы
обратной инженерии к компьютеру и использовал недокументированные
средства микрокода, но время выполнения оставалось все еще неприемле­
мым - 17 часов. Отчаявшись, он обратился за помощью на фи рм у - про­
изводитель компьютера (на которой я в то время работал) .
Я спросил разработчика, какой алгоритм сортировки о н использует. О н
ответил, что использует сортировку слиянием. Н о сортировка слиянием
относится к семейству оптимальных алгоритмов сортировки. Сколько же
записей он сортировал? "Несколько тысяч': - ответил он. Это было нево­
образимо. Система, которую он использовал, должна была сортировать его
данные менее чем за час. Я попросил разработчика подробно описать при­
мененный алгоритм сортировки. Я не могу вспомнить в деталях, как он
мне отвечал, но главное - то, что он называл сортировкой слиянием, было
на самом деле сортировкой вставками. Сортировка вставками - плохой
выбор, особенно если учесть, что ее время работы пропорционально квад ­
рату количества сортируемых записей (см. табл. 5.3). Он знал, что какой-то
алгоритм называется сортировкой слиянием и что он считается оптималь­
ным. Вот он и описал сортировку вставками, используя слова "сортировка
слиянием".
Когда я написал для этого клиента программу реальной сортировки слия­
нием, его данные стали сортироваться за 45 минут.
Возможность использовать оптимальные алгоритмы не связана с конкретным
размером данных. Есть много прекрасных книг, освещающих эту тему, и их можно
изучать всю жизнь. Жаль, что в этой книге я могу только слегка коснуться темы оп­
тимальных алгоритмов.
Раздел "Шаблоны оптимизации" главы 5 , "Оптимизация алгоритмов': охватывает
несколько важн ы х методик пов ышения производительности; к ним относятся пред ­
вычисления
(перемещение вычислений со времени выполнения на время компоновки,
Страте г и и о п тимизаци и кода на С++

31

компиляции или разработки), отложенные вычи сления ( п ер еме ще н ие вычисления в
точку, где не используемый в ряде случаев результат действительно необходим) и
кеширование (сохранение и повторное использование результатов дорогостоящих
вычислений). В главе 7, "Оптимизация инструкций", имеется много примеров при­
менения этих ме тодо в н а п р а кти ке .
Исnо11ьзование 11учwих биб11 иотек

Стандартные библиотеки шаблонов и времени выполнения, которые поставляют­
ся с компиляторами С++, должны быть простыми в обслуживании, обобщенными
и очень надежными. Сюрпризом для программистов может оказаться то, что эти
библиотеки не обязательно настроены для получения оптимальной скорости рабо­
ты. Еще более удивительным может оказаться то, что даже после 30 лет развития
С++ библиотеки, которые поставляются с коммерческими компиляторами С++, по­
прежнему содержат ошибки и мо гут не соответствовать текущим стандартам С++
или даже стандарту, действовавшему во время выпуска компилятора. Это осложняет
задачу измерений или рекомендации оптимизаций и делает непереносимым любой
опыт оптимизации, который, как им кажется, имеют разработчики. Глава 8, "Исполь­
зование лучших библиотек� посвящена именно этим вопросам.
Умение работать со стандартной библиотекой С++ является одним из важнейших
навыков разработчика. Эта книга содержит рекомендации по алгоритмам поиска
и сортировки (глава 9, "Оптимизация сортировки и поиска"), оптимальных идиом
использования контейнерных классов (глава 10, "Оптимизация структур данных"),
ввода-вывода (глава 1 1, "Оптимизация ввода- вывода"), параллелизма (глава 1 2 , " О п­
тимизация параллельности") и управления памятью ( глава 1 3, "Оптимизация управ­
ления п ам ятью " ) .
Для выполнения важных функций, таких как управление памятью (см. раздел
"Высокопроизводительные диспетчеры памяти" главы 13, "Оптимизация управления
памятью"), имеются открытые библиотеки, которые обеспечивают сложные реали­
зации, могущие быть более быстрыми и более функциональными, чем стандартные
библиотеки времени выполнения С++. Преимущество этих альтернативных библио­
тек заключается в том, что можно ле г ко добавить их в существующий проект и обес­
печить немедленное увеличение скорости.
Среди множества других есть много общедоступных библиотек проекта Boost
( h t t p : / /www . boo s t . o r g ) и Google Code ( h t t p s : / / code . go o g l e . com), которые пре­
доставляют библиотеки для ввода-вывода, управления окнами, обработки строк
(см. раздел "Применение более новой реализации строки" главы 4, "Оптимизация
использования строк") и параллелизма (см. раздел "Библиотеки для параллельных
вычислений" главы 1 2, "Оптимизация параллельности") и которые не являются заме­
ной стандартных библиотек, а просто предла г ают улучшенную производительность
и повышенную функциональность. Эти библиотеки обладают преимуществом - по­
вышенной скоростью - за счет ряда компромиссов проектирования, отличных от
используемых в стандартной библиотеке.
Наконец, можно разработать библиотеку специально для проекта, которая ослаб­
ляет некоторые из ограничений безопасности и надежности стандартной библиотеки
32

Гnава 1 . Обзор о nт11ммза цмм

в обмен на преимущество в скорости. Все эти темы рассматриваются в главе 8, "Ис­
пользование лучших библиотек".
Вызовы функций являются дорогостоящими по нескольким причинам (см. раздел
"Стоимость вызовов функций" главы 7, "Оптимизация инструкций") . API хорошей
библиотеки функций предоставляют функции, которые отражают идиомы использо­
вания этих API, так что пользователю не приходится прибегать к чрезмерно частым
вызовам базовых функций. Например, интерфейс, который получает символы и пре­
доставляет только функцию get _char ( ) , требует вызова этой функции для каждого
символа. Если же интерфейс предоставляет также функцию ge t_bu f f e r ( ) , можно
избежать необходимости вызова функции для каждого символа.
Библиотеки функций и классов позволяют скрыть сложность, которая иногда яв­
ляется ценой хорошо настроенной программы. Библиотеки должны компенсировать
стоимость вызова функций, делая свою работу с максимальной эффективностью.
Библиотечные функции часто оказываются на дне глубоко вложенных цепочек вы­
зовов, что усиливает эффект повышения производительности.

Ум еньшение коn иче ства выдеn ений памяти и копирований
Уменьшение количества вызовов диспетчера памяти является столь эффективной
оптимизацией, что разработчик может быть успешным оптимизатором, зная только
один этот трюк. Несмотря на то, что стоимость большинства языковых возможнос­
тей С++ не превышает нескольких команд, стоимость каждого обращения к диспет­
черу памяти измеряется тысячами инструкций.
Поскольку строки являются очень важной (и очень дорогостоящей) частью мно­
гих программ С++, я посвятил им целую главу в качестве тематического исследова­
ния в области оптимизации. Глава 4, "Оптимизация использования строк", вводит
и оправдывает многие концепции оптимизации в знакомом контексте обработки
строк. Глава 6, "Оптимизация переменных в динамической памяти': посвящена сни­
жению стоимости динамического выделения памяти без отказа от таких полезных
идиом программирования С++, как строки и контейнеры стандартной библиотеки.
Один вызов функции копирования буфера также может потребовать тысяч так­
тов процессора. Таким образом, уменьшение количества операций копирования
является очевидным способом ускорения кода. Большое количество копирований
выполняется в связи с выделением памяти, поэтому исправление одной проблемы
часто позволяет устранить другую. Еще одними "горячими точками" в смысле ко­
пирования являются конструкторы и операторы присваивания и ввода-вывода. Эта
тема подробно рассматривается в главе 6, "Оптимизация переменных в динамичес­
кой памяти':

Устранение вычис n ений
Если не считать распределение памяти и вызовы функций, стоимость одной ин струкции С++ обычно незначительна. Но выполнение одного и того же кода мил­
лион раз в цикле, или всякий раз, когда программа обрабатывает событие, может
оказаться существенным. Большинство программ имеет один или несколько циклов
С т ра теrии о птимизации кода н а С ++

33

обработки событий и одну или несколько функций, которые обрабатывают символы.
Идентификация и оптимизация этих циклов почти всегда оказывается плодотвор­
ной. В главе 7, "Оптимизация инструкций': предложено несколько советов о том, как
найти часто выполняемый код. Можно спорит ь , что он всегда будет находиться в
цикле.
В литературе по оптимизации содержится обилие методов эффективного исполь­
зования отдельных инструкций С++. Многие программисты считают, что знание
этих трюков является хлебом и маслом оптимизации. Но дело в том, что, если только
этот код не выполняется очень часто, удаление из него одного или двух обращений
к памяти не приводит к измеримой разнице в общей производительности. В главе 3,
"Измерение производительности': содержатся методы, позволяющие определить, ка­
кие части программы выполняются чаще всего, чтобы уменьшить объем вычислений
именно в этих местах.
Современные компиляторы С++ делают действительно выдающуюся работу по
поиску и выполнению таких локальных усовершенствований. Поэтому разработчи­
кам не следует пытаться менять все вхождения i + + на + + i , раскрывать все циклы и
объяснять каждому коллеге, что такое устройство Даффа и почему оно такое замеча­
тельное. Тем не менее я делаю беглый обзор всего этого изобилия в главе 7, "Опти­
мизация инструкций".
Испоnьзование nучw их с труктур данных

Выбор наиболее подходящей структуры данных имеет огромное влияние на про­
изводительность. Отчасти это связано с тем, что алгоритмы для вставки, итерации,
сортировки и извлечения записей имеют стоимость выполнения, которая зависит от
структуры данных. Кроме того, различные структуры данных по-разному использу­
ют диспетчер памяти. Кроме того, структура данных может иметь (или не иметь) хо­
рошую локальность кеша. В главе 1 0 , "Оптимизация структур данных': исследуются
производительность, поведение и компромиссы между структурами данных, предо­
ставляемыми стандартной библиотекой С++. В главе 9, "Оптимизация сортировки и
поиска': обсуждается использование алгоритмов стандартной библиотеки для реали­
зации табличных структур данных на основе простых векторов и массивов С.

Увеn ичение n араnn еnь ности
Большинство программ вынуждены ожидать завершения некоторых действий в
утомительном мире физической реальности. Они должны ждать, пока файлы будут
считаны с механических дисков, страницы будут загружены из Интернета или поль­
зователи медленными пальцами нажмут нужные клавиши. Все время, пока програм­
мы блокируются в ожидании такого события, упускаются возможности для выпол­
нения некоторых вычислений.
Современные компьютеры имеют несколько ядер процессора, доступных для вы­
полнения команд. Если распределить работу среди нескольких ядер, она может быть
завершена быстрее.

34

Г11а ва 1 . Обзор оптимиза ции

Наряду с параллельным выполнением имеются инструменты для синхронизации
параллельных потоков выполнения таким образом, чтобы они могли обменивать­
ся данными. Эти инструменты также могут быть использованы хорошо или плохо.
Глава 12, "Оптимизация параллельности� рассматривает некоторые соображения по
поводу эффективного управления синхронизацией параллельных потоков.
Опти мизация уnрав11ен ия памя ть ю
Диспетчер памяти, я вляющийся частью библиотеки времени выполнения С++,
которая управляет выделением динамической памяти, - это очень часто выполняе­
мый код во многих программах на С++. Язык С++ имеет обширный интерфейс уп ­
равления памятью, хотя большинство разработчиков никогда не используют все его
возможности. В главе 13, "Оптимизация управления памятью� показаны некоторые
способы улучшения производительности управления памятью.

Резюме
Данная книга помогает разработчику идентифицировать и использовать следующие возможности улучшения производительности кода.



Использовать лучшие компиляторы и их о п тими заторы.
Исп ользовать оптимальные алгоритмы.
Использовать лучшие библ иотеки и использовать их наиболее э ффективно.



Снизить количество р аспр е д елени й памят и
.



Снизить кол ичество копирований.
Устранить л ишние вычисл ения.
Исп ользовать опт имальные структуры данных.
Увел ичить степень параллель ности.
Оптимизировать управление п амятью.









Как я уже говорил, дьявол пряче тся в подробностях. И т ак, начнем.

Реэ�оме

35

ГЛАВА 2

Оптимизация, вл ия ю щая
на п оведение к омп ь ютера

Лож ь, умение рассказывать пре красные истории, каких никогда не случа ­
лось, составляет истинную цель Искусства. 1
- Оскар Уайльд (Oscar Wilde), " Упадок лжи ", Намерения ( 1 89 1 )

Цель настоящей главы - обеспечить минимум справочной информации об обо­
рудовании компьютера для мотивации оптимизаций, описанных в этой книге, чтобы
читателю не пришлось сходить с ума, углубляясь в 600-страничный справочник по
процессору. Здесь приводится пов ерхностный обзор архите ктуры процессора, позво­
ляющий выделить некоторые эвристики для оптимизации. Очень нетерпеливый чи­
татель может пропус ти т ь эт у главу и вернуться к ней позже, когда другие главы будут
ссылаться на материал из данной главы. Тем не менее приводимая здесь информация
является важной и полезной для понимания остального материала книги.
Микропроцессорные устройства в настоящее время невероятно разнообразны.
Они варьируются о т дешевых вс т роенных ус т ройс тв, сос тоящих всего лишь из не­
скольких тысяч логических вентилей с тактовой частотой ниже 1 МГц, до настоль­
ных устройств с миллиардами в е нтилей и частотами, изм еря е мыми в гигаг ерцах.
Мощные ЭВМ моrут иметь размер большой комнаты, содержат ь тысячи независи­
мых исполнительных устройств и потреблять электроэнергию, достаточную для ос­
вещения небольшого города. Заманчиво считать, что ничто не связывает это изо ­
билие вычислит ельных устройств, н о в действительности они имеют оч е нь много
общего. В конце концов, если бы между ними не было никакого сходства, было бы
невозможно компилировать код на С++ для множества процессоров, для которых
имеются соответствующие компиляторы.
Все широко используемые компьютеры выполняют команды, хранящиеся в памя­
ти. Эти команды обрабатывают данные, которые также хранятся в памяти. Память
делится на множество небольших слов по нескольку битов. Несколько драгоцен­
ных слов памяти представляют собой регистры, которые именуются в машинных
1

Перевод А. Зверева.

командах непосредственно. Большинство же слов памяти именуются с использова­
нием их числового адреса. Определенный регистр в каждом компьютере содержит
адрес следующей выполняемой команды. Если память рассматривать как книгу,
то в ьтолняемый а дрес выглядит как палец, указывающий на следующее читаемое
слово. Исп олнительное устройство (именуемое также процессором, ядром, ЦПУ и
кучей других слов) считывает поток команд из памяти и действует в соответствии
с ними. Команды говорят исполнительному устройству, какие данные читать (за­
гружать, выбирать) из памяти, что с ними делать и где в памяти записывать (запо­
минать, сохранять) результаты. Компьютер состоит из устройств, которые подчи­
няются физическим законам. Чтение или запись каждого адреса памяти занимает
некоторое ненулевое количество времени, как и выполнение некоторых действий
над считанными данными.
За пределами этой базовой схемы, знакомой любому первокурснику, генеалогичес­
кое дерево компьютерных архитектур демонстрирует буйный рост и изобилие ветвей.
Поскольку компьютерные архитектуры весьма изменчивы, трудно сформулировать
какие-либо строгие числовые правю1а в отношении поведения аппаратного обеспече­
ния. Современные процессоры выполняют так много различных взаимодействующих
операций, чтобы ускорить выполнение команд, что какие-то конкретные сроки их
выполнения в общем случае указать невозможно. С учетом того, что многие разра­
ботчики даже не знают точно, на каких процессорах будет работать их код, лучшее,
что можно ожидать в смысле оптимизации, - это некоторые эвристики.

Л ож ь о комп ь юте рах, в кото ру ю ве р и т С ++
Конечно, программа на С++ по крайней мере делает вид, что верит в изложен­
ную в предыдущем разделе версию простой модели компьютера. Существует память,
адресуемая в байтах размера char, которая является по существу бесконечной. Су­
ществует специальный адрес, именуемый n u l l p t r, который отличается от любого
допустимого адреса памяти. Целое число О преобразуется в nu l lp t r, хотя n u l l p t r
не обязательно указывает на адрес о . Существует единый концептуальный адрес вы­
полнения, указывающий на инструкцию исходного кода, выполняемую в настоящее
время. Инструкции выполняются в том порядке, в котором они написаны, с учетом
действия операторов управления потоком выполнения С++.
С++ знает, что в действительности компьютеры сложнее, чем эта простая модель.
Поэтому из сияющих внутренностей реализации С++ сквозь крышку пробиваются
следующие сверкающие лучи.




38

Программа на С++ должна лишь вести себя так, "как если бы" инструкции
выполнялись в указанном порядке. Компилятор С++ и сам компьютер имеют
право изменять порядок выполнения, чтобы добиться ускорения работы про­
граммы, если при этом не меняется смысл выполняемых вычислений.
Что касается стандарта С++ 1 1 , то С++ больше не считает, что существует толь­
ко один адрес выполнения. Стандартная библиотека С++ теперь поставляется
с возможностями запуска и остановки потоков выполнения и синхронизации
Гnава 2 . О птимиза ция, вnияющая на поведение комп ьютера





доступа к памяти между этими потоками. До С++ 1 1 программисты лгали ком ­
пилятору С++ о потоках, что зачастую приводило к трудно отлаживаемым
проблемам.
Некоторые адреса памяти на самом деле вместо обычной памяти могут быть
регистрами устройства. Значения в некоторых адресах могут изменяться меж­
ду двумя последовательными чтениями одного и того же адреса в одном и том
же потоке, отражая некоторые действия со стороны аппаратного обеспечения.
Такие места описаны в С++ с помощью ключевого слова vol a t i l e . Объявле­
ние переменной как vol a t i l e требует от компилятора извлекать новую копию
переменной всякий раз, когда она используется, вместо того, чтобы оптими­
зировать программу путем сохранения значения в регистре и его повторного
использования. Могут быть также объявлены указатели на vоl а t i l е-память.
C++ l l предлагает магическое заклинание s td : : atomi c, которое заставляет
память некоторое время вести себя так, как если бы это в действительности
было простое линейное хранилище байтов, без учета всех сложностей сов­
ременных микропроцессоров с их многочисленными потоками выполнения,
многослойными кешами памяти и т.д. Некоторые разработчики считают, что
для этого служит ключевое слово vol a t i le, но они сильно ошибаются.

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

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

П р а вда о ком п ьютерах

39

М ед11 енная память
Основная память компьютера очень медленная по сравнению с его внутренни­
ми логическими вентилями и регистрами. Она медленная настолько, что процессор
настольного компьютера может выполнить сотни команд за время, необходимое для
извлечения из основной памяти единственного слова данных.
Следствием этого для оптимизации является то, что доступ к п амяти доминирует
над другими действиями процессора, включая выполнение команд.
Уз кое м есто фо н Н е й ма н а

Интерфейс к основной памяти является узким местом, которое ограничи­
вает скорость работы. Это узкое место даже имеет имя. Оно называется
узким местом (или бутылочным горлышком) фон Неймана, в честь зна­
менитого пионера компьютерной архитектуры и математика Джона фон
Неймана (John von Neumann) ( 1 903- 1 957).
Например, компьютер, оснащенный памятью DDR2 с частотой 1 000 МГц
( типичной для компьютеров несколько лет назад ) , имеет теоретическую
пропускную способность 2 млрд слов в секунду, или 500 пс на слово. Но
это не значит, что компьютер может считывать или записывать случайное
слово данных каждые 500 пс.
Начнем с того, что за один такт (половина такта часов с частотой 1 000 МГц)
может выполняться только последовательный доступ. Доступ к не после­
довательным местам в памяти выполняется примерно за 6- 1 0 тактов.
Далее, за доступ к шине памяти конкурируют несколько действий. Про­
цессор постоянно проводит выборку очередных выполняемых команд из
памяти. Контроллер кеш-памяти выполняет выборку блоков данных па­
мяти для кеша и сбрасывает записанные строки кеша. Контроллер DRAM
также требует циклы для обновления заряда в динамических ячейках уст­
ройства памяти. Числа ядер многоядерного процессора достаточно, чтобы
гарантировать перенасыщенность шины памяти. Фактическая скорость, с
которой данные могут быть прочитаны из основной памяти в определен­
ное ядро, составляет более 20-80 нс на одно слово.
Закон Мура позволяет каждый год получать все больше и больше ядер в
процессорах. Но чтобы обеспечить более быстрый интерфейс основной
памяти, этого мало. Таким образом, удвоение числа ядер в будущем будет
оказывать на производительность отрицательное влияние. Ядра будут бо­
роться за доступ к памяти. Надвигающееся ограничение производитель­
ности называется стеной п амяти.

40

Гnава 2. Оптимиза ция, вn ия юща я на поведен и е ком п ь ютера

Недоступност ь ба йтов
Хотя С++ считает, что каждый байт доступен по отдельности, компьютеры часто
компенсируют медленность физической памяти путем выборки данных большими
блоками. Самые мелкие процессоры могут выбирать из основной памяти отдельные
байты, но процессоры настольных компьютеров могут выбирать за один раз по
64 байта. Некоторые суперкомпьютеры и графические процессоры выполняют вы­
борку еще большего размера.
Когда С++ выбирает многобайтные данные, такие как тип i n t , douЫ e или указа­
тель, может оказаться, что байты, составляющие данные, входят в два слова физичес­
кой памяти. Это называется невыровненным д оступом к памяти. Важным для опти­
мизации является то, что нев ыровненный дос туп требует в два раза больше времени,
чем если бы все байты были в одном и том же слове, поскольку при этом требуется
чтение двух слов. Компилятор С++ выравнивает структуры таким образом, чтобы
каждое поле начиналось с адреса байта, кратного размеру поля. Но это создает собс­
твенную проблему: "дыры " в структурах, содержащие неиспользуемые данные. Об­
ращая внимание на размер полей данных и их порядок в структуре, можно сделать
структуры максимально компактными при сохранении выровненности.
Одни об ращения к памяти мед11еннее дру rих
Для компенсации "медленности " основной памяти многие компьютеры содержат
кеш-память, разновидность быстрой временной памяти, располагающейся близко к
процессору, чтобы ускорить доступ к наиболее часто используемым словам памяти.
Одни компьютеры обходятся без кеша; другие имеют один или несколько уровней
кеша, каждый из которых меньше, быстрее и дороже предыдущего. Когда процессо­
ру нужны байты из слова кешированной памяти, они могут быть быстро извлечены
без повторного обращения к основной памяти. Насколько кеш-память быстрее? Эм­
пирическое правило гласит, что каждый уровень кеш-памяти примерно в 10 раз быс­
трее, чем предыдущий уровень в иерархии памяти. На процессорах настольных ком­
пьютеров время доступа к памяти может меняться на пять порядков в зависимости
от того, к чему осуществляется доступ: к кеш-памяти первого, второго или третьего
уровня, к основной памяти или к странице виртуальной памяти на диске. Это одна
из причин, по которым увлеченность тактами выполнения команд процессором и
другими подобными тайнами так часто оказывается глупой и бесполезной - состоя­
ние кеша делает время выполнения команды весьма неопределенным.
Когда процессор нуждается в выборке данных, находящихся не в кеше, другие
данные, расположенные в кеше, могут быть удалены из него для того, чтобы осво­
бодить место. Данные, выбираемые для удаления, как правило, являются наиболее
давно использовавшимися. Это важно для оптимизации, поскольку означает, что до­
ступ к интенсивно используемым ячей кам памят и мож но получить быстрее, чем к
используемым реже .
Чтение даже одного байта данных, который не находится в кеше, приводит к
кешированию множества близлежащих байтов ( как следствие это означает, что
множество байтов, находящихся в настоящее время в кеше, удаляются из него).
П ра вда о комп ьютера х

41

Эти близлежащие байты будут готовы для быстрого доступа. Это важно для опти­
мизации, поскольку означает, что обращение к соседним ячей кам памяти (в среднем)
быстрее, чем к памяти в отдаленных местах.
В терминах С++ это означает, что блок кода, содержащий цикл, может выпол­
няться быстрее, поскольку инструкции, составляющие цикл, активно используются,
располагаются близко одна к другой и, таким образом, вероятно, будут оставаться в
кеше. Блок кода, содержащий вызовы функций или инструкции i f, которые приво­
дят к дальним переходам, могут выполняться более медленно, потому что исполь­
зуются части кода, далеко отстоящие одна от другой. Такой код использует больше
пространства кеша, чем цикл. Если программа большая, а кеш конечен, часть кода
будет удаляться из кеша, чтобы освободить место для других вещей, что приведет
к замедлению доступа в следующий раз, когда потребуется этот код. Аналогично
доступ к структуре данных, состоящей из последовательных местоположений в па­
мяти, такой как массив или вектор, может быть быстрее, чем к структуре данных,
состоящих из узлов, связанных указателями, поскольку данные в последовательных
местоположениях с большей вероятностью будут оставаться в кеш-памяти. Доступ к
структуре данных, состоящих из записей, связанных с помощью указателей (напри­
мер, к списку или дереву), может быть медленнее из-за необходимости считывания
данных каждого узла из основной памяти в новые строки кеша.
Остроконечные и тупоконечные сnова
Из памяти может быть выбран один байт данных, но зачастую одновременно
извлекается несколько последовательных байтов, образующих число. Например, в
Microsoft Visual С++ значение типа int образуют четыре байта, считываемые из памя­
ти вместе. Так как к памяти можно обращаться двумя способами, люди, которые про­
ектируют компьютеры, должны ответить на важный вопрос: что содержится в первом
байте (адрес которого наименьший) - старшие или младшие биты значения i n t ?
На первый взгляд кажется, что это н е должно иметь значения. Конечно, важно,
чтобы все части компьютера одинаково считали, с какого конца in t адрес меньше,
иначе воцарится хаос. Вот в чем заключается разница между этими способами хра­
нения. Если значение int, равное О х 0 1 2 3 4 5 6 7 , хранится по адресам 1 000- 1 003 и пер­
вым хранятся старшие биты, то по адресу 1 000 содержится байт O x O l , а по адресу
1 003 - байт О х 6 7 , в то время как если сначала хранится младший байт, то по ад­
ресу 1 000 содержится О х 6 7 , а по адресу 1 003 - O x O l . Компьютеры, которые хранят
старшие биты в байте с младшим адресом, называются компьютерами с обратным
порядком байтов ("тупоконечниками': blg-endian). Компьютеры с прямым поряд ком
байтов ("остроконечники': little-endian) сначала читают младшие биты. Итак, имеет­
ся два способа хранения целого числа (или указателя ), и нет никаких причин пред­
почесть один другому, так что разные команды, работающие на разных процессорах
для разных компаний, могут делать разный выбор.
Проблемы начинаются, когда данные, записанные на диск или отправленные по
сети одним компьютером, должны быть прочитаны другим компьютером. Диски
и сети пересылают информацию побайтно, а не весь i n t одновременно. Поэтому

42

Гnа ва 2 . О птимиза ция, вnияющая н а поведе ние комп ьюте ра

оказывается важно, какой конец числа сохраняется (или отправляется) первым. Если
отправляющий и принимающий компьютеры не согласованы, то значение, отправля­
емое как О х 0 1 2 3 4 5 6 7 , может быть получено как О х 6 7 4 5 2 3 0 1 .
Порядок байтов является лишь одной и з причин, п о которым С + + н е определяет,
как биты располагаются в i nt или как значение одного поля в объединении влияет
на другие поля. Это одна из причин, по которым программу можно написать так, что
она будет успешно работать на компьютере одного вида, но приводить к аварийному
завершению на другом.
К оnичеств о памяти оrраничено
Память компьютера не бесконечна. Чтобы сохранить иллюзию бесконечной памя­
ти,операционная система может использовать физическую память наподобие кеш-па­
мяти и хранить данные, которые не помещаются в физическую память, в виде файла
на диске. Эта схема называется виртуальной памятью. Виртуальная память создает
иллюзию большего количества физической памяти. Однако получение блока памяти
с диска занимает десятки миллисекунд - вечность для современного компьютера.
Быстрая кеш-память весьма дорогостоящая. В настольном компьютере или смарт­
фоне может быть гигабайт памяти, но кеш раз мером лишь в несколько мегабайтов.
Программы и их данные обычно в кеш не помещаются.
Результатом кеширования и применения виртуальной памяти может быть то, что
из-за кеширования определенная фун кция, работающая в контексте всей программы ,
может выполнят ься медленнее, чем та же функция в тестовой программе, в 1 0 тысяч
раз. В контексте всей программы функции и данные не могут все время оставаться
в кеше , в то время как в контексте теста это именно так и происходит. Этот эффект
усиливает преимущества оптимизаций, которые снижают использование памяти или
диска, в то время как преимущества оптимизаций, которые уменьшают размер кода,
остаются небольшими.
Вторым результатом кеширования является то, что если большая программа
выполняет рассеянный доступ ко многим участкам памяти, то кеш-памяти может
оказаться недостаточно для хранения данных, непосредственно используемых про­
граммой. Это приводит к снижению производительности, которое называют про­
буксовкой страницы. Когда пробуксовка страницы происходит во внутреннем кеше
микропроцессора, результатом является снижение производительности. Когда это
происходит в файле виртуальной памяти операционной системы, производитель­
ность падает тысячекратно. Эта проблема возникала чаще, когда физическая память
была дороже и меньше по размеру, но она встречается и сейчас.
Медnенн ое выпоnнение коман д
Простые микропроцессоры наподобие встроенных в кофеварки и микроволно­
вые печи предназначены для выполнения команд с той же скоростью, с которой они
могут извлекаться из памяти. Микропроцессоры настольного компьютера имеют до­
полнительные ресурсы для параллельной обработки нескольких команд, поэтому они
способны выполнять команды во много раз быстрее, чем те могут быть извлечены из
П ра вда о ком п ьютерах

43

основной памяти, так что большую часть времени для хранения команд и их переда­
чи процессору используется быстрая кеш-память. Важность этого для оптимизации
заключается в том, что время доступа к памяти превышает время вычислений.
Современные настольные компьютеры выполняют команды с удивительной ско­
ростью, если им ничто не мешает. Они могут завершать команды каждые несколько
сотен пикосекунд (пикосекунда представляет собой i o-1 2 с, до смешного короткое
время). Но это не значит, что каждая команда выполняется так быстро. Процессор
содержит "конвейер " одновременно выполняемых команд. Команды проходят через
конвейер, дешифруются, получают свои аргументы, выполняют вычисления и со­
храняют результаты. Чем более мощный процессор, тем более сложен его конвейер,
разбивающий выполнение команды на десяток этапов так, чтобы как можно больше
команд могли быть обработаны одновременно.
Если команда А вычисляет значение, которое требуется команде Б, то команда Б
не может выполнить свое вычисление до тех пор, пока команда А не даст необходи­
мый результат. Это приводит к остановке ко нв е йер а короткой паузе в выполнении
команд, которая возникает, когда выполнение двух команд не может полностью пе­
рекрываться. Остановка конвейера в особенности долгая, если команда А извлекает
значение из памяти, а затем выполняет вычисление, которое дает значение, необхо­
димое команде Б. Остановке конвейера подвержены все современные микропроцес­
соры, что делает их время от времени почти такими же медленными, как процессор
в вашем тостере.
,

Трудное принятие решений
Еще одна вещь, которая может вызвать остановку конвейера, - это принятие
решения компьютером. После большинства команд выполнение продолжается с ко­
манды, находящейся в памяти по следующему адресу. Большую часть времени эта
следующая команда уже находится в кеше. Последовательные команды можно загру­
жать в конвейер, как только на первом этапе конвейера для этого появляется место.
Но не таковы команды передачи управления. Команда перехода или вызова под­
программы заменяет адрес выполнения произвольным новым значением. "Следу­
ющая " команда не может быть считанной из памяти и попасть в конвейер до тех
пор, пока в некоторый момент в процессе обработки команды перехода не будет об­
новлен адрес выполнения. У слова памяти по новому адресу выполнения меньше
шансов находиться в кеше. Конвейер останавливается на время обновления адреса
выполнения и загрузки в конвейер новой "следующей" команды.
После команды условного ветвления выполнение продолжается в одном из двух
разных мест: выполняется либо следующая команда, либо команда по адресу, кото­
рый является целевым для команды ветвления, в зависимости от результатов некото­
рых предыдущих вычислений. Конвейер останавливается, пока не будут завершены
все команды, участвующие в предыдущих вычислениях, и остается в этом состоянии
до тех пор, пока не будет определен следующий выполняемый адрес и не будет про­
читана соответствующая команда по этому адресу.
Важность этого явления для оптимизации заключается в том, что вычисление быс­
трее принятия решения.
44

Гла ва 2. Оптимиза ция, влия ющая на по веде ние компьютера

Мно жественные потоки выпопнения
Любая программа, работающая в современной операционной системе, разделяет
компьютер с другими программами, выполняемыми в то же время, с периодичес­
кими обслуживающими процессами типа проверки диска или поиска обновлений
Java или Flash, и с различными частями операционной системы, управляющими се­
тевым интерфейсом, дисками, звуковыми устройствами и другими периферийными
устройствами. Каждая программа конкурирует с другими программами за ресурсы
компьютера .
Программа обычно не слишком об этом осведомлена. Она просто работает не ­
много медленнее. Исключением является ситуация, когда одновременно запускается
много программ, и все они конкурируют за память и диск. Для настройки произво­
дительности, если программа должна запускаться при запуске системы или в периоды
пиковой нагрузки, ее производительность должна измеряться под нагрузкой.
По состоянию на начало 20 1 6 года настольные компьютеры имеют до 1 6 ядер
процессора, а микропроцессоры, используемые в телефонах и планшетах, - до вось­
ми. Беглый взгляд на диспетчер задач Windows, вывод состояния процесса Linux или
список задач Android обычно показывает гораздо больше процессов, чем ядер, и
большинство процессов имеют несколько потоков выполнения. Операционная сис­
тема выполняет каждый поток в течение короткого времени, а затем переключает
контекст на другой поток или процесс. С точки зрения программы это выглядит, как
если бы одна команда выполнялась наносекунду, а следующая - 60 мс.
Что означает переключение контекстов? Если операционная система переключа­
ется от одного потока к другому в одной программе, это означает сохранение регист­
ров процессора для приостановки потока и загрузки сохраненных регистров для
возобновления другого потока. Регистры современного процессора содержат сотни
байтов данных. Когда новый поток возобновляет выполнение, данные могут не быть
в кеше, так что имеется начальный период медленного выполнения, пока новый
контекст загружается в кеш. Таким образом, при переключении контекстов потоков
имеются значительные затраты.
Процедура переключения операционной системой контекста от одной программы
к другой еще более дорогостоящая. Все "грязные" страницы кеша (с записанными
данными, которые еще не внесены в основную память) должны быть сброшены в
физическую память, а все регистры процессора сохранены. Затем сохраняются ре­
гистры страниц отображения физической памяти на виртуальную в диспетчере па­
мяти. Далее для нового процесса загружаются соответствующие регистры памяти и
регистры процессора. И наконец выполнение программы может возобновиться. Но
кеш в этот момент пуст, так что начальный период характеризуется низкой произво­
дительностью и конфликтами памяти.
Когда программа должна ожидать некоторое событие, это ожидание может про ­
должаться даже после того, как это событие произойдет, пока операционная система
не освободит процессор для продолжения программы. При выполнении программы
в контексте работы других программ, конкурирующих за ресурсы компьютера, это
может увеличить время выполнения программы и сделать его более неопределенным.
П ра вда о ком nь�отерах

45

Исполнительные устройства многоядерных процессоров и связанная с ними кеш­
память для достижения лучшей производительности работают более или менее не­
зависимо друг от друга. Однако все исполнительные устройства имеют одну и ту же
основную память. Они вынуждены конкурировать за доступ к оборудованию, свя­
зывая его с основной памятью, что делает узкое место фон Неймана в компьютере с
несколькими исполнительными устройствами еще более ограничивающим.
Когда исполнительное устройство записывает значение, оно сначала попадает в
кеш -память. В конечном итоге оно должно быть записано из кеша в основную па­
мять, так что это значение станет видимым для других исполнительных устройств.
Однако из-за конфликтов доступа к основной памяти среди исполнительных уст­
ройств основная память может не обновляться в течение сотен команд после того,
как значение было изменено.
Если на компьютере имеется несколько исполнительных устройств, то одно из них
может, таким образом, на протяжении длительного периода времени не увидеть дан­
ные, записанные другим устройством в основной памяти, а изменения в основной
памяти могут произойти не в том же порядке, что и порядок выполнения команд.
В зависимости от непредсказуемых временных факторов исполнительное устройство
может увидеть как старое значение общей памяти, так и обновленное значение. Для
того чтобы различные исполнительные устройства видели согласованные представле ­
ния памяти, должны использоваться специальные команды синхронизации. Значение
этого эффекта для оптимизации состоит в том, что обра щ ение к совмест но используе­
мым потоками выполнения данным гораздо медленнее, чем к не разделяемым данным.
Вызовы операционной системы яв11я ются дороrостоящими
Все процессоры, кроме самых мелких, имеют аппаратное обеспечение для обеспе­
чения изоляции между программами, так что программа А не может читать, писать
или выполнять команды в физической памяти, принадлежащей программе Б. То же
самое оборудование защищает ядро операционной системы от перезаписи программа­
ми. С другой стороны, ядру операционной системы требуется доступ к памяти, прина­
длежащей каждой программе, чтобы эти программы могли делать системные вызовы
операционной системы. Некоторые операционные системы также позволяют програм­
мам делать запросы для совместного использования памяти. Способы организации
системных вызовов и совместно используемой памяти разнообразны и полны тайной
магии. С точки зрения оптимизации важным является то, что системные вызовы доро ­
ги, в сотни раз дороже, чем вызовы функций внутри одного потока одной программы.

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

Гnава 2. О пт11м11эа ц11и, вnмиющаи на поведение ком п ьютера

Не все инст рукции одинаково до роrие
В мирные, давно минувшие дни программирования на языке С Кернигана и Ритчи
каждая инструкция была примерно такой же дорогой, как и любая другая. Вызов
функции может содержать вычисления произвольной сложности. Однако оператор
присваивания в общем случае копирует нечто, помещающееся в машинном регистре,
во что-то другое, что может хранить содержимое регистра компьютера. Таким обра­
зом, инструкция
int i , j ;

i = j;

копирует 2 или 4 байта из j в i . Объявление может быть i n t , f l o a t или s t r u c t Ы g
s t r u c t * , но инструкция присваивания все равно выполняет одно и то же количество
работы.
В настоящее время это не так. В С++ присвоение одного int другому представляет
собой точно такой же объем работы, как и в случае соответствующей инструкции С.
Но инструкция наподобие B i g i n s t a n c e i O t h e r Ob j e c t ; может копировать целые
структуры. Более того, этот вид присваивания вызывает конструктор B i g i n s tance,
который может скрывать произвольно сложный механизм. Конструктор вызывается
также для каждого выражения, переданного функции в качестве формального аргу­
мента, и вновь, когда функция возвращает значение. Арифметические операторы и
операторы сравнения также могут быть перегружены, так что выражение А = В * С ;
может умножать п-мерные матрицы, а i f ( х< у ) . . . может сравнивать два пути через
ориентированный граф произвольной сложности. Значение этого для оптимизации
заключается в том, что некоторые инструкции скрывают большие количества вы­
числений. Вид инструкции ничего не говорит об ее стоимости.
Разработчики, которые начинали изучение языков программирования с С++, мо­
гут не увидеть в этом ничего удивительного; но для тех, кто начинал с изучения С, их
инстинкты могут привести к катастрофическим заблуждениям.
=

Инст рукции выпоn няются не по порядку
Программы на С++ ведут себя так, как если бы они выполнялись в порядке, ука­
занном инструкциями управления потоком выполнения С++. Хитрая оговорка "как
если бы" в предыдущем предложении является главной, на которой построено мно­
жество оптимизаций и трюков современного компьютерного оборудования.
За кулисами компилятор может - и зачастую так и делает - переупорядочивать
инструкции для повышения производительности. Но компилятор знает, что пере­
менная должна содержать последний результат вычисления, присвоенный до провер­
ки или присваивания его другой переменной. Современные микропроцессоры также
могут выбрать команды для выполнения не по порядку их следования, но они содер­
жат логические схемы, которые гарантируют выполнение записи в память до после­
дующего чтения того же места. Логика управления памятью микропроцессора может
даже выбрать задержку записи в память для оптимального использования шины па­
мяти. Однако контроллер памяти знает, что именно в настоящее время находится в
С++ тоже лже т

47

процессе записи от исполняющеrо устройства через кеш-память в основную память,
и гарантирует, что если будет считываться тот же адрес, то будет получено коррект­
ное значение, даже если ero запись выполнена еще не до конца.
Параллелизм усложняет эту картину. Проrраммы на С++ компилируются без зна­
ния о друrих потоках, которые моrут выполняться одновременно с данным. Компи­
лятор С++ не знает, какие переменные, если таковые имеются, совместно использу­
ются потоками. Совокупный эффект изменения порядка инструкций компилятором
и компьютером и задержка записи в основную память разрушают иллюзию выпол ­
нения инструкций в указываемом проrраммой порядке, если проrрамма содержит
параллельные потоки, которые совместно используют данные. Разработчик должен
добавить явную синхронизацию кода мноrопоточных проrрамм для гарантии полу­
чения соrласованноrо предсказуемоrо поведения. Синхронизация кода снижает сте­
пень параллелизма, получаемого при совместном использовании данных параллельны­
ми потоками.

Резюме





















48

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

Гnа в а 2. О nтимизация, вnия�оща я на пов е д е н и е ком п ьюте ра

ГЛАВА 3

И зме ре н ие п роизводитеn ь н ос ти

Измеряй все измеримое и делай измеримым не являющееся таковым.
-

Галилео Галилей

(Galileo Galilei) (1564-1642)

Измерения и эксперименты являются основой любой серьезной попытки улуч­
шить производительность программы. В этой главе представлены два программных
инструмента, которые измеряют производительность: профайлер и программный
таймер. Рассмотрим, как спроектировать эксперименты по измерению производи­
тельности так, чтобы получ ить результаты значимые, а не вводящие в заблуждение.
Самые главные и наиболее часто выполняемые измерения производительности
программного обеспечения отвечают на вопрос "Как долго? " Сколько времени вы­
полняется функция? Как долго считывается конфигурация с диска? Сколько времени
выполняется запуск или завершение программы?
На эти вопросы можно попытаться ответить (пусть и неуклюже) с помощью до
смешного простых инструментов . Исаак Ньютон пытался измерять гравитационную
постоянную по времени падения объектов, измеряемому с помощью сердцебиения.
Я уверен, что каждый разработчик время от времени начинал считать (вслух или про
себя) "двадцать один, двадцать два . . :: чтобы получить приблизительное количество
прошедших секунд. Цифровые наручные часы с секундомером - не веяние моды, а
обязательный атрибут программиста. В мире встроенных устройств разработчики
имеют в своем распоряжении отличные инструменты, включая частотомеры и ос­
циллографы, которые могут точно измеря ть врем я выполнения даже очень коротких
процедур. Производители программного обеспечения выпускают и продают различ­
ные специализированные инструменты, которых слишком много даже для беглого
обзора в этой книге.
Эта глава сосредоточена на двух широко доступных инструментах, в общем слу­
чае полезных и недорогих. Первый инструмент, профайлер, обычно поставляется
вместе с компилятором. Профайлер создает табличный отчет по совокупному вре­
мени, затраченному на выполнение каждой функции, вызываемой во время работы
программы. Это важный инструмент оптимизации программного обеспечения, ко­
торый создает список узких мест в программе.

Второй инструмент
программны й таймер
представляет собой и нстру­
мент, который моrут создавать сами разработчики, как рыцари-джедаи создают
свои световые мечи (если вы простите ссылку на "Звездные войны ") . Если цена
компилятора с профайлером слишком велика или если поставщик компилятора
не предлагает такового, разработчик все равно может проводить эксперименты по
выяснению времени выполнения достаточно длительных действий. Проrраммный
таймер полезен также для выяснения продолжительности задач, которые не явля­
ются вычислениями.
Третий инструмент, лабораторный журнал, имеет столь почтенный возраст, что
мноrие разработчики полаrают, что он полностью вышел из употребления. Однако
лабораторный журнал (или эквивалентный текстовый файл) является незаменимым
инструментом в наборе инструментов оптимизации.
-

-

О птимизиру ю щ ее м ышл е н ие
Прежде чем поrрузиться в измерения и эксперименты, я хотел бы немноrо поrо­
ворить о практикуемой мною философии оптимизации, которой я надеюсь обучить
вас в этой книге.
П р оизводитеnьность доn ж на быть измерена
Чувства человека, как правило, недостаточно точны для тоrо, чтобы обнаружить
нарастающие изменения в производительности. Ваша память может не позволить
вам точно вспомнить результаты многих экспериментов. Учебник может обмануть
вас, заставив поверить не всеrда верным утверждениям. Интуиция часто подводит
разработчиков, коrда они решают, следует ли оптимизировать определенную часть
кода. Они пишут функции, зная, что те будут использованы, но мало заботясь о том,
как часто или каким кодом они будут использованы. Затем неэффективный фрагмент
кода попадает в критический компонент, rде он вызывается миллиард раз. Опыт так­
же может вас подвести. Языки программирования, компиляторы, библиотеки и про­
цессоры - все это находится в постоянном развитии. Функции, которые ранее были
узкими местами, моrут стать более эффективными (но может случиться и обратное).
Только измерение скажет вам, выиrрали вы или проиrрали в иrре оптимизации.
Разработчики, навыки которых в области оптимизации я уважаю более других,
подходят к задаче оптимизации систематически.


Они высказывают проверяемые предположения и записывают свои прогнозы.



Они ведут учет изменений кода.
Они выполняют измерения с наилучшими доступными инструментами.
Они подробно описывают полученные экспериментальные результаты.




50

Гл ава 3. И змерение n рои з водитеn ьности

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

Оптими з а ция - бо11ьwая и r ра
Я требую взлететь и взорвать всю ста н ци ю с ор биты. Это единствен ­
ный надежный способ.
- Элен Рипли (Сигурни Уивер (Sigourney Weaver)), к/ф "Чужие", 1986

Оптимизация - игра с большими ставками. Стоит ли ускорение программы на
1% риска внести ошибки при изменении рабочей программы? Изменения должны
быть существенными по крайней мере локально , чтобы считать их полезными. Кро­
ме того, ускорение в 1 % может оказаться всего лишь ошибкой измерения , которая
будет принята за улучшение. Любое ускорение должно быть доказано с применением
рандомизации, статистических методов и определением доверительного интервала.
Не слишком ли много работы для слишком малого эффекта? Это не наш путь, не то ,
ради чего я взялся за написание данной книги.
Улучшение на 20% - дело совсем иное. Оно развеивает все возражения по по­
воду методологии. В книге не так уж много статистических данных, и я не прошу за
это прощения. Смысл книги в том, чтобы помочь разработчику найти возможности
улучшения производительности, достаточно существенные для того, чтобы переве­
шивать любой вопрос их стоимости. Это улучшение по-прежнему может зависеть от
множества факторов, таких как операционная система и компилятор, поэтому оно
может не оказать большого влияния в другой системе или в другое время. Но такие
большие изменения почти никогда не приводят к снижению производительности
при переносе кода на новую систему.

Пра ви 110 90/1 О
Фундаментальным правилом оптимизации является правило 90/ 1 0: 90% времени
работы программа затрачивает на выполнение 1 0% кода. Это правило эвристичес­
кое; это не закон природы, а скорее полезное обобщение для размышлений и плани­
рования. Это правило иногда называют правилом 80/20; впрочем , идея остается той
же. Интуитивно правило 90/ 1 0 означает, что определенные блоки кода представляют
собой "горячие точки': которые выполняются очень часто, в то время как другие час­
ти кода не выполняются практически никогда. Эти горячие точки и являются целями
для приложения усилий по оптимизации.

О nтммизиру�ощее мыwnе нме

51

Из истории оптимизационных войн

С правилом 90/ 1 0 как про ф ессиональный разработчик я впервые столк­
нулся в одном из моих первых проектов - встроенного устройства с кла ­
виатурой, которое совершенно случайно называлось 90 1 0А (рис. 3 . 1 ) .

Рис. 3. 1 . Fluke 901 0А (British Computer History Museum)
В нем имелась функция опроса клавиатуры, предназначенная для того,
чтобы увидеть, не нажата ли клавиша STOP. Эта функция часто вызыва­
лась каждой подпрограммой. Руч ная оптимизация кода одной лишь этой
функции на языке ассемблера Z80 , сгенерированного компилятором С
( я затратил на это 45 минут), повысила общую производительность на 7%,
что для данного устройства было весьма существенно.
Эта ситуация в общем случае типична для проблемы оптимизации. В на­
чале процесса оптимизации очень много времени выполнения тратилось в
одном месте программы. Это место было довольно очевидным: служебные
действия выполнялись многократно, на каждой итерации каждого цикла.
Оптимизация требовала работы с кодом на языке ассемблера вместо С.
Однако выполненная работа на языке ассемблера была очень ограничен­
ной, чтобы уменьшить риск, который влечет за собой выбор этого языка.

Ситуация была типична и в том, что горячей точкой был только один блок
кода. После того, как был улучшен этот код, первое место заня л другой
блок кода, но его вклад в общее время выполнения был гораздо меньшим.
Фактически он был настолько мал, что мы прекратили оптимизацию пос­
ле внесения всего лишь одного описанного выше изменени я . Мы не могли
обнаружить никаких других возможностей оптимизации, которые дали бы
нам хотя бы 1 о/о ускорения.

52

Гnава 3 . И змерение n р оиз водитеn ьности

Из правила 90/ 1 0 следует, что оптимизация каждой процедуры в программе бес­
смысленна. Оптимизация малой части кода дает практически все улучшения произ­
водительности, которые можно получить. Выявление "горячих" 1 0% кода - с поль­
зой проведенное время. Выбор кода для оптимизации на основании предположений,
скорее всего, будет временем, потраченным впустую.
Я хотел бы вернуться к цитате Кнута из главы 1, "Обзор оптимизации': Вот более
длинная версия той же цитаты.

Программ и сты тр а тят огром н о е коли ч ест во времени, ра зм ышляя (или
беспокоясь) о скорости некрит ических частей их программ, и э т и по ­
пыт ки повышения э ффект ивности на самом деле имеют сильное отри ­
ц а тел ьное влияние на отла д ку и обслужи ва ние. Мы не должны помнить
о мало й э ффективности, скажем, около 97 процентов времени : преждев ­
ременная оптимизация является корнем всех зол.
- Дональд Кнут (Donald Knuth), Strиctured Programming with go to Statements,
АСМ Computing Surveys 6 (Dec 1 974) : 268. CiteSeerX: 1 0. 1 . 1 . 1 03.6084
(http : / /bi t . l y / kn u t h - 1 9 7 4 )

Доктор Кнут не говорит, что оптимизация в общем случае есть зло, как считают
некоторые. Он сказал лишь, что злом является тратить время на оптимизацию не­
критических 90% программы. Видимо, ему тоже было известно о правиле 90/ 1 О.

За кон Амдаnа
Закон Амдала, придуманный одним из пионеров вычислительной техники Джи ­
ном Амдалом (Gene Amdahl) и названный в его честь, описывает, насколько повы­
сится общая производительность при оптимизации части кода. Существует несколь­
ко способов выражения закона Амдала, но что касается оптимизации, он может быть
выражен с помощью формулы
1


р

=

( 1 - Р ) + -�
Sp

Здесь Sт - улучшение времени выполнения программы в целом в результате опти­
мизации, Р - доля оптимизированного общего времени выполнения, а Sp - показа­
тель улучшения в оптимизированной части Р.
Предположим, например, что выполнение программы занимает 1 00 с. Посредс­
твом профилирования (см. раздел "Профилирование выполнения программы" далее
в главе) вы обнаружили, что программа тратит 80 с на выполнение нескольких вызо­
вов одной функции f . Теперь предположим, что вы переписали функцию f, сделав ее
на 30% быстрее. Насколько это улучшит общее время выполнения программы?
Р, часть исходного времени выполнения функ ц ии f, равно 0 , 8 . Sp равно 1 ,3. Под­
становка этих величин в закон Амдала дает


=

1
( 1 - 0.8 ) +

22
В � 1.
О_:___
_

_

1 .3

Оп ти м и з и рую щее мыwnение

53

Повышение производительности одной этой функции на 30% увеличивает произ­
водительность всей программы на 22%. В этом случае закон Амдала проиллюстриро­
вал правило 90/ 1 0 и предоставил пример того, насколько существенным может быть
улучшение в 1 0% кода.
Давайте рассмотрим еще один пример. Вновь будем считать, что общее время
выполнения программы равно l 00 с и профилирование выяснило, что программа
тратит 10 с на выполнение нескольких вызовов одной функции g . Мы переписали ее
так, что теперь она работает в 1 00 раз быстрее. Насколько повысится производитель­
ность программы в этом случае?
Часть общего времени выполнения, которое программа затрачивает на функцию
g, составляет Р 0. 1 , а Sp 1 00. Подстановка этих значений в закон Амдала дает
=

=

Sт =

l

1.1 1
01 �
1 00

( 1 - О. 1 ) + -·-

В этом случае закон Амдала, по сути, предостерегает нас. Даже если героические
усилия программиста или применение черной магии сведет время работы функции g
к нулю, она все равно останется в неважных 90% программы. Общее улучшение про­
изводительности все равно составит 1 1 %, с точностью до двух знаков после запятой.
Закон Амдала гласит, что даже самая успешная оптимизация не является существен­
ной, если оптимизированный код не составляет большую часть времени выполнения
программы в целом. Уроком закона Амдала является, что если ваш коллега прихо­
дит на рабочую встречу команды и сообщает, что он ускорил некоторые вычисления
в 1 О раз, это вовсе не обязательно значит, что все ваши проблемы производительнос­
ти решены.

П р о ве д ен ие э ксперим е нтов
Разработка программного обеспечения - это всегда эксперимент, в том смысле,
что вы начинаете писать программу, думая, что она будет делать некоторые конк­
ретные вещи, а затем смотрите, что же она делает на самом деле. Настройка произ­
водительности является экспериментом в более формальном смысле этого понятия.
Вы должны начинать работу с правильного кода, в том смысле, что это код, кото­
рый делает то, что вы ожидаете и что от него требуется. Вы должны посмотреть на
этот код новым взглядом и задаться вопросом "Где в этом коде узкое место?" Почему
эта конкретная функция из многих сотен функций вашей программы появляется в
верхней части списка профайлера? Эта функция тратит время на выполнение чего­
то лишнего? Есть ли более быстрый способ выполнить те же вычисления? Данная
функция использует дорогостоящие ресурсы? Эта функция на самом деле предельно
эффективна, насколько это вообще возможно, но просто вызывается слишком часто,
что не оставляет никакой возможности для повышения ее производительности?
Ваш ответ на вопрос "Где в этом коде узкое место?" формирует гипотезу, которую
вы будете проверять. Эксперимент принимает форму двух измерений времени вы­
полнения программы: одно - до внесения изменений в программу, а второе - после.
54

Гnава 3. Измерение nромзводмтеn ьностм

Если второе измерение дает значение, меньшее первого, эксперимент подтверждает
гипотезу.
Обратите внимание на примененную терминологию. Эксперимент не обязатель­
но до казывает что-либо вообще. Измененный код может выполняться быстрее или
медленнее по разным причинам, не имеющим ничего общего с внесенными вами из­
менениями, например:






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

Хорошие ученые всегда скептики. Они всегда подозрительны. Если ожидаемое
улучшение не наступает или проя вляе тся слишком хорошо, ч тобы это было правдой,
скептик ставит новые эксперименты, пересматривает свои исходные предположения
или ищет ошибки.
Хорошие ученые всегда открыт ы для новых знаний, даже если они противоречат
знаниям, уже имеющимся в их головах. В процессе написания этой книги я узнал не­
сколько неожиданных для себя вещей о оптимизации. Технические рецензенты тоже
узнали много нового. Хорошие ученые никогда не прекращают учиться!
И з и сто р и и о птими заци о нн ы х во й н

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

Я читал, как кто-то в Интернете говорил, что линейный поиск часто ока­
зывается быстрее потому, что лучше использует локальность кеша, чем би­
нарный поиск. Действительно, моя реализация линейного поиска должна
была иметь о тличную локальность кеша. Но э тот результат противоречил
всему опыту и всем почтенным книгам, посвященным производительнос­
ти поиска.
При более глубоком изучении вопроса я понял, что тестировал поиск на
таблицах только с несколькими ключевыми словами и использовал в качест­
ве искомых слова, которые в моих экспериментах гарантированно имелись
в таблице. Если таблица содержит восемь записей, линейный поиск в сред­
нем просматривает половину из них (четыре) перед возвратом результата.
П роведение э кс п ериме нтов

55

Бинарный поиск будет делить таблицу пополам три раза при каждом вы­
зове. Так что эти два алгоритма имеют одинаковую среднюю производи­
тельность на малых наборах ключевых слов. Эта реальность противоречи­
ла моей интуиции, которая искренне считала, что бинарный поиск будет
лучшим "всегда':
Но это был не тот результат, который я хотел продемонстрировать! Так что
я сделал таблицу побольше, рассчитывая, что должен быть некоторый раз­
мер, при котором бинарный поиск будет проходить быстрее. Я также до­
бавил к тесту несколько слов, отсутствующих в таблице. Результаты, как и
раньше, были в пользу линейного поиска. В этот раз мне пришлось отло­
жить эту задачу на пару дней, но результат не давал мне покоя.
Я все еще был уверен, что бинарный поиск должен быть быстрее. Я пе­
ресмотрел модульные тесты для обоих поисков, как оказалось, для тоrо,
чтобы обнаружить, что линейный поиск всегда возвращает успешный ре­
зультат после первого же сравнения. Мой тест проверял не корректный
возврат, а просто ненулевое значение. Обзывая себя последними словами,
я исправил код линейного поиска и тестов. Эксперимент немедленно под­
твердил ожидаемый результат: бинарный поиск быстрее линейного.
В этом случае экспериментальные результаты сначала отвергли, но позже
подтвердили мою гипотезу, хотя и бросили попутно вызов моим представ­
лениям о поиске.

В еден и е я абораторно rо журна я а
Хорошие оптимизаторы (как и все хорошие ученые) заинтересованы в повторя­
емости результатов. Здесь в игру вступает такая вещь, как лабораторный журнал.
Каждый хронометраж начинается с гипотезы, одной или нескольких настроек кода
и набора входных данных, а заканчивается ничем не примечательным числом мил­
лисекунд. Не так уж трудно запомнить время, которое показал предыдущий запуск,
чтобы сравнить его со следующим. Пока каждое изменение кода оказывается успеш­
ным, этого достаточно.
Но в конце концов разработчик где-то поступит неправильно, и время последне­
го запуска оказывается хуже времени предыдущего запуска. Перед разработчиком
будет стоять масса вопросов. Был ли запуск № 5 быстрее запуска No 3, несмотря на
то что он оказался медленнее запуска No 4? Какое изменение кода было выполнено
перед запуском N!! 3? Разница в скорости в пределах погрешности или программа
действительно работает быстрее?
Если каждый э ксперимент з адокумент ирован, ответить на такие вопросы не­
сложно. Документирование делает ответы на эти вопросы тривиальными. В против­
ном случае разработчик должен вернуться и повторно выполнить предыдущий экс­
перимент, чтобы получить интересующее время работы, т.е., конечно, если он может

56

Гл ава 3. Измерение n роизводитеn ьности

точно вспомнить, какие изменения кода были сделаны. Если выполнение програм ­
мы - несложная задача, а память у разработчика хорошая, то ему повезло и он мо­
жет затратить лишь небольшое время. Но так может и не повезти, и будет потеряно
перспективное направление работы или просто рабочий день.
Когда я даю этот совет, всегда находится кто-то, кто высокомерно заявляет "Я могу
сделать это без бумаги! Я могу написать сценарий Perl, который будет изменять ко­
манду foo с помощью инструмента Smart-foo так, чтобы сохранять результаты каж­
дого хронометража с набором выполненных изменений. Если я сохраню тестовые
u
Е ели я выполню тест в каталоге, то ..."
результаты в ф аил...
Я не хочу препятствовать инновациям разработчиков программного обеспечения.
Если вы старший менеджер, который полагает этот способ наилучшим, поступайте
как знаете. Замечу, однако, что письмо на бумаге является надежным и простым в ис­
пользовании методом, проверенным тысячелетиями. Он будет работать, если команда
обновит систему управления версиями или систему тестирования. Он будет работать
и при выполнении нового задания разработчика. Это древнее решение может рабо­
тать и сегодня и, вероятно, будет так же хорошо служить разработчикам завтра.
Измерение базовой производите11ьности и постанов ка це11ей
Для разработчиков, работающих в одиночку в собственное рабочее время, про­
цесс оптимизации может быть случайным, выполняемым до тех пор, пока произ­
водительность не станет "выглядеть достаточно хорошей". Однако разработчики,
работающие в группах, имеют руководство и должны удовлетворять заинтересо­
ванные стороны. Усилия по оптимизации руководствуются двумя показателями:
базовой производительностью до оптимизации и целевой производительностью.
Базовая производительность важна не только для измерения успеха отдельных
улучшений, но и для обоснования стоимости усилий по оптимизации для заинтере­
сованных сторон.
Целевая производительность важна, поскольку оптимизация представляет со­
бой процесс с убывающей отдачей. Изначально есть, так сказать, "низко висящие
плоды'', которые легко достать - просто протянув руку: отдельные процессы или
наивно закодированные функции, которые обеспечивают большие возможности
повышения производительности при оптимизации. Но как только эти улучшения
выполнены, для каждого последующего шага оптимизации требуется все больше и
больше усилий.
Многие команды изначально не думают об установке целевых производительнос­
ти и гибкости просто потому, что не привыкли делать это. К счастью, низкая произ­
водительность обычно очевидна (пользовательский интерфейс с длинными перио­
дами "зависания� сервер, который не в состоянии справляться с высокими нагруз­
ками, чрезмерные расходы процессорного времени и др.). После того как команда
обратит свое внимание на производительность приложения, будет не так уж слож­
но определить количественные цели. Имеется целая наука о том, как пользователи
воспринимают время ожидания. Для начала взгляните на следующий список часто
встречающихся аспектов производительности, а также на критерии, которые говорят
о наличии проблем.
П р оведен11е з ксп ер11ментов

57

Время запуска
Время, которое проходит от нажатия клавиши до момента, когда
программа входит в свой основной цикл обработки сообщений. Часто, хотя
и не всегда, разработчик может просто измерить время, проходя щее между
входом в функцию ma i n ( ) и входом в основной цикл. Производители опера­
ционных систем, которые предлагают выполнение серти ф икации программ,
имеют строгие требования в отношении программ, которые запускаются при
запуске компьютера или при каждом входе пользователя в систему. Например,
Microsoft требует от поставщиков аппаратного обеспечения, чтобы оболочка
Windows входила в свой основной цикл менее чем за 10 секунд после запуска.
Это ограничивает количество программ, которые производителю позволено
предварительно загружать и запускать в занятой запуском среде. Корпорация
Майкрософт предлагает специализированные и нструменты для измерения
времени запуска.
Время выключения
Время, которое проходит с момента, когда пользователь щелкает на пикто­
грамме Close или вводит команду выхода, до фактического завершения работы
процесса. Часто, хотя и не всегда, можно просто измерить время, проходящее
между получением главным окном команды завершения и фактическим выхо­
дом из ma i n ( ) . Время завершения работы включает также время, необходимое
для остановки всех потоков и зависимых процессов. Производители операци­
онных систем, которые предлагают выполнение сертификации программ, име­
ют строгие требования в отношении времени выключения программ. Время
завершения работы имеет также важное значение потому, что время, необхо­
димое для перезапуска службы или длительно работающей программы,равно
суммарному времени ее выключени я и запуска.
Время отклика
Среднее или наихудшее время, необходимое для выполнения команды. Для
веб-сайтов среднее и наихудшее время отклика вносит существенный вклад в
удовлетворенность пользователей сайтом. Время отклика можно грубо разде­
лить на следующие диапазоны.
Менее О, 1 с: непосредственное управление со стороны пользователя
Если время отклика составляет менее О, 1 с, пользователи чувствуют себя
так, как будто они непосредственно управляют пользовательским интер­
фейсом а изменения пользовательского интерфейса вызываются непо­
средственно их действи я ми. Это максимальная задержка между моментом,
когда пользователь начинает перетаскивать объект, и моментом, когда этот
объект приходит в движение, или между щелчком пользователя на поле и
его подсветкой. Пользователь чувствует себя так, как будто он выдает ко­
манду, которую компьютер выполняет мгновенно.
,

58

Гпа ва 3 . И змер ен ие п роизводитеп ьности

От О, 1 до 1 с: пользователь управляет командами
Если время отклика составляет от 0, 1 до 1 с, пользователи чувствуют, что
они руководят компьютером, рассматривая краткую задержку как выпол­
нение компьютером команды, приводящей к изменению пользовательско­
го интерфейса. Пользователи могут терпеть такую задержку без рассеяния
своего внимания и отвлечения мыслей от происходящего в компьютере.
От 1 до 1 О с: потеря управления
Если время отклика - от 1 до 1 0 с, то пользователям кажется, что, выпол­
нив команду, они потеряли управление над компьютером, в то время как
он обрабатывает команду. Пользователи могут потерять концентрацию и
забыть находящуюся в краткосрочной памяти информацию, которая необ­
ходима им для выполнения задачи; 10 с - максимальное время, которое
пользователь еще может удерживать концентрацию. Удовлетворенность
пользователей интерфейсом с таким временем отклика очень быстро схо­
дит на нет.
Более 1 0 с: пора попить ко фе
Если время отклика превышает 1 0 с, пользователи начинают считать, что
у них достаточно времени, чтобы заняться некоторыми другими задачами.
Если их работа требует использовать пользовательский интерфейс, они ста­
нут пить кофе, пока компьютер перемалывает данные. Если пользователь
может себе это позволить, он просто закроет программу и пойдет искать
удовлетворение в другом месте.
Якоб Нильсен (Jakob Nielsen) написал интересную статью ( h t t p : / /b i t . l y /
powe r s - 1 0 ) о масштабах времени в пользовательском и нтерфейсе, которая
указывает на крайне любопытные научные исследования.

Пропус к ная способность
Значение, обратное времени отклика. Пропускная способность в общем случае
выражается как среднее число операций за единицу времени при некоторой
тестовой рабочей нагрузке. Пропускная способность, по сути, измеряет то же,
что и время отклика, но является более подходящей для программ, ориенти ­
рованных на пакетную работу, таких как базы данных и веб-службы. Как пра­
вило, желательно, чтобы это число было настолько большим, насколько это
возможно.
С оптимизацией можно и переборщить. Например, во многих случаях пользова­
тели рассматривают время отклика менее 0, 1 с как мгновенный отклик. В такой си­
туации уменьшение времени отклика от 0, 1 с до 1 мс практически не имеет никакого
значени я , пусть это время отклика и в 1 00 раз меньше.

П ро веден ие э кс пери ме нтов

59

Уnучwить можно тоnыо измеряемое
Оптимизация одной функции, подсистемы, задачи или контрольного примера
никогда не является такой же, как повышение производительности всей програм­
мы. Настройка тестовой версии во многих отношениях отличается от работы над
окончательной версией программы, работающей с данными пользователя, так что
повышение производительности, измеренное при тестировании, редко соответствует
улучшению производительности в реальных условиях. Ускорение выполнения одной
задачи может не повлиять на скорость работы всей программы в целом, даже если
задача составляет большую часть логики программы.
Например, если разработчик базы данных профилирует выполнение конкретного
запроса выборки из базы данных 1 ООО раз и выполняет оптимизацию на основе этих
данных, это ускоряет не всю базу данных, а только выполнение ею конкретного за­
проса. Такое ускорение может также повысить производительность других запросов.
Но оно будет иметь менее предсказуемое влияние на запросы удаления или обновле­
ния, индексирование и прочие действия базы данных.

П ро ф и n и рование вы п оn нен ия п ро r ра ммы
Профайлер
это программа, которая генерирует статистические данные о том,
как и на что другая программа тратит свое время работы. Профайлер создает отчет,
показывающий частоту выполнения каждой инструкции или функции и суммарное
время выполнения каждой функции.
Многие компиляторы, включая Visual Studio в Windows и GCC в Linux, постав­
ляются с профайлером, который помогает обнаружить узкие места в программе.
Исторически Майкрософт предлагает профайлер только в дорогостоящих версиях
Visual Studio, но Visual Studio 20 1 5 Community Edition поставляется с профайлером.
Имеются и иные профайлеры с открытым исходным кодом для Windows, для более
ранних версий Visual Studio.
Имеется несколько способов реализации профайлера. Один из методов, исполь­
зуемый и в Windows, и в Linux, работает следующим образом.
-

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

60

Гnава 3. Измерение nроизводитеn ьности

Другой метод профилирования работает следующим образом.
1 . Оснащение программы измерительным кодом происходит при компоновке не­
модифицированной программы с профи лирующей библиотекой. Эта библиоте­
ка содержит подпрограммы, которые с большой частотой прерывают выполне­
ние программы и записывают значение указателя команд.
2. При каждом запуске такой программы ею на диске создается файл с таблицей с
соответствующей информацией.
3. Профайлер получает эту таблицу в качестве своих входных данных и генериру­
ет ряд текстовых и графических отчетов.
Выходные данные профайлера могут принимать различные формы. Одной из них
является листинг исходного текста, аннотированный количеством выполнений каж­
дой строки. Другой я вляется список имен функций вместе с количеством вызовов
каждой из них. Еще один вариант представл я ет собой такой же список функций с
общим временем работы каждой функции и всех функций, вызванных из нее. Воз­
можен вариант списка функций со временем работы каждой функции минус время
работы вызванных из них функций, системного кода или оЖ идания событий.
Средства профилирования тщательно разработаны, чтобы быть настолько не­
дорогими, насколько это возможно. Влияние профайлера на общее время выпол­
нения программы невелико и обычно составляет несколько процентов замедления
для каждой операции. Первый метод дает точные цифры за счет более высоких
накладных расходов и отключения некоторых оптимизаций. Второй метод дает
приблизительные результаты и может пропустить несколько редко вызываемых
функций, но имеет преимущество возможности работы с окончательной версией
программы.
Наиболее важным преимуществом профайлера является то, что он непосредс­
твенно выводит список "горячих функций" кода. Процесс оптимизации сводится
к получению списка функций, его исследованию и проверке возможности оптими­
зации каждой функции, внесению изменений и повторному выполнению кода для
получения новых выходных данных профайлера, пока в программе не останется от ­
кровенно узких мест или пока вы не исчерпаете все идеи по оптимизации кода. Пос­
кольку обнаруживаемые профайлером узкие места - это по определению места, где
происходит много вычислений, такой процесс в целом прост.
Мой опыт работы с профилированием говорит о том, что профилирование от­
ладочной версии программы дает результат ы, которые оказываютс я такими же ак­
туальными, как и полученные для окончательной версии программы. В некотором
смысле отладочную версию проще профилировать, потому что она включает все
функции, в том числе и встраиваемые, в то время как окончательная версия скрыва­
ет от профилирования очень часто вызываемые встроенные функции.

П р офиn и р ов а н ие вы nоn не н и я n р оr раммы

61

Со вет от n р оф есси о напа

Одна из проблем, связанных с профилированием отладочной версии в
Windows, заключается в том, что отладочная сборка компонуется с от­
ладочной версией библиотеки времени выполнения. Отладочная версия
функций диспетчера памяти выполняет множество дополнительных про­
верок, чтобы позволять сообщать о таких событиях, как повторное ос­
вобождение памяти или утечки памяти. Стоимость этих дополнитель­
ных проверок может значительно увеличить стоимость некоторых фун­
кций. Имеется переменная окружения, которая требует от отладчика не
использовать отладочную версию диспетчера памяти. Воспользуйтесь
управляющим элементом Панель управления Q Система Q Дополнитель­
ные параметры системы Q Переменные среды . . . Q Системные переменные
(Control PaneJ Q Syste m Properties QAdvanced System Settin g s Q Environ ment

в англоязычной версии Windows) и добавьте но­
вую переменную с именем _NО_ DЕВU G_НЕА Р со значением 1 .

VariaЫes Q System VariaЫes

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






62

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

Гnа ва 3 . И зме рен и е nроизводитеn ьности

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

Дn итеnы о ра б ота ющий код
Если программа выполняет одну задачу, которая в основном сосредоточена на
вычислениях, профилирование автоматически покажет вам ее узкие места. Но если
программа делает мноrо разных вещей, никакая функция не будет резко выделять­
ся профайлером. Программа может также проводить много времени в ожидании
ввода-вывода или внешних событий, что снижает ее общую производительность,
измеряемую как общее время работы. В этих случаях нужно хронометрировать от­
дельные части программы, а затем пытаться сокращать время выполнения медлен­
ных частей.
Разработчик использует хронометраж для поиска горячих точек программы, пос­
ледовательно сужая исследуемую часть продолжительной задачи до тех пор, пока
один из разделов не станет отнимать слишком большое время. Выявив подозритель­
ный раздел кода, разработчик проводит эксперименты с небольшими подсистемами
или отдельными функциями.
Хронометраж выполнения - эффективный способ проверки гипотез о том, как
уменьшить стоимость конкретной функции.
Не так сложно сообразить, что компьютер легко может быть запрограммирован
для работы в качестве секундомера. Вероятно, ваш телефон или ноутбук и так бу­
дит вас по утрам в будние дни или напоминает за 5 минут до назначенного важного
звонка. Измерение субмикросекундных интервалов времени выполнения функций
в современных компьютерах оказывается более сложным, в частности, потому, что
распространенные платформы Wiпdows/PC исторически имели проблемы с предо­
ставлением часов с высоким разрешением, которые вели бы себя согласованно в раз­
личных аппаратных моделях и версиях программного обеспечения.
Как разработчик вы должны быть готовы к созданию собственного программно­
го секундомера, понимая, что он может измениться в будущем. Для этого рассмот­
рим, как измеряется время и какие инструменты поддерживают измерения времени
на компьютерах.

Дnит еn ьно работа ющи й к од

63

"П оnузна й с т во " о б и змерени и времен и
И полузнайство ложь в себе таит •.
- Александр Поуп (Alexander Роре), "Опыт о критике�

1 774

Идеальное измерение каждый раз должно точно указывать размер, вес или, в слу­
чае этой книги, продолжительность измеренного я вления . Сделать идеальное изме­
рение - это все равно что лучнику раз за разом попадать точно в центр мишени,
раскалывая своей стрелой попавшую туда ранее. Такая стрельба из лука происходит
только в легендах, и то же самое можно сказать и о точном измерении.
Реальные измерения (как и реальные лучники) должны бороться с погрешностями:
источниками ошибок, которые не позволяют достичь совершенства. Погрешность мо­
жет быть случайной и систематической. Случайные погрешности влияют на каждое
измерение по-разному, как порыв ветра, который заставляет конкретную стрелу не­
много отклониться в полете. Систематические погрешности влияют на все измерения
аналогичным образом, как, например, поза лучника может влиять на каждый его вы­
стрел, заставляя стрелу немного отклоняться, скажем, влево от предполагаемой цели.
Сами погрешности тоже могут быть измерены. Сводные показатели погрешности
явл яются свойствами измерения под названием прецизионность (precision) и истин­
ность (trueness). Вместе эти свойства формируют одно интуитивное свойство, назы­
ваемое точностью (accuracy).
Прецизионность, истинность и точность

Ученые, которых интересуют измерения, часто спорят об используемой терми­
нологии - достаточно посмотреть на слово "точность" в Википедии. Я выбрал для
разъя снений термины из стандарта ISO 5725- 1 1 994 года (http : / /Ьi t . l y / i s o- 5 7 2 5 1 ),
"Ассиrасу (trиeпess апd precisioп) of теаsиrете пt тethods апd resиlts - Part 1 : Geпeral
priпciples апd defiпitioпs".
Измерение является прецизионным, если оно свободно от случайной погрешнос­
ти. То есть, если многократно измеря ется одно и то же явление и измеренные зна­
чения находятся близко одно к другому, такое измерение является прецизионным.
Однако прецизионные измерения по-прежнему могут содержать систематические
погрешности. Лучник, который попадает несколькими выстрелами в одно и то же
место, очень прецизионный2, даже если и не очень точный. Его мишень может вы­
глядеть так, как показано на рис. 3.2.
Если я измеряю некоторое значение (скажем, время выполнения функции) 1 0 раз и
получаю один и тот же результат все 1 О раз, можно предположить, что мое измерение
является прецизионным. (Как и при любом эксперименте, я должен оставаться скепти­
чески настроенным до тех пор, пока у меня не накопится много доказательств моей ги­
потезы.) Если же я получил шесть раз один результат, а три раза - несколько отличный
от него и еще раз - совсем иной результат, измерение явля ется менее прецизионным.

1 Перевод А. Субботина.
2 Что касается стрельбы, то в русском языке для этого явления имеется свой терми н - "кучность·:

Примеч. пер.

64

Гл а в а 3. Изм ере ние п роизводитепьности

-

Рис. 3.2. Кучн ая (хотя и неточн ая)
стрель ба
И змерение являе тся ист инным, если оно свободно о т сис темат ической погреш­
нос ти. То ес т ь, если многократно измерять одно и то же я в ление и среднее в сех ре­
зультатов множес тва измер ений оказывае тся близким к фактической измеряемой ве­
личине, то можно вери ть, ч то измерение является ис т инным. Отдельны е измер е ния
могут быть затронуты случайными погрешностя ми и оказы в аться ближе или дальше
от фактического значен ия. П равильнос т ь не я вляе тся вознаграждаемым навыком
при стрельбе из лука. На рис. 3.3 среднее че тырех выст релов было бы в я блочко, если
бы была такая с т рела. Кроме то го, все э т и выс т релы имеют одну и т у же точнос т ь
(расс тояние от цен тра) в единицах колец.

Рис. 3 . 3 . Истинная стрель ба
Точность измерени я я в ля ется неофициальной концепцией, которая зависи т о т
насколько каждое индивидуальное измерение оказы в ается близким к факти­
ого,
т
ческому измеряемому значению. Расс тоя ние о т фактического значения включае т как
случайную, т ак и сис темат ическую по г решнос т ь. Ч то б ы б ы т ь точным, изм е р е ни е
должно б ыть и прецизи о нн ым, и истинным.

Дnмтеn ьно работа 1О щ мй код

65

И зм ере ни е вре м е ни

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






3

В солнечных часах используется периодическое вращение Земли. По определе­
нию один полный оборот Земли составляет один день. Земля является несо­
вершенными часами потому, что их период очень продолжительный, а также
потому, что скорость вращения Земли несколько варьируется (на уровне мик­
росекунд) в связи с дрейфом континентов на всей ее поверхности. Эта вариа­
ция скорости является случайной. Приливные силы Солнца и Луны замедляют
общую скорость вращения Земли. Эта вариация скорости является системати­
ческой.
Дедушкины часы подсчитывают количество регулярных качаний маятника.
Шестерни преобразуют качание маятника в движение стрелок, которые отоб­
ражают время. Период м ая тн и к а может быть скорректирован вручную таким
образом, чтобы отображаемое время было синхронизировано с вращением
Земли. Период колебаний маятника зависит от его длины, так что каждый
маятник может оказаться быстрее или медленнее, чем требуется . Такое от­
клонение является систематическим. Трение, давление воздуха и накопление
пыли - все может повлиять на маятник, даже если он изначально отлично
работал. Эти факторы являются источниками случайн ых отклонений.
В электрических часах используется периодическая, с частотой 50 Гц ( 60 Гц
в США), синусоидальная волна переменного тока для привода синхронного
двигателя. Шестерни переводят эти колебания в электросети в движение стре­
лок для отображения времени. Электрические часы не являются идеальными,
поскольку 50 Гц - это только соглашение, а не фундаментальный закон при­
роды. При интенсивном использовании электроэнергии в часы пиковых на­
грузок частота может падать3• Это отклонение является случайным. ЭлектриИменно потому что н а частоте 50 Гц работало очень много часов по стране, в СССР отслеживаJ1ось

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

66

- Пр имеч. 11ер.

Гл ава 3. И змерен ие производите л ьности



ческие часы, созданные для работы в США, будут работать медленнее, будучи
подключенными к розетке 50 Гц в Европе (такое отклонение носит системати­
ческий характер).
В цифровых наручных часах используются колебания кристалла кварца. Ло­
гическая схема управляет дисплеем на основании подсчета этих колебаний.
Частота колебаний кристалла зависит от его размера, температуры и прило­
женного напряжения. Влияние размера кристалла является систематическим
отклонением, в то время как изменения температуры и напряжения являются
случайными.

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

Ра зре ш ение измерени й
Разрешением измерения является размер единиц, в которых представлены резуль­
таты измерений.
Лучник, стреляющий в цель, получает одинаковое количество очков при попадании
в любую точку одного круга. Центр мишени является не бесконечно малой точкой,
а кругом определенного диаметра (рис. 3.4). Стрела попадает в центр, первое коль­
цо, второе кольцо и т.д. Ширина кольца и является разрешением оценки попадания.

Рис. 3.4. Разрешение: попадание в любую точку
круга мишен и д ает о д инаковое количество очков
Разрешение измерения времени ограничено продолжительностью лежащих в ос­
нове измерения периодических событий. Измерение времени может дать один такт
или два такта, но не некоторое промежуточное значение. Таким образом, разреше­
нием часов является период между их тактами.
Наблюдатель может различать события, происходящие между двумя тактами
медленных часов, таких как маятниковые. Это оз н ачает, что у людей в головах име­
ются более быстрые (но менее точные) часы, которые они неформально сравнивают
Дnмтеn ьно работающий код

67

с маятниковыми. Но если наблюдатель планирует измерять такие малые и незамет­
ные для человеческого восприятия длительности, как миллисекунды, необходимо
пользоваться только тактами часов с высоким разрешением.
Нет явно выраженной связи между точностью измерения и разрешением, с кото­
рым представлены его результаты. В моей повседневной деятельности я могу сказать,
что мне потребовалось два дня, чтобы написать этот раздел книги. Полезным разре­
шением данного измерения является один день. Я мог бы преобразовать это время
в секунды и сказать, что мне потребовалось 1 72 800 секунд. Но если только у меня в
руках не было реально отсчитывающего время секундомера, приведенное значение
в секундах может дать ложное чувство того, что измерение было более точным, чем
на самом деле, или ложное впечатление, что я все это время не ел и не спал, а только
работал за клавиатурой.
Результаты измерения могут быть приведены в единицах, меньших значения по­
лезного разрешения, поскольку эти единицы являются стандартными. У меня есть
духовка, которая отображает температуру в ней в градусах Цельсия. Однако разре­
шение термостата, который управляет духовкой, имеет полезное разрешение 5°С, так
что при нагреве духовки на дисплее появляются значения 1 50, 1 55, 1 60°С и т.д. Имеет
смысл отображать значение температуры в знакомых общепринятых единицах вмес­
то того, чтобы вводить единицы для данного термостата. Это просто означает, что
наименьшая значащая ци фра измерения может принимать значения только О и 5.
Читатель может быть удивлен и разочарован, узнав реальные разрешения многих
недорогих термометров, весов и других измерительных приборов, имеющих на экра­
не разрешение в одну стандартную единицу или даже в одну десятую ее часть.
И зм ерен и е с п о мощ ью неск оnы их ча со в

Человек с одними часами всегда зна ет, который час. Человек с двумя ча­
сами никогда не уверен полностью.
- Чаще всего приписывают Ли Сегаллу (Lee Segall)
Когда два события происходят в одном месте, время между ними легко измерить
в тактах одних часов. Когда два события происходят на большом расстоянии одно
от другого, может потребоваться использовать двое часов. Количество тактов между
различными часами нельзя сравнивать непосредственно.
Человечество подошло к решению этой проблемы путем синхронизации часов с
помощью универсального глобального времени (Coordinated Universal Time). Все­
общее скоординированное время синхронизируется с астрономической полночью
на долготе О
произвольной линии, которая проходит через Королевскую обсер­
ваторию в Гринвиче, Англия (рис. 3.5). Это позволяет время в тактах п реобразо­
вывать во время в часах, минутах и секундах после полуночи UTC (Universal Time
Coordinated
всеобщее скоординированное время; неуклюжее сокращение, являю­
щееся результатом переговоров между французскими и английскими учеными).
Если двое часов идеально синхронизированы с UTC, то UТС-время одних часов
можно непосредственно сравнивать с показаниями других. Но, конечно, идеальная
-

-

68

Гn ава 3. И змерен ие n рои зводитеn ь ности

синхронизация не представляется возможной. Любые часы содержат независимые
источники отклонений, которые приведут к расхождению их показаний с UTC и од­
них с друrими.

Рис. 3 . 5. Нулево й меридиа н в Королев ской о бсерватории в Гринвиче

Измерение времен и с помо щ ь ю компь ютеров
Для создания часов на компьютере необходимы источник периодических сиr­
налов - предпочти тельно с хорошей прецизионнос т ью и ис т инностью - и спо­
соб проrраммноrо получения тактов из этоrо источника. Можно леrко разработат ь
специализированный компьютер, предназначенный для сообщения информации о
времени. Однако при разработке наиболее популярных современных компьютерных
архитектур никто особо не задумывался о том, как обеспечит ь хорошие часы. Я про­
иллюстрирую имеющиеся проблемы с использованием архитект уры РС и Microsoft
Windows. Проблемы у Linux и встраиваемых платформ аналоrичны.
Кристаллический rенератор в сердце компьютерных часов имеет типичную ба­
зовую точность около 0,0 1 %, или около 8 с в день. Хо тя эт а точнос т ь только не­
мноrим лучше точности дешевых цифровых наручных часов, ее более чем доста­
точно для выполнения измерений производительности, для которых вполне rодят­
ся результаты с точностью до нескольких процентов. Хотя схемы часов в дешевых
Дnитеnьно работа ющи й код

69

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

Первоначально в IBM РС вовсе не было каких-либо аппаратных счетчиков тактов.
В них имелись обычные часы, предоставляющие текущее время дня, которое моrло
быть считано программно. Ранние библиотеки времени выполнения Microsoft С ко­
пировали библиотеку ANSI С, предоставляющую фун кц и ю t ime_t t ime ( t ime_t * ) ,
которая возвращает количество секунд, прошедших с момента 00:00 1 января
1 970 года UTC. Оригинал ь ная версия t irne ( ) возвращала 32-битное целое число со
знаком, но во время подготовки к решению проблемы 2000 года оно было изменено
на 64-разрядное знаковое целое число.
Оригинальный компьютер IBM РС использовал периодическое прерывание от
блока питания для выполнения переключения задач и иных операций ядра. Период
этих тактов составлял 1 6,67 мс в США, поскольку частота переменного тока в США
составляет 60 Гц, и 10 мс там, где частота переменного тока равна 50 Гц.
Начиная с Windows 98 (возможно, и ранее) Microsoft С предоставляет функцию
ANSI С c l o c k_ t c l o c k ( ) , которая возвращает значение счетчика тактов в знаковом
формате. Константа с1оскs_ PER_ SEC указывает количество тактов в с е кунду Ее зна­
чение - 1 означает, что функция c l o c k ( ) не поддерживается. Первоначально эта фун­
кция сообщала о тактах, основанных на периодических прерываниях от переменно­
го тока. Функция clock ( ) , реализованная в Windows, отличается от спецификации
ANSI, измеряя прошедшее время, а не затраченное процессорное время. Функция
c l o c k ( ) была недавно реализована заново на основе Get S y s t ernT irneAs Fi l e T ime ( ) а
в 20 1 5 году она возвращала миллисекундные такты с разрешением 1 мс, что делает ее
хорошими миллисекундными часами в Windows.
Начиная с Windows 2000 программный счетчик тактов, основанный на прерывани­
ях электропитания, был сделан доступным с помощью вызова DWORD Get T i c kCount ( ) .
Такты, подсчитываемые с помощ ь ю GetTickCount ( ) , зависят от аппаратного обеспе­
чения компьютера и могут быть значительно длиннее одной миллисекунды. Функция
Ge tTi c kCount ( ) выполняет вычисление для п р е обр азо в ан и я тактов в милли с екунды,
чтобы частично устранить эту неопределенность. Обновленный вариант этого вы­
зова ULONGLONG Ge t T i c kCoun t 6 4 ( ) , возвращает тот же с четчик тактов как 64-битное
беззнаковое целое число, позволяющее и з мерять более длительные интервалы. Хотя
нет никакого способа для получения текущего периода прерываний, есть пара функ­
ций, которые уменьшают период, а затем восстанавливают его:
ММRESULT t irneBeginPeriod ( UINT )
.

,

ММRESULT t imeEndPer i od ( U INT )

Эти функции воздействуют на гло ба ль н ую переменную, влияющую на все про­
цессы и многие другие фун кц ии , такие как S l eep ( ) , зависящие от прерывания пе­
ременного тока. Другой вызов, DWORD t irneGe t T irne ( ) , как представляется, получает
значение того же счетчика тактов другим методом.

70

Гnава 3 . И змере н ие n р о11 звод11теn ьност11

Начиная с архитектуры Pentium, компания Intel предоставила аппаратный регистр
под названием "Счетчик штампа времени" (Time Stamp Counter - TSC). Это 64-бит­
ный регистр, который подсчитывает такты часов процессора. Этот счетчик можно
очень быстро получить с помощью машинной команды RDT S C. Начиная с Windows
2000, счетчик можно прочитать путем вызова функции BOOL Q u e r y Pe r fo rm a n c e
Coun t e r ( LARGE_INTEGER * ) , которая дает значение счетчика тактов без конкретного
разрешения. Значение разрешения может быть получено с помощью вызова вооL
Que ryPe r fo rmance Frequ e n c y ( LARGE_I NTEGER* ) , который возвращает частоту в виде
количества тактов в секунду. LARGE_ I NTEGER представляет собой структуру, которая
хранит 64-битное целочисленное значение в знаковом формате, поскольку в момент
введения этой функции Visual Studio еще не имела типа для 64-битных знаковых це­
лочисленных значений.
Проблема с первоначальной версией Que ryPe r fo rma nceCount e r ( ) заключалась в
том, что частота тактов зависит от часов процессора, а они разные у разных процес­
соров и материнских плат. Старые компьютеры, особенно с процессорами от AMD,
в то время не имели TSC. При отсутствии TSC функция Que ryPe r fo rmanceCount e r ( )
фактически возвращалась к низкому разрешению GetT i c kCount ( ) .
В Windows 2000 была также добавлена функция vo i d G e t S y s t emT imeAs Fi l eT ime
( F I L E T I ME * ) , которая возвращает количество 1 00- наносекундных тактов, прошед­
ших с 00:00 1 января 1 60 1 года UTC. F I LE T I ME представляет собой структуру, кото­
рая хранит 64 бита целого числа, на этот раз в беззнаковом формате. Несмотря на
кажущееся очень высокое разрешение счетчика тактов, некоторые реализации ис­
пользуют тот же медленный счетчик, что и в GetT i c kCount ( ) .
Вскоре выявились и другие проблемы, связанные с Que ryPe r fo rma nceCounter ( ) .
Некоторые процессоры реализованы с переменной тактовой частотой для снижения
энергопотребления. Это привело к изменению периода между тактами. В многопро­
цессорных системах с использованием нескольких дискретных процессоров значе­
ние, возвращаемое Que r y P e r formanceCou n t e r ( ) , зависит от того, на каком процессо­
ре выполняется данный поток. Процессоры также стали реализовывать возможнос­
ти переупорядочения команд, так что команда RDTSC может оказаться отложенной,
тем самым уменьшая точность программного обеспечения, использующего TSC.
Для решения этих проблем Windows Vista использует для Que r y Pe r fo rm a n c e
Coun t e r ( ) другой счетчик, обычно таймер ACPI. С помощью этого счетчика решает­
ся проблема хронометража в многопроцессорных системах, но значительно увеличи­
вается задержка. Тем временем Intel переделала TSC так, чтобы получать максималь­
ную, неизменяемую частоту часов. Intel также добавила непереупорядочиваемую
команду RDT S C P.
Начиная с Windows 8 становится доступным надежный аппаратный счетчик так­
тов с высоким разрешением на основе TSC. Функция vo i d Ge t S y s t emT ime P r e c i s e
As Fi leT ime ( F I LET I ME * ) обеспечивает такты с высокой разрешающей способностью
с фиксированной частотой и субмикросекундной точностью на любой системе под
управлением Windows 8 или более поздней версии.
Подытоживая этот урок по истории, можно резюмировать, что компьютеры ни­
когда не разрабатывались для функционирования в качестве часов, поэтому счетчики
Дnитеn ьно р абота�о щ и й код

71

тактов, которые они предоставляют, являются ненадежными. Если экстраполировать
последние 35 лет развития, то будущие процессоры и будущие операционные систе­
мы по-прежнему могут не позволять получить надежные, с высоким разрешением
счетчики тактов.
Единственный счетчик тактов, доступный в персональных компьютерах всех по­
колений, - это GetTickCount ( ) со всеми его недостатками. Лучше использовать так­
ты с периодом 1 мс, возвращаемые функцией c l o c k ( ) ; они должны быть доступны
во всех компьютерах, производимых в последние 10 лет или около того. Если огра­
ничиться только Windows 8 и более поздними версиями и новыми процессорами, то
очень точным является счетчик 1 00-наносекундных тактов от GetSys temT ime Prec i s e
As Fi l e T ime { ) . Однако мой опыт утверждает, что миллисекундной точности для про­
ведения экспериментов вполне достаточно.

Ц икn и ч@ски й воз в рат
Циклический возврат ( wraparound) - это то, что происходит, когда счетчик так­
тов часов достигает своего максимального значения, а затем переходит к нулевому
значению. Точно то же происходит в полдень и в полночь с двенадцатичасовыми ана­
логовыми часами. Wi ndows 98 "зависала" при непрерывной работе в течение 49 дней
(см. Q2 1 664 1 (http : / /Ь i t . l y /windows - 4 9)) из-за циклического возврата 32-битного
счетчика миллисекундных тактов. Ошибка 2000 года связана с циклическим возвра­
том времени, представленного в виде двух цифр года. Циклический возврат календа­
ря майя в 20 1 2 году был многими воспринят как сигнал о наступающем конце света.
В январе 2038 года произойдет циклический возврат эпохи Unix (знаковое 32-бит­
ное значение коли чества секунд, прошедших с 00:00 1 января 1 970 года по Гринви­
чу), возможно, приведя к фактическому концу света для некоторых долгоживущих
встраиваемых систем. Проблема циклического возврата заключается в отсутствии
дополнительных битов для записи следующего значения, так что в результате оче­
редное более позднее значение времени численно оказывается более ранним. Часы с
проблемой циклического возврата хорошо работают только при измерении длитель­
ности, которая меньше соответствующего интервала.
Например, в Windows функция GetT i c kCount ( ) возвращает счетчик тактов с раз­
решением 1 мс в виде 32-битного беззнакового целого числа. Циклический возврат
значения функции GetTickCount { ) происходит примерно каждые 49 дней. Функция
Ge t T i c kCount { ) может беспроблемно использоваться для хронометража операций,
продолжительность которых гораздо меньше, чем 49 дней. Если программа вызывает
GetT i c kCount { ) в начале и в конце операции, разница между возвращаемыми зна­
чениями может трактоваться как число миллисекунд, прошедших между вызовами,
например:
DWORD s ta r t
Get T i c kCount ( ) ;
=

DoBigTas k ( ) ;
DWORD end = GetTickCoun t ( ) ;

cout < < " Вьmолнение продолжается

"

< < end- s tart < < " мс " < < endl ;

Способ реализации беззнаковой арифметики в С++ дает корректный результат
даже при наличии циклического возврата.
72

Гл ава 3 . Измерение произ водител ь ности

Функция Ge t T i c kCount ( ) менее эффективна для запоминания времени после за­
пуска. Мноrие серверы с продолжительным временем работы моrут продолжать ра­
ботать месяцы или даже rоды. Проблема с циклическими возвратами заключается в
отсутствии битов для записи их количества, end- s t a r t может давать одно и то же
значение без циклических возвратов, с одним или с несколькими.
Начиная с Vista корпорация Майкрософт добавила функцию GetT i c kCoun t 6 4 ( ) ,
которая возвращает беззнаковое 64-битное количество тактов с разрешением 1 мс.
До циклическоrо возврата Ge t T i c kCoun t 6 4 ( ) должны пройти миллионы лет, что зна­
чительно снижает вероятность возникновения такой проблемы.

Разре ше ние - н е то же, что и точность
В Windows функция Get T i c kCount ( ) возвращает 32-битное беззнаковое целое
число. Если проrрамма вызывает Ge t T i c kCount ( ) в начале и в конце операции, раз­
ница между возвращаемыми значениями может быть истолкована как число мил­
лисекунд, прошедших между вызовами. Таким образом, выводимое разрешение
GetT i c kCount ( ) составляет 1 мс.
Например, следующий блок кода измеряет относительную производительность
произвольной функции под названием Foo ( ) в Windows путем вызова Foo ( ) в цикле.
Количества тактов, полученные в начале и конце цикла, дают время работы цикла в
миллисекундах:
DWORD start = GetTic kCount ( ) ;
for ( uns i gned i = О ; i < 1 0 0 0 ; + + i ) {
Foo ( ) ;
}

DWORD end = GetTic kCount ( ) ;
cout = Ох2 0 )
resul t += s [ i ] ;

return resu l t ;

Это маленькое изменение оказывает большое влияние на производительность.
Хронометраж при тех же исходных условиях дает среднее время одного вызова
1 ,72 мкс , или ускорение в 1 3 р а з. Источник этого улучшения - ликвидация всех вы­
зовов выделения памяти для временных строковых объектов, хранящих результат
конкатенации, и связанных с ними копирований и удалений временных значений.
В зависимости от реализации строки устраняются также выделение памяти и копи­
рование для выполнения присваивания.
Уменьwен ие работ ы с памя т ью с помощью резерв и р ова н ия

Функция remove_ctr l _mutating ( ) по-прежнему выполняет операцию удлинения
строки r e s u l t . Это означает, что r e s u l t периодически выполняет копирование во
внутренний динамический буфер большего размера. Как уже отмечалось, одна воз­
можная реализация std : : s tring удваивает объем памяти буфера при каждом выде­
лении. При такой реализации s t d : : s t r i n g перераспределение может выполниться
для 1 00 символов 8 раз.
Если исходить из строк, в основном состоящих из печатных символов, с малым
количеством удаляемых управляющих символов, то длина строки-аргумента s обес­
печивает отличную оценку возможной длины строки результата. В примере 4.3 вы­
полняется улучшение remove_ctr l _mut a t i ng ( ) - заранее выделяется место в памяти
с помощью функции-члена res e rve ( ) класса s td : : s t r i ng. Это не тол ько устраняет
необходимость перераспределения буфера строки, но и улучшает локальность кеша
данных, к которым обращается функция, так что мы получаем еще больший эффект
от такого изменения.
П ример 4.3. remove c trl reserve () : резервирование памяти

s td : : s tring remove c t r l reserve ( s td : : s t ring s )
std : : s t ring resu l t ; ­
resul t . reserve ( s . length ( ) ) ;

96

Гnава 4. Оnтимизация ис nоn ьзова н и я строк

{

for ( int i = O ; i < s . length ( ) ; + + i )
i f ( s [ i ] >= О х2 0 )
resu l t + = s [ i ] ;

{

return resul t ;

Устранение ряда выделений памяти приводит к значительному повыше­
нию производительности. Тестовое выполнение дает время выполнения вызова
remove_ ctrl _ r e s e rve ( ) , равное 1 ,47 мкс, т.е. повышение производительности на 1 5%
по сравнению с remove_ctrl_mutating ( ) .

Устранение ко пи р ов а ния ст р оково rо ар rумента
До сих пор я успешно оптимизировал remove _c t r l ( ) , удаляя вызовы диспетчера
памяти. Поэтому имеет смысл продолжить искать очередные распределения памяти
для удаления.
Если строковое выражение передается в функцию по значению, формальный ар­
гумент (в данном случае s ) конструируется с помощью копирования. В зависимости
от реализации строки это может привести к следующему копированию.






Если с т рока реализована с использованием идиомы копирования при записи,
то компилятор генерирует вызов копирующего конструктора, который выпол­
няет копирование указателя и увеличивает счетчик ссылок.
Если строка реализована с использованием собственного, не разделяемого бу­
фера, то копирующий конструктор должен выделить новый буфер и скопиро­
вать в него содержимое фактического аргумента.
Если компилятор реализует rvаluе-ссылки в стиле С++ 1 1 и семантику переме­
щения, то, если фактический аргумент является выражением, он будет rvalue,
так что компилятор сгенерирует вызовперемещающего конструктора, копируя
в результате указатель. Если фактическим аргументом является переменная, то
вызывается копирующий конструктор формального аргумента, что приводит
к выделению памяти и копированию. Семантика перемещения и rvаluе-ссылки
более подробно описаны в разделе "Реализация семантики перемещения" гла­
вы 6, "Оптимизация переменных в динамической памяти".

Функция remove_ct r l_re f_a r g s ( ) в примере 4.4 представляет собой усовер­
шенствованную функцию, которая никогда не копирует s при вызове. Посколь­
ку функция не изменяет s, нет причины делать отдельную копию s . Вместо этого
remove _ c t r l_re f_a r g s ( ) получает в качестве аргумента константную ссылку на s .
Это избавляет нас о т еще одного выделения памяти. Поскольку выделение памяти операция дорогостоящая, может иметь смысл отказ даже от одной такой операции.
Пр имер 4.4. remove ctrl ref args О : устранение коnи р ования а р rумента

std : : s t ring remove c t r l ref a rg s ( s td : : s tring cons t& s )
std : : s t r ing resu l t ; resul t . reserve ( s . length ( ) ) ;
for ( int i = O ; i < s . l ength ( ) ; + + i )

{

П ервая п о пытк а о пт и м и за ци и стр ок

97

i f ( s [ i ] >= Ох2 0 )
r e su l t + = s [ i ] ;

return r e sult ;
Результат удивителен - хронометраж показывает, что remove_ctr l_r e f_args ( )
требует 1 ,60 мкс на вызов, что на 8% хуже, чем у функции remove_ct rl _ rese rve ( ) .
Что же происходит? Visual Studio 20 1 0 копирует строковые значения при вызове,
поэтому данное изменение должно сэкономить выделение памяти. Либо эта эконо­
мия осталась не реализованной, либо что-то иное, связанное с превращением s из
строки в ссылку на строку, "съело" всю эту экономию.
Ссылки на перемен ные реализованы как указатели . Так что везде, где в
remove_ctrl_re f_args ( ) встречается s, программа выполняет разыменование указа­
теля, которое не требуется делать в r em o ve_ ctr l _r e s e r ve ( ) . Я предполагаю, что этой
дополнительной работы может быть достаточно для снижения производительности.

Устранение разыменований с помо щ ью итераторов
Решение заключается в использовании итераторов строки, как показано в приме­
ре 4.5. Итератор строки представляет собой простой указатель в буфер символов. По
сравнению с применением в цикле ссылки, использование итераторов должно эконо­
мить две операции разыменования.
Пример 4.5. remove ctrl ref arqs it ( ) : версия remove ctrl ref arqs ( )
_
_
_
_
_
_
_
с итераторами

s td : : s t r i ng remove c t rl re f args i t ( s td : : string const& s )
std : : s t r ing resu l t ; resul t . rese rve ( s . length ( ) ) ;
for ( auto i t=s . Ьegin ( ) , end=s . end ( ) ; i t ! = end ; ++it)
if ( * i t >= Ох20 )
resul t += * i t ;

{
{

return resul t ;

Хронометраж r emove_ctr l_r e f_a r g s_it ( ) дает удовлетворительный резуль­
тат в 1 ,04 мкс на вызов. Это, определенно, лучше, чем в версии без применения
итераторов. Но что можно сказать о превращении s в ссылку на строку? Что­
бы убедиться, что эта оптимизация действительно что-то дает, я написал версию
функции r emove_c t r l_r e s e r ve ( ) с использованием итераторов. Хронометраж
remove_ctr l_reserve_i t ( ) дал 1 ,26 мкс на вызов, что явно меньше l ,47 мкс. Исполь­
зование ссылки вместо значения, определенно, улучшило производительность.
На самом деле я использовал версии с итераторами для всех функций, производ­
ных от remove_ctrl ( ) . Итераторы во всех версиях давали выигрыш по сравнению с
применением индексов (однако в разделе "Вторая попытка оптимизации строк" да­
лее в главе вы увидите, что это не всегда так).

98

Гл ава 4. О птимизация испол ьзования строк

Функция remove_ctrl_ref_a rgs_it ( ) содержит еще одну оптимизацию, о кото­
р ой следует сказать. Значение s . end ( ) , используемое для управления циклом fo r ,
кешируется при инициализации цикла. Это сохраняет еще 2п косвенных обращений,
где п - длина строки-аргумента.
Уст ранение коп ирования возвращаемоrо значения

Исходная функция remove_ctrl ( ) возвращает результат по значению. С++ созда­
ет результат с помощью копирующего конструирования в вызывающем контексте,
хотя компилятор имеет право удалить это копирующее конструирование, если мо­
жет обойтись без него. Если мы хотим быть уверенными, что копирования нет, при­
мем к сведению, что есть несколько вариантов, как поступить. Один из вариантов,
который работает на всех версиях С++ и со всеми реализациями строк, должен воз­
вращать строку в качестве выходного параметра. Собственно, именно так и поступа­
ет компилятор при упомянутом выше устранении копирования. Улучшенная версия
remove_ c t r l _ ref_a r g s _i t ( ) приведена в примере 4.6.
Пример 4.6. remove_ctrl_ref_resul t_i t () : устранение

копирования возвращаемоrо значения

void remove-c t r l -re f-result- i t ( std : : string& resul t ,
std : : string const& s )
resul t . clear ( )

;
res u l t . reserve ( s . length ( ) ) ;
for ( auto i t=s . begin ( ) , end=s . end ( ) ; i t ! = end ; + + i t )
i f ( * i t >= Ох2 0 )
result + = * i t ;

{

Когда программа вызывает remove_ct r l_ref_re s u l t_i t ( ) , в качестве первого
аргумента r e s u l t передается ссылка на некоторую строковую переменную. Если
строковая переменная, на которую ссылается resul t , пустая, вызов reserve ( ) вы­
деляет память для достаточного количества символов. Если эта переменная исполь­
зовалась раньше (при вызове программой функции remove_ctrl _ ref_ resul t _ i t ( ) в
ц и к ле), буфер уже может быть достаточно большим, и в этом случае новое выделе­
ние не происходит. При возврате из функции строковая переменная в вызывающем
коде уже содержит возвращаемое значение, и копирование не требуется. Функция
remove_ctrl _ ref_ resul t_ i t ( ) красива тем, что во множестве случаев полностью ус­
траняет все выделения памяти.
Измеренная пр оиз в од ите л ьность r emove _ c t r l_r e f_r e s u l t_i t ( ) составляет
1,02 мкс на вызов, т.е. примерно на 2% быстрее, чем предыдущая версия.
Функция r emove_c t r l_re f_re s u l t_ i t ( ) оч е нь э ф фективна, но ее интер ф ейс
легко использовать неверно, чего не позволял и нтерфейс исходной функции
remove ctrl ( ) . Ссылка - даже константная - ведет себя не в точности так же,

П ервая п оп ытка о пт и м и за ц ии строк

99

как значение. Следующий вызов приведет к неожиданным результатам, возвращая
пустую строку:
s td : : s t r i ng foo ( " thi s is а s t ring " ) ;
remove_c t r l _ re f_ resu l t_i t ( foo , foo ) ;

Испоnьзов ание масси вов симвоn ов вместо строк
Когда от программы требуется максимальная производительность, можно отка­
заться от стандартной библиотеки С++ вообще и самому писать код на основе стро­
ковых функций в стиле С, как показано в примере 4.7. Строковые функции в стиле С
сложнее в использовании, чем s td : : s t r i ng, но выигрыш в производительности мо­
жет оказаться впечатляющим. Чтобы использовать эти функции, программист дол­
жен либо вручную выделять и освобождать буфера для символов, либо использовать
статические массивы с размерами, достаточными для наихудшего случая. Объявле­
ние статических массивов проблематично, в особенности при ограниченной памяти.
Однако, как правило, имеется возможность статически объявить большие времен­
ные буфера в локальной памяти (т.е. в стеке функции). Эти буфера освобождают­
ся очень дешево при выходе из выполняемой функции. За исключением наиболее
ограниченных встраиваемых сред, не проблема выделить тысячу или даже 1 О тысяч
символов в стеке для такого буфера.
Пример 4.7. remove ctrl cstrings ( ) : работа в С-стипе

vo id remove-c t r l -c s t r ings ( char * de s tp , cha r cons t * s rcp , s i ze t s i z e )
{
for ( s i ze t i=O ; i < s i ze ; + + i )
i f ( srcp [ i ] >= Ох2 0 )
* de s tp++ = srcp [ i ] ;
}
* de s tp = О ;

Функция remove_ctrl_cst rings ( ) требует всего лишь 0, 1 5 мкс на вызов. Это в 6
раз быстрее ее предшественницы и в невероятные 1 70 раз быстрее исходной функ­
ции. Одной из причин такого улучшения является ликвидация нескольких вызовов
функций с соответствующим улучшением локальности кеша.
Однако отличная локальность кеша может способствовать заблуждениям при
простых измерениях производительности. В общем случае другие операции между
вызовами remove_c t r l _c s t rings ( ) сбрасывают кеш. Но при вызове в непрерывном
цикле и инструкции, и данные остаются в кеше.
Другим фактором, влияющим на функцию remove_c t r l_c s t r ings ( ) , является
интерфейс, существенно отличный от интерфейса первоначальной функции. Если
эта функция вызывается из многих мест, изменение всех вызовов может потребо­
вать слишком значительных усилий. Тем не менее функция remove_ctrl_cstrings ( )
является примером того, насколько может быть повышена производительность, если
разработчик готов полностью перекодировать функцию и изменить ее интерфейс.

1 00

Гл а ва 4. О птимизация испо л ьзования строк

О ста н о в и сь и п одумай

Я думаю, по этому мосту мы можем зайт и слишком далеко.
Генерал - лейтенант Фридрих Браунинг (Frederick Browning)
-

( 1 896- 1 965)
Замечание, сделанное 1 0 сентября 1 944 года no поводу плана ф ельдмар­
шала Монтгомери по захвату союзниками моста в Арнеме. Замечание

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

Как отмечалось ранее, усилия по оптимизации могут привести в точку,
где дополнительная производительность добывается за счет простоты
и безопасности. Функция remove_c t r l_re f_re s u l t_ i t ( ) требует изме­
нений в сигнатуре функции, предоставляющих возможность потенци­
ального неправильного использования функции, которое не представ­
ляется возможным в случае функции r emove _ c t r l ( ) Оптимизация
remove _ ctrl _ c s t r i ng s ( ) получена ценой ручного управления временным
хранилищем. Для некоторых команд программистов это может оказаться
мостом, ведущим слишком далеко.
.

Разработчики имеют разные, иногда весьма упорно отстаиваемые, мнения
о том, оправдывает ли конкретное повышение производительности до­
полнительную сложность интерфейса или необходимость пересмотра ис­
пользования функции. Разработчики, которые выступают за оптимизацию
посредством возвращаемого параметра, могут заявить, что опасность не­
корректного использования функции преувеличена, а изменение сигнату­
ры может быть хорошо документировано. Возврат строки через параметр,
кроме того, оставляет возвращаемое значение функции доступным для ис­
пользования, например для возврата кодов ошибок. Те, кто выступает про­
тив этой оптимизации, могут сказать, что ничто не сможет защитить поль­
зователей от неверного использования функции, ведущего к появлению
трудно выявляемых ошибок, так что потери от проблем будут большими,
чем выгода от оптимизации. В конце концов, команда должна ответить на
вопрос " Насколько нам нужно это улучшение производительности?"
Я не могу предложить никаких советов по обнаружению ситуации, когда
усилия по оптимизации зашли слишком далеко. Это зависит от того, на­
сколько важно повышение производительности. Но разработчикам нельзя
упустить этот момент, чтобы остановиться и немного подумать.
С++ предлагает разработчикам широкий выбор между простым и безопас­
ным, но медленно работающим кодом и радикально быстрым кодом, кото­
рый следует использовать с предельной осторожностью. Сторонники дру­
гих языков могут назвать это слабостью, но с точки зрения оптимизации
это одна из самых сильных сторон С++.

П ервая nоn ытка оnт11 м11за ц1111 ст р ок

101

Итоr и пе р вой попытки оптими з ации
В табл. 4. 1 кратко подытожены результаты усилий п о оптимизации функции
remove _ c t r l ( ) . Эти результаты получены с помощью одного простого правила: уда­

лять распределения памяти и связанные с ними операции копирования. Первая оп­
тимизация дала самое значительное ускорение.
Табпица 4.1 . Итоrи повышения n р оизводитепьности; VS 201 0, i7
Оmадочная
версия, мкс

Функции

4 скороети, %

4 скоросПроизводственная
ТИ, %
версии, мкс

Соотношение
времени
отnадочной
и nроиз·
водственной
версий

remove_c t r l ( )

967

remove_ctrl_mu t a t i ng ( )
remove_cr t l_re s e rve ( )

1 04
1 02

remove_c t r l_re f_a rg s_i t ( )

215

83,0
84,8

39,00

35,0

1 ,04

2285

206,73

remove_c t r l re f_re s u l t i t ( )
remove_ct r l_cs t r i ngs ( )

215

35,0
966,0

1 ,02
0, 1 5

233 1
1 6433

2 1 0,78
6,67

24,80

1 ,72
1 ,47

1 343
1 587

60,47

69,39

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

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

Исnоnьзован ие nyчwero аnrоритма
Один из путей заключается в попытке улучшить используемый алгоритм. Ориги­
нальная функция remove _c t r 1 ( ) использует простой алгоритм, который копирует в
результирующую строку один символ за раз. Этот неудачный выбор дает наихудшее
возможное поведение выделения. Пример 4.8 улучшает исходный дизайн путем пе­
ремещения в результирующую строку целых подстрок. Это изменение приводит к со­
кращению числа операций выделения памяти и копирования. Другая оптимизация,
1 02

Гnава 4 . Оптимизация мсnоn ьзова нм я строк

введенная в remove_c t r l _Ы o c k ( ) , - кеширование длины строки аргумента для сни­
жения стоимости проверки завершения внешнего цикла for.
Пример 4.8. remove ctrl Ыосk ( ) : бо11ее быстрый а11rоритм

std : : s tring remove c t r l Ы o c k ( s td : : s t r ing s ) {
s td : : s t r ing resu l t ; for ( s i z e t Ь= О , i = Ь , е = s . length ( ) ; Ь < е ; Ь
for (I = Ь ; i < е ; + + i ) {
i f ( s [ i ] < О х2 0 )
brea k ;

i+ l )

{

1

re sult = resul t + s . suЬ s t r ( b , i -b ) ;
return resul t ;

Функция remove_ct rl_Ы o c k ( ) проходит тест за 2,9 1 мкс, примерно в 7 раз быст­
рее исходной remove_ct r l ( ) .
Эта функция, в свою очередь, может быть улучшена так же, как и раньше, пу­
тем замены конкатенации трансформирующими операциями ( r em o v e _ c t r l _
Ы o c k_mutate ( ) , 1 ,27 мкс н а вызов), н о s ub s t r ( ) по-прежнему создает временную
строку. Поскольку функция добавляет символы в re s u l t , разработчик может ис­
пользовать одну из перегрузок функции -члена append ( ) класса s t d : : s t r i n g для
копирования подстроки без создания временной строки. Результирующая функция
remove _ c t r l _Ы o c k_ append ( ) (показанная в примере 4.9), проходит хронометраж с
результатом 0,65 мкс на вызов. Этот результат превосходит лучшее время в 1 ,02 мкс
для функции remove_c t r l_re f_re s u l t_ i t ( ) и оказывается в 36 раз лучше ориги­
нальной функции remove _ c t r l ( ) Это простая демонстрация мощи выбора хороше­
го алгоритма.
.

Пример 4.9. remove ctrl Ыосk append ( ) : бо11ее быстрый апrоритм

std : : s t r ing remove c t r l Ыос k append ( s td : : s t r ing s )
std : : s t r i ng resu l t ; -

-

resul t . reserve ( s . length ( ) ) ;
for ( s i ze t b=O , i=b ; Ь < s . length ( ) ; Ь = i+ l )
for (I=b ; i < s . length ( ) ; + + i ) {
i f ( s [ i ] < О х2 0 ) brea k ;

{

{

)

resul t . append ( s , Ь , i -b ) ;
return r esu l t ;

Эти результаты, в свою очередь, могут быть улучшены путем резервирования
памяти для re s u l t и удаления копирования аргумента ( remove_c t r l_Ы oc k_a rgs ( ) ,
0,55 мкс на вызов) и путем удаления копирования возвращаемого значения
( remove_c t r l_Ы ock_ret ( ) , 0,5 1 мкс на вызов).
Одна вещь, которая не улучшает результаты, по крайней мере на первый взгляд, это переписывание remove_ ctrl _Ы o c k ( ) с использованием итераторов. Однако пос ­
ле того, как и аргумент, и возвращаемое значение передаются как ссылки, версия с
Вторая п о п ытка о п тимизации строк

1 03

итераторами вместо того, чтобы оставаться в 1 0 раз дороже, оказывается на 20% де­
шевле, как показано в табл. 4.2.
Табпица 4.2. Производитепьность при изменении иmопьзуемоrо апrоритма
Время одноrо вызова, мкс

Функция

remove c t r l ( )
-

4 скорости no отноwени�о
к nредыдущей, %

24,80

2,9 1

752
95

remove_c t r l_Ь l o c k_a rgs ( )
remove_c t r l_Ь l o c k_ret ( }

0,65
0,55
0,5 1

remove_ct r l_Ы o c k_re t_i t ( )

0,43

remove_c t r l _Ы o c k ( )
remove _ c t r l_Ь l o c k_mutate ( )
r emove_c t r l_Ы o c k_append ( )

1 29

1 ,27

18

8

19

Еще один путь повышения производительности - изменение строки-аргумента
путем удаления из нее управляющих символов с помощью функции-члена e r a s e ( }
класса s t d : : s t ring. Этот подход показан в примере 4. 1 0.
Пример 4.1 0 . remove ctrl erase ( ) : изменение арrумента вместо создания резупьтата

s td : : string remove ctrl erase ( std : : s t ring s )
for ( s i z e t i � О ; I < s . length ( ) ; }
i f ( s [i ] < О х2 0 )
s . erase ( i , l ) ;
e l se + + i ;
return s ;

{

Преимуществом этого алгоритма является то, что, поскольку s становится коро­
че, не потребуется никакое перераспределение памяти, за исключением, возможно,
распределения для возвращаемого значения. Производительность этой функции
очень высока; хронометраж показал 0,8 1 мкс на вызов, что в 30 раз быстрее, чем для
оригинальной функции remove _c t r l ( } . Разработчик, достигший этого превосходно­
го результата с первой попытки, может быть объявлен победителем и покинуть поле
битвы без каких-либо дальнейших усилий по оптимизации. Иногда другой алгори тм
оказывается проще для оптим изации или по самой своей природе является более эф ­
фек т ивным.
Испоnьэова ние nyчwero компиnятора

Я выполнил те же хронометрирующие тесты с помощью компилятора Visual Studio
20 1 3. Visual Studio 20 1 3 реализует семантику перемещения, которая сделала некото­
рые из функций значительно более быстрыми. Однако результаты оказались неод­
нозначными. Работа в отладчике Visual Studio 20 1 3 была на 5- 1 5% быстрее, чем для
Visual Studio 20 1 0. При запуске из командной строки VS20 1 3 оказался на 5-20% мед­
леннее. Пробная версия Visual Studio 20 1 5 оказалась еще медленнее. Это могло быть

1 04

Гл ава 4. Оптимиэация испо11 ьэовани11 строк

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

Определение s t d : : s t r ing первоначально было довольно расплывчатым, что
обеспечило наличие широкого диапазона реализаций. Требования эффективности и
предсказуемости в конечном итоге привели к добавлениям в стандарт С++, которые
сделали нестандартными самые новые реализации. Таким образом, поведение, опре­
деленное для s td : : s t ring, является компромиссом, который постепенно эволюци­
онировал из конкурирующих конструктивных соображений в течение длительного
периода времени.








Подобно прочим библиотечным контейнерам, std : : string предоставляет ите­
раторы для доступа к отдельным символам строки.
Подобно символьным строкам в С, std : : s t ri ng предоставляет возможность
индексирования в духе массивов с помощью ope rator [ J для доступа к его
элементам. std : : s t ring обеспечивает также механизм получения указателя
на строку в стиле С с завершающим нулевым символом.
s td : : s t r ing имеет оператор конкатенации и возвращающие значения фун­
кции, которые придают строкам семантику значений, подобную строкам
BASIC.
st d : : string предоставляет множество операций, которое некоторые програм­
мисты считают слишком ограниченным.

Желание сделать s t d : : s t r i n g таким же эффективным, как символьные масси­
вы С, подталкивает реализации к представлениям строк в виде непрерывного блока
памяти. Стандарт С++ требует, чтобы итераторы были итераторами произвольного
доступа, и запрещает семантику копирования при записи. Это упрощает определе­
ние, какие действия делают итераторы s td : : s t ri n g недействительными и почему, но
ограничивает возможность более интеллектуальных реализаций.
Кроме того, реализация std : : s t r i ng, поставляемая с коммерческим компилято­
ром С++, должна быть достаточно прямолинейной, чтобы ее можно было протести­
ровать для гарантии соответствия стандарту и приемлемой эффективности в любой
мыслимой ситуации. Стоимость ошибки для производителя компилятора слишком
высока. Это подталкивает к простым реализациям.
Определенное в стандарте поведение s t d : : s t ring приводит к ряду слабых мест.
Вставка одного символа в строку из миллиона символов заставляет копировать все
окончание строки и может привести к перераспределению памяти. Аналогично все опе­
рации, возвращающие значения подстрок, должны выделять память и копировать свои
результаты. Некоторые разработчики ищут возможности оптимизации путем отмены
одного или нескольких описанных выше ограничений (итераторы, индексирование, до­
ступ к С-строке, семантика значений, простота).

Вторая п о п ытка о пт ими за ци и строк

1 05

П рименение и но й бибnи отеки д nя s td : : s tring

Иногда использование лучшей библиотеки означает не более чем предоставление
дополнительных строковых функций. Вот некоторые из множества библиотек, рабо­
тающих с s t d : : s t r i ng .
Boost string library

(http : / / Ь i t . l y /boo s t - s t r i ng)

Библиотека Boost string library предоставляет функции для лексического ана­
лиза, форматирования и иных экзотических манипуляций s t d : : s t r i n g . Она
доставит немало удовольствия тем, кто любит копаться в заголовочном файле
< a l g o r i t hm> стандартной библиотеки.
С++ String Toolkit Library ( h t tp : / / Ь i t . l y / s t r i n g - k i t - l i b )
Еще одним вариантом является С++ String Toolkit Library (StrTk). Эта библи­
отека особенно полезна для анализа строк и их разделения на лексемы и сов­
местима с s t d : : s t r i n g .
П р име н е н и е s td : : s trings tream во избежа н ие сема нт ики з н а ч е ни й

С++ содержит несколько различных реализаций строк: шаблонные, с доступом
через итераторы, переменной длины строки s t d : : s t r i n g ; с простым итераторным
интерфейсом s t d : : ve c t o r < c h a r > ; более старые в стиле С строки с завершающими
нулевыми символами в массивах фиксированного размера.
Хотя и сложно хорошо использовать символьные строки С, мы уже провели эк­
сперимент, который показал, что замена строк С++ s t d : : s t r i ng строками символов
С в массивах резко повышает производительность. Ни одна из этих реализаций не
подходит идеально для любой ситуации.
С++ содержит еще один вид строк. s t d : : s t r i n g s t r e a m делает для строк то, что
s t d : : o s t r eam делает для выходных файлов. Класс s t d : : s t r i n g s t r e am инкапсули­
рует буфер с динамически изменяемым размером (в действительности обычно это
s t d : : s t r i n g ) иначе, как су бъ ект (см. раздел "Объекты-значения и объекты-сущнос­
ти" главы 6, "Оптимизация переменных в динамической памяти"), к которому могут
быть добавлены данные. s td : : s t r i n g s t r e am является примером тоrо, как наложение
другого API поверх аналогичной реализации может стимулировать более эффектив­
ное кодирование. Пример 4. 1 1 иллюстрирует его использование.
Пример 4.1 1 . s td : : strinqstream: похож на строку, но яв11яется объектом

s td : : s t r ings tream s ;
for ( in t i = O ; i < l O ; + + i )
s . clear ( ) ;
s < < " Квадра т " < < i
log ( s . s t r ( ) ) ;

f } ;

shared_ptr< Foo> myFoo = make_shared ( } ;
fiddle ( myFoo } ;

myFoo владеет динамическим экземпляром Foo. Когда программа вызывает
fiddle ( ) , вызов создает вторую ссылку на динамический экземпляр Foo, увеличи­
вая счетчик ссылок sha red_ptr. Когда выполняется возврат из f i ddle ( ) , аргумент
sha red_pt r освобождает свое владение динамическим экземпляром Foo. Вызываю­
щий объект по-прежнему владеет указателем. Минимальная стоимость этого вызо ­
ва - излишние атомарные инкремент и декремент, каждый с полным ограждением
памяти. В одной функции эта дополнительная плата является незначительной. Но в
качестве практики программирования передача во всей программе всем функциям,
которым требуется указатель на Foo, аргумента shared_ptr может привести к значи­
тельной стоимости.
Передача функции f i dd l e ( J обычного указателя устраняет эту стоимость:
void f i ddle ( Foo* f ) ;
shared_p t r< Foo> myFoo

ma ke shared< Foo> ( } ;
_

fiddle (myFoo . get ( ) ) ;

Имеется общепринятое мнение, что обычные указатели в стиле С никогда не
должны появляться в программе, за исключением реализации интеллектуальных
указателей. Но есть и другое мнение, что простые указатели вполне допустимы до
тех пор, пока используются для представления не владеющих указателей. В команде,
в которой разработчики уверены, что простые указатели - это ни что иное, как иг­
рушки дьявола, тот же результат можно получить с помощью ссылок. Делая аргумент
функции имеющим тип Foo &, мы сообщаем о том, что вызывающий объект отвечает
за корректность ссылки на время вызова и что указатель отличен от n u l l p t r :
v o i d fiddle ( Foo& f ) ;
shared ptr< Foo> myFoo
_

ma ke shared ( ) ;
_

i f ( myFoo )
fiddle ( *myFoo . get ( ) ) ;

Оператор разыменования * преобразует указатель на Foo, который возвращает
get ( ) , в ссылку на Foo. Таким образом, код при этом не генерируется вовсе; это всего
лишь примечание для компилятора. Ссылки представляют собой соглашение, глася­
щее: "ненулевой указатель, которым я не владею':
Уме н ь w е н и е исnоn ьзован и н динамических п еремен н ых

1 51

Испо11ьзование 11r11 авн оrо ук азате11 я " д11 я вп а дения
д инамиче скими пе ременными
Интеллектуальный указатель s t d : : s ha red_p t r прост. Он автоматизирует управ­
ление динамическими переменными. Но, как отмечалось ранее, такие интеллекту­
альные указатели дорогостоящи. Во многих случаях они излишни.
Очень часто одна структура данных владеет динамической переменной на про­
должительности всей ее жизни. Ссылки или указатели на динамическую переменную
могут быть переданы и возвращены функциями, присвоены переменным и так далее,
но ни одна из этих ссылок не переживет "главную" ссылку.
Если существует такая главная ссылка, то она может быть эффективно реализова­
на с помощью std : : unique_ptr. Для обращения к объекту во время вызова функции
может использоваться обычный указатель в стиле С или ссылка С++. При таком по­
следовательном применении указателей и ссылок следует документировать, что они
являются "не владеющими" указателями.
Разработчики начинают чувствовать себя очень неуютно при любом отходе от
использования класса s t d : : s h a red_p t r . Однако те же разработчики каждый день
используют итераторы, возможно, не понимая, что они ведут себя, как не владеющие
указатели, которые могут быть недействительными. Мой опыт использования глав­
ных указателей в нескольких крупных проектах показал, что на практике проблем
утечек памяти или двойного освобождения не возникает. При наличии очевидно­
го владельца главные указатели представляют собой большую победу оптимизации.
Если вы сомневаетесь, класс std : : s ha red_p t r всегда к вашим услугам.

Ум ен ьш ен ие коnич е ст в а перер асп ред еn ен ий
динамических п ерем ен ных
Часто удобство динамической переменной просто слишком велико, чтобы его
пропустить. На ум сразу приходит s td : : s t r i ng. Но это не извиняет неосторожность
разработчика . Существуют методы для сокращения количества выделений памяти
при использовании контейнеров стандартной библиотеки. Эти методы могут быть
обобщены и для разработчиков собственных структур данных.

Пред в а рите11ь ное выд е11 ение памяти д11 я динамиче с ких
пе р еменны х д11я п редотв ращения пе р е расп р еде11 ен и й
Когда к объекту s t d : : s t r i n g или s td : : ve c t o r добавляются данные, его дина­
мически выделенная внутренняя память в конечном итоге становится заполненной.
Очередная операция добавления заставляет строку или вектор выделять больший
блок памяти и копировать старое содержимое в новое хранилище. И вызов диспет­
чера памяти, и копирование выполняют много обращений к памяти и состоят из
многих инструкций. Да, действительно, добавление данных имеет временную сто­
имость 0( 1 ), но скрытая в ней константа (которая говорит о конкретном времени
работы в миллисекундах) может оказаться весьма значительной.
1 52

Гп ава б . Оптимизация переменн ых в динами ческой памяти

И s t r i ng, и vec t o r имеют функцию-член res e rve ( s i ze_t n ) , которая просит
строку или вектор убедиться, что в них имеется достаточно места минимум для n
записей. Если размер можно вычислить или оценить, то вызов reserve ( ) предохра­
няет строку или вектор внутренней памяти от необходимости перераспределения,
пока данные растут до указанного предела:
std : : s t r i ng e r rmsg ;

errmsg . reserve ( l 0 0 ) ; / / Одно выделение памяти для всех добавлений
errmsg += "Ошибка 1 2 3 4 : переменная " ;
erпnsg += varname ;
erпnsg += " исполь зована до присваивания . Неопределенное поведение . " ;

Вызов rese rve ( ) действует для строки или вектора в качестве подсказки. В от­
личие о т выдел е н ия стат иче с ко г о б уф ера для наихудшего случая, единственным
штрафом за слишком малое предположение является возможность дополнительных
перераспределений. Даже расточительное завышение ожидаемого размера не явля­
ется проблемой, если строка или вектор будет использоваться недолго, а затем будет
уничтожен. Для возврата неиспользуемой памяти диспетчеру памяти после приме­
нения rese rve ( ) к строке или вектору можно воспользоваться функцией-членом
s h r i n k_to_ f i t ( ) класса s t d : : s t ring или std : : v e ct or.
Тип хеш-таблицы s td : : u no rde red_map стандартной библ иотеки (см. раздел
"std::unordered_map и std::unordered_multimap" главы 1 0, "Оптимизация структур
данных") содержит базовый массив (список корзин), содержащий ссылки на осталь­
ные структуры данных. Он также имеет функцию-член res e rve ( ) . К сожалению, в
классе std : : deque, который также имеет базовый массив, функция-член rese rve ( )
отсутствует.
Разработчики собственных структур данных, содержащих базовый массив, облег­
чат жизнь пользователям, если включат в класс такую функцию, как rese rve ( ) , для
тоrо чтобы заранее выделить память для этого массива.

Со здание динамич еских пе р е м е нных вн е ци кnов
В приведенном далее маленьком цикле есть большая проблема. Он добавляет
строки каждого файла из списка name l i s t в переменную conf ig типа std : : s t ring,
а затем извлекает небол ь шое количество данных из con fig. Проблема заключается в
том, что con f i g создается при каждой итерации цикла и каждый раз при увеличении
размера перераспределяется. Затем в конце цикла con f i g он выходит из области ви­
димости и уничтожается, возвращая память диспетчеру памяти:
for ( auto & f i lename : name l i s t ) {
s td : : s t r ing con f i g ;

ReadFi leXМL ( fi l ename , con fig ) ;
Proce s sXМL ( config ) ;

Одним из способов поднять эффективность этого цикла является перемещение
объявления переменной con f i g вне ц икла. Внутри цикла con f ig очищается, од­
нако функция clear ( ) не освобождает динамический буфер внутри config. Она
просто устанавливает длину содержимого равной нулю. После первой итерации
Умен ь wение коn ичества п ерерас п р едеnени й ди намичес ких переме н н ых

1 53

перераспределение con f i g происходить не будет, если только следующий файл не
окажется значительно больше первого:
s td : : s t r ing con f i g ;

f o r ( auto& filename : nameli s t )
co n f i g . c l e a r ( ) ;

ReadFi leXМL ( f i lename , con fi g ) ;
Proces sXМL ( con fig ) ;

Вариации на эту тему включают превращение con fig в переменную-член класса.
От этого совета некоторым разработчикам может пахнуrь серой пропаганды исполь­
зования глобальных переменных. В определенном смысле так и есть. Однако превра­
щение динамически выделяемых переменных в долгожители может иметь огромное
влияние на производительность.
Этот трюк работает не только с s td : : s t r i ng, но и с std : : ve ctor и любыми други­
ми структурами данных, которые имеет динамически изменяемый базовый размер.
И з истории о пти м и зац и онных во й н

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

Устра нение изn и w не rо коп ирова н ия
В классическом определении С от Кернигана (Kernighan) и Ритчи (Ritchie) (K&R)
все сущности, которые могут быть непосредственно присвоены, были примитивны­
ми типами, такими как cha r, i nt, float и указатели, которые вписывались в один
регистр процессора. Таким образом, любой оператор присваивания типа а = ь ; был
очень эффективным, генерируя только одну или две команды для вы б ор ки значения
ь и его сохранения в а . В С++ присваивание базовых типов наподобие char, int или
float такое же эффективное.
1 54

Гл ава 6. Оптимизация п еременных в динамической памяти

Но имеются присваивания простого вида, которые далеко не столь эффективны
в С++. Если а и ь являются экземплярами класса BigCl a s s , то присваивание а = Ь ;
вызывает функцию-член B i g C l a s s , которая называется оператором присваивания.
Оператор присваивания может быть таким же простым, как и копирование полей
ь в а . Но дело в том, что ему позволено делать абсолютно все, что может делать фун­
кция С++. Класс BigCl a s s может иметь десятки копируемых полей. Если большой
класс владеет динамическими переменными, которые могут копироваться, в резуль­
тате выполняются вызовы диспетчера памяти. Если BigClass владеет о т о браже ни ем
s t d : : map с миллионом записей или даже массивом char с миллионом символов, сто­
имость оператора присваивания может быть значительной.
имя класса, инициализирующее объявление Foo ь ; может
В С++, если Foo
вызывать другую функцию-член, именуемую копирующим конструктором. Копиру­
ющий конструктор и оператор присваивания являются тесно связанными функция­
ми-членами, которые в основном делают одну и ту же работу: копируют поля одного
э кзе мп л яра класса в другой. И, как и в случае с оператором присваивания, верхнего
пр едела стоимости копирующего конструктора нет.
Разработчик, ищущий узкие места в коде, должен уделить особое внимание при­
сваиваниям и объявлениям, поскольку это места, в которых может скрываться доро­
гостоя щее коп ирование. Ф а ктически копирование может выполняться в любом из
следующих мест.
-











=

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

Скотт Мейерс (Scott Meyers) среди прочих премудростей работы с С++ основа­
тельно осветил вопрос копирующего конструирования в своей книге Effective С+ + .
Приведенные здесь краткие примечания представляют собой не более чем набросок
его описания .

Устра нение не ж е11ате11ь но rо ко п и р ования в о пр еде11 ении к11 асса
Не каждый объект в п рог ра м ме необходимо копировать. Например, объекты, ко­
торые ведут себя, как сущности (см. раздел "Объекты-значения и объекты-сущнос­
ти" данной главы), не должны копироваться, иначе они теряют свой смысл.
Многие объекты, которые ведут себя как сущности, имеют значительное коли­
чество состоя н и й ( нап р и мер, вектор из 1000 строк или таблица из 1000 си м волов) .
Устра нение и зnи w н еrо ко п и рова н и я

1 55

Да, программа, которая копирует сущность в функцию для изучения состояния этой
сущности, будет корректно работать, но стоимость копирований времени выполне­
ния может быть очень большой.
При дорогостоящем или нежелательном копировании экземпляра класса один
из способов избежать его является запрет копирования. Объявление копирующего
конструктора и оператора присваивания закрытыми предотвращает их вызов. По­
скольку их нельзя вызвать, их определение не является необходимым; вполне доста­
точно только лишь объявления.
1 1 Способ запре та копирования до появления стандарта C+ + l l

c l a s s B i gC l a s s {
private :
BigC l a s s ( Bi g C l a s s con s t & ) ;
BigC l a s s & ope rator= ( BigC l a s s con s t & ) ;
pu Ы i c :

};

В С++ 1 1 для достижения того же эффекта к объявлению копирующего конструк­
тора и оператора присваивания можно добавить ключевое слово de l e t e . В этом слу­
чае удаленные конструкторы лучше делать открытыми, так как тогда компилятор
дает более понятные сообщения об ошибках:
1 1 Запрет копирования в C++ l l

c l a s s B i gC l a s s {
puЫ i c :
B i g C l a s s ( Bi g C l a s s con s t & )
BigC l a s s & ope rator= ( BigCl a s s con s t & )

de l e t e ;
de l e t e ;

};

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

1 56

Гnава б. Оптимизация перемен н ых в динамической памяти

"Построение" означает, что вызывается конструктор формального аргумента.
Если формальный аргумент имеет базовый тип, такой как int, douЫe или c h a r * ,
конструктор этого типа является концептуальной, а н е фактической функцией. Про­
грамма просто копирует значение в память, отведенную формальному аргументу.
Однако если формальный аргумент является экземпляром некоторого класса, то
для инициализации экземпляра вызывается один из копирующих конструкторов
класса. Во всех, кроме тривиальных, случаях копирующий конструктор является фак­
тической функцией. Выполняется код вызова этой функции и то, что делает сам копи­
рующий конструктор. Если аргумент представляет собой экземпляр std : : l i s t с мил­
лионом записей, то его копирующий конструктор миллион раз вызывает диспетчер
памяти для создания новых записей. Если аргумент представляет собой список отоб­
ражений или строк, то будет копироваться вся структура данных, узел за узлом. Для
очень больших и сложных аргументов копирование может занять достаточно дли­
тельное время, чтобы привлечь внимание разработчиков. Но если аргумент во время
тестирования содержит лишь несколько записей, существует риск, что ошибка будет
оставаться нераспознанной до тех пор, пока данный дизайн не закрепится и не ста­
нет препятствием для масштабирования программы. Рассмотрим следующий пример:
int Sum ( std : : l i s t< int> v)
int sum = О ;
for ( auto i t : v )

{

sum + = * i t ;
return sum;

Когда вызывается показанная здесь функция
ляется список, например
int total

=

S um ( ) ,

фактическим аргументом яв­

Sum ( MyLi s t ) ;

Формальный аргумент v также представляет собой список. v конструируется с по­
мощью конструктора, который принимает ссылку на список. Это копирующий кон­
структор. Копирующий конструктор s td : : l i s t создает копию каждого элемента
списка. Если MyL i s t всегда длиной только несколько элементов, излишняя стоимость
оказывается сносной. Однако с ростом списка накладные расходы могут стать обре­
менительными. Если список содержит 1 000 элементов, диспетчер памяти вызывается
1 ООО раз. В конце функции формальный аргумент v выходит из области видимости,
так что эти 1 ООО элементов по одному возвращаются в список свободной памяти.
Чтобы избежать этой цены, формальные аргументы могут быть определены как
типы, имеющие тривиальные копирующие конструкторы. При передаче экземпляров
класса в функцию указатели или ссылки имеют тривиальные конструкторы. В пре­
дыдущем примере v может быть s t d : : l i s t < i n t > con s t & . Тогда вместо копирующего
конструирования экземпляра класса ссылка инициализируется ссылкой на факти­
ческий аргумент. Ссылка обычно реализуется с помощью указателя.
Передача ссылки на экземпляр класса может повысить производительность, если
копирующее конструирование экземпляра вызывает диспетчер памяти для копиро­
вания внутренних данных (как в случае с контейнерами стандартной библиотеки),

Устранение и зnи w н е rо к опи ров а н и я

1 57

класс содержит массив, который должен быть скопирован, или есть много локальных
переменных. Имеется определенная стоимость доступа к экземпляру через ссылку:
указатель, реализующий ссылку, должен разыменовываться всякий раз при обраще­
нии к экземпляру. Если функция большая и ее тело использует значение аргумента
много раз, теоретически стоимость постоянного разыменования ссылки может пре­
высить экономию, достигнутую отказом от копирования. Но для небольшой функ­
ции передача аргументов по ссылке повышает производительность - кроме разве
что уж очень маленьких классов.
Ссылочные аргументы не ведут себя так же, как аргументы-значения. Ссылочный
аргумент, изменяемый в функции, приводит к изменению экземпляра, на который
он указывает, в то время как изменение ар гумента - значени я не оказывает никакого
влияния за пределами функции. Объявление ссылочных аргументов как c o n s t пре­
дотв ращае т их случайное и з м ен е н ие.
Ссылочные аргументы могут также создавать псевдонимы, вызывающие непред­
виденные последствия. Например, если функция имеет с и г н атур у
void func { Fo o & а , Foo & Ь ) ;

то вызов f u n c ( х , х ) ; вводитпсевдоним. Если
неожиданно обновленной.

func (

) обновляе т

а,

то и Ь окажется

Устранен ие копирования при возврате из функц ии
Когда функция возвращает значение, это значение создае тся копированием в не­
именованной в реме нн о й переменной типа, возвращаемого функцией. Копи рующ ее
конструирование тривиально для фундаментальных типов, таких как l o n g , douЫ e
или указатели, но когда переменные являются экземплярами кла сс ов, копирующий
конструктор обычно представляет собой реальный вызов функции. Чем больше и
сложнее класс, тем более длительным является его копирующий конструктор. Вот
простой пример:
std : : vector scalar_product ( s td : : vector< int> con s t & v, int с)
s td : : vector resu l t ;

{

result . reserve ( v . s i ze ( ) ) ;
for { auto val

:

v)

result . push back ( va l * с ) ;

return resul t ; -

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

1 58

ГnilBil 6. О nтимизilция перемен н ых в динilмичес кой памяти

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

=

scalar_product ( argarray,

10) ;

Таким образом, поверх одного копирующего конструктора, вызываемого внутри
функции, в вызывающей функции используется другой копирующий конструктор
или оператор присваивания.
Такое двойное копирование было сущим убийцей производительности в ранних
программах С++. К счастью, головастые разработчики стандарта С++ и многих за­
мечательных компиляторов С++ нашли способ устранения дополнительного вызова
копирующего конструктора. Такая оптимизация носит разные имена устранение
ко пирования или опт имизация возвращаемого значения (return value optimization
RVO). Разработчики, возможно, слышали о RVO и полагают, что это означает, что
они могут возвращать объекты по значению, не беспокоясь о стоимости. К сожа­
лению, это не так. Условия, при которых компилятор может выполнять RVO, очень
специфичны. Функция должна возвращать локально созданный объект. Компиля­
тор должен иметь возможность определить, что один и тот же объект возвращается
на всех путях выполнения. Объект должен иметь тип, совпадающий с объявленным
возвращаемым типом функции. В приближении первого порядка, если функция не­
велика и имеет единственный путь выполнения, то вполне вероятно, что компилятор
выполнит RVO. Если функция побольше или путь выполнения ветвится, компилято­
ру становится сложнее определить, что RVO возможна. Качество анализа возможных
оптимизаций у разных компиляторов весьма различно.
Существует способ устранения конструирования экземпляра класса внутри
функции, а также обоих копирований (или эквивалентного копирующего конструк­
тора с последующим оператором присваивания), которые происходят при возврате
из функции. Он включает в себя прямые действия разработчика, поэтому результат
оказывается более определенным, чем надежда на то, что компилятор прибегнет к
RVO. Значение может быть возвращено не только с помощью оператора r e t u rn, но
и в виде выходного параметра, который представляет собой ссылочный аргумент,
модифицируемый возвращаемым значением внутри функции:
-

-

void scalar -product ( s td : : vector con s t & v ,
int с ,

resul t . clear ( ) ;

vector< int>& result ) {

resul t . reserve ( v . s i ze ( ) ) ;
for ( auto val : v )

result . push_back ( val

* с) ;

Здесь выходной параметр r e s u l t добавлен в список аргументов функции. У тако­
го механизма имеется ряд преимуществ.


Объект уже построен при вызове функции. Иногда объект должен быть очи­
щен или повторно инициализирован, но это вряд ли будет дороже, чем кон ­
струирование.
Ус тр а нение и зn и w н еrо копи рова н ия

1 59







Объект, обновляемый внутри функции, не нужно копировать в неименован­
ную временную переменную в операторе return.
Поскольку фактические данные возвращаются в аргумент, возвращаемый тип
функции может быть vo i d; возвращаемое значение может также использовать­
ся для указания ошибки или информации о состоянии выполнения функции.
Поскольку обновленный объект уже связан с именем в вызывающей функции,
его не нужно копировать или присваивать после возврата из функции.

Но это еще не все. Многие структуры данных (строки, векторы, хеш-таблицы)
имеют динамически выделяемый базовый массив, который часто может быть ис­
пользован повторно, если функция в программе вызывается несколько раз. Иногда
результат вызываемой функции должен быть сохранен в вызывающей функции, но
стоимость этого сохранения не б ольше, чем стоимость копирующего конструктора,
который вызывался бы, если бы функция возвращала экземпляр класса по значению.
Есть ли у такого механизма какие-то дополнительные расходы времени выпол­
нения, такие как стоимость дополнительного аргумента? Не совсем. На самом деле
компилятор преобразует функцию, возвращающу ю значение, в функцию с дополни­
тельным аргументом - ссылкой на неинициализированную память для неименован­
ной временной переменной, возвращаемой функцией.
Есть одно место в С++, где нет иного выбора, кроме возвращения объектов по
значению: это функции операторов. Программисты, пишущие математические фун­
кции, работающие с матрицами и желающие использовать удобочитаемый оператор­
ный синтаксис А = В* С ; , не могут использовать ссылочные аргументы. Вместо этого
они должны сосредоточиться на тщательной реализации функций операторов - та­
ким образом, чтобы они могли максимально эффективно использовать RVO и се­
мантику перемещения.
& иб11 иотеки без ко п ирования
Когда буфер, s t r uct или некоторые другие структуры данных, которые должны
быть заполнены информацией, являются аргументами функций, ссылка может деше­
во передаваться через несколько слоев вызовов библиотеки. Я слышал, как библиоте­
ки, реализующие такое поведение, называют библиотеками, "свободными от копиро­
вания� Этот метод встречается во многих библиотеках функций, для которых произ­
водительность критически важна и стоит того, чтобы познакомиться с ним поближе.
Например, функция-член i s t r eam : : r e a d ( ) стандартной библиотеки С++ имеет
следующую сигнатуру:
i s t ream& read ( char * s , s t reams i ze n ) ;

Эта функция читает n байтов в хранилище, на которое указывает s . Буфер s явля­
ется выходным параметром, поэтому считанные данные не должны копироваться
во вновь выделяемую память. Поскольку s является аргументом, i s tream : : read ( )
может использовать возвращаемое значение для чего-то иного; в данном случае воз­
вращается разыменованный указатель * th i s , который представляет собой ссылку на
объект потока.
1 60

Гnава б . Оnтмммзацм я перемен ных в дм наммческой памяти

Сама функция-член i s t ream : : read ( ) не выбирает данные из ядра операционной
системы. Она вызывает друrую функцию. Реализации могут быть различными, и она
может вызвать, например, библиотечную функцию С fread ( ) , которая имеет следу­
ющую сигнатуру:
s i ze_t fread ( void* pt r , s i ze_t s i z e , s i z e_t nmemЬ , FILE* s t ream ) ;

Функция fread ( ) читает s i z e * nmemЬ байтов данных и сохраняет их в памяти, на ко­
торую указывает ptr. Аргумент p t r функции fread ( ) совпадает с аргументом s фун­
кции i s t ream : : read ( ) .
Но и fread ( ) - не конец цепи вызовов. В Linux fread ( ) вызывает стандартную
функцию Unix read ( ) . В Windows fread ( ) вызывает функцию ReadFi l e ( ) из Win3 2 .
Эти две функции имеют похожие сигнатуры:
s s i ze_t read ( int fd , void * bu f , s i ze_t count ) ;

BOOL ReadFi le ( НANDLE hFi l e , vo id* bu f , DWORD
OVERLAPPED* pOver lapped ) ;

n,

DWORD* b ytesrea d ,

Обе функции принимают указатель vo i d * на буфер для заполнения и максимальное
число читаемых байтов. Хотя его тип был приведен из cha r * к vo i d * на пути вниз по
цепи вызовов, указатель указывает на ту же самую память.
Существует альтернативная точка зрения на дизайн, согласно которой эти струк­
туры и буфера должны возвращаться по значению. Они создаются в функции, поэ­
тому не обязаны существовать до вызова функции. Такая функция оказывается на
один аргумент "проще� С++ позволяет разработчику возвращать структуры по зна­
чению, поэтому этот путь должен быть "естественным" в С++. При том что разработ­
чики Unix, Windows, С и С++ выступают за стиль без копирования, я крайне удив­
лен, что у этой альтернативной точки зрения имеются сторонники, несмотря на ее
высокую стоимость: многократное копирование структуры или буфера при проходе
по слоям библиотеки. Если возвращаемое значение имеет динамические переменные,
стоимость может включать многократные вызовы менеджера памяти для создания
копий. Попытка выделить структуру один раз и возвращать указатель на нее требует
неоднократной передачи владения этим указателем. RVO и семантика перемещения
лишь частично решают проблему расходов и требуют пристального внимания раз­
работчика для хорошей реализации. С точки зрения производительности дизайн без
копирования является гораздо более эффективным.
Реал из а ция и диом ы " ко п ирования при за п иси "
Копирование при записи (сору on wгite - COW) представляет собой идиому
программирования, которая используется для эффективного копирования экзем­
пляров класса, содержащих динамические переменные с дорогостоящими опера­
циями копирования. COW является хорошо известной оптимизацией с долгой ис­
торией. Она давно и долго использовалась, в частности - в реализации классов
символьных строк С++. Символьные строки c s t r i n g в Windows используют COW,
как и некоторые старые реализации std : : s tring. Однако стандарт C++ l l явно за­
прещает его применение в реализации s td : : s t r i ng . COW не всегда дает выигрыш
Устра нен и е и зп и w н еrо к опи ровани я

1 61

в оптимизации, поэтому его необходимо использовать разумно, несмотря на его
долгую историю.
Обычно, если копируется объект, который владеет динамической переменной,
должна быть создана новая копия динамической переменной. Это называется опе­
рацией глубокого коп ирования. Объект, содержащий невладеющие указатели, может
обойтись копированием указателей, а не переменных, на которые они указывают.
Это называется поверхностным копированием.
Идея копирования при записи заключается в том, что две копии объекта совпа­
дают, пока один из них не изменяется. Таким образом, до тех пор, пока один из эк­
земпляров не будет модифицирован, они могут совместно использовать указатели
на любые части с дорогостоящим копированием. Копирование при записи сначала
выполняет операцию поверхностного копирования и задерживает выполнение глу­
бокого копирования до изменения элемента объекта.
В реализации COW в современном С++ любой член класса, который ссылается
на динамическую переменную, реализуется с помощью интеллектуального указателя
с разделяемым владением, такого как s td : : shared_ptr. Копирующий конструктор
класса копирует указатель с разделяемым владением, задерживая создание новой ко­
пии динамической переменной до тех пор, пока любая из копий не захочет изменить
динамическую переменную.
Любая трансформирующая операция класса проверяет счетчик ссылок общего ука­
зателя перед продолжением работы. Если значение счетчика ссылок больше 1 , что ука­
зывает на общее владение, операция создает новую копию объекта, обменивает член
с общим указателем на новую копию и освобождает старую копию, уменьшая счет­
чик ее ссылок. Теперь трансформирующая операция может свободно продолжаться,
поскольку динамическая переменная теперь всецело принадлежит текущему объекту.
Важно создавать динамическую переменную в СОW- классе, используя
s td : : ma ke_ shared ( ) . В противном случае использование общего указателя требует
дополнительного обращения к диспетчеру памяти, чтобы получить объект счетчика
ссылок. Для многих динамических переменных это та же стоимость, что и стоимость
простого копирования динамической переменной в новое хранилище и его присво­
ение (не разделяем ому) интеллектуальному указателю. Так что, если сделано мно­
го копий или если изменяющие содержимое динамических переменных операции
обычно не вызываются, идиома копирования при записи может не давать никакого
выигрыша.
С р ез ы
Срез (slice) представляет собой идиому программирования, в которой одна пере­
менная ссылается на часть другой. Например, экспериментальный тип s t r i ng_vi ew,
предложенный для стандарта С++ 1 7, ссылается на подстроку другой строки и содер­
жит указатель cha r * на начало подстроки и длину, которая определяет конец под­
строки в строке, на которую ссылается.
Срезы представляют собой небольшие, легко копируемые объекты без высо­
кой стоимости выделения памяти для элементов и копирования содержимого в

1 62

Гnава 6. Оптимизация переменных в динамической памяти

подмассив или подстроку. Если структурой данных среза владеют интеллектуальные
указатели с общим владением, то такие срезы моrут быть полностью безопасными.
Но опыт учит, что срезы достаточно эфемерны. Они обычно недолго служат опре­
деленной цели и выходят из области видимости прежде, чем можно будет удалять
структуру данных, на которую они ссылаются. s t r i ng_vi ew, например, использует
не владеющий указатель в строку.

Реаnиза ц ия семанти ки перемещен ия
Что касается оптимизации, то семантика перемещения, добавленная в С++ 1 1 ,
пожалуй, самое цен ное из того, что когда-либо происходило с С++. Семантика пере­
мещения решает ряд проблем из предыдущих версий С++.
-





Объект присваивается переменной, что приводит к большим затратам време­
ни на копирование внутреннего содержимого объекта, после чего исходный
объект немедленно уничтожается. По большому счету копирование выполне­
но напрасно, потому что можно было бы повторно использовать содержимое
ИСХОДНОГО объекта.
Разработчик хочет присвоить переменной сущность (см. раздел "Объекты­
значения и объекты-сущности" выше в данной главе), например auto_p t r или
дескриптор ресурса. Операция копирования при присваивании для такого
объекта не определена, поскольку такой объект уникален.

Обе эти проблемы трудно решить, работая с динамическими контейнерами, та­
кими как s td : : ve c t o r , в которых внутренняя память контейнера должна перерас­
пределяться по мере увеличения количества элементов в контейнере. Первый случай
делает перераспределение контейнера более дорогим, чем это необходимо. Второй
случай препятствует хранению в контейнерах таких сущностей, как auto_p t r .
Проблема возникает из-за того, что операция копирования, выполняемая копи­
рующими конструкторами и операторами присваивания, отлично работает для фун­
даментальных типов и невладеющих указателей, но не имеет смысла для сущностей.
Объекты с членами таких типов моrут быть помещены в массивы в стиле С, но не в
динамические контейнеры наподобие s td : : ve c t o r .
До появления стандарта С++ 1 1 не было стандартного способа эффективного пе­
ремещения содержимого переменной в друrую переменную в случае, когда дорого­
стоящее копирование не требуется.
Н естандартная семанти ка ко п и р ов а ния : 6011 езненн ы й ха к
Когда переменная ведет себя, как сущность, создание копии обычно представляет
собой билет в один конец в страну неопределенного поведения. Для такой перемен­
ной рекомендуется отключить копирующий конструктор и оператор присваивания.
Но такие контейнеры, как s td : : ve c t o r , требуют от своих элементов возможности
копирования при перераспределении памяти контейнера, поэтому отключение копи­
рования означает, что такой тип в качестве элемента контейнера использовать нельзя.
Реаn изация семанти ки пе реме щения

1 63

Для отчаянных разработчиков, желающих во что бы то ни стало разместить сущ­
ности в контейнерах стандартной библиотеки, до появления семантики перемещения
обходной путь заключался в реализации присваивания нестандартным образом. На­
пример, можно было создать разновидность интеллектуального указателя, который
реализовывал присваивание так, как показано в примере 6. 1 .
П ример 6.1 . Хакерски й инте1111ектуа11ыы й указате11ь с некопирующим присваиванием
hac ky_p t r & hac ky_pt r : : operato r= ( ha c ky_p t r & rhs )
i f ( * this ! = rhs ) {
t h i s ->ptr = rhs . pt r ;

{

rhs . ptr_ - nul lptr ; -

return * thi s ;

Этот оператор присваивания компилируется и выполняется. Такая инструкция,
как q = р ; , передает владение указателем переменной q, устанавливая указатель в р
равным n u l l p t r . Владение при таком определении сохраняется. Указатель, опреде­
ленный таким образом, работает в s t d : : ve c t o r .
Хотя сигнатура оператора присваивания тонко намекает на то, что r h s изменяет­
ся, что является необычным поведением для присваивания, само присваивание не
предлагает никакого ключа к своему девиантному поведению (пример 6.2).
П ример 6. 2 . С юрпризы hacky_ytr
hac ky_ptr р , q ;
р = new Foo ;
q = р;
p-> foo_func ( ) ;

1 1 Сюрприз ! Разыменование nu l lp t r

Новый разработчик, который ожидает, что этот код будет нормально работать,
оказывается очень разочарованным, возможно, после длительного сеанса отладки.
Такое понимание "копирования" ужасно, его применение трудно обосновать, даже
когда оно кажется необходимым.
s td : : swap ( )

: семантика перемещения дn я бедных

Еще одной возможной операцией с двумя переменными является обмен содержи­
мым двух переменных. Такой обмен хорошо определен, даже когда переменные явля­
ются сущностями, поскольку в конце операции каждая сущность содержится только
в одной переменной. С++ предоставляет шаблон функции s t d : : s wap ( ) для обмена
содержимым двух переменных:
s td : : vector

a ( l000000, 0 ) ;

s td : : vector Ь ;
s td : : swap ( v , w ) ;

11 ь

пуст

11 Теперь в Ь миллион элементов

Инстанцирование s t d : : s wap ( ) по умолчанию до появления семантики переме­
щения было эквивалентным следующему коду:
1 64

Гnа ва 6 . Оnтимизация переменн ых в динами ческой памяти

template < t ypename Т> void s td : : swap ( T & а , Т & Ь ) (
Т tmp
а;
1 1 Создание новой копии а
а
Ь;
/ / Копируем Ь в а , уничтожая старое значение а
Ь
= tmp ; / / Копируем tmp в Ь , уничтожая старое значение Ь

Такое инстанцирование по умолчанию работает только для объектов, для кото­
рых определена операция копирования. Оно также является потенциально неэффек­
тивным: исходное значение а копируется два раза, а исходное значение ь один раз.
Если тип т содержит динамически выделяемые члены, выполняется три копирования
и три уничтожения (с учетом уничтожения переменной tmp при выходе из области
видимости). Это дороже, чем концептуальная операция копирования, которая делает
только одно копирование и одно удаление.
Мощь операции обмена заключается в том, что она может быть рекурсивно при­
менена к членам класса. Вместо тоrо чтобы копировать объекты, на которые ука­
зывают указатели, поменяться местами могут сами указатели. Для классов, которые
указывают на большие динамически выделенные структуры данных, обмен является
гораздо более эффективным, чем копирование. На практике функции s td : : s wap ( )
могут быть специализированы для любого требуемого класса. Стандартные контей­
неры предоставляют функции-члены s w a p ( ) для обмена местами указателей на их
динамические члены. Контейнеры также специализируют s t d : : swap ( ) , позволяя вы­
полнять эффективный обмен без вызовов диспетчера памяти. Определяемые пользо­
вателем типы также могут предоставлять специализации для s t d : : swap ( ) .
std : : vector не использует обмен для копирования своего содержимого при рос­
те его базового массива, но аналогичным структурам данных ничто не препятствует
поступать таким образом.
Проблема с обменом заключается в том, что, хотя обмен и является более эф­
фективным, чем копирование классов с динамическими переменными, требующими
глубокого копирования, он менее эффективен, чем копирование для других классов.
Но как минимум обмен имеет смысл и для простых типов, и для владеющих указате­
лей, что делает его шагом в правильном направлении.
-

Раздеnяемое вп адение су щ ностями
Копировать сущности нельзя. Однако можно копировать разделяемый указатель
на сущность. Таким образом, в то время как невозможно было создать, например,
s t d : : ve c t o r < s t d : : mu t e x > до появления семантики перемещения, вполне мож­
но было определить s t d : : ve c t o r < s t d : : s h a r e d_p t r < s t d : : mu t e x > > . Копирование
sha red_p t r имеет точно определенный смысл: создание дополнительной ссылки на
уникальный объект.
Конечно, создание s h a red_pt r для сущности представляет собой обходной путь.
Хотя он и имеет то преимущество, что использует инструменты стандартной библи­
отеки С++, в нем много ненужной сложности и он обладает повышенными наклад­
ными расходами времени выполнения.

Реал изация сема нтики п еремеще н и я

1 65

П ереме щаю щая часть семантики переме щ ения
Создатели стандарта поняли, что им необходимо закрепить операцию "пере­
мещения " как фундаментальную концепцию в С++. Перемещение должно переда­
вать владение. Оно более эффективно, чем копирование, и точно определено и для
значений, и для сущностей. Результат получил название семант ика перемещения.
Я собираюсь осветить здесь основные моменты семантики перемещения, но есть
много деталей, которые я никак не могу охватить в столь кратком объеме. Крайне
рекомендую обратить внимание на книгу Скотта Мейерса (Scott Meyers) Effective
Modern С+ +4; из 42 разделов книги автор посвящает семантике перемещения 1 0
разделов. Статья Томаса Беккера (Thomas Becker) С+ + Rvalue References Explained
( h t tp : / / Ь i t . l y / b e c ke r - rva l u e ) представляет собой доступное введение в семан­
тику перемещения, свободно доступное в Интернете и содержащее гораздо больше
информации, чем приведено здесь.
Для облегчения семантики перемещения в компиляторы С++ внесено изменение,
чтобы они могли распознавать, когда переменная существует только как временная.
Такой экземпляр не имеет имени. Например, объект, возвращаемый функцией или
образующийся в результате выполнения выражения new, не имеет имени. На такой
объект не может быть других ссылок. Объект доступен для инициализации, для при­
сваивания переменной или использования в качестве аргумента выр ажения или вы­
зова функции, но будет уничтожен в следующей точке следования. Неименованные
значения называются rvalue, потому что они похожи на результат выражения справа
от оператора присваивания. Lvalиe, напротив, представляет собой значение, которое
является именем переменной. В инструкции у = 2 * x + l ; результат выражения 2 * x + l
представляет собой rvalue; это временное значение без имени. Переменная слева от
знака равенства является lvalue, а ее именем является у.
Когда объект является rvalue, его содержимое может быть уничтожено, что­
бы стать значением lvalue. Единственное налагаемое требование - rvalue должно
остаться в допустимом состоянии, чтобы его деструктор вел себя корректно.
Система типов С++ была расширена так, чтобы иметь возможность отличить
rvalue от lvalue при вызове функции. Если т произвольный тип, то объявление Т & &
представляет собой rvalue cc ылкy на т , т.е. ссылку н а rvalue типа т . Правила разре­
шения переrрузки функции были расширены так, что, когда фактическим аргумен­
том функции является rvalue, перегрузка rvаluе-ссылки является предпочтительной,
а когда аргументом является lvalue, требуется переrрузка ссылки lvalue.
Список специальных функций-членов был расширен, и теперь он включает пе­
ремещающий конструктор и оператор перемещающего присваивания. Эти функции
являются перегрузками копирующего конструктора и копирующего оператора при­
сваивания, которые принимают rvalue-ccылкy. Если класс реализует перемещающий
конструктор и оператор перемещающего присваивания, то инициализация или при­
сваивание экземпляра может использовать эффективную семантику перемещения.
-

-

4 Имеется русский перевод: Скотт Мейерс. Эффективный

исполь.юван ию C+ + l l и С+ + 1 4.

1 66

-

и современный
М.: ООО "И.Д. Вильяме': 20 1 6. - 304 с.

Гnава 6 . Оnтимизация перемен н ых в ди намической памяти

С+ +:

42 рекоменда ции п о

В примере 6.3 представлен простой класс, содержащий уникальную сущность.
Компилятор автоматически генерирует перемещающие конструкторы и перемещаю­
щие операторы присваивания для простых классов. Эти перемещающие операторы
выполняют операцию перемещения для каждого элемента, для которого определена
операция перемещения, и копирование - для других членов. Это эквивалентно вы­
полнению для каждого члена кода this->mernЬer = std : : move ( rhs memb e r )
.

.

Пример 6.3. Knacc с семантико й перемещения
c l a s s Foo {

s td : : unique-ptr value ;

puЫ i c :

Foo ( Foo & & r h s ) {
value = rhs . release ( ) ;
Foo ( Foo cons t & rhs ) : value- ( nu l lptr ) {
i f ( rhs . value )
value
std : : make_unique< int * > { * rhs . value_ ) ;
};

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

И з м енения код а дn я и споnьзования семантики перемещения
Внесение изменений в код для использования семантики перемещения может
быть выполнено поклассово. Вот небольшой контрольный список, который поможет
ос уществить э тот пр о ц е сс
.

5

Копирующий конструктор и оператор присваивания автоматически генерируются, даже если оп

-

реде11ен деструктор, хотя это прави110 и рассматривается как устаревшее, начиная с C++ l l . Лучше всеrо
явно удалять копирующий конструктор и оператор присваивания, если таковые не должны быть опре­
де11ен ы .

Реа л и эация семантики п е ремеще ни я

1 67









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

Тонкости семанти ки п е р еме щ ения
Я не хочу сказать, что семантика перемещения является хаком. Нет, эта возмож­
ность имеет слишком важное значение, и авторы стандарта действительно вы пол
нили очень большую работу, чтобы сделать ее семантически сходной с копирующим
конструированием. Но я думаю, что вполне можно сказать, что семантика переме­
щения - вопрос тонкий. Это одна из тех возможностей С++, которая должна ис­
пользоваться с осторожностью и серьезными знаниями и пониманием, чтобы можно
было получить преимущества от ее использования.
-

П ереме ще ни е э кземnnя ров в s td : : vector

Недостаточно просто написать перемещающий конструктор и оператор переме­
щающего присваивания, если вы хотите, чтобы объект эффективно перемещался, на­
ходясь в std : : ve c t o r . Разработчик должен объявить перемещающий конструктор и
оператор перемещающего присваивания как n o e x c e p t . Это необходимо, потому что
std : : vector обеспечивает строгую гарантию безопасност и исключений: происшед­
шее во время операции исключение оставляет вектор в том же состоянии, что и до
операции. Копирующий конструктор не изменяет исходный объект. Перемещающий
конструктор его разрушает. Любое исключение в перемещающем конструкторе нару­
шает строгую гарантию безопасности исключений.
Если перемещающий конструктор и оператор перемещающего присваивания
не объявлены как n o e x c e p t , s t d : : v e c t o r вынужден использовать менее эффек­
тивные операции копирования. Компилятор может не выдавать никаких предуп ­
реждений о том, что это произошло, и код по-прежнему будет работать правиль­
но, но медленно.
168

Гп ава 6 . Оптимизации перемен ных в динамической пам11ти

noexcept представляет собой очень сильное обещание. Обещание noexcept озна­
чает отсутствие вызовов диспетчера памяти, ввода-вывода или любых других фун­
кций, которые могут вызвать исключение. Или это может означать, что все исклю­
чения будут перехвачены без возможности сообщить, что программа так поступила.
В Windows это означает, что преобразование структурированных исключений в ис­
ключения С++ сопряжено с риском, так как нарушение обещания noexcept означа­
ет внезапное невосстановимое прекращение работы программы. Это цена, которую
приходится платить за эффективность.

Rvalue-ccыnки в качестве а р rуме нтов явnяются lvalue
Когда функция принимает rvalue-ccылкy в качестве аргумента, он использует ее
для создания формального аргумента. Поскольку формальный аргумент имеет имя,
он является lvalue, несмотря на то что был создан из rvаluе-ссылки.
К счастью, разработчик может явно привести lvalue к rvalue-ccылкe. Стандартная
библиотека предоставляет отличный шаблон функции s t d : : move ( ) в заголовочном
файле для выполнения этой работы, как показано в примере 6.4.
Пример

6.4.

Явное перемещение

std : : string MoveExamp l e ( s td : : s t r ing& & s ) {
s td : : s t r ing tmp ( s t d : : move ( s ) ) ;
1 1 Внимание ! Сейчас s - пустая с трока .
return tmp ;
s t d : : s t r i ng s l
std : : s t r i ng s 2
s t d : : s t ring s З

" he l l o " ;
" e ver yon e " ;
MoveExarnpl e ( s l + s 2 ) ;

В примере 6.4 вызов MoveExamp l e ( s 1 + s 2 ) приводит к созданию s из rvаluе-ссылки,
а это означает, что фактический аргумент перемещается в s. Вызов s t d : : move ( s ) со­
здает rvalue-ccылкy на содержимое s . Поскольку возвращаемым значением функции
s td : : move ( ) является rvalue-ccылкa, оно не имеет имени. Rvalue-ccылкa инициализи­
рует tmp, вызывая перемещающий конструктор s t d : : s t r i ng. После этого s больше не
указывает на фактический строковый аргумент для MoveExamp l e ( ) . Теперь это, веро­
ятно, пустая строка. Когда возвращается tmp, концептуально происходит следующее:
значение tmp копируется в анонимное возвращаемое значение, а затем tmp удаляется.
Анонимное возвращаемое значение MoveExamp l e ( ) копируется в s З с использовани­
ем копирующего конструктора. Однако в действительности в этом случае компиля­
тор может выполнить RVO, так что аргумент s фактически будет перемещен непо­
средственно в память s З . В общем случае RVO более эффективна, чем перемещение.
Вот версия шаблона функции s t d : : swap ( ) с использованием семантики переме­
щения - функции std : : move ( ) :
template < t ypename Т> void s t d : : swap ( T & а , Т& Ь )
{
Т tmp ( s td : : move ( а ) ) ;
а
s td : : move ( Ь ) ;
Ь = std : : move ( tmp ) ;

{

Реаn и заци я семантики п еремеще ни я

1 69

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

Не возв р ащайте rvalue-ccыnки
Еще одна тонкость семантики перемещений заключена в том, что функции обычно
не должны быть определены как возвращающие rvаluе-ссылки. Возвращение rvаluе­
ссылки имеет интуитивно понятный смысл. В вызове, таком как х ; foo ( у ) , возвра­
щение rvаluе-ссылки будет приводить к эффективному перемещению возвращае­
мого значения из неименованной временной переменной в целевую переменную х.
Но в действительности возврат rvаluе-ссылки препятствует оптимизации возвра­
щаемого значения (см. раздел "Устранение копирования при возврате из функции"
ранее в данной главе), которая позволяет компилятору устранить копирование не­
именованной временной переменной в целевой объект, передавая ссылку на целевой
объект в функцию в качестве неявного аргумента. Если сделать возвращаемое зна­
чение rvаluе-ссылкой, это приводит к двум операциям перемещения, в то время как
возврат значения приводит к единственной операции перемещения с помощью RVO.
Таким образом, ни фактический аргумент оператора r e t u r n , ни объявленный
возвращаемый тип функции не должен быть rvаluе-ссылкой, если возможно приме­
нение RVO.
Пе реме ще ни е базовы х кnа ссов и чnе н ов

Чтобы реализовать семантику перемещения для класса, необходимо реализовать
семантику перемещения также для всех базовых классов и членов, как показано в
примере 6.5. В противном случае базовые классы и члены будут не перемещены, а
скопированы.
П ример 6. 5 . П еремещение базовых кnассов и чnенов
c l a s s Ba s e (

... };

c l a s s De rived : Base {
s td : : unique__ptr memЬer ;

};

Ba r * baпnernЬe r ;

De r i ved : : De r ived ( De rived& & rhs )

Base ( s td : : move ( rhs ) ) ,

mernЬer

( s t d : : move ( rhs . mernЬer- ) ) ,

barmemБer_ ( nu l lpt r ) {

s td : : swap ( th i s - >barmemЬer_, rhs . barmemЬer_ ) ;

В примере 6.5 показаны некоторые тонкости написания перемещающего кон­
структора. Если ва s е имеет перемещающий конструктор, то он вызывается, толь­
ко если lvalue rhs приводится к rvalue-ccылкe с помощью s t d : : move ( ) . Аналогично
перемещающий конструктор s t d : : un i que_p t r вызывается, только если rhs . member_
приведен к rvalue-ccылкe. Что касается b a rmemb e r который представляет собой
_,

1 70

Гл ава 6. Оптимизация переменн ых в динамической памяти

обычный указатель в стиле С, или любого объекта, в котором не определен пере­
мещающий конструктор, операция в стиле перемещения реализуется с помощью
std : : swap ( ) .
Функция s t d : : s w a p ( J может привести к некоторым проблемам при реализации
оператора перемещающего присваивания. Проблема заключается в том, что t h i s мо­
жет указывать на объект, который уже имеет выделенную память. s t d : : s w a p ( ) не
освобождает ненужную память. Она сохраняет ее в r h s , и память не будет освобож­
дена, пока не будет уничтожен объект r h s . Потенциально это может играть роль,
если, скажем, член содержит строку из миллиона символов или таблицу с милли­
оном записей. В этом случае лучше явно скопировать указатель barmemЬer а затем
обнулить его в r h s , чтобы деструктору rhs не пришлось его освобождать:
vo i d De r i ve d : : opera t o r = ( De r i v e d & & rhs )
_,

Bas e : : ope rator= ( s td : : move ( rhs ) ) ;
de lete ( th i s ->barmemЬe r ) ;
this- >barmemЬer
rhs�barmemЬer
rhs . barmemЬer � nullpt r ;
=

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






Плоские структуры данных требуют меньше дорогостоящих вызовов диспет­
чера памяти для построения, чем структуры данных, части которых связаны
между собой указателями. Одни структуры данных ( список, дек, отображение,
хеш-таблица) создают множество динамических объектов; другие же (вектор)
существенно меньше. Как неоднократно показано в главе 1 О, "Оптимизация
структур данных': даже если подобные операции имеют ту же производитель­
ность, выраженную через "большое о·: плоские структуры данных, такие как
s t d : : ve c t o r и s t d : : a rr a y , имеют при этом значительное преимущество.
Плоские структуры данных, такие как a r ra y и ve c t o r, занимают меньше памя­
ти, чем структуры данных на основе узлов, такие как список, отображение или
хеш-табли ц а, из-за наличия связывающих указателей в структурах на основе
узлов. Компактность улучшает локальность кеша, даже если общее количество
байтов не является проблемой. Плоские структуры данных имеют преимущес­
тво в плане локальности кеша, что делает их более эффективными при обходе.
Трюки наподобие создания векторов или отображений интеллектуальных ука­
зателей, которые были необходимы до появления семантики перемещения в
С++ 1 1 для хранения некопируемых объектов, больше необходимыми не явля­
ются. Значительная стоимость времени выполнения, связанная с выделением
памяти для интеллектуальных указателей и указываемых объектов, теперь мо­
жет быть устранена.
П nо с кие с т руктур ы да н н ы х

1 71

Ре зюме


Наивное использование динамически вы деляемых переменных является наи боль­
шим врагом производительности в программах С++. Когда производительность
важна, n e w вам не друг.



Разработчик может существенно повысить производительность, не делая ни­
чего, кр оме уменьшения количества вызовов диспетчера памяти.
Программа может глобально изменить принципы выделения и осво божде­
ния памяти, пре д оставляя опред еления операторов : : op e ra t o r n e w ( ) и



: : ope ra t o r de l e t e () .
















1 72

Программа может глобально изменить принципы управления памятью, заме­
няя функции ma l l o c () и free () .
Интеллектуальные указатели автоматизируют владение д инамическими пере­
менными.
Разделяемое владение динам иче скими переменными имеет бол ьшу ю стои­
мость.
Создавайте экземпляры класса статически.
Создавайте члены класса стат ически и при необхо димост и прибегайте к двух­
этапной инициализации.
Использу йте главный ука затель для вла дения динами чес кими п еременными и
невла деющие указатели вместо использования совместного владения.
Создавайте функции без копирования, которые возвращают данн ые через выход­
ные параметры.
Реализуйте семантику перемещения.
Предпочитайте плоские структуры д анных.

Гnава 6. Оптимизация переменных в динами ческой памяти

ГЛАВА 7

Оптимизация ин с т р у кци й

Скульптура уже з десь, в глыбе мрамора. Все, что мне остается - убрать
все лишнее.
- Микеланджело де Франческо де Нери де Миниато дель Сера и Лодови­
ко ди Леон ар до д и Буон ар р о ти Симони (Michelangelo di Francesci di Neri di
Miniato del Sera i Lodovico di Leonardo di Buonarroti Simoni) ( 1475- 1 564) в
ответ на воnрос " Как вы создаете свои скульптуры? "

Оптимизация на уровне инструкций может быть смоделирована как процесс уда­
ления инструкций из потока выполнения, так же, как Микеланджело описал процесс
создания своих шедевров. Проблема в совете Микеланджело в том, что он не поясня­
ет, какая часть глыбы лишняя, а какая является шедевром.
Проблема оптимизации на уровне инструкций заключается в том, что, помимо
вызовов функций, никакие инструкции С++ не превращаются в большее, чем не­
сколько машинных команд. Обычно такие мелкие оптимизации не дают улучшения,
достаточного для того, чтобы оправдать приложенные усилия, если только разра­
ботчик не обнаружит факторы, которые увеличивают стоимость инструкции, делая
ее достаточно "горячей': чтобы ее стоило оптимизировать. Эти факторы включают
следующее.
Цикль1
Стоимость операторов в цикле умножается на количество их повторений.
Профайлер может указывать на функцию, содержащую горячий цикл, но не
указать, какой именно цикл в функции самый горячий. Он может указать на
функц ию, которая является горячей потому, что вызывается в одном или не­
скольких циклах, но не укажет, какие именно места вызова являются горячи­
ми. Так как профайлер не указывает непосредственно на цикл, разработчик
должен изучить код, чтобы найти конкретный цикл, используя вывод профай­
лера в качестве источника улик.
Часто вызываемые функции

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

Идиомы, испол ьзуемы е в программе

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

Оптимизация кода на уровне инструкций может дать существенное увеличение
производительности на небольших, более простых процессорах, которые встроены в
инструменты, приборы, периферийные устройства и игрушки, потому что они полу­
чают команды непосредственно из памяти и выполняют их одну за другой. Однако
процессоры уровня настольных компьютеров обеспечивают такой уровень парал­
лелизма на уровне команд и кеширования, что оптимизация на уровне инструкций
дает меньший эффект, чем оптимизация распределения памяти или копирования.
В программах, разработанных для класса настольных компьютеров, оптимизация
на уровне инструкций может быть целесообразной только для часто вызываемых
библиотечных функций или наиболее глубоко вложенных ц иклов программы, на­
пример в графических механизмах игр или припрограммирования переводчиков с
других языков, которые работают все время.
Еще одна проблема оптимизации на уровне инструкций связана с тем, что эффек­
тивность оптимизации может зависеть от компилятора. Каждый компилятор имеет
один или несколько планов, как генерировать код для определенного оператора С++.
Идиома кодирования, которая улучшает производительность на одном компилято­
ре, может не давать результата на другом и даже замедлить код на третьем. Трюк,
повышающий производительность при использовании GCC, может не работать с
Visual С++. Более того, это означает, что, когда команда программистов обновляет
свой компилятор до новой версии, новый компилятор может вызвать снижение оп­
тимальности имеющегося тщательно настроенного кода. Это еще одна причина, по
которой оптимизация на уровне инструкций может быть менее плодотворной, чем
другие усилия по повышению производительности.

Уда n ен ие кода из цикn ов
Цикл состоит из двух частей: многократно выполняемого блока инс трукций и уп­
равляющей конструкции, которая определяет, сколько раз повторяется цикл. Общие
замечания по удалению вычислений из операторов С++ применимы и к инструкци­
ям в теле цикла. Что касается управляющих конструкций циклов, то здесь имеются
дополнительные возможности для оптимизации, потому что в определенном смысле
они представляет собой накладные расходы.
Рассмотрим цикл for из примера 7. 1 , в котором выполняется обход строки с за­
меной пробела звездочкой.
Пример 7 1 Неоnтимизированны й цикп for
.

.

char s [ ]

=

"В этой строке немало символов пробела

for ( s i z e t i = О ; i < s t rlen ( s ) ; + + i )
i f ( sТi J == ' ' )
' * ' ;
s [i]
=

1 74

Гл ава 7 . Оптимизация и н струкций

( 0х2 0 ) .

";

Проверка i < s t r l en ( s ) в цикле f o r выполняется для каждого символа строки1•
Вызов s t r len ( ) - дорогостоящий, приводящий к обходу всей строки для подсчета
ее символов и превращающий этот алгоритм из алгоритма О(п) в алгоритм О(п2). Это
пример внутреннего цикла, скрытого в библиотечной функции (см. раздел "Оценка
стоимости циклов" главы 3, "Измерение производительности").
Десять миллионов итераций этого цикла занимают 13 238 мс при компиляции
кода Visual Studio 20 1 0 и 1 1 467 мс - при использовании Visual Studio 20 1 5. Изме­
рения показывают, что VS20 1 5 генерирует на 1 5% более быстрый код, что говорит о
том, что эти компиляторы генерируют для данного цикла разные коды.
К е ш и р ов а ние конечно rо зн а чения цик nа
Конечное значение цикла, возвращаемое дорогостоящей функцией s t r l e n ( ) ,
можно предварительно вычислить и кешировать в заголовке цикла для повышения
производительности. Измененный цикл показан в примере 7.2.
Пример 7.2. Цикn for с кеwированным конечным значением
for ( s i z e t i = О ,
if

( s[i ]
s [i]

==
=

'

len = s t r l en ( s ) ; i < len ; + + i )
'

)

'*';

Эффект от этого изменения потрясающий из-за высокой стоимости функции
s t r l e n ( ) . Тест оптимизированного кода выполняется за 636 мс при компиляции

VS20 1 0 и 54 1 мс в случае VS20 1 5 - почти в 20 раз быстрее, чем исходный код. VS20 1 5
по-прежнему опережает VS20 1 0, на этот раз - на 1 7%.

Пр и м енен и е боn ее э фф ективных инструк ций ци кn ов
Вот как выглядит синтаксис цикла

for

fоr ( инициализация

выражение_продолжения

;

условие

;

в С++:
)

тело цикла

Грубо говоря, этот цикл компилируется в код, выглядящий примерно таким образом:
инициализация ;
! услов ие ) goto 12 ;
т ело_ цикла ;
выражение продолжения

11 : i f (

12 :

goto

Цикл

for

Ll ; -

должен выполнять переход дважды: один раз - при ложности усл овия

и еще один раз - после вычисления выражения_ продолжения. Эти переходы могут

замедлить выполнение. С++ имеет более простой, но реже используемый цикл
имеющий следующий синтаксис:
do тело_цикла whi l e ( усло в ие )
1

do,

;

Некоторые читатели бурно отреагируют на этот код: "Ну сколько можно! Зачем п исать такой код?
s t d : : s t r i ng и меет фун кцию length ( ) с константным временем работ ы ? "

Разве вы не в курсе, что

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

Удаnени е кода и з ци кnов

1 75

Опять же, грубо говоря, этот цикл компилируется в код, выглядящий примерно та­
ким образом:
Ll :

тело

цикла

( условие )

if

goto

11 ;

Таким образом, цикл for часто может быть упрощен до цикла do, который может
оказаться более быстрым. В примере 7.3 показан код сканирования строки с помо­
щью цикла do.
Пример 7.3. Преобразование цима for в цик11 do

s i ze t i = О , len = s t r l en ( s ) ; / / Инициализация цикла for
do {
i f ( s [ i ] == ' ' )
s [i] = ' * ' ;
++ i ;

whi l e ( i < len ) ;

1 1 Выражение продолжения цикла for

/ / Условие ЦИкла for

При использовании Visual Studio 20 1 0 мы получаем выигрыш в производитель­
ности: тестовый цикл выполняется за 482 мс, т.е. быстрее на 1 2%. Однако в случае
Visual Studio 20 1 5 это изменение приводит к куда худшим результатам: тест выпол­
няется за 674 мс, или на 25% медленнее цикла for.

Из ме н е н ие н а n равn е н ия цикnа
Вариацией кеширования конечного значения я вляется изменение направления
отсчета; таким образом, кешируя конечное значение в переменной индекса цикла.
Многие циклы имеют одно конечное условие, существенно менее дорогостоящее,
чем другое. Например, в цикле в примере 7.3 одно конечное условие представляет
собой константу О, в то время как другое конечное условие является дорогостоящим
вызовом strlen ( ) . В примере 7.4 представлен цикл из примера 7. 1 , в котором изме­
нено направление отсчета.
Пример 7 4 Оптимизация цикnа путем изменениа наnравnения
.

.

for ( int i = ( int ) s t rlen ( s ) - 1 ; i >= О ; - - i )
i f ( s [ i ] == ' ' )
s [i] = ' * ' ;

Обратите внимание, что я изменил тип переменной i с беззнаковоrо типа s i z e_t
на знаковый тип i n t . Условие завершения цикла имеет вид i > = O . Если бы i было
беззнаковым, то оно, определенно, всегда было бы больше или равно нулю, так что
такой цикл не мог бы завершиться. Это очень распространенная ошибка при обрат­
ном счете до нуля.
Тот же самый хронометрический тест показывает, что время выполнения цик­
ла - 6 1 9 мс при компиляции Visual Studio 20 1 0 и 57 1 мс - при компиляции Visual
Studio 20 1 5. Непонятно, почему наблюдается такое значительное отклонение от ре­
зультатов кода из примера 7.2.

1 76

Гnава 7 . Оnтимизация инструкций

Уст ранение инвариантно rо кода из ц и кnо в
Пример 7.2, в котором конечное значение цикла кешируется для эффективного
повторного использования, является примером более общей методики устранения
инвариантного кода из цикла. Код является инвариантным относительно цикла,
если он не зависит от индекса цикла. Например, в несколько надуманном цикле в
примере 7.5 оператор при с ваивания j 1 0 0 ; и подвыражение j * x * x являются инва­
риантными относительно цикла.
=

Пример 7.S. Цикn с инвариантным кодом
int i , j , x , a [ l O J ;

for ( i=O ; i< l O ; + + i )
j = 100;
a [i] = i + j * х * х;

Этот цикл может быть переписан так, как показано в примере 7.6.
Пример 7 .6. Цикn с удапенным инвариантным кодом
int i , j , x , a [ l O J ;
j = 100;

int tmp = j * х * х ;
for ( i= O ; i< l O ; + + i )
a [ i ] = i + tmp ;

Современные компиляторы хорошо находят фрагменты инвариантного кода (та­
кие, как показанные здесь), которые можно вынести из тела цикла для повышения
производительности. Разработчику обычно не нужно переписывать цикл, потому что
компилятор сам находит инвариантный код и переписывает цикл при компиляции.
Когда инструкция внутри цикла вызывает функцию, компилятор может не быть
в состоянии определить, зависит ли возвращаемое функцией значение от чего-то в
цикле. Функция может быть сложной, тело функции может быть в другой единице
компиляции и не видимым для компилятора. Разработчик должен сам определить
вызовы функций, являющиеся инвариантными относительно цикла, и вручную уда­
лить их из цикла.
Уда n ение ненужных вызовов функ ц и й из ц и кn ов
Вызов функции может выполнять сколь угодно большое количество ин струкций
Если функция инвариантна относительно цикла, ее стоит убрать из цикла для повы­
шения производительности. В примере 7. 1 , воспроизводимом здесь, вызов strlen ( )
является инвариантным относительно цикла и может быть вынесен из него:

.

for ( s i ze t i = О ; i < s t rlen ( s } ; + + i )
i f ( s Тi ] ==
)
s [i] = ' * ' ;
/ / Заме на ' ' на ' * '
1

1

В примере 7.7 показано, как может выглядеть исправленный цикл.
Удаnенме кода из цм к nов

1 77

Пример 7.7. Цикn с инвариантом strlen ( )

s i z e t end = s t r l en ( s ) ;
for (s i ze t i
О ; i < end ; + + i )
i f ( s(i ] == ' ' )
=

s (i]

=

'*';

11

Замен а ' ' на ' * '

В примере 7.8 значение, возвращаемое s t r l e n ( ) , не является инвариантом отно­
сительно цикла, поскольку удаление пробела уменьшает длину строки. Конечное ус­
ловие в данном случае нельзя выносить из цикла.
Пример 7.8. Цикn, в котором strlen

()

не явnяется инвариантом цикnа

= О ; i < s t rlen ( s ) ; + + i )
')
i f ( s[i ]
memrnove ( & s [ i ] , & s [ i + l ] , s t r l en ( & s [ i ] ) ) ; / / Удаляем пробел

for ( s i z e t i

==

'

Не существует простого правила для определения, является ли функция инва­
риантной относительно цикла в конкретной ситуации. В примере 7.8 показано, что
конкретная функция инвариантна для одного цикла, но не инвариантна для другого.
Это то место, где человеческое мышление превосходит тщательный, но ограничен­
ный анализ компилятора. (Вызов s t r l e n ( ) - не единственное неверное место в этой
функции. Другие ошибки и возможности оптимизации остаются читателям в качес­
тве упражнения.)
Одной из разновидностей функции, которая всегда может быть вынесена из цик­
ла, является чистая функция, т.е. функция, возвращаемое значение которой зависит
только от значений ее аргументов и которая не имеет побочных действий. Если та­
кая функция появляется в цикле, а ее аргумент в цикле не изменяется, то это ин­
вариантная относительно цикла функция, которая может быть вынесена из цикла.
В примере 7.7 функция s t r l e n ( ) является чистой функцией. В этом цикле длина ее
аргумента s никогда не изменяется, поэтому вызов s t r l e n ( ) инвариантен относи­
тельно цикла. В цикле в примере 7.8 вызов memmove ( ) изменяет длину s, поэтому
вызов s t r l e n ( s ) не является инвариантным.
Вот еще один пример, включающий математические функции sin ( ) и cos ( ) , кото­
рые возвращают значения математических функций синуса и косинуса для значения
в радианах. Многие математические функции являются чистыми, поэтому в числен­
ных расчетах такая ситуация возникает достаточно часто. В примере 7.9 графичес­
кое преобразование поворота применяется к фигуре с 1 6 вершинами. Хронометраж
1 00 миллионов выполнений этого преобразования занимает 7 502 мс для компилято­
ра VS20 1 0 и 6 864 мс для VS20 1 5 (как видим, этот компилятор вновь демонстрирует
преимущество в скорости генерируемого кода).
Пример 7.9. rotate ( ) с цикпом, содерж ащим инвариантные чистые функции

void rotate ( s td : : vector< Point> & v , douЫe theta )
for ( s i ze t i = О ; i < v . s i ze ( ) ; + + i ) {
douЫe х = v [ i ] . х , у = v [ i ] . у ;

v [i]



cos ( theta ) * x - sin ( theta ) * y ;

v [ i ] · У= = s i n ( theta ) * x + cos ( theta ) * y ;

1 78

Гnава 7. Оптимиза ция инструкций

{

Функции s in ( t het a ) и cos ( thet a ) зависят только от аргумента функции t h e t a и
не зависят от переменной цикла. Поэтому их можно вынести из цикла, как показано
в примере 7. 1 0.
П ример 7.1 0. rotate_invariant ( ) с инва р иантными чистыми функциями,

вы несенн ы ми из ци кп а
void rotate invariant ( std : : vector& v , douЬle theta ) {
douЫe sin-theta = s i n ( theta ) ;
cos ( theta ) ;
douЫe cos theta
for ( s i ze t i = О ; i < v . s i ze ( ) ; + + i )
douЫe х
v[i] .х , у
v[iJ .y ;
cos theta * x - s i n thet a * y ;
v [i] .x
v [ i ] . y=
s in-theta * x + cos =theta * y ;
=

=

Эта функция работает примерно н а 3% быстрее, с о временем выполнения 7 382 мс
(VS20 10) и 6 620 мс (VS20 1 5).
Экономия на настольном компьютере менее драматична, чем полученная путем
отмены вызова s t r l e n ( ) в предыдущем разделе, так как математические функции,
как правило, работают с одним или двумя числами в реrистрах и не требуют досту­
па к памяти, как s t r l e n ( ) . На встроенном же процессоре без аппаратных команд с
плавающей точкой или на древнем РС 1 990-х rодов без сопроцессора экономия мо­
жет быть куда более существенной, поскольку вычисление синусов и косинусов там
обходится rораздо дороже.
Иноrда функция, вызываемая в цикле, вообще не совершает никакой работы или
выполняет необязательную работу. Можно, конечно, удалять такие функции. Леrко
сказать "Только болван может вызывать функцию, которая не совершает никакой по­
лезной работы!" Гораздо труднее запомнить все места, rде вызывается функция, и про­
верить их все, коrда поведение функции изменяется за несколько лет жизни проекта.
Приведенный далее код представляет собой идиому, с которой я не раз сталки­
вался на протяжении своей карьеры:
UsefulTool suЬsystem ;
InputHandler input_getter ;
while ( input getter . more wor k_ava i l aЬle ( ) ) {
s uЬ sys t ern . ini t i a l i z e( ) ;
subsystem . proces s_work ( input_getter . get_work ( ) ) ;

В этой повторяющейся схеме подсистема мноrократно инициализируется для
работы, а затем запрашивает у процесса очередную порцию работы. Нет ли каких­
то "некорректностей " в коде, можно определить только путем проверки u s e f u l
Tool : : i n i t i a l i z e ( ) . Может оказаться, что i n i t i a l i z e ( J нужно вызывать только пе­
ред первой порцией работы или, возможно, для первоrо блока данных и после ошиб­
ки. Часто p r o c e s s_wo r k ( ) на выходе устанавливает тот же инвариант класса, что и
ini t i a l i z e ( ) . Вызов i n i t i a l i z e ( ) в каждой итерации цикла просто повторяет тот
же код, который выполняет p r o c e s s_wo r k ( ) . Есл и это так, вызов initialize ( ) мож­
но вынести из цикла:
Уд аnение код а из ци кnов

1 79

UsefulTool suЬsystem;

InputHandler input_gett e r ;

subsystem . ini t i a l i z e ( ) ;
whi l e ( input ge tter . more wor k ava i l aЬ le ( ) ) {

suЬsystem . proce s s_work { input_getter . ge t_wor k { ) ) ;

Не следует самонадеянно винить разработчика, который написал первоначаль­
ный код, в небрежности. Иногда поведение ini t i a l i ze { ) изменяется с перемещени­
ем кода в proce s s wo r k { ) . Иногда проектной документации оказывается недостаточ­
но или назначение кода ini t i a l i z e { ) оказывается неясным, и разработчик просто
прибегает к оборонительному программированию. Я несколько раз встречался в ре ­
альных проектах с инициализацией, которая требовалась однократно, но выполня­
лась каждый раз перед очередной порцией работы.
Если экономия времени является достаточно важной, стоит взглянуть на каждый
вызов функции в цикле, чтобы убедиться в том, что ее работа действительно необ­
ходима.
_

Удаnение скр ытых вы зовов функций и з цик n ов
Обычные вызовы функций имеют свой четко выраженный вид - с именем функ­
ции и списком выражений аргументов в скобках. Но код С++ может также вызывать
функции неявно, без этого легко обнаруживаемого синтаксиса. Это может произой­
ти, когда переменная класса появляется в любой иэ следующих ситуаций.















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

Такие вызовы функций скрытые. Они не имеют знакомого внешнего вида вызова
функции с именем и списком аргументов; они выглядят, как присваивания и объ­
явления. Поэтому очень легко упустить тот факт, что это на самом деле вызов фун­
кции. Я говорил об этом раньше, в разделе "Устранение излишнего копирования"
главы 6, "Оптимизация переменных в динамической памяти':
1 80

Гл ава 7 . О nтимизация и нструкций

Вызовы скрытых функций, являющиеся результатом построения формальных ар­
гументов функций, иногда могут быть устранены передачей ссылки или указателя
на класс вместо передачи фактического аргумента по значению. Преимущество это­
го метода было продемонстрировано для строк в разделе "Устранение копирования
строкового аргумента" главы 4, "Оптимизация использования строк� и для любо­
го объекта с копированием данных в разделе "Устранение копирования при вызове
функции" главы 6, "Оптимизация переменных в динамической памяти�
Вызовы скрытых функций в результате копирования возвращаемого функцией
значения могут быть удалены, если изменить сигнатуру функции таким образом,
чтобы возвращаемый экземпляр класса создавался по ссылке или указателю, исполь­
зуемому в качестве выходного параметра функции. Преимущество этого метода было
продемонстрировано для строк в разделе "Устранение копирования возвращаемого
значения" главы 4, "Оптимизация использования строк': и для любого объекта, кото­
рый копирует данные в разделе "Устранение копирования при возврате из функции "
главы 6, "Оптимизация переменных в динамической памяти':
Если присваивание или инициализированное объявление является инвариантом
относительно цикла, его можно вынести из него. Иногда, даже если значение пере­
менной необходимо устанавливать при каждой итерации цикла, можно вынести ее
объявление из цикла и на каждой итерации выполнять менее дорогостоящую функ­
цию. Например, s t d : : s t r i n g - это класс, который содержит динамически выделен­
ный массив char. В коде
for

(...) {
std : : s t ring s ( " " ) ;

s += " < /р> " ;

размещение объявления s в цикле for является дорогостоящим. По достижении за­
крывающей фигурной скобки блока инструкций вызывается деструктор s. Этот де­
структор освобождает динамически выделенную для s память, так что при очеред­
ной итерации для этой строки вновь должна быть выделена память. Этот код можно
переписать следующим образом:
s td : : s tring s ;
for ( . . . ) {
s . clear ( ) ;
s += " " ;
s += " < /р> " ;

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

1 81

Уда11ение доро r их мед11 енно меняю щ ихся вы з овов и з цик11ов
Некоторые вызовы функций не являются инвариантными, но могут быть таковы­
ми. Хорошим примером является вызов для получения текущего времени суток для
использования в журнале. Он выполняет нетривиальное количество команд для по­
лучения времени суток от операционной системы и еще больше времени, чтобы от­
форматировать полученное время в виде текста. В примере 7. 1 1 представлена функ­
ция форматирования текущего времени в массив символов с завершающим нулевым
символом.
Пример 7.1 1 . timetoa ( ) : форматирование времени в массиве симвоnов

# include
# in c l ude < c s t r ing>

char * t irnetoa ( char * buf , s i ze t bufs z )
if

( bu f

О 1 1 bu f s z < 9)

==

return nul lpt r ;

s t d : : t ime ( nul lptr ) ;

trn trn

* s td : : loca l t ime ( & t ) ;

s i ze

/ / минуты,

t sz

if (sz

аргумент
/ / Получение времени от
/ / операционной сис т емы
/ / Разделение на часы,
/ / Неверный

t irne t t

се кунды

1 1 Форматирование в
" % с " , & trn ) ; / / буфере bu f
1 1 Ошибка

s td : : s t r f t ime ( bu f , bufs z ,
0)

s td : : s t rcpy ( bu f ,

return bu f ;

"ХХ : ХХ : ХХ " ) ;

Хронометраж показывает, что t imetoa ( ) требует около 700 нс для получения
значения времени и его форматирования. Это значительная стоимость, почти вдвое
превышающая добавление двух текстовых строк в файл. В том же тестовом цикле
инструкция
out

« " Fri Jan 01 0 0 : 0 0 : 0 0 2 0 1 6 "
< < " Тестовая строка дл я вывода в журнальный файл \ n " ;

требует только 372 нс, в то время как инструкция
out



int main ( int , char* * ) {
printf ( "Hello, World ! \n " ) ;
return О ;
}

Сколько выполнимых байтов должна содержать эта программа? Если вы
предп оложили " о коло 50 или 1 00 байто в� вы о шиблись на два по рядка. Эта
пр о грамма занимала б олее 8 Кбайтов на встроенном контроллере , для ко­
торого мне как-то пришлось программировать. И это просто код, не таб­
лицы симв оло в, не информация загрузчика или что-нибудь еще.
В о т еще одна программа, выпо лняющая те же действия:
#

i n c l ude < s tdio . h >

int main ( int , char* * ) {
puts ( "Hello, World ! " ) ;
return О ;
}

Эта программа является практически той же самой, но отличается толь­
ко исп ользованием для выв ода стр о ки put s ( ) вместо print f ( ) . Но вторая
пр о грамма занимает на то м же ко нтр о ллере о коло 1 00 байто в. Что же вы­
зывает такую разницу в размерах?

226

Глава 8. Ислол ьэова ние лучш их библ иотек

Вин о вницей является функция print f ( ) . Она может выв одить каждый
ко нкретный тип в трех или четырех ф орматах. Она может интерпре­
т ировать строку формата и читать переменное количество аргументов .
prin t f ( ) - б ольшая функция сама по себе, но что действительно делает
ее б ольш о й, так это то , что о на подтягивает функции стандартно й библи­
отеки для фо рматирования каждого базового типа. На моем встроенном
контроллере все обстояло еще хуже, потому что процессор не реализовы­
вал арифметику с плавающей то чко й аппаратно, и вместо этого использо­
валась обширная библи о тека функций. print f ( ) на самом деле представ­
ляет собой образцовый плакат "функции Бога" - функции, которая делает
так мн о го , что втягивает в программу огромные фрагменты стандартной
библиотеки времени выполнения С.
Функция же put s ( ) просто отправляет единственную строку на стандар­
тный выв од. Внутренне о на до в о льн о пр о ста, а главное - не заставляет
компоноваться с пр о граммой п ол о вину стандартной библиотеки.

Ре з ю м е














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

Ко н трольные примеры критичны для опт имизации библиотеки.
Про ектирование библиотеки сходно с проектированием другого кода на С+ +,
но с гораздо более высокими ставками.
Большинство абстракций требуют не более трех слоев н аследования классов.
Реализации большинства абстракций требуют не более трех вложенных вы­
зовов фу нкций .

Реэ �оме

227

ГЛАВА 9

Оптими з ация с о рти р о в ки и п ои с ка

Если есть способ сделать д ело лучше - най д и его .
- Томас Алва Эдисон (Thomas А. Edison) { 1 847- 193 1 ) , американский
изобретатель и оптимизатор

Программы на языке С++ выполняют множество поисков. От программирования
компиляторов языка до веб-браузеров, от списков до баз данных - многие повторя­
ющиеся действия включают поиск в самых глубоко вложенных внутренних циклах.
По моему опыту поиск достаточно часто оказывается в списке самых горячих функ­
ций. Поэтому эффективному поиску стоит уделить особое внимание.
В этой главе рассматривается поиск в таблицах с точки зрения оптимизатора.
Я использую поиск в качестве примера обобщенного процесса, при попытках опти­
мизации которого разработчик может разделить существующее решение на алгорит­
мы и структуры данных, а затем рассмотреть каждую часть в поисках возможностей
повышения производительности. При демонстрации процесса оптимизации я также
рассматриваю некоторые конкретные методы поиска.
Большинство разработчиков на С++ знают, что контейнер s td : : map стандартной
библиотеки можно использовать для поиска значений, которые связаны с числовым
индексом или буквенно-цифровой строкой. Такие ассоциации называются таблица­
ми
"ключ/значение ". Они создают отображение ключей на значения. Разработчики,
знакомые с std : : map, помнят, что этот контейнер имеет хорошую в смысле "боль­
шого О" производительность. В этой главе рассматриваются способы оптимизации
поиска на основе отображений.
О том, что заголовочный файл стандартной библиотеки С++ содер­
жит несколько алгоритмов на основе итераторов, которые выполняют поиск в после­
довательных контейнерах, знает меньшее количество разработчиков. Даже при опти­
мальных условиях не все эти алгоритмы имеют такую же эффективность в терминах
большого О. Наилучший алгоритм для каждой ситуации не очевиден, а советы в Ин­
тернете не всегда указывают оптимальный метод поиска. Поиск лучшего алгоритма
поиска представлен как еще один пример процесса оптимизации.
Но даже разработчики, которые хорошо знают используемые ими алгоритмы
стандартной библиотеки, могли не слышать, что в С++ 1 1 в стандартную библиотеку

вошли контейнеры на основе хеш-таблиц (а задолго до этого они были доступны в
библиотеке Boost ( ht tp : / /www . boo s t . org / }) . Эти неупорядоченные ассоциативные
контейнеры демонстрируют превосходную эффективность с константным средним
временем поиска, но и они не являются панацеей.

Та бл и ц ы " кл юч/зна ч ение" с испол ьзованием
std : : шар и std : : string
В качестве примера в этом разделе рассматривается производительность поиска
и сортировки очень распространенной разновидности таблицы "ключ/значение". Ти ­
пом ключа таблицы является строка АSСП-символов, которая может быть инициа­
лизирована строковым литералом С++ или храниться в s td : : s t r ing 1 • Такого рода
таблицы обычно используются в ходе анализа профилей инициализации, команд­
ных строк, ХМL-файлов, таблиц баз данных и других приложений, т ребу ю щи х ог­
раниченного набора ключей. Тип значения таблицы может как представлять собой
простое целое число, так и быть произвольно сложным. Тип значения не влияет на
производительность поиска, за исключением того, что действительно большое значе­
ние может снизить производительность кеша. По моему опыту в любом случае доми­
нируют простые типы значений, так что мы будем считать, что в нашей таблице тип
значения - простой uns igned
Легко создать т аблицу, которая отображает имена std : : s t r ing на значения с помощью s td : : map. Такая таблица может быть определена очень просто:
# include < s t r ing>
# include
s td : : map< s td : : string , uns igned> tаЫе ;

Разработчики, использующие компилятор C++ l 1 , могут использовать синтаксис
со списком инициализации, который позволяет легко заполнить таблицу записями:
std : : map< std : : string , uns i gned> const tаЫе
{
{
{
{
{
{
{

{

{

{

{

{

} ;

{

" alpha " ,

1

} ,

5

}

{

2 },
4 },
" foxtrot " , 6 } ,
"hote l " ,
В } ,
" j uliet " , 1 0 } ,
" l ima " ,
12 } ,
"novemЬe r " , 1 4 } ,
" рара " ,
16 } ,
" romeo " ,
18 } ,
20 } ,
" tango " ,
"victor " , 2 2 } ,
24 } ,
" x - ray" ,
26 }
" zu lu " ,

"bravo " ,

" charl ie " , 3 } , { "de l ta " ,

" echo " ,

"gol f " ,

" india " ,
" ki lo " ,
11
13
"mi ke " ,
" oscar " , 1 5
" queb ec " , 1 7
" s ierra " , 1 9
"uni form" , 2 1
"whi s key" , 2 3

"yankee " ,

, {

7 },
9 },

}
}
}
}
}
}
}
25 }

,
,
,
,
,

{
{
{
{
{
{
{

, {
, {

, {

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

230

Гnава 9. Оптимизация сорти ровки и поиска

Если ко мпилято р не поддерживает такой синтаксис, разработчик должен использо­
вать код для вставки каждого элемента наподобие следующего:
taЬle [ " alpha " ]
1;
2;

taЬle [ "bravo " ]

taЫe [ " zulu " ]

26;

Получить или пр о тестировать значения не составляет никакого труда:
uns i gned va l
std : :

taЬle [ " echo " ] ;

=

s t r ing key

=

"diarnond" ;

i f ( taЫe . f ind ( key) ! = t aЫe . end ( ) )

std : : cout

243

Однако немного шаблонной магии предоставляют нам возможность написать шаб­
лонные функции для этой цели. Поскольку они принимают тип массива в качестве
аргумента, массив не приводится к указателю, как это обычно происходит:
//
Получение размера и итера торов beg i n / end для массивов в стиле
template < t yp e n ame Т , int N> s i ze t s i ze ( T ( & а ) [ N ] ) {
return N ;
t emplate < typename Т , int N > Т * begin ( T ( & а )

return

&а [ О ] ;

С

[N] )

}

template Т * end ( T ( &а ) [ N ] ) {
return &а [ N ] ;

В С++ 1 1 в з а голо во чном фа йле < i tera tor> в пространстве имен std имеются бо­
лее сложные определения begin ( ) и end ( ) , созданные с помощью той же шаблонной
магии. Этот заголовочный файл включается всякий раз при включении загол ов оч но­
го файла любого контейнера стандартной библиотеки. Visual Studio 20 1 0 предостав­
лял эти определения в ожидании выхода нового стандарта. К сожалению, функция
s i z e ( ) не вошла в стандарт до С++ 1 4 и не реализована в Visual Studio 20 1 0 , хотя
написать ее упрощенный эквивалент достаточно легко.

s td : : find ( )

: очевидное имя, стоимост ь

-

O(n)

Заголовочный файл стандартной библиотеки определяет шаблонную
функцию find ( ) следующим образом:
t emplate It find ( I t f i rs t , It last , const Т& key) ;

Алгоритм find ( ) представляет собой простой линейный поиск - наиболее об­
щий вид поиска. Он не требует никакого упорядочения данных, в которых прово­
дится поиск, и требует только реализацию проверки равенства ключей.
Алгоритм find ( ) возвращает итератор, указывающий на первую запись в после­
довательном контейнере, имеющую ключ, эквивалентный искомому. Аргументы-ите­
раторы first и last определяют диапазон поиска, причем итератор last указывает
на первый элемент после окончания данных, в которых выполняется поиск. Типы
f i r s t и l a s t , задаваемые параметром шаблона I t, зависят от вида структуры дан­
ных, которую обходит find ( ) . Соответствующий пример приведен в примере 9.4.
Пример 9.4. Линейный поиск с иmоnьзованием std : : find ( )
kv* resu l t = s td : : find ( s td : : begin ( name s ) , s t d : : end ( name s ) , key) ;
В

этом вызове name s - имя массива, в котором выполняется поиск. key представ­
ляет собой значение, сравниваемое с каждой записью kv. Чтобы выполнить сравне­
ние, в области видимости инстанцирования функции find ( ) должна быть определе­
на функции сравнения ключей. Эта функция говорит s td : : find ( ) все, что ей нужно
знать, чтобы выполнить сравнение. С++ позволяет перегрузить оператор равенства
boo l ope rator== ( v l , v2 ) для значений произвольных пар типов. Если key представ­
ляет собой указатель на char, то такая функция имеет следующий вид:
244

Глава 9. Опти мизация сортировки и поиска

bool operator== { kv con s t & n l , char con s t * key ) {
return s t r crnp ( n l . key , key ) == О ;

Хронометраж с использованием std : : find ( ) и набором ключей, как имеющихся
в 26-элементной таблице, так и отсутствующих в ней, занимает 1 425 мс.
Вариация функции f ind ( ) с названием f ind_ i f ( ) принимает в качестве треть­
его аргумента функцию сравнения. Вместо определения оператора operato r== ( ) в
области видимости find ( ) разработчик может написать его как лямбда-выражение.
Лямбда-выражение принимает один аргумент - элемент таблицы, с которым вьшол­
няется сравнение. Таким образом, лямбда-выражение должно захватывать значение
ключа из среды выполнения.

s td : : binary search ( ) : не возвращает значения
_
Бинарный поиск является стратегией "разделяй и властвуй': которая обычно на­
столько полезна, что стандартная библиотека С++ предоставляет несколько различ­
ных алгоритмов на ее основе. Но по какой-то причине такое "говорящее" имя, как
Ьinary_ search, оказалось использованным для алгоритма, который не слишком по­
лезен для поиска значений.
Алгоритм стандартной библиотеки Ь inary_ search ( ) возвращает значение типа
bool, указывающее, находится ли ключ в отсортированной таблице. Как ни странно,
связанной функции для возврата соответствующего элемента таблицы нет. Таким
образом, ни одно из двух очевидных имен, find и Ьinary_ search, не дает нам иско­
мого решения.
Если программа просто хочет знать, есть ли искомый элемент в таблице, и ее не
интересует значение этого элемента, то тест с применением s t d : : Ьinary_search ( )
выполняется за 972 мс.

Бинарный поиск с исn оnьзовани ем s td : : equal_ranqe ( )
Если последовательный контейнер отсортирован, разработчик может собрать во­
едино эффективную функцию поиска из фрагментов, предоставляемых стандартной
библиотекой С++. К сожалению, эти элементы имеют имена, которые никак не ассо­
циируются с понятием бинарного поиска.
Заголовочный файл стандартной библиотеки С++ содержит шаблон­
ную функцию std : : equa l_range ( ) , определенную следующим образом:
template
std : : pa ir
equa l_range ( Fo rward l t f i rs t , Forwardl t las t , con s t Т & val ue ) ;

equal_range ( ) возвращает пару итераторов, отделяющую подпоследовательность
отсортированной последовательности [ f i r s t , l a s t ) , в которой содержатся записи
со значением, равным value. Если записей со значением value нет, equal _range ( )
возвращает пару итераторов, указывающих на одну и ту же точку, идентифицируя
таким образом пустой диапазон. Если возвращенные итераторы не равны, в кон­
тейнере имеется хотя бы одна запись, которая имеет значение value. По способу
О птим изация пои ска с испоп ьзованием за rоповочноrо файn а

245

построения, использованному в нашем примере, в ко нтейнер е может иметься не бо­
лее одного совпадения, и первый итератор указывает на него. В примере 9.5 перемен­
ная resu l t указывает на соответствующую запись в таблице (если ключ найден) или
на конец таблицы (в п ро тив н о м случае).
Бинарный поиск с исnоnьзованием s td : : equal ranqe ( )
auto res = std : : equa l - range ( std : : begin ( name s ) ,
std : : end ( names ) , key ) ;
kv* result
( re s . f i r s t
res . second )
? s td : : end ( name s )
res . fi r s t ;

Пример

9.S.

==

Эксперимент по определению производительности equ a l _ range ( ) для приве­
денной ранее таблицы дает время выполнения 1 8 1 0 мс. Фактически это хуже, чем
линейный поиск дл я таблиц ы тако го же размера, что оказывается довольно шокиру­
ющим. Однако далее мы увидим, что equal _range ( ) не является лучшим выбором в
качестве функции бинарного поиска.

Бина р ный п оиск с испо11ьзованием

s td : :

lower_bound ( )

Хотя equal _ range ( ) обещает стоимость поиска O(log2n), в действительности в
ней выполняется больше действий, чем требуется для простого поиска в таблице.
Возможная реализация equal_ range ( ) может выглядеть следующим образом:
template
s td : : pa i r< I t , I t >
equal range ( I t fir s t , I t l a s t , const Т & value ) (
return s td : : ma ke pa i r ( s td : : l ower bound ( fi r s t ,
value )
s td : : upper bound ( fi r s t ,
va lue )

,

las t ,

la s t ,
);

Функция upp e r_bound ( ) для поиска конца возвращаемого диапазона делает
второй проход через таблицу в стиле "разделяй и властвуй", поскольку алгоритм
equa l _ range ( ) достаточно обобщенный для работы с отсортированными последо­
вательностями, содержащими более одно го значени я с оди наковы м ключом. Но в
соответствии с принципом построения в таблице из нашего примера диапазон всег­
да будет содержать либо од ну запись, либо ни од н ой. Таким образом, поиск может
выполняться с помощью lower_bound ( ) и одного дополнительного сравнения, как
показано в примере 9.6.
Пример 9.6. Бинарный поиск с исnоnьзованием s td : : lower Ьound ( )
kv* resul t

=

s td : : lower bound ( s td : : begin ( names ) ,
s td : : end ( name s ) ,
key ) ;
i f ( result ! = std : : end ( name s ) & & key < * resul t . key )
re sult
s td : : end ( names ) ;
=

В этом примере s td : : lower_bound ( ) возвращает итератор, указывающий на пер­
вую запись в таблице, ключ которой не меньше искомого ключа. Если все записи
246

Г11ава 9 . Опт11м11зация сорти ров ки 11 поиска

меньше искомого ключа, итератор указывает за конец таблицы. Поскольку данный
итератор может указывать на запись, которая больше искомого ключа, последняя
инструкция if устанавливает resul t за конец таблицы, если любое из этих условий
истинно. В противном случае re sul t указывает на запись, ключ которой равен key.
Хронометраж с применением такого варианта поиска дает время выполнения
973 мс, что на 46% быстрее, чем s td : : equal _range ( ) . Этого следовало ожидать, так
как новый вариант делает половину работы.
Поиск с использованием s t d : : l owe r_bound имеет производительность, конку­
рентоспособную с лучшей реализацией с применением std : : map, и обладает допол­
нительным преимуществом нулевой стоимости построения и уничтожения статичес­
кой таблицы. Тест с функцией std : : Ьinary_ search ( ) также выполняется за 973 мс,
хотя и дает только логический результат. Похоже, это все, чего мы можем достичь с
использованием алгоритмов стандартной библиотеки С++.

Са мостоятеn ьн о е кодирование бинарноrо поиска
Можно самостоятельно написать функцию бинарного поиска, принимающую
те же аргументы, что и функции стандартной библиотеки. Все алгоритмы стандар­
тной библиотеки использ уют одну функцию упорядочения, ope rator< ( ) , так что
необходимо предоставить только минимальный интерфейс. Поскольку эти функ­
ции в конечном итоге определяют, соответствует ли ключ записи, в конце они де­
лают дополнительные сравнения исходя из того, что а==Ь можно определить как
! ( а tаЫе ;

for { auto i t = name s ; i t ! = name s+name s i ze ; + + i t )

taЬle [ it->key] = i t ->value ;

Хеш-функция, используемая s t d : : un o r d e r e d_ma p по умолчанию, - объект
шаблона функции s t d : : ha s h. Этот шаблон имеет специализацию для s t d : : s t r i ng,
поэтому предоставлять хеш-функцию явно не требуется.
После внесения записей в таблицу можно выполнить поиск:
auto it

=

taЫe . find { ke y ) ;

i t - это итератор таблицы, который либо указывает на корректную запись, либо
равен t а Ы е . end ( ) .
Для простоты и обеспечения производительности s t d : : unordered_map с клю­
чами s t d : : s t r i ng использует все значения по умолчанию шаблона s t d : : map. Эк­
сперимент по измерению производительности дает значение времени выполнения
1 725 мс ( б ез учета времени на созда н ие табл и цы). Это на 25% б ы стрее, чем s t d : : map
со строковыми ключами, но едва ли его можно считать мировым рекордом. С уче­
том уве р ен ий в литературе о превосходной производительности хеширования этот
результат кажется удивительным и разочаровывающим.

Хеширование с фиксирован ными симвоnьными
массивами в качестве кnючей
Версия шаблона класса простого фиксированного символьного массива c h a r b u f
из раздела " Применение символьных массивов фиксированного размера в качестве
ключей std::map" данной главы может использоваться и с хеш-таблицами. Приведен­
ный далее шаблон расширяет c h a r b u f средствами для хеширования символьной
строки и оператором ope r a t or== ( ) для сравнения ключей в случае коллизии:
template
s t ruct charbuf {
charbuf { ) ;

charbuf ( charbuf const&
ch a rb u f { T con s t * р ) ;

сЬ ) ;

charbuf& operator= { charbuf const & rhs ) ;

charbuf & operator= { T cons t * rhs ) ;
operator s i ze_t { ) cons t ;

bool operator== { charbuf const& that ) cons t ;
bool op e r a tor < { ch a rbu f cons t & tha t ) cons t ;
private :
Т data_ [ N J ;
};

250

Гnава 9. О птимизация сорти ровки и поиска

Хеш-функция представляет собой o p e r a t o r s i z e t ( ) . Это не слишком ин­
туитивно понятный результат. Дело в том, что специализация по умолчанию для
s t d : : h a s h ( ) получает аргумент типа s i z e _ t. В случае указателей выполняется
обычное приведение битов указателя, но в случае c h a rbu f & вызывается оператор
приведения c h a r b u f : : ope r a t o r s i z e_ t ( ) , возвращающий значение типа s i z e_t.
Объявление хеш-таблицы с использованием charbuf выглядит следующим образом:
std : : unordered_rnap tаЫе ;
Произ водительность этой х еш - табл и ц ы

2 277 мс,

разочаровывает. Тест выполнился за
что даже хуже, чем в случае хеш-таблицы или отображения с s td : : s t ring.

Хеширование с кn ючами в виде строк
с заве рша ю щ ими нул евым и символ ами
Такие вещи тре буют д еликатности, иначе чары ра з веются . . .
- Злая Колдунья Запада (Маргарет Хэмилтон (Margaret Hamilton),
к!ф Воли1ебник страны Оз, ( 1 939) ), размышляя о том, как снять
башмачк и

с

Дороти

Если хеш -таблица может быть инициализирована долгоживущими строками с
завершающими нулевыми символами, такими как строковые литералы С++, то хеш­
таблица "ключ/значение" может быть построена с использованием указателей на эти
строки. Золото производительности можно добыть из шахты s t d : : unorde red_map,
работая с ключами c h a r * . Но это задача далеко не тривиальная .
Вот как выглядит полное определение s t d : : uno rde red_map:
ternplate<
t ypenarne Кеу ,

typenarne Va lue ,

typenarne H a sh

s td : : hash ,
typenarne Ke yEqua l = s td : : equa l_to ,
=

typenarne Al locator = s td : : al locator< std : : pa ir< const Кеу , Value>>

> c l a s s unordered_rnap ;

представляет собой объявление типа функционального объекта или ука­
зателя на функцию для функции, которая хеширует Ке у. Ke yEqu a l - объявление
типа функционального объекта или указателя на ф у нк ц и ю для функции, которая
сравнивает два экземпляра Ке у на равенство для разрешения коллизий хеша.
Если Кеу я вляется указателем, H a s h точно определен. Программа будет компи­
лироваться без ошибок. Может даже показаться, что она работает. (Мои первые
тесты дали отл и ч ные результаты времени и ложное чувство выполненного долга.)
Но написанная таким образом программа не является правильной ! Дело в том, что
s t d : : ha s h создает хеш значения указателя, а не строки, на которую он указывает.
Если, например, тестовая программа загружает таблицу из массива строк, а затем
проверяет, действительно ли каждую строку можно найти, то указатели, использо­
ванные в тесте для поиска, совпадают с указателями на ключи, которые использова­
ны при инициализации таблицы, и создается впечатление, что она отлично работает!
Hash

Оптими заци я п оис ка в хеwи рова н н ы хтабnицах "кn юч/значение•

251

Протестируйте таблицу с дубликатами строк, полученными из пользовательского
ввода, и чары развеются - тест будет утверждать, что такой строки нет в таблице,
так как указатель на искомую строку не совпадает с указателем на ключ, которым
и ниц иализировалась таблица.
Эта проблема может быть решена путем предоставления нестандартной хеш­
функции вместо значения по умолчанию для третьего аргумента шаблона. Как и для
отображения, эта хеш-функция может быть функциональным объектом, лямбда-вы­
ражением и л и ук азател ем н а с в ободн ую фун кц и ю :
s t ruct hash с s t r ing (
vo id hash-comЬine ( s i z e t & seed , Т con s t & v ) {
seed л= v + О х 9е3 779Ь 9 + ( seed > 2 ) ;
s td : : s i ze t operator ( ) ( char con s t * р ) con s t {
s i ze t hash = О ;
for { ; * р ; ++р )
hash comЬ i ne ( hash , * р ) ;
return hash ;
};

1 1 Э то решение не пол ное
см . ниже
s td : : unordered_map tаЫ е ;
-

Я взял хеш-функцию из Boost. Она имеется в реализации стандартной библио­
теки, соответствующей С++ 14 или более поздней версии. Увы, Visual Studio 20 1 0 эту
функцию не предоставляет.
Тщательное тестирование показывает, что это объявление все еще не корректно,
хотя и компилируется без ошибок и дает программу, которая может работать для
некоторых небольших таблиц. Проблема связана с KeyEqual, четвертым аргументом
ш аблон а s t d : : unordered_map. З н аче н и ем это го ар гумен та по умолча н ию является
std : : equa l_to, функциональный объект, который применяет оператор operator== к
двум своим операндам. Этот оператор определен для указателей, и сравнивает указа­
тели по их порядку в памяти компьютера, а не по строкам, на которые они указыв ают.
Решением, конечно, является другой нестандартный функциональный объект па­
раметра шаблона KeyEqual. Полное решение показано в примере 9. 1 0.
П р име р 9.10. s tc:l : : unordered_шар с кnючами, явnяющимися

строками с заверwа�ощими нуnев ы ми симвоnами
s t ruct hash с s t ring {
void hash-comЬine ( s i ze t & seed , т cons t & v ) {
seed л= v + Ох9е3779Ь 9 + ( seed > 2 ) ;
s td : : s i ze t operator ( ) ( char con s t * р ) const {
s i ze t hash = О ;
for (; * р ; ++р )
hash comЬine ( hash , *р ) ;
return has h ;
};
252

Гn ава 9. Оптимизация сортировки и поиска

s t ruct сотр с s t r ing {
bool operator ( ) ( char cons t * p l , char cons t * р2 )
return s t rcmp ( p l , p2 ) == О ;

con s t {

};

std : : unordered map<
char con s t* ,
uns i gned ,
hash_c_s tring ,
comp с s t ring
> tаЫ е ; - -

Эта версия таблицы "ключ/значение", использующая s t d : : u n o r d e r e d_map
и char, выполняет тест за 993 мс. Это на 56% быстрее, чем хеш-таблица на осно­
ве s t d : : s t r i ng. Но это медленнее, чем лучшая реализация на основе s td : : map и
ключей cha r * . И медленнее, чем бинарный поиск в простом статическом массиве
записей "ключ/значение" с помощью s t d : : lower_bound. Это совсем не то, чего я мог
ожидать после нескольких лет шумной рекламы. (В разделе " s td : : uno rde red_map и
s td : : unordered_mu l t imap" главы 1 0, " Оптимизация структур данных': мы увидим,
что большие хеш-таблицы имеют большее преимущество перед алгоритмами бинар­
ного поиска. )

Хе ширование с nо11 ьз овате11ьской хеw -таб11и цей
Хеш-функция для использования с неизвестными ключами должна быть очень
обобщенной. Если же ключи известны заранее, как в таблице из нашего примера,
может быть достаточно очень простой хеш-функции.
Хеш, создающий таблицу, в которой нет никаких коллизий для данного набора
ключей, называется идеальным. Хеш, который создает таблицу без неиспользуемых
пробелов, называется минимальным. Святой Грааль хеширования - минимальный
совершенный хеш, создающий таблицу без коллизий и пробелов. Легко создавать
идеальные хеши для разумно коротких наборов ключевых слов; идеальный мини­
мальный хеш лишь немногим сложнее. Первая буква (или первые несколько букв),
сумма букв, а также длина ключа - все это примеры хеш-функций, которые могут
быть опробованы.
В примере таблицы для этого раздела 26 допустимых записей начинаются с раз­
ных букв и отсортированы, поэтому хеш на основе первой буквы является идеаль­
ным минимальным хешем. Недопустимые ключи не имеют никакого значения. Они
сравниваются с корректным ключом, имеющим тот же хеш, и сравнение завершается
ошибкой. В примере 9. 1 1 показана очень простая пользовательская хеш-табли ца, ре­
ализованная в стиле s td : : unordered_map.
П ример 9.1 1 . П ример м и н и ма11ьной идеа 11ьной хеw-та б11ицы

uns i gned hash ( char con s t * key )
return key [ 0 ] % 2 6 ;

{

Оптимизация поиска в хе w ирова н н ы х табnи цах •кn�оч/зна чен и е "

253

kv* find hash ( kv* firs t , kv* la s t , char con s t * key )

uns igned i

=

hash ( key ) ;

return strcmp ( fi r s t [ i ] . key, key)

?

last : first

+

i;

Функция hash ( ) отображает первую букву key на одну из 26 записей таблицы.
Хронометраж функции find_hash ( ) показал время выполнения, равное 253 мс.
Этот результат просто ошеломляет!
Хотя нам просто очень повезло с простой хеш-функцией, которая работает для
нашей таблицы-примера, сама таблица не является специально созданной для до­
стижения такого впечатля ющего результата. Очень часто существует простая
функция, которая представляет собой минимальный идеальный хеш. В Интернете
можно найти статьи, в которых обсуждаются различные методы автоматическог о
создания совершенной минимальной хеш-функции для небольших наборов клю­
чевых слов. GNU Project (среди прочих) создал утилиту командной строки gpe r f
(ht tp : / /www . gnu . org / software/ gperf / ) для создания идеальной хеш-функции, ко ­
торая часто оказывается одновременно и минимальной.

Цена а б стра кци й Степанова
Эксперимент, который я испо л ьзов а л в этой главе, выполняет поиск всех 26 до­
пустимых и 27 недопустимых записей таблицы. Это позволяет оценить своего рода
среднюю производительность. Линейный поиск только для ключей, с одержащихся в
таблице, выглядит относительно лучше, так как завершается немедленно по нахож­
дении искомой записи. Бинарный поиск делает примерно одинаковое количество
сравнений для поиска как имеющихся в таблице ключей, так и отсутствующих.
В табл. 9. 1 подытожены результаты моих экспериментов.
Таб11ица 9.1. Время работы раз11ичных методов поиска
VS20 1 0, i7, 1 М
итера ц и й , мс

Уnучwение п о
срав н е н и ю с
п ред ы ду щи м
экспериментом, %

Уnучwение

map< s t ring>

2307

map< cha r * > free f unct i on
map< char * > funct ion obj ect

1 453

37

37

820

44

64

о

64

map larnЬda
std : : find ( )
std : : equal_range ( )

820

1 42 5

в

кате гории , %

1 806

s td : : lowe r bound

973

46

46

find_binary_Зway ( )
std : : unordered_map ( )
find_hash ( )

77 1

21

57

62

62

509
1 95

Как и ожидалось, бинарный поиск оказался быстрее, чем линейный, а хеширова­
ние быстрее, чем бинарный поиск.
254

Гnава 9 . Оптимизация сортировки и п оиска

Стандартная библиотека С++ предоставляет множество готовых к использова­
нию, отлаженных алгоритмов и структур данных, которые полезны во многих си­
туациях. Стандарт определяет их стоимость в наихудшем случае в О-записи, чтобы
продемонстрировать их широкую применимость.
Но чрезвычайно мощный и универсальный механизм стандартной библиотеки
имеет свою стоимость. Даже если существует стандартный алгоритм библиотеки с
хорошей производительностью, зачастую он не может конкурировать с лучшим ал­
горитмом, закодированным вручную. Это может быть связано с недостатками в коде
шаблона или недостатками в дизайне компилятора, или просто потому, что код стан­
дартной библиотеки должен работать для очень общих ситуаций (например, исполь­
зуя только operator< ( ) , а не s t rcmp ( ) ). Эта стоимость может убедить разработчика
кодировать действительно важные поиски вручную.
Этот разрыв в производительности между стандартными и хорошо закодирован­
ными вручную алгоритмами называется стоимостью абстракций Степанова в честь
Александра Степанова, который разработал оригинальные исходные алгоритмы
стандартной библиотеки и классы контейнеров еще в то время, когда не существова­
ло компилятора, способного их скомпилировать. Стоимость абстракции Степанова
является неизбежной ценой универсального решения по сравнению с пользователь­
ским. Это плата за использование высокопродукт ивных инструментов, таких как
алгоритмы стандартной библиотеки С+ +. Это не плохо, но это то, что разработчи­
кам необходимо иметь в виду, когда им нужна очень высокая производительность.

О пти м изация сорт и ро в к и с испоnьзо ван ие м
стандартн о й б и бn и отеки С ++
Последовательные контейнеры должны быть отсортированы, прежде чем над
ними можно будет выполнять эффективный поиск с помощью алгоритмов "разде­
ляй и властвуй': Стандартная библиотека С++ предоставляет два стандартных алго­
ритма, std : : sort ( ) и std : : staЫe_ sort ( ) , которые могут эффективно сортировать
последовательные контейнеры.
Хотя стандарт не указывает, какой именно алгоритм сортировки используется,
он написан так, что std : : sort может быть реализован с помощью некоторых ва­
риаций алгоритм а быстрой сортировки, а s td : : s t aЫe _ sort может быть реализо­
ван с помощью сортировки слиянием. Стандарт С++ОЗ требовал, чтобы алгоритм
std : : sort имел среднюю производительность О(п · log2п). Реализации, соответству­
ющие стандарту С+ +ОЗ, обычно реализуют s td : : sort с использованием быстрой
сортировки, обычно с некоторыми хитрыми вариантами выбора медианы, чтобы
уменьшить вероятность получения времени работы быстрой сортировки О(п2) в на­
ихудшем случае. С++ 1 1 требует, чтобы наихудшая производительность сортировки
была О(п log2n). Реализации, соответствующие стандарту С++ 1 1 , как правило, явля­
ются гибридными, такими как тимсорт или интроспективная сортировка.
Алгоритм s t d : : s t aЫ e _s o r t обычно представляет собой некоторый вари­
ант сортировки слиянием. Своеобразная формулировка стандарта гласит, что
s t d : : s t a Ы e _ s o r t имеет время работы О ( п · lоg2 п ) , если может быть выделен
·

О птим иза ци я сорти ровки с и с поnьзованием ста ндартной бибnиотеки С++

255

достаточный объем дополнительной памяти, в противном же случае ero время рабо­
ты
О( п (log2 n)2). Типичная реализация заключается в использовании сортировки
слиянием, если глубина рекурсии не слишком велика, и пирамидальной сортиров­
ки - в противном случае.
Важность устойчивой сортировки заключается в том, что программа может отсор­
тировать диапазон записей по каждому из нескольких критериев (как, например, имя,
а затем фамилия) и получить записи, отсортированные по второму критерию, а затем
по первому критерию в рамках второго (например, по фамилии, а затем по имени
в пределах одинаковых фамилий). Этим свойством обладает только устойчивая сор­
тировка. Это дополнительное свойство оправдывает наличие двух видов сортировки.
В табл. 9 . 2 приведены результаты сортировки 1 00 тысяч случайным образом сге­
нерированных записей " ключ/значение" в контейнере s t d : : vector. Интерес но, что
std : : s taЫe_ sort ( ) имеет более высокую производительность, чем s td : : sort ( ) .
Я также тестировал сортировку для уже отсортированной таблицы. Сортировку в дру­
гих структурах данных я рассматриваю в главе 1 0, "Оптимизация структур данных':
-

·

Табnица 9.2. Резуnьтаты изуч ен ия производитеnь ности сортировок
s td : : vector,

std :
std :
std :
std :

1 00k эл е м енто в, VS201 0, i7

: sort ( ) для в ектора
: sort ( ) для отсорти рова н ного ве ктора
: staЫe_sort ( ) для вектор а
: staЫe_sort ( ) для отс орти рова н н о го вектора

В ремя, мс

1 8,61

3,77
1 6,08
5,0 1

Последовательный контейнер s t d : : l i s t обеспечивает только двунаправлен­
ные итераторы, поэтому с ним алгоритм s td : : s o r t ( ) будет работать за время
О( п 2 ). Однако std : : l i s t предоставляет функцию-член sort ( ) со временем раб оты
O(n · log2 n).
Упорядоченные ассоциативные контейнеры хранят свои данные в отсортированном
порядке, поэтому их сортировать не нужно. Неупорядоченные ассоциативные контей­
неры поддерживают свои данные в определенном порядке, который не представляет
интереса для пользователей. Такие контейнеры не могут быть отсортированы.
Заголовочный файл стандартной библиотеки С++ содержит фраг­
менты различных алгоритмов сортировки, из которых могут быть построены более
сложные виды сортировки для входных данных, обладающих дополнительными спе­
циальными свойствами.







256

std : : heap_sort преобразует диапазон, обладающий свойством пирамиды, в
отсортированный диапазон. Сортировка heap_ sort не является устойчивой.
s td : : pa r t i t ion выполняет базовое действие по разбиению диапазона для
быстрой сортировки.
std : : merge выполняет базовое действие сортировки слиянием.
Член insert различных последовательных контейнеров выполняет базовое
действие для сортировки вставками.
Гnава 9 . Оnтмммзацмя сортм ровкм м поиска

Ре з юме


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



В большинстве случаев имеется такое огромное количество возможностей оп­
тимизации, что человеческая память не в состоян и и дост о верно помнить их
в се. Бум ага имеет луч шую п амять.
s t d : : u n o rde red_ тар



В тесте поиска в табли ц е



Це н а абстрак ци й Ст е п анов а являе тся плат о й за испол ьзован и е таких вы­
сокопроду кт ивн ых инструментов, как алгоритмы станд артной библиоте­
ки С+ +.

с 26 строковым и клю чам и
г
т олько на 25% быстрее, чем поиск в анало ичном s t d : : тар. С учетом рекламы
в ыигры ша произво д ител ь но ст и при хешировании эт о т р езул ьт ат выгляд ит
более чем скром но.

Реэюме

257

ГЛА ВА 1 0

Оптимизация с тру кт ур данны х

В прекрасной ве щи жизнь и радость вечны. 1
-

Джон Китс ( John Keats) ( 1 8 1 8)

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

Знакомство с контейнерами ста ндартной бибnиотек и
Есть много вещей, которые делают контейнеры стандартной библиотеки С++ при­
влекательными, например единообразное именование и согласованное и последова­
тельное понятие итераторов для обхода контейнеров. Но для оптимизации особенно
важное значение имеют некоторые иные свойства, к которым относятся следующие:





гарантии производительности (в О-терминах) вставки и удаления;
амортизированная константная стоимость добавления к последовательным
контейнерам;
возможность тонкого контроля распределения динамической памяти контей­
нером.

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

Ilер евод А. Гастева.

горячительное. Как и другие части С++, контейнеры стандартной библиотеки разви­
вались независимо один от дpyroro. Их интерфейсы перекрываются только частично.
Производительность одной и той же операции меняется от контейнера к контейнеру.
Что еще более важно, семантика некоторых функций-членов варьируется от одного
контейнера к другому, даже если они имеют одинаковые имена. Разработчик должен
досконально знать каждый класс контейнера, чтобы понимать, как оптимально их
использовать.

Посn едоватеn ьные контейнеры
Последовательные контейнеры s td : : s t r i ng, s td : : vector, s td : : deque, std : : l i s t

и std : : forwa rd_l i s t хранят элементы в том порядке, в котором они были вставлены
в контейнер. Соответственно, каждый контейнер имеет начало и конец. Все последо­
вательные контейнеры имеют средство для вставки элементов и все они, за исклю­
чением std : : forward_l i s t, имеют функции-члены с константным временем работы
для вставки элементов в конец ко нте й нер а . Одна ко только std : : deque, std : : li s t
и std : : forward_l i s t могут эффективно вставлять элементы в начало контейнера.
Элементы в std : : string, std : : vector и s td : : deque пронумерованы от О до size-1
и могут быть эффективно получены с помощью индекса. Контейнеры s t d : : l i s t и
s td : : forward_ l i s t различны, но оба не имеют оператора индексации.
Контейнеры std : : s tring, s td : : vector и std : : deque построены вокруг внут­
реннего "массивообразного" скелета. При вставке элемента в контейнер все эле­
менты, следующие за ним, должны быть перенесены в соседние местоположения
в массиве, так что стоимость вставки в произвольное место контейнера (но не
в его конец! ) составляет О(п), где п
количество элементов в контейнере. При
вставке элементов внутренние массивы могут быть перераспределены, что приво­
дит к недействительности всех имеющихся итераторов и указателей. Напротив, у
s td : : l i s t и std : : forward_l i s t недействительными становятся только итераторы
и указатели на удаленные из списка элементы. Два экземпляра списков std : : l i s t и
s t d : : fo r w a r d_ l i s t могут даже соединяться вместе без потери корректности итера­
торов. Вставка в средину s td : : l i s t или s td : : forward_l i s t выполняется за конс­
тантное время в предположении, что итератор уже указывает на точку вставки.
-

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

Гла ва 1 0 . Оптимизация структур да н н ы х

В плане реализации имеется четыре упорядоченных ассоциативных контейнера:
std : : map, std : : multimap, std : : set и std : : multi set. Упорядоченные ассоциативные
контейнеры требуют определения упорядочивающего отношения operator< ( ) для
ключей ( s t d : : map ) или самих элементов ( s t d : : set). Упорядоченные ассоциативные
контейнеры реализованы в виде сбалансированных бинарных деревьев. В сортиров­
ке упорядоченных ассоциативных контейнеров необходимости нет. Обход элементов
такого контейнера выполняется в порядке их упорядочения. Вставка или удаление
количество
элементов имеет амортизированное время выполнения O(log2 n), где п
элементов в контейнере.
Хотя для отображений и множеств возможны различные реализации, на практи­
ке все четыре ассоциативных контейнера реализуются как отдельные фасады поверх
одной и той же структуры данных сбалансированного бинарного дерева. Это верно
как минимум для всех компиляторов, которые я использовал. Поэтому я не предо­
ставляю отдельные результаты хронометража для мультиотображений, множеств и
мультимножеств.
Со стандартом С++ 1 1 в стандартную библиотеку вошли также четыре неупорядо­
ченнь1х ассоциат ивных контейнера: std : : unordered_map, std : : unordered_mul t imap,
std : : unordered_se t и s td : : unorde red_mu l t i se t . Эти контейнеры появились в
Visual С++ еще в версии 20 1 0 года. Неупорядоченные ассоциативные контейнеры
требуют определения только равенства отношения для ключей ( std : : unordered_map )
или элементов ( std : : unordered_ set). Неупорядоченные ассоциативные контейнеры
реализованы в виде хеш-таблиц. Порядок обхода неупорядоченного ассоциативного
контейнера не определен. Вставка или удаление элементов в среднем выполняется за
константное время, хотя время вставки в наихудшем случае О(п).
Ассоциативные контейнеры являются очевидным выбором для таблиц поиска.
Разработчик может также хранить элементы, имеющие отношение упорядочения, в
последовательном контейнере, после сортировки которого поиск становится относи­
тельно эффективным - со временем работы O(log2 n).
-

-

Э ксп ерименты с контейнерами стандартной бибnиотеки
Я создал несколько разнотипных контейнеров со 1 00 тысячами элементов в каж­
дом и измерил производительность вставки, удаления и обхода элементов. В после­
довательных контейнерах я также измерил стоимость сортировки.
Это количество элементов является достаточным, чтобы амортизированная стои­
мость вставки приблизилась к своему асимптотическому поведению, указанному для
каждого контейнера
1 00 тысяч элементов достаточно, чтобы основательно загру­
зить кеш-память. С любой точки зрения это никак не маленький контейнер, но и не
такой непомерно большой, чтобы быть крайне редким.
Про изв од ительн о сть в терминах "большого О" эт о не вся ист ория. Я обнару ­
жил, что одни контейнеры ок а зывались во много раз быстрее друг их, даже когда та
и л и иная операция имела асимптотическую стоимость 0( 1 ) для обоих сравниваемых
контейнеров.
Я также обнаружил, что неупорядоченные отображения с их стоимостью поиска
О( 1 ) быстрее, чем отображения, но не с таким большим отрывом, какого я ожидал.
Кроме того, значительной оказывается стоимость памяти для получения такой про­
изводительности.
-

-

З н а комство с контейнерами стандартной бибn иотеки

261

Большинство типов контейнеров предоставляют несколько способов вставки эле­
ментов. Я обнаружил, что одни из них на 1 0- 1 5% быстрее других, причем причина
этого не ясна.
Стоимость вставки 1 00 тысяч элементов в контейнер состоит из двух частей: сто­
имость выделения памяти и стоимость копирующего создания элементов в памяти.
Стоимость выделения памяти фиксирована для элементов определенного размера, в
то время как стоимость копирующего построения не ограничена и зависит от при­
хоти программиста. В сл уч ае очень дорогого копирующего конструктора стоимость
копирования элементов будет доминировать над стоимостью построения контейне­
ра. В этом случае все контейнеры будут давать примерно одинаковую производи­
тельность при тестировании вставок.
Большинство типов контейнеров также предоставляют несколько способов пе­
ребора элементов. Здесь я также нашел, что одни способы демонстрируют большую
скорость по сравнению с другими при отсутствии какой-то очевидной причины для
т ако го поведения. Интересно, что разница во времени обхода разных контейнеров
оказалась меньшей, чем я ожидал.
Я проверил стоимость сортировки последовательных контейнеров, чтобы понять,
могут ли они заменить ассоциативные контейнеры в приложениях, выполняющих
поиск в таблицах. Одни контейнеры выполняют сортировку элементов в процессе
вставки; другие ко н тейнер ы нельзя сортировать вовсе.
Полученные мною результаты достаточно значительны, чтобы быть интересны­
ми, но, вероятно, достаточно недолговечны. По мере развития реализаций с тече­
нием времени самым быстрым методом может стать другой. Например, алгоритм
s taЫe_ sort ( ) постоянно превосходит алгоритм sort ( ) . Я подозреваю, что, когда
staЫe_sort ( ) был добавлен в библиотеку алгоритмов, дела обстояли иначе.
Тип данны х зn еме нта

Для элементов в последовательных контейнерах я использовал структуру "ключ/
значение': Ассоциативные контейнеры создают очень похожие структуры, построен­
ные поверх s td : : pair:
s t ruct kvstruct {
char key [ 9 ] ;
uns igned value ;
1 1 Может быт ь чем угодно
kvs truct ( unsigned k ) : value ( k )
{
i f ( s trcpy s ( key, s t r ingi fy ( k ) ) )
DebugBreak ( ) ;
)

bool operator< ( kvstruct con s t & that ) cons t {
return s trcmp ( th i s - > key, that . ke y ) < О ;
}

bool operator== ( kvstruct con s t & that ) const {
return s trcmp ( th i s - > key, that . ke y ) == О ;
};

Копирующий конструктор для этого класса создается компилятором, так как
в данном случае его работа нас устраивает; но в общем случае это нетривиальная
262

Гnава 1 О. Оnтмммзация структур да н н ы х

операция, которой недостаточно побитового копирования содержимого одного
объекта kvs t ruct в другой. Как и ранее, моей целью было сделать копирование и
сравнение хотя бы немного дорогими для имитации реальных структур данных.
Сами ключи представляют собой строки в стиле С с завершающими нулевыми
символами, состоящие из семи цифр. Ключи были получены с помощью равномерно­
го случайного распределения с использованием заголовочного файла С++ < r andom> .
То же значение сохранялось и как целое беззнаковое число в качестве поля value
элемента. Повторяющиеся ключи устранялись, чтобы получить исходный вектор из
100 тысяч различных значений в произвольном порядке.
П р имечани я о проектир ован ии э к спери ме нта

Некоторые контейнеры очень недороги при вставке или обходе, даже со 1 00 ты­
сячами элементов. Чтобы получить тест, работающий измеримое количество време­
ни, я должен был повторить вставку или обход 1 ООО раз. Но здесь есть проблема.
Каждый раз при вставке элементов в контейнер необходимо также удалить из кон­
тейнера имеющиеся в нем элементы, что влияет на общее время работы. Например,
следующий код измеряет стоимость присваивания одного вектора другому. Это не­
избежно добавляет стоимость создания новой копии random_vector и последующе­
го ее удаления:
Stopwatch sw ( " Присваивание вектора вектору + удаление х l О О О " ) ;
std : : vector< kvs t ruct> test cont a i ne r ;
for ( un s i gned j = О ; j < 100 0 ; + + j ) {
t e s t container = random vector ;

s tct : :vector< kvs t ruct> ( ) �swap ( te s t_container ) ;

Для получения отдельных стоимостей присваивания и удаления я создал более
сложную версию кода, по отдельности накапливающую время, затраченное на созда­
ние новой копии и ее удаление:
Stopwat ch sw ( " Присваивание вектора вектору " , fa l se ) ;

Stopwa tch : : t i c k- t t i c ks ;

Stopwat ch : : t i c k t a s s i gn х 1 0 0 0 =

О;

Stopwatch : : t i c k-t de lete-x- 1 0 0 0 = О ;
std : : ve c t o r< kv s t ru c t > t est -containe r ;
for ( un s igned j = О ; j < 100 0 ; + + j ) {
sw . Start ( " " ) ;

test container = random-vector ;
= sw . Show ( " " ) ;

t i c ks

a s s i gn х 1 0 0 0 += t i c k s ;

)

s t d : : vector< kvs t ruct> ( ) . swap ( te s t container ) ;
delete_x_l O O O + = sw . S top ( " " ) - t i cks ;

std : : cout < < " присваивание вектора вектору х 1 0 0 0 : "


11 Построение вектора с count элементами ,

1 1 содержаще го уни каль ные случайные строки

void build rnd vector ( s td : : vector& v, uns i gned count ) {
std : : defauit random engine е ;
std : : uni fo rm-int dist ributi on d ( count , l O * count- 1 ) ;
auto randomize r � s td : : bind ( d, e ) ;
std : : set unique ;
v . clear ( ) ;

wh i l e ( v . s i ze ( ) < coun t ) {
unsigned rv = randomi ze r ( ) ;
i f ( un i que . i nsert ( rv ) . s econd
kvs truct keyva lue ( rv ) ;

t rue ) { //

Элемент вста влен

v . pus h_ba c k ( keyva l u e ) ;

Первая строка bu i l d_rnd_vecto r ( ) создает генератор случайных чисел,
основной источник случайности. Вторая строка создает распределение слу­
чайных чисел, объект, который преобразует последовательности случай­
ных чисел, получаемых от генератора, в последовательности чисел, кото­
рые отвечают некоторому распределению вероятностей. В данном случае
распределение является равномерным, а это означает, что с равной вероят­
ностью может встретиться любое значение - от минимального значения
count до максимального значения l O * count- 1. Таким образом, если count
равно 1 00 000, значения, предоставляемые распределением, будут варьи­
роваться от 1 00 ООО до 999 999 (т.е. все они будут шестизначными). Третья
строка создает объект, который применяет генератор в качестве аргумента
распределения, таким образом, чтобы вызов оператора operator ( ) объек­
та генерировал случайное число.
Все генераторы документированы и имеют известные свойства. Есть даже
генератор, называемый s t d : : randorn_de v i c e который создает значения из
источника истинной случайности, если таковой доступен.
,

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

З н а комство с конте й не рами ста ндартной 6и611иотеки

265

s t d : : un i fo rm_int_di s t ribut i on d i c e ( l ,

6) ;

Распределение выпадений честной шестигранной кости, которое с одина­
ковой вероятностью дает числа от 1 до 6. Кости с 4, 20 или 1 00 сторонами
можно моделировать путем изменения второго аргумента.
s td : : bi nomi a l _di s t r ibuti on coin ( l , 0 . 5 ) ;

Распределение выпадений честной монеты, которое дает значение О или 1
с одинаковой вероятностью. Не совсем честную монету можно моделиро­
вать путем корректировки второго аргумента, делая его отличным от 0,5.
s td : : norma l_di s t ribu t i on i q ( l O O . O ,

15 . 0 ) ;

Распределение оценочных данных о количестве населения, возвращающее
значения типа douЫe, такие, что между 85,0 и 1 1 5,О находится около двух
третей результатов.
Для более изысканных ценителей есть такие распределения, как распреде­
ление Пуассона или экспоненциальные распределения для построения мо­
делей событий, а также ряд иных распределений.

s td : : vector и std : : string
"Список характеристик" этих структур данных имеет следующий вид.



Последовательный контейнер.
Время вставки: амортизированная вставка в конец
0( 1 ), в произвольное
место О(п).
Время индексации: по позиции, за время 0( 1 ).
Сортировка за время О(п log2n).
Поиск за время O(log2n) в отсортированном контейнере, в противном случае
за время О(п).
Итераторы и ссылки становятся недействительными при перераспределении
памяти для внутреннего массива.
Итераторы обходят элементы от начала до конца и от конца до начала.
Разумный контроль над выделенной памятью независимо от размера.
-

-










·

Исторически s t d : : s tring было разрешено иметь иные реализации, но в C++ l l
определение строки зафиксировано. Реализация Visual Studio может быть производ­
ным от s t d : : vector классом со специализированными функциями-членами для
работы со строками. Комментарии об std : : vector в равной степени относятся к
std : : string в Visual Studio.
s t d : : vec t o r представляет собой массив с динамически изменяемым разме­
ром (рис. 1 0. 1 ). Элементы массива являются экземплярами параметра типа шабло­
на т, которые создаются копированием в вектор. Хотя копирующие конструкторы
266

Гла ва 1 0 . Оптимизация структур данных

элементов могут выделять память для членов, запросы к диспетчеру памяти для пе­
рераспределения внутреннего буфера при добавлении элементов может делать толь ­
ко std : : vector. Такая плоская структура делает std : : vector необычайно эффек­
тивным. Создатель С++ Бьярне Страуструп (Bjarne Stroustrup) рекомендует исполь­
зовать в качестве контейнера std : : vector, если только у вас нет серьезной причины
выбрать другой контейнер. В этом разделе будет показано, почему это так.

Р ис 1 0 1
. .
.

В озможная реализация

std: : vector

Многие операции над s t d : : vector являются эффективными в терминах "большо ­
го о·: выполняясь за константное время. Среди этих операций - добавление нового
элемента в конец вектора и получение ссылки на i-й элемент. Из-за простой внутрен­
ней структуры вектора эти операции выполняются довольно быстро и в абсолютном
выражении. Итераторы s td : : vector представляют собой итераторы с произволь­
ным доступом, а это означает, что вычисление расстояния между двумя итераторами
в одном векторе может быть выполнено за константное время. Это свойство делает
эффективными поиск и сортировку "разделяй и властвуй" в std : : vector.

Сn едствия перера спредеn ения дn я производитеn ьност и
std : : vector имеет разм ер, который описывает, как много элементов в настоящее
время имеется в векторе, и емкость, который говорит о том, насколько велик внут­
ренний буфер, содержащий элементы. Когда размер равен емкости, любые дальней­
шие вставки приводят к выполнению дорогостоящего расширения: перераспределе­
ние внутреннего буфера и копирование элементов вектора в новое место в памяти,
которые делают недействительными все итераторы и ссылки на старый буфер. Когда
перераспределение становится необходимым, емкость нового буфера получается пу­
тем умножения прежней на некоторый коэффициент. В результате агрегированная
стоимость вставки элемента оказывается константной, хотя одни вставки являются
дорогостоящими, а другие - нет.
Один секрет эффективного использования std : : vector заключается в том, что
емкость может быть зарезервирована заранее путем вызова void reserve ( s i ze_t n ) ,
таким образом предотвращая выполнение ненужных циклов перераспределения и
копирования.
std::vector

м

std ::string

267

Еще одним секретом эффективности std : : vector является то, что при удалении
элементов он не возвращает автоматически память диспетчеру памяти. Если про­
грамма поместит миллион элементов в вектор, а затем удалит их, вектор останется с
памятью, в которой безо всяких распределений можно разместить миллион элемен­
тов. Разработчики должны и меть в виду этот факт, используя std : : vector в ограни­
ченных средах.
На емкость std : : vector влияют некоторые его функции-члены, но стандарт
очень уклончив в отношении каких-либо гарантий. void clear ( ) сбрасывает р азмер
контейнера до нуля, но не гарантирует перераспределение его внутреннего буфера
для уменьшения емкости. В C+ + l 1 и в Visual Studio 20 1 0 void shrink_to_fit ( ) яв­
ляется подсказкой вектору о сокращении емкости в соответствии с текущим ра з ме­
ром, но выполнение перераспределения не является обязательным.
Чтобы гарантировать освобожден ие памяти вектора во всех версиях С++, вос­
пользуйтесь следующим трюком:
s td : : vector х ;

vector< Foo> ( ) . swap ( x ) ;
Эта инструкция создает временный пустой вектор, обменивает его содержимое
с содержимым вектора х, а затем удаляет временный вектор, так что диспетчер па­
мяти освобождает всю память, принадлежавшую х, и последний становится пустым
вектором.

Вставка и уда n ение в s td : : vector
Существует несколько способов заполнения вектора данными. Я исследовал сто­
имость построения вектора со 1 00 тысячами экземпляров kvs t ruct, чтобы найти,
определенно, более быстрые и более медленные методы.
Самый быстрый способ заполнения вектора - это его присваивание:
std : : vector< kvs truct> test_container , random vector ;
=

random-vecto r ;
Присваивание является очень эффективным, поскольку знает размер копируемо­
го вектора и ему требуется вызвать диспетчер памяти только один раз для создания
внутреннего буфера памяти в целевом векторе. Хронометраж показал, что вектор со
1 00 тысячами записей копируется за 0,445 мс.
Если данные находятся в другом контейнере, скопировать их в вектор можно с
помощью std : : vector : : insert ( ) :
std : : vector< kvstruct> test_containe r , random_vector ;
test-container

test -container . insert ( test container . end ( ) ,
random vector . begin ( ) ,
r a ndom=ve ct o r . end ( ) ) ;
Хронометраж показал, что данная инструкция копирует вектор со 1 00 тысячами
записей за 0,696 мс.

268

Гn ава 1 0 . О птимизация структур да н н ы х

Функция-член s td : : vector : : push_back ( ) может эффективно (т.е. за констант­
ное время) добавить новый элемент в конец вектора. Так как элементы находятся в
другом векторе, их нужно как-то получать. Для этого есть три способа.


Использовать итератор вектора.
std : : vector< kvstruct> test_container, random vector ;

for ( auto it=random vector . begin ( ) ; it ! =random-vector . end ( ) ; ++ i t }
test_container . push_bac k ( * i t ) ;


Использовать функцию-член std : : vector : : at ( ) .
std : : vector< kvstruct> test_container , random vector ;

for ( un s i gned i = О ; i < nelt s ; ++i )
test container . push_back ( random_vector . at ( i ) ) ;


Код может использовать индекс вектора.
std : : vector test_container , random vector ;

for ( unsigned i = О ; i < nel t s ; + + i }
test_container . push_back ( random_vector [ i ] ) ;
В моих тестах эти три метода дали схожие времена
2,26, 2,05 и 1 ,99 мс соот­
ветственно. Однако это в шесть раз превосходит количество времени, требуемого
оператором простого присваивания.
Причина, по которой этот код оказывается более медленным, он вставляет эле­
менты в вектор по одному. Вектор не знает, сколько элементов будет в него вставлено,
и поэтому наращивает свой внутренний буфер постепенно. Несколько раз в течение
цикла вектор перераспределяет внутренний буфер и копирует все элементы в новый
буфер. std : : vector гарантирует, что амортизированное время работы push_back ( }
константное, но это не означает, что стоимости у этой функции-члена нет.
Разработчик может использовать дополнительные знания для повышения эффек­
тивности это го цикла, предварительно запросив буфер достаточного размера, чтобы
поместить в нем все элементы. Вариант этого кода с итераторами выглядит следую­
щим образом:
-

-

std : : vector< kvs t ruct> t e s t_conta i ne r , random vecto r ;

test container . reserve ( ne l t s ) ;
for (auto it=random vector . begin ( ) ; i t ! = random_vector . end ( ) ; + + i t )
test_container . push_back ( * it ) ;
Такой цикл выполняется за респектабельные 0,674 мс.
Есть и другие способы вставки элементов в вектор. Можно воспользоваться дру­
гим вариантом функции-члена insert ( } :
std : : vector< kvs truct> test _container, random_vector ;

for ( auto i t=random vector . begin ( } ; i t ! = random vector . end ( } ; ++i t )
test_container . Insert ( test container . end ( } , *it } ;

std::vector и std::string

269

Этот код должен бы иметь ту же стоимость, что и push_back ( ) , но это не так (по
крайней мере в Visual Studio 20 1 0). Все три варианта (с итераторами, at ( ) и индек­
сами) выполняются примерно за 2,7 мс. Резервирование внутреннего буфера умень­
шает это время до 1 ,45 мс, но эта величина не может конкурировать с любым из
предыдущих результатов.
Наконец, мы добрались до самого больного места std : : vec tor: до вставки эле­
ментов в начало вектора. s td : : vector не предоставляет член push_front ( ) , потому
что его стоимость равна О(п). Вставка в начало вектора неэффективна, потому что,
чтобы освободить место для новой записи, должен быть скопирован каждый эле ­
мент вектора. И это действительно неэффективная операция . Цикл
std : : vector< kvs truct> tes t_containe r , random_vecto r ;

f o r ( auto it=random vector . begin ( ) ; it ! = random vector . end ( ) ; ++ i t )
test_container . Insert ( test_container . begin ( ) � * i t ) ;
выполняется за 8065 мс. Да, здесь нет опечатки - этот цикл выполняется почти в
тр и тысячи р аз
д ольше вставки в конец вектора.
Таким образом, чтобы эффективно заполнить вектор, используйте присваивание,
insert ( ) с итераторами из другого контейнера, push_back ( ) и i ns e rt ( ) в конец
вектора - в указанном порядке.

Итерирован ие s td : : vector
Обойти вектор с посещением каждого элемента - недорого. Но, как и в случае со
вставкой, стоимость доступных методов существенно различается.
Есть три способа перебора элементов вектора: с помощью итератора, функции­
члена at ( ) и индекса. Если тело цикла стоит дорого, разница в стоимости различных
методов обхода становится несущественной. Однако р азр або т ч и к и часто выполняют
только простые быстрые действия с данными из каждого элемента. В приведенном
примере цикл суммирует элементы вектора, что занимает очень незначительное ко­
личество времени (а также заставляет компилятор воздержаться от оптимизации,
которая выбросила бы прочь весь цикл как ничего не делающ и й) :
std : : vector test containe r ;

uns igned sum = О ;
for ( auto i t=test container . begin ( ) ; i t ! =test container . end ( ) ; ++ i t )
s um + = it->vaiue ;
std : : vector< kvstruct> tes t_container;
unsigned sum = О ;
for ( un s i gn ed i = О ; i < nelts ; ++i )
sum += test_container . at ( i ) . value ;
std : : vector< kvstruct> tes t_conta iner ;
unsigned sum = О ;
for ( unsigned i = О ; i < nel ts ; ++i )
sum += test_container [ i ] . value ;
270

Гnава 1 0. Оптимизация структур да н н ы х

Разумно ожидать, что эти циклы будут практически эквивалентны по стоимости,
но на самом деле они таковыми не являются. Версия с итератором выполняется за
0,236 мс. Версия с а t ( ) немногим лучше, выполняясь за 0,230 мс. Но, как и в случае
со вставкой, версия с индексом в Visual Studio 20 1 0 оказывается на 46% эффективнее,
чем с итератором.

Сортировка s td : : vector
Чтобы использовать бинарный поиск в векторе, вектор следует отсортировать.
Стандартная библиотека С++ и меет два алгоритма сортировки, s t d : : s o r t ( ) и
std : : staЫe_sort ( ) . Оба они имеют время работы О(п log2n), если итераторы кон ­
тейнера являются итераторами с произвольным доступом, как в std : : vector. Оба
алгоритма работают несколько быстрее с данными, которые уже отсортированы.
Сортировка выполняется с помощью одного краткого вызова:
std : : vector< kvstruct> sorted_container , random_vector ;
·

sorted container = random vector;
std : : sort ( sorted_container . begin ( ) , sorted_container . end ( ) ) ;
Результаты подытожены в табл. 1 0. 1 .
Табnица 1 0.1 . Стоимость сортировки вектора со 1 00 тысячами эnементов

VS201 0, i7, 1 00k эn е ментов,

std::vector

std :
std :
std :
std :

: sort ( ) вектор
: sort ( ) отсо рт и ро ва н н ы й вектор
: staЫe_sort ( ) в ектор
: staЫe_sort ( ) отсо рти р ова н н ы й

мс

1 8, 6 1
З, 7 7

1 6,08
векто р

5,01

Поиск в std : : vector
Приведенный далее фрагмент программы ищет каждый ключ из random_vector в
отсортированном контейнере:
std : : vector< kvstruct> sorted_containe r , random_vecto r ;

for ( auto it=random vector . begin ( ) ; it ! =random vector . end ( ) ; ++it )
kp = std : : lower-bound ( sorted container . begln ( ) ,
sorted-container . end ( ) ,
*it ) ; i f ( kp ! = sorted con ta i n e r . en d ( ) & & * i t < * kp )
kp = sorted_conta iner . end ( ) ;

{

Эта программа выполняет поиск 1 00 тысяч ключей в отсортированном векторе за
28,92 мс.

s td : : deque
"Список характеристик" этой структуры данных имеет следующий вид.
std::deq u e

271










Последовательный контейнер.
Время вставки: в конец или в начало - 0( 1 ), в произвольное место - О(п).
Время индексации: по позиции, за время 0( 1 ) .
Сортировка за время О(п - log2 n).
Поиск за время O(log2 n), если контейнер отсортирован, иначе за О(п).
Итераторы и ссылки становятся недействительными после перераспределения
внутреннего массива.
Итераторы обходят элементы от начала до конца и от конца до начала.

s t d : : deque - специализированный контейнер для создания очереди (первым
вошел - первым вышел, FIFO). Вставка и удаление в любом конце выполняются за
константное время. Индексация также является операцией с константным временем
выполнения. Итераторы std : : deque, как и итераторы std : : vector, являются итера­
торами с произвольным доступом, так что контейнер std: : deque может быть отсор­
тирован за время О(п log2 n).
Поскольку std : : deque дает те же гарантии производительности (в терминах
" большого О") , что и std : : vector, а также обеспечивает константное время вставки
с обоих концов, так и хочется спросить - а зачем нам тогда std : : vector?Одна­
ко коэффициент пропорциональности во всех этих операциях у дека больше, чем
у вектора. Измеренная производительность основных операций с участием деков
в 3- 1 О раз меньше производительности у соответствующих операций с векторами.
Светлым лучом у деков являются только обход, сортировка и поиск, которые при­
мерно на 30% медлен нее, чем у векторов.
std : : deque обычно реализуется как массив массивов (рис. 1 0.2). Для получения
элемента из дека необходимы два косвенных обращения, что уменьшает локаль­
ность кеша; стоимость более частых вызовов диспетчера памяти у дека больше, чем
у std : : vector.
Для вставки элемента в любой конец дека может потребоваться более двух вы­
зовов аллокатора: для добавления еще одного блока элементов и (гораздо реже) для
расширения базового массива дека. Такое поведение аллокатора более сложное, чем
у векторов, и, таким образом, о нем труднее судить, чем о поведении аллокаторо в
в векторах. s td : : deque не предлагает каких-либо эквивалентов функции - член а
std : : vector : : reserve ( ) для выделения достаточного места для размещения эле­
ментов во внутренних структурах данных. Дек может показаться очевидным кан­
дидатом на реализацию очереди FIFO. Существует даже шаблон адаптера контейне­
ра, именуемый std : : queue, для которого дек является реализацией по умолчанию.
Однако нет никакой гарантии, что производительность распределения памяти будет
при таком применении дека достаточно хорошей.
·

272

Гnа ва 1 0. Оnтимизация структур д а н н ы х

Рис. 1 0.2. В озможная реализация s t d : : deque (состояние после нескольких вста­
вок и удалени й)

Вставка и уда11ен ие в s td : : deque
std : : deque обеспечивает тот же интер фейс для вставки, что и std : : vector, плюс
функцию-член push_front ( ) .
По каз а нная ни же о пер ация п р исв а ив ания од н ого дека д ругом у выполняется за
5,70 мс:
std : : deque< kvs truct> test containe r ;
std : : vector< kvstruct> random vecto r ;
test container

random_vector ;

std::deque

273

Вставка в дек с использованием пары итераторов, показанная ниже, в ы пол няетс я
за 5,28 мс:
s td : : deque< kvs t ruct> test container;
s td : : vector< kvs t ruct> ranaom vec tor ;

t e s t container . i nsert ( t e s t con t a i ne r . end ( ) ,

-

random vector . begin ( ) ,

random=vector . end ( ) ) ;

Вот три способа копирования элементов из вектора в дек с использованием
push_back ( ) :

s td : : deque< kvstruct> test containe r ;
s td : : vect o r < kv s t ruct> ranaom_vecto r ;

for ( auto i t = random vector . begin ( ) ; i t ! =random vector . end ( ) ; + + i t )
t e s t_container . push_bac k ( * i t ) ;
-

for ( un s i gned i = О ; i

< ne l t s ; + + i )
t e s t_conta ine r . push_bac k ( random_vector . a t ( i ) ) ;

for ( un s i gned i = О ; i < nel t s ; + + i )

test_container . push_back ( random_vector [ i ] ) ;

Предположив, что эти три цикла будут иметь практически одинаковую стоимость
( раз ве что вариант с at ( ) должен быть чуть-чуть медленнее из-за дополнительной
проверки, которую он выполняет), вы будете недалеки от истины. Действительно,
версия с итератором выполняется за 4,33 мс - на 14% быстрее, чем версия с ин­
дексом и временем выполнения 5,0 1 мс , а a t ( ) вер си я располагается между ними
со временем работы 4,76 мс. Это не такая уж огромная разница, по крайней мере не
такая, в оптимизацию которой обычно вкладываются большие усилия.
Результаты добавления с помощью p u s h_f r o n t ( ) похожи на результаты для
push _b a c k ( ) . Версии с итератором потребовалось 5, 1 9 мс, с индексом - 5,55 мс.
В целом результаты push_front ( ) оказались примерно на 1 5% медленнее, чем у
-

push_back ( )

.

Вставка в конце и вначале оказалась почти в два раза медленнее, чем использова­
ние pu s h_back ( ) и push_ front ( ) соответственно.
Теперь взглянем на производительность std : : vector по сравнению с std : : deque.
Вектор выполняет присваивание в 1 3 раз быстрее при том же количестве записей.
Вектор в 22 раза быстрее при удалении, в 9 раз быстрее при вставке с помощью ите­
ратора, вдвое быстрее при работе с push_back ( ) и втрое - при вставке в конец.
И з и сто р ии опт и м и за ц ио нн ых в о й н

В самом начале тестирования производительности дека я столкнулся с не­
приятным сюрпризом: операции с s t d : : deque были в тысячу раз медлен­
нее, чем эквивалентные операции с s t d : : vector. Сначала я сказал себе:
"Что ж, имеем то, что имеем. Дек - просто ужасный выбор в качестве
структуры данных". И только когда я стал выполнять "окончательный" на­
бор тестов для таблиц в этой книге, я понял, какого дурака свалял.
274

Гnава 1 0. Оптимизация структур данных

Обычно при разработке я запускаю тестовые программы в отладчике, по­
тому что для этого в интегрированной среде разработки имеется большая
жирная кнопка. Мне было известно, что при отладке с программой связы­
вается библиотека времени выполнения С++ с дополнительными возмож­
ностями отладки. Но я никогда не видел, чтобы это приводило к больше­
му, чем несущественное различие в производительности. Для таблиц же в
книге я выполнял окончательный запуск вне отладчика, чтобы получать
более по � ледовательные и точные данные хронометража. Так я обнаружил,
что std : : deque под отладчиком имеет чудовищную стоимость из-за диа­
гностического кода, добавленного к процедуре выделения памяти. Этот
результат выпадал из всего моего опыта измерений относительной произ­
водительности в отладочной и окончательной, производственной верси­
ях сборки. Имеется возможность управлять тем, как при отладке работа­
ет диспетчер памяти - в отладочном или производственном режиме (см.
врезку "Совет от профессионала" в разделе "Профилирование выполнения
программы" главы 3, "Измерение производительности").
Ите ри рован и е s td : : deque

Итерирование элементов дека выполняется за 0,828 мс для версии с индексами и
за 0,450 мс для кода на основе итератора. Интересно, что в случае дека гораздо быс­
трее работает код на основе итератора, в то время как для вектора быстрее работает
код с использованием индексов. Но самый быстрый метод обхода дека в два раза
медленнее самого быстрого метода обхода вектора (что продолжает выявленную ра­
нее тенденцию к отставанию дека от вектора).

Сортировка s td : : deque
std : : sort ( ) обрабатывает 1 00 тысяч записей в деке за 24,82 мс, что примерно на
треть дольше, чем сортировка вектора. Что касается std : : staЫe_sort ( ) , то здесь от­
ставание сортировки для дека находится в пределах 1 0% от сортировки вектора. В обо­
их случаях отсортированный контейнер сортировался быстрее неотсортированного.

П оис к в s td : : deque
Поиск всех 1 00 тысяч ключей в отсортированном деке выполняется за 35 мс. По­
иск в деке примерно на 20% медленнее поиска в векторе.
s td : : li s t
"Список характеристик" этой структуры данных имеет следующий вид.




Последовательный контейнер.
Время вставки: О( 1 ) в любой позиции.
Сортировка за время О(п log2n).
·

std ::list

275






Поиск за время О(п).
Итераторы и указатели никогда не становятся недействительными, за исклю­
чением только удаленных элементов.
Итераторы обходят элементы от начала до конца и от конца до начала.

std : : l i s t имеет много общих свойств с std : : vector и std : : deque. Как и в слу­
чае вектора и дека, добавление элементов в конец списка выполняется за констан­
тное время. Как и у дека (но не у вектора) вставка элементов в начало списка так­
же выполняется за константное время. Кроме того, в отличие от вектора или дека,
вставка элементов в середину списка выполняется за константное время (при нали­
чии итератора, указывающего на точку вставки). Как и дек и вектор, список может
быть эффективно отсортирован. Но, в отличие от векторов и деков, не существует
эффективного способа поиска в списке. Лучшее, что можно сделать, - применить
алгоритм s t d : : find ( ) , который имеет временную сложность О(п) .
Общеизвестная мудрость - что s td : : l i s t слишком неэффективен, чтобы быть
полезным, но после того как я исследовал его производительность, я так не считаю.
В то время как копирование или создание s t d : : l i s t может быть в 10 раз дороже
соответствующей операции для std : : vector, список вполне может конкурировать с
деком. Последовательное добавление элементов в хвост списка менее чем вдвое до­
роже добавления к вектору. Обход и сортировка списка только на 30% дороже, чем
те же операции над вектором. Для большинства операций, которые я тестировал,
std : : l i s t был дешевле, чем std : : deque.
В соответствии с тем же общеизвестным мнением s td : : l i s t , с его прямыми и
обратными связями и методом s i z e ( ) с константным временем работы, дороже,
чем это необходимо для функций, которые он предоставляет. Это мнение в конеч­
ном итоге привело к включению в стандарт C++ l l списка s td : : forward_l i s t. Од­
нако стоит немного протестировать производительность, как становится очевидным,
что стоимость операций std : : l i s t практически идентична стоимости операций
std : : forward_list, по крайней мере на персональных компьютерах.
Поскольку std : : l i s t не имеет в своей основе массива, который требуется пере­
распределять, итераторы и ссылки на элементы списка никогда не становятся недей­
ствительными из-за вставки. Они становятся таковыми только тогда, когда удаляют­
ся элементы, на которые они указывают.
Светлым местом std : : list является то, что списки могут быть разрезаны (за вре­
мя О( 1 ) ) и объединены без копирования элементов списка. Даже такие операции,
как сращивание и сортировка списков, не делают итераторы std : : l i s t недействи­
тельными. Вставка в средину списка выполняется за константное время при условии,
что программе уже известно, куда выпол нять вставку. Таким образом, приложение,
которое создает списки элементов, а затем перетасовывает их, может быть более эф­
фективным при использовании std : : l i st, чем с std : : vector.
s td : : l i s t взаимодействует с диспетчером памяти простым и предсказуемым
способом. Каждый элемент списка при необходимости выделяется отдельно. В спис­
ке нет никакой скрытой неиспользованной емкости (рис. 1 0.3).

276

Гnава 1 0. О птимизации структур да н н ых

размер = З

Рис. 1 0. 3. Возможная реализация s t d : : l i s t
(состояние после нескольких вставок и удалений)
Пам я ть, выделенная дл я каждого элемента списка, имеет од ин и тот же размер.
Это помогает сложным диспетчерам памяти работать эффективно и с меньшим
риском фрагментации памяти. Можно также определить простой пользовательский
аллокатор для std : : l i st, который использует это свойство для повышения эффек­
тивности (см. раздел 'Алло к ато р блоков фиксированного размера" главы 1 3, О п ти­
мизация управления памятью").
"

'

std ::l ist

277

В став ка и уда11 е ни е в s td : : l i s t
Алгоритмы для копирования одно г о списка в другой с помощью i n s e r t ( ) ,
( ) и push_ front ( ) идентичны перечисленным для вектора и дека, з а ис­
ключением объявления структуры данных в начале кода. Очень простая структура
std : : l i s t не дает компилятору особых шансов улучшить код во время компиляции.
Хронометраж всех этих механизмов подытожен в табл. 1 0 . 2.
pu s h_back

Таб11ица 1 0.2. Итоrи экспериментов по nроизводитеnыости s td : : list

std::list, 1 00k эл е ментов, VS20 1 0 release, i7

В ре м я , м с

П рис ва и ва н ие
Уд а ле н ие

insert ( end (

))

push_bac k ( )

с

С п и сок по сра вн ен и ю с в е кторо м, %

5, 1 0

1 046

2,49

2141

3,69

533

4,26

88

4,50

1 20

push_bac k ( ) с и ндексам и
push_ front ( ) с итераторами

4,63

1 32

at ( )

4,82

at ( )

итераторами
push_bac k ( )

pu sh_front ( )

4, 7 7

push_ front ( ) с и нде кса ми

4,99

Итераторная вста вка в конец

4,75

75

at ( ) вста вка в конец

4,84

77

4,88

75

И ндексна я вста вка в конец

Итераторная вставка в начало
at (

)

4,84

вста вка в на чал о

5,Q2

И ндек сная вста вка в на ч ало

5,04

Вставка в конец списка оказалась наиболее быстрым способом построения спис­
ка; по ряду причин она д аже быстрее, чем operator= ( ) .

И терирова ние s td : : l i s t
Для списков н е существует операторов индексации. Единственным вариантом об­
хода списка является использование итераторов.
Обход всех 100 тысяч элементов списка выполняется за 0,326 мс. Это всего на 38%
дольше, чем обход вектора.

Сортировка std : : list
Итераторы s t d : : l i s t являются двунаправленными итераторами, менее мощ­
ными, чем итераторы с произвольным доступом у s t d : : vector. В частности, одно
из свойств этих итераторов - найти расстояние, или количество элементов меж­
ду двумя двунаправленными итераторами, можно за время О(п). Таким образом,
std : : sort ( ) при сортировке s td : : l i s t имеет производительность О(п2). Компиля­
тор по-прежнему без замечаний будет компилировать s t d : : sort ( ) для списка, но
его производительность будет гораздо хуже, чем может ожидать разработчик.
278

Гnава 1 0. Оптимизация структур да н н ы х

К счастью, s td : : l i s t имеет более эффективную встроенную сортировку, выпол­
няющуюся за время О(п - log2n). Сортировка списка с помощью s td : : l i s t : : sort ( )
выполнилась за время 23,2 мс, только на 25% дольше, чем сортировка эквивалентно­
го вектора.

По и ск в s td : : l i s t
Поскольку s t d : : l i s t предоставляет только двунаправленные итераторы, алго­
ритмы бинарного поиска для списков дают время работы О(п). Поиск с использова­
нием std : : find ( ) также дает О(п), где п - количество записей в списке. Это делает
std : : l i s t плохим кандидатом на замену ассоциативных контейнеров.
s td : : forward

list

"Список характеристик " этой структуры данных имеет следующий вид.








Последовательный контейнер.
Время вставки: О( 1 ) в любой позиции.
Сор т ировка за время О(п log2 n).
Поиск за время О(п).
Итераторы и указатели никогда не становятся недействительными, за исклю­
чением только удаленных элементов.
Итераторы обходят элементы от начала до конца.
·

std : : forward_l i s t представляет собой последовательный контейнер, упрощен­
ный до предела. Он содержит один указатель на головной узел списка. Он был разра­
ботан специально для того, чтобы сделать его эквивалентом однонаправленного спис­
ка, закодированного вручную. У него нет функций-членов back ( ) или rbegin ( ) .
s t d : : forwa rd_l i s t взаимодействует с диспетчером памяти очень простым и
предсказуемым способом. Каждый элемент односвязного списка выделяется при
необходимости отдельно. Нет никакой неиспользованной емкости, скрытой в таком
списке (рис. 1 0.4). Выделяемая для каждого элемента списка память имеет один и
тот же размер. Это помогает сложным диспетчерам памяти работать эффективно и с
меньшим риском фрагментации памяти. Можно также определить простой пользо­
вательский аллокатор для s td : : forward_l i s t, который использует это свойство для
повышения эффективности (см. раздел "Аллокатор блоков фиксированного размера"
главы 1 3, "Оптимизация управления памятью " ).
Односвязный список отличается от списка тем, что предлагает только однона­
правленные итераторы, как и предполагается из его названия. Такой список можно
обходить обычным циклом:
std : : fo rward- l i s t < kv s t ruct> f l i s t ;
11

. . .

unsigned sum = О ;
for ( auto i t = f l i s t . b e g i n ( ) ; i t ! = f l i s t . end ( ) ; + + i t )
sum += i t - >value ;

std : :forward_list

279

front

Рис.

1 0. 4 .

Возможная реализация

s td : : forwa rd_ l i s t

Вставка, однако, требует иного подхода. Вместо метода i n s e r t ( ) класс
s t d : : forward_l i s t имеет метод i n s e r t_a f t e r ( ) . Существует также функция
before_begin ( ) для получения итератора, указывающего на ( несуществующий) эле­
мент перед первым элементом списка (дpyroro способа вставки перед первым элемен­
том нет, поскольку все элементы содержат указатели только на следующий элемент):
s td : : forward l i s t < kvstruct> f l i s t ;
s td : : vector vect ;

11 . . .

auto p l ace = f l i s t . be fore begin ( ) ;
for ( auto it = vvect . begin ( ) ; i t ! = vect . end ( ) ; + + i t )

place

=

f l i s t . insert_after ( place , * i t ) ;

На моем компьютере s t d : : forward_l i s t не опережал s t d : : l i s t сколь-нибудь
значительно. Все то, что делает s td : : l i s t медленным ( поэлементное выделение
памяти, плохая локальность кеша), остается такой же большой проблемой и для
std : : forward_l i st. Он может быть полезен на меньших процессорах с более жест­
кими ограничениями памяти, но для процессоров класса настольных компьютеров и
телефонов мало что можно сказать в его пользу.

Вс тавка и удаnен ие в s td : : forward_l i s t

std : : forward_l i s t выполняет вставку за константное время в любую позицию
при наличии итератора, указывающего на предыдущую позицию. Вставка 1 00 тысяч
записей в односвязный список заняла 4,24 мс, примерно столько же, сколько и для
s t d : : l i s t.

std : : forward_li s t имеет функцию-член push_front ( ) . Вставка 1 00 тысяч запи­
сей с ее помощью выполнилась за 4, 1 6 мс - вновь примерно за то же время, что и
для s td : : l i s t.

Итерирование std : : forward_list
Для s td : : forward_l i s t н е существует оператора индексации. Единственным ва­
риантом обхода списка является использование итераторов.
Обход всех 1 00 тысяч элементов списка выполняется за 0,343 мс. Это всего на 45%
дольше, чем обход вектора.
280

Глава 1 О. О птимизация структур да н н ых

Сортировка s td : : forward_l i s t

Как и std : : li st, s td : : forward_l i s t имеет встроенную функцию-член, которая
выполняет сортировку за время О(п log2n). Производительность сортировки анало­
гична таковой для s t d : : l i s t, - сортировка 1 00 тысяч элементов занимает 23,3 мс.
·

П оиск в std : : forward l i s t
Поскольку std : : forward_l i s t предоставляет только однонаправленные итера­
торы, алгоритмы бинарного поиска для списков дают время работы О(п) . Поиск с
и с пользованием std : : find ( ) также дает О(п), где п - количество записей в списке.
Это делает однонаправленный список плохим кандидатом на замену ассоциативных
контейнеров.

s td : : шар and std : : multimap
"Список характеристик" этих структур данных имеет следующий вид.







Упорядоченный ассоциативный контейнер.
Время вставки: O(log2n).
Время индексации: по ключу O(log2n).
Итераторы и ссылки никогда не становятся недействительными, за исключе­
нием только удаленных элементов.
Итераторы обходят элементы в порядке сортировки или в обратном ему по­
рядке.

std : : map отображает экземпляры типа ключа на соответствующие экземпляры
некоторого типа значения. std : : map представляет собой структуру данных на осно­
ве узлов, как s td : : l i st. Однако отображение упорядочивает свои узлы в соответ­
ствии со значением ключа. Внутренне отображение реализуется как сбалансирован­
ное бинарное дерево с дополнительными ссылками для облегчения обхода на основе
итератора (рис. 1 0.5).
Хотя отображение std : : map реализовано с использованием дерева, это не дерево.
Нет никакого способа обратиться к ссылкам, выполнить обход в ширину или другие
операции над деревом, работая с отображением.
Взаимодействие s td : : map с диспетчером памяти простое и предсказуемое. Па­
мять для каждого элемента отображения при необходимости выделяется отдельно.
std : : map не содержит базового массива, который может быть перераспределен, по­
этому итераторы и ссылки на элементы отображения никогда не становятся недей­
ствительными из-за вставки. Они станут недействительными, только если будут уда­
лены элементы, на которые они указывают.
Выделяемая для каждого элемента отображения память имеет один и тот же раз­
м ер. Это помогает сложным диспетчерам памяти работать эффективно и с мень­
шим ри с ком фрагментации памяти. Можно также определить простой пользова­
тельский аллокатор для std : : map, который использует это свойство для повышения
std::map and std::multimap

281

эффективности (см. раздел "Аллокатор блоков фиксированноrо размера" rлавы 1 3,
"Оптимизация управления памятью").
размер = 7
head

.о·

,
,
1
,

,
,,

1
1
,
1
1
1

,
,,

1

.с�
left

rlght

left

right

left

right

Рис. 1 0. 5 . Упрощенная возможная реализация s t d : :тар и s td : : set

В ставка и удаn ение в s td : : map
Вставка 1 00 тысяч случайных записей из вектора в std : : rnap выполнилась за
33,8 мс.
Вставка в отображение обычно имеет стоимость O(log2 п) из-за необходимости
поиска во внутреннем дереве отображения точки вставки. Эта стоимость достаточно
высока для тоrо, чтобы класс s td : : rnap предоставлял версию insert ( ) , которая при­
нимает дополнительный итератор отображения, выступающий в качестве подсказки
места вставки, и которая может оказаться куда более эффективной. Если подсказка
оптимальна, амортизированное время вставки - 0( 1 ).
Что касается подсказки, то здесь есть и хорошие, и плохие новости. Хорошей но­
востью является то, что вставка с подсказкой никогда не может быть дороже, чем
обычная вставка. Плохая новость - оптимальное значение, рекомендуемое в ка­
честве подсказки для вставки, изменено в стандарте С++ 1 1 . До С++ 1 1 оптимальное
значение подсказки для вставки было позицией перед новым элементом, т.е., если
элементы вставляются в отсортированном порядке, оптимальная подсказка пред­
ставляет собой положение предыдущей вставки. Начиная с С++ l l оптимальной
подсказкой для вставки является позиция после новоrо элемента, т.е., если элементы
вставляются в порядке сортировки, позиция подсказки должна быть end ( ) . Чтобы
сделать ранее вставленный элемент оптимальной подсказкой, проrрамма должна
282

Глава 1 0 . Оптимизация структур д а н н ы х

выполнять проход по отсортированным вводимым данным в обратном порядке, как
показано в примере 10.2.
П ример 1 0.2. В ставка из отсортированноrо вектора
с иmоnьзованием подсказки в стиnе С ++ 1 1

ContainerT test container ;
std : : vector< kvstruct> sorted_vector ;
std : : staЫe sort ( sorted vector . begin ( ) , sorted_vector . end ( ) ) ;
auto hint =-test conta iner . end ( ) ;
for ( auto i t
s o r t e d vector . rbegin ( ) ;
i t ! = sorted vector . rend ( ) ; ++ i t )
hint
test_container . insert ( hint , value- type ( it->key,
it->value ) ) ;
=

=

Как показывает мой опыт с GCC и Visual Studio 20 1 0, реализация стандартной
библиотеки может как опережать последний стандарт, так и отставать от него. В ре­
зультате программа, оптимизированная с помощью подсказки в старом стиле, мо­
жет замедлить работу при использовании более нового компилятора, даже если этот
компилятор не полностью соот в етствует стандарту С++ 1 1 .
Я выполнял хронометраж вставки, используя три варианта подсказки: end ( ) ,
итератор на предыдущий узел ( использовавшийся в стандартных библиотеках до
C ++ l l ) и итератор , указывающий на узел-преемник, как требует C++ l l . Результаты
показаны в табл. 1 0.3. Для выполнения этого теста входные данные также должны
быть отсортированы.
Табnица 1 0.3. Производитеnьность вставки с подсказко й в std : : шар
Э кс перимен т

Отсортиро ванный ве ктор :
Отс орти ро ва н н ы й в е ктор:
Отсорти ро ва н н ы й в е ктор:

Время, мс

insert ( J
insert ( )
insert ( J

1 8,00
с подсказкой

end ( )

с подсказкой до С ++ 1 1

Отсортирова нный вектор: i n s e r t ( ) с п одс ка з кой С++ 1 1

9, 1 1
1 4,40
8, 56

Похоже, что Visual Studio 20 1 0 реализует подсказку в стиле С + + 1 1 . Но и та, и дру­
гая подсказки оказываются лучше, чем отсутствие подсказки вообще, и лучше, чем
версия insert ( ) без подсказки и неотсортированный ввод.

Оптими за ция идиомы п роверки и об н овnе н и я
В часто встречающейся идиоме программа проверяет, есть ли некоторые ключи в
отображении, а затем выполняет действия в зависимости от полученного результата.
Оптимизация производительности возможна, когда действие включает в себя встав­
ку или обновление значения, соответствующего ключу поиска.
Ключом к понимани ю оптимизации является то, что и map : : f i n d ( ) , и
rnap : : insert ( ) имеют стоимость O(log2 n) из-за необходимости проверки наличия
ключа и поиска точки вставки. Обе эти операции выполняют обход одинакового на­
бора узлов в бинарном дереве отображения:
std ::map and std::multimap

283

i terator i t = taЫe . fi nd ( key ) ; / / O ( log n )
i f ( i t ! = taЫe . end ( ) ) {
/ / Ключ найден
i t - > se cond = value ;

}

else {
/ / Ключ не найден
it = taЫ e . insert ( ke y , value ) ; / / O ( l og n )

Результат первого поиска программа может использовать как подсказку для
insert ( ) , что делает стоимость вставки равной О( 1 ) . Есть два способа улучшить эту
идиому, в зависимости от потребностей программы. Если все, что вам нужно, - это
знать, был ли ключ найден, можно использовать версию insert ( ) , которая возвраща­
ет пару, содержащую итератор, указывающий на найденную или вставленную запись,
и логическое значение типа bool, равное fa l s e, если запись с таким ключом была
найдена, а потому не вставлена; и t rue, если она была успешно вставлена. Это реше­
ние работает, если программа знает, как инициализировать запись, прежде чем выяс­
нить, присутствует ли она в отображении, или если ее значение недорого обновить:
s td : : pa i r re s u l t = taЫe . in s e r t ( ke y , value ) ;
i f ( re s u l t . second ) {
1 1 key успешно вставлен в отображение

}

e l se {
/ / ke y уже имеется в отображении

Второй метод предусматривает поиск ключа или точки вставки с помощью вы­
зова upper_bound ( ) для подсказки в стиле С++98 или l ower_bound ( ) для подсказки
в стиле С++ 1 1 . lowe r_bound ( ) возвращает итератор, указывающий на наименьшую
запись в отображении, ключ которой не меньше, чем искомый, или end ( ) , если та­
ковой не найден. Этот итератор является подсказкой для вставки, если ключ должен
быть вставлен, и указывает на существующий ключ, если необходимо обновить су­
ществующую запись. Этот метод не делает никаких предположений о записи, кото­
рую требуется вставить:
i terator i t = t aЬle . lower bound ( ke y ) ;
i f ( i t == taЫe . e n d ( ) 1 1 key < i t - > f i r s t )
1 1 Ключ не найден

}

taЫe . insert ( i t , key, va l ue ) ;

else {
/ / Ключ найден
i t - > s econd = value ;

И тер и рование s td : : map
Обход всех 1 00 тысяч элементов отображения выполняется за 1 ,34 мс, примерно
в 10 раз медленнее, чем в случае вектора.

284

Гnава 1 0 . Оптимизация структур д а н н ы х

Сортиро вка s td : : map
Отображения отсортированы по самой своей природе. Итерирование отобра­
жения воспроизводит записи в упорядоченном по ключам виде в соответствии с
используемым предикатом поиска. Обратите внимание, что невозможно повторно
отсортировать отображение с использованием другого предиката сортировки без ко­
пирования всех элементов в друтое отображение.

Поиск в s td : : map
Поиск всех 1 00 тысяч записей в отображении занял 42,3 мс. Тот же поиск всех за­
писей в отсортированном векторе занял 28,9 мс и 35, 1 мс - в отсортированном деке,
с использованием s t d : : l ower_bound ( ) . В табл. 1 0.4 подытожены производительнос­
ти вектора и отображения при использовании в качестве таблицы поиска.
Таблица 10.4. Время вставки и поиска в векторе и отображении
Вставка + сортировка , мс

П оиск, мс

В ектор

1 9, 1

28,9

О то б ра же ни е

ЗЗ.8

42,3

Реализация на основе вектора 1 00 ООО-элементной таблицы, которая строится вся
сразу, а затем в ней многократно осуществляется поиск, будет быстрее, чем на ос­
нове отображения. Если таблица будет часто меняться путем вставок или удалений,
то повторная сортировка таблицы на основе вектора "съест" любое преимущество,
полученное благодаря более быстрому поиску.

std : : set и std : : multiset
"Список характеристик" этих структур данных имеет следующий вид.







Упорядоченный ассоциативный контейнер.
Время вставки: O(log2n).
Время доступа по ключу: O(log2n).
Итераторы и ссылки никогда не становятся недействительными, за исключе­
нием только удаленных элементов.
Итераторы обходят элементы в порядке сортировки или обратном ему.

Я не выполнял тест производительности для s td : : set. В Windows std : : set и
std : : multiset используют ту же структуру данных, что и s td : : map (см. рис. 1 0.5),
так что характеристики производительности у них те же, что и для s td : : map. Хотя
множество, в принципе, может быть реализовано с использованием иной структуры
данных, нет никаких причин, по которым следует поступать именно так.
Единственное различие между std : : map и std : : set заключается в константности
возвращаемых элементов в std : : set. Но эта проблема меньше, чем кажется. Если вы
действительно хотите использовать абстракцию множества, то поля в типе данных
std::set и std ::mu ltiset

285

элементов, которые не участвуют в определении отношения упорядочения, могут
быть объявлены mutaЫe. Конечно, компилятор безоговорочно доверяет разработ­
чику, поэтому важно и в самом деле не изменять члены, которые участвуют в опреде­
лении отношения, иначе структура данных множества станет недействительной .
s td : : unordered_шар и s td : : unordered mul timap
_

" Список характеристик" этих структур данных имеет следующий вид .







Неупорядоченный ассоциативный контейнер.
Время вставки: 0( 1 ) в среднем, О(п) в наихудшем случае.
Время индексации по ключу: 0( 1 ) в среднем, О(п) в наихудшем случае.
Итераторы становятся недействительными при перехешировании, ссылки ста­
новятся недействительными только для удаленных элементов.
Емкость может увеличиваться и уменьшаться независимо от размера.

s t d : : unordered_map отображает экземпляры с типом ключа на соответствую­
щие экземпляры с типом значения. Таким образом, он похож на s td : : map. Однако
отображение реализовано иначе, чем класс s t d : : unorde red_map, который реализо­
ван как хеш-таблица. Ключи преобразуются в целочисленный хеш-код, который ис­
пользуется в качестве индекса массива для поиска значения за константное среднее
амортизированное время.
Стандарт С++ ограничивает реализацию s t d : : uno rdered map так же, как и
s td : : s t ring. Та ким образом, хотя хеш-таблица может быть реализована нескольки­
ми разными способами, стандарту, пож ал уй, соответствует только дизайн с динами­
чески выделяемым базовым массивом сегментов, указывающих на связанные списки
динамически выделенных узлов.
Неупорядоченные отображения дороги в построении. Они содержат динамичес­
ки выделенные узлы для каждой записи таблицы, а также массив сегментов с ди­
намически изменяемым размером, который периодически перераспределяется по
мере роста таблицы (рис. 1 0.6). Таким образом, для достижения лучшей производи­
тельности поиска используется значительный объем памяти. Итераторы становят­
ся недействительными при каждом перераспределении массива сегментов. Однако
ссылки, указывающие на узлы, становятся недействительными только при удалении
соответствующих элементов.
Хеш-таблицы, такие как s t d : : unordered_map, имеют несколько параметров на­
стройки для достижения оптимальной производительности. В этом их сила - или
слабость, в зависимости от точки зрения разработчика.
Количество записей в неупорядоченном отображении называется его размером.
Вычисляемое отношение размер/емкость называется коэффициентом загрузки. Коэф­
фициент загрузки, больший 1 ,0, означает, что некоторые сегменты содержат цепочки
из нескольких записей, что приводит к снижению производительности поиска этих
ключей (другими словами, хеш не является совершенным). В реальных хеш-таблицах
коллизии ключей приводят к появлению цепочек записей даже при коэффициенте
_

286

Гnава 1 0. Оnтимизация структур данных

загрузки, меньшем 1 ,0. Коэффициент загрузки, меньший, чем 1 ,0, означает, что име­
ются неиспользуемые сегменты, потребляющие место в базовом массиве неотсорти­
рованного отображения (другими словами, хеш не является минимальным) . Когда
коэффициент загрузки меньше 1 ,0, значение 1 -коэффициент загрузки представляет
собой нижнюю границу количества пустых сегментов, но поскольку хеш-функции
могут быть несовершенными, объем неиспользуемого пространства обычно больше
вычисленного по этой формуле.
размер = 5
емКОСТЬ '"" 7

коэффициент загрузки



данные
·-· -

,.

1•

1:

/

j

" i'



1

·.

."

-

"

�-

,,

kv
·-

т

kv

t

-

,.

kv

,,

..

\- kv

-

•. '
.,
,,
"

\.�'

"

'

..

.

1

Рис. 1 0. 6. В озможная реализация

-

,.

kv

...

s td : : unordered_map

Коэффициент загрузки является зависимой переменной в s t d : : unordered_rnap.
Программа может наблюдать его значение, но не может установить его непосредствен­
но или предсказать его значение после перераспределения. Когда в неупорядоченное
std::u nordered_map и std ::unordered_multimap

287

ото б ражени е вставляется новая запись, то, если коэффициент загрузки превышает
заранее установленный программой максимальны й коэ ффициент загрузки, массив
сегментов перераспределяется, и все записи перехешируются в новый массив. Пос­
кольку количество сегментов всегда увеличивается на коэффициент, больший, чем 1 ,
амортизированная стоимость вставки оказывается равной 0( 1 ) . Эффективность
вставки и поиска существенно уменьшится, если максимальный фактор загрузки
больше 1 ,0 (значение по умолчанию). Производительность можно несколько улуч­
шить путем уменьшения максимального коэффициента загрузки до значения, ме нь ­
шего 1 ,0.
Изначальное количество сегментов в неотсортированном отображении можно
указать в качестве аргумента конструктора. Контейнер не будет выполнять перерас­
пределение до тех пор, пока его размер не превысит значение емкость * коэффици­
ент загрузки. Программа может увеличить количество сегментов в неотсортирован­
ном отображении путем вызова функции-члена rehash ( ) . rehash ( s i ze_t n ) уста­
навливает количество сегментов равным как минимум n, перераспределяет массив
сегментов и перестраивает таблицу, перемещая все записи в соответствующие сег­
менты в новом массиве. Если n меньше текущего количества сегментов, rehash ( )
может как уменьшить размер таблицы, так и не уменьшать его.
Вызов reserve ( si ze_t n ) может зарезервировать память, достаточную для со ­
хранения n за п ис е й, прежде чем придется прибегать к перераспределению. Этот вы­
зов эквивалентен вызову rehash ( cei l ( n/max_load_ factor ( ) ) ) .
Вызов функции-члена неотсортированного отображения clear ( ) стирает все за­
писи и возвращает всю выделенную память диспетчеру памяти. Это более строгое
поведение, чем в случае функции-члена clear ( ) вектора или строки.
В отл и ч и е от других контейнеров стандартной б и бл иотеки С + + ,
std : : unordered_map предоставляет структуру своей реализации путем предоставле­
ния интерфейса для обхода сегментов, а также для обхода записей в одном с егм енте.
Проверка длин цепочек в каждом сегменте может помочь выявить проблемы с хеш­
функцией. Я использовал этот интерфейс для проверки качества хеш-функции, как
показано в примере 1 0.3.
Пример 1 0. 3 . Изучение поведения std : : unordered JDa.P

template void hash_stats ( T con s t & tаЫе ) {
uns i gned zeros = О ;
uns igned ones = О ;
uns i gned many = О ;
unsigned many- sigma = О ;
for ( uns i gned i = О ; i < taЫe . buc ket count ( ) ; ++ i ) {

uns igned how many thi s bucket = о;
for ( auto i t-= taБle . begin ( i ) ; it ! = taЫe . end ( i ) ; ++it ) {
how_many_thi s_bucket += 1 ;
}

swi tch ( how many-this-bucke t )
case О :
zeros += 1 ;
-

break ;

288

Гпава 1 0. Оnтимизация структур да н н ых

case 1 :
one s + = 1 ;

brea k;

defaul t :

many + = 1 ;
many s i gma + = how_many_thi s_buc ket ;
break;

}
s td : : cout
и Al locY, это свойство не является особо важным. Тип контейнера включа­
ет тип аллокатора. Вы не можете выполнить соединение s td : : l i s t
с s t d : : l i s t < T , Al l o c Y > точно так же, как не можете выполнить его для
std : : list и s td : : l i s t< s t ring>.

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

375



В б ольшинстве случаев аллокатор с локальным состоянием не может б ыть
построен по умолчанию. Аллокатор должен быть создан, а затем передан в
конструктор контейнера:
char arena [ l O O O O ] ;
MyAl loc< Foo> a l loc ( arena ) ;
s td : : l i s t< Foo , MyAl l oc> foo l i s t ( al l oc ) ;



Состояние аллокатора должно храниться в каждой переменной, увеличивая
ее размер. Э то очень б олезненно для таких конте й неров, как s td : : l i s t и
std : : map, которые создают много узлов, но именно эти контейнеры, как пра­



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

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

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

М ин има n ь ны й а nnо като р в С ++ 1 1
Если программисту повезло и в его руках компилятор и стандартная библио­
те к а, которые полностью соответствуют стандарту С++ 1 1 , он может создать ми­
нимальный аллокатор, который требует лишь нескольких определений. В приме­
ре 1 3.7 представлен аллокатор, выполняющи й при близительно те же де й ствия, что
и

std : : a l locator.

Пример 1 3.7. Минимаnыый амокатор С++1 1
temp l a t e

< t ypename

Т> s t ruct my-a l l ocator

us ing v a lue_t ype

= Т;

rny_a l locator ( ) = de faul t ;
template < c l a s s U > my_a l l ocator ( con s t rny_a l l ocator & ) { }
Т* a l l ocate ( s td : : s i ze t n , void con s t * = 0 ) {
return reinterpret_cas t < T * > ( : : operator new ( n* s i zeof ( T ) ) ) ;

376

Глава 1 3 . О птимизация управл е н и я памятью

void dea l locate ( T * ptr , s i z e_t )

: : operator delete (ptr ) ;

1;

template

inl ine bool operator== ( const rny a l locator & ,
cons t rny=al locator & )
return t rue ;

ternplate

inline bool operator ! = ( const my allocator& а ,
con s t rny=a l l ocator & Ь )
return ! ( а == Ь ) ;

Минимальный аллокатор содержит следующие несколько функций.
allocator ( )
Это конструктор по умолчанию. Если аллокатор имеет конструктор по умол­
чанию, разработчику не нужно явным образом создавать экземпляр для пере­
дачи ero конструктору контейнера. Конструктор по умолчанию в аллокаторах
без состояния обычно пуст и обычно отсутствует во всех аллокаторах с неста­
тическим состоянием.
template a l locator ( U & )

Этот копирующий конструктор позволяет преобразовать a l locator в свя­
занный аллокатор для закрытого класса, такого как al locator.
Это важно, потому что в большинстве контейнеров память для узлов типа т
не выделяется.
Копирующий конструктор обычно пуст в аллокаторах без состояния, но дол­
жен копировать или клонировать состояния в аллокаторах с нестатическими
состояниями.
Т * allocate ( s ize_type n, const void* hint = 0 )

Эта функция выделяет достаточную память для хранения n байтов и возвра­
щает указатель на них или генерирует исключение s td : : bad_a l loc. Подсказка
hint предназначена для помощи аллокатору в решении вопросов, связанных с
"локальностью': Я еще не встречался с реализацией, в которой использовалась
бы эта подсказка.
void deallocate ( Т * р, s i z e_t n )

Эта функция освобождает память, которая ранее была возвращена вызовом
al locate { ) , на кот орую указывае т указатель р и которая занимает n байтов,
передавая ее диспетчеру памяти. n должно быть тем же самым, что и аргумент
вызова allocate ( ) , который выделил память, на которую указывает р.

bool operator== ( a l locator const& а ) const
boo l operator ! = ( a l locator con s t & а ) con s t

П оn ьзов атеn ьские а n n о к в т о ры

ств ндв ртноii бибnиотеки

377

Эти функции прове ряют два экземпляра аллокато р а одного и того же типа на

равенство.

Е сли экземпляр ы р авны то память для о бъектов вы д ел енная о д
­
,
,
ним экземпляр ом, может бы т ь безопасно осво божд ена другим
. Это значит, что

эти два экземпляра выделяют память для о б ъектов из одно й о бласти памяти.
Равенство имеет важное значение. Н апример, оно означает, что элементы
s t d : : 1 i s t из одного списка можно соединять с другим только в том случае,

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

ли они одну и ту же память.
Аллокаторы без состояния возвращают при проверке на равенство, безуслов­
но, t ru e . Аллокаторы с нестатическими состояниями должны сравнивать со­
стояния для определения равенства или просто возвращать значение f a l s e .

Допоn нитеn ьные оп р едеn ения дn я аnn окато ра С ++98
С++ 1 1 прилагает значительные усилия, чтобы аллокаторы было проще разраба­
тывать. Это упрощение достигается ценой усложнения классов контейнеров. Разра­
ботчик, который должен написать аллокатор для контейнера стандартной библиоте­
ки до C++ l l , по й мет, о чем я говорю.
А ллокаторы изначально не предназначались для управления памятью (или, по
крайней мере, предназначались не только для этого). Концепция аллокаторов разви­
лась в 1 980-е годы, когда микропроцессоры и разработчики пытались вырваться из
ограниченного 1 6 - б итного адресного пространства. Персональные компьютеры того

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

или

near,

основываясь на том, доступ к какому количеству памяти с их помощью

хотел получить разраб отчик.

Изначально аллокаторы задумывались как средство для наведения порядка в этом
б едламе моделе й памяти. Но к моменту появления аллокаторов в С++ производители
о б орудования уже не могли не реагировать на возмущенные вопли тысяч разработ­

чиков на С и реализовали унифицированную модель памяти без сегментных регист­
ров. К роме того, решение с использованием аллокаторов б ыло неэ фф ективнымпри
использовании компиляторов тех времен.

До появления С++ 1 1 каждый аллокатор содержал все функции рассмотренного
выше минимального аллокатора, а также весь перечисленный ниже багаж.
va lue_type
Тип о б ъекта, для которого выделяется память.
378

Гnава 1 3 . Оптимизация управnения памятью

s i ze_type

Интегральный тип, достаточно большой для хранения максимального коли­
чества байтов, которые может выделить данный аллокатор.
Для аллокаторов, используемых в качестве параметров шаблонов кон ­
тейнеров стандартной библиотеки, это определение должно иметь вид
typede f s i ze_t s i z e_type ; .
d i f fe r en ce_t yp e

Интегральный тип, достаточно большой для хранения ма ксимальной разности
двух указателей.
Для аллокаторов, испол ьзуемых в качестве параметров шаблонов кон ­
тей неров стандартной библиоте к и, это определение должно иметь вид
typede f pt rdi f f_t di fference_type ; .
pointer
c on s t_poin t er

Тип указателя на ( const)

т.

Для аллокаторов, используемых в качестве параметров шаблонов контейнеров
стандартной б и бл и оте ки, это определение должно иметь вид
typede f Т* pointe r ;
typede f

Т

con s t * const_pointer ;

Для прочих аллокаторов pointer может быть классом в стиле указателя, кото­
рый реализует оператор разыменования operator* ( ) .
reference
cons t ref erence

Тип ссылки на ( c o n s t) Т.
Для аллокаторов, и спользуемых в качестве параметров шаблонов контейнеров
стандартной библиотеки, это определение должно иметь вид
typedef Т& reference ;
typede f Т cons t & cons t_reference ;

pointer addre s s ( re ference )
const_pointer addre s s ( cons t_reference )

Фун к ции, которые для данной ссылки на ( const )

т

дают указатель на { const )

т.

Для аллокаторов, используемых в к ачестве параметров шаблонов контейнеров
стандартной библиотеки, это определение должно иметь вид
pointer addre s s ( re ference r ) { return & r ; }
cons t_po inter a dd r e s s ( c o ns t_re f ere n c e r )

{ return & r ;

Эти функции б ыли предназначены для абстрактных моделей памяти. К сожа­
лению, они мешают совместимости с контейнерами стандартной библиотеки,
которая требует от poin t e r быть Т * , чтобы обеспечить эффективность итера­
торов с про и звольным доступом и работу бинарного поиска.

Поn ьзов атеn ьскме

аnnо каторы стандартной б111 б n111 о тек111

379

Несмотря на то что эти определения имеют фиксированные значения для аллока­
торов, используемых контейнерами стандартной библиотеки, они необходимы, по­
скольку код контейнера в С++98 их использует, например:
t ypede f s i ze_type allocator : : s i ze_type ;

Некоторые разработчики выводят свои шаблоны аллокаторов из s td : : al locator,
что б ы получить эти определения б ез их явного написания. Однако эта практика яв­
ляется спорной. В кон це концов, когда-нибудь s td : : a l locator может и измениться.
Так, он часто изменялся в первые годы и снова изменился при принятии стандарта
С++ 1 1 , так что подо б ные опасения являются вполне о б основанными. Д руго й подход
заключается в том, что б ы выделить наи б олее неизменные из н их, нап ример, следу­

ющим образом:
template < t ypename Т> s t ruct std-a l l ocator-de fs
typede f Т value t ype ;
typede f Т* pointer ;
typede f con s t Т * cons t_po inter ;
typede f Т & re ference ;
typede f con s t Т& const re ference ;
typede f s i z e t s i z e t ype ;
typede f ptrdi f f_t cti f ference_type ;

};

pointer addre s s ( re ference r ) { return & r ; }
cons t_pointer addre s s ( cons t_re fe rence r ) { return & r ; }

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

классом свойств, как у некоторых из более сложных шаблонов аллокаторов, которые
можно на й ти в ве б е. Это также то, что делает минимальны й аллокатор С++ 1 1 , толь­
ко класс свойств работает в обратном направлении. Класс свойств выполняет поиск
этих определени й в шаблоне аллокатора и п редоставляет стандартное оп ределение,

если в аллокаторе соответствующего определения нет. Затем код контейнера ссыла­
ется на класс al locator_trai t s , а не на класс аллокатора, как эдесь:
t ypede f s td : : a l locator_t ra i t s > : : value_type va lue_type ;
Теперь пришло время взглянуть на важные определения ( помните, что сюда вклю­

чаются и определения минимального распределителя из приведенного выше раздела
" М инимальны й аллокатор в С++ 1 1 ) .
"

vo id con s t ruct ( pointer р , con s t Т & va l )
Эта функция конструирует экземпляр т копированием с использованием раз­
мещающего new:
new ( p ) T ( val ) ;

В случае С++ 1 1 эта функция может быть определена так, что список аргумен­
тов передается конструктору

т:

template < typename U, typename . . . Args>
void construct ( U * р , Arg s & & . . . args ) {
new ( p ) T ( s td : : fo rward ( args . . . ) ) ;

380

Глава 1 3 . Оптимизации управле н ии памитыо

void de s t roy ( pointer р ) ;

Эта функция уничтожает указатель на

т,

вызывая р->-Т ( ) ; .

reЬind : : value

Объявление s t ruct reblnd находится в самом сердце аллокатора. Обычно оно
имеет следующий вид:
template s truct re b ind
)

;

typede f a l l ocator value ;

r e Ь i n d дает формулу для создания аллокатора для нового типа u, и мея
a l l o c a t o r < T > . Кажд ы й аллокатор должен предоставлять такую формулу.
Именно так контейнер наподобие s td : : l i s t выделяет память для экзем­

пляров std : : l i s t : : l i stnode. В бол ьшинстве контейнеров память для
узлов типа т никогда не выделяется.
В примере 1 3.8 приведен полный аллокатор в стиле С++98, эквивалентный мини­
мальному аллокатору С++ 1 1 из примера 1 3.7.
Пример 1 3.8. Аnnокатор С++98

template s truct my_allocator_98
pu Ы i c std_a l l ocator_de fs {

t emplate s t ruct reblnd
typedef тy_al locator_9 8 < U , n> othe r ;
};
ту al locator 9 8 ( ) ( / * Пусто * / }

тy=al locator=9 8 (тy_a l locator_98 cons t & ) { / * Пусто * / }
void con s t ruct ( pointer р , con st Т & t ) {
new ( р ) Т ( t ) ;
}
void des t roy ( po inter р ) {
р->-Т ( ) ;

}
s i z e type max s i z e ( ) const
return Ыoc k_o_тemory : : Ыocks i z e ;

}
pointer al locate (

s i z e type n ,

typename s td : : a ll o cator : : con s t_po inter = 0 ) {
return reinterpret_ca s t ( : : operator new ( n * s i zeof ( T ) ) ) ;

}
void dea l locate ( pointer р , s i ze-type )
: : operator delete ( ptr } ;
};

template < t yp e n ame Т , typename U>
inline bool ope rator== ( cons t ту a l l ocator 9 8 < Т> & ,
con s t my=al l oc a t o r= 9 8 < U> & )

return t rue ;

П о л ьзовател ьские а лл окаторы стандартно й б иб л и оте к и

381

template

98&

inl ine bool operator ! = ( cons t ту a l locator
return ! ( а == Ь ) ;

а,

const my=allocator= 98& Ь )

При изучении исходных текстов аллокаторов, найденных в Интернете, разработ­
чики будут сталкиват ься со множеством различных вариантов написания типов.
Очень осторожный, заботящийся о соответствии стандарту разработчик запишет
сигнатуру функции al locate ( ) как
pointer a l l ocate ( s i ze type n ,
typename s td : : a l locator : : const _pointer = О ) ;
в то же время менее осторожный и аккуратный может записать ту же сигнатуру для

аллокатора, предназначенного строго для испол ьзования с контейнерами стандарт­
ной библиотеки, как
Т* al locate ( s i ze_t n , void con s t * = 0 ) ;
Первая сигнатура технически соответствует стандарту в наибольшей степени, но
и вторая сигнатура будет успешно компилироват ься и имеет преимущество кратко­
сти. Так нередко случается в мире шаблонов.
Еще одна проблема, связанная с исходным текстом аллокаторов, которые могут
быть найдены в Интернете, заключается в том, что функция a l l ocate ( ) должна ге­
нерироват ь исключение std : : bad_alloc, если ей не удается удовлетворить запрос.
Так, например, следующий код, который вызывает mal loc ( ) для выделения памяти,
не соответствует стандарту, потому что ma l loc ( ) может возвращать nul lptr:
pointer a l l ocate ( s i ze type n ,
typename s td : : a l l ocator : : cons t -po inter = 0 )
return reinterpret_ca s t < T * > ( ma l loc ( n * s i zeof ( T ) ) ) ;

Аnn окатор бnоков фиксиров а нно rо ра змера
Классы контейнеров стандартной библиотеки s t d : : l i s t , s t d : : m a p ,
std : : mu l t imap, s td : : s e t и s td : : mu l t i set создают структуру данных из множест­
ва одинаковых узлов. Такие классы могут воспользоваться простыми аллокаторами,
реализуемыми с помощ ь ю диспетчера памяти для блоков фиксированного размера,
описанного выше, в разделе "Диспетчер памяти для блоков фиксированного размера':
Частичное определение в примере 1 3.9 демонстрирует две функции
allocate ( ) и
dea l locate ( ) . Другие определения идентичны определениям стандартного аллока­
тора, приведенного в примере 1 3.8.
-

Пример 1 3.9. А1111окатор 611оков фиксированноrо размера
extern fixed Ыосk memory-manage r< fixed-arena control ler>
l i s t_memory_manage r ;
-

template < t ypenarne Т> c l a s s Statele s s Li s tAllocator
pu Ы i c :

382

Гл 111 1 3 . Оптимиз1ция управ л ения n1мятью

pointer al locate (
s i ze type count ,
typename s td : : allocator : : cons t_pointer = nullptr )
return reinterpret cast
( l i s t_memory_manager . a l locate ( count * s i zeof ( T ) ) ) ;
}
void dea l locate ( pointer р , s i ze type ) {
string_memory_manager . dea l locate ( p ) ;
)

;

Как упоминалось ранее, s t d : : l i s t никогда не пытается выделять память для
узлов типа Т. Вместо этого std : : l i s t использует параметр шаблона Allocator для
построения listnode с помощью вызова list_memory_manager . al locate ( s i zeof
( < l i s tnode< T>> ) ) .

Аллокатор списка требует изменения ранее определенного диспетчера памяти.
Реализация s td : : l i s t, поставляемая с Microsoft Visual С++ 20 1 5, в ы дел яе т специ­
альный ограничивающий узел, размер которого отличен от размера других узлов
списка. Он меньше, чем обычный узел сп и ск а, так что можно внести в диспетчер
памяти для фиксированных блоков небольшие изменения, которые позволят ему ра­
ботать. Измененная версия показана в примере 1 3 . 1 0. Изменение заключается в том,
что вместо тестирования текущего запроса на равенство сохраненному размеру бло­
ка al locate ( ) проверяет только, не больше ли запрашиваемый размер сохраненного
размера блока.
Пример 1 3. 1 0. Модифицированная функция allocate ( )

template
inl ine void* fixed Ыосk memory manager
: : allocate ( size-t s i z e ) {
i f ( empty ( ) ) {
free ptr
reinterpret cast< free Ыосk* >
- ( arena_ . al locate ( s i ze) ) ;
Ыосk s i ze
size;
н ( empty ( ) J
throw s td : : bad_alloc ( ) ;
}
=

=

if ( size > Ыосk size )

throw s t d : : bad a lToc ( ) ;

aut o р = fre e_p t r_T
free_ptr_
free_ptr_->next ;
return р ;
=

Прои зводите11ь н о ст ь а1111окатора 611око в ф и к си рованн оrо размера

Я написал программу для тестирования производительности аллокатора блоков
фиксированного размера. Программа представляет собой цикл, который много­
кратно создает список из 1 000 целых чисел, а затем удаляет его. При использова­
нии аллокатора по умолчанию этот цикл выполняется за 76,2 мкс. Та же програм­
ма с использованием аллокатора блоков фиксированного размера выполняется за
1 1 ,6 мкс, т.е. приблизительно в 6,6 раза бы стрее . Это впечатляющие показатели,
П о л ь э о вател ь с к м е алnокаторы стандартном

биб л иотеки

383

однако их следует рассматривать с некото р ым подозрением. Выгод у от этой опти ­
мизации получает только соэдание и уничтожение списка. Прирост общей произ­
водительности программы, которая, кроме того, обрабатывает список, будет гораз­
до более скромным.
Я также строил отображение с 1 000 целочисленных ключей. Соэдание и уничто­
жение отображения с использованием аллокатора по умолчанию занимает 1 42 мкс,
по сравнению с 67,4 мкс с аллокатором блоков фиксированного размера. Это более
скромное улучшение показывает, что любая дополнительная деятельность програм­
мы (в данном случае балансировка дерева, в котором хранится отображение) сущест­
венно влияет на повышение производительности, достигаемое путем оптимизации
аллокатора.
Аnnокатор бnоков фиксирова н ноrо размера дnя строк

Класс s td : : s t ring хранит свое содержимое в динамическом масс и ве элементов
типа char. Поскольку с ростом строки выполняется перераспределение памяти для
массива, он не представляется возможн ы м кандидатом для использования просто­
го аллокатора блоков фиксированного размера из предыдущего раздела. Но иногда
можно преодолеть даже это ограничение. Разработчик, который знает, какой макси­
мальный размер строки может быть в программе, может создать аллокатор, который
всегда выделяет блоки фиксированного максимал ь ного размера. Это очень распро­
страненная ситуация, поскольку количество приложений со строками из миллиона
символов на удивление мало .
В примере 1 3. 1 1 приведен частичный листинг аллокатора блоков фиксированного
размера для строк.
Пример 1 3 .1 1 . А1111окатор 611оков фиксированноrо размера дпя строк
t emplate < t ypename

puЫ i c :

Т>

c l a s s NewAl locator {

pointer a l locate (
s i z e type / * Количество * / ,
typenarne s td : : a l l ocator : : cons t-pointer

=

nul lpt r )

{

return reinterpret cast

( s tring_meiiiory_шanager . allocate ( 5 1 2 ) ) ;

)

void dea l locate ( pointer р , s i z e_type )

: : operator de lete ( p ) ;

};

)

Важной особенностью э того аллокатора является то, что a l locate ( ) полностью
игнорирует запрашиваемый размер и возвращает блок фиксированного размера.
П р о из вод итеn ьность аnnок атора дnн ст р ок

Я протестировал аллокатор с помощью версии функции remove_ct r l ( ) из при­
мера 4. 1 . Эта функция неэффективно испол ьзует s t d : : s t r ing, создавая множество
временных строк. В примере 1 3. 1 2 приведена модифицированная функция.
384

Гnава 1 3 . Оnт11м11зац11я уnравnе ния памятью

Пример 1 3.12. Версии remove ctrl О с исnо11ьзованием
а1111окатора 611оков фиксирова нноrо размера
typede f s td : : ba s i c- s t r ing<

char ,

std : : char t ra i t s< char> ,

State lessStringAl locator> fixe d_Ы oc k_ s t r i n g ;
fixed Ыосk s t ring remove ctrl fixed-Ыock ( s td : : s t r ing s )

f1xed ьiock s t r ing result ;
for ( si z e t-i = О ; i < s . l ength ( ) ; + + i )
i f ( s[i ] >= Ох2 0 )
r esu l t = result + s [ i ) ;

{

{

return resul t ;

Хронометраж исходной функции remove_ctr 1 ( ) показал время выполнения, рав­
ное 2693 мс. Усовершенствованная версия из примера 1 3. 1 2 выполнилась за 1 1 24 мс,
т.е. в 2,4 раза быстрее. Это значительное повышение производительности, но, как мы
видели в главе 4, " Оптимизация использования строк': другие оптимизации оказы­
ваются еще лучше.
Написание пользовательского диспетчера памяти или аллокатора может бьtть
эффективным, но приносит меньше пользы, чем оптимизации, которые полностью
удаляют вызовы диспетчера памяти.

Резюме















Имеется множество более плод отворных мест для поиска возможностей улуч­
шения производительност и, чем диспетчер памяти.
Улучшения производительност и программы в целом, вызванные заменой дис­
петчера памят и по умолчанию, варьировались в некоторых крупных програм­
мах с открытым исходным кодом от незначительного до 30%.
Диспетчеры памят и для запросов блоков о д инакового размера легки в нап иса­
нии и эффект ивны в работе.
Все запросы в ы деления памяти для экземпляров определенного класса запраши­
в а ют одинаковое количество ба й тов.
opera t o r new ( ) может быть переопределен на уровне класса.
Классы контейнер ов стан дар т но й би бл иотеки s t d : : l i s t , s t d : : т ар,
s t d : : m u l t imap, s t d : : s e t и s t d : : m u l t i s e t создают структуру данных из
множества одинаковых узлов.
Контейнеры стандартной библиотеки принимают аргумент A l l o ca t or, обес­
печивающий возможность настройки у правления памятью, аналогичную пре­
доставляемой оператором new для конкретного класса.
Нап исание пользовательского диспетчера памяти или аллокатора может
быть эффективным, но приносит меньше пользы, чем оптимизации, которые
полностью удаляю т вызовы диспетчера памят и.
Ре зюме

385

П редмет н ы й у ка з ател ь
А

cow, 9 3 ;

u
с

161
D

delete,

1 39; 1 56; 36 1
deque, 1 49; 271
Е

extern, 1 33
F

forward_list, 279
inline, 1 9 1
L
list, 1 4 9; 275
м

make_shared, 1 50
28 1
multimap, 28 1
multiset, 285

map < > ,

N

new, 1 38
nothrow, 3 5 9
noexcept, 2 1 1
nullptr, 38; 1 38
R

RAII, 84; 320
s

set, 24 1 ; 285
s h a red p t r 142
SIMD, 334
static, 1 33
string, 266
_

,

т

thread, 320
thread_local, 1 33

1 46
async, 325; 335
atomic< > , 330
array< >,

unique_ptr, 1 4 1
unordered_map, 249; 286
v

vector, 1 46; 266
А

Адрес, 38
Алгоритм, 27; 30
амортизированная стоимость, 1 1 8
большое О, 1 1 5
временная стоимость, 1 1 5
поиска, 1 1 9
сортировки, 1 2 1
требования к памяти, 1 1 8
Аллокатор, 1 40
стандартной библиотеки, 374
Атомарность, 3 1 7
6

Барьер памяти, 3 1 8
Блокировка, 326
колонны, 346
в

Владение, 1 35
глобальное, 1 3 5
динамическими переменными, 1 36; 1 40
и интеллектуальные указатели, 1 4 1
лексической области видимости, 1 35
совместное, 1 42
членом, 1 36
Время
выключения, 58
запуска, 58
измерение, 64; 66
отклика, 58

г

Генерация случа й ных чисел, 265
Голодание, 3 1 9
Гонка, 3 1 5
Громовое стадо, 346
Группир овка, 1 27

д

Д во й ная проверка, 1 29
Д вухэтапная инициализация, 1 46
Д еструктор, 1 40; 1 99; 362
явный вызов, 362
Д лительность хранения, 1 32
автоматическая, 1 33
динамическая, 1 34
локальная по отношению к потоку, 1 33
статическая, 1 32
3

З адержка, 75
З акон Амдала, 53
и

И диома, 1 74
COW, 93; 1 26 ; 1 6 1

PIMPL, 1 96
RAII, 84; 320
двухэтапно й инициализации, 1 46
инкапсуляции указателя, 1 4 1
копирования при записи. См. Идиома
cow

проверки и о б новления, 283
среза, 1 62
И ерархии наследования, 223
И нверсия управления, 339
И нтер ф е й с, 1 92
И сключение, 209
И тератор, 242
к

Кеш, 4 1
Кеширование, 1 26
К линч, 3 1 9
Компилятор, 29
Конструктор, 1 40
копирующи й , 1 55
явны й вызов, 362
388

П редметн ы й указатеn ь

Конте й нер, 259
ассоциативный, 260
последовательны й , 260
Копирование
глуб окое и поверхностное, 1 62
К ритически й раздел, 3 1 7
м

М ьютекс, 325; 344
н

Неопределенное поведение, 1 34 ; 1 39 ; 1 43 ;

1 63; 357; 36 1 ; 362
Неполны й тип, 1 97
о

Об ъект
значение, 92 ; 1 36 ; 1 37
сущность, 1 36
О ператор

delete, 357
new, 355
для класса, 3 7 1
присваивания, 1 55
О птимизация
алгоритма, 1 04; 1 1 3
б и бл иотеки, 2 1 7
возвращаемого значения, 1 59
выражени й , 200
группирование констант, 202
группировка, 1 27
дво й ная проверка, 1 29
двухэтапная инициализация, 1 46
динамического поиска, 225
зависящая от процессора, 24
и интуиция, 25
использование итераторов, 98
использования динамических
переменных, 144
использования станда р тно й
б и б лиотеки, 2 1 3
кеширование, 1 26
конкатенации строк, 95
копирование при записи, 1 6 1
копирования возвращаемого
значения, 99

многопоточных программ, 334
на уровне инструкций, 1 73
ожидаемого пути, 1 28
отложенн ы е в ы числения, 1 2 5
па кетирование, 1 26
подсказка, 1 28
поиска, 23 1
потока управления, 207
предвычисления, 1 24
преждевременная, 25
преобразования строк, 1 1 О
синхронизации, 343
сортировки, 255
специализация, 1 27
функций, 1 86
встраивание, 1 90
удаление неиспользуемых
интерфейсов, 1 92
устранение неиспользуемого
полиморфизма, 1 9 1
хеширование, 1 28
цикла, 1 74
шаблоны оптимизации, 1 23
Остановка конвейера, 44
Отложенные в ы числения, 1 25
п

Пакетирование, 1 26
Память, 40; 353
аллокаторы, 374
барьер, 3 1 8; 33 1
виртуальная, 43
выделение, 355
диспетчер, 1 34; 143; 353
иерархия, 4 1
кеш, 4 1
локальность, 42
модель, 378
С++, 3 1 6
х86, 332
порядок байтов, 42
пробуксовка страницы, 43
п ул, 37 1
слово, 37
стена, 40

утечка, 1 43; 1 52; 358
функции библиотеки С, 357
Параллелизм, 307
атомарность, 3 1 7
барьер памяти, 33 1
библиотеки, 350
бло к ировка, 326
голодание, 3 1 9
гонка, 3 1 5
клинч, 3 1 9
критический раздел, 3 1 7
мьютекс, 325
поток, 3 1 2; 320; 335
пул, 338
разновидности, 309
синхронизация, 3 1 6
условная переменная, 327
Переполнение стека, 1 34
Погрешность измерения, 64
Подсказка, 1 28
Поиск, 1 1 9
оп т имизация, 23 1
Последовательная согласованность, 3 1 4
Поток, 3 1 2; 320; 335
пул, 338
Правило Горнера, 20 1
Предвычисления, 1 24
Примитивы синхронизации, 3 1 6
Пропускная способность, 59
Профайлер, 49; 60
р

Регистр, 38
с

Семантика перемещения, 1 63; 1 66
Синхронизация, 3 1 6; 339
примитивы, 3 1 6
Случайн ы е числа, 265
Сопрограммы, 334
Сортировка, 1 2 1
Специализация, 1 27
Срез, 1 62
Ссылка, 1 38; 1 5 1
опережающая, 1 48
Предметный указатель

389

Строка, 9 1

динамическое выделение памяти, 92
кодировка, 1 1 1
п рео б р аз о в ан ие, 1 1 0
Структура данных, 34
плоская, 1 7 1
т

Таблица виртуальных функций, 1 88
Та й мер, 50
у

Указатель, 1 38
интеллектуальный, 1 40; 1 4 1
shared_ptr, 1 42
unique_ptr, 1 4 1
н а функцию, 1 89
Условная переменная, 327
ф

Функция
виртуальная, 1 88
встраиваемая, 1 9 1

390

П ред м етн ы й указатеnь

сиrнатура, 1 90
стоимость вызова, 1 86
чистая, 1 78
чисто виртуальная, 1 92
Фьючерс, 32 1
х

Хеширование, 1 28; 249; 286
идеальное, 253
Х ронометраж, 85

ц
Цикл, 88; 1 73
вложенный, 88
инверсия, 1 83
ложны й , 89
Цикличес к ий возврат, 72
э

Эксперимент, 54

АЛГОРИТМЫ НА С++
АНАЛИЗ, СТРУКТУРЫ ДАННЫХ,
СОРТИРОВКА, ПОИСК,
АЛГОРИТМЫ НА ГРАФАХ
Роберт Седжвик

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

лет. Кристофер Ван В и к и

С ед ж в и к разработал и новые
л а ко н и ч н ые реал и:-� а ц и и н а
С + + , которые естествен н ы м и
н а гл я д н ы м образом о п и с ы вают
м е то д ы

и

м о гут п ри м е н я т ьс я

в реал ь н ы х п р и ложе н и я х .
Каждая ч асть с одерж и т новые

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

усвое н и я м ате р и а л а . А к цент

www. williamspuЬlishing.com

н а АТД рас ш и ряет д и а п азон
п ри ме н е н и я п рогра м м

и л у ч ше соотносится с
современ н ы м и с реда м и
объект но -ориен т и рован ного
п ро г ра м м и ро в а н и я .

К н и га п ред назначена
для ш и роко г о круга
раз работ ч и ко в и с т уде н т о в .

I S B N 978-5-8459-2 070-6

в

продаже

ЯЗ Ы К П РО Г РА ММ И РО В А Н ИЯ С++
БАЗОВЫ Й КУРС
ПЯТОЕ ИЗДАНИЕ
СТЕНЛИ Б . липnМАН,
ЖО3И ЛАЖОЙЕ,
БАР6АРА 3. МУ

Ст.или б. Лиnnман
Жози Ла11ой�

Барбара Э. Му

www.williamspuЫishing.com

ISBN

9 7 8 - 5 -84 59- 1 83 9-0

П ол ностью и зменен ны й
и об но вл е н н ы й под недавн о
в ы ш ед ш и й стандарт С + + 1 1 ,
этот известн ы й исче рп ываю щ и й
вводный курс по С + + поможет
вам б ыстро изуч и ть язы к
и испол ьзо вать е го ве сьма
эффе кт и в н ым и и п е редовым и
сп особам и . Кн и га с са мого
нач ала знакоми т ч и тателя со
стандарт ной библ и оте кой С + + ,
ее наи бол ее п опуля р н ы м и
фун кц и я ми и средствам и ,
п оз воля я с оздавать пол е з н ые
про гр ам м ы , е ще не о владе в
все м и подроб н остям и я зыка.
Б ол ьш и н ство п р и м еров кн иги
б ыл о пересмотрено так , чтоб ы
испол ьзо вать н о вы е средст ва
язы ка и продемон стр и ровать их
наилуч ш и е с пособ ы пр именен ия .
Эта кн и га - н е тол ько
п рове ренное руководст во дл я
н о ви ч ко в С + + , о на соде ржи т
также авторитетное обсужде н ие
базов ы х ко н цеп ц и й и методи к
язы ка С + + и я вл яется це н н ы м
ресурсом для оп ыт н ы х
п рогр амм и сто в , ос обе н н о
жел а ющ и х п об ыстре й уз н ать об
усове рш е нствова н иях С + + 1 1 .

в

п родаже

ПРОГРАММИРОВАНИЕ.
ПРИНЦИПЫ И ПРАКТИКА С И С ПОЛЬЗОВАНИЕМ С++

ВТОРОЕ ИЗДАНИ Е
Бьярн е Стр аус труп

Э та к н и га - учебн и к по
п рограм м и рован и ю. Н е с мот ря

н а то ч т о е го а втор - создате л ь
я зы ка С++, к н и га не
посвя ще н а этом у язы ку; он

и г ра е т

в бол ьше й сте п е н и

илл юстрат и в н у ю рол ь. К н и га

з аду м а н а как ввод н ы й ку рс
п о п р о г ра м м и р ова н и ю с
п р и мерам и п рогра м м н ы х

р е ш е н и й н а язы ке С++
и о п и с ы вае т ш и рок и й

круг п о н я т и й и п р иемов

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

www.williamspuЫishing.com

п ро гра м м истам , н о о н а будет
полезна и п рофессионал а м ,

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

ISBN

978-5 -8459- 1 949-6

в

п родаже

ЯЗЫК ПРОГРАММИРОВАНИЯ С++
ЛЕКЦИИ И УПРАЖНЕН И Я
6-Е ИЗДАНИЕ

Стивен nра та

8

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

С+ +
6-е издание

www.williamspuЫishing.com

ISBN 978-5-8459-2048- 5

К н и га п р едставл яет с обой
т щ ат е л ьн о п ро в е р е н н ы й ,
каче ст ве н но с о ст авл е н н ы й
пол ноце н н ы й у ч ебн и к
п о одной и з кл юч е в ы х
те м дл я п рогр а м м и стов
и разра бо тч и ко в . Эта
кл а сс и ч е ск ая р аб от а по
вы ч и сл и тел ьной тех н и ке
обу ч ает п р и н ц и п а м
п рогр ам м и ро ва н и я , с р еди
кото ры х с т ру кту р и р ова н н ы й
код и н и с ходя щее
п рое кт и ро в а н и е,
а т ак же и с пол ь з о в а н и ю
кл асс о в , н асл едо ва н и я ,
ш абло н о в , и ск л юч е н и й ,
л я м бда - в ы р а ж е н и й ,
и н т е лл ек ту ал ьн ы х у казател е й
и с е м а н т и к и пе р ен о с а .
А вто р и п р е п ода вател ь Сти вен
П р ат а с озда л поу ч и т е л ьн о е ,
я с ное и стр о г ое в веде н и е в С++.
Фу нда ме н та л ьн ые ко н це п ц и и
п р о гр а м м и р ован и я и зл а г аю т ся
в м е с те с под р об и ы м и
св еде н и я м и о я з ы ке С ++ .
М н оже ст во ко р о т к и х
п р акти че с к и х п р и ме р ов
и л л ю с т р и р у ют од н у ил и две
кон це п ц и и за р аз, ст и м ул и руя
ч и тате л е й о с ва и ват ь нов ы е
те м ы з а с че т не по с р едс т в е н ной
и х п р ове р к и н а п р а кт и ке.
в

п родаже

ЭФФЕКТИВНЫЙ
И СОВРЕМЕННЫЙ С++
42 рекомендации по использованию
С ++ 1 1 и С ++ 1 4
Скотт Мейерс

В этой к н и ге отражен бесце н н ы й
опыт ее автора как программ иста
на С++. Глобал ьные измене н и я

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

С++, при ведш ие к поя вле н и ю
стандартов C ++ l 1 / 14, при водят
к необход и мости изучен и я

С + + если

н е заново, т о по

край ней мере как оче н ь
сил ьно измен ившегося язы ка
програм м и рования.
Пройти путь изучения
и освое н и я современ ного

С++

вам поможет к н и га С котта
Мейерса, показы вающая
наиболее интересные места языка
Скотт Мейерс

и преду п реждающая о возмож н ы х

проблемах и лову ш ках.
Хотя эта к н и га в перву ю

www.williamspu blishing.com

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

ISBN

9 7 8 - 5 -845 9-2000- 3

в

п родаже

СТА НД А Р ТН АЯ Б И БЛ И ОТЕК А С++ :
СПРАВОЧНОЕ РУКОВОДСТВО
ВТОРОЕ ИЗДАНИЕ
Николаи М. Джосаттис

В это й к н и ге соде ржится п ол н ое
о п и са н и е б и бл и оте ки с учетом
но вого стандарта С + + 1 1 . Ч и тател и
н а йдут в н е й и с ч е р п ы ва ю щ ее

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

а также м н о гоч и сл е н н ы е п р и меры
работос пособ н ы х п р о гра м м .

Основное в н и ма н и е уделяется
стандарт н о й б и бл иотеке шаблонов
( ST L ) , в ч астности конте й нерам ,
итераторам , фун к ц и о н ал ь н ы м
объе ктам и ал горитм а м .
С п р а воч н и к п редста вл яет

собой н астол ьную кн и гу все х

www.williamspuЬlishing.com

I S B N 9 7 8- 5-84 59 - 1 837-6

п р о гра м м и стов н а С + + .

в

п родаже

ГИБКАЯ РАЗРАБОТКА ПРОГРАММ
НА JAVA И С++:
ПРИНЦИПЫ, ПАПЕРНЫ
И МЕТОДИКИ
Роберт lVlартин, при
участии Джеймса Ньюкирка
и Роберта Косса

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

В н е й рассматриваются объектно­
ориенти рован ное п роекти рование,

UML,

паттерны, приемы
гибкого и экстремал ьного
п рограм м и рован и я , а также
приводится детал ьное оп исание
пол ного процесса проектирован ия
для м ногократно испол ьзуем ы х
п рограм м на С++ и

Java.

С

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

www. d i a 1 ekti ka .com

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

I S B N 9 7 8 - 5 -990846 2 - 8 - 9

в

п р одаже

Современное
проектирование на С++
Андрей Александреску

В книге изложена ноная
технология програм мирования ,
представляющая собой сплав
обобщенного программирования,
м ета п ро грамм ир о ва ния ш аблонов

и объектно-ориентированного
п рограммирования на С + + .

Настраиваемые компоненты ,
созданные автором , высоко
подняли уровень абстракции,
наделив язык

С++

черта ми языка

с пецификации проекти рования ,
сохранив всю его м о щ ь

и выразительн ость.
В книге изложены способы
реализации основн ых шаблонов
п роектирования. Разработан ные

комп оненты

воплощен ы

в библиотеке

Loki,

которую можно

загрузить с WеЬ-страницы автора.
Кни га п редназначена jtля

www.williamspuЬlishing.com

I S B N 978- 5 - 8459-1940-3

опытных п рограммист ов на

в

п родаже

С + +.

ЭФФЕКТИВНОЕ
ПРОГРАММИРОВАНИЕ
НА С++
Эндрю Кёниг,
Барбара Му
Эта книга, в первую очередь,
предназначена для тех,

кому

хотелось бы быстро научиться
писать
я з ы ке

С++

настоя щие програм м ы на
За ч ас тую

С++.

новички в

пытаются освоить язык чисто

механически, даже не попытавш ис ь

узнать, как м ожно эффективно
примен ить его к решению
каждоднев н ы х пробл е м .

Цель данной книги - научить
програ мми рован и ю на С + + , а н е

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

знаком с

С++

этот язык

в

и хочет использовать

более натуральном,

естественном стил е .

www.wi l liamspuЫishi ng.com

I S B N 978 - 5 -8459-2056-0

в

п р ода же

СОВРЕМЕННЫЙ С++
Для программиаов, инженеров и ученых

nитер Готтшлинr

Перед вам и к н и га для тех , кто
н уждается в б ы с т ром о с в ое н и и

п ередовых возможностей

С++. В ней описаны мощные
возможности стандарта
С+ + 1 4 , н а и б олее полезные
для нау ч н ы х и и нженерн ы х
п риложений. К н и га не п р ед­

полагает у ч итателя нал и ч и я
о п ы та п рогра м м и рован и я
на С+ + и л и и н ы х языках

п рограмм и рования.
Ч итател и узнают, как воспол ь­
зоваться преимуществами
мощны х библиотек, доступ ных
для п рограмм истов С+ + :
стандартной б и бл и отеки
шаблонов (STL) и научных
библиотек для решения задач
л и нейной алгебры, арифме­
тики, дифференциальных
уравнений и построе н и я
граф и ков. Н а п ротяже н и и всей
к н и ги автор де м о н с т р и руе т,

www.williamspuЬlishing.com

как

п исать п рограм м ы ясно

и в ы разител ьно, и с н ол ьзуя
о б ъектно-орие н т и ро в а н ное,

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

ISBN

9 7 8- 5 -8459-209 5 - 9

в

п родаже

­

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

"Рог

изобилия

полезных

своевременных ,

которые позволяют разработчикам оптимизи ровать п рограммы

иногда

на языке С++. В ы узнаете, как п и сать код, кото р ы й воплощает

ных и

наилучшие практики п роекти рова н и я С++, работает быстрее и

точку.

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

советов -

анекдотич­
всегда

в

Справочник,

показывающий новое
лицо С++".

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

Джерри Тан,
старший программист
в The Depository Trust & Clearing
Corporation

ству оцен ите советы, при веденные в этой книге, когда услыш ите
от коллеги: "Не может быть! Кто и как сумел это сделать?"

Курт Гантерот, программист

• Обнаружен ие узких мест программы с помощью

более чем с 35-летним

профилировщика и программных таймеров

стажем, четверть века

• П роведение экспериментов по измерению повышения

занимается разработкой

производител ьности в связи с изменением кода

высокопроизводител ьного
кода на С++. Разрабатывал

• Оптимизация использования динамически выделяемой

программы для Windows,

памяти

Linux и встраиваемых

• Повышение производительности циклов и функций

устройств. Живет в Сиэттле,

• Ускорение обработки строк

штат Ваши нгтон.

• Применение эффективных алгоритмов и шаблонов
оптимизации

• Сильные и слабые стороны контей неров С++
• Оптимизирующий взгляд на поиск и сортировку
• Эффекти вное использование потоков ввода-вывода С++
• Эффективное использование многопоточности С++

ISBN 978-5-99089 1 0-6-7

gNIA.J,i:КlilUКtl
www.dialektika.com

Twitter: @oreillymedia
facebook.com/oreilly

9

785990 89 1 067